@drakon-systems/multi-clawd 2.1.2 → 2.2.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 CHANGED
@@ -284,16 +284,15 @@ claude-cli/claude-fable-5 # main login
284
284
 
285
285
  ## Platform support
286
286
 
287
- multi-clawd is pure Node (no native modules, no shell-outs) and mirrors the
288
- bundled `claude-cli` backend 1:1 — it runs anywhere OpenClaw's normal Claude
289
- Code backend runs.
287
+ multi-clawd uses Node without native modules. External commands are run with
288
+ argument arrays, never through a shell string. Launch mode varies by platform.
290
289
 
291
290
  | Platform | Status |
292
291
  |---|---|
293
292
  | Linux | ✅ Verified in production (x64 and arm64) |
294
- | macOS | ✅ Supported — no platform-specific code paths |
293
+ | macOS | ✅ Supported — reads Claude logins from the Keychain through the `security` CLI and schedules the optional watchdog with a launchd plist in `~/Library/LaunchAgents/` |
295
294
  | Windows (WSL2) | ✅ Supported — OpenClaw's recommended gateway runtime on Windows; follow the Linux instructions inside WSL |
296
- | 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 |
295
+ | Windows (native) | ⚠️ Not yet verified by us — verified launch is not implemented on Windows; it falls back to the legacy node + shim launch, so the v2.1.0 verified-launch fix for OpenClaw 2026.10.2 setup authority ("Ask OpenClaw") does not apply there. Reports welcome. |
297
296
 
298
297
  ## Install
299
298
 
@@ -375,6 +374,8 @@ cd multi-clawd && npm install && npm run build
375
374
  openclaw plugins install "$(pwd)"
376
375
  ```
377
376
 
377
+ npm 10 and 11 both work here because the lockfile is committed.
378
+
378
379
  ```powershell
379
380
  # Windows (native, PowerShell)
380
381
  git clone https://github.com/Drakon-Systems-Ltd/multi-clawd.git
@@ -385,7 +386,7 @@ openclaw plugins install (Get-Location).Path
385
386
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
386
387
  Code on the target machine already? Paste it this and go make coffee:
387
388
 
388
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.1.2/SETUP-AGENT.md
389
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.2.0/SETUP-AGENT.md
389
390
  > and follow it to set up multi-clawd on this machine. I own a second
390
391
  > Claude account — ask me when you need me to log in.
391
392
 
@@ -553,7 +554,8 @@ error.
553
554
  "pool": {
554
555
  "id": "clawd",
555
556
  "accounts": ["claw1", "claw2"], // preference order; first = home
556
- "utilizationThreshold": 0.85, // hand over at 85% of any window
557
+ "utilizationThreshold": 0.85, // default hand-over; minute/hour and overage windows use this
558
+ "weeklyUtilizationThreshold": 0.97, // optional: day/week/month quota windows at 97%
557
559
  "minDwellMs": 600000, // v0.3: anti-flap dwell before returning home
558
560
  "degrade": { // v0.3.5: last step before provider drop
559
561
  "ladder": ["claude-opus-4-8"], // whole pool exhausted → same account, lower tier
@@ -629,6 +631,7 @@ from the provider** on a timer — the same figures the Claude CLI's own
629
631
  "id": "clawd",
630
632
  "accounts": ["claw1", "claw2"],
631
633
  "utilizationThreshold": 0.85, // hand over here (unchanged)
634
+ "weeklyUtilizationThreshold": 0.97, // optional: day/week/month quota windows at 97%; overage stays at 85%
632
635
  "usagePoll": { // v1.10 — on by default
633
636
  "intervalMs": 120000, // every 2 min (minimum 60000)
634
637
  "warnThreshold": 0.95 // operator alert from here
@@ -745,9 +748,10 @@ What to know:
745
748
 
746
749
  - **Why a native or config-dir login needs its own token.** That login is a
747
750
  rotating one-time OAuth grant, and a copy of it breaks on the next refresh.
748
- multi-clawd never reads Claude's own credential files. Run `claude
749
- setup-token` signed in as that account and store the token. `setup` walks
750
- you through it.
751
+ multi-clawd never copies that login into a direct profile. (The usage poll
752
+ and login checks do read Claude's credential files, but never copy or
753
+ rewrite them.) Run `claude setup-token` signed in as that account and store
754
+ the token. `setup` walks you through it.
751
755
  - **Already stored a profile yourself?** Adopt it with
752
756
  `"direct": { "profileId": "anthropic:<id>" }`. It gets ordered, never
753
757
  rewritten.
@@ -828,6 +832,18 @@ event, and no plugin API can force a rebuild. See `DESIGN.md`.
828
832
  a usable login now reports as available, a dead one still reports
829
833
  "missing-auth", and the direct API transport keeps refusing CLI-login
830
834
  accounts as before. Update with `multi-clawd update`.
835
+ If an account's stored login is deleted or becomes unreadable, the account
836
+ can stay available until the 30-second cache window that began at its last
837
+ assessment ends; the window runs from that assessment, not from the loss.
838
+ The first availability check after the window ends starts a background
839
+ re-check, and the account stays available only while that re-check is
840
+ pending; once it finishes it marks the account unavailable. Even if the
841
+ re-check stalls, a login backed by a valid access token is refused at its
842
+ access-token expiry minus 60 seconds, including at that exact moment and
843
+ even inside the first window. A login proven only by a recent successful
844
+ turn (its stored token can already be expired) is refused at two windows
845
+ after the assessment or 24 hours after that turn, whichever comes first. A
846
+ CLI login failure recorded on a real turn removes it at once.
831
847
 
832
848
  - **Shim window persistence is sequential-safe, not concurrent-writer-safe.**
833
849
  `persistState()` in `src/shim.ts` does a read-merge-write on every save so a
@@ -849,11 +865,11 @@ callsite (the ones static scanners flag) and why each exists.
849
865
 
850
866
  | Surface | What multi-clawd does |
851
867
  |---|---|
852
- | 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. |
853
- | 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. |
854
- | Files read | `~/.openclaw/openclaw.json` (config), account config dirs you declare (e.g. `~/.claw2`), token files you declare. |
855
- | 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. |
856
- | 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. |
868
+ | Network | The gateway plugin polls `https://api.anthropic.com/api/oauth/usage` for eligible pooled accounts on an interval (default 120000 ms / 2 minutes); `pool.usagePoll.enabled: false` disables that poll. `multi-clawd usage` requests the same endpoint on demand. `doctor` runs `npm view` when its registry cache is stale; install and update commands contact the package registry. Claude Code subprocesses make their own provider requests. No plugin telemetry or analytics endpoint. |
869
+ | Credentials | Reads declared setup-token files or gateway-resolved `oauthTokenRef` values for Claude child launches; reads each eligible account's `.credentials.json` access token for the usage poll; reads Claude login credentials from files or the macOS Keychain via the `security` CLI for login checks and runtime auth; reads account identity for file-backed logins from `.claude.json` (`~/.claude.json` for a native login, or the account config dir). Setup tokens are passed to Claude through the child environment. |
870
+ | Files read | `~/.openclaw/openclaw.json`, declared account config dirs and token files, Claude `.credentials.json` and `.claude.json` files as described above, and local state files. |
871
+ | Files written | Under `~/.openclaw/state/multi-clawd/` by default: `<id>.json`, `<id>.usage.json`, `pool-<id>.sticky.json`, `direct-order.sticky.json`, `known-models.json`, `alerts-spool.jsonl`, `watchdog.json`, `registry-check.json`, `auth-profiles-<agent>.json`, `runtime/<hash>/` (copied runtime files), and `watchdog-launcher.mjs`. The watchdog state file can be redirected with `MULTI_CLAWD_WATCHDOG_STATE`. Setup writes `~/.openclaw/openclaw.json` and a dated backup. On session handover, transcript `.jsonl` files are copied between account config dirs. The optional watchdog installs `~/.config/systemd/user/multi-clawd-eviction-watchdog.service` and `.timer` on Linux, or `~/Library/LaunchAgents/com.multi-clawd.eviction-watchdog.plist` on macOS; the unit points at the state-dir launcher. |
872
+ | Processes | Launches Claude Code and the node + shim transport; CLI, setup and doctor run npm, OpenClaw, Claude and OS scheduler commands; the optional watchdog checks OpenClaw logs and may restart the gateway; Hermes integration runs its Python bridge. See [SECURITY.md](SECURITY.md) for the process-call table. |
857
873
  | Consent | The wizard asks before every write, merges non-destructively, and never overwrites an existing account entry. `--dry-run` previews everything. |
858
874
 
859
875
  Housekeeping:
package/SECURITY.md CHANGED
@@ -15,9 +15,9 @@ credit you (unless you'd rather we didn't), and note it in the CHANGELOG.
15
15
 
16
16
  ## How credentials are handled
17
17
 
18
- - **Tokens are never logged and never appear in a command line.** They are
19
- passed to the Claude CLI child process through its environment only, so they
20
- cannot leak into a process list, a shell history, or a log file.
18
+ - **Tokens are never logged and never appear in a command line.** Claude CLI
19
+ child launches receive setup tokens through their environment; direct-route
20
+ OpenClaw commands receive them on stdin.
21
21
  - **Secret references are preferred over token files.** `oauthTokenRef` uses
22
22
  the same `{source, provider, id}` shape as the rest of `openclaw.json` and
23
23
  resolves through the gateway's own configured secret providers (1Password,
@@ -34,8 +34,9 @@ credit you (unless you'd rather we didn't), and note it in the CHANGELOG.
34
34
  environment variables covering authentication, runtime, and telemetry are
35
35
  cleared before each launch, so one account's credential cannot bleed into
36
36
  another account's session.
37
- - **State files are written `0600`,** atomically (temp file + rename), and
38
- contain rate-limit telemetry only — never credentials.
37
+ - **Per-account health and usage files are written `0600`,** using temporary
38
+ files and rename, and contain rate-limit data rather than credentials. Other
39
+ state files include model, alert, registry and watchdog data.
39
40
 
40
41
  ## Permissions
41
42
 
@@ -48,20 +49,25 @@ nothing and Claude keeps its default prompt-honouring behaviour.
48
49
  ## Why static scanners flag this package
49
50
 
50
51
  Automated scans flag multi-clawd for `dangerous_exec`. That is expected and
51
- correct: starting Claude Code processes *is* the product. Every such callsite
52
- is listed here so you can check them yourself.
52
+ correct: starting Claude Code processes *is* the product. Every source file
53
+ that calls a child-process API is listed here so you can check it yourself.
53
54
 
54
55
  | Location | What it runs | Why |
55
56
  | --- | --- | --- |
56
- | `dist/shim.js` | the `claude` binary | The core function: launches Claude Code for the selected account. Argv is built from a fixed base list plus your configured model; no shell. |
57
- | `dist/watchdog-schedule.js` | `node <bundled script>` | Runs the eviction watchdog on a timer. Path is the package's own script. |
58
- | `scripts/cli.mjs` | `node <bundled script>` | The CLI dispatching to its own subcommands. |
59
- | `scripts/setup.mjs` | `launchctl` / `systemctl` | Loads the watchdog timer during setup on macOS/Linux. |
60
- | `dist/openclaw-runner.js` | the `openclaw` CLI (or `directRoute.openclawCommand`) | Direct route only (v1.9, accounts with `direct`): reads and writes the `anthropic` auth profiles and order through OpenClaw's own subcommands (`models auth list`/`order get`/`order set`/`paste-token`, `secrets apply`, `models status`). A setup-token only ever travels on the child's stdin. Never spawned without `direct` configured. |
61
-
62
- None of these pass a string to a shell — they are direct process spawns with
63
- argument arrays, so there is no quoting or injection surface. Paths are
64
- package-internal or derived from your home directory, never from remote input.
57
+ | `dist/shim.js` | selected `claude` executable | Launches Claude Code for the selected account and, when an in-turn retry is armed, relaunches it for a sibling account. |
58
+ | `dist/shim-transport.js` | `node <shim>` | Runs the shim for an execute-transport turn. |
59
+ | `dist/credential-store.js` | `security find-generic-password` / `security dump-keychain` | Reads a macOS Keychain login or inventories Keychain item attributes for credential checks. |
60
+ | `dist/watchdog-schedule.js` | generated `watchdog-launcher.mjs` runs `node <bundled eviction-watchdog.mjs>` | Renders the state-dir launcher that a systemd user timer or launchd agent runs. The launcher resolves the current install before spawning its bundled watchdog script. |
61
+ | `dist/openclaw-runner.js` | the `openclaw` CLI (or `directRoute.openclawCommand`) | Direct route only: reads and writes the `anthropic` auth profiles and order through OpenClaw subcommands. A setup token travels on the child's stdin. |
62
+ | `scripts/cli.mjs` | `npm view` / `npm i -g`, `openclaw --version`, `openclaw` plugin install and gateway restart, `claude` login/status commands, `launchctl` / `systemctl`, and `node <bundled script>` | Checks and updates the CLI/plugin, handles account login, dispatches subcommands and repairs watchdog scheduling. |
63
+ | `scripts/setup.mjs` | `claude --version`, `launchctl` / `systemctl` | Checks the Claude CLI and loads or repairs optional watchdog scheduling. |
64
+ | `scripts/doctor.mjs` | `npm view`, `claude --version`, `openclaw models auth list` / `openclaw agent`, `launchctl` / `systemctl` | Checks registry version when its cache is stale, inspects auth and watchdog status, and optionally probes accounts. |
65
+ | `scripts/eviction-watchdog.mjs` | `openclaw logs --plain` / `openclaw gateway restart` | Detects the eviction signature and restarts the gateway when the watchdog's conditions are met. |
66
+ | `scripts/hermes.mjs` | Python interpreter with `-c` / bundled bridge | Checks Python availability and runs the Hermes bridge. |
67
+
68
+ None of these pass a command string to a shell: child-process calls use an
69
+ executable and argument array. The launcher generated by `watchdog-schedule`
70
+ uses the same form.
65
71
 
66
72
  ## Things you should decide for yourself
67
73
 
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_UTILIZATION_THRESHOLD } from "./health.js";
1
2
  const WINDOW_LABELS = {
2
3
  five_hour: "5-hour",
3
4
  seven_day: "weekly",
@@ -84,11 +85,14 @@ export function renderExplanation(model) {
84
85
  }
85
86
  lines.push("");
86
87
  if (model.pool) {
87
- const pct = Math.round((model.pool.utilizationThreshold ?? 0.85) * 100);
88
+ const generalThreshold = model.pool.utilizationThreshold ?? DEFAULT_UTILIZATION_THRESHOLD;
89
+ const weeklyThreshold = model.pool.weeklyUtilizationThreshold ?? generalThreshold;
88
90
  lines.push(`POOL ${model.pool.id} (${model.pool.accounts.join(" → ")})`);
89
- lines.push(` Every Claude launch runs on the first account that is NOT nearly maxed`);
90
- lines.push(` out — hand-over at ${pct}% of any rate window, home account reclaims`);
91
+ lines.push(` Every Claude launch runs on the first account below its threshold;`);
92
+ lines.push(` the home account reclaims work`);
91
93
  lines.push(` automatically once its window resets.`);
94
+ lines.push(` Thresholds: general ${Math.round(generalThreshold * 100)}%, weekly ${Math.round(weeklyThreshold * 100)}%${model.pool.weeklyUtilizationThreshold === undefined ? " (follows general)" : ""}.`);
95
+ lines.push(` The weekly threshold applies to day/week/month quota windows; overage uses the general threshold.`);
92
96
  const ladder = model.pool.degrade?.ladder ?? [];
93
97
  if (ladder.length > 0) {
94
98
  lines.push(` If the WHOLE pool is exhausted: step down to ${ladder.join(" → ")} first.`);
package/dist/health.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { modelWindowApplies, modelWindowKey } from "./shim-core.js";
2
- const DEFAULT_UTILIZATION_THRESHOLD = 0.85;
2
+ export const DEFAULT_UTILIZATION_THRESHOLD = 0.85;
3
3
  const DEFAULT_STALE_AFTER_MS = 6 * 60 * 60 * 1000;
4
4
  export const MODEL_REJECTED_TTL_MS = 60 * 60 * 1000;
5
5
  export const CREDENTIAL_FAILED_TTL_MS = 15 * 60 * 1000;
@@ -19,6 +19,9 @@ export function isPeriodWindow(window) {
19
19
  export function isOverageWindow(window) {
20
20
  return /overage/i.test(window);
21
21
  }
22
+ export function isLongQuotaWindow(window) {
23
+ return /(^|_)(days?|weeks?|months?)(_|$)/.test(window) && !isOverageWindow(window);
24
+ }
22
25
  export function isWarningStatus(status) {
23
26
  return /warning/i.test(status);
24
27
  }
@@ -75,7 +78,7 @@ export function lastAuthSuccessAt(state) {
75
78
  }
76
79
  export function classifyAccountHealth(state, options, nowMs, requestedModel) {
77
80
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
78
- const threshold = options.utilizationThreshold ?? DEFAULT_UTILIZATION_THRESHOLD;
81
+ const generalThreshold = options.utilizationThreshold ?? DEFAULT_UTILIZATION_THRESHOLD;
79
82
  if (!state)
80
83
  return { verdict: "no_data" };
81
84
  const credentialFailure = credentialFailureFor(state, nowMs);
@@ -153,6 +156,9 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
153
156
  reason: `${window} rejected ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; ${Math.round(resetLessBlockMs(window) / 3600000)}h block)`,
154
157
  };
155
158
  }
159
+ const threshold = isLongQuotaWindow(window)
160
+ ? options.weeklyUtilizationThreshold ?? generalThreshold
161
+ : generalThreshold;
156
162
  if (typeof w.utilization === "number" &&
157
163
  w.utilization >= threshold &&
158
164
  (resetBearing || resetMs === undefined)) {
package/dist/index.js CHANGED
@@ -805,6 +805,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
805
805
  }
806
806
  const options = {
807
807
  utilizationThreshold: pool.utilizationThreshold,
808
+ weeklyUtilizationThreshold: pool.weeklyUtilizationThreshold,
808
809
  staleAfterMs: pool.staleAfterMs,
809
810
  rotateOnOverage: pool.rotateOnOverage,
810
811
  };
@@ -1040,6 +1041,7 @@ export function startDirectOrderSync(params) {
1040
1041
  const intervalMs = Math.max(15_000, params.directRoute?.intervalMs ?? 60_000);
1041
1042
  const healthOptions = {
1042
1043
  utilizationThreshold: params.pool?.utilizationThreshold,
1044
+ weeklyUtilizationThreshold: params.pool?.weeklyUtilizationThreshold,
1043
1045
  staleAfterMs: params.pool?.staleAfterMs,
1044
1046
  rotateOnOverage: params.pool?.rotateOnOverage,
1045
1047
  };
@@ -1137,6 +1139,7 @@ export function startUsagePoll(params) {
1137
1139
  const warnThreshold = effectiveUsageWarnThreshold(pool.usagePoll);
1138
1140
  const healthOptions = {
1139
1141
  utilizationThreshold: pool.utilizationThreshold,
1142
+ weeklyUtilizationThreshold: pool.weeklyUtilizationThreshold,
1140
1143
  staleAfterMs: pool.staleAfterMs,
1141
1144
  rotateOnOverage: pool.rotateOnOverage,
1142
1145
  };
@@ -1205,7 +1208,7 @@ export function startUsagePoll(params) {
1205
1208
  usageInitial.unref?.();
1206
1209
  usageTimer = setInterval(tick, intervalMs);
1207
1210
  usageTimer.unref?.();
1208
- logger.info(`[multi-clawd] usage poll: reading live usage for ${members.map((m) => m.id).join(", ")} every ${Math.round(intervalMs / 1000)}s — rotate at ${Math.round((pool.utilizationThreshold ?? 0.85) * 100)}%, warn at ${Math.round(warnThreshold * 100)}%${skipped.length > 0 ? `; not polled: ${skipped.join("; ")}` : ""}`);
1211
+ logger.info(`[multi-clawd] usage poll: reading live usage for ${members.map((m) => m.id).join(", ")} every ${Math.round(intervalMs / 1000)}s — rotate at ${Math.round((pool.utilizationThreshold ?? 0.85) * 100)}%${pool.weeklyUtilizationThreshold !== undefined ? ` (weekly ${Math.round(pool.weeklyUtilizationThreshold * 100)}%)` : ""}, warn at ${Math.round(warnThreshold * 100)}%${skipped.length > 0 ? `; not polled: ${skipped.join("; ")}` : ""}`);
1209
1212
  return { active: true, members: members.map((m) => m.id) };
1210
1213
  }
1211
1214
  export async function runUsagePollTickNow() {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "2.1.2",
4
+ "version": "2.2.0",
5
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.",
6
6
  "cliBackends": [
7
7
  "claw1",
@@ -47,6 +47,12 @@
47
47
  "type": "number",
48
48
  "description": "Hand over when any rate-limit window's utilization reaches this fraction. Default 0.85."
49
49
  },
50
+ "weeklyUtilizationThreshold": {
51
+ "type": "number",
52
+ "minimum": 0,
53
+ "maximum": 1,
54
+ "description": "Hand over when a day/week/month-scale quota window (such as seven_day) reaches this fraction. Defaults to utilizationThreshold. Minute/hour and overage windows keep using utilizationThreshold; overage rotation still requires rotateOnOverage."
55
+ },
50
56
  "staleAfterMs": {
51
57
  "type": "number",
52
58
  "description": "Ignore health data older than this. Default 21600000 (6h)."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/multi-clawd",
3
- "version": "2.1.2",
3
+ "version": "2.2.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. Also imports those accounts' setup tokens into Hermes Agent's Anthropic credential pool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,7 +61,7 @@
61
61
  "setup": "node scripts/setup.mjs",
62
62
  "sync-manifest": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('openclaw.plugin.json','utf8'));m.version=process.env.npm_package_version||JSON.parse(fs.readFileSync('package.json','utf8')).version;fs.writeFileSync('openclaw.plugin.json',JSON.stringify(m,null,2)+'\\n')\"",
63
63
  "sync-readme-tag": "node scripts/sync-readme-tag.mjs",
64
- "version": "npm run sync-manifest && npm run sync-readme-tag && git add openclaw.plugin.json README.md"
64
+ "version": "npm run sync-manifest && npm run sync-readme-tag && git add openclaw.plugin.json README.md package-lock.json"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "openclaw": ">=2026.8.1"
package/scripts/cli.mjs CHANGED
@@ -200,6 +200,7 @@ async function loadDirect(config) {
200
200
  const runner = run.createOpenclawRunner(directRoute.openclawCommand ?? "openclaw");
201
201
  const healthOptions = {
202
202
  utilizationThreshold: pool?.utilizationThreshold,
203
+ weeklyUtilizationThreshold: pool?.weeklyUtilizationThreshold,
203
204
  staleAfterMs: pool?.staleAfterMs,
204
205
  rotateOnOverage: pool?.rotateOnOverage,
205
206
  };
@@ -676,6 +677,7 @@ async function explain() {
676
677
  }
677
678
  const h = health.classifyAccountHealth(state, {
678
679
  utilizationThreshold: pool?.utilizationThreshold,
680
+ weeklyUtilizationThreshold: pool?.weeklyUtilizationThreshold,
679
681
  staleAfterMs: pool?.staleAfterMs,
680
682
  }, now);
681
683
  let detail = h.reason;
@@ -684,6 +686,7 @@ async function explain() {
684
686
  }
685
687
  const usage = health.summarizeWindowUsage(state, {
686
688
  utilizationThreshold: pool?.utilizationThreshold,
689
+ weeklyUtilizationThreshold: pool?.weeklyUtilizationThreshold,
687
690
  staleAfterMs: pool?.staleAfterMs,
688
691
  }, now);
689
692
  return { id: a.id, verdict: h.verdict, detail, usage };
@@ -742,11 +745,13 @@ async function usageCommand(args) {
742
745
  const pool = pc.pool ? { ...pc.pool, id: pc.pool.id?.trim() || "clawd", accounts: pc.pool.accounts ?? [] } : undefined;
743
746
  const healthOptions = {
744
747
  utilizationThreshold: pool?.utilizationThreshold,
748
+ weeklyUtilizationThreshold: pool?.weeklyUtilizationThreshold,
745
749
  staleAfterMs: pool?.staleAfterMs,
746
750
  rotateOnOverage: pool?.rotateOnOverage,
747
751
  };
748
752
  const warnThreshold = up.effectiveUsageWarnThreshold(pool?.usagePoll);
749
753
  const rotateAt = pool?.utilizationThreshold ?? 0.85;
754
+ const weeklyRotateAt = pool?.weeklyUtilizationThreshold;
750
755
  const stateDir = join(homedir(), ".openclaw", "state", "multi-clawd");
751
756
  const now = Date.now();
752
757
  const rows = [];
@@ -797,13 +802,17 @@ async function usageCommand(args) {
797
802
  const mins = Math.max(0, Math.round((resetsAt * 1000 - now) / 60000));
798
803
  return mins < 90 ? ` (resets in ~${mins}m)` : mins < 36 * 60 ? ` (resets in ~${Math.round(mins / 60)}h)` : ` (resets in ~${Math.round(mins / 1440)}d)`;
799
804
  };
800
- const mark = (u) => (u >= 1 ? "⛔" : u >= warnThreshold ? "🔴" : u >= rotateAt ? "🟠" : "🟢");
801
- console.log(`\n${BOLD}🦞 multi-clawd — live usage${RESET} ${DIM}(rotate at ${pct(rotateAt)}, warn at ${pct(warnThreshold)})${RESET}\n`);
805
+ const mark = (u, window) => {
806
+ const weekly = window && health.isLongQuotaWindow(window);
807
+ const threshold = weekly ? weeklyRotateAt ?? rotateAt : rotateAt;
808
+ return u >= 1 ? "⛔" : u >= warnThreshold ? "🔴" : u >= threshold ? "🟠" : "🟢";
809
+ };
810
+ console.log(`\n${BOLD}🦞 multi-clawd — live usage${RESET} ${DIM}(rotate at ${pct(rotateAt)}${weeklyRotateAt !== undefined ? `, weekly ${pct(weeklyRotateAt)}` : ""}, warn at ${pct(warnThreshold)})${RESET}\n`);
802
811
  for (const r of rows) {
803
812
  console.log(` ${BOLD}${r.id}${RESET}${r.label ? ` ${DIM}— ${r.label}${RESET}` : ""} pool verdict: ${r.verdict}${r.reason ? ` ${DIM}(${r.reason})${RESET}` : ""}`);
804
813
  if (r.snapshot) {
805
814
  for (const [w, u] of Object.entries(r.snapshot.windows)) {
806
- console.log(` ${mark(u.utilization)} ${up.usageWindowLabel(w).padEnd(12)} ${pct(u.utilization).padStart(4)}${until(u.resetsAt)}`);
815
+ console.log(` ${mark(u.utilization, w)} ${up.usageWindowLabel(w).padEnd(12)} ${pct(u.utilization).padStart(4)}${until(u.resetsAt)}`);
807
816
  }
808
817
  for (const s of r.snapshot.scoped) {
809
818
  console.log(` ${mark(s.utilization)} ${`${s.label} model`.padEnd(12)} ${pct(s.utilization).padStart(4)}${until(s.resetsAt)}`);
@@ -301,6 +301,7 @@ const { summarizeWindowUsage, classifyAccountHealth, overdueProbeAccount, lastAu
301
301
  "overdueProbeAccount",
302
302
  "lastAuthSuccessAt",
303
303
  ]);
304
+ const { DEFAULT_UTILIZATION_THRESHOLD } = await import(join(REPO_DIR, "dist", "health.js"));
304
305
  // "cli", like the chain audits: resolving WHICH login backs an account is
305
306
  // doctor's own diagnostic, not a description of the running plugin's
306
307
  // behaviour. Asking the installed copy meant a fix to the resolver could not
@@ -495,6 +496,9 @@ else {
495
496
  const members = (pool.accounts ?? []).filter((id) => accounts.some((a) => a.id === id));
496
497
  if (members.length < 2) bad(`pool "${pool.id ?? "clawd"}" has ${members.length} valid member(s); needs ≥ 2`);
497
498
  else ok(`pool "${pool.id ?? "clawd"}": ${members.join(" → ")}`);
499
+ const generalThreshold = pool.utilizationThreshold ?? DEFAULT_UTILIZATION_THRESHOLD;
500
+ const weeklyThreshold = pool.weeklyUtilizationThreshold ?? generalThreshold;
501
+ note(`thresholds: general ${Math.round(generalThreshold * 100)}%, weekly ${Math.round(weeklyThreshold * 100)}%${pool.weeklyUtilizationThreshold === undefined ? " (follows general)" : ""}`);
498
502
  // Setup-token members: what they do and do not get, said once, plainly.
499
503
  const tokenMembers = members
500
504
  .map((id) => accounts.find((a) => a.id === id))
@@ -529,6 +533,7 @@ else {
529
533
  const healthOptions = {
530
534
  staleAfterMs: pool.staleAfterMs,
531
535
  utilizationThreshold: pool.utilizationThreshold,
536
+ weeklyUtilizationThreshold: pool.weeklyUtilizationThreshold,
532
537
  rotateOnOverage: pool.rotateOnOverage,
533
538
  };
534
539
  const verdicts = members.map((id) => ({
@@ -876,6 +881,7 @@ if (accounts.some((a) => a && a.direct !== undefined && a.direct !== false)) {
876
881
  readHealth,
877
882
  healthOptions: {
878
883
  utilizationThreshold: pool?.utilizationThreshold,
884
+ weeklyUtilizationThreshold: pool?.weeklyUtilizationThreshold,
879
885
  staleAfterMs: pool?.staleAfterMs,
880
886
  rotateOnOverage: pool?.rotateOnOverage,
881
887
  },