@drakon-systems/multi-clawd 2.4.0 → 2.5.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/dist/shim.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
- import { dirname } from "node:path";
4
- import { classifyStateReadFailure, clearCredentialFailure, clearModelRejection, createLineScanner, limitFamilyFor, mergeHealthStates, parseAuthFailure, parseRateLimitEvent, parseStoredState, parseSuccessResult, recordCredentialFailure, updateHealthState, } from "./shim-core.js";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { persistHealthState, readHealthStateFile } from "./health-journal.js";
4
+ import { acquireShimLock } from "./shim-lock.js";
5
+ import { recordHistory } from "./history.js";
6
+ import { clearCredentialFailure, clearModelRejection, createForwardWriter, createLineScanner, limitFamilyFor, parseAuthFailure, parseRateLimitEvent, parseSuccessResult, recordCredentialFailure, updateHealthState, } from "./shim-core.js";
5
7
  import { rewriteModelArg } from "./degrade.js";
6
- import { parseModelLimitError, recordModelLimit } from "./shim-core.js";
8
+ import { parseApiRetryNotice, parseModelLimitError, parseTransientApiError, recordModelLimit, } from "./shim-core.js";
7
9
  import { canonicalModelId } from "./models.js";
8
10
  import { buildRetryEnv, chooseLaunchableRetryAccount, materializeRetryAccount, parseRetryRoster, retryArming, RETRY_ROSTER_ENV, } from "./retry-plan.js";
9
11
  import { eligibleSessionAccount, launchSessionId, readSessionPin, recordSessionPin, SESSION_AFFINITY_ENV, } from "./session-affinity.js";
@@ -60,40 +62,12 @@ function preserveCorruptState(raw) {
60
62
  process.stderr.write(`[multi-clawd shim] state file unreadable/corrupt — starting fresh (${note})\n`);
61
63
  }
62
64
  function readPersistedState() {
63
- if (!stateFile)
64
- return undefined;
65
- let raw;
66
- try {
67
- raw = readFileSync(stateFile, "utf8");
68
- }
69
- catch (err) {
70
- if (classifyStateReadFailure(err) === "absent")
71
- return undefined;
72
- preserveCorruptState(undefined);
73
- return undefined;
74
- }
75
- const parsed = parseStoredState(raw);
76
- if (parsed === undefined) {
77
- preserveCorruptState(raw);
78
- return undefined;
79
- }
80
- return parsed;
65
+ return stateFile ? readHealthStateFile(stateFile, { onSnapshotError: preserveCorruptState }) : undefined;
81
66
  }
82
67
  function persistState() {
83
68
  if (!stateFile)
84
69
  return;
85
- try {
86
- const disk = readPersistedState();
87
- if (disk)
88
- state = mergeHealthStates(disk, state, Date.now());
89
- mkdirSync(dirname(stateFile), { recursive: true });
90
- const tmp = `${stateFile}.tmp-${process.pid}`;
91
- writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
92
- renameSync(tmp, stateFile);
93
- }
94
- catch (err) {
95
- process.stderr.write(`[multi-clawd shim] state write failed: ${String(err)}\n`);
96
- }
70
+ state = persistHealthState(stateFile, state, { onSnapshotError: preserveCorruptState });
97
71
  }
98
72
  const { command, prependArgs, argv0: claudeArgv0 } = resolveClaudeCommand();
99
73
  let childArgs = [...prependArgs, ...process.argv.slice(2)];
@@ -163,6 +137,8 @@ let child = spawn(command, childArgs, {
163
137
  env: launchEnv,
164
138
  ...(claudeArgv0 ? { argv0: claudeArgv0 } : {}),
165
139
  });
140
+ child.once("spawn", () => recordHistory(stateFile, { at: new Date().toISOString(),
141
+ kind: "served", accountId, modelId: effectiveModelId() }));
166
142
  const STDIN_REPLAY_CAP_BYTES = 8 * 1024 * 1024;
167
143
  let stdinReplay = [];
168
144
  let stdinReplayBytes = 0;
@@ -209,6 +185,7 @@ function guessLimitResetsAt() {
209
185
  let sawAuthFailure = false;
210
186
  let sawModelLimit = false;
211
187
  let sawSuccessResult = false;
188
+ let recordedLimitDisplayName;
212
189
  function observeLine(line) {
213
190
  try {
214
191
  if (parseSuccessResult(line))
@@ -219,7 +196,8 @@ function observeLine(line) {
219
196
  persistState();
220
197
  }
221
198
  const limitHit = parseModelLimitError(line);
222
- if (limitHit) {
199
+ if (limitHit && limitHit.displayName !== recordedLimitDisplayName) {
200
+ recordedLimitDisplayName = limitHit.displayName;
223
201
  sawModelLimit = true;
224
202
  const model = effectiveModelId();
225
203
  if (model) {
@@ -242,12 +220,17 @@ function observeLine(line) {
242
220
  }
243
221
  }
244
222
  const scanner = createLineScanner(observeLine);
223
+ const forward = createForwardWriter((bytes) => process.stdout.write(bytes), () => process.stderr.write(`[multi-clawd shim] model limit on ${accountId} forwarded to the host marked as a rate limit\n`));
245
224
  const HOLD_CAP_BYTES = 256 * 1024;
246
225
  const HOLD_MAX_MS = Number(process.env.MULTI_CLAWD_HOLD_MAX_MS ?? 10_000);
226
+ const HOLD_RETRY_MAX_MS = Math.min(Number(process.env.MULTI_CLAWD_HOLD_RETRY_MAX_MS ?? 120_000), 150_000);
227
+ const HOLD_RETRY_GRACE_MS = 5_000;
247
228
  let held = [];
248
229
  let heldBytes = 0;
249
230
  let holdTail = "";
250
231
  let retryUsed = false;
232
+ let pendingRetryCause;
233
+ let sawTerminalFailure = false;
251
234
  function isPreambleRecord(line) {
252
235
  if (line.length === 0)
253
236
  return true;
@@ -265,12 +248,12 @@ function isPreambleRecord(line) {
265
248
  }
266
249
  function releaseHeldOutput() {
267
250
  if (held.length > 0) {
268
- process.stdout.write(held.join(""));
251
+ forward.push(Buffer.from(held.join(""), "utf8"));
269
252
  held = [];
270
253
  heldBytes = 0;
271
254
  }
272
255
  if (holdTail.length > 0) {
273
- process.stdout.write(holdTail);
256
+ forward.push(Buffer.from(holdTail, "utf8"));
274
257
  scanner.push(holdTail);
275
258
  holdTail = "";
276
259
  }
@@ -278,14 +261,15 @@ function releaseHeldOutput() {
278
261
  stdinReplay = [];
279
262
  }
280
263
  function readSiblingState(file) {
281
- try {
282
- return parseStoredState(readFileSync(file, "utf8"));
283
- }
284
- catch {
285
- return undefined;
286
- }
264
+ return readHealthStateFile(file);
265
+ }
266
+ function describeCause(cause) {
267
+ if (cause.kind === "model_limit")
268
+ return "model limit";
269
+ const d = cause.detail;
270
+ return `transient provider error (${d.kind}${d.status !== undefined ? ` ${d.status}` : ""})`;
287
271
  }
288
- function attemptRetry() {
272
+ function attemptRetry(cause) {
289
273
  if (!retryArmed || retryUsed)
290
274
  return false;
291
275
  const model = effectiveModelId();
@@ -294,13 +278,13 @@ function attemptRetry() {
294
278
  readState: readSiblingState,
295
279
  modelId: model,
296
280
  nowMs: Date.now(),
281
+ allowOverdueProbe: cause.kind !== "transient",
297
282
  materialize: (account) => materializeRetryAccount(account, (path) => readFileSync(path, "utf8")),
298
283
  onSkip: (id, reason) => process.stderr.write(`[multi-clawd shim] not retrying onto ${id}: its declared credential did not resolve (${reason})\n`),
299
284
  });
300
285
  if (!target) {
301
- process.stderr.write(`[multi-clawd shim] model limit on ${accountId} with no healthy sibling to retry onto — ` +
286
+ process.stderr.write(`[multi-clawd shim] ${describeCause(cause)} on ${accountId} with no healthy sibling to retry onto — ` +
302
287
  `passing the failure through to the host's chain\n`);
303
- releaseHeldOutput();
304
288
  return false;
305
289
  }
306
290
  if (resumeSnapshot) {
@@ -308,41 +292,40 @@ function attemptRetry() {
308
292
  handoverSnapshotTo(resumeSnapshot, effectiveConfigDir(target.env));
309
293
  }
310
294
  catch (err) {
311
- process.stderr.write(`[multi-clawd shim] model limit on ${accountId}, but session ` +
295
+ process.stderr.write(`[multi-clawd shim] ${describeCause(cause)} on ${accountId}, but session ` +
312
296
  `${resumeSnapshot.sessionId.slice(0, 8)} could not be handed to ${target.id} ` +
313
297
  `(${err instanceof Error ? err.message : String(err)}) — ` +
314
298
  `passing the failure through to the host's chain\n`);
315
- releaseHeldOutput();
316
299
  return false;
317
300
  }
318
301
  }
319
302
  retryUsed = true;
320
303
  retryArmed = false;
321
- process.stderr.write(`[multi-clawd shim] model limit on ${accountId}${model ? ` for ${model}` : ""} — ` +
304
+ const reason = cause.kind === "model_limit" ? "model_limit" : "transient";
305
+ recordHistory(stateFile, { at: new Date().toISOString(), kind: "retry", accountId,
306
+ toAccountId: target.id, modelId: model, reason });
307
+ if (target.id !== accountId)
308
+ recordHistory(stateFile, { at: new Date().toISOString(),
309
+ kind: "rotation", accountId, toAccountId: target.id, modelId: model, reason });
310
+ process.stderr.write(`[multi-clawd shim] ${describeCause(cause)} on ${accountId}${model ? ` for ${model}` : ""} — ` +
322
311
  `retrying this turn on ${target.id}${resumeSnapshot ? `, session ${resumeSnapshot.sessionId.slice(0, 8)} handed over` : ""} (nothing forwarded downstream yet)\n`);
323
312
  held = [];
324
313
  heldBytes = 0;
325
314
  holdTail = "";
326
- const previous = child;
327
- previous.stdout.removeAllListeners();
328
- previous.removeAllListeners("close");
329
- previous.removeAllListeners("error");
330
- try {
331
- previous.kill("SIGTERM");
332
- }
333
- catch {
334
- }
335
315
  accountId = target.id;
336
316
  stateFile = target.stateFile;
337
317
  state = { accountId, windows: {} };
338
318
  sawAuthFailure = false;
339
319
  sawModelLimit = false;
320
+ recordedLimitDisplayName = undefined;
340
321
  sawSuccessResult = false;
341
322
  child = spawn(command, childArgs, {
342
323
  stdio: ["pipe", "pipe", "inherit"],
343
324
  env: buildRetryEnv(launchEnv, target),
344
325
  ...(claudeArgv0 ? { argv0: claudeArgv0 } : {}),
345
326
  });
327
+ child.once("spawn", () => recordHistory(stateFile, { at: new Date().toISOString(),
328
+ kind: "served", accountId, modelId: effectiveModelId() }));
346
329
  attachChildHandlers();
347
330
  for (const chunk of stdinReplay)
348
331
  child.stdin.write(chunk);
@@ -360,34 +343,84 @@ function onArmedChunk(chunk) {
360
343
  const raw = holdTail.slice(0, idx + 1);
361
344
  holdTail = holdTail.slice(idx + 1);
362
345
  const line = raw.trim();
363
- observeLine(line);
364
- if (parseModelLimitError(line) && attemptRetry())
365
- return;
366
- held.push(raw);
367
- heldBytes += raw.length;
368
- if (!isPreambleRecord(line) || heldBytes > HOLD_CAP_BYTES) {
369
- releaseHeldOutput();
370
- return;
346
+ const releaseDecision = stateFile && (parseModelLimitError(line) || parseTransientApiError(line))
347
+ ? acquireShimLock(`${stateFile}.retry`)
348
+ : undefined;
349
+ try {
350
+ observeLine(line);
351
+ let cause;
352
+ if (parseModelLimitError(line)) {
353
+ cause = { kind: "model_limit" };
354
+ }
355
+ else if (!sawModelLimit && !sawAuthFailure) {
356
+ const transient = parseTransientApiError(line);
357
+ if (transient)
358
+ cause = { kind: "transient", detail: transient };
359
+ }
360
+ if (cause && (!pendingRetryCause || cause.kind === "model_limit")) {
361
+ pendingRetryCause = cause;
362
+ }
363
+ let terminalFailure = false;
364
+ try {
365
+ const record = JSON.parse(line);
366
+ terminalFailure = record.type === "result" && record.is_error === true;
367
+ }
368
+ catch { }
369
+ if (terminalFailure)
370
+ sawTerminalFailure = true;
371
+ const retryNotice = parseApiRetryNotice(line);
372
+ if (retryNotice)
373
+ extendHold(retryNotice.retryDelayMs + HOLD_RETRY_GRACE_MS);
374
+ held.push(raw);
375
+ heldBytes += Buffer.byteLength(raw);
376
+ if ((!isPreambleRecord(line) && !(terminalFailure && pendingRetryCause && !sawAuthFailure)) || heldBytes > HOLD_CAP_BYTES) {
377
+ releaseHeldOutput();
378
+ return;
379
+ }
380
+ }
381
+ finally {
382
+ releaseDecision?.();
371
383
  }
372
384
  }
385
+ if (retryArmed && heldBytes + Buffer.byteLength(holdTail) > HOLD_CAP_BYTES) {
386
+ releaseHeldOutput();
387
+ }
373
388
  if (!retryArmed && holdTail.length > 0) {
374
389
  const rest = holdTail;
375
390
  holdTail = "";
376
- process.stdout.write(rest);
391
+ forward.push(Buffer.from(rest, "utf8"));
377
392
  scanner.push(rest);
378
393
  }
379
394
  }
380
- if (retryArmed) {
381
- const holdTimer = setTimeout(() => {
395
+ let holdTimer;
396
+ const holdStartedAt = Date.now();
397
+ let holdDeadline = holdStartedAt + HOLD_MAX_MS;
398
+ function armHoldTimer() {
399
+ if (holdTimer)
400
+ clearTimeout(holdTimer);
401
+ holdTimer = setTimeout(() => {
382
402
  if (!retryArmed)
383
403
  return;
384
- process.stderr.write(`[multi-clawd shim] in-turn retry window closed after ${HOLD_MAX_MS}ms — releasing the stream\n`);
404
+ process.stderr.write(`[multi-clawd shim] in-turn retry window closed after ${Date.now() - holdStartedAt}ms — releasing the stream\n`);
385
405
  releaseHeldOutput();
386
- }, HOLD_MAX_MS);
406
+ }, Math.max(0, holdDeadline - Date.now()));
387
407
  holdTimer.unref?.();
388
408
  }
409
+ function extendHold(ms) {
410
+ if (!retryArmed)
411
+ return;
412
+ const wanted = Date.now() + ms;
413
+ const ceiling = holdStartedAt + HOLD_RETRY_MAX_MS;
414
+ const next = Math.min(wanted, ceiling);
415
+ if (next <= holdDeadline)
416
+ return;
417
+ holdDeadline = next;
418
+ armHoldTimer();
419
+ }
420
+ if (retryArmed)
421
+ armHoldTimer();
389
422
  function onPassthroughChunk(chunk) {
390
- process.stdout.write(chunk);
423
+ forward.push(chunk);
391
424
  scanner.push(chunk.toString("utf8"));
392
425
  }
393
426
  function attachChildHandlers() {
@@ -399,8 +432,10 @@ function attachChildHandlers() {
399
432
  onPassthroughChunk(chunk);
400
433
  });
401
434
  child.stdout.on("end", () => {
402
- if (retryArmed)
435
+ if (retryArmed && (!sawTerminalFailure || holdTail.length > 0))
403
436
  releaseHeldOutput();
437
+ if (!retryArmed)
438
+ forward.flush();
404
439
  scanner.flush();
405
440
  });
406
441
  child.on("close", onChildClose);
@@ -451,6 +486,20 @@ function clearRecordedModelRejectionOnSuccess() {
451
486
  }
452
487
  }
453
488
  function onChildClose(code, signal) {
489
+ if (!signal && retryArmed && sawTerminalFailure && !sawSuccessResult &&
490
+ !sawAuthFailure && pendingRetryCause) {
491
+ const releaseDecision = stateFile ? acquireShimLock(`${stateFile}.retry`) : undefined;
492
+ try {
493
+ if (attemptRetry(pendingRetryCause))
494
+ return;
495
+ }
496
+ finally {
497
+ releaseDecision?.();
498
+ }
499
+ }
500
+ if (retryArmed)
501
+ releaseHeldOutput();
502
+ forward.flush();
454
503
  if (signal) {
455
504
  process.kill(process.pid, signal);
456
505
  return;
package/dist/spread.js ADDED
@@ -0,0 +1,54 @@
1
+ export const DEFAULT_SPREAD_WINDOW_MS = 60 * 60 * 1000;
2
+ export const MIN_SPREAD_WINDOW_MS = 60 * 1000;
3
+ export const MAX_SPREAD_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
4
+ export function spreadWindowMs(value) {
5
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
6
+ return DEFAULT_SPREAD_WINDOW_MS;
7
+ return Math.min(MAX_SPREAD_WINDOW_MS, Math.max(MIN_SPREAD_WINDOW_MS, value));
8
+ }
9
+ export function spreadStatsFromHistory(events, nowMs, windowMs = DEFAULT_SPREAD_WINDOW_MS) {
10
+ const cutoff = nowMs - windowMs;
11
+ const stats = new Map();
12
+ for (const event of events) {
13
+ if (event.kind !== "served")
14
+ continue;
15
+ const at = Date.parse(event.at);
16
+ if (!Number.isFinite(at) || at < cutoff || at > nowMs)
17
+ continue;
18
+ const current = stats.get(event.accountId) ?? { servedInWindow: 0 };
19
+ current.servedInWindow += 1;
20
+ if (current.lastServedAt === undefined || at > current.lastServedAt)
21
+ current.lastServedAt = at;
22
+ stats.set(event.accountId, current);
23
+ }
24
+ return stats;
25
+ }
26
+ export function spreadStatsFor(stats, accountId) {
27
+ const s = stats.get(accountId);
28
+ return { servedInWindow: s?.servedInWindow ?? 0, ...(s?.lastServedAt !== undefined ? { lastServedAt: s.lastServedAt } : {}) };
29
+ }
30
+ export function withSpreadStats(verdicts, stats) {
31
+ return verdicts.map((v) => ({ ...v, ...spreadStatsFor(stats, v.id) }));
32
+ }
33
+ export function spreadBefore(a, b) {
34
+ const served = (a.servedInWindow ?? 0) - (b.servedInWindow ?? 0);
35
+ if (served !== 0)
36
+ return served < 0;
37
+ return (a.lastServedAt ?? -Infinity) < (b.lastServedAt ?? -Infinity);
38
+ }
39
+ export function describeSpreadPick(picked, windowMs) {
40
+ const window = describeWindow(windowMs);
41
+ const n = picked.servedInWindow ?? 0;
42
+ if (n === 0)
43
+ return `not served in the last ${window}`;
44
+ return `fewest served launches in the last ${window} (${n})`;
45
+ }
46
+ export function describeWindow(windowMs) {
47
+ const minutes = Math.round(windowMs / 60_000);
48
+ if (minutes < 60)
49
+ return `${minutes}m`;
50
+ const hours = minutes / 60;
51
+ if (hours < 48)
52
+ return Number.isInteger(hours) ? `${hours}h` : `${hours.toFixed(1)}h`;
53
+ return `${Math.round(hours / 24)}d`;
54
+ }
package/dist/sticky.js CHANGED
@@ -7,6 +7,8 @@ export function decideStickySelection(params) {
7
7
  const healthChoice = choosePoolAccount(verdicts, params.strategy);
8
8
  if (!healthChoice)
9
9
  return { account: fallbackPoolAccount(verdicts, nowMs) };
10
+ if (params.strategy === "spread")
11
+ return { account: healthChoice };
10
12
  if (params.strategy === "reset-aware") {
11
13
  const current = sticky && verdicts.find((v) => v.id === sticky.account);
12
14
  const stillUsable = current?.verdict === "ok" || current?.verdict === "no_data";
@@ -15,6 +15,80 @@ export function decideUpdateAction(opts) {
15
15
  return "unknown";
16
16
  return compareVersions(opts.installed, opts.latest) < 0 ? "update" : "up-to-date";
17
17
  }
18
+ export function pluginInstallAttempts(pkg, version) {
19
+ return [
20
+ ["plugins", "install", `clawhub:${pkg}@${version}`, "--force"],
21
+ ["plugins", "install", `${pkg}@${version}`, "--pin", "--force"],
22
+ ];
23
+ }
24
+ export function exactTargetVersion(version) {
25
+ return version && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?$/.test(version)
26
+ ? version : undefined;
27
+ }
28
+ export function classifyClawhubFailure(result, output) {
29
+ if (result.status === 0 || result.signal || result.status === null || result.error != null || result.status >= 128)
30
+ return "stop";
31
+ if (/moderation|integrity|trust|policy|signature|security|malicious|quarantin|revoked|blocked|cancel(?:led|ed)?|abort|clawhub_(?:download_blocked|security_unavailable|archive_integrity_mismatch)/i.test(output))
32
+ return "stop";
33
+ if (/package not found on clawhub|version not found on clawhub|\b(?:package_not_found|version_not_found)\b/i.test(output))
34
+ return "fallback";
35
+ if (/clawhub.{0,80}(?:unreachable|temporarily unavailable|network error|fetch failed|connection (?:failed|refused|timed out))|ClawHub .* failed \(5\d\d\)|(?:ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT)/i.test(output))
36
+ return "fallback";
37
+ if (/unknown (?:option|command)|unsupported (?:spec|source|protocol)|invalid (?:spec|source).{0,80}clawhub:|clawhub:.{0,80}(?:not supported|unsupported)/i.test(output))
38
+ return "fallback";
39
+ return "stop";
40
+ }
41
+ function asInstallRecord(value) {
42
+ if (!value || typeof value !== "object" || Array.isArray(value))
43
+ return undefined;
44
+ const record = value;
45
+ if (typeof record.source !== "string")
46
+ return undefined;
47
+ return {
48
+ source: record.source,
49
+ ...(typeof record.version === "string" ? { version: record.version } : {}),
50
+ ...(typeof record.clawhubChannel === "string" ? { clawhubChannel: record.clawhubChannel } : {}),
51
+ ...(typeof record.installPath === "string" ? { installPath: record.installPath } : {}),
52
+ };
53
+ }
54
+ export function parseInstallRecord(inspectJsonText, openclawConfig) {
55
+ let inspected;
56
+ try {
57
+ inspected = JSON.parse(inspectJsonText ?? "");
58
+ }
59
+ catch {
60
+ }
61
+ const inspectRecord = inspected && typeof inspected === "object" && !Array.isArray(inspected)
62
+ ? asInstallRecord(inspected.install)
63
+ : undefined;
64
+ if (inspectRecord)
65
+ return inspectRecord;
66
+ const config = openclawConfig && typeof openclawConfig === "object" && !Array.isArray(openclawConfig)
67
+ ? openclawConfig
68
+ : undefined;
69
+ return asInstallRecord(config?.plugins?.installs?.["multi-clawd"]);
70
+ }
71
+ export function classifyInstallSource(record) {
72
+ switch (record?.source) {
73
+ case "clawhub":
74
+ return {
75
+ level: "ok",
76
+ text: `install source: ClawHub${record.version ? ` v${record.version}` : ""}${record.clawhubChannel ? ` (${record.clawhubChannel} channel)` : ""}`,
77
+ };
78
+ case "npm":
79
+ return {
80
+ level: "warn",
81
+ text: "install source: npm — OpenClaw shows an untrusted-source caution for npm installs. Fix: multi-clawd update moves it to ClawHub",
82
+ };
83
+ case "path":
84
+ case "archive":
85
+ case "git":
86
+ case "marketplace":
87
+ return { level: "note", text: `install source: ${record.source} (source/dev install)` };
88
+ default:
89
+ return undefined;
90
+ }
91
+ }
18
92
  export function classifyCliSkew(opts) {
19
93
  if (opts.pluginVersion === undefined)
20
94
  return "plugin-missing";
@@ -80,7 +154,7 @@ export function formatUpdateBanner(opts) {
80
154
  const action = decideUpdateAction(opts);
81
155
  switch (action) {
82
156
  case "install":
83
- return `not installed — latest is v${opts.latest}`;
157
+ return opts.latest ? `not installed — latest is v${opts.latest}` : "not installed — could not determine the latest version";
84
158
  case "update":
85
159
  return `update available: v${opts.installed} → v${opts.latest}`;
86
160
  case "up-to-date":
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "2.4.0",
4
+ "version": "2.5.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",
@@ -45,8 +45,17 @@
45
45
  },
46
46
  "strategy": {
47
47
  "type": "string",
48
- "enum": ["ordered", "reset-aware"],
49
- "description": "Healthy account selection: ordered (default) keeps pool order; reset-aware prefers the healthy account whose live seven-day quota window resets soonest."
48
+ "enum": [
49
+ "ordered",
50
+ "reset-aware",
51
+ "spread"
52
+ ],
53
+ "description": "Healthy account selection: ordered (default) keeps pool order; reset-aware prefers the healthy account whose live seven-day quota window resets soonest; spread starts each new session on the healthy account with the fewest served launches in the recent window (spreadWindowMs) and keeps sessions on their serving account (implies sessionAffinity). Near-limit, exhausted and credential-failed accounts are never chosen by any strategy."
54
+ },
55
+ "spreadWindowMs": {
56
+ "type": "number",
57
+ "exclusiveMinimum": 0,
58
+ "description": "Spread strategy only: how far back served launches count when placing a new session. Default 3600000 (1h); clamped to 60000..604800000."
50
59
  },
51
60
  "utilizationThreshold": {
52
61
  "type": "number",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/multi-clawd",
3
- "version": "2.4.0",
3
+ "version": "2.5.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",