@drakon-systems/multi-clawd 1.0.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,134 @@
1
+ {
2
+ "id": "multi-clawd",
3
+ "name": "multi-clawd",
4
+ "description": "Register additional Claude Code logins (Max/Pro accounts) as first-class OpenClaw CLI backends for cross-account failover, keeping the full skills/MCP harness on every account.",
5
+ "cliBackends": ["claw1", "claw2", "clawd"],
6
+ "providers": ["claw1", "claw2", "clawd"],
7
+ "modelCatalog": {
8
+ "runtimeAugment": true
9
+ },
10
+ "activation": {
11
+ "onStartup": true
12
+ },
13
+ "configSchema": {
14
+ "type": "object",
15
+ "additionalProperties": false,
16
+ "properties": {
17
+ "pool": {
18
+ "type": "object",
19
+ "additionalProperties": false,
20
+ "description": "Pooled backend: one backend id fronting several accounts. Every launch runs on the first pooled account that is not nearly maxed out (from each account's rate_limit_event health state), so a near-limit home account hands over BEFORE it hard-fails. Route your chain at <pool.id>/<model> to get proactive same-model account failover.",
21
+ "properties": {
22
+ "id": {
23
+ "type": "string",
24
+ "description": "Backend id / provider prefix for the pool. Default \"clawd\"."
25
+ },
26
+ "label": {
27
+ "type": "string",
28
+ "description": "Human-friendly label shown in status/catalog."
29
+ },
30
+ "accounts": {
31
+ "type": "array",
32
+ "items": { "type": "string" },
33
+ "description": "Account ids (from accounts[]) in preference order; the first is the home account and reclaims the pool when its usage window resets."
34
+ },
35
+ "utilizationThreshold": {
36
+ "type": "number",
37
+ "description": "Hand over when any rate-limit window's utilization reaches this fraction. Default 0.85."
38
+ },
39
+ "staleAfterMs": {
40
+ "type": "number",
41
+ "description": "Ignore health data older than this. Default 21600000 (6h)."
42
+ },
43
+ "minDwellMs": {
44
+ "type": "number",
45
+ "description": "After rotating away from home, stay on the rotated-to account at least this long before returning home (anti-flap hysteresis; health always overrides). Default 600000 (10min)."
46
+ },
47
+ "degrade": {
48
+ "type": "object",
49
+ "additionalProperties": false,
50
+ "description": "Tier-aware degradation (v0.3.5): when EVERY pooled account is exhausted, launch on the requested model's same-provider fallback tier instead of hard-failing to the next provider. While any account can still serve the requested tier, rotation always wins. Also enables single-account pools (a pool of one account + a ladder replaces a bespoke tier-switch watcher).",
51
+ "properties": {
52
+ "ladder": {
53
+ "type": "array",
54
+ "items": { "type": "string" },
55
+ "description": "Same-provider models to step down to, best first (e.g. [\"claude-opus-4-8\"]). Requests already at/below the ladder never degrade further."
56
+ },
57
+ "pins": {
58
+ "type": "array",
59
+ "description": "Never-degrade lanes: launches matching any pin keep their requested model and fail over via the chain instead (contractual model lanes).",
60
+ "items": {
61
+ "type": "object",
62
+ "additionalProperties": false,
63
+ "properties": {
64
+ "agentDirIncludes": { "type": "string", "description": "Pin when the launching agent's dir contains this substring." },
65
+ "workspaceDirIncludes": { "type": "string", "description": "Pin when the workspace dir contains this substring." }
66
+ }
67
+ }
68
+ }
69
+ }
70
+ },
71
+ "models": {
72
+ "type": "array",
73
+ "items": { "type": "string" },
74
+ "description": "Extra model ids to expose on the pool backend."
75
+ },
76
+ "defaultModel": {
77
+ "type": "string",
78
+ "description": "Model id used for live probes. Defaults to claude-fable-5."
79
+ }
80
+ }
81
+ },
82
+ "accounts": {
83
+ "type": "array",
84
+ "description": "Additional Claude accounts to register as CLI backends. Each becomes a provider prefix usable in model refs and fallback chains, e.g. claw2/claude-fable-5.",
85
+ "items": {
86
+ "type": "object",
87
+ "additionalProperties": false,
88
+ "required": ["id"],
89
+ "properties": {
90
+ "id": {
91
+ "type": "string",
92
+ "description": "Backend id / provider prefix, e.g. 'claw2'. Must be unique and not collide with the bundled 'claude-cli'."
93
+ },
94
+ "label": {
95
+ "type": "string",
96
+ "description": "Human-friendly label shown in status/catalog."
97
+ },
98
+ "native": {
99
+ "type": "boolean",
100
+ "description": "Use the machine's native Claude login (default config dir; OS keychain on macOS). Sets neither CLAUDE_CONFIG_DIR nor a token. Use this to include the main account in the rotation pool."
101
+ },
102
+ "configDir": {
103
+ "type": "string",
104
+ "description": "Isolated CLAUDE_CONFIG_DIR for this login (keeps its sessions separate from the native login and other accounts)."
105
+ },
106
+ "oauthTokenFile": {
107
+ "type": "string",
108
+ "description": "Path to a file (0600) containing this account's Claude Code setup-token. Read at launch and passed as CLAUDE_CODE_OAUTH_TOKEN. Mutually exclusive with oauthTokenRef."
109
+ },
110
+ "oauthTokenRef": {
111
+ "type": "object",
112
+ "description": "Secret reference resolving to this account's Claude Code setup-token via the gateway's configured secret providers — same shape as every other secret in openclaw.json, e.g. {\"source\":\"exec\",\"provider\":\"onepassword\",\"id\":\"op://Vault/Item/field\"}. Preferred over oauthTokenFile (no plaintext on disk). Resolution failures degrade the account (auth fails, chain steps) rather than crashing the launch.",
113
+ "additionalProperties": true,
114
+ "properties": {
115
+ "source": { "type": "string", "description": "Secret source kind: env | file | exec." },
116
+ "provider": { "type": "string", "description": "Configured secret provider name, e.g. \"onepassword\"." },
117
+ "id": { "type": "string", "description": "Provider-scoped secret id, e.g. an op:// reference." }
118
+ }
119
+ },
120
+ "models": {
121
+ "type": "array",
122
+ "items": { "type": "string" },
123
+ "description": "Extra model ids to expose for this account beyond the mirrored claude-cli catalog (e.g. a brand-new model OpenClaw doesn't list yet). Unknown modern claude-* ids also resolve on demand without being listed here."
124
+ },
125
+ "defaultModel": {
126
+ "type": "string",
127
+ "description": "Model id used for live probes (openclaw models status). Defaults to claude-fable-5."
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@drakon-systems/multi-clawd",
3
+ "version": "1.0.0",
4
+ "description": "Multi-account Claude Code failover for OpenClaw — register additional Claude (Max/Pro) logins as first-class CLI backends and keep the full skills/MCP harness across every account.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Drakon Systems Ltd",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Drakon-Systems-Ltd/multi-clawd.git"
11
+ },
12
+ "keywords": [
13
+ "openclaw",
14
+ "claude",
15
+ "claude-code",
16
+ "cli-backend",
17
+ "failover",
18
+ "multi-account",
19
+ "anthropic",
20
+ "plugin"
21
+ ],
22
+ "openclaw": {
23
+ "build": {
24
+ "openclawVersion": "2026.6.11"
25
+ },
26
+ "extensions": [
27
+ "./src/index.ts"
28
+ ],
29
+ "runtimeExtensions": [
30
+ "./dist/index.js"
31
+ ],
32
+ "compat": {
33
+ "pluginApi": ">=2026.3.24-beta.2",
34
+ "minGatewayVersion": "2026.3.24-beta.2"
35
+ }
36
+ },
37
+ "main": "./dist/index.js",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "scripts",
44
+ "openclaw.plugin.json",
45
+ "README.md",
46
+ "LICENSE"
47
+ ],
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.json",
50
+ "prepublishOnly": "npm run build",
51
+ "test": "vitest run",
52
+ "test:watch": "vitest",
53
+ "prepare": "npm run build",
54
+ "doctor": "node scripts/doctor.mjs",
55
+ "setup": "node scripts/setup.mjs"
56
+ },
57
+ "peerDependencies": {
58
+ "openclaw": ">=2026.6"
59
+ },
60
+ "devDependencies": {
61
+ "openclaw": "2026.7.1",
62
+ "typescript": "^5.9.0",
63
+ "vitest": "^4.1.10"
64
+ }
65
+ }
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * multi-clawd doctor — one command that says whether this box is actually
4
+ * ready (per the v0.3 spec). Checks, without ever printing secret values:
5
+ *
6
+ * 1. plugin install + manifest/config key agreement (the --force trap)
7
+ * 2. compiled-artifact freshness (stale dist detection)
8
+ * 3. claude CLI availability + PATH sanity
9
+ * 4. per-account credential-source health
10
+ * 5. per-account rate-limit telemetry (state files, age, windows)
11
+ * 6. pool configuration + sticky state
12
+ * 7. effective chain — Claude tiers must route through the clawd pool
13
+ * 8. eviction watchdog presence (launchd/systemd)
14
+ *
15
+ * Flags:
16
+ * --preflight print the exact config keys to strip before a --force
17
+ * install against an older installed manifest
18
+ * --probe spend one cheap turn proving the pool answers end-to-end
19
+ *
20
+ * Exit code: 0 all good, 1 any ❌.
21
+ */
22
+ import { execFileSync } from "node:child_process";
23
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { dirname, join, resolve } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ const HOME = homedir();
29
+ const EXT_DIR = join(HOME, ".openclaw", "extensions", "multi-clawd");
30
+ const CONFIG_PATH = join(HOME, ".openclaw", "openclaw.json");
31
+ const STATE_DIR = join(HOME, ".openclaw", "state", "multi-clawd");
32
+ const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
33
+
34
+ const args = new Set(process.argv.slice(2));
35
+ let failures = 0;
36
+ const ok = (msg) => console.log(` ✅ ${msg}`);
37
+ const warn = (msg) => console.log(` ⚠️ ${msg}`);
38
+ const note = (msg) => console.log(` ℹ️ ${msg}`);
39
+ const bad = (msg) => {
40
+ failures++;
41
+ console.log(` ❌ ${msg}`);
42
+ };
43
+
44
+ function expandHome(p) {
45
+ if (p === "~") return HOME;
46
+ if (p?.startsWith("~/")) return join(HOME, p.slice(2));
47
+ return p;
48
+ }
49
+
50
+ function readJson(path) {
51
+ try {
52
+ return JSON.parse(readFileSync(path, "utf8"));
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ function newestMtime(dir, exts) {
59
+ let newest = 0;
60
+ let newestFile = "";
61
+ try {
62
+ for (const f of readdirSync(dir)) {
63
+ if (!exts.some((e) => f.endsWith(e))) continue;
64
+ const m = statSync(join(dir, f)).mtimeMs;
65
+ if (m > newest) {
66
+ newest = m;
67
+ newestFile = f;
68
+ }
69
+ }
70
+ } catch {
71
+ /* missing dir */
72
+ }
73
+ return { newest, newestFile };
74
+ }
75
+
76
+ console.log("multi-clawd doctor\n");
77
+
78
+ // ── 1. install + manifest/config agreement ─────────────────────────────────
79
+ console.log("install & config");
80
+ const manifest = readJson(join(EXT_DIR, "openclaw.plugin.json"));
81
+ const config = readJson(CONFIG_PATH);
82
+ const entry = config?.plugins?.entries?.["multi-clawd"];
83
+ const pluginConfig = entry?.config ?? {};
84
+ if (!manifest) bad(`no installed manifest at ${EXT_DIR}`);
85
+ else ok(`installed at ${EXT_DIR}`);
86
+ if (!entry) bad("no plugins.entries[\"multi-clawd\"] in openclaw.json");
87
+ else if (entry.enabled !== true) bad("plugin entry present but not enabled");
88
+ else ok("plugin entry enabled");
89
+ const allow = config?.plugins?.allow;
90
+ if (Array.isArray(allow) && !allow.includes("multi-clawd")) {
91
+ bad('plugins.allow exists but does not include "multi-clawd"');
92
+ } else ok("plugins.allow OK");
93
+
94
+ const unknownKeys = [];
95
+ if (manifest && pluginConfig) {
96
+ const schemaProps = manifest.configSchema?.properties ?? {};
97
+ for (const key of Object.keys(pluginConfig)) {
98
+ if (!schemaProps[key]) unknownKeys.push(key);
99
+ }
100
+ const accountProps = schemaProps.accounts?.items?.properties ?? {};
101
+ for (const [i, account] of (pluginConfig.accounts ?? []).entries()) {
102
+ for (const key of Object.keys(account)) {
103
+ if (!accountProps[key]) unknownKeys.push(`accounts[${i}].${key}`);
104
+ }
105
+ }
106
+ const poolProps = schemaProps.pool?.properties ?? {};
107
+ for (const key of Object.keys(pluginConfig.pool ?? {})) {
108
+ if (!poolProps[key]) unknownKeys.push(`pool.${key}`);
109
+ }
110
+ }
111
+ if (unknownKeys.length > 0) {
112
+ bad(
113
+ `config keys the INSTALLED manifest does not know: ${unknownKeys.join(", ")} — a --force install will refuse. Strip them, install, re-add (SETUP-AGENT.md §6).`,
114
+ );
115
+ } else ok("config keys all known to installed manifest");
116
+ if (args.has("--preflight")) {
117
+ console.log(
118
+ unknownKeys.length > 0
119
+ ? `\npreflight: strip these keys first → ${unknownKeys.join(", ")}\n`
120
+ : "\npreflight: config is manifest-clean; --force install is safe as-is\n",
121
+ );
122
+ }
123
+
124
+ // ── 2. dist freshness ───────────────────────────────────────────────────────
125
+ console.log("build artifacts");
126
+ // Tolerance matters: `openclaw plugins install` copies dist/ before src/ with
127
+ // fresh mtimes, so the installed copy's src is always seconds "newer". A
128
+ // genuinely stale dist (pulled src, forgot to build) lags by minutes-to-days.
129
+ const STALE_TOLERANCE_MS = 120_000;
130
+ for (const [label, dir] of [["installed", EXT_DIR], ["checkout", REPO_DIR]]) {
131
+ const src = newestMtime(join(dir, "src"), [".ts"]);
132
+ const dist = newestMtime(join(dir, "dist"), [".js"]);
133
+ if (src.newest === 0) continue;
134
+ if (dist.newest === 0) bad(`${label}: no dist/ — run npm run build`);
135
+ else if (src.newest > dist.newest + STALE_TOLERANCE_MS)
136
+ bad(`${label}: dist is STALE (src ${src.newestFile} newer than dist) — run npm run build`);
137
+ else ok(`${label}: dist fresh`);
138
+ }
139
+
140
+ // ── 3. claude CLI + PATH ────────────────────────────────────────────────────
141
+ console.log("claude CLI");
142
+ try {
143
+ const v = execFileSync("claude", ["--version"], { encoding: "utf8", timeout: 15000 }).trim();
144
+ ok(`claude on PATH (${v.split("\n")[0]})`);
145
+ } catch {
146
+ bad("claude CLI not found on PATH");
147
+ }
148
+
149
+ // ── 4. account credentials (values never printed) ──────────────────────────
150
+ console.log("account credentials");
151
+ const { checkAccountCredential } = await import(join(EXT_DIR, "dist", "login-health.js")).catch(
152
+ () => import(join(REPO_DIR, "dist", "login-health.js")),
153
+ );
154
+ const io = {
155
+ readFile: (p) => readFileSync(expandHome(p), "utf8"),
156
+ keychainHasClaudeCredentials: () => {
157
+ try {
158
+ execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials"], {
159
+ stdio: "ignore",
160
+ });
161
+ return true;
162
+ } catch {
163
+ return false;
164
+ }
165
+ },
166
+ platform: process.platform,
167
+ };
168
+ const accounts = pluginConfig.accounts ?? [];
169
+ if (accounts.length === 0) warn("no accounts configured");
170
+ for (const account of accounts) {
171
+ if (account.oauthTokenRef) {
172
+ warn(`${account.id}: oauthTokenRef — validated by the gateway's async probe, not doctor`);
173
+ continue;
174
+ }
175
+ const check = checkAccountCredential(account, io);
176
+ if (check.status === "ok") ok(`${account.id}: credential source looks alive`);
177
+ else if (check.status === "unknown") warn(`${account.id}: cannot verify (${check.reason ?? "no source"})`);
178
+ else bad(`${account.id}: ${check.reason}`);
179
+ }
180
+
181
+ // ── 5. telemetry state ──────────────────────────────────────────────────────
182
+ console.log("rate-limit telemetry");
183
+ for (const account of accounts) {
184
+ const state = readJson(join(STATE_DIR, `${account.id}.json`));
185
+ if (!state) {
186
+ warn(`${account.id}: no health state yet (fills after its first turn)`);
187
+ continue;
188
+ }
189
+ const ageMin = Math.round((Date.now() - (state.updatedAt ?? 0)) / 60000);
190
+ const windows = Object.entries(state.windows ?? {})
191
+ .map(([w, d]) => `${w}:${d.status}${typeof d.utilization === "number" ? `@${Math.round(d.utilization * 100)}%` : ""}`)
192
+ .join(" ");
193
+ ok(`${account.id}: ${windows || "no windows"} (${ageMin}m old)`);
194
+ }
195
+
196
+ // ── 6. pool ─────────────────────────────────────────────────────────────────
197
+ console.log("pool");
198
+ const pool = pluginConfig.pool;
199
+ if (!pool) warn("no pool configured — direct backends only, no proactive rotation");
200
+ else {
201
+ const members = (pool.accounts ?? []).filter((id) => accounts.some((a) => a.id === id));
202
+ if (members.length < 2) bad(`pool "${pool.id ?? "clawd"}" has ${members.length} valid member(s); needs ≥ 2`);
203
+ else ok(`pool "${pool.id ?? "clawd"}": ${members.join(" → ")}`);
204
+ const sticky = readJson(join(STATE_DIR, `pool-${pool.id ?? "clawd"}.sticky.json`));
205
+ if (sticky) warn(`pool is currently stuck to ${sticky.account} (since ${new Date(sticky.since).toISOString()})`);
206
+ else ok("no sticky — pool is on its home account");
207
+ }
208
+
209
+ // ── 7. effective chain (pool-bypass sweep — CASE 1 config + CASE 2 session) ──
210
+ //
211
+ // CASE 1: a STATIC, at-rest scan of openclaw.json. Every Claude model reference
212
+ // under `agents` must route through the clawd pool; a Claude tier pinned to
213
+ // `anthropic/…`, `claude-cli/…`, or a single `claw<N>/…` account silently
214
+ // defeats cross-account failover — yet doctor used to still say READY (e.g.
215
+ // a Claude fallback pinned to `anthropic/claude-fable-5`).
216
+ //
217
+ // CASE 2: a STATIC scan of session state. A persisted per-session `/model`
218
+ // override (`~/.openclaw/agents/<agent>/sessions/sessions.json`) bypasses the
219
+ // pool exactly like a config pin but lives outside openclaw.json — invisible to
220
+ // case 1. Same off-pool predicate, same warn classes.
221
+ //
222
+ // Both emit warn(), never bad(): a box may *intentionally* pin one account or
223
+ // one session, so neither may flip the exit code / READY.
224
+ console.log("effective chain");
225
+ if (!pool) {
226
+ // No clawd pool ⇒ nothing to bypass; skip the whole section (mirrors §6).
227
+ } else {
228
+ const { auditEffectiveChain, auditSessionOverrides, maskSessionKey } = await import(
229
+ join(EXT_DIR, "dist", "chain-audit.js")
230
+ ).catch(() => import(join(REPO_DIR, "dist", "chain-audit.js")));
231
+ const poolId = pool.id ?? "clawd";
232
+ const verbose = process.env.DOCTOR_VERBOSE === "1" || process.argv.includes("--verbose");
233
+ // Session keys embed the operator's private channel id (e.g. a Telegram chat
234
+ // id). Mask the id tail by default so doctor output is safe to paste into
235
+ // issues/support threads; `--raw` restores full keys for local exact-match
236
+ // debugging. Only case-2 session surfaces carry a key; config surfaces don't.
237
+ const raw = process.env.DOCTOR_RAW === "1" || process.argv.includes("--raw");
238
+ const renderSurface = (surface) =>
239
+ raw ? surface : surface.replace(/^session (.+)$/, (_m, k) => `session ${maskSessionKey(k)}`);
240
+
241
+ // ── case 1: config-level refs ──────────────────────────────────────────────
242
+ const findings = auditEffectiveChain(config, poolId);
243
+ const warns = findings.filter((f) => f.severity === "warn");
244
+ const notes = findings.filter((f) => f.severity === "note");
245
+ for (const f of warns) warn(`${f.surface}: ${f.ref} ${f.reason}`);
246
+ // Allowlist entries are registered-but-not-live rungs: informational only,
247
+ // and there are typically many (every non-pool Claude id someone MAY ref).
248
+ // Collapse to one line so the section stays scannable; DOCTOR_VERBOSE lists
249
+ // them. A dead-noisy section trains people to skip it — the opposite of the
250
+ // point. (Live-tier bypasses above are always listed in full.)
251
+ if (notes.length > 0) {
252
+ if (verbose) {
253
+ for (const f of notes) {
254
+ note(`${f.surface}: ${f.ref} (allowlist entry, not a live tier) ${f.reason}`);
255
+ }
256
+ } else {
257
+ note(
258
+ `${notes.length} allowlist rung(s) name a non-pool Claude ref (registered, not a live tier) — run with --verbose to list`,
259
+ );
260
+ }
261
+ }
262
+ if (warns.length === 0) ok(`effective chain: all live Claude tiers route through the ${poolId} pool`);
263
+
264
+ // ── case 2: session-level /model overrides ─────────────────────────────────
265
+ // Enumerate agents/*/sessions/sessions.json. A store that is expected (its
266
+ // agent has a sessions/ dir) but absent/unparseable gets a LOUD skip — never
267
+ // a silent pass; a missed off-pool pin is worse than an extra line.
268
+ const AGENTS_DIR = join(HOME, ".openclaw", "agents");
269
+ const stores = [];
270
+ try {
271
+ for (const agent of readdirSync(AGENTS_DIR)) {
272
+ const sessionsDir = join(AGENTS_DIR, agent, "sessions");
273
+ if (!existsSync(sessionsDir)) continue; // not an agent-with-sessions
274
+ stores.push(join(sessionsDir, "sessions.json"));
275
+ }
276
+ } catch {
277
+ /* no agents dir at all */
278
+ }
279
+ if (stores.length === 0) {
280
+ note("session overrides: no agent session stores found (clean environment)");
281
+ } else {
282
+ let sessionWarns = 0;
283
+ let readable = 0;
284
+ for (const storePath of stores) {
285
+ const parsed = readJson(storePath);
286
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
287
+ warn(`session-override check SKIPPED for ${storePath} (store unreadable)`);
288
+ continue;
289
+ }
290
+ readable++;
291
+ const sessionFindings = auditSessionOverrides(parsed, true);
292
+ for (const f of sessionFindings) {
293
+ warn(`${renderSurface(f.surface)}: ${f.ref} ${f.reason}`);
294
+ sessionWarns++;
295
+ }
296
+ }
297
+ if (readable > 0 && sessionWarns === 0) ok("session overrides: no off-pool /model pins");
298
+ }
299
+ }
300
+
301
+ // ── 8. watchdog ─────────────────────────────────────────────────────────────
302
+ console.log("eviction watchdog");
303
+ let watchdogFound = false;
304
+ try {
305
+ const out = execFileSync("launchctl", ["list"], { encoding: "utf8" });
306
+ if (out.includes("multiclawd")) watchdogFound = true;
307
+ } catch {
308
+ /* not macOS */
309
+ }
310
+ try {
311
+ const out = execFileSync("systemctl", ["--user", "list-timers", "--all"], { encoding: "utf8" });
312
+ if (out.includes("multi-clawd") || out.includes("multiclawd") || out.includes("eviction")) watchdogFound = true;
313
+ } catch {
314
+ /* not systemd */
315
+ }
316
+ if (watchdogFound) ok("watchdog scheduled");
317
+ else warn("no watchdog found (needed until openclaw#107596 ships — see README)");
318
+
319
+ // ── 9. optional live probe ──────────────────────────────────────────────────
320
+ if (args.has("--probe")) {
321
+ console.log("live probe (spends one turn)");
322
+ const ref = pool ? `${pool.id ?? "clawd"}/${pool.defaultModel ?? "claude-fable-5"}` : accounts[0] ? `${accounts[0].id}/claude-fable-5` : undefined;
323
+ if (!ref) bad("nothing to probe");
324
+ else {
325
+ try {
326
+ const out = execFileSync(
327
+ "openclaw",
328
+ ["agent", "--agent", "main", "--session-key", "agent:main:mc-doctor-probe", "--model", ref, "--json", "--message", "Reply with exactly this line and nothing else: MC_DOCTOR_OK. Do not use any tools."],
329
+ { encoding: "utf8", timeout: 180000 },
330
+ );
331
+ if (out.includes("MC_DOCTOR_OK")) ok(`${ref} answered end-to-end`);
332
+ else bad(`${ref} probe returned unexpected output`);
333
+ } catch (err) {
334
+ bad(`${ref} probe failed: ${String(err).slice(0, 200)}`);
335
+ }
336
+ }
337
+ }
338
+
339
+ console.log(failures === 0 ? "\ndoctor: READY 🦞" : `\ndoctor: ${failures} problem(s) found`);
340
+ process.exit(failures === 0 ? 0 : 1);