@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 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
+ [![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)
14
+ [![license: MIT](https://img.shields.io/badge/license-MIT-2ea44f)](LICENSE)
15
+ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](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
+ }