@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,178 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * multi-clawd eviction watchdog (v0.3, turn-safe) — mitigation for
4
+ * openclaw#107408 using a turn-safe lane-guard pattern.
5
+ *
6
+ * On OpenClaw <= 2026.7.1, core's scoped harness activation can silently drop
7
+ * plugin-registered CLI backends; affected turns fail with
8
+ * `Unknown CLI backend: <id>`. A gateway restart always restores them — but a
9
+ * blind restart can kill a live user turn, so this watchdog:
10
+ *
11
+ * 1. Detects in-flight work two ways (either defers the restart):
12
+ * a. any agent session transcript written within the last 180s
13
+ * (newest .jsonl mtime under ~/.openclaw/agents/<agent>/sessions/)
14
+ * b. opt-in background-worker pidfiles: any live pid recorded in
15
+ * $MULTI_CLAWD_WORKER_PID_DIR (skipped when unset/missing)
16
+ * 2. Persists the pending eviction and re-evaluates each tick; after
17
+ * MAX_DEFER (15 min) it restarts anyway — the backends are already
18
+ * broken, so endless deferral protects nothing.
19
+ * 3. Applies a 10-min restart cooldown on top of once-per-eviction dedupe.
20
+ * 4. Never restarts on missing evidence, and never restarts silently:
21
+ * every restart is spooled as an operator alert the plugin delivers via
22
+ * the agent's next heartbeat.
23
+ *
24
+ * Run every ~5 min (launchd/systemd). Remove once openclaw#107596 ships.
25
+ * Env: MULTI_CLAWD_WATCHDOG_STATE, MULTI_CLAWD_WORKER_PID_DIR,
26
+ * MULTI_CLAWD_WATCHDOG_DRY=1
27
+ */
28
+ import { execFileSync } from "node:child_process";
29
+ import {
30
+ appendFileSync,
31
+ existsSync,
32
+ mkdirSync,
33
+ readFileSync,
34
+ readdirSync,
35
+ statSync,
36
+ writeFileSync,
37
+ } from "node:fs";
38
+ import { homedir } from "node:os";
39
+ import { dirname, join, resolve } from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+
42
+ const HOME = homedir();
43
+ const STATE_DIR = join(HOME, ".openclaw", "state", "multi-clawd");
44
+ const stateFile = process.env.MULTI_CLAWD_WATCHDOG_STATE ?? join(STATE_DIR, "watchdog.json");
45
+ const spoolFile = join(STATE_DIR, "alerts-spool.jsonl");
46
+ const SIGNATURE = /Unknown CLI backend: /;
47
+ const INFLIGHT_GRACE_MS = 180 * 1000;
48
+ const MAX_DEFER_MS = 15 * 60 * 1000;
49
+ const RESTART_COOLDOWN_MS = 10 * 60 * 1000;
50
+
51
+ const coreDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "dist");
52
+ const { decideWatchdogAction } = await import(join(coreDir, "watchdog-core.js"));
53
+
54
+ function readState() {
55
+ try {
56
+ return JSON.parse(readFileSync(stateFile, "utf8"));
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
62
+ function writeState(state) {
63
+ mkdirSync(dirname(stateFile), { recursive: true });
64
+ writeFileSync(stateFile, JSON.stringify(state, null, 2));
65
+ }
66
+
67
+ function spoolAlert(severity, text) {
68
+ try {
69
+ mkdirSync(dirname(spoolFile), { recursive: true });
70
+ appendFileSync(
71
+ spoolFile,
72
+ JSON.stringify({ key: `watchdog:${Date.now()}`, severity, text, at: Date.now() }) + "\n",
73
+ );
74
+ } catch {
75
+ /* alerting must never block the watchdog */
76
+ }
77
+ }
78
+
79
+ // ── observations (failure of any = do nothing) ──────────────────────────────
80
+ let evictionTimestamp;
81
+ try {
82
+ const log = execFileSync("openclaw", ["logs", "--plain"], {
83
+ encoding: "utf8",
84
+ maxBuffer: 32 * 1024 * 1024,
85
+ });
86
+ const hits = log.split("\n").filter((l) => SIGNATURE.test(l));
87
+ if (hits.length > 0) evictionTimestamp = hits[hits.length - 1].slice(0, 30);
88
+ } catch (err) {
89
+ console.error(`[watchdog] could not read gateway logs: ${String(err)} — doing nothing`);
90
+ process.exit(0);
91
+ }
92
+
93
+ function newestTranscriptMtime() {
94
+ const agentsDir = join(HOME, ".openclaw", "agents");
95
+ let newest = 0;
96
+ try {
97
+ for (const agent of readdirSync(agentsDir)) {
98
+ const sessions = join(agentsDir, agent, "sessions");
99
+ if (!existsSync(sessions)) continue;
100
+ for (const f of readdirSync(sessions)) {
101
+ if (!f.endsWith(".jsonl")) continue;
102
+ const m = statSync(join(sessions, f)).mtimeMs;
103
+ if (m > newest) newest = m;
104
+ }
105
+ }
106
+ } catch {
107
+ /* unreadable agents dir → no signal */
108
+ }
109
+ return newest;
110
+ }
111
+
112
+ function liveWorkerPids() {
113
+ const dir = process.env.MULTI_CLAWD_WORKER_PID_DIR;
114
+ if (!dir || !existsSync(dir)) return 0;
115
+ let live = 0;
116
+ try {
117
+ for (const f of readdirSync(dir)) {
118
+ if (!f.endsWith(".pid")) continue;
119
+ const pid = Number(readFileSync(join(dir, f), "utf8").trim());
120
+ if (!Number.isInteger(pid) || pid <= 0) continue;
121
+ try {
122
+ process.kill(pid, 0);
123
+ live++;
124
+ } catch {
125
+ /* dead pid */
126
+ }
127
+ }
128
+ } catch {
129
+ /* unreadable dir → no signal */
130
+ }
131
+ return live;
132
+ }
133
+
134
+ const now = Date.now();
135
+ const transcriptFresh = now - newestTranscriptMtime() < INFLIGHT_GRACE_MS;
136
+ const workers = liveWorkerPids();
137
+ const inFlight = transcriptFresh || workers > 0;
138
+
139
+ const decision = decideWatchdogAction({
140
+ evictionTimestamp,
141
+ state: readState(),
142
+ inFlight,
143
+ nowMs: now,
144
+ maxDeferMs: MAX_DEFER_MS,
145
+ restartCooldownMs: RESTART_COOLDOWN_MS,
146
+ });
147
+
148
+ if (decision.action === "none") {
149
+ console.log(`[watchdog] ${decision.reason}`);
150
+ process.exit(0);
151
+ }
152
+
153
+ if (decision.action === "defer") {
154
+ console.log(
155
+ `[watchdog] eviction pending — ${decision.reason} (transcriptFresh=${transcriptFresh}, workers=${workers})`,
156
+ );
157
+ writeState(decision.nextState);
158
+ process.exit(0);
159
+ }
160
+
161
+ console.log(`[watchdog] restarting gateway: ${decision.reason}`);
162
+ if (process.env.MULTI_CLAWD_WATCHDOG_DRY === "1") {
163
+ console.log("[watchdog] dry run — not restarting");
164
+ process.exit(0);
165
+ }
166
+ try {
167
+ execFileSync("openclaw", ["gateway", "restart"], { encoding: "utf8", timeout: 120000 });
168
+ writeState(decision.nextState);
169
+ spoolAlert(
170
+ "info",
171
+ `eviction watchdog restarted the gateway (${decision.reason}) — backends restored`,
172
+ );
173
+ console.log("[watchdog] gateway restarted; backends restored");
174
+ } catch (err) {
175
+ spoolAlert("error", "eviction watchdog FAILED to restart the gateway — backends may be down");
176
+ console.error(`[watchdog] restart failed: ${String(err)}`);
177
+ process.exit(1);
178
+ }
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * multi-clawd interactive setup wizard.
4
+ *
5
+ * npm run setup (from a source checkout)
6
+ * node scripts/setup.mjs (from an installed copy)
7
+ * ... --dry-run (show what would change, write nothing)
8
+ *
9
+ * Walks a user through the standard multi-account shape and merges the result
10
+ * into ~/.openclaw/openclaw.json non-destructively (backup first, accounts
11
+ * merged by id, an existing pool is never overwritten, re-runs are no-ops):
12
+ *
13
+ * - main account (claw1): your existing `claude` login in the DEFAULT
14
+ * config dir (~/.claude) — nothing to set up, it is used as-is.
15
+ * - second account (claw2): its own ISOLATED config dir (e.g. ~/.claw2),
16
+ * a fully separate Claude "app", so the two logins can never touch each
17
+ * other. Its token comes from a secret manager ref (preferred), a token
18
+ * file, or the dir's own stored login.
19
+ * - pool (clawd): one backend id fronting both accounts with near-limit
20
+ * rotation — route chains at clawd/<model>.
21
+ *
22
+ * The wizard never sees or stores a token value. All scaffolding logic is
23
+ * pure and unit-tested in src/setup-core.ts; this file owns prompts and IO.
24
+ */
25
+ import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join, dirname, resolve } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import { execFileSync } from "node:child_process";
30
+ import readline from "node:readline/promises";
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ const DRY_RUN = process.argv.includes("--dry-run");
34
+ const CONFIG_PATH = join(homedir(), ".openclaw", "openclaw.json");
35
+
36
+ let core;
37
+ try {
38
+ core = await import(resolve(__dirname, "..", "dist", "setup-core.js"));
39
+ } catch {
40
+ console.error("setup: dist/setup-core.js is missing — run `npm run build` first (source checkout) or reinstall the plugin.");
41
+ process.exit(1);
42
+ }
43
+ const { buildMainAccount, buildSecondAccount, buildPool, validateSecondConfigDir, planFromExisting, mergeSetupIntoConfig } = core;
44
+
45
+ // Line-queued prompts: interactive AND pipe-safe. With piped stdin, readline
46
+ // emits every buffered line immediately — a plain question() would capture one
47
+ // and drop the rest, hanging later prompts. Queue them all; EOF yields "" so
48
+ // remaining prompts fall back to their defaults.
49
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
50
+ const pendingLines = [];
51
+ const waiters = [];
52
+ let stdinClosed = false;
53
+ rl.on("line", (l) => {
54
+ const w = waiters.shift();
55
+ if (w) w(l);
56
+ else pendingLines.push(l);
57
+ });
58
+ rl.on("close", () => {
59
+ stdinClosed = true;
60
+ for (const w of waiters.splice(0)) w("");
61
+ });
62
+ const readAnswer = (prompt) => {
63
+ process.stdout.write(prompt);
64
+ if (pendingLines.length > 0) {
65
+ const l = pendingLines.shift();
66
+ process.stdout.write(`${l}\n`);
67
+ return Promise.resolve(l);
68
+ }
69
+ if (stdinClosed) {
70
+ process.stdout.write("\n");
71
+ return Promise.resolve("");
72
+ }
73
+ return new Promise((r) => waiters.push(r));
74
+ };
75
+ const ask = async (q, dflt) => {
76
+ const a = (await readAnswer(dflt !== undefined ? `${q} [${dflt}] ` : `${q} `)).trim();
77
+ return a || dflt || "";
78
+ };
79
+ const yes = async (q, dflt = true) => {
80
+ const a = (await readAnswer(`${q} ${dflt ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
81
+ if (!a) return dflt;
82
+ return a.startsWith("y");
83
+ };
84
+
85
+ console.log(`
86
+ multi-clawd setup — two Claude accounts, one failover pool
87
+ ==========================================================
88
+ How it works:
89
+ • Your MAIN account is the \`claude\` login already on this machine
90
+ (default config dir ~/.claude). The wizard leaves it exactly as-is.
91
+ • A SECOND account lives in its own isolated config dir (a separate
92
+ Claude "app") — the two logins can never overwrite each other.
93
+ • A POOL (one backend id, e.g. "clawd") fronts both: each launch runs on
94
+ the first account that is not nearly maxed out.
95
+ This wizard edits ~/.openclaw/openclaw.json (backup taken first). It never
96
+ sees, stores, or prints a token value.${DRY_RUN ? "\n (dry-run: nothing will be written)" : ""}
97
+ `);
98
+
99
+ // ── preflight ────────────────────────────────────────────────────────────────
100
+ try {
101
+ execFileSync("claude", ["--version"], { stdio: "pipe" });
102
+ } catch {
103
+ console.error("preflight: the `claude` CLI is not on PATH. Install Claude Code first: npm install -g @anthropic-ai/claude-code");
104
+ process.exit(1);
105
+ }
106
+ let existingRaw = "{}";
107
+ if (existsSync(CONFIG_PATH)) existingRaw = readFileSync(CONFIG_PATH, "utf8");
108
+ let existing;
109
+ try {
110
+ existing = JSON.parse(existingRaw);
111
+ } catch {
112
+ console.error(`preflight: ${CONFIG_PATH} exists but is not valid JSON — fix it before running setup.`);
113
+ process.exit(1);
114
+ }
115
+ const state = planFromExisting(existing);
116
+ if (state.accountIds.length > 0) {
117
+ console.log(`Found existing multi-clawd accounts: ${state.accountIds.join(", ")} — re-running is safe (merge by id, no duplicates).\n`);
118
+ }
119
+
120
+ // ── main account ─────────────────────────────────────────────────────────────
121
+ const accounts = [];
122
+ if (await yes("Add your MAIN account (the machine's existing `claude` login) to the pool?")) {
123
+ const id = await ask(" id for the main account:", "claw1");
124
+ accounts.push(buildMainAccount({ id, label: await ask(" label:", "Main Claude") }));
125
+ }
126
+
127
+ // ── second account ───────────────────────────────────────────────────────────
128
+ if (await yes("Set up a SECOND Claude account (its own isolated config dir)?")) {
129
+ const id = await ask(" id for the second account:", "claw2");
130
+ let configDir;
131
+ for (;;) {
132
+ configDir = await ask(" isolated config dir:", `~/.${id}`);
133
+ const err = validateSecondConfigDir(configDir);
134
+ if (!err) break;
135
+ console.log(` ✗ ${err}`);
136
+ }
137
+ console.log(`
138
+ Now log the SECOND account in (you, in your own terminal — the wizard
139
+ cannot and must not do this for you):
140
+
141
+ CLAUDE_CONFIG_DIR=${configDir} claude setup-token
142
+
143
+ Sign in as the SECOND account (not your main one!) and note where you put
144
+ the printed setup-token. Options for where the plugin reads it from:`);
145
+ console.log(`
146
+ 1) secret manager ref (RECOMMENDED — no plaintext on disk)
147
+ e.g. 1Password: store the token as an item field, then give the
148
+ reference like op://Vault/Item/field
149
+ 2) token file
150
+ e.g. save it to ${configDir}/oauth-token and chmod 600 it
151
+ 3) none — rely on the login stored inside ${configDir} itself
152
+ `);
153
+ const choice = await ask(" token source (1/2/3):", "1");
154
+ let tokenSource;
155
+ if (choice === "1") {
156
+ const provider = await ask(" gateway secret provider name:", "onepassword");
157
+ let refId;
158
+ for (;;) {
159
+ refId = await ask(" secret reference (e.g. op://Vault/Item/field):");
160
+ if (refId) break;
161
+ if (stdinClosed) {
162
+ console.error("setup: a secret reference is required for token source 1 — aborting (nothing written).");
163
+ process.exit(1);
164
+ }
165
+ console.log(" ✗ the reference is required (it is NOT the token itself — just the pointer to it)");
166
+ }
167
+ tokenSource = { kind: "ref", ref: { source: "exec", provider, id: refId } };
168
+ } else if (choice === "2") {
169
+ tokenSource = { kind: "file", path: await ask(" token file path:", `${configDir}/oauth-token`) };
170
+ } else {
171
+ tokenSource = { kind: "dir-login" };
172
+ }
173
+ accounts.push(buildSecondAccount({ id, label: await ask(" label:", "Second Claude"), configDir, tokenSource }));
174
+ }
175
+
176
+ if (accounts.length === 0 && state.accountIds.length === 0) {
177
+ console.log("Nothing to set up — no accounts chosen. Bye.");
178
+ process.exit(0);
179
+ }
180
+
181
+ // ── pool ─────────────────────────────────────────────────────────────────────
182
+ const poolMemberIds = [...new Set([...accounts.map((a) => a.id), ...state.accountIds])];
183
+ let pool;
184
+ const modelRungs = [];
185
+ if (state.hasPool) {
186
+ console.log("A pool already exists — leaving it untouched.");
187
+ } else if (poolMemberIds.length >= 1 && (await yes(`Create the failover pool over [${poolMemberIds.join(", ")}]?`))) {
188
+ pool = buildPool(poolMemberIds, { id: await ask(" pool id:", "clawd") });
189
+ modelRungs.push(`${pool.id}/claude-fable-5`);
190
+ }
191
+
192
+ // ── merge + write ────────────────────────────────────────────────────────────
193
+ const { config, changes } = mergeSetupIntoConfig(existing, { accounts, pool, modelRungs });
194
+ console.log("\nPlanned changes:");
195
+ if (changes.length === 0) console.log(" (none — config already matches)");
196
+ for (const c of changes) console.log(` • ${c}`);
197
+
198
+ if (DRY_RUN || changes.length === 0) {
199
+ console.log(DRY_RUN ? "\ndry-run: nothing written." : "");
200
+ rl.close();
201
+ process.exit(0);
202
+ }
203
+
204
+ if (!(await yes(`\nWrite these to ${CONFIG_PATH}? (backup taken first)`))) {
205
+ console.log("Aborted — nothing written.");
206
+ rl.close();
207
+ process.exit(0);
208
+ }
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
+
218
+ // ── next steps ───────────────────────────────────────────────────────────────
219
+ console.log(`
220
+ Done. Finish with:
221
+
222
+ openclaw gateway restart
223
+ node ${join(__dirname, "doctor.mjs")} # expect READY
224
+
225
+ If the plugin was installed BEFORE these config keys existed, the gateway may
226
+ refuse the config against the old manifest — run the doctor's preflight for
227
+ the strip → force-install → re-add plan:
228
+
229
+ node ${join(__dirname, "doctor.mjs")} --preflight
230
+ `);
231
+ rl.close();