@drakon-systems/multi-clawd 1.0.0 → 1.1.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 CHANGED
@@ -10,7 +10,8 @@ Pool every Claude Max account you own into a single failover chain —
10
10
  same model, next account, full harness on every hop.
11
11
 
12
12
  [![OpenClaw plugin](https://img.shields.io/badge/OpenClaw-plugin-ff4f00)](https://docs.openclaw.ai/plugins)
13
- [![version](https://img.shields.io/badge/version-0.3.7-4c9aff)](package.json)
13
+ [![npm](https://img.shields.io/badge/npm-%40drakon--systems%2Fmulti--clawd-cb3837)](https://www.npmjs.com/package/@drakon-systems/multi-clawd)
14
+ [![version](https://img.shields.io/badge/version-1.0.1-4c9aff)](CHANGELOG.md)
14
15
  [![license: MIT](https://img.shields.io/badge/license-MIT-2ea44f)](LICENSE)
15
16
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](tsconfig.json)
16
17
 
@@ -112,7 +113,7 @@ Code backend runs.
112
113
 
113
114
  ## Install
114
115
 
115
- **From npm (recommended, v1.0+):**
116
+ **From npm (recommended):**
116
117
 
117
118
  ```bash
118
119
  openclaw plugins install @drakon-systems/multi-clawd --pin
@@ -123,8 +124,9 @@ The gateway pulls the prebuilt package — no clone, no build step, nothing to
123
124
  keep in sync. `--pin` records the exact resolved version, so an upgrade is a
124
125
  deliberate `@latest`, never a surprise. `openclaw` itself is a *peer*
125
126
  dependency (the host provides it), so the install stays lean. Confirm with
126
- `openclaw plugins list` (expect `multi-clawd` enabled), then run the doctor:
127
- `node ~/.openclaw/extensions/multi-clawd/scripts/doctor.mjs`.
127
+ `openclaw plugins list` (expect `multi-clawd` enabled) it also shows the
128
+ install path; run the doctor from there:
129
+ `node <install-path>/scripts/doctor.mjs`.
128
130
 
129
131
  **From ClawHub (alternative registry):**
130
132
 
@@ -0,0 +1,66 @@
1
+ export const WATCHDOG_LAUNCHD_LABEL = "com.multi-clawd.eviction-watchdog";
2
+ export const WATCHDOG_SYSTEMD_NAME = "multi-clawd-eviction-watchdog";
3
+ export function renderWatchdogUnit(opts) {
4
+ if (opts.platform === "darwin") {
5
+ return [
6
+ {
7
+ path: `Library/LaunchAgents/${WATCHDOG_LAUNCHD_LABEL}.plist`,
8
+ content: `<?xml version="1.0" encoding="UTF-8"?>
9
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
10
+ <plist version="1.0">
11
+ <dict>
12
+ <key>Label</key>
13
+ <string>${WATCHDOG_LAUNCHD_LABEL}</string>
14
+ <key>ProgramArguments</key>
15
+ <array>
16
+ <string>${opts.nodePath}</string>
17
+ <string>${opts.scriptPath}</string>
18
+ </array>
19
+ <key>StartInterval</key>
20
+ <integer>300</integer>
21
+ <key>RunAtLoad</key>
22
+ <true/>
23
+ </dict>
24
+ </plist>
25
+ `,
26
+ },
27
+ ];
28
+ }
29
+ if (opts.platform === "linux") {
30
+ return [
31
+ {
32
+ path: `.config/systemd/user/${WATCHDOG_SYSTEMD_NAME}.service`,
33
+ content: `[Unit]
34
+ Description=multi-clawd eviction watchdog (turn-safe; openclaw#107408 mitigation)
35
+
36
+ [Service]
37
+ Type=oneshot
38
+ ExecStart=${opts.nodePath} ${opts.scriptPath}
39
+ `,
40
+ },
41
+ {
42
+ path: `.config/systemd/user/${WATCHDOG_SYSTEMD_NAME}.timer`,
43
+ content: `[Unit]
44
+ Description=Run the multi-clawd eviction watchdog every 5 minutes
45
+
46
+ [Timer]
47
+ OnBootSec=2min
48
+ OnUnitActiveSec=5min
49
+
50
+ [Install]
51
+ WantedBy=timers.target
52
+ `,
53
+ },
54
+ ];
55
+ }
56
+ return [];
57
+ }
58
+ export function extractWatchdogTarget(unitText) {
59
+ const m = unitText.match(/[^<>\s="']*eviction-watchdog\.mjs/);
60
+ return m ? m[0] : undefined;
61
+ }
62
+ export function classifyWatchdogUnit(targetPath, exists) {
63
+ if (targetPath === undefined)
64
+ return "absent";
65
+ return exists(targetPath) ? "ok" : "orphaned";
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/multi-clawd",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
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
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,6 +57,11 @@
57
57
  "peerDependencies": {
58
58
  "openclaw": ">=2026.6"
59
59
  },
60
+ "peerDependenciesMeta": {
61
+ "openclaw": {
62
+ "optional": true
63
+ }
64
+ },
60
65
  "devDependencies": {
61
66
  "openclaw": "2026.7.1",
62
67
  "typescript": "^5.9.0",
@@ -26,7 +26,38 @@ import { dirname, join, resolve } from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
27
 
28
28
  const HOME = homedir();
29
- const EXT_DIR = join(HOME, ".openclaw", "extensions", "multi-clawd");
29
+
30
+ /**
31
+ * Where is the plugin actually installed? Path installs land in
32
+ * ~/.openclaw/extensions/multi-clawd; registry installs (npm spec) land in
33
+ * ~/.openclaw/npm/projects/<pkg-hash>/node_modules/@drakon-systems/multi-clawd.
34
+ * Prefer the extensions dir when both exist (it shadows), else the newest
35
+ * npm-project install carrying a manifest.
36
+ */
37
+ function resolveInstallDir() {
38
+ const extDir = join(HOME, ".openclaw", "extensions", "multi-clawd");
39
+ if (existsSync(join(extDir, "openclaw.plugin.json"))) return extDir;
40
+ const projects = join(HOME, ".openclaw", "npm", "projects");
41
+ let best = extDir;
42
+ let bestM = -1;
43
+ try {
44
+ for (const p of readdirSync(projects)) {
45
+ if (!p.startsWith("drakon-systems-multi-clawd-")) continue;
46
+ const dir = join(projects, p, "node_modules", "@drakon-systems", "multi-clawd");
47
+ const manifest = join(dir, "openclaw.plugin.json");
48
+ if (!existsSync(manifest)) continue;
49
+ const m = statSync(manifest).mtimeMs;
50
+ if (m > bestM) {
51
+ bestM = m;
52
+ best = dir;
53
+ }
54
+ }
55
+ } catch {
56
+ /* no npm projects dir */
57
+ }
58
+ return best;
59
+ }
60
+ const EXT_DIR = resolveInstallDir();
30
61
  const CONFIG_PATH = join(HOME, ".openclaw", "openclaw.json");
31
62
  const STATE_DIR = join(HOME, ".openclaw", "state", "multi-clawd");
32
63
  const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -313,8 +344,43 @@ try {
313
344
  } catch {
314
345
  /* not systemd */
315
346
  }
316
- if (watchdogFound) ok("watchdog scheduled");
317
- else warn("no watchdog found (needed until openclaw#107596 ships see README)");
347
+ if (watchdogFound) {
348
+ // "Scheduled" is not enough: the unit points at a script INSIDE an install
349
+ // dir, and installs move (path→registry migration, uninstall/reinstall).
350
+ // An orphaned unit fires every tick against a missing file — silently.
351
+ // Deliberately self-contained (no dist import): the check must work even
352
+ // when the install itself is the thing that went missing.
353
+ let orphan;
354
+ for (const d of [
355
+ join(HOME, "Library", "LaunchAgents"),
356
+ join(HOME, ".config", "systemd", "user"),
357
+ ]) {
358
+ let files = [];
359
+ try {
360
+ files = readdirSync(d);
361
+ } catch {
362
+ continue;
363
+ }
364
+ for (const f of files) {
365
+ // Only real unit files — launchd loads *.plist, systemd *.service/*.timer;
366
+ // backups like *.plist.bak-... are inert and must not be flagged.
367
+ if (!/\.(plist|service|timer)$/.test(f)) continue;
368
+ let text;
369
+ try {
370
+ text = readFileSync(join(d, f), "utf8");
371
+ } catch {
372
+ continue;
373
+ }
374
+ const target = text.match(/[^<>\s="']*eviction-watchdog\.mjs/)?.[0];
375
+ if (target && !existsSync(target)) orphan = { file: join(d, f), target };
376
+ }
377
+ }
378
+ if (orphan) {
379
+ bad(
380
+ `watchdog unit ${orphan.file} points at a MISSING script (${orphan.target}) — it fails silently every tick. Repoint it at ${join(EXT_DIR, "scripts", "eviction-watchdog.mjs")} or run the setup wizard to repair.`,
381
+ );
382
+ } else ok("watchdog scheduled");
383
+ } else warn("no watchdog found (needed until openclaw#107596 ships — see README)");
318
384
 
319
385
  // ── 9. optional live probe ──────────────────────────────────────────────────
320
386
  if (args.has("--probe")) {
package/scripts/setup.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  * The wizard never sees or stores a token value. All scaffolding logic is
23
23
  * pure and unit-tested in src/setup-core.ts; this file owns prompts and IO.
24
24
  */
25
- import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from "node:fs";
25
+ import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
26
26
  import { homedir } from "node:os";
27
27
  import { join, dirname, resolve } from "node:path";
28
28
  import { fileURLToPath } from "node:url";
@@ -33,11 +33,12 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
33
33
  const DRY_RUN = process.argv.includes("--dry-run");
34
34
  const CONFIG_PATH = join(homedir(), ".openclaw", "openclaw.json");
35
35
 
36
- let core;
36
+ let core, wds;
37
37
  try {
38
38
  core = await import(resolve(__dirname, "..", "dist", "setup-core.js"));
39
+ wds = await import(resolve(__dirname, "..", "dist", "watchdog-schedule.js"));
39
40
  } catch {
40
- console.error("setup: dist/setup-core.js is missing — run `npm run build` first (source checkout) or reinstall the plugin.");
41
+ console.error("setup: built dist/ modules are missing — run `npm run build` first (source checkout) or reinstall the plugin.");
41
42
  process.exit(1);
42
43
  }
43
44
  const { buildMainAccount, buildSecondAccount, buildPool, validateSecondConfigDir, planFromExisting, mergeSetupIntoConfig } = core;
@@ -195,25 +196,145 @@ console.log("\nPlanned changes:");
195
196
  if (changes.length === 0) console.log(" (none — config already matches)");
196
197
  for (const c of changes) console.log(` • ${c}`);
197
198
 
198
- if (DRY_RUN || changes.length === 0) {
199
- console.log(DRY_RUN ? "\ndry-run: nothing written." : "");
200
- rl.close();
201
- process.exit(0);
199
+ let wroteConfig = false;
200
+ if (DRY_RUN) {
201
+ if (changes.length > 0) console.log("\ndry-run: config not written.");
202
+ } else if (changes.length > 0) {
203
+ if (!(await yes(`\nWrite these to ${CONFIG_PATH}? (backup taken first)`))) {
204
+ console.log("Aborted — nothing written.");
205
+ rl.close();
206
+ process.exit(0);
207
+ }
208
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
209
+ if (existsSync(CONFIG_PATH)) {
210
+ const backup = `${CONFIG_PATH}.bak-setup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
211
+ copyFileSync(CONFIG_PATH, backup);
212
+ console.log(`backup: ${backup}`);
213
+ }
214
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
215
+ console.log(`wrote ${CONFIG_PATH}`);
216
+ wroteConfig = true;
217
+ }
218
+
219
+ // ── eviction watchdog: create, or repair an orphaned unit ────────────────────
220
+ // The unit points at a script INSIDE an install dir, and installs move
221
+ // (path→registry migration, uninstall/reinstall) — an orphaned unit fires
222
+ // every 5 min against a missing file, silently. The wizard owns this now.
223
+ await watchdogStep();
224
+
225
+ async function watchdogStep() {
226
+ const platform = process.platform;
227
+ console.log("\nEviction watchdog (openclaw#107408 mitigation — see README):");
228
+ if (platform !== "darwin" && platform !== "linux") {
229
+ console.log(" (auto-scheduling not supported on this platform — see README for manual setup)");
230
+ return;
231
+ }
232
+ const scriptPath = join(__dirname, "eviction-watchdog.mjs");
233
+ const scanDir =
234
+ platform === "darwin"
235
+ ? join(homedir(), "Library", "LaunchAgents")
236
+ : join(homedir(), ".config", "systemd", "user");
237
+ let found;
238
+ try {
239
+ for (const f of readdirSync(scanDir)) {
240
+ // Only real unit files — backups like *.plist.bak-... are inert; never
241
+ // detect (or "repair") one of those instead of the live unit.
242
+ if (!/\.(plist|service|timer)$/.test(f)) continue;
243
+ const p = join(scanDir, f);
244
+ let text;
245
+ try {
246
+ text = readFileSync(p, "utf8");
247
+ } catch {
248
+ continue;
249
+ }
250
+ const target = wds.extractWatchdogTarget(text);
251
+ if (target) {
252
+ found = { file: p, target, text };
253
+ break;
254
+ }
255
+ }
256
+ } catch {
257
+ /* scan dir missing — treated as absent */
258
+ }
259
+ const state = wds.classifyWatchdogUnit(found?.target, existsSync);
260
+ if (state === "ok") {
261
+ console.log(` ✅ already scheduled and healthy → ${found.target}`);
262
+ return;
263
+ }
264
+ if (state === "orphaned") {
265
+ console.log(
266
+ ` ⚠ ${found.file}\n points at a MISSING script: ${found.target}\n (an old install dir — the watchdog has been failing silently)`,
267
+ );
268
+ if (DRY_RUN) {
269
+ console.log(` dry-run: would repoint it at ${scriptPath}`);
270
+ return;
271
+ }
272
+ if (!(await yes(" Repoint it at this install?"))) return;
273
+ writeFileSync(found.file, found.text.split(found.target).join(scriptPath));
274
+ reloadWatchdogUnit(platform, found.file);
275
+ console.log(` ✅ repointed → ${scriptPath}`);
276
+ return;
277
+ }
278
+ if (DRY_RUN) {
279
+ console.log(` dry-run: not scheduled — would offer to schedule it → ${scriptPath}`);
280
+ return;
281
+ }
282
+ if (!(await yes(" Not scheduled. Schedule it now (runs every 5 min)?"))) return;
283
+ const unitFiles = wds.renderWatchdogUnit({ platform, nodePath: process.execPath, scriptPath });
284
+ for (const uf of unitFiles) {
285
+ const abs = join(homedir(), uf.path);
286
+ mkdirSync(dirname(abs), { recursive: true });
287
+ writeFileSync(abs, uf.content);
288
+ console.log(` wrote ${abs}`);
289
+ }
290
+ if (platform === "darwin") {
291
+ reloadWatchdogUnit(platform, join(homedir(), unitFiles[0].path));
292
+ } else {
293
+ try {
294
+ execFileSync("systemctl", ["--user", "daemon-reload"]);
295
+ execFileSync("systemctl", ["--user", "enable", "--now", `${wds.WATCHDOG_SYSTEMD_NAME}.timer`]);
296
+ console.log(" ✅ timer enabled");
297
+ } catch {
298
+ console.log(
299
+ ` ⚠ units written but enabling failed — run: systemctl --user enable --now ${wds.WATCHDOG_SYSTEMD_NAME}.timer`,
300
+ );
301
+ }
302
+ }
202
303
  }
203
304
 
204
- if (!(await yes(`\nWrite these to ${CONFIG_PATH}? (backup taken first)`))) {
205
- console.log("Aborted nothing written.");
305
+ function reloadWatchdogUnit(platform, file) {
306
+ if (platform === "darwin") {
307
+ try {
308
+ execFileSync("launchctl", ["unload", file], { stdio: "ignore" });
309
+ } catch {
310
+ /* was not loaded */
311
+ }
312
+ try {
313
+ // launchctl can print "Load failed" to stderr and still exit 0 — merge
314
+ // the streams and check the text, not just the exit code.
315
+ const out = execFileSync("/bin/sh", ["-c", `launchctl load "${file}" 2>&1 || true`], {
316
+ encoding: "utf8",
317
+ });
318
+ if (/load failed|bootstrap failed/i.test(out)) throw new Error(out.trim());
319
+ console.log(" ✅ launchd agent (re)loaded");
320
+ } catch {
321
+ console.log(` ⚠ plist written but load failed — run: launchctl load ${file}`);
322
+ }
323
+ } else {
324
+ try {
325
+ execFileSync("systemctl", ["--user", "daemon-reload"]);
326
+ console.log(" ✅ systemd reloaded");
327
+ } catch {
328
+ console.log(" ⚠ run: systemctl --user daemon-reload");
329
+ }
330
+ }
331
+ }
332
+
333
+ if (DRY_RUN || !wroteConfig) {
334
+ console.log(DRY_RUN ? "\ndry-run: nothing written." : "");
206
335
  rl.close();
207
336
  process.exit(0);
208
337
  }
209
- mkdirSync(dirname(CONFIG_PATH), { recursive: true });
210
- if (existsSync(CONFIG_PATH)) {
211
- const backup = `${CONFIG_PATH}.bak-setup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
212
- copyFileSync(CONFIG_PATH, backup);
213
- console.log(`backup: ${backup}`);
214
- }
215
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
216
- console.log(`wrote ${CONFIG_PATH}`);
217
338
 
218
339
  // ── next steps ───────────────────────────────────────────────────────────────
219
340
  console.log(`