@drakon-systems/multi-clawd 1.9.8 → 2.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/README.md CHANGED
@@ -223,6 +223,12 @@ claude-cli/claude-fable-5 # main login
223
223
  stream-json and records per-window health to
224
224
  `~/.openclaw/state/multi-clawd/<account>.json`. Passthrough-first: a state
225
225
  write can never break a live turn.
226
+ - 📡 **Live usage (v1.10)** — the pool also *asks* the provider what is left,
227
+ on a timer, using the login each account already holds. Both windows come
228
+ back as real percentages with reset times — including the 5-hour window the
229
+ stream never puts a number on — so hand-over happens at 85% of the session
230
+ window, not at the error. Passing 95% raises an operator alert through the
231
+ heartbeat; `multi-clawd usage` shows it on demand.
226
232
  - 🧰 **Full harness on every hop** — each backend is a genuine Claude Code
227
233
  subprocess: native tools, skills, MCP bridge, and native compaction all
228
234
  stay intact when failover steps across accounts.
@@ -247,7 +253,8 @@ claude-cli/claude-fable-5 # main login
247
253
  your chain: the refusal is swallowed before anything reaches you and the turn
248
254
  is re-spawned on a healthy account. Covers ongoing conversations too: the
249
255
  session is handed to the sibling as it stood before the refused attempt.
250
- Secret-free sibling accounts, one retry.
256
+ One retry. Works for config-dir, native, and setup-token-file accounts; the
257
+ shim is handed paths only and reads a token file at the moment it retries.
251
258
  - 📟 **Operator alerts (v0.3)** — dead logins (probed every 15 min without
252
259
  spending quota), pool rotations, whole-pool exhaustion, and watchdog
253
260
  restarts surface through your agent's next heartbeat (e.g. straight into
@@ -255,8 +262,10 @@ claude-cli/claude-fable-5 # main login
255
262
  - 🩺 **`npm run doctor` (v0.3)** — one command that says whether a box is
256
263
  actually ready: config/manifest agreement (with the exact `--force`
257
264
  preflight strip plan), dist freshness, CLI presence, credential health
258
- (values never printed), **which Claude login each account authenticates as
259
- and which one the next turn will run on**, telemetry age, pool + sticky
265
+ judged on evidence — an unexpired token or a successful turn, never mere
266
+ presence (values never printed), a **credential split-store** warning when a
267
+ login's OAuth credential exists in more than one place, **which Claude login
268
+ each account authenticates as and which one the next turn will run on**, telemetry age, pool + sticky
260
269
  state, **whether every non-Claude chain rung still has a usable auth
261
270
  profile**, watchdog presence, optional `--probe` (one turn per account).
262
271
  - 🧯 **Self-healing config** — registration re-reads the resolved runtime
@@ -376,7 +385,7 @@ openclaw plugins install (Get-Location).Path
376
385
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
377
386
  Code on the target machine already? Paste it this and go make coffee:
378
387
 
379
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.9.8/SETUP-AGENT.md
388
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.0.0/SETUP-AGENT.md
380
389
  > and follow it to set up multi-clawd on this machine. I own a second
381
390
  > Claude account — ask me when you need me to log in.
382
391
 
@@ -456,10 +465,17 @@ dir* — a separate Claude "app" — so the logins can never clobber each other.
456
465
 
457
466
  ```bash
458
467
  mkdir -p ~/.claw2 && chmod 700 ~/.claw2
459
- CLAUDE_CONFIG_DIR=~/.claw2 claude setup-token > ~/.claw2/oauth-token # log in as the 2nd account
460
- chmod 600 ~/.claw2/oauth-token
468
+ CLAUDE_CONFIG_DIR=~/.claw2 claude setup-token # log in as the 2nd account; it prints the token
469
+ install -m 600 /dev/null ~/.claw2/oauth-token
470
+ nano ~/.claw2/oauth-token # paste ONLY the printed sk-ant-oat01-… token, on one line; save
461
471
  ```
462
472
 
473
+ Do not redirect `claude setup-token > file`: that captures the whole
474
+ screen (sign-in prompts and instructions) around the token. multi-clawd
475
+ refuses a token file holding anything but the single token line — the
476
+ account is skipped with an alert naming the file, and `multi-clawd doctor`
477
+ says exactly what to fix — rather than guess which part is the token.
478
+
463
479
  **Windows (native, PowerShell):**
464
480
 
465
481
  ```powershell
@@ -574,9 +590,11 @@ How it decides, per launch (all data from each account's live
574
590
  | no data / stale data | used — never rotate on missing evidence |
575
591
  | whole pool exhausted | home account anyway → real limit error → your chain drops provider |
576
592
 
577
- **Why the short-window rule exists (v1.7.2).** The threshold rule needs a
578
- utilization number, and Anthropic does not send one for the 5-hour session
579
- window — across every observation we hold from two accounts it arrives as a
593
+ **Why the short-window rule exists (v1.7.2).** (v1.10's live usage poll now
594
+ supplies the missing number — see below — so this rule is the fallback for
595
+ hosts where polling is off or the account cannot be polled.) The threshold
596
+ rule needs a utilization number, and Anthropic does not send one for the
597
+ 5-hour session window — across every observation we hold from two accounts it arrives as a
580
598
  bare status plus a reset time, while the weekly windows carry percentages. A
581
599
  rule that waits for a number therefore could never pre-empt the session
582
600
  limit: the pool would take the hit and rotate afterwards. So on hour-scoped
@@ -596,6 +614,89 @@ Notes:
596
614
  which runs on every launch on every turn path. Details in
597
615
  [`DESIGN.md`](./DESIGN.md).
598
616
 
617
+ ## Live usage: ask, don't wait to be told (v1.10)
618
+
619
+ Everything above learns about quota from the stream — the `rate_limit_event`
620
+ records the CLI emits at the top of a turn. That is reactive by construction:
621
+ nothing is observed between turns, and the 5-hour session window arrives with
622
+ a status but no percentage, so the pool could see "warning" but never "how
623
+ close". v1.10 closes both gaps by reading each pooled account's **live usage
624
+ from the provider** on a timer — the same figures the Claude CLI's own
625
+ `/usage` command shows — with the OAuth session the CLI already holds.
626
+
627
+ ```jsonc
628
+ "pool": {
629
+ "id": "clawd",
630
+ "accounts": ["claw1", "claw2"],
631
+ "utilizationThreshold": 0.85, // hand over here (unchanged)
632
+ "usagePoll": { // v1.10 — on by default
633
+ "intervalMs": 120000, // every 2 min (minimum 60000)
634
+ "warnThreshold": 0.95 // operator alert from here
635
+ }
636
+ }
637
+ ```
638
+
639
+ What a poll does, per account:
640
+
641
+ | Provider says | Health file gets | Pool does |
642
+ |---|---|---|
643
+ | 5-hour at 40%, weekly at 20% | `usage:five_hour` / `usage:seven_day`, `allowed` with the number and reset | nothing — home account serves |
644
+ | 5-hour at 86% | the same, `utilization 0.86` | **next launch rotates** to the spare (threshold rule, now with a real number on the session window) |
645
+ | 5-hour at 96% | the same | rotates, **and raises an alert**: account, window, reset time, where launches now go |
646
+ | 5-hour at 100% | `rejected` with the provider's reset | `exhausted` until that reset — for every model, before any turn is refused |
647
+ | every account past 95% | — | **pool-wide alert**: at 100% the ladder or the host's chain takes over; soonest reset named |
648
+ | model-scoped limit (e.g. one family at 97%) | — | alert naming the model; the shim's reactive capture still handles the refusal |
649
+
650
+ Polled records live under their own keys (`usage:<window>`) so they never race
651
+ the shim's `five_hour` / `seven_day` for the same slot — the shim's next bare
652
+ `allowed` cannot erase the number the poll just wrote, and `explain` shows
653
+ both ("5-hour (live) 40%"). The health rules are the existing ones; the poll
654
+ only improves what they can see.
655
+
656
+ Boundaries:
657
+
658
+ - **Read-only on credentials.** The poll reads the access token from the
659
+ account's `.credentials.json` and sends it to the usage endpoint — nowhere
660
+ else. It never refreshes, rewrites, or copies a token; an expired one is a
661
+ skipped tick (the next CLI launch refreshes it, as it always has).
662
+ - **Best-effort.** A failed poll changes nothing: the health file keeps what
663
+ the shim wrote, selection keeps working from it, and the failure is logged
664
+ once per transition, not per tick.
665
+ - **Native and `configDir` logins only — not setup-token accounts.** A
666
+ `claude setup-token` token carries only the `user:inference` scope, and the
667
+ usage endpoint requires `user:profile`: it answers `403
668
+ oauth_scope_insufficient` (checked against the live endpoint; upstream
669
+ anthropics/claude-code#11985). Reading a credentials file from the
670
+ account's dir instead would read a different login than the one it runs
671
+ on. So token accounts (`oauthTokenFile` / `oauthTokenRef`) are not polled,
672
+ and `doctor`, `usage` and the gateway log say so. What that means in
673
+ practice for a pool of token accounts:
674
+ - **Still works:** rotation from stream telemetry — every turn's
675
+ `rate_limit_event` feeds the same threshold rule, the numberless 5-hour
676
+ warning rule (v1.7.2), and rejection-with-reset handling in the table
677
+ above; and the in-turn retry still catches a limit that lands mid-turn.
678
+ - **Missing:** an idle account is not re-measured between turns, the
679
+ 5-hour window usually has no percentage (only a warning status), and
680
+ there is no 95% usage alert.
681
+ macOS native logins kept in the keychain (no `.credentials.json`) are
682
+ likewise not polled.
683
+ - **Off switch:** `"usagePoll": { "enabled": false }`.
684
+
685
+ On demand:
686
+
687
+ ```
688
+ multi-clawd usage # live figures per account + what the next launch would do
689
+ multi-clawd usage --json
690
+ ```
691
+
692
+ **What "seamless" means here.** Rotation is decided per launch, so a turn that
693
+ starts after the poll has seen 85% simply runs on the other account — the user
694
+ sees nothing. A turn already in flight when its account runs out is covered by
695
+ the shim's in-turn retry (v1.9): the refusal is held back, the sibling is
696
+ spawned, the conversation is handed over, and the turn completes there. The
697
+ poll's job is to make that second path rare by moving work off an account
698
+ before it reaches the cliff, and to tell you when every account is nearing one.
699
+
599
700
  ## Direct route: the pool for `anthropic/*` too (v1.9)
600
701
 
601
702
  OpenClaw reaches Claude two ways. The pool above drives **Claude Code CLI**
@@ -750,14 +851,17 @@ Housekeeping:
750
851
  - Prefer a secret reference (`oauthTokenRef`, v0.3) over a plaintext
751
852
  file; when a file is used, keep it `0600` (POSIX) or locked to your user
752
853
  with `icacls` (Windows).
753
- - **Credential resolution fails closed (v1.7.3).** An account that declares
754
- `oauthTokenRef` or `oauthTokenFile` is authenticated by that token. If it
755
- resolves to nothing — provider briefly unavailable, empty secret, truncated
756
- file — and the account has no `configDir` to fall back on, the launch is
757
- refused with `declares a token source but none resolved` rather than
758
- allowed to proceed on the machine's default login and spend a different
759
- account's quota under this account's name. Native accounts are exempt: the
760
- default login *is* their credential.
854
+ - **Credential resolution fails closed (v1.7.3; configDir no longer an
855
+ exception).** An account that declares `oauthTokenRef` or `oauthTokenFile`
856
+ is authenticated by that token and nothing else. If it resolves to nothing
857
+ — provider briefly unavailable, empty secret, truncated file, or a file
858
+ holding anything besides the single token line — the launch on that
859
+ account is refused with `declares a token source but none resolved`, even
860
+ when the account also has a `configDir` (whatever login sits in that dir
861
+ is not the credential you declared). In a pool the account is skipped, the
862
+ turn runs on a sibling, and an operator alert names the account and why;
863
+ only when no member resolves does the launch fail. Native accounts are
864
+ exempt: the default login *is* their credential.
761
865
  - Migrating a token file into a vault? `op read` (and most secret CLIs)
762
866
  append a trailing newline on output — resolution trims the resolved
763
867
  value (guaranteed in `token-resolution.ts`), so a file-vs-vault diff
@@ -7,6 +7,18 @@ export function expandHomePath(p) {
7
7
  return resolve(homedir(), p.slice(2));
8
8
  return resolve(p);
9
9
  }
10
+ export class AccountCredentialError extends Error {
11
+ name = "AccountCredentialError";
12
+ }
13
+ export function credentialFailureText(err) {
14
+ if (err instanceof AccountCredentialError)
15
+ return err.message.replace(/^\[multi-clawd\] /, "");
16
+ const code = err?.code;
17
+ if (typeof code === "string" && /^E[A-Z]+$/.test(code))
18
+ return `credential file unreadable (${code})`;
19
+ const cls = err instanceof Error ? err.constructor.name : typeof err;
20
+ return `credential resolution failed (${cls})`;
21
+ }
10
22
  export function buildAccountChildEnv(account, token, stateFile) {
11
23
  const env = {
12
24
  MULTI_CLAWD_ACCOUNT_ID: account.id,
@@ -19,12 +31,38 @@ export function buildAccountChildEnv(account, token, stateFile) {
19
31
  env.CLAUDE_CONFIG_DIR = expandHomePath(account.configDir);
20
32
  }
21
33
  const declaresToken = Boolean(account.oauthTokenFile) || Boolean(account.oauthTokenRef);
22
- if (!account.native && declaresToken && !usableToken && !account.configDir) {
23
- throw new Error(`[multi-clawd] account "${account.id}" declares a token source but none resolved — ` +
24
- `refusing to launch on the default login`);
34
+ if (!account.native && declaresToken && !usableToken) {
35
+ throw new AccountCredentialError(`[multi-clawd] account "${account.id}" declares a token source but none resolved — ` +
36
+ `refusing to launch it on any other login`);
25
37
  }
26
38
  return env;
27
39
  }
40
+ const TOKEN_LINE_RE = /^sk-ant-oat[0-9]{2,}-[A-Za-z0-9_-]+$/;
41
+ const TOKEN_ANYWHERE_RE = /sk-ant-oat[0-9]+-/g;
42
+ export function parseSetupTokenFile(raw, label = "token file") {
43
+ const lines = raw
44
+ .split(/\r?\n/)
45
+ .map((l) => l.trim())
46
+ .filter((l) => l.length > 0);
47
+ if (lines.length === 0)
48
+ return { error: `${label} is empty` };
49
+ if (lines.length === 1 && TOKEN_LINE_RE.test(lines[0]))
50
+ return { token: lines[0] };
51
+ const runs = lines.reduce((n, l) => n + (l.match(TOKEN_ANYWHERE_RE)?.length ?? 0), 0);
52
+ if (runs === 0) {
53
+ return {
54
+ error: `${label} does not contain a setup-token (expected one line: sk-ant-oat01- then letters, ` +
55
+ `digits, - or _)` +
56
+ (lines.length > 1 ? `; it has ${lines.length} lines of other text` : ""),
57
+ };
58
+ }
59
+ return {
60
+ error: `${label} has text besides the token (${lines.length} non-empty line(s), ${runs} token-like) — ` +
61
+ `it must hold ONLY the token. This is what \`claude setup-token > file\` produces: it captures the on-screen ` +
62
+ `instructions too. Edit the file down to the single sk-ant-oat01-… line (check it is not ` +
63
+ `wrapped across two lines), keep it chmod 600, and nothing else is needed.`,
64
+ };
65
+ }
28
66
  export function tokenFileModeWarning(path, mode) {
29
67
  const perms = mode & 0o777;
30
68
  if ((perms & 0o077) === 0)
@@ -1,3 +1,8 @@
1
+ export function nativeDefaultConfigDir(env, homeDir) {
2
+ const dir = `${homeDir.replace(/\/+$/, "")}/.claude`;
3
+ const inherited = env.CLAUDE_CONFIG_DIR?.trim();
4
+ return inherited ? { dir, ignored: inherited } : { dir };
5
+ }
1
6
  export function identityFilePaths(account, io) {
2
7
  if (account.oauthTokenFile || account.oauthTokenRef)
3
8
  return [];
@@ -5,6 +5,9 @@ import { parseStoredState, mergeHealthStates, clearCredentialFailure, } from "./
5
5
  export function healthStateFile(accountId) {
6
6
  return join(homedir(), ".openclaw", "state", "multi-clawd", `${accountId}.json`);
7
7
  }
8
+ export function usageStateFile(accountId) {
9
+ return join(homedir(), ".openclaw", "state", "multi-clawd", `${accountId}.usage.json`);
10
+ }
8
11
  export function clearAccountCredentialFailure(accountId) {
9
12
  const file = healthStateFile(accountId);
10
13
  let state;
@@ -0,0 +1,222 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir, userInfo } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { CLAUDE_KEYCHAIN_SERVICE, claudeKeychainAccount, keychainServiceForConfigDir, parseOauthCredential, } from "./login-health.js";
6
+ export function parseKeychainDump(text) {
7
+ const items = [];
8
+ for (const block of text.split(/^keychain: /m)) {
9
+ if (!/^class: "genp"/m.test(block))
10
+ continue;
11
+ const attr = (name) => {
12
+ const m = block.match(new RegExp(`^\\s*"${name}"<[a-z]+>=(.*)$`, "m"));
13
+ if (!m)
14
+ return undefined;
15
+ const value = m[1].trim();
16
+ if (value === "<NULL>")
17
+ return undefined;
18
+ const hex = value.match(/^0x([0-9A-Fa-f]+)/);
19
+ if (hex)
20
+ return Buffer.from(hex[1], "hex").toString("utf8").replace(/\0+$/, "");
21
+ const quoted = value.match(/^"(.*)"$/);
22
+ return quoted ? quoted[1] : value;
23
+ };
24
+ const service = attr("svce");
25
+ if (service === undefined)
26
+ continue;
27
+ const mdat = attr("mdat")?.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z/);
28
+ items.push({
29
+ service,
30
+ account: attr("acct") ?? "",
31
+ modifiedAt: mdat
32
+ ? Date.UTC(+mdat[1], +mdat[2] - 1, +mdat[3], +mdat[4], +mdat[5], +mdat[6])
33
+ : undefined,
34
+ });
35
+ }
36
+ return items;
37
+ }
38
+ export function credentialLocations(account, io) {
39
+ if (account.oauthTokenFile || account.oauthTokenRef)
40
+ return undefined;
41
+ if (account.native) {
42
+ return { service: CLAUDE_KEYCHAIN_SERVICE, file: io.expandHome("~/.claude/.credentials.json") };
43
+ }
44
+ if (!account.configDir)
45
+ return undefined;
46
+ const dir = io.expandHome(account.configDir).replace(/\/+$/, "");
47
+ return { service: keychainServiceForConfigDir(dir), file: `${dir}/.credentials.json` };
48
+ }
49
+ export function findCredentialCopies(account, io) {
50
+ const where = credentialLocations(account, io);
51
+ if (!where)
52
+ return { accountId: account.id, status: "n/a", copies: [], activeKnown: true };
53
+ const copies = [];
54
+ let cliItem = "absent";
55
+ let incomplete = false;
56
+ if (io.platform === "darwin") {
57
+ const listed = io.listKeychainItems();
58
+ incomplete = listed === undefined;
59
+ const items = (listed ?? []).filter((i) => i.service === where.service);
60
+ if (!items.some((i) => i.account === io.keychainAccount)) {
61
+ items.unshift({ service: where.service, account: io.keychainAccount });
62
+ }
63
+ for (const item of items) {
64
+ const read = io.readKeychainItem(where.service, item.account);
65
+ const isCli = item.account === io.keychainAccount;
66
+ if (isCli)
67
+ cliItem = read.status;
68
+ if (read.status === "absent")
69
+ continue;
70
+ const meta = read.status === "found" ? parseOauthCredential(read.data) : undefined;
71
+ copies.push({
72
+ kind: "keychain",
73
+ location: `keychain "${where.service}" acct=${item.account || "(empty)"}`,
74
+ keychainAccount: item.account,
75
+ expiresAt: meta?.expiresAt,
76
+ unreadable: read.status === "unreadable"
77
+ ? "could not be read (Keychain locked, access refused or timed out)"
78
+ : meta
79
+ ? undefined
80
+ : "not a Claude OAuth credential",
81
+ active: isCli && read.status === "found",
82
+ modifiedAt: item.modifiedAt,
83
+ });
84
+ }
85
+ }
86
+ let fileRaw;
87
+ let fileUnreadable = false;
88
+ try {
89
+ fileRaw = io.readFile(where.file);
90
+ }
91
+ catch (err) {
92
+ const code = err?.code;
93
+ fileUnreadable = code !== "ENOENT" && code !== "ENOTDIR";
94
+ }
95
+ if (fileRaw !== undefined || fileUnreadable) {
96
+ const meta = fileRaw === undefined ? undefined : parseOauthCredential(fileRaw);
97
+ copies.push({
98
+ kind: "file",
99
+ location: where.file,
100
+ expiresAt: meta?.expiresAt,
101
+ unreadable: fileUnreadable
102
+ ? "could not be read"
103
+ : meta
104
+ ? undefined
105
+ : "not a Claude OAuth credential",
106
+ active: cliItem === "absent",
107
+ });
108
+ }
109
+ return {
110
+ accountId: account.id,
111
+ status: copies.length > 1 ? "split" : copies.length === 1 ? "single" : "none",
112
+ copies,
113
+ activeKnown: cliItem !== "unreadable",
114
+ incomplete: incomplete || undefined,
115
+ service: where.service,
116
+ cliAccount: io.platform === "darwin" ? io.keychainAccount : undefined,
117
+ file: where.file,
118
+ };
119
+ }
120
+ function shellQuote(s) {
121
+ return `'${s.replace(/'/g, `'\\''`)}'`;
122
+ }
123
+ export function describeSplitStore(report, nowMs) {
124
+ const fmt = (c) => {
125
+ const expiry = c.expiresAt !== undefined
126
+ ? `${c.expiresAt > nowMs ? "expires" : "expired"} ${new Date(c.expiresAt).toISOString()}`
127
+ : (c.unreadable ?? "no expiry recorded");
128
+ const modified = c.modifiedAt !== undefined ? `, last written ${new Date(c.modifiedAt).toISOString()}` : "";
129
+ return `${c.location} — ${expiry}${modified}${c.active ? " ← the copy the Claude CLI reads" : ""}`;
130
+ };
131
+ const active = report.copies.find((c) => c.active);
132
+ const latest = Math.max(...report.copies.map((c) => c.expiresAt ?? Number.NEGATIVE_INFINITY));
133
+ const newerElsewhere = active?.expiresAt !== undefined &&
134
+ Number.isFinite(latest) &&
135
+ latest > active.expiresAt;
136
+ const headline = `${report.accountId}: ${report.copies.length} copies of this login's OAuth credential — ` +
137
+ `refresh tokens rotate, so once the copies diverge only the one refreshed last can still ` +
138
+ `refresh, and a tool that reads another sees a login that is not the one in use` +
139
+ (!report.activeKnown
140
+ ? "; the Claude CLI's own Keychain item could not be read, so which copy it uses is unknown"
141
+ : newerElsewhere
142
+ ? "; a NEWER copy exists than the one the Claude CLI reads"
143
+ : "");
144
+ if (!report.activeKnown) {
145
+ return {
146
+ headline,
147
+ copies: report.copies.map(fmt),
148
+ remedy: `unlock the Keychain (or re-authenticate with \`multi-clawd login ${report.accountId}\`), ` +
149
+ `confirm with \`multi-clawd doctor --probe\`, then re-run doctor for cleanup steps. ` +
150
+ `Remove nothing until doctor can tell which copy the Claude CLI reads.`,
151
+ };
152
+ }
153
+ const strays = report.copies.filter((c) => !c.active && !(c.kind === "keychain" && c.keychainAccount === report.cliAccount));
154
+ const files = strays.filter((c) => c.kind === "file");
155
+ const items = strays.filter((c) => c.kind === "keychain");
156
+ const steps = [
157
+ `first prove the copy the Claude CLI reads with \`multi-clawd doctor --probe\` ` +
158
+ `(if it fails, re-authenticate with \`multi-clawd login ${report.accountId}\` and probe again)`,
159
+ ];
160
+ if (files.length > 0) {
161
+ steps.push(`after a passing probe, move ${files.map((c) => shellQuote(c.location)).join(" and ")} aside ` +
162
+ `(e.g. rename to .credentials.json.stale — reversible)`);
163
+ }
164
+ if (items.length > 0) {
165
+ steps.push(`delete a stray Keychain item only after a fresh \`multi-clawd login ${report.accountId}\` ` +
166
+ `and a passing probe (a probe can pass on an unexpired access token while its refresh ` +
167
+ `token is already dead): ` +
168
+ items
169
+ .map((c) => `\`security delete-generic-password -s ${shellQuote(report.service ?? "")} -a ${shellQuote(c.keychainAccount ?? "")}\``)
170
+ .join(", ") +
171
+ ` (irreversible)`);
172
+ }
173
+ const remedy = `${steps.join("; ")}. Never remove the copy the Claude CLI reads: another copy may hold ` +
174
+ `the only live refresh token until the CLI's own copy is proven.`;
175
+ return { headline, copies: report.copies.map(fmt), remedy };
176
+ }
177
+ export function expandHomeDir(p) {
178
+ if (p === "~")
179
+ return homedir();
180
+ if (p.startsWith("~/"))
181
+ return join(homedir(), p.slice(2));
182
+ return resolve(p);
183
+ }
184
+ const SECURITY_TIMEOUT_MS = 10_000;
185
+ const SECURITY_ITEM_NOT_FOUND = 44;
186
+ export function createSystemCredentialIo(deps = {}) {
187
+ const exec = deps.exec ?? ((file, args, options) => execFileSync(file, args, options));
188
+ return {
189
+ readFile: (p) => readFileSync(expandHomeDir(p), "utf8"),
190
+ expandHome: expandHomeDir,
191
+ keychainAccount: claudeKeychainAccount(deps.env ?? process.env, userInfo),
192
+ platform: deps.platform ?? process.platform,
193
+ readKeychainItem: (service, account) => {
194
+ try {
195
+ const data = exec("security", ["find-generic-password", "-s", service, "-a", account, "-w"], {
196
+ encoding: "utf8",
197
+ stdio: ["ignore", "pipe", "ignore"],
198
+ timeout: SECURITY_TIMEOUT_MS,
199
+ });
200
+ return { status: "found", data: data.replace(/\n$/, "") };
201
+ }
202
+ catch (err) {
203
+ const status = err?.status;
204
+ return status === SECURITY_ITEM_NOT_FOUND ? { status: "absent" } : { status: "unreadable" };
205
+ }
206
+ },
207
+ listKeychainItems: () => {
208
+ try {
209
+ const out = exec("security", ["dump-keychain"], {
210
+ encoding: "utf8",
211
+ stdio: ["ignore", "pipe", "ignore"],
212
+ timeout: SECURITY_TIMEOUT_MS * 3,
213
+ maxBuffer: 64 * 1024 * 1024,
214
+ });
215
+ return parseKeychainDump(out);
216
+ }
217
+ catch {
218
+ return undefined;
219
+ }
220
+ },
221
+ };
222
+ }
@@ -124,7 +124,8 @@ const VERDICT_RANK = {
124
124
  no_data: 0,
125
125
  near_limit: 1,
126
126
  exhausted: 2,
127
- credential_failed: 3,
127
+ credential_unverified: 3,
128
+ credential_failed: 4,
128
129
  };
129
130
  export function planDirectOrder(params) {
130
131
  const { members } = params;
@@ -2,6 +2,8 @@ const WINDOW_LABELS = {
2
2
  five_hour: "5-hour",
3
3
  seven_day: "weekly",
4
4
  seven_day_overage_included: "weekly incl. overage",
5
+ "usage:five_hour": "5-hour (live)",
6
+ "usage:seven_day": "weekly (live)",
5
7
  };
6
8
  export function relativeUntil(ms, nowMs) {
7
9
  const mins = Math.max(0, Math.round((ms - nowMs) / 60000));
@@ -71,6 +73,7 @@ const VERDICT_WORDS = {
71
73
  near_limit: "NEAR ITS LIMIT — the pool will hand over before it hard-fails",
72
74
  exhausted: "EXHAUSTED",
73
75
  credential_failed: "LOGIN REJECTED — excluded from the pool until it is re-authenticated",
76
+ credential_unverified: "LOGIN UNPROVEN — last rejected and not proven since; used only as a re-test when nothing proven can serve",
74
77
  };
75
78
  export function renderExplanation(model) {
76
79
  const lines = [];
package/dist/health.js CHANGED
@@ -43,14 +43,36 @@ export function credentialFailureFor(state, nowMs) {
43
43
  const credential = state?.credential;
44
44
  if (!credential || credential.status !== "failed")
45
45
  return undefined;
46
- if (nowMs - credential.seenAt > CREDENTIAL_FAILED_TTL_MS)
47
- return undefined;
46
+ const age = Math.max(0, nowMs - credential.seenAt);
47
+ const ageText = age < 90 * 60000 ? `${Math.round(age / 60000)}m` : `${Math.round(age / 3600000)}h`;
48
+ const detail = credential.reason ? `: ${credential.reason}` : "";
49
+ if (age <= CREDENTIAL_FAILED_TTL_MS) {
50
+ return {
51
+ verdict: "credential_failed",
52
+ resumeAt: credential.seenAt + CREDENTIAL_FAILED_TTL_MS,
53
+ reason: `login rejected by the Claude CLI ${ageText} ago${detail} — re-authenticate this account`,
54
+ };
55
+ }
48
56
  return {
49
- verdict: "credential_failed",
50
- resumeAt: credential.seenAt + CREDENTIAL_FAILED_TTL_MS,
51
- reason: `login rejected by the Claude CLI ${Math.round((nowMs - credential.seenAt) / 60000)}m ago${credential.reason ? `: ${credential.reason}` : ""} — re-authenticate this account`,
57
+ verdict: "credential_unverified",
58
+ observedAt: credential.seenAt,
59
+ reason: `login rejected by the Claude CLI ${ageText} ago${detail} and not proven since — ` +
60
+ `re-authenticate, or prove it with \`multi-clawd doctor --probe\``,
52
61
  };
53
62
  }
63
+ const POLL_WINDOW_PREFIX = "usage:";
64
+ export function lastAuthSuccessAt(state) {
65
+ if (!state || state.credential?.status === "failed")
66
+ return undefined;
67
+ let latest = state.credential?.status === "ok" ? state.credential.seenAt : undefined;
68
+ for (const [key, w] of Object.entries(state.windows ?? {})) {
69
+ if (key.startsWith(POLL_WINDOW_PREFIX))
70
+ continue;
71
+ if (typeof w.seenAt === "number" && (latest === undefined || w.seenAt > latest))
72
+ latest = w.seenAt;
73
+ }
74
+ return latest;
75
+ }
54
76
  export function classifyAccountHealth(state, options, nowMs, requestedModel) {
55
77
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
56
78
  const threshold = options.utilizationThreshold ?? DEFAULT_UTILIZATION_THRESHOLD;
@@ -219,6 +241,9 @@ export function fallbackPoolAccount(pool, nowMs) {
219
241
  const probe = nowMs === undefined ? undefined : overdueProbeAccount(pool, nowMs);
220
242
  if (probe)
221
243
  return probe;
244
+ const retest = pool.find((a) => a.verdict === "credential_unverified");
245
+ if (retest)
246
+ return retest.id;
222
247
  return (pool.find((a) => a.verdict !== "credential_failed") ?? pool[0]).id;
223
248
  }
224
249
  export function pickPoolAccountForLaunch(pool, nowMs) {