@drakon-systems/multi-clawd 1.8.11 → 1.9.2
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 +100 -2
- package/SECURITY.md +1 -0
- package/dist/account-env.js +5 -0
- package/dist/chain-audit.js +9 -7
- package/dist/direct-report.js +119 -0
- package/dist/direct-route.js +156 -0
- package/dist/direct-sync.js +335 -0
- package/dist/explain-core.js +44 -2
- package/dist/history-identity.js +91 -0
- package/dist/index.js +154 -1
- package/dist/openclaw-runner.js +42 -0
- package/dist/session-handover.js +117 -0
- package/dist/setup-core.js +25 -0
- package/dist/shim.js +6 -0
- package/openclaw.plugin.json +70 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +236 -4
- package/scripts/doctor.mjs +117 -2
- package/scripts/setup.mjs +74 -1
package/dist/index.js
CHANGED
|
@@ -14,13 +14,18 @@ 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
16
|
import { RETRY_ROSTER_ENV } from "./retry-plan.js";
|
|
17
|
+
import { SESSION_DIRS_ENV } from "./session-handover.js";
|
|
17
18
|
import { addAlert, alertKeysWithPrefix, clearAlert, pendingAlertText, } from "./alerts.js";
|
|
18
19
|
import { healthStateFile, clearAccountCredentialFailure } from "./credential-state.js";
|
|
19
20
|
export { healthStateFile, clearAccountCredentialFailure };
|
|
20
|
-
import { buildAccountChildEnv, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
|
|
21
|
+
import { accountConfigDir, buildAccountChildEnv, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
|
|
21
22
|
import { diffCatalogModels, formatNewModelNotice, } from "./model-currency.js";
|
|
22
23
|
import { checkAccountCredential, keychainServiceForConfigDir, createRefProbeTracker, } from "./login-health.js";
|
|
23
24
|
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { collectDirectMembers } from "./direct-route.js";
|
|
26
|
+
import { createDirectOrderController, isValidAgentId } from "./direct-sync.js";
|
|
27
|
+
import { createOpenclawRunner } from "./openclaw-runner.js";
|
|
28
|
+
import { HISTORY_IDENTITY_CONFIG_KEY, describeEnsureResult, ensureIdentityProfiles, identityProfileId, loadProviderAuthSdk, } from "./history-identity.js";
|
|
24
29
|
const CLI_FRESH_WATCHDOG_DEFAULTS = {
|
|
25
30
|
noOutputTimeoutRatio: 0.8,
|
|
26
31
|
minMs: 180_000,
|
|
@@ -396,6 +401,7 @@ export function buildBackend(account, execMode) {
|
|
|
396
401
|
resolveExecutionArgs: resolvePoolExecutionArgs,
|
|
397
402
|
sideQuestionToolMode: "disabled",
|
|
398
403
|
ownsNativeCompaction: true,
|
|
404
|
+
defaultAuthProfileId: identityProfileId(account.id),
|
|
399
405
|
config: {
|
|
400
406
|
command: process.execPath,
|
|
401
407
|
args: [SHIM_PATH, ...BASE_ARGS, ...permissionModeArgs(execMode)],
|
|
@@ -506,6 +512,9 @@ export default definePluginEntry({
|
|
|
506
512
|
"startup-pluginConfig",
|
|
507
513
|
];
|
|
508
514
|
if (accounts.length === 0) {
|
|
515
|
+
const mode = api.registrationMode;
|
|
516
|
+
if (mode === undefined || mode === "full")
|
|
517
|
+
stopDirectOrderSync();
|
|
509
518
|
api.logger.warn(`[multi-clawd] no accounts configured — nothing to register (sources: ${candidates
|
|
510
519
|
.map((c, i) => `${sourceNames[i]}=${hasAccounts(c) ? "ok" : c ? "empty" : "absent"}`)
|
|
511
520
|
.join(", ")})`);
|
|
@@ -545,6 +554,12 @@ export default definePluginEntry({
|
|
|
545
554
|
api.registerProvider(buildCatalogProvider(normalized));
|
|
546
555
|
}
|
|
547
556
|
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
|
+
});
|
|
548
563
|
{
|
|
549
564
|
const logger = api.logger;
|
|
550
565
|
try {
|
|
@@ -557,10 +572,66 @@ export default definePluginEntry({
|
|
|
557
572
|
logger.warn(`[multi-clawd] heartbeat alert hook unavailable: ${String(err)}`);
|
|
558
573
|
}
|
|
559
574
|
startLoginHealthProbe(accounts.filter((a) => seen.has(a.id.trim())), logger);
|
|
575
|
+
startDirectOrderSync({
|
|
576
|
+
accounts: accounts.filter((a) => seen.has(a.id.trim())),
|
|
577
|
+
pool: cfg.pool,
|
|
578
|
+
directRoute: cfg.directRoute,
|
|
579
|
+
registrationMode: api.registrationMode,
|
|
580
|
+
configOrder: () => {
|
|
581
|
+
const root = (runtimeConfigLoader?.() ?? api.config);
|
|
582
|
+
const order = root?.auth?.order?.anthropic;
|
|
583
|
+
return Array.isArray(order) ? order.filter((v) => typeof v === "string") : undefined;
|
|
584
|
+
},
|
|
585
|
+
logger,
|
|
586
|
+
});
|
|
560
587
|
}
|
|
561
588
|
api.logger.info(`[multi-clawd] registered ${seen.size} backend(s)+provider(s): ${[...seen].join(", ")}`);
|
|
562
589
|
},
|
|
563
590
|
});
|
|
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
|
+
}
|
|
564
635
|
function readHealthState(accountId) {
|
|
565
636
|
try {
|
|
566
637
|
return JSON.parse(readFileSync(healthStateFile(accountId), "utf8"));
|
|
@@ -742,6 +813,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
742
813
|
if (roster.length > 0)
|
|
743
814
|
env[RETRY_ROSTER_ENV] = JSON.stringify(roster);
|
|
744
815
|
}
|
|
816
|
+
env[SESSION_DIRS_ENV] = JSON.stringify([...new Set(members.map(accountConfigDir))]);
|
|
745
817
|
if (ladder.length > 0) {
|
|
746
818
|
const pinned = matchesPin(pins, {
|
|
747
819
|
agentDir: ctx.agentDir ?? "",
|
|
@@ -778,3 +850,84 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
778
850
|
}
|
|
779
851
|
})();
|
|
780
852
|
}
|
|
853
|
+
let directTimer;
|
|
854
|
+
let directInitial;
|
|
855
|
+
let directController;
|
|
856
|
+
export function stopDirectOrderSync() {
|
|
857
|
+
if (directTimer)
|
|
858
|
+
clearInterval(directTimer);
|
|
859
|
+
if (directInitial)
|
|
860
|
+
clearTimeout(directInitial);
|
|
861
|
+
directTimer = undefined;
|
|
862
|
+
directInitial = undefined;
|
|
863
|
+
directController = undefined;
|
|
864
|
+
}
|
|
865
|
+
export function startDirectOrderSync(params) {
|
|
866
|
+
const { logger } = params;
|
|
867
|
+
const mode = params.registrationMode;
|
|
868
|
+
if (mode !== undefined && mode !== "full")
|
|
869
|
+
return { active: false, members: [] };
|
|
870
|
+
const { members, problems } = collectDirectMembers(params.accounts, params.pool?.accounts ?? []);
|
|
871
|
+
for (const p of problems) {
|
|
872
|
+
logger.warn(`[multi-clawd] direct route: account "${p.accountId}" skipped — ${p.reason}`);
|
|
873
|
+
}
|
|
874
|
+
if (members.length === 0 || params.directRoute?.manageOrder === false) {
|
|
875
|
+
stopDirectOrderSync();
|
|
876
|
+
return { active: false, members: members.map((m) => m.accountId) };
|
|
877
|
+
}
|
|
878
|
+
const requested = params.directRoute?.agents?.length ? params.directRoute.agents : ["main"];
|
|
879
|
+
const agents = requested.filter((a) => {
|
|
880
|
+
if (isValidAgentId(a))
|
|
881
|
+
return true;
|
|
882
|
+
logger.warn(`[multi-clawd] direct route: ignoring agent id ${JSON.stringify(a)} in directRoute.agents — letters, digits, "_" and "-" only`);
|
|
883
|
+
return false;
|
|
884
|
+
});
|
|
885
|
+
if (agents.length === 0) {
|
|
886
|
+
stopDirectOrderSync();
|
|
887
|
+
return { active: false, members: members.map((m) => m.accountId) };
|
|
888
|
+
}
|
|
889
|
+
const intervalMs = Math.max(15_000, params.directRoute?.intervalMs ?? 60_000);
|
|
890
|
+
const healthOptions = {
|
|
891
|
+
utilizationThreshold: params.pool?.utilizationThreshold,
|
|
892
|
+
staleAfterMs: params.pool?.staleAfterMs,
|
|
893
|
+
rotateOnOverage: params.pool?.rotateOnOverage,
|
|
894
|
+
};
|
|
895
|
+
const signature = JSON.stringify({
|
|
896
|
+
members: members.map((m) => m.profileId),
|
|
897
|
+
agents,
|
|
898
|
+
intervalMs,
|
|
899
|
+
healthOptions,
|
|
900
|
+
minDwellMs: params.pool?.minDwellMs,
|
|
901
|
+
command: params.directRoute?.openclawCommand,
|
|
902
|
+
});
|
|
903
|
+
if (directController?.signature === signature && directTimer) {
|
|
904
|
+
return { active: true, members: members.map((m) => m.accountId) };
|
|
905
|
+
}
|
|
906
|
+
stopDirectOrderSync();
|
|
907
|
+
const stickyFile = join(homedir(), ".openclaw", "state", "multi-clawd", "direct-order.sticky.json");
|
|
908
|
+
const controller = createDirectOrderController({
|
|
909
|
+
members,
|
|
910
|
+
agents,
|
|
911
|
+
runner: createOpenclawRunner(params.directRoute?.openclawCommand ?? "openclaw"),
|
|
912
|
+
readHealth: (id) => readHealthState(id),
|
|
913
|
+
healthOptions,
|
|
914
|
+
minDwellMs: params.pool?.minDwellMs,
|
|
915
|
+
configOrder: params.configOrder,
|
|
916
|
+
readSticky: () => readStickyEntry(stickyFile),
|
|
917
|
+
writeSticky: (entry) => writeStickyEntry(stickyFile, entry, logger),
|
|
918
|
+
logger,
|
|
919
|
+
});
|
|
920
|
+
directController = { signature, tick: controller.tick };
|
|
921
|
+
const tick = () => void controller.tick().catch((err) => logger.warn(`[multi-clawd] direct route tick failed: ${String(err)}`));
|
|
922
|
+
directInitial = setTimeout(tick, Math.min(30_000, intervalMs));
|
|
923
|
+
directInitial.unref?.();
|
|
924
|
+
directTimer = setInterval(tick, intervalMs);
|
|
925
|
+
directTimer.unref?.();
|
|
926
|
+
logger.info(`[multi-clawd] direct route: keeping anthropic order for agent(s) ${agents.join(", ")} in pool-health order — ${members
|
|
927
|
+
.map((m) => `${m.accountId}=${m.profileId}`)
|
|
928
|
+
.join(", ")}`);
|
|
929
|
+
return { active: true, members: members.map((m) => m.accountId) };
|
|
930
|
+
}
|
|
931
|
+
export async function runDirectOrderTickNow() {
|
|
932
|
+
return directController?.tick();
|
|
933
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
const MAX_CAPTURE = 1024 * 1024;
|
|
3
|
+
export function createOpenclawRunner(command = "openclaw") {
|
|
4
|
+
return (args, opts) => new Promise((resolve) => {
|
|
5
|
+
let stdout = "";
|
|
6
|
+
let stderr = "";
|
|
7
|
+
let settled = false;
|
|
8
|
+
const finish = (result) => {
|
|
9
|
+
if (settled)
|
|
10
|
+
return;
|
|
11
|
+
settled = true;
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
resolve(result);
|
|
14
|
+
};
|
|
15
|
+
let child;
|
|
16
|
+
try {
|
|
17
|
+
child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], shell: false });
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
resolve({ code: 127, stdout: "", stderr: String(err.message) });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
child.kill("SIGTERM");
|
|
25
|
+
finish({ code: 124, stdout, stderr: `${stderr}\ntimed out` });
|
|
26
|
+
}, opts?.timeoutMs ?? 60_000);
|
|
27
|
+
timer.unref?.();
|
|
28
|
+
child.stdout?.on("data", (d) => {
|
|
29
|
+
if (stdout.length < MAX_CAPTURE)
|
|
30
|
+
stdout += d.toString("utf8");
|
|
31
|
+
});
|
|
32
|
+
child.stderr?.on("data", (d) => {
|
|
33
|
+
if (stderr.length < MAX_CAPTURE)
|
|
34
|
+
stderr += d.toString("utf8");
|
|
35
|
+
});
|
|
36
|
+
child.on("error", (err) => finish({ code: 127, stdout, stderr: `${stderr}\n${err.message}` }));
|
|
37
|
+
child.on("close", (code) => finish({ code: code ?? 1, stdout, stderr }));
|
|
38
|
+
child.stdin?.on("error", () => {
|
|
39
|
+
});
|
|
40
|
+
child.stdin?.end(opts?.input ?? "");
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
export const SESSION_DIRS_ENV = "MULTI_CLAWD_SESSION_DIRS";
|
|
5
|
+
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
6
|
+
export function resumeSessionId(argv) {
|
|
7
|
+
for (let i = 0; i < argv.length; i++) {
|
|
8
|
+
const arg = argv[i];
|
|
9
|
+
let value;
|
|
10
|
+
if (arg === "--resume" || arg === "-r")
|
|
11
|
+
value = argv[i + 1];
|
|
12
|
+
else if (arg.startsWith("--resume="))
|
|
13
|
+
value = arg.slice("--resume=".length);
|
|
14
|
+
if (value !== undefined)
|
|
15
|
+
return SESSION_ID_RE.test(value) ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
export function effectiveConfigDir(env) {
|
|
20
|
+
const dir = env.CLAUDE_CONFIG_DIR?.trim();
|
|
21
|
+
return dir ? dir : join(homedir(), ".claude");
|
|
22
|
+
}
|
|
23
|
+
export function parseSessionDirs(raw) {
|
|
24
|
+
if (!raw)
|
|
25
|
+
return [];
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
if (!Array.isArray(parsed))
|
|
29
|
+
return [];
|
|
30
|
+
return parsed.filter((d) => typeof d === "string" && d.trim().length > 0);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export const nodeHandoverFs = {
|
|
37
|
+
listDirs(dir) {
|
|
38
|
+
try {
|
|
39
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
40
|
+
.filter((d) => d.isDirectory())
|
|
41
|
+
.map((d) => d.name);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
mtimeMs(path) {
|
|
48
|
+
try {
|
|
49
|
+
const st = statSync(path);
|
|
50
|
+
return st.isFile() ? st.mtimeMs : undefined;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
export function planSessionHandover(params) {
|
|
58
|
+
const fs = params.fs ?? nodeHandoverFs;
|
|
59
|
+
const file = `${params.sessionId}.jsonl`;
|
|
60
|
+
const dirs = [params.targetDir, ...params.memberDirs.filter((d) => d !== params.targetDir)];
|
|
61
|
+
let newest;
|
|
62
|
+
for (const dir of dirs) {
|
|
63
|
+
const projects = join(dir, "projects");
|
|
64
|
+
for (const sub of fs.listDirs(projects)) {
|
|
65
|
+
const path = join(projects, sub, file);
|
|
66
|
+
const mtime = fs.mtimeMs(path);
|
|
67
|
+
if (mtime === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
if (!newest || mtime > newest.mtime)
|
|
70
|
+
newest = { dir, sub, path, mtime };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!newest)
|
|
74
|
+
return { action: "none", reason: "not-found" };
|
|
75
|
+
if (newest.dir === params.targetDir)
|
|
76
|
+
return { action: "none", reason: "already-local" };
|
|
77
|
+
return {
|
|
78
|
+
action: "copy",
|
|
79
|
+
from: newest.path,
|
|
80
|
+
to: join(params.targetDir, "projects", newest.sub, file),
|
|
81
|
+
fromDir: newest.dir,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function applySessionHandover(plan) {
|
|
85
|
+
const tmp = join(dirname(plan.to), `.${basename(plan.to)}.handover-${process.pid}`);
|
|
86
|
+
mkdirSync(dirname(plan.to), { recursive: true, mode: 0o700 });
|
|
87
|
+
try {
|
|
88
|
+
copyFileSync(plan.from, tmp);
|
|
89
|
+
renameSync(tmp, plan.to);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
rmSync(tmp, { force: true });
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function handoverForLaunch(argv, env, deps = {}) {
|
|
97
|
+
const sessionId = resumeSessionId(argv);
|
|
98
|
+
if (!sessionId)
|
|
99
|
+
return undefined;
|
|
100
|
+
const memberDirs = parseSessionDirs(env[SESSION_DIRS_ENV]);
|
|
101
|
+
if (memberDirs.length === 0)
|
|
102
|
+
return undefined;
|
|
103
|
+
const targetDir = effectiveConfigDir(env);
|
|
104
|
+
try {
|
|
105
|
+
const plan = planSessionHandover({ sessionId, targetDir, memberDirs, fs: deps.fs });
|
|
106
|
+
if (plan.action === "none") {
|
|
107
|
+
return plan.reason === "not-found"
|
|
108
|
+
? `resume handover: session ${sessionId.slice(0, 8)} is in no pool account's config dir — leaving the launch as-is`
|
|
109
|
+
: undefined;
|
|
110
|
+
}
|
|
111
|
+
(deps.apply ?? applySessionHandover)(plan);
|
|
112
|
+
return `resume handover: session ${sessionId.slice(0, 8)} copied from ${plan.fromDir} into ${targetDir} (newer copy)`;
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
return `resume handover failed for session ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`;
|
|
116
|
+
}
|
|
117
|
+
}
|
package/dist/setup-core.js
CHANGED
|
@@ -157,3 +157,28 @@ export function existingAccountDefaults(config, id) {
|
|
|
157
157
|
export function looksLikeSecretRef(id) {
|
|
158
158
|
return id.includes("://");
|
|
159
159
|
}
|
|
160
|
+
export function buildDirectSetting(account, choice) {
|
|
161
|
+
switch (choice.kind) {
|
|
162
|
+
case "skip":
|
|
163
|
+
return undefined;
|
|
164
|
+
case "reuse":
|
|
165
|
+
if (account.native || (!account.oauthTokenRef && !account.oauthTokenFile)) {
|
|
166
|
+
throw new Error(`account "${account.id}" has no setup-token to reuse — a native or config-dir login needs its own \`claude setup-token\` for the direct route`);
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
case "ref":
|
|
170
|
+
if (!isSecretRefShape(choice.ref)) {
|
|
171
|
+
throw new Error('secret ref must be { "source": "...", "provider": "...", "id": "..." }');
|
|
172
|
+
}
|
|
173
|
+
return { tokenRef: choice.ref };
|
|
174
|
+
case "file": {
|
|
175
|
+
const path = choice.path?.trim();
|
|
176
|
+
if (!path)
|
|
177
|
+
throw new Error("a token file path is required");
|
|
178
|
+
return { tokenFile: path };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export function canReuseCliTokenForDirect(account) {
|
|
183
|
+
return !account.native && Boolean(account.oauthTokenRef || account.oauthTokenFile);
|
|
184
|
+
}
|
package/dist/shim.js
CHANGED
|
@@ -6,6 +6,7 @@ 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
10
|
function resolveClaudeCommand() {
|
|
10
11
|
const override = process.env.MULTI_CLAWD_CLAUDE_BIN;
|
|
11
12
|
if (override) {
|
|
@@ -88,6 +89,11 @@ if (modelOverride) {
|
|
|
88
89
|
process.stderr.write(`[multi-clawd shim] degrading model for this launch → ${modelOverride}\n`);
|
|
89
90
|
}
|
|
90
91
|
}
|
|
92
|
+
{
|
|
93
|
+
const note = handoverForLaunch(childArgs, process.env);
|
|
94
|
+
if (note)
|
|
95
|
+
process.stderr.write(`[multi-clawd shim] ${note}\n`);
|
|
96
|
+
}
|
|
91
97
|
const retryRoster = parseRetryRoster(process.env[RETRY_ROSTER_ENV]);
|
|
92
98
|
const arming = retryArming(childArgs, retryRoster);
|
|
93
99
|
let retryArmed = arming.armed;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.9.2",
|
|
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",
|
|
@@ -163,9 +163,78 @@
|
|
|
163
163
|
"defaultModel": {
|
|
164
164
|
"type": "string",
|
|
165
165
|
"description": "Model id used for live probes (openclaw models status). Defaults to claude-fable-5."
|
|
166
|
+
},
|
|
167
|
+
"direct": {
|
|
168
|
+
"description": "Opt this account into the gateway's direct Anthropic route (anthropic/* models) as well as the CLI. `true` reuses the account's own setup-token (oauthTokenRef / oauthTokenFile). A native or config-dir login is a rotating grant that is never copied, so it needs an explicit setup-token: {\"tokenRef\": {...}} or {\"tokenFile\": \"...\"}. `multi-clawd update`/`setup` store it as the OpenClaw auth profile anthropic:<id>, and the plugin keeps auth.order.anthropic in pool-health order. Omit to leave the account CLI-only (unchanged behaviour).",
|
|
169
|
+
"anyOf": [
|
|
170
|
+
{
|
|
171
|
+
"type": "boolean"
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"type": "object",
|
|
175
|
+
"additionalProperties": false,
|
|
176
|
+
"properties": {
|
|
177
|
+
"tokenRef": {
|
|
178
|
+
"type": "object",
|
|
179
|
+
"additionalProperties": true,
|
|
180
|
+
"description": "Secret reference resolving to a `claude setup-token` value for THIS account, e.g. {\"source\":\"exec\",\"provider\":\"onepassword\",\"id\":\"op://Vault/Item/field\"}.",
|
|
181
|
+
"properties": {
|
|
182
|
+
"source": {
|
|
183
|
+
"type": "string"
|
|
184
|
+
},
|
|
185
|
+
"provider": {
|
|
186
|
+
"type": "string"
|
|
187
|
+
},
|
|
188
|
+
"id": {
|
|
189
|
+
"type": "string"
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"tokenFile": {
|
|
194
|
+
"type": "string",
|
|
195
|
+
"description": "Path to a 0600 file holding a `claude setup-token` value for THIS account. Mutually exclusive with tokenRef."
|
|
196
|
+
},
|
|
197
|
+
"profileId": {
|
|
198
|
+
"type": "string",
|
|
199
|
+
"description": "OpenClaw auth profile id. Default anthropic:<account id>. Must start with anthropic:."
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
]
|
|
166
204
|
}
|
|
167
205
|
}
|
|
168
206
|
}
|
|
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
|
+
"directRoute": {
|
|
213
|
+
"type": "object",
|
|
214
|
+
"additionalProperties": false,
|
|
215
|
+
"description": "The gateway's direct Anthropic route (anthropic/* models), active only when at least one account sets `direct`. The plugin keeps each listed agent's auth.order.anthropic in pool-health order (via `openclaw models auth order set`), so a nearly-maxed account is tried last before it errors; OpenClaw's own cooldowns and in-turn rotation handle the rest.",
|
|
216
|
+
"properties": {
|
|
217
|
+
"agents": {
|
|
218
|
+
"type": "array",
|
|
219
|
+
"items": {
|
|
220
|
+
"type": "string",
|
|
221
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"
|
|
222
|
+
},
|
|
223
|
+
"description": "Agents whose anthropic auth order is managed. Default [\"main\"]."
|
|
224
|
+
},
|
|
225
|
+
"manageOrder": {
|
|
226
|
+
"type": "boolean",
|
|
227
|
+
"description": "Keep the order in pool-health order. Default true. false = credentials are still synced by setup/update, the order is left to you."
|
|
228
|
+
},
|
|
229
|
+
"intervalMs": {
|
|
230
|
+
"type": "number",
|
|
231
|
+
"description": "How often the order is re-planned from health files. Default 60000; minimum 15000. The CLI is only invoked when the plan changes (or hourly to re-assert)."
|
|
232
|
+
},
|
|
233
|
+
"openclawCommand": {
|
|
234
|
+
"type": "string",
|
|
235
|
+
"description": "OpenClaw CLI used for auth-store reads/writes. Default \"openclaw\" on PATH."
|
|
236
|
+
}
|
|
237
|
+
}
|
|
169
238
|
}
|
|
170
239
|
}
|
|
171
240
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/multi-clawd",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.2",
|
|
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",
|