@drakon-systems/multi-clawd 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -56,7 +56,7 @@ WantedBy=timers.target
56
56
  return [];
57
57
  }
58
58
  export function extractWatchdogTarget(unitText) {
59
- const m = unitText.match(/[^<>\s="']*eviction-watchdog\.mjs/);
59
+ const m = unitText.match(/[^<>\s="']*(?:eviction-watchdog|watchdog-launcher)\.mjs/);
60
60
  return m ? m[0] : undefined;
61
61
  }
62
62
  export function classifyWatchdogUnit(targetPath, exists) {
@@ -64,3 +64,52 @@ export function classifyWatchdogUnit(targetPath, exists) {
64
64
  return "absent";
65
65
  return exists(targetPath) ? "ok" : "orphaned";
66
66
  }
67
+ export function isFragileWatchdogTarget(target) {
68
+ return target.includes("/.openclaw/npm/projects/");
69
+ }
70
+ export function renderWatchdogLauncher() {
71
+ return `#!/usr/bin/env node
72
+ // multi-clawd watchdog launcher — the STABLE target for scheduler units.
73
+ // Installs move on every update (the npm project dir is regenerated), so the
74
+ // unit points HERE; this launcher finds the current install at runtime and
75
+ // runs its eviction watchdog. Managed by the setup wizard / update command.
76
+ import { existsSync, readdirSync, statSync } from "node:fs";
77
+ import { homedir } from "node:os";
78
+ import { join } from "node:path";
79
+ import { spawnSync } from "node:child_process";
80
+
81
+ const HOME = homedir();
82
+
83
+ function installDir() {
84
+ const ext = join(HOME, ".openclaw", "extensions", "multi-clawd");
85
+ if (existsSync(join(ext, "openclaw.plugin.json"))) return ext;
86
+ const projects = join(HOME, ".openclaw", "npm", "projects");
87
+ let best;
88
+ let bestM = -1;
89
+ try {
90
+ for (const p of readdirSync(projects)) {
91
+ if (!p.startsWith("drakon-systems-multi-clawd-")) continue;
92
+ const dir = join(projects, p, "node_modules", "@drakon-systems", "multi-clawd");
93
+ const manifest = join(dir, "openclaw.plugin.json");
94
+ if (!existsSync(manifest)) continue;
95
+ const t = statSync(manifest).mtimeMs;
96
+ if (t > bestM) { bestM = t; best = dir; }
97
+ }
98
+ } catch {}
99
+ return best;
100
+ }
101
+
102
+ const dir = installDir();
103
+ if (!dir) {
104
+ console.error("[watchdog-launcher] no multi-clawd install found — nothing to do");
105
+ process.exit(0);
106
+ }
107
+ const script = join(dir, "scripts", "eviction-watchdog.mjs");
108
+ if (!existsSync(script)) {
109
+ console.error("[watchdog-launcher] " + script + " missing — nothing to do");
110
+ process.exit(0);
111
+ }
112
+ const r = spawnSync(process.execPath, [script], { stdio: "inherit" });
113
+ process.exit(r.status ?? 0);
114
+ `;
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/multi-clawd",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
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",
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Shared helpers for the CLI scripts (cli.mjs, setup.mjs).
3
3
  *
4
- * resolveWatchdogScript exists because of a real footgun: run via `npx`, a
5
- * script's __dirname is the EPHEMERAL npx cache pointing a scheduled unit
6
- * there breaks on the next cache clean. The watchdog target must be the
7
- * INSTALLED plugin's copy whenever one exists; the __dirname sibling is only
8
- * correct for a source checkout with no install.
4
+ * Two real footguns shaped this file: run via `npx`, a script's __dirname is
5
+ * the EPHEMERAL npx cache; and the npm install dir itself is regenerated on
6
+ * every update. So nothing durable (scheduler units) may point at either
7
+ * they point at the stable WATCHDOG_LAUNCHER, which resolves the current
8
+ * install at runtime.
9
9
  */
10
10
  import { existsSync, readdirSync, statSync } from "node:fs";
11
11
  import { homedir } from "node:os";
@@ -37,12 +37,16 @@ export function resolveInstallDir() {
37
37
  return best;
38
38
  }
39
39
 
40
- /** The watchdog script a scheduled unit should point at. */
41
- export function resolveWatchdogScript(fallbackDir) {
42
- const inst = resolveInstallDir();
43
- if (inst) {
44
- const p = join(inst, "scripts", "eviction-watchdog.mjs");
45
- if (existsSync(p)) return p;
46
- }
47
- return join(fallbackDir, "eviction-watchdog.mjs");
48
- }
40
+ /**
41
+ * The stable launcher path scheduler units point at. Lives in the state dir
42
+ * (never moved by installs); its content is rendered by
43
+ * watchdog-schedule.ts#renderWatchdogLauncher and resolves the current
44
+ * install at runtime.
45
+ */
46
+ export const WATCHDOG_LAUNCHER = join(
47
+ homedir(),
48
+ ".openclaw",
49
+ "state",
50
+ "multi-clawd",
51
+ "watchdog-launcher.mjs",
52
+ );
package/scripts/cli.mjs CHANGED
@@ -127,6 +127,7 @@ async function update() {
127
127
  } else {
128
128
  console.log(" ⏳ remember: the new version loads on the next gateway restart.");
129
129
  }
130
+ await healWatchdogUnit();
130
131
  console.log(`\n${BOLD} health check${RESET}`);
131
132
  const doc = spawnSync(process.execPath, [join(__dirname, "doctor.mjs")], { stdio: "inherit" });
132
133
  if (doc.status !== 0) {
@@ -136,6 +137,73 @@ async function update() {
136
137
  console.log(`\n ✅ done — now on v${installedVersion() ?? "?"}`);
137
138
  }
138
139
 
140
+ /**
141
+ * Self-heal the scheduled watchdog after an update: the npm install dir is
142
+ * regenerated on every update, so a unit pointing into it just orphaned.
143
+ * Move any broken-or-fragile unit onto the stable launcher; refresh the
144
+ * launcher's content when a unit already uses it. Never fatal.
145
+ */
146
+ async function healWatchdogUnit() {
147
+ try {
148
+ const wds = await import(resolve(__dirname, "..", "dist", "watchdog-schedule.js"));
149
+ const { WATCHDOG_LAUNCHER } = await import(join(__dirname, "_shared.mjs"));
150
+ const { existsSync, readdirSync, readFileSync: rf, writeFileSync, mkdirSync } = await import("node:fs");
151
+ const { homedir } = await import("node:os");
152
+ const { dirname: dn } = await import("node:path");
153
+ for (const d of [
154
+ join(homedir(), "Library", "LaunchAgents"),
155
+ join(homedir(), ".config", "systemd", "user"),
156
+ ]) {
157
+ let files = [];
158
+ try {
159
+ files = readdirSync(d);
160
+ } catch {
161
+ continue;
162
+ }
163
+ for (const f of files) {
164
+ if (!/\.(plist|service|timer)$/.test(f)) continue;
165
+ const file = join(d, f);
166
+ let text;
167
+ try {
168
+ text = rf(file, "utf8");
169
+ } catch {
170
+ continue;
171
+ }
172
+ const target = wds.extractWatchdogTarget(text);
173
+ if (!target) continue;
174
+ const refreshLauncher = () => {
175
+ mkdirSync(dn(WATCHDOG_LAUNCHER), { recursive: true });
176
+ writeFileSync(WATCHDOG_LAUNCHER, wds.renderWatchdogLauncher());
177
+ };
178
+ if (target === WATCHDOG_LAUNCHER) {
179
+ refreshLauncher();
180
+ continue;
181
+ }
182
+ if (!existsSync(target) || wds.isFragileWatchdogTarget(target)) {
183
+ refreshLauncher();
184
+ writeFileSync(file, text.split(target).join(WATCHDOG_LAUNCHER));
185
+ if (d.endsWith("LaunchAgents")) {
186
+ try {
187
+ execFileSync("/bin/sh", ["-c", `launchctl unload "${file}" 2>/dev/null; launchctl load "${file}" 2>&1 || true`]);
188
+ } catch {
189
+ /* manual load needed */
190
+ }
191
+ } else {
192
+ try {
193
+ execFileSync("systemctl", ["--user", "daemon-reload"]);
194
+ } catch {
195
+ /* manual reload needed */
196
+ }
197
+ }
198
+ console.log(` 🔧 watchdog unit ${f} → stable launcher (survives future updates)`);
199
+ }
200
+ }
201
+ }
202
+ } catch {
203
+ /* healing is best-effort; doctor still reports the truth */
204
+ }
205
+ }
206
+
139
207
  switch (cmd) {
140
208
  case "setup":
141
209
  runSibling("setup.mjs", rest);
@@ -371,8 +371,13 @@ if (watchdogFound) {
371
371
  } catch {
372
372
  continue;
373
373
  }
374
- const target = text.match(/[^<>\s="']*eviction-watchdog\.mjs/)?.[0];
374
+ const target = text.match(/[^<>\s="']*(?:eviction-watchdog|watchdog-launcher)\.mjs/)?.[0];
375
375
  if (target && !existsSync(target)) orphan = { file: join(d, f), target };
376
+ else if (target && target.includes("/.openclaw/npm/projects/")) {
377
+ warn(
378
+ `watchdog unit ${join(d, f)} points INTO the npm install dir — regenerated on every update, so it WILL orphan. Run the setup wizard (or \`update\`) to move it to the stable launcher.`,
379
+ );
380
+ }
376
381
  }
377
382
  }
378
383
  if (orphan) {
package/scripts/setup.mjs CHANGED
@@ -229,10 +229,16 @@ async function watchdogStep() {
229
229
  console.log(" (auto-scheduling not supported on this platform — see README for manual setup)");
230
230
  return;
231
231
  }
232
- // Install-aware: under npx, __dirname is the EPHEMERAL npx cache the unit
233
- // must point at the installed plugin's copy whenever one exists.
234
- const { resolveWatchdogScript } = await import(resolve(__dirname, "_shared.mjs"));
235
- const scriptPath = resolveWatchdogScript(__dirname);
232
+ // Units point at the STABLE LAUNCHER, never at an install path: the npm
233
+ // install dir is regenerated on every update (orphaning any unit that
234
+ // points into it), and under npx our own __dirname is an ephemeral cache.
235
+ // The launcher lives in the state dir and finds the install at runtime.
236
+ const { WATCHDOG_LAUNCHER } = await import(resolve(__dirname, "_shared.mjs"));
237
+ const scriptPath = WATCHDOG_LAUNCHER;
238
+ const refreshLauncher = () => {
239
+ mkdirSync(dirname(WATCHDOG_LAUNCHER), { recursive: true });
240
+ writeFileSync(WATCHDOG_LAUNCHER, wds.renderWatchdogLauncher());
241
+ };
236
242
  const scanDir =
237
243
  platform === "darwin"
238
244
  ? join(homedir(), "Library", "LaunchAgents")
@@ -260,19 +266,37 @@ async function watchdogStep() {
260
266
  /* scan dir missing — treated as absent */
261
267
  }
262
268
  const state = wds.classifyWatchdogUnit(found?.target, existsSync);
263
- if (state === "ok") {
269
+ if (state === "ok" && !wds.isFragileWatchdogTarget(found.target)) {
270
+ if (found.target === WATCHDOG_LAUNCHER && !DRY_RUN) refreshLauncher();
264
271
  console.log(` ✅ already scheduled and healthy → ${found.target}`);
265
272
  return;
266
273
  }
274
+ if (state === "ok") {
275
+ // Exists today, but points INTO the npm install — orphans on next update.
276
+ console.log(
277
+ ` ⚠ ${found.file}\n points INTO the npm install dir:\n ${found.target}\n That directory is regenerated on every update — the unit will orphan.`,
278
+ );
279
+ if (DRY_RUN) {
280
+ console.log(` dry-run: would move it to the stable launcher ${scriptPath}`);
281
+ return;
282
+ }
283
+ if (!(await yes(" Move it to the stable launcher (survives every update)?"))) return;
284
+ refreshLauncher();
285
+ writeFileSync(found.file, found.text.split(found.target).join(scriptPath));
286
+ reloadWatchdogUnit(platform, found.file);
287
+ console.log(` ✅ now → ${scriptPath}`);
288
+ return;
289
+ }
267
290
  if (state === "orphaned") {
268
291
  console.log(
269
292
  ` ⚠ ${found.file}\n points at a MISSING script: ${found.target}\n (an old install dir — the watchdog has been failing silently)`,
270
293
  );
271
294
  if (DRY_RUN) {
272
- console.log(` dry-run: would repoint it at ${scriptPath}`);
295
+ console.log(` dry-run: would repoint it at the stable launcher ${scriptPath}`);
273
296
  return;
274
297
  }
275
- if (!(await yes(" Repoint it at this install?"))) return;
298
+ if (!(await yes(" Repoint it at the stable launcher (survives every update)?"))) return;
299
+ refreshLauncher();
276
300
  writeFileSync(found.file, found.text.split(found.target).join(scriptPath));
277
301
  reloadWatchdogUnit(platform, found.file);
278
302
  console.log(` ✅ repointed → ${scriptPath}`);
@@ -283,6 +307,7 @@ async function watchdogStep() {
283
307
  return;
284
308
  }
285
309
  if (!(await yes(" Not scheduled. Schedule it now (runs every 5 min)?"))) return;
310
+ refreshLauncher();
286
311
  const unitFiles = wds.renderWatchdogUnit({ platform, nodePath: process.execPath, scriptPath });
287
312
  for (const uf of unitFiles) {
288
313
  const abs = join(homedir(), uf.path);