@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.
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/dist/account-env.js +35 -0
- package/dist/alerts.js +28 -0
- package/dist/catalog-source.js +34 -0
- package/dist/chain-audit.js +202 -0
- package/dist/degrade.js +32 -0
- package/dist/exec-policy.js +15 -0
- package/dist/health.js +83 -0
- package/dist/index.js +508 -0
- package/dist/login-health.js +91 -0
- package/dist/models.js +69 -0
- package/dist/setup-core.js +140 -0
- package/dist/shim-core.js +218 -0
- package/dist/shim.js +151 -0
- package/dist/sticky.js +24 -0
- package/dist/token-resolution.js +62 -0
- package/dist/watchdog-core.js +53 -0
- package/openclaw.plugin.json +134 -0
- package/package.json +65 -0
- package/scripts/doctor.mjs +340 -0
- package/scripts/eviction-watchdog.mjs +178 -0
- package/scripts/setup.mjs +231 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Drakon Systems Ltd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="assets/multi-clawd-hero.jpg" alt="multi-clawd โ the lobster with two extra claws" width="760">
|
|
4
|
+
|
|
5
|
+
# ๐ฆ multi-clawd
|
|
6
|
+
|
|
7
|
+
**One Claude is never enough.**
|
|
8
|
+
|
|
9
|
+
Pool every Claude Max account you own into a single failover chain โ
|
|
10
|
+
same model, next account, full harness on every hop.
|
|
11
|
+
|
|
12
|
+
[](https://docs.openclaw.ai/plugins)
|
|
13
|
+
[](package.json)
|
|
14
|
+
[](LICENSE)
|
|
15
|
+
[](tsconfig.json)
|
|
16
|
+
|
|
17
|
+
*A normal lobster has two claws. This one has four.*
|
|
18
|
+
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Why
|
|
24
|
+
|
|
25
|
+
OpenClaw's bundled `claude-cli` backend runs Claude Code on a **single**
|
|
26
|
+
login. When that account hits its usage limit, OpenClaw can't move the
|
|
27
|
+
running subprocess onto your second Claude account โ it drops down to the
|
|
28
|
+
next *model* instead. If you own two Claude Max accounts, the second one's
|
|
29
|
+
capacity just sits there, idle, while you get downgraded.
|
|
30
|
+
|
|
31
|
+
**multi-clawd fixes that.** Each extra account becomes its own first-class
|
|
32
|
+
backend that slots into the failover chain like any other model โ so a limit
|
|
33
|
+
on account #1 rolls to account #2 *on the same model* before any tier drop:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
claude-cli/claude-fable-5 # main login
|
|
37
|
+
โ claw2/claude-fable-5 # 2nd login (this plugin) โ same model
|
|
38
|
+
โ claw3/claude-fable-5 # 3rd login? go on then
|
|
39
|
+
โ anthropic/claude-opus-4-8 # only NOW drop a tier
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## What you get
|
|
43
|
+
|
|
44
|
+
- ๐ฆ **Extra claws** โ every account registers as a real CLI backend
|
|
45
|
+
(`claw2/โฆ`, `claw3/โฆ`), resolvable in model refs, fallback chains, and
|
|
46
|
+
per-agent overrides. No API keys, no `baseUrl` hacks.
|
|
47
|
+
- ๐ฑ **The pool (v0.2)** โ one backend id (`clawd/โฆ`) fronting all your
|
|
48
|
+
accounts. Every launch runs on the first account that is **not nearly
|
|
49
|
+
maxed out**, using live `rate_limit_event` health (status, utilization,
|
|
50
|
+
reset time) captured from each account's own Claude stream. Hand over
|
|
51
|
+
*before* the limit error; return home automatically when the window
|
|
52
|
+
resets; when the whole pool is exhausted, fail for real so your chain
|
|
53
|
+
drops provider (OpenAI โ xAI โ โฆ) exactly as configured.
|
|
54
|
+
- ๐ **Usage-aware accounts** โ a transparent shim tees each account's
|
|
55
|
+
stream-json and records per-window health to
|
|
56
|
+
`~/.openclaw/state/multi-clawd/<account>.json`. Passthrough-first: a state
|
|
57
|
+
write can never break a live turn.
|
|
58
|
+
- ๐งฐ **Full harness on every hop** โ each backend is a genuine Claude Code
|
|
59
|
+
subprocess: native tools, skills, MCP bridge, and native compaction all
|
|
60
|
+
stay intact when failover steps across accounts.
|
|
61
|
+
- ๐ฎ **Future-proof models (v0.2)** โ model ids are not hardcoded: the
|
|
62
|
+
catalog mirrors the bundled claude-cli list live (with a built-in
|
|
63
|
+
fallback), and *any* modern `claude-*` id resolves on demand. When the
|
|
64
|
+
flagship subscription model changes (Fable 5 โ Opus 5), `clawd/claude-opus-5`
|
|
65
|
+
just works โ no plugin update.
|
|
66
|
+
- ๐ **Native accounts (v0.2)** โ `"native": true` pools the machine's main
|
|
67
|
+
Claude login (default config dir / OS keychain) without duplicating its
|
|
68
|
+
credentials.
|
|
69
|
+
- ๐ **Token hygiene** โ setup-tokens are read at launch and passed only via
|
|
70
|
+
the child process env. Never committed, never logged. **v0.3:**
|
|
71
|
+
`oauthTokenRef` resolves tokens through the gateway's own secret providers
|
|
72
|
+
(1Password etc.) โ same `{source, provider, id}` shape as the rest of
|
|
73
|
+
openclaw.json, no plaintext files, fixed-reason-code redaction on failure.
|
|
74
|
+
- ๐งฒ **Sticky rotation (v0.3)** โ after handing over, the pool dwells on the
|
|
75
|
+
spare account (default 10 min) before returning home, so turns never flap
|
|
76
|
+
across the threshold. Health always overrides stickiness.
|
|
77
|
+
- ๐ **Operator alerts (v0.3)** โ dead logins (probed every 15 min without
|
|
78
|
+
spending quota), pool rotations, whole-pool exhaustion, and watchdog
|
|
79
|
+
restarts surface through your agent's next heartbeat (e.g. straight into
|
|
80
|
+
Telegram) โ not just journal lines.
|
|
81
|
+
- ๐ฉบ **`npm run doctor` (v0.3)** โ one command that says whether a box is
|
|
82
|
+
actually ready: config/manifest agreement (with the exact `--force`
|
|
83
|
+
preflight strip plan), dist freshness, CLI presence, credential health
|
|
84
|
+
(values never printed), telemetry age, pool + sticky state, watchdog
|
|
85
|
+
presence, optional `--probe` end-to-end turn.
|
|
86
|
+
- ๐งฏ **Self-healing config** โ registration re-reads the resolved runtime
|
|
87
|
+
config if the loader hands it an empty block, so a flaky registration pass
|
|
88
|
+
can't silently no-op the plugin.
|
|
89
|
+
- ๐ **Observable registration** โ every `register()` pass logs which config
|
|
90
|
+
source won and which backends it registered, so a silent no-op can't hide
|
|
91
|
+
in a long-running gateway.
|
|
92
|
+
- ๐ถ **Turn-safe eviction watchdog** โ `scripts/eviction-watchdog.mjs`
|
|
93
|
+
mitigates upstream openclaw#107408 (idle plugin backends silently dropped)
|
|
94
|
+
by restarting the gateway when the `Unknown CLI backend` signature
|
|
95
|
+
appears. **v0.3:** it defers while any turn is in flight (transcript
|
|
96
|
+
activity across all agents + opt-in worker pidfiles), with a 15-min defer
|
|
97
|
+
cap and 10-min restart cooldown โ a restart can no longer eat a live
|
|
98
|
+
reply.
|
|
99
|
+
|
|
100
|
+
## Platform support
|
|
101
|
+
|
|
102
|
+
multi-clawd is pure Node (no native modules, no shell-outs) and mirrors the
|
|
103
|
+
bundled `claude-cli` backend 1:1 โ it runs anywhere OpenClaw's normal Claude
|
|
104
|
+
Code backend runs.
|
|
105
|
+
|
|
106
|
+
| Platform | Status |
|
|
107
|
+
|---|---|
|
|
108
|
+
| Linux | โ
Verified in production (x64 and arm64) |
|
|
109
|
+
| macOS | โ
Supported โ no platform-specific code paths |
|
|
110
|
+
| Windows (WSL2) | โ
Supported โ OpenClaw's recommended gateway runtime on Windows; follow the Linux instructions inside WSL |
|
|
111
|
+
| Windows (native) | โ ๏ธ Expected to work (the gateway spawns `claude` for this plugin exactly as it does for the bundled backend), not yet verified by us โ reports welcome |
|
|
112
|
+
|
|
113
|
+
## Install
|
|
114
|
+
|
|
115
|
+
**From npm (recommended, v1.0+):**
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
openclaw plugins install @drakon-systems/multi-clawd --pin
|
|
119
|
+
openclaw gateway restart
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The gateway pulls the prebuilt package โ no clone, no build step, nothing to
|
|
123
|
+
keep in sync. `--pin` records the exact resolved version, so an upgrade is a
|
|
124
|
+
deliberate `@latest`, never a surprise. `openclaw` itself is a *peer*
|
|
125
|
+
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`.
|
|
128
|
+
|
|
129
|
+
**From ClawHub (alternative registry):**
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
openclaw plugins install clawhub:drakon-systems/multi-clawd
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**From source (contributors, or ahead of a release):**
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
git clone https://github.com/Drakon-Systems-Ltd/multi-clawd.git
|
|
139
|
+
cd multi-clawd && npm install && npm run build
|
|
140
|
+
openclaw plugins install "$(pwd)"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
```powershell
|
|
144
|
+
# Windows (native, PowerShell)
|
|
145
|
+
git clone https://github.com/Drakon-Systems-Ltd/multi-clawd.git
|
|
146
|
+
cd multi-clawd; npm install; npm run build
|
|
147
|
+
openclaw plugins install (Get-Location).Path
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
151
|
+
Code on the target machine already? Paste it this and go make coffee:
|
|
152
|
+
|
|
153
|
+
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/master/SETUP-AGENT.md
|
|
154
|
+
> and follow it to set up multi-clawd on this machine. I own a second
|
|
155
|
+
> Claude account โ ask me when you need me to log in.
|
|
156
|
+
|
|
157
|
+
The guide has the guardrails built in (config backup, merge-don't-overwrite,
|
|
158
|
+
never print tokens, ask before touching routing).
|
|
159
|
+
|
|
160
|
+
**Requirements:** OpenClaw โฅ 2026.6, the `claude` CLI on `PATH`, and a
|
|
161
|
+
second Claude subscription you own.
|
|
162
|
+
|
|
163
|
+
**Upgrading:**
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
# Registry install (npm / ClawHub):
|
|
167
|
+
openclaw plugins install @drakon-systems/multi-clawd@latest --force
|
|
168
|
+
openclaw gateway restart
|
|
169
|
+
|
|
170
|
+
# From source:
|
|
171
|
+
cd multi-clawd && git pull && npm install && npm run build && npm run doctor
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
On the source path run `npm run build` explicitly โ don't rely on the
|
|
175
|
+
`prepare` hook to refresh `dist/` on a pull-upgrade (observed stale on a live
|
|
176
|
+
production rollout; `doctor` flags it STALE if you forget). If a release adds new
|
|
177
|
+
config keys, `openclaw plugins install --force` validates against the *old*
|
|
178
|
+
manifest โ run `node scripts/doctor.mjs --preflight` first for the strip โ
|
|
179
|
+
install โ re-add plan.
|
|
180
|
+
|
|
181
|
+
## Set up a second account
|
|
182
|
+
|
|
183
|
+
**Easiest: the setup wizard.** It walks you through the whole shape below โ
|
|
184
|
+
main account, isolated second account, token storage, pool โ and merges the
|
|
185
|
+
result into `openclaw.json` non-destructively (backup first, merge by id,
|
|
186
|
+
re-runs are no-ops, never sees a token value):
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
npm run setup # source checkout
|
|
190
|
+
node ~/.openclaw/extensions/multi-clawd/scripts/setup.mjs # installed copy
|
|
191
|
+
# add --dry-run to preview the changes without writing anything
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The key idea either way: your **main** account keeps the default `~/.claude`
|
|
195
|
+
login untouched, and each **extra** account gets its *own isolated config
|
|
196
|
+
dir* โ a separate Claude "app" โ so the logins can never clobber each other.
|
|
197
|
+
|
|
198
|
+
**Or by hand:**
|
|
199
|
+
|
|
200
|
+
1. Give the account an isolated config dir and capture its Claude Code
|
|
201
|
+
setup-token into it.
|
|
202
|
+
|
|
203
|
+
**macOS / Linux / WSL2:**
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
mkdir -p ~/.claw2 && chmod 700 ~/.claw2
|
|
207
|
+
CLAUDE_CONFIG_DIR=~/.claw2 claude setup-token # log in as the 2nd account
|
|
208
|
+
# store the token where the plugin can read it (0600):
|
|
209
|
+
# ~/.claw2/oauth-token
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**Windows (native, PowerShell):**
|
|
213
|
+
|
|
214
|
+
```powershell
|
|
215
|
+
New-Item -ItemType Directory -Force "$HOME\.claw2" | Out-Null
|
|
216
|
+
$env:CLAUDE_CONFIG_DIR = "$HOME\.claw2"
|
|
217
|
+
claude setup-token # log in as the 2nd account
|
|
218
|
+
# store the token as $HOME\.claw2\oauth-token, then lock it to your user:
|
|
219
|
+
icacls "$HOME\.claw2\oauth-token" /inheritance:r /grant:r "$($env:USERNAME):(R,W)"
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
2. Configure the plugin (in `openclaw.json`; if `plugins.allow` is set, add
|
|
223
|
+
`"multi-clawd"` to it). `~` expands on every platform; absolute Windows
|
|
224
|
+
paths (`C:\\Users\\you\\.claw2`) work too:
|
|
225
|
+
|
|
226
|
+
```jsonc
|
|
227
|
+
{
|
|
228
|
+
"plugins": {
|
|
229
|
+
"entries": {
|
|
230
|
+
"multi-clawd": {
|
|
231
|
+
"enabled": true,
|
|
232
|
+
"config": {
|
|
233
|
+
"accounts": [
|
|
234
|
+
{
|
|
235
|
+
"id": "claw2",
|
|
236
|
+
"label": "Second Max",
|
|
237
|
+
"configDir": "~/.claw2",
|
|
238
|
+
"oauthTokenFile": "~/.claw2/oauth-token"
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
"agents": {
|
|
246
|
+
"defaults": {
|
|
247
|
+
// allow the model for agents (separate from the failover chain)
|
|
248
|
+
"models": { "claw2/claude-fable-5": {} }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
3. Slot the backend into your fallback chain:
|
|
255
|
+
|
|
256
|
+
```jsonc
|
|
257
|
+
"agents": { "defaults": { "model": {
|
|
258
|
+
"primary": "anthropic/claude-fable-5",
|
|
259
|
+
"fallbacks": [
|
|
260
|
+
"claw2/claude-fable-5",
|
|
261
|
+
"anthropic/claude-opus-4-8"
|
|
262
|
+
]
|
|
263
|
+
} } }
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
4. Restart the gateway. Done โ a limit on the main account now rolls to the
|
|
267
|
+
second account on the same model before any tier drop.
|
|
268
|
+
|
|
269
|
+
## The pool: proactive rotation (v0.2)
|
|
270
|
+
|
|
271
|
+
Individual backends (`claw2/โฆ`) fail over *reactively* โ OpenClaw steps the
|
|
272
|
+
chain when a turn actually dies with a limit error. The pool goes one better:
|
|
273
|
+
it watches each account's own usage signal and hands over **before** the
|
|
274
|
+
error.
|
|
275
|
+
|
|
276
|
+
```jsonc
|
|
277
|
+
"plugins": { "entries": { "multi-clawd": { "enabled": true, "config": {
|
|
278
|
+
"accounts": [
|
|
279
|
+
{ "id": "claw1", "label": "Main Claude", "native": true },
|
|
280
|
+
{ "id": "claw2", "label": "Second Max", "configDir": "~/.claw2",
|
|
281
|
+
// v0.3 preferred: resolve via your gateway's secret providers โ
|
|
282
|
+
// no plaintext token file on disk
|
|
283
|
+
"oauthTokenRef": { "source": "exec", "provider": "onepassword",
|
|
284
|
+
"id": "op://YourVault/claw2-setup-token/password" } }
|
|
285
|
+
],
|
|
286
|
+
"pool": {
|
|
287
|
+
"id": "clawd",
|
|
288
|
+
"accounts": ["claw1", "claw2"], // preference order; first = home
|
|
289
|
+
"utilizationThreshold": 0.85, // hand over at 85% of any window
|
|
290
|
+
"minDwellMs": 600000, // v0.3: anti-flap dwell before returning home
|
|
291
|
+
"degrade": { // v0.3.5: last step before provider drop
|
|
292
|
+
"ladder": ["claude-opus-4-8"], // whole pool exhausted โ same account, lower tier
|
|
293
|
+
"pins": [ // contractual lanes never degrade
|
|
294
|
+
{ "agentDirIncludes": "billing-app" }
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} } } },
|
|
299
|
+
"agents": { "defaults": {
|
|
300
|
+
"models": { "clawd/claude-fable-5": {} },
|
|
301
|
+
"model": {
|
|
302
|
+
"primary": "clawd/claude-fable-5", // the pool IS the Claude lane
|
|
303
|
+
"fallbacks": [
|
|
304
|
+
"openai/gpt-5.6", // both accounts exhausted
|
|
305
|
+
"xai/grok-4.5" // ...or Anthropic is down
|
|
306
|
+
]
|
|
307
|
+
}
|
|
308
|
+
} }
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
How it decides, per launch (all data from each account's live
|
|
312
|
+
`rate_limit_event` stream, captured by the shim):
|
|
313
|
+
|
|
314
|
+
| Account state | Effect |
|
|
315
|
+
|---|---|
|
|
316
|
+
| `rejected` + reset in the future | skipped until `resetsAt` passes |
|
|
317
|
+
| any window utilization โฅ threshold | skipped (nearly maxed โ the point of the pool) |
|
|
318
|
+
| `allowed_warning` alone | still used โ weekly windows warn early; only the utilization number rotates |
|
|
319
|
+
| no data / stale data | used โ never rotate on missing evidence |
|
|
320
|
+
| whole pool exhausted | home account anyway โ real limit error โ your chain drops provider |
|
|
321
|
+
|
|
322
|
+
Notes:
|
|
323
|
+
|
|
324
|
+
- Rotation happens at limit boundaries only. A mid-conversation handover
|
|
325
|
+
costs the Claude CLI its native session (it lives in the previous
|
|
326
|
+
account's config dir); OpenClaw's fresh-session retry recovers the turn.
|
|
327
|
+
- Plugin lifecycle hooks were investigated and ruled out for this job: on
|
|
328
|
+
OpenClaw โค 2026.7.1, `before_model_resolve` never fires for gateway RPC
|
|
329
|
+
turns and `before_agent_start` overrides are ignored on the prompt path.
|
|
330
|
+
The pool therefore decides inside the backend's own `prepareExecution`,
|
|
331
|
+
which runs on every launch on every turn path. Details in
|
|
332
|
+
[`DESIGN.md`](./DESIGN.md).
|
|
333
|
+
|
|
334
|
+
## How it works
|
|
335
|
+
|
|
336
|
+
Three moves, all through the official plugin SDK (details in
|
|
337
|
+
[`DESIGN.md`](./DESIGN.md)):
|
|
338
|
+
|
|
339
|
+
1. **`registerCliBackend`** mirrors the bundled `claude-cli` backend โ same
|
|
340
|
+
argv, same JSONL stream parsing, same MCP config-file bridge โ scoped to
|
|
341
|
+
one account id.
|
|
342
|
+
2. **A minimal provider per account** implements `resolveDynamicModel` +
|
|
343
|
+
`augmentModelCatalog`, which is what makes `claw2/claude-fable-5`
|
|
344
|
+
resolvable without an API key (installed extensions can't use the
|
|
345
|
+
bundled plugins' static-catalog path โ this is the supported alternative).
|
|
346
|
+
3. **`prepareExecution`** injects that account's own login
|
|
347
|
+
(`CLAUDE_CONFIG_DIR` + `CLAUDE_CODE_OAUTH_TOKEN`) into the child process
|
|
348
|
+
env, after the host's ambient Claude credentials are stripped.
|
|
349
|
+
|
|
350
|
+
## Known issue: idle backends can be evicted by OpenClaw core
|
|
351
|
+
|
|
352
|
+
On OpenClaw โค 2026.7.1, core's *scoped* harness activation can silently drop
|
|
353
|
+
a plugin-registered CLI backend from the live registry: when an agent turn
|
|
354
|
+
selects a harness owned by a different plugin and that scoped set isn't
|
|
355
|
+
already fully loaded, core rebuilds the plugin registry with **only** that
|
|
356
|
+
plugin (+ the memory plugin) and swaps it in globally. Your `claw2` backend
|
|
357
|
+
then fails with `Unknown CLI backend: claw2` โ while `openclaw infer model
|
|
358
|
+
list` (a separate cache) still lists its models. A common real-world trigger
|
|
359
|
+
is an hourly heartbeat running on a model served by another harness.
|
|
360
|
+
|
|
361
|
+
- Upstream bug: [openclaw#107408](https://github.com/openclaw/openclaw/issues/107408)
|
|
362
|
+
- Upstream fix: [openclaw#107596](https://github.com/openclaw/openclaw/pull/107596)
|
|
363
|
+
|
|
364
|
+
**Until that lands:** a gateway restart always restores the backend (startup
|
|
365
|
+
loads are full-scope), and backends that are in regular use effectively
|
|
366
|
+
re-assert themselves. This repo ships a ready-made mitigation โ
|
|
367
|
+
`scripts/eviction-watchdog.mjs` โ which tails the gateway log for the
|
|
368
|
+
`Unknown CLI backend` signature and restarts the gateway at most once per
|
|
369
|
+
eviction event. Run it every few minutes from cron/launchd/systemd:
|
|
370
|
+
|
|
371
|
+
```bash
|
|
372
|
+
node scripts/eviction-watchdog.mjs # restart on detection
|
|
373
|
+
MULTI_CLAWD_WATCHDOG_DRY=1 node scripts/eviction-watchdog.mjs # report only
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
An in-process fix was investigated and is impossible by design:
|
|
377
|
+
`registerCliBackend` is not late-callable, there is no registry-rebuilt
|
|
378
|
+
event, and no plugin API can force a rebuild. See `DESIGN.md`.
|
|
379
|
+
|
|
380
|
+
## Known limitations
|
|
381
|
+
|
|
382
|
+
- **Shim window persistence is sequential-safe, not concurrent-writer-safe.**
|
|
383
|
+
`persistState()` in `src/shim.ts` does a read-merge-write on every save so a
|
|
384
|
+
turn that only reports one window type (say `five_hour`) doesn't clobber
|
|
385
|
+
the last-seen `seven_day` data โ but the read, merge, and rename aren't
|
|
386
|
+
atomic together. Two truly concurrent shim processes for the *same*
|
|
387
|
+
account can still race each other and drop an event on last-rename-wins.
|
|
388
|
+
In practice this needs two in-flight turns on one account at once, which
|
|
389
|
+
is rare, but it's a real gap. A per-account lock/retry protocol is tracked
|
|
390
|
+
as a v0.3.x follow-up.
|
|
391
|
+
|
|
392
|
+
## Security
|
|
393
|
+
|
|
394
|
+
- Tokens are never committed and never logged; `.gitignore` blocks token
|
|
395
|
+
and account directories by default.
|
|
396
|
+
- Prefer a secret reference (`oauthTokenRef`, v0.3) over a plaintext
|
|
397
|
+
file; when a file is used, keep it `0600` (POSIX) or locked to your user
|
|
398
|
+
with `icacls` (Windows).
|
|
399
|
+
- Migrating a token file into a vault? `op read` (and most secret CLIs)
|
|
400
|
+
append a trailing newline on output โ resolution trims the resolved
|
|
401
|
+
value (guaranteed in `token-resolution.ts`), so a file-vs-vault diff
|
|
402
|
+
showing only a trailing-newline mismatch is a false alarm.
|
|
403
|
+
- Use only accounts you own, within your provider's terms of service.
|
|
404
|
+
|
|
405
|
+
## Status & roadmap
|
|
406
|
+
|
|
407
|
+
Early but real โ built for and dogfooded in production.
|
|
408
|
+
|
|
409
|
+
- **v0.1** โ single extra account, verified end-to-end โ
|
|
410
|
+
- **v0.1.1** โ `jsonlDialect` declared on registered backends, fixing raw
|
|
411
|
+
stream-JSON reaching connected channels on live turns โ
|
|
412
|
+
- **v0.1.2** โ registration-pass logging (config-source attribution +
|
|
413
|
+
registered-backend summary) โ
|
|
414
|
+
- **v0.2** โ the pool: proactive near-limit rotation from live
|
|
415
|
+
`rate_limit_event` health; native (keychain) accounts; future-proof model
|
|
416
|
+
resolution (mirrored catalog + permissive `claude-*` pass-through);
|
|
417
|
+
eviction watchdog; vitest suite โ
|
|
418
|
+
- **v0.3** โ hardening from field feedback: `oauthTokenRef` via gateway
|
|
419
|
+
secret providers with strict redaction; sticky rotation with anti-flap
|
|
420
|
+
dwell; login-health probes + heartbeat operator alerts; turn-safe
|
|
421
|
+
watchdog (lane-guard); `doctor` + `--preflight`; build-on-install; shim
|
|
422
|
+
window persistence โ
|
|
423
|
+
- **v0.3.5** โ tier-aware degradation (whole pool exhausted โ step down the
|
|
424
|
+
configured ladder on the same account, e.g. Fable โ Opus, instead of
|
|
425
|
+
dropping provider) + never-degrade pins for contractual model lanes +
|
|
426
|
+
single-account pools โ
|
|
427
|
+
- **v0.3.6** โ reactive model-limit capture: a 429 "reached your <model>
|
|
428
|
+
limit" error is recorded as a model-scoped rejected window, and health is
|
|
429
|
+
model-aware (exhausted-for-Fable โ exhausted-for-Opus) โ the first hard
|
|
430
|
+
limit teaches the pool, the next launch flips accounts โ
|
|
431
|
+
- **v0.3.7** โ reset-aware per-window staleness: reset-bearing windows
|
|
432
|
+
(weekly, model:*) bind until their reset regardless of observation age
|
|
433
|
+
(capped at 8d with a clock-skew alarm), reset-less windows keep TTL/decay,
|
|
434
|
+
and model windows age by their own TTL independent of pool `staleAfterMs`
|
|
435
|
+
โ closes the quiet-pool blindness half of the no-flip failure class โ
|
|
436
|
+
- **v0.4** โ standalone localhost proxy (OpenAI-compatible) so Hermes and
|
|
437
|
+
custom runtimes can share the pool; true per-session affinity; local
|
|
438
|
+
five-hour-window signal (turn counting)
|
|
439
|
+
- **v1.0** โ npm + ClawHub parity releases
|
|
440
|
+
|
|
441
|
+
See [`DESIGN.md`](./DESIGN.md) for the architecture, the three obvious
|
|
442
|
+
approaches that *don't* work, and why.
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
<div align="center">
|
|
447
|
+
|
|
448
|
+
Built by [Drakon Systems Ltd](https://drakonsystems.com) ยท MIT licensed
|
|
449
|
+
|
|
450
|
+
๐ฆ *Claws out.*
|
|
451
|
+
|
|
452
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
export function expandHomePath(p) {
|
|
4
|
+
if (p === "~")
|
|
5
|
+
return homedir();
|
|
6
|
+
if (p.startsWith("~/"))
|
|
7
|
+
return resolve(homedir(), p.slice(2));
|
|
8
|
+
return resolve(p);
|
|
9
|
+
}
|
|
10
|
+
export function buildAccountChildEnv(account, token, stateFile) {
|
|
11
|
+
const env = {
|
|
12
|
+
MULTI_CLAWD_ACCOUNT_ID: account.id,
|
|
13
|
+
MULTI_CLAWD_STATE_FILE: stateFile,
|
|
14
|
+
};
|
|
15
|
+
if (token)
|
|
16
|
+
env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
17
|
+
if (!account.native && account.configDir) {
|
|
18
|
+
env.CLAUDE_CONFIG_DIR = expandHomePath(account.configDir);
|
|
19
|
+
}
|
|
20
|
+
return env;
|
|
21
|
+
}
|
|
22
|
+
export function validateAccountTokenSources(account) {
|
|
23
|
+
const sources = [];
|
|
24
|
+
if (account.native)
|
|
25
|
+
sources.push("native");
|
|
26
|
+
if (account.oauthTokenFile)
|
|
27
|
+
sources.push("oauthTokenFile");
|
|
28
|
+
if (account.oauthTokenRef)
|
|
29
|
+
sources.push("oauthTokenRef");
|
|
30
|
+
if (sources.length <= 1)
|
|
31
|
+
return [];
|
|
32
|
+
return [
|
|
33
|
+
`account "${account.id}" declares ${sources.join(" + ")} โ token sources are mutually exclusive; precedence applied is ${sources.includes("native") ? "native" : "oauthTokenFile"} first. Remove the extras.`,
|
|
34
|
+
];
|
|
35
|
+
}
|
package/dist/alerts.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const DEFAULT_TTL_MS = {
|
|
2
|
+
error: 6 * 60 * 60 * 1000,
|
|
3
|
+
info: 30 * 60 * 1000,
|
|
4
|
+
};
|
|
5
|
+
export function addAlert(state, alert, nowMs) {
|
|
6
|
+
return {
|
|
7
|
+
alerts: [
|
|
8
|
+
...state.alerts.filter((a) => a.key !== alert.key),
|
|
9
|
+
{ ...alert, at: nowMs },
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function clearAlert(state, key) {
|
|
14
|
+
return { alerts: state.alerts.filter((a) => a.key !== key) };
|
|
15
|
+
}
|
|
16
|
+
function isLive(alert, nowMs) {
|
|
17
|
+
const ttl = alert.ttlMs ?? DEFAULT_TTL_MS[alert.severity];
|
|
18
|
+
return nowMs - alert.at <= ttl;
|
|
19
|
+
}
|
|
20
|
+
export function pendingAlertText(state, nowMs) {
|
|
21
|
+
const live = state.alerts.filter((a) => isLive(a, nowMs));
|
|
22
|
+
if (live.length === 0)
|
|
23
|
+
return undefined;
|
|
24
|
+
const ordered = [...live].sort((a, b) => a.severity === b.severity ? a.at - b.at : a.severity === "error" ? -1 : 1);
|
|
25
|
+
return ordered
|
|
26
|
+
.map((a) => `[multi-clawd] ${a.severity.toUpperCase()}: ${a.text}`)
|
|
27
|
+
.join("\n");
|
|
28
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { FALLBACK_MODEL_IDS, isModernClaudeModelId } from "./models.js";
|
|
5
|
+
export async function loadBundledCatalogIds() {
|
|
6
|
+
try {
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const sdkPath = require.resolve("openclaw/plugin-sdk/plugin-entry");
|
|
9
|
+
const distRoot = sdkPath.slice(0, sdkPath.lastIndexOf("/dist/") + "/dist".length);
|
|
10
|
+
const catalogPath = join(dirname(distRoot), "dist", "extensions", "anthropic", "cli-catalog.js");
|
|
11
|
+
const mod = (await import(pathToFileURL(catalogPath).href));
|
|
12
|
+
const entries = mod.buildClaudeCliCatalogEntries?.();
|
|
13
|
+
if (!Array.isArray(entries))
|
|
14
|
+
return null;
|
|
15
|
+
const ids = entries
|
|
16
|
+
.map((e) => (typeof e?.id === "string" ? e.id : ""))
|
|
17
|
+
.filter((id) => id.length > 0);
|
|
18
|
+
return ids.length > 0 ? ids : null;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function resolveBaseModelIds(loader = loadBundledCatalogIds) {
|
|
25
|
+
let mirrored = null;
|
|
26
|
+
try {
|
|
27
|
+
mirrored = await loader();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
mirrored = null;
|
|
31
|
+
}
|
|
32
|
+
const clean = (mirrored ?? []).filter(isModernClaudeModelId);
|
|
33
|
+
return clean.length > 0 ? clean : [...FALLBACK_MODEL_IDS];
|
|
34
|
+
}
|