@drakon-systems/multi-clawd 1.8.10 → 1.8.11

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
@@ -241,6 +241,11 @@ claude-cli/claude-fable-5 # main login
241
241
  - 🧲 **Sticky rotation (v0.3)** — after handing over, the pool dwells on the
242
242
  spare account (default 10 min) before returning home, so turns never flap
243
243
  across the threshold. Health always overrides stickiness.
244
+ - 🔁 **In-turn retry on a reactive limit** — when an account hits a model cap
245
+ *during* a launch, the pool no longer loses that turn to the next provider in
246
+ your chain: the refusal is swallowed before anything reaches you and the turn
247
+ is re-spawned on a healthy account. Fresh launches, secret-free sibling
248
+ accounts, one retry.
244
249
  - 📟 **Operator alerts (v0.3)** — dead logins (probed every 15 min without
245
250
  spending quota), pool rotations, whole-pool exhaustion, and watchdog
246
251
  restarts surface through your agent's next heartbeat (e.g. straight into
@@ -369,7 +374,7 @@ openclaw plugins install (Get-Location).Path
369
374
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
370
375
  Code on the target machine already? Paste it this and go make coffee:
371
376
 
372
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.8.10/SETUP-AGENT.md
377
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.8.11/SETUP-AGENT.md
373
378
  > and follow it to set up multi-clawd on this machine. I own a second
374
379
  > Claude account — ask me when you need me to log in.
375
380
 
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { mergeHealthStates, parseStoredState, recordCredentialFailure, } from ".
13
13
  import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js";
14
14
  import { resolveSecretRefValues } from "openclaw/plugin-sdk/secret-ref-runtime";
15
15
  import { resolvePoolExecutionArgs } from "./tool-cap.js";
16
+ import { RETRY_ROSTER_ENV } from "./retry-plan.js";
16
17
  import { addAlert, alertKeysWithPrefix, clearAlert, pendingAlertText, } from "./alerts.js";
17
18
  import { healthStateFile, clearAccountCredentialFailure } from "./credential-state.js";
18
19
  export { healthStateFile, clearAccountCredentialFailure };
@@ -433,6 +434,21 @@ export function buildBackend(account, execMode) {
433
434
  Object.assign(backend.config, { sessionArg: "--session-id" });
434
435
  return backend;
435
436
  }
437
+ export function buildRetryRoster(members, launchedId) {
438
+ const roster = [];
439
+ for (const member of members) {
440
+ if (member.id === launchedId)
441
+ continue;
442
+ if (member.oauthTokenFile || member.oauthTokenRef)
443
+ continue;
444
+ const stateFile = healthStateFile(member.id);
445
+ const env = buildAccountChildEnv(member, undefined, stateFile);
446
+ delete env.MULTI_CLAWD_ACCOUNT_ID;
447
+ delete env.MULTI_CLAWD_STATE_FILE;
448
+ roster.push({ id: member.id, stateFile, env });
449
+ }
450
+ return roster;
451
+ }
436
452
  async function buildAccountEnv(account, resolver) {
437
453
  const token = await resolveTokenAsync(account, resolver);
438
454
  return buildAccountChildEnv(account, token, healthStateFile(account.id));
@@ -697,10 +713,12 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
697
713
  writeStickyEntry(stickyFile, decision.sticky, logger);
698
714
  const order = [chosen, ...members.filter((m) => m.id !== chosen.id)];
699
715
  let env;
716
+ let launched;
700
717
  const unresolved = [];
701
718
  for (const candidate of order) {
702
719
  try {
703
720
  env = await buildAccountEnv(candidate, deps?.resolver);
721
+ launched = candidate;
704
722
  if (candidate.id !== chosen.id) {
705
723
  logger.warn(`[multi-clawd] pool ${poolId}: ${chosen.id}'s credential did not resolve — ` +
706
724
  `launching on ${candidate.id} instead (account not benched; secret provider may be down)`);
@@ -719,6 +737,11 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
719
737
  throw new Error(`[multi-clawd] ${text}`);
720
738
  }
721
739
  alertState = clearAlert(alertState, `pool-unresolvable:${poolId}`);
740
+ {
741
+ const roster = buildRetryRoster(members, (launched ?? chosen).id);
742
+ if (roster.length > 0)
743
+ env[RETRY_ROSTER_ENV] = JSON.stringify(roster);
744
+ }
722
745
  if (ladder.length > 0) {
723
746
  const pinned = matchesPin(pins, {
724
747
  agentDir: ctx.agentDir ?? "",
package/dist/models.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export const MODEL_ALIASES = {
2
2
  opus: "opus",
3
+ "opus-5.5": "claude-opus-5-5",
3
4
  "opus-5": "claude-opus-5",
4
5
  "opus-4.8": "claude-opus-4-8",
5
6
  "opus-4.7": "claude-opus-4-7",
@@ -9,6 +10,7 @@ export const MODEL_ALIASES = {
9
10
  haiku: "haiku",
10
11
  };
11
12
  const KNOWN_SPECS = {
13
+ "claude-opus-5-5": { name: "Claude Opus 5.5", contextWindow: 1000000, maxTokens: 128000 },
12
14
  "claude-opus-5": { name: "Claude Opus 5", contextWindow: 1000000, maxTokens: 128000 },
13
15
  "claude-opus-4-8": { name: "Claude Opus 4.8", contextWindow: 1048576, maxTokens: 128000 },
14
16
  "claude-opus-4-7": { name: "Claude Opus 4.7", contextWindow: 1048576, maxTokens: 64000 },
@@ -0,0 +1,70 @@
1
+ import { classifyAccountHealth } from "./health.js";
2
+ export const RETRY_ROSTER_ENV = "MULTI_CLAWD_RETRY_ACCOUNTS";
3
+ const CREDENTIAL_ENV_KEYS = ["CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN"];
4
+ export function parseRetryRoster(raw) {
5
+ if (!raw)
6
+ return [];
7
+ let parsed;
8
+ try {
9
+ parsed = JSON.parse(raw);
10
+ }
11
+ catch {
12
+ return [];
13
+ }
14
+ if (!Array.isArray(parsed))
15
+ return [];
16
+ const out = [];
17
+ for (const entry of parsed) {
18
+ if (!entry || typeof entry !== "object")
19
+ continue;
20
+ const e = entry;
21
+ if (typeof e.id !== "string" || !e.id)
22
+ continue;
23
+ if (typeof e.stateFile !== "string" || !e.stateFile)
24
+ continue;
25
+ const env = {};
26
+ if (e.env && typeof e.env === "object") {
27
+ for (const [k, v] of Object.entries(e.env)) {
28
+ if (typeof v === "string")
29
+ env[k] = v;
30
+ }
31
+ }
32
+ out.push({ id: e.id, stateFile: e.stateFile, env });
33
+ }
34
+ return out;
35
+ }
36
+ export function retryArming(argv, roster) {
37
+ if (roster.length === 0) {
38
+ return { armed: false, reason: "no secret-free sibling account to retry onto" };
39
+ }
40
+ if (argv.includes("--resume")) {
41
+ return {
42
+ armed: false,
43
+ reason: "resumed session — its Claude session lives in this account's config dir",
44
+ };
45
+ }
46
+ if (!argv.some((a) => a === "stream-json" || a === "--output-format=stream-json")) {
47
+ return { armed: false, reason: "not a stream-json launch" };
48
+ }
49
+ return { armed: true };
50
+ }
51
+ export function chooseRetryAccount(params) {
52
+ for (const account of params.roster) {
53
+ 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")
56
+ return account;
57
+ }
58
+ return undefined;
59
+ }
60
+ export function buildRetryEnv(base, account) {
61
+ const env = { ...base };
62
+ for (const key of CREDENTIAL_ENV_KEYS)
63
+ delete env[key];
64
+ for (const [k, v] of Object.entries(account.env))
65
+ env[k] = v;
66
+ env.MULTI_CLAWD_ACCOUNT_ID = account.id;
67
+ env.MULTI_CLAWD_STATE_FILE = account.stateFile;
68
+ env[RETRY_ROSTER_ENV] = "";
69
+ return env;
70
+ }
package/dist/shim.js CHANGED
@@ -5,6 +5,7 @@ import { classifyStateReadFailure, clearCredentialFailure, createLineScanner, me
5
5
  import { rewriteModelArg } from "./degrade.js";
6
6
  import { parseModelLimitError, recordModelLimit } from "./shim-core.js";
7
7
  import { canonicalModelId } from "./models.js";
8
+ import { buildRetryEnv, chooseRetryAccount, parseRetryRoster, retryArming, RETRY_ROSTER_ENV, } from "./retry-plan.js";
8
9
  function resolveClaudeCommand() {
9
10
  const override = process.env.MULTI_CLAWD_CLAUDE_BIN;
10
11
  if (override) {
@@ -20,8 +21,8 @@ function resolveClaudeCommand() {
20
21
  }
21
22
  return { command: "claude", prependArgs: [] };
22
23
  }
23
- const stateFile = process.env.MULTI_CLAWD_STATE_FILE;
24
- const accountId = process.env.MULTI_CLAWD_ACCOUNT_ID ?? "unknown";
24
+ let stateFile = process.env.MULTI_CLAWD_STATE_FILE;
25
+ let accountId = process.env.MULTI_CLAWD_ACCOUNT_ID ?? "unknown";
25
26
  let state = { accountId, windows: {} };
26
27
  function preserveCorruptState(raw) {
27
28
  if (!stateFile)
@@ -87,11 +88,43 @@ if (modelOverride) {
87
88
  process.stderr.write(`[multi-clawd shim] degrading model for this launch → ${modelOverride}\n`);
88
89
  }
89
90
  }
90
- const child = spawn(command, childArgs, {
91
+ const retryRoster = parseRetryRoster(process.env[RETRY_ROSTER_ENV]);
92
+ const arming = retryArming(childArgs, retryRoster);
93
+ let retryArmed = arming.armed;
94
+ if (!retryArmed && retryRoster.length > 0 && arming.reason) {
95
+ process.stderr.write(`[multi-clawd shim] in-turn retry unavailable: ${arming.reason}\n`);
96
+ }
97
+ let child = spawn(command, childArgs, {
91
98
  stdio: ["pipe", "pipe", "inherit"],
92
99
  env: process.env,
93
100
  });
94
- process.stdin.pipe(child.stdin);
101
+ const STDIN_REPLAY_CAP_BYTES = 8 * 1024 * 1024;
102
+ let stdinReplay = [];
103
+ let stdinReplayBytes = 0;
104
+ let stdinEnded = false;
105
+ if (retryArmed) {
106
+ process.stdin.on("data", (chunk) => {
107
+ if (retryArmed) {
108
+ stdinReplayBytes += chunk.length;
109
+ if (stdinReplayBytes > STDIN_REPLAY_CAP_BYTES) {
110
+ retryArmed = false;
111
+ stdinReplay = [];
112
+ releaseHeldOutput();
113
+ }
114
+ else {
115
+ stdinReplay.push(chunk);
116
+ }
117
+ }
118
+ child.stdin.write(chunk);
119
+ });
120
+ process.stdin.on("end", () => {
121
+ stdinEnded = true;
122
+ child.stdin.end();
123
+ });
124
+ }
125
+ else {
126
+ process.stdin.pipe(child.stdin);
127
+ }
95
128
  function effectiveModelId() {
96
129
  const idx = childArgs.indexOf("--model");
97
130
  if (idx < 0 || idx + 1 >= childArgs.length)
@@ -109,7 +142,7 @@ function guessLimitResetsAt() {
109
142
  return undefined;
110
143
  }
111
144
  let sawAuthFailure = false;
112
- const scanner = createLineScanner((line) => {
145
+ function observeLine(line) {
113
146
  try {
114
147
  const event = parseRateLimitEvent(line);
115
148
  if (event) {
@@ -135,12 +168,154 @@ const scanner = createLineScanner((line) => {
135
168
  }
136
169
  catch {
137
170
  }
138
- });
139
- child.stdout.on("data", (chunk) => {
171
+ }
172
+ const scanner = createLineScanner(observeLine);
173
+ const HOLD_CAP_BYTES = 256 * 1024;
174
+ const HOLD_MAX_MS = Number(process.env.MULTI_CLAWD_HOLD_MAX_MS ?? 10_000);
175
+ let held = [];
176
+ let heldBytes = 0;
177
+ let holdTail = "";
178
+ let retryUsed = false;
179
+ function isPreambleRecord(line) {
180
+ if (line.length === 0)
181
+ return true;
182
+ try {
183
+ const record = JSON.parse(line);
184
+ if (record.type === "system" || record.type === "rate_limit_event")
185
+ return true;
186
+ if (record.type === "assistant" && record.message?.model === "<synthetic>")
187
+ return true;
188
+ return false;
189
+ }
190
+ catch {
191
+ return false;
192
+ }
193
+ }
194
+ function releaseHeldOutput() {
195
+ if (held.length > 0) {
196
+ process.stdout.write(held.join(""));
197
+ held = [];
198
+ heldBytes = 0;
199
+ }
200
+ if (holdTail.length > 0) {
201
+ process.stdout.write(holdTail);
202
+ scanner.push(holdTail);
203
+ holdTail = "";
204
+ }
205
+ retryArmed = false;
206
+ stdinReplay = [];
207
+ }
208
+ function readSiblingState(file) {
209
+ try {
210
+ return parseStoredState(readFileSync(file, "utf8"));
211
+ }
212
+ catch {
213
+ return undefined;
214
+ }
215
+ }
216
+ function attemptRetry() {
217
+ if (!retryArmed || retryUsed)
218
+ return false;
219
+ const model = effectiveModelId();
220
+ const target = chooseRetryAccount({
221
+ roster: retryRoster,
222
+ readState: readSiblingState,
223
+ modelId: model,
224
+ nowMs: Date.now(),
225
+ });
226
+ if (!target) {
227
+ process.stderr.write(`[multi-clawd shim] model limit on ${accountId} with no healthy sibling to retry onto — ` +
228
+ `passing the failure through to the host's chain\n`);
229
+ releaseHeldOutput();
230
+ return false;
231
+ }
232
+ retryUsed = true;
233
+ retryArmed = false;
234
+ process.stderr.write(`[multi-clawd shim] model limit on ${accountId}${model ? ` for ${model}` : ""} — ` +
235
+ `retrying this turn on ${target.id} (nothing forwarded downstream yet)\n`);
236
+ held = [];
237
+ heldBytes = 0;
238
+ holdTail = "";
239
+ const previous = child;
240
+ previous.stdout.removeAllListeners();
241
+ previous.removeAllListeners("close");
242
+ previous.removeAllListeners("error");
243
+ try {
244
+ previous.kill("SIGTERM");
245
+ }
246
+ catch {
247
+ }
248
+ accountId = target.id;
249
+ stateFile = target.stateFile;
250
+ state = { accountId, windows: {} };
251
+ sawAuthFailure = false;
252
+ child = spawn(command, childArgs, {
253
+ stdio: ["pipe", "pipe", "inherit"],
254
+ env: buildRetryEnv(process.env, target),
255
+ });
256
+ attachChildHandlers();
257
+ for (const chunk of stdinReplay)
258
+ child.stdin.write(chunk);
259
+ if (stdinEnded)
260
+ child.stdin.end();
261
+ stdinReplay = [];
262
+ return true;
263
+ }
264
+ function onArmedChunk(chunk) {
265
+ holdTail += chunk.toString("utf8");
266
+ while (retryArmed) {
267
+ const idx = holdTail.indexOf("\n");
268
+ if (idx < 0)
269
+ break;
270
+ const raw = holdTail.slice(0, idx + 1);
271
+ holdTail = holdTail.slice(idx + 1);
272
+ const line = raw.trim();
273
+ observeLine(line);
274
+ if (parseModelLimitError(line) && attemptRetry())
275
+ return;
276
+ held.push(raw);
277
+ heldBytes += raw.length;
278
+ if (!isPreambleRecord(line) || heldBytes > HOLD_CAP_BYTES) {
279
+ releaseHeldOutput();
280
+ return;
281
+ }
282
+ }
283
+ if (!retryArmed && holdTail.length > 0) {
284
+ const rest = holdTail;
285
+ holdTail = "";
286
+ process.stdout.write(rest);
287
+ scanner.push(rest);
288
+ }
289
+ }
290
+ if (retryArmed) {
291
+ const holdTimer = setTimeout(() => {
292
+ if (!retryArmed)
293
+ return;
294
+ process.stderr.write(`[multi-clawd shim] in-turn retry window closed after ${HOLD_MAX_MS}ms — releasing the stream\n`);
295
+ releaseHeldOutput();
296
+ }, HOLD_MAX_MS);
297
+ holdTimer.unref?.();
298
+ }
299
+ function onPassthroughChunk(chunk) {
140
300
  process.stdout.write(chunk);
141
301
  scanner.push(chunk.toString("utf8"));
142
- });
143
- child.stdout.on("end", () => scanner.flush());
302
+ }
303
+ function attachChildHandlers() {
304
+ const armedForThisChild = retryArmed;
305
+ child.stdout.on("data", (chunk) => {
306
+ if (armedForThisChild && retryArmed)
307
+ onArmedChunk(chunk);
308
+ else
309
+ onPassthroughChunk(chunk);
310
+ });
311
+ child.stdout.on("end", () => {
312
+ if (retryArmed)
313
+ releaseHeldOutput();
314
+ scanner.flush();
315
+ });
316
+ child.on("close", onChildClose);
317
+ child.on("error", onChildError);
318
+ }
144
319
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
145
320
  process.on(signal, () => {
146
321
  child.kill(signal);
@@ -159,7 +334,7 @@ function clearRecordedAuthFailureOnSuccess() {
159
334
  catch {
160
335
  }
161
336
  }
162
- child.on("close", (code, signal) => {
337
+ function onChildClose(code, signal) {
163
338
  if (signal) {
164
339
  process.kill(process.pid, signal);
165
340
  return;
@@ -167,8 +342,9 @@ child.on("close", (code, signal) => {
167
342
  if ((code ?? 0) === 0)
168
343
  clearRecordedAuthFailureOnSuccess();
169
344
  process.exit(code ?? 0);
170
- });
171
- child.on("error", (err) => {
345
+ }
346
+ function onChildError(err) {
172
347
  process.stderr.write(`[multi-clawd shim] failed to spawn claude: ${String(err)}\n`);
173
348
  process.exit(127);
174
- });
349
+ }
350
+ attachChildHandlers();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "1.8.10",
4
+ "version": "1.8.11",
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.8.10",
3
+ "version": "1.8.11",
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",