@drakon-systems/multi-clawd 1.4.1 → 1.5.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 +32 -4
- package/dist/explain-core.js +26 -0
- package/dist/health.js +22 -0
- package/dist/index.js +2 -2
- package/dist/shim-core.js +10 -1
- package/openclaw.plugin.json +46 -12
- package/package.json +8 -2
- package/scripts/cli.mjs +6 -2
package/README.md
CHANGED
|
@@ -167,7 +167,7 @@ dependency (the host provides it), so the install stays lean.
|
|
|
167
167
|
**From ClawHub (alternative registry):**
|
|
168
168
|
|
|
169
169
|
```bash
|
|
170
|
-
openclaw plugins install clawhub
|
|
170
|
+
openclaw plugins install clawhub:@drakon-systems/multi-clawd
|
|
171
171
|
```
|
|
172
172
|
|
|
173
173
|
**From source (contributors, or ahead of a release):**
|
|
@@ -446,6 +446,19 @@ event, and no plugin API can force a rebuild. See `DESIGN.md`.
|
|
|
446
446
|
|
|
447
447
|
## Security
|
|
448
448
|
|
|
449
|
+
**Full declaration of what this plugin touches — nothing else:**
|
|
450
|
+
|
|
451
|
+
| Surface | What multi-clawd does |
|
|
452
|
+
|---|---|
|
|
453
|
+
| Network | **Zero egress of its own.** The only network activity is npm/registry traffic during `install`/`update`, and the Claude Code subprocesses talking to Anthropic exactly as the bundled backend does. No telemetry, no analytics, no vendor endpoints. |
|
|
454
|
+
| Credentials | Reads each account's setup-token at launch (file, or a secret-manager reference resolved by your gateway) and passes it **only** via the child process env. Never written elsewhere, never logged, never printed — log redaction is covered by dedicated tests. |
|
|
455
|
+
| Files read | `~/.openclaw/openclaw.json` (config), account config dirs you declare (e.g. `~/.claw2`), token files you declare. |
|
|
456
|
+
| Files written | `~/.openclaw/state/multi-clawd/<account>.json` (local usage-health telemetry, stays on the box), config backups the wizard takes before merging, and — only if you accept the wizard's watchdog step — one systemd user unit / launchd plist pointing at the installed watchdog script. |
|
|
457
|
+
| Processes | Spawns the `claude` CLI per turn (same as the bundled backend). The optional watchdog may restart the OpenClaw gateway when the eviction signature appears — that's its entire job, documented below. |
|
|
458
|
+
| Consent | The wizard asks before every write, merges non-destructively, and never overwrites an existing account entry. `--dry-run` previews everything. |
|
|
459
|
+
|
|
460
|
+
Housekeeping:
|
|
461
|
+
|
|
449
462
|
- Tokens are never committed and never logged; `.gitignore` blocks token
|
|
450
463
|
and account directories by default.
|
|
451
464
|
- Prefer a secret reference (`oauthTokenRef`, v0.3) over a plaintext
|
|
@@ -488,10 +501,25 @@ Early but real — built for and dogfooded in production.
|
|
|
488
501
|
(capped at 8d with a clock-skew alarm), reset-less windows keep TTL/decay,
|
|
489
502
|
and model windows age by their own TTL independent of pool `staleAfterMs`
|
|
490
503
|
— closes the quiet-pool blindness half of the no-flip failure class ✅
|
|
491
|
-
- **
|
|
504
|
+
- **v1.0** — the public line: published to npm as
|
|
505
|
+
[`@drakon-systems/multi-clawd`](https://www.npmjs.com/package/@drakon-systems/multi-clawd),
|
|
506
|
+
`openclaw` demoted to an optional peer, registry installs verified
|
|
507
|
+
end-to-end ✅
|
|
508
|
+
- **v1.1–v1.2** — the `multi-clawd` CLI (`update` / `setup` / `doctor`);
|
|
509
|
+
wizard owns the eviction watchdog (schedules it, detects + repoints
|
|
510
|
+
orphaned units after migrations); wizard account-protection (existing
|
|
511
|
+
accounts default to keep-unchanged; suspicious secret refs challenged) ✅
|
|
512
|
+
- **v1.3** — `multi-clawd explain`: the whole setup in plain English —
|
|
513
|
+
accounts, pool decisions, every fallback rung annotated, live health with
|
|
514
|
+
reset times ✅
|
|
515
|
+
- **v1.4** — `multi-clawd login <account>`: the right Claude sign-in flow
|
|
516
|
+
for each account shape, verified afterwards (signed-in email shown, token
|
|
517
|
+
values never touched); ClawHub package published under
|
|
518
|
+
`@drakon-systems` ✅
|
|
519
|
+
- **Next** — standalone localhost proxy (OpenAI-compatible) so Hermes and
|
|
492
520
|
custom runtimes can share the pool; true per-session affinity; local
|
|
493
|
-
five-hour-window signal (turn counting)
|
|
494
|
-
|
|
521
|
+
five-hour-window signal (turn counting); per-account lock for the shim
|
|
522
|
+
persistence race
|
|
495
523
|
|
|
496
524
|
See [`DESIGN.md`](./DESIGN.md) for the architecture, the three obvious
|
|
497
525
|
approaches that *don't* work, and why.
|
package/dist/explain-core.js
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
const WINDOW_LABELS = {
|
|
2
|
+
five_hour: "5-hour",
|
|
3
|
+
seven_day: "weekly",
|
|
4
|
+
seven_day_overage_included: "weekly incl. overage",
|
|
5
|
+
};
|
|
6
|
+
export function relativeUntil(ms, nowMs) {
|
|
7
|
+
const mins = Math.max(0, Math.round((ms - nowMs) / 60000));
|
|
8
|
+
if (mins < 90)
|
|
9
|
+
return `~${mins}m`;
|
|
10
|
+
const hours = Math.round(mins / 60);
|
|
11
|
+
if (hours < 36)
|
|
12
|
+
return `~${hours}h`;
|
|
13
|
+
return `~${Math.round(hours / 24)}d`;
|
|
14
|
+
}
|
|
15
|
+
export function renderUsageLine(usage, nowMs) {
|
|
16
|
+
return usage
|
|
17
|
+
.map((u) => {
|
|
18
|
+
const label = WINDOW_LABELS[u.window] ?? u.window;
|
|
19
|
+
const pct = `${Math.round(u.utilization * 100)}%`;
|
|
20
|
+
const reset = u.resetsAt !== undefined ? ` (resets ${relativeUntil(u.resetsAt, nowMs)})` : "";
|
|
21
|
+
return `${label} ${pct}${reset}`;
|
|
22
|
+
})
|
|
23
|
+
.join(" · ");
|
|
24
|
+
}
|
|
1
25
|
export function describeAccount(acc) {
|
|
2
26
|
if (acc.native) {
|
|
3
27
|
return "the machine's main `claude` login (default config dir; OS keychain on macOS)";
|
|
@@ -82,10 +106,12 @@ export function renderExplanation(model) {
|
|
|
82
106
|
}
|
|
83
107
|
lines.push("");
|
|
84
108
|
if (model.health.length > 0) {
|
|
109
|
+
const nowMs = model.nowMs ?? Date.now();
|
|
85
110
|
lines.push("RIGHT NOW");
|
|
86
111
|
for (const h of model.health) {
|
|
87
112
|
const word = VERDICT_WORDS[h.verdict] ?? h.verdict;
|
|
88
113
|
lines.push(` ${h.id}: ${word}${h.detail ? ` — ${h.detail}` : ""}`);
|
|
114
|
+
lines.push(` usage: ${h.usage?.length ? renderUsageLine(h.usage, nowMs) : "no live telemetry"}`);
|
|
89
115
|
}
|
|
90
116
|
if (model.pool) {
|
|
91
117
|
lines.push(model.stickyAccount
|
package/dist/health.js
CHANGED
|
@@ -72,6 +72,28 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
72
72
|
return { verdict: "no_data" };
|
|
73
73
|
return worst;
|
|
74
74
|
}
|
|
75
|
+
export function summarizeWindowUsage(state, options, nowMs) {
|
|
76
|
+
if (!state)
|
|
77
|
+
return [];
|
|
78
|
+
const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
79
|
+
const usage = [];
|
|
80
|
+
for (const [window, w] of Object.entries(state.windows)) {
|
|
81
|
+
if (window.startsWith(MODEL_WINDOW_PREFIX))
|
|
82
|
+
continue;
|
|
83
|
+
if (typeof w.utilization !== "number")
|
|
84
|
+
continue;
|
|
85
|
+
const resetMs = typeof w.resetsAt === "number" ? w.resetsAt * 1000 : undefined;
|
|
86
|
+
const resetBearing = resetMs !== undefined && resetMs > nowMs;
|
|
87
|
+
if (resetBearing && nowMs - w.seenAt > MAX_RESET_HORIZON_MS)
|
|
88
|
+
continue;
|
|
89
|
+
if (resetMs !== undefined && !resetBearing)
|
|
90
|
+
continue;
|
|
91
|
+
if (!resetBearing && nowMs - w.seenAt > staleAfterMs)
|
|
92
|
+
continue;
|
|
93
|
+
usage.push({ window, utilization: w.utilization, resetsAt: resetMs });
|
|
94
|
+
}
|
|
95
|
+
return usage.sort((a, b) => (b.resetsAt ?? 0) - (a.resetsAt ?? 0));
|
|
96
|
+
}
|
|
75
97
|
export function choosePoolAccount(pool) {
|
|
76
98
|
const usable = pool.find((a) => a.verdict === "ok" || a.verdict === "no_data");
|
|
77
99
|
if (usable)
|
package/dist/index.js
CHANGED
|
@@ -216,7 +216,7 @@ const SHIM_PATH = fileURLToPath(new URL("./shim.js", import.meta.url));
|
|
|
216
216
|
export function healthStateFile(accountId) {
|
|
217
217
|
return join(homedir(), ".openclaw", "state", "multi-clawd", `${accountId}.json`);
|
|
218
218
|
}
|
|
219
|
-
function buildBackend(account, execMode) {
|
|
219
|
+
export function buildBackend(account, execMode) {
|
|
220
220
|
return {
|
|
221
221
|
id: account.id,
|
|
222
222
|
liveTest: {
|
|
@@ -247,7 +247,7 @@ function buildBackend(account, execMode) {
|
|
|
247
247
|
imagePathScope: "workspace",
|
|
248
248
|
sessionArg: "--session-id",
|
|
249
249
|
sessionMode: "always",
|
|
250
|
-
reseedFromRawTranscriptWhenUncompacted:
|
|
250
|
+
reseedFromRawTranscriptWhenUncompacted: true,
|
|
251
251
|
sessionIdFields: ["session_id", "sessionId", "conversation_id", "conversationId"],
|
|
252
252
|
systemPromptFileArg: "--append-system-prompt-file",
|
|
253
253
|
systemPromptMode: "append",
|
package/dist/shim-core.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export const PRUNE_AFTER_MS = 14 * 24 * 60 * 60 * 1000;
|
|
2
|
+
export const EXPIRED_REJECTED_GRACE_MS = 5 * 60 * 1000;
|
|
2
3
|
const RAW_INFO_MAX_CHARS = 512;
|
|
3
4
|
export function createLineScanner(onLine) {
|
|
4
5
|
let buffer = "";
|
|
@@ -106,8 +107,16 @@ export function mergeHealthStates(disk, live, now, pruneAfterMs = PRUNE_AFTER_MS
|
|
|
106
107
|
const windows = canonicalizeModelWindowKeys(merged);
|
|
107
108
|
if (now !== undefined) {
|
|
108
109
|
for (const [key, w] of Object.entries(windows)) {
|
|
109
|
-
if (now - w.seenAt > pruneAfterMs)
|
|
110
|
+
if (now - w.seenAt > pruneAfterMs) {
|
|
110
111
|
delete windows[key];
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const resetMs = typeof w.resetsAt === "number" ? w.resetsAt * 1000 : undefined;
|
|
115
|
+
if (w.status === "rejected" &&
|
|
116
|
+
resetMs !== undefined &&
|
|
117
|
+
now - resetMs > EXPIRED_REJECTED_GRACE_MS) {
|
|
118
|
+
delete windows[key];
|
|
119
|
+
}
|
|
111
120
|
}
|
|
112
121
|
}
|
|
113
122
|
const updatedAt = Math.max(disk.updatedAt ?? 0, live.updatedAt ?? 0);
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
+
"version": "1.5.0",
|
|
4
5
|
"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": [
|
|
6
|
-
|
|
6
|
+
"cliBackends": [
|
|
7
|
+
"claw1",
|
|
8
|
+
"claw2",
|
|
9
|
+
"clawd"
|
|
10
|
+
],
|
|
11
|
+
"providers": [
|
|
12
|
+
"claw1",
|
|
13
|
+
"claw2",
|
|
14
|
+
"clawd"
|
|
15
|
+
],
|
|
7
16
|
"modelCatalog": {
|
|
8
17
|
"runtimeAugment": true
|
|
9
18
|
},
|
|
@@ -29,7 +38,9 @@
|
|
|
29
38
|
},
|
|
30
39
|
"accounts": {
|
|
31
40
|
"type": "array",
|
|
32
|
-
"items": {
|
|
41
|
+
"items": {
|
|
42
|
+
"type": "string"
|
|
43
|
+
},
|
|
33
44
|
"description": "Account ids (from accounts[]) in preference order; the first is the home account and reclaims the pool when its usage window resets."
|
|
34
45
|
},
|
|
35
46
|
"utilizationThreshold": {
|
|
@@ -51,7 +62,9 @@
|
|
|
51
62
|
"properties": {
|
|
52
63
|
"ladder": {
|
|
53
64
|
"type": "array",
|
|
54
|
-
"items": {
|
|
65
|
+
"items": {
|
|
66
|
+
"type": "string"
|
|
67
|
+
},
|
|
55
68
|
"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
69
|
},
|
|
57
70
|
"pins": {
|
|
@@ -61,8 +74,14 @@
|
|
|
61
74
|
"type": "object",
|
|
62
75
|
"additionalProperties": false,
|
|
63
76
|
"properties": {
|
|
64
|
-
"agentDirIncludes": {
|
|
65
|
-
|
|
77
|
+
"agentDirIncludes": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "Pin when the launching agent's dir contains this substring."
|
|
80
|
+
},
|
|
81
|
+
"workspaceDirIncludes": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"description": "Pin when the workspace dir contains this substring."
|
|
84
|
+
}
|
|
66
85
|
}
|
|
67
86
|
}
|
|
68
87
|
}
|
|
@@ -70,7 +89,9 @@
|
|
|
70
89
|
},
|
|
71
90
|
"models": {
|
|
72
91
|
"type": "array",
|
|
73
|
-
"items": {
|
|
92
|
+
"items": {
|
|
93
|
+
"type": "string"
|
|
94
|
+
},
|
|
74
95
|
"description": "Extra model ids to expose on the pool backend."
|
|
75
96
|
},
|
|
76
97
|
"defaultModel": {
|
|
@@ -85,7 +106,9 @@
|
|
|
85
106
|
"items": {
|
|
86
107
|
"type": "object",
|
|
87
108
|
"additionalProperties": false,
|
|
88
|
-
"required": [
|
|
109
|
+
"required": [
|
|
110
|
+
"id"
|
|
111
|
+
],
|
|
89
112
|
"properties": {
|
|
90
113
|
"id": {
|
|
91
114
|
"type": "string",
|
|
@@ -112,14 +135,25 @@
|
|
|
112
135
|
"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
136
|
"additionalProperties": true,
|
|
114
137
|
"properties": {
|
|
115
|
-
"source": {
|
|
116
|
-
|
|
117
|
-
|
|
138
|
+
"source": {
|
|
139
|
+
"type": "string",
|
|
140
|
+
"description": "Secret source kind: env | file | exec."
|
|
141
|
+
},
|
|
142
|
+
"provider": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"description": "Configured secret provider name, e.g. \"onepassword\"."
|
|
145
|
+
},
|
|
146
|
+
"id": {
|
|
147
|
+
"type": "string",
|
|
148
|
+
"description": "Provider-scoped secret id, e.g. an op:// reference."
|
|
149
|
+
}
|
|
118
150
|
}
|
|
119
151
|
},
|
|
120
152
|
"models": {
|
|
121
153
|
"type": "array",
|
|
122
|
-
"items": {
|
|
154
|
+
"items": {
|
|
155
|
+
"type": "string"
|
|
156
|
+
},
|
|
123
157
|
"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
158
|
},
|
|
125
159
|
"defaultModel": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/multi-clawd",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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",
|
|
@@ -55,7 +55,9 @@
|
|
|
55
55
|
"test:watch": "vitest",
|
|
56
56
|
"prepare": "npm run build",
|
|
57
57
|
"doctor": "node scripts/doctor.mjs",
|
|
58
|
-
"setup": "node scripts/setup.mjs"
|
|
58
|
+
"setup": "node scripts/setup.mjs",
|
|
59
|
+
"sync-manifest": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('openclaw.plugin.json','utf8'));m.version=require('./package.json').version;fs.writeFileSync('openclaw.plugin.json',JSON.stringify(m,null,2)+'\\n')\"",
|
|
60
|
+
"version": "npm run sync-manifest && git add openclaw.plugin.json"
|
|
59
61
|
},
|
|
60
62
|
"peerDependencies": {
|
|
61
63
|
"openclaw": ">=2026.6"
|
|
@@ -69,5 +71,9 @@
|
|
|
69
71
|
"openclaw": "2026.7.1",
|
|
70
72
|
"typescript": "^5.9.0",
|
|
71
73
|
"vitest": "^4.1.10"
|
|
74
|
+
},
|
|
75
|
+
"homepage": "https://github.com/Drakon-Systems-Ltd/multi-clawd#readme",
|
|
76
|
+
"bugs": {
|
|
77
|
+
"url": "https://github.com/Drakon-Systems-Ltd/multi-clawd/issues"
|
|
72
78
|
}
|
|
73
79
|
}
|
package/scripts/cli.mjs
CHANGED
|
@@ -253,7 +253,11 @@ async function explain() {
|
|
|
253
253
|
if (h.verdict === "exhausted" && h.resumeAt) {
|
|
254
254
|
detail = `${h.reason ?? "limit hit"} — back in ${rel(h.resumeAt)}`;
|
|
255
255
|
}
|
|
256
|
-
|
|
256
|
+
const usage = health.summarizeWindowUsage(state, {
|
|
257
|
+
utilizationThreshold: pool?.utilizationThreshold,
|
|
258
|
+
staleAfterMs: pool?.staleAfterMs,
|
|
259
|
+
}, now);
|
|
260
|
+
return { id: a.id, verdict: h.verdict, detail, usage };
|
|
257
261
|
});
|
|
258
262
|
let stickyAccount;
|
|
259
263
|
if (pool) {
|
|
@@ -266,7 +270,7 @@ async function explain() {
|
|
|
266
270
|
}
|
|
267
271
|
console.log(`\n${BOLD}🦞 multi-clawd — your setup, in plain English${RESET}\n`);
|
|
268
272
|
console.log(
|
|
269
|
-
ec.renderExplanation({ accounts, pool, chain, health: healthRows, stickyAccount }),
|
|
273
|
+
ec.renderExplanation({ accounts, pool, chain, health: healthRows, stickyAccount, nowMs: now }),
|
|
270
274
|
);
|
|
271
275
|
console.log(`\n${DIM}(health checks: multi-clawd doctor · change things: multi-clawd setup)${RESET}`);
|
|
272
276
|
}
|