@drakon-systems/multi-clawd 1.9.6 → 1.9.8

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
@@ -245,8 +245,9 @@ claude-cli/claude-fable-5 # main login
245
245
  - 🔁 **In-turn retry on a reactive limit** — when an account hits a model cap
246
246
  *during* a launch, the pool no longer loses that turn to the next provider in
247
247
  your chain: the refusal is swallowed before anything reaches you and the turn
248
- is re-spawned on a healthy account. Fresh launches, secret-free sibling
249
- accounts, one retry.
248
+ is re-spawned on a healthy account. Covers ongoing conversations too: the
249
+ session is handed to the sibling as it stood before the refused attempt.
250
+ Secret-free sibling accounts, one retry.
250
251
  - 📟 **Operator alerts (v0.3)** — dead logins (probed every 15 min without
251
252
  spending quota), pool rotations, whole-pool exhaustion, and watchdog
252
253
  restarts surface through your agent's next heartbeat (e.g. straight into
@@ -375,7 +376,7 @@ openclaw plugins install (Get-Location).Path
375
376
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
376
377
  Code on the target machine already? Paste it this and go make coffee:
377
378
 
378
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.9.6/SETUP-AGENT.md
379
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.9.8/SETUP-AGENT.md
379
380
  > and follow it to set up multi-clawd on this machine. I own a second
380
381
  > Claude account — ask me when you need me to log in.
381
382
 
package/dist/health.js CHANGED
@@ -1,4 +1,4 @@
1
- import { modelWindowKey } from "./shim-core.js";
1
+ import { modelWindowApplies, modelWindowKey } from "./shim-core.js";
2
2
  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;
@@ -78,8 +78,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
78
78
  if (!modelFresh)
79
79
  continue;
80
80
  hasLiveEvidence = true;
81
- const canonicalWindow = modelWindowKey(window.slice(MODEL_WINDOW_PREFIX.length));
82
- if (!requestedWindowKey || canonicalWindow !== requestedWindowKey)
81
+ if (requestedModel === undefined || !modelWindowApplies(window, w, requestedModel))
83
82
  continue;
84
83
  if (w.status !== "rejected")
85
84
  continue;
@@ -88,6 +87,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
88
87
  return {
89
88
  verdict: "exhausted",
90
89
  resumeAt: resetMs,
90
+ observedAt: w.seenAt,
91
91
  reason: `${requestedModel} limit rejected until ${new Date(resetMs).toISOString()}`,
92
92
  };
93
93
  }
@@ -96,6 +96,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
96
96
  return {
97
97
  verdict: "exhausted",
98
98
  resumeAt: w.seenAt + MODEL_REJECTED_TTL_MS,
99
+ observedAt: w.seenAt,
99
100
  reason: `${requestedModel} limit hit ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; TTL block)`,
100
101
  };
101
102
  }
@@ -114,6 +115,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
114
115
  return {
115
116
  verdict: "exhausted",
116
117
  resumeAt: resetMs,
118
+ observedAt: w.seenAt,
117
119
  reason: `${window} rejected until ${new Date(resetMs).toISOString()}`,
118
120
  };
119
121
  }
@@ -125,6 +127,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
125
127
  return {
126
128
  verdict: "exhausted",
127
129
  resumeAt: w.seenAt + resetLessBlockMs(window),
130
+ observedAt: w.seenAt,
128
131
  reason: `${window} rejected ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; ${Math.round(resetLessBlockMs(window) / 3600000)}h block)`,
129
132
  };
130
133
  }
@@ -200,9 +203,24 @@ export function choosePoolAccount(pool) {
200
203
  export function allCredentialFailed(pool) {
201
204
  return pool.length > 0 && pool.every((a) => a.verdict === "credential_failed");
202
205
  }
203
- export function fallbackPoolAccount(pool) {
206
+ export function overdueProbeAccount(pool, nowMs) {
207
+ let oldest;
208
+ for (const a of pool) {
209
+ if (a.verdict !== "exhausted" || a.observedAt === undefined)
210
+ continue;
211
+ if (nowMs - a.observedAt <= REJECTION_REVALIDATE_AFTER_MS)
212
+ continue;
213
+ if (!oldest || a.observedAt < oldest.observedAt)
214
+ oldest = a;
215
+ }
216
+ return oldest?.id;
217
+ }
218
+ export function fallbackPoolAccount(pool, nowMs) {
219
+ const probe = nowMs === undefined ? undefined : overdueProbeAccount(pool, nowMs);
220
+ if (probe)
221
+ return probe;
204
222
  return (pool.find((a) => a.verdict !== "credential_failed") ?? pool[0]).id;
205
223
  }
206
- export function pickPoolAccountForLaunch(pool) {
207
- return choosePoolAccount(pool) ?? fallbackPoolAccount(pool);
224
+ export function pickPoolAccountForLaunch(pool, nowMs) {
225
+ return choosePoolAccount(pool) ?? fallbackPoolAccount(pool, nowMs);
208
226
  }
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { MODEL_ALIASES, buildCatalogEntries, canonicalModelId, isModernClaudeMod
7
7
  import { decideDegradation, matchesPin } from "./degrade.js";
8
8
  import { resolveExecMode, permissionModeArgs } from "./exec-policy.js";
9
9
  import { resolveBaseModelIds } from "./catalog-source.js";
10
- import { allCredentialFailed, classifyAccountHealth } from "./health.js";
10
+ import { allCredentialFailed, classifyAccountHealth, overdueProbeAccount, } from "./health.js";
11
11
  import { decideStickySelection } from "./sticky.js";
12
12
  import { mergeHealthStates, parseStoredState, recordCredentialFailure, } from "./shim-core.js";
13
13
  import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js";
@@ -25,6 +25,7 @@ import { execFileSync } from "node:child_process";
25
25
  import { collectDirectMembers } from "./direct-route.js";
26
26
  import { createDirectOrderController, isValidAgentId } from "./direct-sync.js";
27
27
  import { createOpenclawRunner } from "./openclaw-runner.js";
28
+ import { materialiseRuntime } from "./runtime-home.js";
28
29
  const CLI_FRESH_WATCHDOG_DEFAULTS = {
29
30
  noOutputTimeoutRatio: 0.8,
30
31
  minMs: 180_000,
@@ -332,7 +333,12 @@ function buildRuntimeModel(account, modelId) {
332
333
  },
333
334
  };
334
335
  }
335
- const SHIM_PATH = fileURLToPath(new URL("./shim.js", import.meta.url));
336
+ const SHIM_RUNTIME = materialiseRuntime(dirname(fileURLToPath(import.meta.url)), join(homedir(), ".openclaw", "state", "multi-clawd"));
337
+ if (!SHIM_RUNTIME.stable) {
338
+ console.error(`[multi-clawd] could not copy the shim to a stable dir (${SHIM_RUNTIME.error}); ` +
339
+ `using the load path, which dies if OpenClaw prunes its plugin capture (issue #31)`);
340
+ }
341
+ const SHIM_PATH = SHIM_RUNTIME.shimPath;
336
342
  function knownModelsFile() {
337
343
  return join(homedir(), ".openclaw", "state", "multi-clawd", "known-models.json");
338
344
  }
@@ -672,15 +678,28 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
672
678
  throw new Error(`[multi-clawd] ${text}`);
673
679
  }
674
680
  const previousSticky = readStickyEntry(stickyFile);
681
+ const poolVerdicts = verdicts.map((v) => ({
682
+ id: v.id,
683
+ verdict: v.health.verdict,
684
+ observedAt: v.health.observedAt,
685
+ }));
675
686
  const decision = decideStickySelection({
676
- verdicts: verdicts.map((v) => ({ id: v.id, verdict: v.health.verdict })),
687
+ verdicts: poolVerdicts,
677
688
  sticky: previousSticky,
678
689
  nowMs: now,
679
690
  minDwellMs,
680
691
  });
681
692
  const chosen = members.find((a) => a.id === decision.account) ?? members[0];
682
693
  const previousAccount = previousSticky?.account ?? members[0].id;
683
- if (decision.account !== previousAccount) {
694
+ const probing = overdueProbeAccount(poolVerdicts, now) === decision.account &&
695
+ poolVerdicts.every((v) => v.verdict === "exhausted" || v.verdict === "credential_failed");
696
+ if (probing) {
697
+ const observedAt = poolVerdicts.find((v) => v.id === decision.account)?.observedAt ?? now;
698
+ logger.info(`[multi-clawd] pool ${poolId}: every account is exhausted for ${requestedModel} — ` +
699
+ `launching on ${decision.account} to re-test a rejection last seen ` +
700
+ `${Math.round((now - observedAt) / 3600000)}h ago`);
701
+ }
702
+ else if (decision.account !== previousAccount) {
684
703
  const home = verdicts[0];
685
704
  const line = decision.account === members[0].id
686
705
  ? `pool ${poolId}: returning home to ${decision.account}`
@@ -1,4 +1,5 @@
1
- import { classifyAccountHealth } from "./health.js";
1
+ import { classifyAccountHealth, overdueProbeAccount, } from "./health.js";
2
+ import { resumeSessionId } from "./session-handover.js";
2
3
  export const RETRY_ROSTER_ENV = "MULTI_CLAWD_RETRY_ACCOUNTS";
3
4
  const CREDENTIAL_ENV_KEYS = ["CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN"];
4
5
  export function parseRetryRoster(raw) {
@@ -33,14 +34,19 @@ export function parseRetryRoster(raw) {
33
34
  }
34
35
  return out;
35
36
  }
36
- export function retryArming(argv, roster) {
37
+ export function isResumeLaunch(argv) {
38
+ return (resumeSessionId(argv) !== undefined ||
39
+ argv.some((a) => a === "--resume" || a === "-r" || a.startsWith("--resume=")));
40
+ }
41
+ export function retryArming(argv, roster, opts = {}) {
37
42
  if (roster.length === 0) {
38
43
  return { armed: false, reason: "no secret-free sibling account to retry onto" };
39
44
  }
40
- if (argv.includes("--resume")) {
45
+ if (isResumeLaunch(argv) && !opts.resumeReady) {
41
46
  return {
42
47
  armed: false,
43
- reason: "resumed session — its Claude session lives in this account's config dir",
48
+ reason: "resumed session — its transcript is not in this account's config dir in a state " +
49
+ "that can be handed to a sibling",
44
50
  };
45
51
  }
46
52
  if (!argv.some((a) => a === "stream-json" || a === "--output-format=stream-json")) {
@@ -49,13 +55,16 @@ export function retryArming(argv, roster) {
49
55
  return { armed: true };
50
56
  }
51
57
  export function chooseRetryAccount(params) {
58
+ const verdicts = [];
52
59
  for (const account of params.roster) {
53
60
  const state = params.readState(account.stateFile);
54
- const verdict = classifyAccountHealth(state, params.options ?? {}, params.nowMs, params.modelId).verdict;
55
- if (verdict === "ok" || verdict === "no_data")
61
+ const health = classifyAccountHealth(state, params.options ?? {}, params.nowMs, params.modelId);
62
+ if (health.verdict === "ok" || health.verdict === "no_data")
56
63
  return account;
64
+ verdicts.push({ id: account.id, verdict: health.verdict, observedAt: health.observedAt });
57
65
  }
58
- return undefined;
66
+ const probe = overdueProbeAccount(verdicts, params.nowMs);
67
+ return probe ? params.roster.find((a) => a.id === probe) : undefined;
59
68
  }
60
69
  export function buildRetryEnv(base, account) {
61
70
  const env = { ...base };
@@ -0,0 +1,34 @@
1
+ import { createHash } from "node:crypto";
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export function materialiseRuntime(srcDir, stateRoot) {
5
+ const fallback = join(srcDir, "shim.js");
6
+ try {
7
+ const files = readdirSync(srcDir).filter((f) => f.endsWith(".js")).sort();
8
+ if (!files.includes("shim.js"))
9
+ throw new Error(`shim.js missing from ${srcDir}`);
10
+ const hash = createHash("sha256");
11
+ for (const f of files)
12
+ hash.update(f).update("\0").update(readFileSync(join(srcDir, f))).update("\0");
13
+ const dir = join(stateRoot, "runtime", hash.digest("hex").slice(0, 16));
14
+ const shimPath = join(dir, "shim.js");
15
+ if (existsSync(shimPath))
16
+ return { shimPath, dir, stable: true };
17
+ const staging = `${dir}.staging-${process.pid}-${Date.now()}`;
18
+ mkdirSync(staging, { recursive: true });
19
+ for (const f of files)
20
+ copyFileSync(join(srcDir, f), join(staging, f));
21
+ writeFileSync(join(staging, "package.json"), JSON.stringify({ type: "module" }) + "\n");
22
+ try {
23
+ renameSync(staging, dir);
24
+ }
25
+ catch (e) {
26
+ if (!existsSync(shimPath))
27
+ throw e;
28
+ }
29
+ return { shimPath, dir, stable: true };
30
+ }
31
+ catch (e) {
32
+ return { shimPath: fallback, dir: srcDir, stable: false, error: e instanceof Error ? e.message : String(e) };
33
+ }
34
+ }
@@ -1,4 +1,5 @@
1
- import { copyFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, copyFileSync, mkdirSync, openSync, readSync, readdirSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { basename, dirname, join } from "node:path";
4
5
  export const SESSION_DIRS_ENV = "MULTI_CLAWD_SESSION_DIRS";
@@ -115,3 +116,136 @@ export function handoverForLaunch(argv, env, deps = {}) {
115
116
  return `resume handover failed for session ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`;
116
117
  }
117
118
  }
119
+ const FINGERPRINT_HEAD_BYTES = 64 * 1024;
120
+ const FINGERPRINT_TAIL_BYTES = 1024 * 1024;
121
+ function readRegion(fd, position, length) {
122
+ const buf = Buffer.alloc(length);
123
+ let got = 0;
124
+ while (got < length) {
125
+ const n = readSync(fd, buf, got, length - got, position + got);
126
+ if (n <= 0)
127
+ throw new Error("the transcript ended before the snapshot point");
128
+ got += n;
129
+ }
130
+ return buf;
131
+ }
132
+ export function fingerprintPrefix(path, bytes) {
133
+ const fd = openSync(path, "r");
134
+ try {
135
+ const hash = createHash("sha256");
136
+ hash.update(`${bytes}:`);
137
+ const head = Math.min(FINGERPRINT_HEAD_BYTES, bytes);
138
+ hash.update(readRegion(fd, 0, head));
139
+ const tail = Math.min(FINGERPRINT_TAIL_BYTES, bytes - head);
140
+ if (tail > 0)
141
+ hash.update(readRegion(fd, bytes - tail, tail));
142
+ return hash.digest("hex");
143
+ }
144
+ finally {
145
+ closeSync(fd);
146
+ }
147
+ }
148
+ const NEWLINE = 0x0a;
149
+ function lastByte(path, length) {
150
+ if (length <= 0)
151
+ return undefined;
152
+ const fd = openSync(path, "r");
153
+ try {
154
+ const buf = Buffer.alloc(1);
155
+ return readSync(fd, buf, 0, 1, length - 1) === 1 ? buf[0] : undefined;
156
+ }
157
+ finally {
158
+ closeSync(fd);
159
+ }
160
+ }
161
+ export function snapshotResumeTranscript(argv, env) {
162
+ try {
163
+ const sessionId = resumeSessionId(argv);
164
+ if (!sessionId)
165
+ return undefined;
166
+ const projects = join(effectiveConfigDir(env), "projects");
167
+ let newest;
168
+ for (const sub of nodeHandoverFs.listDirs(projects)) {
169
+ const path = join(projects, sub, `${sessionId}.jsonl`);
170
+ let st;
171
+ try {
172
+ st = statSync(path);
173
+ }
174
+ catch {
175
+ continue;
176
+ }
177
+ if (!st.isFile())
178
+ continue;
179
+ if (!newest || st.mtimeMs > newest.mtime) {
180
+ newest = { sub, path, mtime: st.mtimeMs, bytes: st.size };
181
+ }
182
+ }
183
+ if (!newest || newest.bytes === 0)
184
+ return undefined;
185
+ if (lastByte(newest.path, newest.bytes) !== NEWLINE)
186
+ return undefined;
187
+ return {
188
+ sessionId,
189
+ sub: newest.sub,
190
+ path: newest.path,
191
+ bytes: newest.bytes,
192
+ fingerprint: fingerprintPrefix(newest.path, newest.bytes),
193
+ };
194
+ }
195
+ catch {
196
+ return undefined;
197
+ }
198
+ }
199
+ const COPY_CHUNK_BYTES = 1024 * 1024;
200
+ export function handoverSnapshotTo(snapshot, targetConfigDir) {
201
+ const size = statSync(snapshot.path).size;
202
+ if (size < snapshot.bytes) {
203
+ throw new Error("the transcript is shorter than when the launch began");
204
+ }
205
+ if (lastByte(snapshot.path, snapshot.bytes) !== NEWLINE) {
206
+ throw new Error("the transcript no longer ends on a record boundary at the snapshot point");
207
+ }
208
+ const to = join(targetConfigDir, "projects", snapshot.sub, `${snapshot.sessionId}.jsonl`);
209
+ if (to === snapshot.path)
210
+ throw new Error("the sibling shares this account's config dir");
211
+ mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
212
+ const tmp = join(dirname(to), `.${basename(to)}.handover-${process.pid}`);
213
+ let src;
214
+ let dst;
215
+ try {
216
+ src = openSync(snapshot.path, "r");
217
+ dst = openSync(tmp, "w", 0o600);
218
+ const buf = Buffer.alloc(Math.min(COPY_CHUNK_BYTES, snapshot.bytes));
219
+ let copied = 0;
220
+ while (copied < snapshot.bytes) {
221
+ const want = Math.min(buf.length, snapshot.bytes - copied);
222
+ const got = readSync(src, buf, 0, want, copied);
223
+ if (got <= 0)
224
+ throw new Error("the transcript ended before the snapshot point");
225
+ writeSync(dst, buf, 0, got);
226
+ copied += got;
227
+ }
228
+ closeSync(dst);
229
+ dst = undefined;
230
+ if (fingerprintPrefix(tmp, snapshot.bytes) !== snapshot.fingerprint) {
231
+ throw new Error("the transcript was rewritten after the launch began");
232
+ }
233
+ renameSync(tmp, to);
234
+ return to;
235
+ }
236
+ catch (err) {
237
+ if (dst !== undefined) {
238
+ try {
239
+ closeSync(dst);
240
+ }
241
+ catch {
242
+ }
243
+ }
244
+ rmSync(tmp, { force: true });
245
+ throw err;
246
+ }
247
+ finally {
248
+ if (src !== undefined)
249
+ closeSync(src);
250
+ }
251
+ }
package/dist/shim-core.js CHANGED
@@ -90,6 +90,7 @@ export function parseStoredState(raw) {
90
90
  seenAt: w.seenAt,
91
91
  rawInfo: typeof w.rawInfo === "string" ? w.rawInfo : undefined,
92
92
  model: typeof w.model === "string" ? w.model : undefined,
93
+ family: typeof w.family === "string" ? w.family : undefined,
93
94
  };
94
95
  }
95
96
  let credential;
@@ -178,6 +179,16 @@ export function canonicalizeModelWindowKeys(windows) {
178
179
  }
179
180
  return out;
180
181
  }
182
+ const MODEL_FAMILY_RE = /^claude-([a-z]+)-/;
183
+ export function modelFamily(modelId) {
184
+ return MODEL_FAMILY_RE.exec(canonicalizeModelIdForWindow(modelId))?.[1];
185
+ }
186
+ export function limitFamilyFor(displayName, modelId) {
187
+ const family = modelFamily(modelId);
188
+ if (!family)
189
+ return undefined;
190
+ return displayName.trim().toLowerCase() === family ? family : undefined;
191
+ }
181
192
  const LIMIT_TEXT_RE = /reached your (.{1,40}?) limit/i;
182
193
  export function parseModelLimitError(line) {
183
194
  if (!/reached your /i.test(line))
@@ -214,7 +225,7 @@ export function parseModelLimitError(line) {
214
225
  }
215
226
  return undefined;
216
227
  }
217
- export function recordModelLimit(state, modelId, now, resetsAt) {
228
+ export function recordModelLimit(state, modelId, now, resetsAt, family) {
218
229
  return {
219
230
  ...state,
220
231
  updatedAt: now,
@@ -224,10 +235,55 @@ export function recordModelLimit(state, modelId, now, resetsAt) {
224
235
  status: "rejected",
225
236
  resetsAt,
226
237
  seenAt: now,
238
+ ...(family ? { family } : {}),
227
239
  },
228
240
  },
229
241
  };
230
242
  }
243
+ export function modelWindowApplies(windowKey, w, requestedModel) {
244
+ if (!windowKey.startsWith("model:"))
245
+ return false;
246
+ if (modelWindowKey(windowKey.slice("model:".length)) === modelWindowKey(requestedModel)) {
247
+ return true;
248
+ }
249
+ return w.family !== undefined && w.family === modelFamily(requestedModel);
250
+ }
251
+ export function clearModelRejection(state, modelId, now) {
252
+ const windows = { ...state.windows };
253
+ let changed = false;
254
+ for (const [key, w] of Object.entries(state.windows)) {
255
+ if (w.status !== "rejected")
256
+ continue;
257
+ if (!modelWindowApplies(key, w, modelId))
258
+ continue;
259
+ windows[key] = { status: "allowed", seenAt: now };
260
+ changed = true;
261
+ }
262
+ if (!changed)
263
+ return { state, changed };
264
+ return { state: { ...state, updatedAt: now, windows }, changed };
265
+ }
266
+ export function parseSuccessResult(line) {
267
+ if (!line.includes('"result"'))
268
+ return false;
269
+ let record;
270
+ try {
271
+ record = JSON.parse(line);
272
+ }
273
+ catch {
274
+ return false;
275
+ }
276
+ if (typeof record !== "object" || record === null)
277
+ return false;
278
+ const r = record;
279
+ if (r.type !== "result")
280
+ return false;
281
+ if (r.is_error === true)
282
+ return false;
283
+ if (typeof r.subtype === "string" && r.subtype.startsWith("error"))
284
+ return false;
285
+ return true;
286
+ }
231
287
  const AUTH_FAILURE_PATTERNS = [
232
288
  /oauth (session|token) (has )?(expired|been revoked)/i,
233
289
  /failed to authenticate/i,
package/dist/shim.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
- import { classifyStateReadFailure, clearCredentialFailure, createLineScanner, mergeHealthStates, parseAuthFailure, parseRateLimitEvent, parseStoredState, recordCredentialFailure, updateHealthState, } from "./shim-core.js";
4
+ import { classifyStateReadFailure, clearCredentialFailure, clearModelRejection, createLineScanner, limitFamilyFor, mergeHealthStates, parseAuthFailure, parseRateLimitEvent, parseStoredState, parseSuccessResult, recordCredentialFailure, updateHealthState, } from "./shim-core.js";
5
5
  import { rewriteModelArg } from "./degrade.js";
6
6
  import { parseModelLimitError, recordModelLimit } from "./shim-core.js";
7
7
  import { canonicalModelId } from "./models.js";
8
8
  import { buildRetryEnv, chooseRetryAccount, parseRetryRoster, retryArming, RETRY_ROSTER_ENV, } from "./retry-plan.js";
9
- import { handoverForLaunch } from "./session-handover.js";
9
+ import { effectiveConfigDir, handoverForLaunch, handoverSnapshotTo, parseSessionDirs, SESSION_DIRS_ENV, snapshotResumeTranscript, } from "./session-handover.js";
10
10
  function resolveClaudeCommand() {
11
11
  const override = process.env.MULTI_CLAWD_CLAUDE_BIN;
12
12
  if (override) {
@@ -95,7 +95,10 @@ if (modelOverride) {
95
95
  process.stderr.write(`[multi-clawd shim] ${note}\n`);
96
96
  }
97
97
  const retryRoster = parseRetryRoster(process.env[RETRY_ROSTER_ENV]);
98
- const arming = retryArming(childArgs, retryRoster);
98
+ const resumeSnapshot = retryRoster.length > 0 && parseSessionDirs(process.env[SESSION_DIRS_ENV]).length > 0
99
+ ? snapshotResumeTranscript(childArgs, process.env)
100
+ : undefined;
101
+ const arming = retryArming(childArgs, retryRoster, { resumeReady: resumeSnapshot !== undefined });
99
102
  let retryArmed = arming.armed;
100
103
  if (!retryArmed && retryRoster.length > 0 && arming.reason) {
101
104
  process.stderr.write(`[multi-clawd shim] in-turn retry unavailable: ${arming.reason}\n`);
@@ -148,8 +151,12 @@ function guessLimitResetsAt() {
148
151
  return undefined;
149
152
  }
150
153
  let sawAuthFailure = false;
154
+ let sawModelLimit = false;
155
+ let sawSuccessResult = false;
151
156
  function observeLine(line) {
152
157
  try {
158
+ if (parseSuccessResult(line))
159
+ sawSuccessResult = true;
153
160
  const event = parseRateLimitEvent(line);
154
161
  if (event) {
155
162
  state = updateHealthState(state, event, Date.now(), effectiveModelId());
@@ -157,11 +164,14 @@ function observeLine(line) {
157
164
  }
158
165
  const limitHit = parseModelLimitError(line);
159
166
  if (limitHit) {
167
+ sawModelLimit = true;
160
168
  const model = effectiveModelId();
161
169
  if (model) {
162
- state = recordModelLimit(state, model, Date.now(), guessLimitResetsAt());
170
+ const family = limitFamilyFor(limitHit.displayName, model);
171
+ state = recordModelLimit(state, model, Date.now(), guessLimitResetsAt(), family);
163
172
  persistState();
164
- process.stderr.write(`[multi-clawd shim] model limit hit recorded: ${model} (reported as "${limitHit.displayName}")\n`);
173
+ process.stderr.write(`[multi-clawd shim] model limit hit recorded: ${model} (reported as "${limitHit.displayName}"` +
174
+ `${family ? `, applies to every ${family} model` : ""})\n`);
165
175
  }
166
176
  }
167
177
  const authFailure = parseAuthFailure(line);
@@ -235,10 +245,23 @@ function attemptRetry() {
235
245
  releaseHeldOutput();
236
246
  return false;
237
247
  }
248
+ if (resumeSnapshot) {
249
+ try {
250
+ handoverSnapshotTo(resumeSnapshot, effectiveConfigDir(target.env));
251
+ }
252
+ catch (err) {
253
+ process.stderr.write(`[multi-clawd shim] model limit on ${accountId}, but session ` +
254
+ `${resumeSnapshot.sessionId.slice(0, 8)} could not be handed to ${target.id} ` +
255
+ `(${err instanceof Error ? err.message : String(err)}) — ` +
256
+ `passing the failure through to the host's chain\n`);
257
+ releaseHeldOutput();
258
+ return false;
259
+ }
260
+ }
238
261
  retryUsed = true;
239
262
  retryArmed = false;
240
263
  process.stderr.write(`[multi-clawd shim] model limit on ${accountId}${model ? ` for ${model}` : ""} — ` +
241
- `retrying this turn on ${target.id} (nothing forwarded downstream yet)\n`);
264
+ `retrying this turn on ${target.id}${resumeSnapshot ? `, session ${resumeSnapshot.sessionId.slice(0, 8)} handed over` : ""} (nothing forwarded downstream yet)\n`);
242
265
  held = [];
243
266
  heldBytes = 0;
244
267
  holdTail = "";
@@ -255,6 +278,8 @@ function attemptRetry() {
255
278
  stateFile = target.stateFile;
256
279
  state = { accountId, windows: {} };
257
280
  sawAuthFailure = false;
281
+ sawModelLimit = false;
282
+ sawSuccessResult = false;
258
283
  child = spawn(command, childArgs, {
259
284
  stdio: ["pipe", "pipe", "inherit"],
260
285
  env: buildRetryEnv(process.env, target),
@@ -340,13 +365,41 @@ function clearRecordedAuthFailureOnSuccess() {
340
365
  catch {
341
366
  }
342
367
  }
368
+ function clearRecordedModelRejectionOnSuccess() {
369
+ if (!stateFile || !sawSuccessResult || sawModelLimit)
370
+ return;
371
+ try {
372
+ const model = effectiveModelId();
373
+ if (!model)
374
+ return;
375
+ const disk = readPersistedState();
376
+ if (!disk)
377
+ return;
378
+ const now = Date.now();
379
+ const cleared = clearModelRejection(disk, model, now);
380
+ if (!cleared.changed)
381
+ return;
382
+ const windows = { ...state.windows };
383
+ for (const [key, w] of Object.entries(cleared.state.windows)) {
384
+ if (disk.windows[key] !== w)
385
+ windows[key] = w;
386
+ }
387
+ state = { ...state, updatedAt: now, windows };
388
+ persistState();
389
+ process.stderr.write(`[multi-clawd shim] ${model} served by ${accountId} — model rejection cleared\n`);
390
+ }
391
+ catch {
392
+ }
393
+ }
343
394
  function onChildClose(code, signal) {
344
395
  if (signal) {
345
396
  process.kill(process.pid, signal);
346
397
  return;
347
398
  }
348
- if ((code ?? 0) === 0)
399
+ if ((code ?? 0) === 0) {
349
400
  clearRecordedAuthFailureOnSuccess();
401
+ clearRecordedModelRejectionOnSuccess();
402
+ }
350
403
  process.exit(code ?? 0);
351
404
  }
352
405
  function onChildError(err) {
package/dist/sticky.js CHANGED
@@ -6,7 +6,7 @@ export function decideStickySelection(params) {
6
6
  const home = verdicts[0];
7
7
  const healthChoice = choosePoolAccount(verdicts);
8
8
  if (!healthChoice)
9
- return { account: fallbackPoolAccount(verdicts) };
9
+ return { account: fallbackPoolAccount(verdicts, nowMs) };
10
10
  const stickyVerdict = sticky
11
11
  ? verdicts.find((v) => v.id === sticky.account)?.verdict
12
12
  : undefined;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "1.9.6",
4
+ "version": "1.9.8",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/multi-clawd",
3
- "version": "1.9.6",
3
+ "version": "1.9.8",
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",
@@ -284,9 +284,10 @@ const { checkAccountCredential, keychainServiceForConfigDir } = await importDist
284
284
  "checkAccountCredential",
285
285
  "keychainServiceForConfigDir",
286
286
  ]);
287
- const { summarizeWindowUsage, classifyAccountHealth } = await importDist("health.js", [
287
+ const { summarizeWindowUsage, classifyAccountHealth, overdueProbeAccount } = await importDist("health.js", [
288
288
  "summarizeWindowUsage",
289
289
  "classifyAccountHealth",
290
+ "overdueProbeAccount",
290
291
  ]);
291
292
  // "cli", like the chain audits: resolving WHICH login backs an account is
292
293
  // doctor's own diagnostic, not a description of the running plugin's
@@ -458,17 +459,15 @@ else {
458
459
  // sticky can be about to expire, and health outranks both.
459
460
  if (members.length > 0) {
460
461
  const now = Date.now();
462
+ const healthOptions = {
463
+ staleAfterMs: pool.staleAfterMs,
464
+ utilizationThreshold: pool.utilizationThreshold,
465
+ rotateOnOverage: pool.rotateOnOverage,
466
+ };
461
467
  const verdicts = members.map((id) => ({
462
468
  id,
463
- verdict: classifyAccountHealth(
464
- readJson(join(STATE_DIR, `${id}.json`)),
465
- {
466
- staleAfterMs: pool.staleAfterMs,
467
- utilizationThreshold: pool.utilizationThreshold,
468
- rotateOnOverage: pool.rotateOnOverage,
469
- },
470
- now,
471
- ).verdict,
469
+ verdict: classifyAccountHealth(readJson(join(STATE_DIR, `${id}.json`)), healthOptions, now)
470
+ .verdict,
472
471
  }));
473
472
  const decision = decideStickySelection({
474
473
  verdicts,
@@ -499,8 +498,54 @@ else {
499
498
  );
500
499
  }
501
500
  // The launch decision is model-aware (a 429 can bench one account for one
502
- // model only); doctor has no model in hand, so this is the account-wide
503
- // answer. Say so rather than let a model-scoped exception read as a lie.
501
+ // model only); doctor has no model in hand, so the line above is the
502
+ // account-wide answer. A model limit is therefore reported separately, per
503
+ // model, from the same classifier the launch uses — "health ok" beside an
504
+ // account that refuses every turn for one model read as a clean bill of
505
+ // health during the very outage it should have named (#26).
506
+ const limitedModels = new Set();
507
+ for (const id of members) {
508
+ const state = readJson(join(STATE_DIR, `${id}.json`));
509
+ for (const key of Object.keys(state?.windows ?? {})) {
510
+ if (key.startsWith("model:")) limitedModels.add(key.slice("model:".length));
511
+ }
512
+ }
513
+ for (const model of [...limitedModels].sort()) {
514
+ const perAccount = members.map((id) => {
515
+ const health = classifyAccountHealth(
516
+ readJson(join(STATE_DIR, `${id}.json`)),
517
+ healthOptions,
518
+ now,
519
+ model,
520
+ );
521
+ return { id, verdict: health.verdict, observedAt: health.observedAt, resumeAt: health.resumeAt };
522
+ });
523
+ const limited = perAccount.filter((v) => v.verdict === "exhausted");
524
+ if (limited.length === 0) continue;
525
+ const detail = limited
526
+ .map((v) => {
527
+ const ageMin = typeof v.observedAt === "number" ? Math.round((now - v.observedAt) / 60000) : undefined;
528
+ const seen =
529
+ ageMin === undefined
530
+ ? ""
531
+ : `, seen ${ageMin < 90 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h`} ago`;
532
+ const until =
533
+ typeof v.resumeAt === "number" ? ` until ${new Date(v.resumeAt).toISOString()}` : "";
534
+ return `${v.id}${until}${seen}`;
535
+ })
536
+ .join("; ");
537
+ if (limited.length === perAccount.length) {
538
+ const probe = overdueProbeAccount(perAccount, now);
539
+ bad(
540
+ `${model}: EVERY account is limited (${detail}) — turns for this model fall through to ` +
541
+ `the host's chain${
542
+ probe ? `; the next launch re-tests ${probe}, whose rejection is the oldest` : ""
543
+ }`,
544
+ );
545
+ } else {
546
+ warn(`${model}: limited on ${detail} — served by the remaining account(s)`);
547
+ }
548
+ }
504
549
  if (VERBOSE) {
505
550
  note(`account-wide verdicts: ${verdicts.map((v) => `${v.id}:${v.verdict}`).join(" ")}`);
506
551
  }