@drakon-systems/multi-clawd 1.8.1 → 1.8.3

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
@@ -73,7 +73,10 @@ multi-clawd hermes sync --profile work --strategy least_used
73
73
  multi-clawd hermes doctor --config ~/configs/openclaw.json
74
74
  ```
75
75
 
76
- Validated against Hermes Agent **0.19.1**.
76
+ Validated against Hermes Agent **0.20.6**. The bridge/core/CLI integration
77
+ tests exercise that installed release's real Python APIs in isolated temporary
78
+ homes; on machines without a compatible Hermes install, those integration tests
79
+ skip while the no-Hermes CLI safety tests still run.
77
80
 
78
81
  ### Only stable setup tokens are imported
79
82
 
@@ -94,7 +97,7 @@ Everything else is refused, on purpose:
94
97
  on that source instead of duplicating the grant.
95
98
  - **`configDir` logins are not importable, and Hermes cannot be pointed at
96
99
  them either.** The same single-use-refresh-token problem applies, and unlike
97
- a native login there is no Hermes-side fallback: as of Hermes Agent 0.19.1,
100
+ a native login there is no Hermes-side fallback: as of Hermes Agent 0.20.6,
98
101
  its `claude_code` credential source reads only the native path above, never
99
102
  an arbitrary `configDir`. A `configDir` account can only reach Hermes' pool
100
103
  by getting its own `oauthTokenFile` (a `claude setup-token`, same as above),
@@ -364,7 +367,7 @@ openclaw plugins install (Get-Location).Path
364
367
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
365
368
  Code on the target machine already? Paste it this and go make coffee:
366
369
 
367
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.8.1/SETUP-AGENT.md
370
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.8.3/SETUP-AGENT.md
368
371
  > and follow it to set up multi-clawd on this machine. I own a second
369
372
  > Claude account — ask me when you need me to log in.
370
373
 
@@ -377,7 +380,11 @@ happens to say today. Swap the tag if you want a different release, but
377
380
  prefer a tag over a branch.
378
381
 
379
382
  **Requirements:** OpenClaw ≥ 2026.6, the `claude` CLI on `PATH`, and a
380
- second Claude subscription you own.
383
+ second Claude subscription you own. The current plugin SDK contract and runtime
384
+ registration path are tested against OpenClaw **2026.8.1**. The standalone
385
+ `multi-clawd` CLI and Hermes commands do not require the optional OpenClaw peer
386
+ to be resolvable; plugin loading and OpenClaw-backed commands still require the
387
+ host-provided peer.
381
388
 
382
389
  **Upgrading:**
383
390
 
@@ -596,7 +603,7 @@ Three moves, all through the official plugin SDK (details in
596
603
  (`CLAUDE_CONFIG_DIR` + `CLAUDE_CODE_OAUTH_TOKEN`) into the child process
597
604
  env, after the host's ambient Claude credentials are stripped.
598
605
 
599
- ## Known issue: idle backends can be evicted by OpenClaw core
606
+ ## Legacy issue: idle backend eviction on OpenClaw 2026.7.1
600
607
 
601
608
  On OpenClaw ≤ 2026.7.1, core's *scoped* harness activation can silently drop
602
609
  a plugin-registered CLI backend from the live registry: when an agent turn
@@ -608,9 +615,10 @@ list` (a separate cache) still lists its models. A common real-world trigger
608
615
  is an hourly heartbeat running on a model served by another harness.
609
616
 
610
617
  - Upstream bug: [openclaw#107408](https://github.com/openclaw/openclaw/issues/107408)
611
- - Upstream fix: [openclaw#107596](https://github.com/openclaw/openclaw/pull/107596)
618
+ - Upstream fix: [openclaw#108110](https://github.com/openclaw/openclaw/pull/108110)
612
619
 
613
- **Until that lands:** a gateway restart always restores the backend (startup
620
+ OpenClaw 2026.8.1 includes the fix. On affected older runtimes, a gateway
621
+ restart always restores the backend (startup
614
622
  loads are full-scope), and backends that are in regular use effectively
615
623
  re-assert themselves. This repo ships a ready-made mitigation —
616
624
  `scripts/eviction-watchdog.mjs` — which tails the gateway log for the
package/SECURITY.md CHANGED
@@ -30,9 +30,10 @@ credit you (unless you'd rather we didn't), and note it in the CHANGELOG.
30
30
  `oauthTokenFile`, multi-clawd warns (once per process) when the file is
31
31
  readable beyond your own user account. It warns rather than refuses: the
32
32
  credential still works, and the fix is yours to make — `chmod 600`.
33
- - **Host credentials are stripped from child processes.** 22 Claude/Anthropic
34
- environment variables are cleared before each launch, so one account's
35
- credential cannot bleed into another account's session.
33
+ - **Host credentials are stripped from child processes.** 42 Claude/Anthropic
34
+ environment variables covering authentication, runtime, and telemetry are
35
+ cleared before each launch, so one account's credential cannot bleed into
36
+ another account's session.
36
37
  - **State files are written `0600`,** atomically (temp file + rename), and
37
38
  contain rate-limit telemetry only — never credentials.
38
39
 
package/dist/index.js CHANGED
@@ -1,6 +1,4 @@
1
- import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2
- import { resolvePluginConfigObject, resolveLivePluginConfigObject, } from "openclaw/plugin-sdk/plugin-config-runtime";
3
- import { CLI_FRESH_WATCHDOG_DEFAULTS, CLI_RESUME_WATCHDOG_DEFAULTS, } from "openclaw/plugin-sdk/cli-backend";
1
+ import { definePluginEntry, } from "openclaw/plugin-sdk/plugin-entry";
4
2
  import { homedir } from "node:os";
5
3
  import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
6
4
  import { dirname, join, resolve } from "node:path";
@@ -21,6 +19,16 @@ import { buildAccountChildEnv, tokenFileModeWarning, validateAccountTokenSources
21
19
  import { diffCatalogModels, formatNewModelNotice, } from "./model-currency.js";
22
20
  import { checkAccountCredential, createRefProbeTracker, } from "./login-health.js";
23
21
  import { execFileSync } from "node:child_process";
22
+ const CLI_FRESH_WATCHDOG_DEFAULTS = {
23
+ noOutputTimeoutRatio: 0.8,
24
+ minMs: 180_000,
25
+ maxMs: 600_000,
26
+ };
27
+ const CLI_RESUME_WATCHDOG_DEFAULTS = {
28
+ noOutputTimeoutRatio: 0.3,
29
+ minMs: 60_000,
30
+ maxMs: 180_000,
31
+ };
24
32
  const BASE_ARGS = [
25
33
  "-p",
26
34
  "--output-format",
@@ -34,7 +42,7 @@ const BASE_ARGS = [
34
42
  "--disallowedTools",
35
43
  "ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor",
36
44
  ];
37
- const CLEAR_ENV = [
45
+ export const CLEAR_ENV = [
38
46
  "ANTHROPIC_API_KEY",
39
47
  "ANTHROPIC_API_KEY_OLD",
40
48
  "ANTHROPIC_API_TOKEN",
@@ -44,6 +52,10 @@ const CLEAR_ENV = [
44
52
  "ANTHROPIC_OAUTH_TOKEN",
45
53
  "ANTHROPIC_UNIX_SOCKET",
46
54
  "CLAUDE_CONFIG_DIR",
55
+ "CLAUDE_CODE_AUTO_COMPACT_WINDOW",
56
+ "CLAUDE_CODE_DISABLE_1M_CONTEXT",
57
+ "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING",
58
+ "MAX_THINKING_TOKENS",
47
59
  "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR",
48
60
  "CLAUDE_CODE_ENTRYPOINT",
49
61
  "CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
@@ -57,6 +69,22 @@ const CLEAR_ENV = [
57
69
  "CLAUDE_CODE_USE_BEDROCK",
58
70
  "CLAUDE_CODE_USE_FOUNDRY",
59
71
  "CLAUDE_CODE_USE_VERTEX",
72
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
73
+ "OTEL_EXPORTER_OTLP_HEADERS",
74
+ "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
75
+ "OTEL_EXPORTER_OTLP_LOGS_HEADERS",
76
+ "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL",
77
+ "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
78
+ "OTEL_EXPORTER_OTLP_METRICS_HEADERS",
79
+ "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL",
80
+ "OTEL_EXPORTER_OTLP_PROTOCOL",
81
+ "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
82
+ "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
83
+ "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
84
+ "OTEL_LOGS_EXPORTER",
85
+ "OTEL_METRICS_EXPORTER",
86
+ "OTEL_SDK_DISABLED",
87
+ "OTEL_TRACES_EXPORTER",
60
88
  ];
61
89
  function expandHome(p) {
62
90
  if (p === "~")
@@ -65,6 +93,18 @@ function expandHome(p) {
65
93
  return resolve(homedir(), p.slice(2));
66
94
  return resolve(p);
67
95
  }
96
+ function pluginConfigFromRoot(root, pluginId) {
97
+ if (typeof root !== "object" || root === null)
98
+ return undefined;
99
+ const entries = root.plugins?.entries;
100
+ const entry = entries?.[pluginId];
101
+ if (typeof entry !== "object" || entry === null)
102
+ return undefined;
103
+ const config = entry.config;
104
+ return typeof config === "object" && config !== null && !Array.isArray(config)
105
+ ? config
106
+ : undefined;
107
+ }
68
108
  let activeTokenResolver;
69
109
  let alertState = { alerts: [] };
70
110
  let loginProbeTimer;
@@ -336,7 +376,7 @@ function checkModelCurrency(catalogIds, chainRefs, poolId) {
336
376
  }
337
377
  }
338
378
  export function buildBackend(account, execMode) {
339
- return {
379
+ const backend = {
340
380
  id: account.id,
341
381
  liveTest: {
342
382
  defaultModelRef: `${account.id}/${account.defaultModel ?? "claude-fable-5"}`,
@@ -356,6 +396,8 @@ export function buildBackend(account, execMode) {
356
396
  command: process.execPath,
357
397
  args: [SHIM_PATH, ...BASE_ARGS, ...permissionModeArgs(execMode)],
358
398
  resumeArgs: [SHIM_PATH, ...BASE_ARGS, ...permissionModeArgs(execMode), "--resume", "{sessionId}"],
399
+ forkArg: "--fork-session",
400
+ resumeAtArg: "--resume-session-at",
359
401
  output: "jsonl",
360
402
  liveSession: "claude-stdio",
361
403
  input: "stdin",
@@ -364,8 +406,9 @@ export function buildBackend(account, execMode) {
364
406
  modelAliases: { ...MODEL_ALIASES },
365
407
  imageArg: "@",
366
408
  imagePathScope: "workspace",
367
- sessionArg: "--session-id",
409
+ sessionArgs: ["--session-id", "{sessionId}"],
368
410
  sessionMode: "always",
411
+ freshSessionRecovery: "invalidated-only",
369
412
  reseedFromRawTranscriptWhenUncompacted: true,
370
413
  sessionIdFields: ["session_id", "sessionId", "conversation_id", "conversationId"],
371
414
  systemPromptFileArg: "--append-system-prompt-file",
@@ -384,6 +427,8 @@ export function buildBackend(account, execMode) {
384
427
  return { env: await buildAccountEnv(account) };
385
428
  },
386
429
  };
430
+ Object.assign(backend.config, { sessionArg: "--session-id" });
431
+ return backend;
387
432
  }
388
433
  async function buildAccountEnv(account, resolver) {
389
434
  const token = await resolveTokenAsync(account, resolver);
@@ -418,12 +463,12 @@ export default definePluginEntry({
418
463
  name: "multi-clawd",
419
464
  description: "Register additional Claude Code logins as first-class OpenClaw CLI backends for cross-account failover.",
420
465
  register(api) {
421
- const runtimeConfigLoader = (api.runtime?.config?.current
466
+ const runtimeConfigLoader = api.runtime?.config?.current
422
467
  ? () => api.runtime.config.current()
423
- : undefined);
468
+ : undefined;
424
469
  const candidates = [
425
- resolveLivePluginConfigObject(runtimeConfigLoader, "multi-clawd", api.pluginConfig),
426
- resolvePluginConfigObject(api.config, "multi-clawd"),
470
+ pluginConfigFromRoot(runtimeConfigLoader?.(), "multi-clawd"),
471
+ pluginConfigFromRoot(api.config, "multi-clawd"),
427
472
  api.pluginConfig,
428
473
  ];
429
474
  const hasAccounts = (c) => Array.isArray(c?.accounts) &&
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "1.8.1",
4
+ "version": "1.8.3",
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.1",
3
+ "version": "1.8.3",
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",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "openclaw": {
23
23
  "build": {
24
- "openclawVersion": "2026.6.11"
24
+ "openclawVersion": "2026.8.1"
25
25
  },
26
26
  "extensions": [
27
27
  "./src/index.ts"
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "main": "./dist/index.js",
38
38
  "bin": {
39
- "multi-clawd": "./scripts/cli.mjs"
39
+ "multi-clawd": "scripts/cli.mjs"
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public"
@@ -72,7 +72,7 @@
72
72
  }
73
73
  },
74
74
  "devDependencies": {
75
- "openclaw": "2026.7.1",
75
+ "openclaw": "2026.8.1",
76
76
  "typescript": "^5.9.0",
77
77
  "vitest": "^4.1.10"
78
78
  },
package/scripts/cli.mjs CHANGED
File without changes
@@ -10,7 +10,7 @@ grant (a native or config-dir ``.credentials.json``) is single-use on refresh,
10
10
  so duplicating one into a second store guarantees that one of the copies dies.
11
11
  For a *native* login that copy is unnecessary anyway: Hermes' own
12
12
  ``claude_code`` credential source already reads that exact file directly. A
13
- *config-dir* login has no such fallback — as of Hermes Agent 0.19.1,
13
+ *config-dir* login has no such fallback — as of Hermes Agent 0.20.6,
14
14
  ``claude_code`` only reads the native path, never an arbitrary config dir — so
15
15
  it can only reach this bridge via its own setup token, never a duplicated
16
16
  grant. Requests carrying refresh tokens or expiries are refused outright.
@@ -60,6 +60,24 @@ ACCOUNT_ID_ALPHABET = frozenset("abcdefghijklmnopqrstuvwxyz0123456789_-")
60
60
  # Cleared whenever a managed row is written: a setup token never expires on a
61
61
  # schedule, so a stale expiry copied from an older row would quarantine it.
62
62
  STALE_EXPIRY_FIELDS = ("expires_at", "expires_at_ms", "last_refresh")
63
+ # Hermes' own runtime bookkeeping (agent/credential_pool.py PooledCredential).
64
+ # Cleared ONLY when the access token itself changes: Hermes' selection
65
+ # (CredentialPool._available_entries) skips an entry whose last_status is
66
+ # "exhausted" or "dead" until last_error_reset_at passes, which can be hours
67
+ # or days out. A rotated-in token proves the row is being actively managed
68
+ # again, so carrying the old verdict forward would leave a freshly-rotated
69
+ # credential unselectable until that window lapses on its own. A
70
+ # metadata-only update (label/priority) with the SAME token is not evidence
71
+ # of anything — Hermes' own telemetry for a healthy pool must survive it
72
+ # untouched.
73
+ STATUS_FIELDS = (
74
+ "last_status",
75
+ "last_status_at",
76
+ "last_error_code",
77
+ "last_error_reason",
78
+ "last_error_message",
79
+ "last_error_reset_at",
80
+ )
63
81
  # Mirrors src/hermes-core.ts's parseClaudeSetupToken: ASCII-only, and shaped
64
82
  # like the current `sk-ant-oat01-...` setup-token family. The version digits
65
83
  # are intentionally unconstrained beyond "two or more" so a future
@@ -443,7 +461,12 @@ def merge_credential(existing: dict[str, Any], credential: DesiredCredential) ->
443
461
  Expiry fields are cleared: a setup token carries no expiry, and a value left
444
462
  over from an older row would make Hermes treat a perfectly good credential
445
463
  as expired.
464
+
465
+ Status fields are cleared too, but only when the access token actually
466
+ changed — see STATUS_FIELDS. A metadata-only update (same token) preserves
467
+ them exactly.
446
468
  """
469
+ token_rotated = existing.get("access_token") != credential.access_token
447
470
  updated = copy.deepcopy(existing)
448
471
  updated.update(
449
472
  {
@@ -458,6 +481,9 @@ def merge_credential(existing: dict[str, Any], credential: DesiredCredential) ->
458
481
  updated.pop("refresh_token", None)
459
482
  for field in STALE_EXPIRY_FIELDS:
460
483
  updated.pop(field, None)
484
+ if token_rotated:
485
+ for field in STATUS_FIELDS:
486
+ updated.pop(field, None)
461
487
  return updated
462
488
 
463
489