@drakon-systems/multi-clawd 1.9.2 → 1.9.4

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
@@ -375,7 +375,7 @@ openclaw plugins install (Get-Location).Path
375
375
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
376
376
  Code on the target machine already? Paste it this and go make coffee:
377
377
 
378
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.9.2/SETUP-AGENT.md
378
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.9.4/SETUP-AGENT.md
379
379
  > and follow it to set up multi-clawd on this machine. I own a second
380
380
  > Claude account — ask me when you need me to log in.
381
381
 
@@ -666,29 +666,9 @@ What to know:
666
666
  any cooldown, and the live order. `explain` shows the same, and `doctor
667
667
  --probe` makes one tiny live call per profile.
668
668
 
669
- ## History identity for fresh Claude sessions
670
-
671
- OpenClaw only replays a conversation into a **fresh** Claude session when it
672
- knows which account owns the history. It learns that from an auth profile in
673
- its own store, and Claude logins aren't OpenClaw profiles, so it used to log
674
- `cli session history refused across auth boundary: reason=auth-unknown` on
675
- every turn and start fresh sessions blank.
676
-
677
- multi-clawd now stores one identity-only profile per backend
678
- (`claw1:multi-clawd-identity`, `clawd:multi-clawd-identity`, …) and points the
679
- backend at it. The profile is a **name, not a credential**: no token, and
680
- OpenClaw never uses it to log in — `openclaw models status` shows it as
681
- `missing_credential`, which is correct. Which account runs is decided exactly
682
- as before. Right after upgrading, each existing conversation resets its Claude
683
- session once (`cli session reset … reason=auth-profile` in the log). OpenClaw
684
- binds the owner only when a conversation is born, so conversations that
685
- already have history keep logging the refusal line until you start a new one
686
- (`/new`); every session started after the upgrade is covered from its first
687
- turn. `"historyIdentity": false` turns the feature off.
688
-
689
669
  ## How it works
690
670
 
691
- Four moves, all through the official plugin SDK (details in
671
+ Three moves, all through the official plugin SDK (details in
692
672
  [`DESIGN.md`](./DESIGN.md)):
693
673
 
694
674
  1. **`registerCliBackend`** mirrors the bundled `claude-cli` backend — same
@@ -701,9 +681,6 @@ Four moves, all through the official plugin SDK (details in
701
681
  3. **`prepareExecution`** injects that account's own login
702
682
  (`CLAUDE_CONFIG_DIR` + `CLAUDE_CODE_OAUTH_TOKEN`) into the child process
703
683
  env, after the host's ambient Claude credentials are stripped.
704
- 4. **`defaultAuthProfileId`** names an identity-only auth profile per backend,
705
- so OpenClaw's CLI history boundary binds and fresh sessions get the
706
- conversation back (see "History identity" above).
707
684
 
708
685
  ## Legacy issue: idle backend eviction on OpenClaw ≤ 2026.7.1
709
686
 
@@ -1,10 +1,11 @@
1
- export function identityFilePath(account, io) {
2
- const dir = !account.native && account.configDir ? io.expandHome(account.configDir) : io.defaultConfigDir;
3
- if (!dir)
4
- return undefined;
1
+ export function identityFilePaths(account, io) {
5
2
  if (account.oauthTokenFile || account.oauthTokenRef)
6
- return undefined;
7
- return `${dir.replace(/\/+$/, "")}/.claude.json`;
3
+ return [];
4
+ const file = (dir) => `${dir.replace(/\/+$/, "")}/.claude.json`;
5
+ if (!account.native && account.configDir)
6
+ return [file(io.expandHome(account.configDir))];
7
+ const candidates = [io.homeDir ? file(io.homeDir) : undefined, io.defaultConfigDir ? file(io.defaultConfigDir) : undefined];
8
+ return [...new Set(candidates.filter((c) => Boolean(c)))];
8
9
  }
9
10
  export function formatPlan(oauthAccount) {
10
11
  const tier = typeof oauthAccount.organizationRateLimitTier === "string"
@@ -47,14 +48,15 @@ export function parseIdentityFile(text) {
47
48
  email: str("emailAddress"),
48
49
  organizationName: str("organizationName"),
49
50
  plan: formatPlan(account),
51
+ profileFetchedAt: typeof account.profileFetchedAt === "number" ? account.profileFetchedAt : undefined,
50
52
  };
51
53
  if (!identity.accountUuid && !identity.email)
52
54
  return undefined;
53
55
  return identity;
54
56
  }
55
57
  export function resolveAccountIdentity(account, io) {
56
- const path = identityFilePath(account, io);
57
- if (!path) {
58
+ const paths = identityFilePaths(account, io);
59
+ if (paths.length === 0) {
58
60
  return {
59
61
  accountId: account.id,
60
62
  status: "unknown",
@@ -63,28 +65,41 @@ export function resolveAccountIdentity(account, io) {
63
65
  : "no config dir to read",
64
66
  };
65
67
  }
66
- let text;
67
- try {
68
- text = io.readFile(path);
69
- }
70
- catch {
71
- return {
72
- accountId: account.id,
73
- status: "unknown",
74
- reason: `no readable ${path} — this account has never completed a login here`,
75
- source: path,
76
- };
77
- }
78
- const identity = parseIdentityFile(text);
79
- if (!identity) {
80
- return {
68
+ let best;
69
+ const problems = [];
70
+ for (const path of paths) {
71
+ let text;
72
+ try {
73
+ text = io.readFile(path);
74
+ }
75
+ catch {
76
+ problems.push(`no readable ${path}`);
77
+ continue;
78
+ }
79
+ const identity = parseIdentityFile(text);
80
+ if (!identity) {
81
+ problems.push(`${path} carries no oauthAccount record`);
82
+ continue;
83
+ }
84
+ const candidate = {
81
85
  accountId: account.id,
82
- status: "unknown",
83
- reason: `${path} carries no oauthAccount record`,
86
+ status: "resolved",
84
87
  source: path,
88
+ ...identity,
85
89
  };
90
+ if (!best || (candidate.profileFetchedAt ?? 0) > (best.profileFetchedAt ?? 0))
91
+ best = candidate;
86
92
  }
87
- return { accountId: account.id, status: "resolved", source: path, ...identity };
93
+ if (best)
94
+ return best;
95
+ return {
96
+ accountId: account.id,
97
+ status: "unknown",
98
+ reason: problems.length === 1
99
+ ? `${problems[0]} — this account has never completed a login here`
100
+ : `${problems.join("; ")} — this account has never completed a login here`,
101
+ source: paths[0],
102
+ };
88
103
  }
89
104
  export function maskEmail(email) {
90
105
  const at = email.lastIndexOf("@");
@@ -271,6 +271,8 @@ export function auditSessionOverrides(sessions, poolConfigured, opts = {}) {
271
271
  const source = entry.modelOverrideSource;
272
272
  if (typeof source !== "string" || source === "auto")
273
273
  continue;
274
+ if (source === "default" && !entry.modelOverride)
275
+ continue;
274
276
  const provider = entry.providerOverride ?? entry.modelProvider;
275
277
  const model = entry.modelOverride;
276
278
  if (!provider || !model) {
package/dist/index.js CHANGED
@@ -25,7 +25,6 @@ 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 { HISTORY_IDENTITY_CONFIG_KEY, describeEnsureResult, ensureIdentityProfiles, identityProfileId, loadProviderAuthSdk, } from "./history-identity.js";
29
28
  const CLI_FRESH_WATCHDOG_DEFAULTS = {
30
29
  noOutputTimeoutRatio: 0.8,
31
30
  minMs: 180_000,
@@ -401,7 +400,6 @@ export function buildBackend(account, execMode) {
401
400
  resolveExecutionArgs: resolvePoolExecutionArgs,
402
401
  sideQuestionToolMode: "disabled",
403
402
  ownsNativeCompaction: true,
404
- defaultAuthProfileId: identityProfileId(account.id),
405
403
  config: {
406
404
  command: process.execPath,
407
405
  args: [SHIM_PATH, ...BASE_ARGS, ...permissionModeArgs(execMode)],
@@ -554,12 +552,6 @@ export default definePluginEntry({
554
552
  api.registerProvider(buildCatalogProvider(normalized));
555
553
  }
556
554
  registerPoolBackend(api, cfg.pool, accounts, seen, execMode);
557
- startHistoryIdentityEnsure({
558
- backendIds: [...seen],
559
- enabled: cfg[HISTORY_IDENTITY_CONFIG_KEY] !== false,
560
- registrationMode: api.registrationMode,
561
- logger: api.logger,
562
- });
563
555
  {
564
556
  const logger = api.logger;
565
557
  try {
@@ -588,50 +580,6 @@ export default definePluginEntry({
588
580
  api.logger.info(`[multi-clawd] registered ${seen.size} backend(s)+provider(s): ${[...seen].join(", ")}`);
589
581
  },
590
582
  });
591
- let historyIdentitySdkLoader = loadProviderAuthSdk;
592
- let historyIdentityPending;
593
- export function setProviderAuthSdkLoaderForTests(loader) {
594
- historyIdentitySdkLoader = loader ?? loadProviderAuthSdk;
595
- historyIdentityPending = undefined;
596
- }
597
- export function awaitHistoryIdentityEnsure() {
598
- return historyIdentityPending ?? Promise.resolve(undefined);
599
- }
600
- function startHistoryIdentityEnsure(params) {
601
- const { logger } = params;
602
- if (params.registrationMode !== undefined && params.registrationMode !== "full")
603
- return;
604
- if (!params.enabled) {
605
- logger.info("[multi-clawd] history identity: disabled by config (historyIdentity: false)");
606
- return;
607
- }
608
- if (params.backendIds.length === 0 || historyIdentityPending)
609
- return;
610
- const ids = [...params.backendIds];
611
- historyIdentityPending = (async () => {
612
- try {
613
- const sdk = await historyIdentitySdkLoader();
614
- if (!sdk) {
615
- logger.warn("[multi-clawd] history identity: this OpenClaw does not expose openclaw/plugin-sdk/provider-auth store writers — skipped (CLI history reseed stays auth-unknown)");
616
- return undefined;
617
- }
618
- const result = await ensureIdentityProfiles(ids, sdk);
619
- const line = `[multi-clawd] history identity: ${describeEnsureResult(result)}`;
620
- if (result.failed.length > 0)
621
- logger.warn(line);
622
- else
623
- logger.info(line);
624
- return result;
625
- }
626
- catch (err) {
627
- logger.warn(`[multi-clawd] history identity: ${String(err)}`);
628
- return undefined;
629
- }
630
- finally {
631
- historyIdentityPending = undefined;
632
- }
633
- })();
634
- }
635
583
  function readHealthState(accountId) {
636
584
  try {
637
585
  return JSON.parse(readFileSync(healthStateFile(accountId), "utf8"));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "1.9.2",
4
+ "version": "1.9.4",
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",
@@ -205,10 +205,6 @@
205
205
  }
206
206
  }
207
207
  },
208
- "historyIdentity": {
209
- "type": "boolean",
210
- "description": "Store one identity-only OpenClaw auth profile per registered backend id (<id>:multi-clawd-identity — a name, no token) and point the backend's defaultAuthProfileId at it, so OpenClaw's CLI history boundary can bind and a fresh Claude session is reseeded with prior context instead of `cli session history refused across auth boundary: reason=auth-unknown`. Default true. false = no auth-store writes; the reseed refusal stays."
211
- },
212
208
  "directRoute": {
213
209
  "type": "object",
214
210
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/multi-clawd",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
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",
@@ -288,13 +288,17 @@ const { summarizeWindowUsage, classifyAccountHealth } = await importDist("health
288
288
  "summarizeWindowUsage",
289
289
  "classifyAccountHealth",
290
290
  ]);
291
+ // "cli", like the chain audits: resolving WHICH login backs an account is
292
+ // doctor's own diagnostic, not a description of the running plugin's
293
+ // behaviour. Asking the installed copy meant a fix to the resolver could not
294
+ // take effect until the plugin itself was reinstalled — so the CLI reported a
295
+ // bug it had already been taught not to make.
291
296
  const { resolveAccountIdentity, describeIdentity, findDuplicateLogins, maskEmail } =
292
- await importDist("account-identity.js", [
293
- "resolveAccountIdentity",
294
- "describeIdentity",
295
- "findDuplicateLogins",
296
- "maskEmail",
297
- ]);
297
+ await importDist(
298
+ "account-identity.js",
299
+ ["resolveAccountIdentity", "describeIdentity", "findDuplicateLogins", "maskEmail"],
300
+ "cli",
301
+ );
298
302
  const { decideStickySelection } = await importDist("sticky.js", ["decideStickySelection"]);
299
303
  const io = {
300
304
  readFile: (p) => readFileSync(expandHome(p), "utf8"),
@@ -322,6 +326,7 @@ const identityIo = {
322
326
  defaultConfigDir: process.env.CLAUDE_CONFIG_DIR
323
327
  ? expandHome(process.env.CLAUDE_CONFIG_DIR)
324
328
  : join(HOME, ".claude"),
329
+ homeDir: HOME,
325
330
  };
326
331
  const accounts = pluginConfig.accounts ?? [];
327
332
  const identities = [];
@@ -334,7 +339,7 @@ for (const account of accounts) {
334
339
  const identity = resolveAccountIdentity(account, identityIo);
335
340
  identities.push(identity);
336
341
  const source = account.native
337
- ? `native login, ${identityIo.defaultConfigDir}`
342
+ ? `native login, ${identity.source ?? identityIo.defaultConfigDir}`
338
343
  : account.configDir
339
344
  ? `config dir ${account.configDir}`
340
345
  : account.oauthTokenFile