@drakon-systems/multi-clawd 2.0.0 → 2.1.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/README.md +24 -2
- package/dist/claude-command.js +69 -0
- package/dist/index.js +102 -11
- package/dist/shim-transport.js +247 -0
- package/dist/shim.js +30 -13
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -385,7 +385,7 @@ openclaw plugins install (Get-Location).Path
|
|
|
385
385
|
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
386
386
|
Code on the target machine already? Paste it this and go make coffee:
|
|
387
387
|
|
|
388
|
-
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.
|
|
388
|
+
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.1.0/SETUP-AGENT.md
|
|
389
389
|
> and follow it to set up multi-clawd on this machine. I own a second
|
|
390
390
|
> Claude account — ask me when you need me to log in.
|
|
391
391
|
|
|
@@ -397,7 +397,7 @@ therefore executes — is fixed at a version you chose, not whatever `master`
|
|
|
397
397
|
happens to say today. Swap the tag if you want a different release, but
|
|
398
398
|
prefer a tag over a branch.
|
|
399
399
|
|
|
400
|
-
**Requirements:** OpenClaw ≥ 2026.
|
|
400
|
+
**Requirements:** OpenClaw ≥ 2026.8.1, the `claude` CLI on `PATH`, and a
|
|
401
401
|
second Claude subscription you own. The current plugin SDK contract and runtime
|
|
402
402
|
registration path are tested against OpenClaw **2026.8.1**. The standalone
|
|
403
403
|
`multi-clawd` CLI and Hermes commands do not require the optional OpenClaw peer
|
|
@@ -868,6 +868,28 @@ Housekeeping:
|
|
|
868
868
|
showing only a trailing-newline mismatch is a false alarm.
|
|
869
869
|
- Use only accounts you own, within your provider's terms of service.
|
|
870
870
|
|
|
871
|
+
### Security notes: verified launch (OpenClaw ≥ 2026.8.1)
|
|
872
|
+
|
|
873
|
+
- **Same verification bar as the built-in backend, no stronger.** Each
|
|
874
|
+
backend's command is the `claude` executable found on the gateway's
|
|
875
|
+
`PATH`. It declares exactly the runtime artifact OpenClaw's built-in
|
|
876
|
+
`claude-cli` backend declares (`@anthropic-ai/claude-code`, native
|
|
877
|
+
executable names `claude` / `claude.exe`). For a native install, OpenClaw
|
|
878
|
+
verifies that executable by its basename, the same check it applies to its
|
|
879
|
+
own `claude-cli`. multi-clawd adds no package or binary of its own to that
|
|
880
|
+
authority and never lists `node` as a native executable.
|
|
881
|
+
- **The shim runs only the verified binary.** Agent turns go through the
|
|
882
|
+
shim, which is given the realpath checked when the turn starts and runs
|
|
883
|
+
that file under the registered command name. A different command, a
|
|
884
|
+
different `argv0`, or an interpreter prefix is refused rather than
|
|
885
|
+
wrapped.
|
|
886
|
+
- **`MULTI_CLAWD_CLAUDE_BIN` is operator-trusted.** When it is set in the
|
|
887
|
+
gateway's environment, the plugin uses it in place of the `PATH` lookup.
|
|
888
|
+
Whoever can set the gateway's environment already controls which binary
|
|
889
|
+
runs, so the variable widens nothing. Only point it at an executable you
|
|
890
|
+
trust. If it is set but not executable, the launch fails; nothing falls
|
|
891
|
+
back to another `claude`.
|
|
892
|
+
|
|
871
893
|
## Status & roadmap
|
|
872
894
|
|
|
873
895
|
Early but real — built for and dogfooded in production.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
2
|
+
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
export const CLAUDE_BIN_ENV = "MULTI_CLAWD_CLAUDE_BIN";
|
|
4
|
+
export const CLAUDE_ARGV0_ENV = "MULTI_CLAWD_CLAUDE_ARGV0";
|
|
5
|
+
export function isExecutableFile(path) {
|
|
6
|
+
try {
|
|
7
|
+
if (!statSync(path).isFile())
|
|
8
|
+
return false;
|
|
9
|
+
accessSync(path, constants.X_OK);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function findOnPath(name, pathValue) {
|
|
17
|
+
for (const dir of (pathValue ?? "").split(delimiter)) {
|
|
18
|
+
if (!dir || !isAbsolute(dir))
|
|
19
|
+
continue;
|
|
20
|
+
const candidate = resolve(join(dir, name));
|
|
21
|
+
if (isExecutableFile(candidate))
|
|
22
|
+
return candidate;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
export function parseClaudeBinOverride(raw) {
|
|
27
|
+
if (!raw)
|
|
28
|
+
return undefined;
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
if (Array.isArray(parsed) &&
|
|
32
|
+
parsed.length > 0 &&
|
|
33
|
+
parsed.every((p) => typeof p === "string" && p.length > 0)) {
|
|
34
|
+
const [command, ...prependArgs] = parsed;
|
|
35
|
+
return { kind: "argv", command, prependArgs };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
}
|
|
40
|
+
return { kind: "path", value: raw };
|
|
41
|
+
}
|
|
42
|
+
export function resolveExecutable(command, pathValue) {
|
|
43
|
+
if (!command.includes("/") && !command.includes("\\"))
|
|
44
|
+
return findOnPath(command, pathValue);
|
|
45
|
+
if (!isAbsolute(command))
|
|
46
|
+
return undefined;
|
|
47
|
+
const absolute = resolve(command);
|
|
48
|
+
return isExecutableFile(absolute) ? absolute : undefined;
|
|
49
|
+
}
|
|
50
|
+
export function resolveClaudeExecutable(env, platform = process.platform) {
|
|
51
|
+
if (platform === "win32") {
|
|
52
|
+
return { ok: false, reason: "verified launch is not implemented on Windows" };
|
|
53
|
+
}
|
|
54
|
+
const override = parseClaudeBinOverride(env[CLAUDE_BIN_ENV]);
|
|
55
|
+
if (override) {
|
|
56
|
+
if (override.kind === "argv" && override.prependArgs.length > 0) {
|
|
57
|
+
return { ok: false, reason: `${CLAUDE_BIN_ENV} is an interpreter argv, not one executable` };
|
|
58
|
+
}
|
|
59
|
+
const raw = override.kind === "argv" ? override.command : override.value;
|
|
60
|
+
const command = resolveExecutable(raw, env.PATH);
|
|
61
|
+
return command
|
|
62
|
+
? { ok: true, command, source: "override" }
|
|
63
|
+
: { ok: false, reason: `${CLAUDE_BIN_ENV}=${raw} is not an executable file` };
|
|
64
|
+
}
|
|
65
|
+
const command = findOnPath("claude", env.PATH);
|
|
66
|
+
return command
|
|
67
|
+
? { ok: true, command, source: "path" }
|
|
68
|
+
: { ok: false, reason: "no executable `claude` on the gateway's PATH" };
|
|
69
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -27,6 +27,8 @@ import { collectDirectMembers } from "./direct-route.js";
|
|
|
27
27
|
import { createDirectOrderController, isValidAgentId } from "./direct-sync.js";
|
|
28
28
|
import { createOpenclawRunner } from "./openclaw-runner.js";
|
|
29
29
|
import { materialiseRuntime } from "./runtime-home.js";
|
|
30
|
+
import { CLAUDE_ARGV0_ENV, CLAUDE_BIN_ENV, resolveClaudeExecutable } from "./claude-command.js";
|
|
31
|
+
import { createShimExecute } from "./shim-transport.js";
|
|
30
32
|
const CLI_FRESH_WATCHDOG_DEFAULTS = {
|
|
31
33
|
noOutputTimeoutRatio: 0.8,
|
|
32
34
|
minMs: 180_000,
|
|
@@ -359,6 +361,63 @@ if (!SHIM_RUNTIME.stable) {
|
|
|
359
361
|
`using the load path, which dies if OpenClaw prunes its plugin capture (issue #31)`);
|
|
360
362
|
}
|
|
361
363
|
const SHIM_PATH = SHIM_RUNTIME.shimPath;
|
|
364
|
+
export const CLAUDE_RUNTIME_ARTIFACT = Object.freeze({
|
|
365
|
+
kind: "bundled-package-tree",
|
|
366
|
+
packageName: "@anthropic-ai/claude-code",
|
|
367
|
+
entrypoint: "command",
|
|
368
|
+
nativeExecutableNames: Object.freeze(["claude", "claude.exe"]),
|
|
369
|
+
});
|
|
370
|
+
export function hostRunsExecuteTransports(runtime) {
|
|
371
|
+
try {
|
|
372
|
+
const rt = runtime;
|
|
373
|
+
return (typeof rt?.agent?.runCommandFromIngress === "function" &&
|
|
374
|
+
typeof rt?.hooks?.dispatchHookAgentTurn === "function");
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
export function resolveBackendLaunch(params) {
|
|
381
|
+
const shimPath = params.shimPath ?? SHIM_PATH;
|
|
382
|
+
if (!params.hostSupportsExecute) {
|
|
383
|
+
return {
|
|
384
|
+
kind: "legacy",
|
|
385
|
+
shimPath,
|
|
386
|
+
reason: "this OpenClaw does not expose runtime.agent.runCommandFromIngress and " +
|
|
387
|
+
"runtime.hooks.dispatchHookAgentTurn, so it is treated as not running plugin execute " +
|
|
388
|
+
"transports (needs >= 2026.8.1)",
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const claude = resolveClaudeExecutable(params.env ?? process.env, params.platform ?? process.platform);
|
|
392
|
+
if (!claude.ok)
|
|
393
|
+
return { kind: "legacy", shimPath, reason: claude.reason };
|
|
394
|
+
return {
|
|
395
|
+
kind: "verified",
|
|
396
|
+
claudeCommand: claude.command,
|
|
397
|
+
claudeSource: claude.source,
|
|
398
|
+
shimPath,
|
|
399
|
+
...(params.log ? { log: params.log } : {}),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
const SHIM_ONLY_ENV = [
|
|
403
|
+
RETRY_ROSTER_ENV,
|
|
404
|
+
SESSION_DIRS_ENV,
|
|
405
|
+
"MULTI_CLAWD_MODEL_OVERRIDE",
|
|
406
|
+
CLAUDE_BIN_ENV,
|
|
407
|
+
CLAUDE_ARGV0_ENV,
|
|
408
|
+
];
|
|
409
|
+
function preparedForLaunch(launch, ctx, env, transport) {
|
|
410
|
+
if (launch.kind === "legacy")
|
|
411
|
+
return { env };
|
|
412
|
+
if (ctx.executionMode === "side-question") {
|
|
413
|
+
for (const key of SHIM_ONLY_ENV)
|
|
414
|
+
delete env[key];
|
|
415
|
+
return { env };
|
|
416
|
+
}
|
|
417
|
+
if (!transport)
|
|
418
|
+
throw new Error("[multi-clawd] verified launch is missing its shim transport");
|
|
419
|
+
return { env, execute: transport };
|
|
420
|
+
}
|
|
362
421
|
function knownModelsFile() {
|
|
363
422
|
return join(homedir(), ".openclaw", "state", "multi-clawd", "known-models.json");
|
|
364
423
|
}
|
|
@@ -407,7 +466,16 @@ function checkModelCurrency(catalogIds, chainRefs, poolId) {
|
|
|
407
466
|
catch {
|
|
408
467
|
}
|
|
409
468
|
}
|
|
410
|
-
export function buildBackend(account, execMode) {
|
|
469
|
+
export function buildBackend(account, execMode, launch = { kind: "legacy", shimPath: SHIM_PATH }) {
|
|
470
|
+
const verified = launch.kind === "verified";
|
|
471
|
+
const shimPrefix = verified ? [] : [launch.shimPath];
|
|
472
|
+
const transport = verified
|
|
473
|
+
? createShimExecute({
|
|
474
|
+
configuredCommand: launch.claudeCommand,
|
|
475
|
+
shimPath: launch.shimPath,
|
|
476
|
+
...(launch.log ? { log: launch.log } : {}),
|
|
477
|
+
})
|
|
478
|
+
: undefined;
|
|
411
479
|
const backend = {
|
|
412
480
|
id: account.id,
|
|
413
481
|
liveTest: {
|
|
@@ -419,6 +487,7 @@ export function buildBackend(account, execMode) {
|
|
|
419
487
|
binaryName: "claude",
|
|
420
488
|
},
|
|
421
489
|
},
|
|
490
|
+
...(verified ? { runtimeArtifact: CLAUDE_RUNTIME_ARTIFACT } : {}),
|
|
422
491
|
bundleMcp: true,
|
|
423
492
|
bundleMcpMode: "claude-config-file",
|
|
424
493
|
nativeToolMode: "selectable",
|
|
@@ -427,9 +496,9 @@ export function buildBackend(account, execMode) {
|
|
|
427
496
|
sideQuestionToolMode: "disabled",
|
|
428
497
|
ownsNativeCompaction: true,
|
|
429
498
|
config: {
|
|
430
|
-
command: process.execPath,
|
|
431
|
-
args: [
|
|
432
|
-
resumeArgs: [
|
|
499
|
+
command: verified ? launch.claudeCommand : process.execPath,
|
|
500
|
+
args: [...shimPrefix, ...BASE_ARGS, ...permissionModeArgs(execMode)],
|
|
501
|
+
resumeArgs: [...shimPrefix, ...BASE_ARGS, ...permissionModeArgs(execMode), "--resume", "{sessionId}"],
|
|
433
502
|
forkArg: "--fork-session",
|
|
434
503
|
resumeAtArg: "--resume-session-at",
|
|
435
504
|
output: "jsonl",
|
|
@@ -457,13 +526,15 @@ export function buildBackend(account, execMode) {
|
|
|
457
526
|
},
|
|
458
527
|
serialize: true,
|
|
459
528
|
},
|
|
460
|
-
async prepareExecution(
|
|
461
|
-
return
|
|
529
|
+
async prepareExecution(ctx) {
|
|
530
|
+
return preparedForLaunch(launch, ctx, await buildAccountEnv(account), transport);
|
|
462
531
|
},
|
|
463
532
|
};
|
|
533
|
+
launchTransports.set(backend, { launch, transport });
|
|
464
534
|
Object.assign(backend.config, { sessionArg: "--session-id" });
|
|
465
535
|
return backend;
|
|
466
536
|
}
|
|
537
|
+
const launchTransports = new WeakMap();
|
|
467
538
|
export function buildRetryRoster(members, launchedId) {
|
|
468
539
|
const roster = [];
|
|
469
540
|
for (const member of members) {
|
|
@@ -561,6 +632,20 @@ export default definePluginEntry({
|
|
|
561
632
|
}
|
|
562
633
|
const execMode = resolveExecMode(runtimeConfigLoader?.() ?? api.config);
|
|
563
634
|
api.logger.info(`[multi-clawd] exec policy: ${execMode ?? "unknown"} → permission-mode ${permissionModeArgs(execMode).length ? "bypassPermissions" : "default (no override)"}`);
|
|
635
|
+
const launchLogger = api.logger;
|
|
636
|
+
const launch = resolveBackendLaunch({
|
|
637
|
+
hostSupportsExecute: hostRunsExecuteTransports(api.runtime),
|
|
638
|
+
log: (line) => launchLogger.info(line),
|
|
639
|
+
});
|
|
640
|
+
if (launch.kind === "verified") {
|
|
641
|
+
api.logger.info(`[multi-clawd] launch mode: verified — claude ${launch.claudeCommand} (from ${launch.claudeSource === "override" ? "MULTI_CLAWD_CLAUDE_BIN" : "PATH"}) is the backend command; host runs execute transports ` +
|
|
642
|
+
`(runtime.agent.runCommandFromIngress + runtime.hooks.dispatchHookAgentTurn present), ` +
|
|
643
|
+
`so agent turns run through shim ${launch.shimPath} and side-questions run claude directly`);
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
api.logger.warn(`[multi-clawd] launch mode: legacy node+shim — ${launch.reason ?? "unknown reason"}; ` +
|
|
647
|
+
`failover is unchanged, but OpenClaw cannot verify this backend as a setup owner`);
|
|
648
|
+
}
|
|
564
649
|
const seen = new Set();
|
|
565
650
|
for (const account of accounts) {
|
|
566
651
|
const id = account?.id?.trim();
|
|
@@ -577,10 +662,10 @@ export default definePluginEntry({
|
|
|
577
662
|
}
|
|
578
663
|
seen.add(id);
|
|
579
664
|
const normalized = { ...account, id };
|
|
580
|
-
api.registerCliBackend(buildBackend(normalized, execMode));
|
|
665
|
+
api.registerCliBackend(buildBackend(normalized, execMode, launch));
|
|
581
666
|
api.registerProvider(buildCatalogProvider(normalized));
|
|
582
667
|
}
|
|
583
|
-
registerPoolBackend(api, cfg.pool, accounts, seen, execMode);
|
|
668
|
+
registerPoolBackend(api, cfg.pool, accounts, seen, execMode, { launch });
|
|
584
669
|
{
|
|
585
670
|
const logger = api.logger;
|
|
586
671
|
try {
|
|
@@ -696,7 +781,8 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
696
781
|
};
|
|
697
782
|
const minDwellMs = pool.minDwellMs;
|
|
698
783
|
const stickyFile = join(homedir(), ".openclaw", "state", "multi-clawd", `pool-${poolId}.sticky.json`);
|
|
699
|
-
const backend = buildBackend(poolAccount, execMode);
|
|
784
|
+
const backend = buildBackend(poolAccount, execMode, deps?.launch);
|
|
785
|
+
const { launch, transport } = launchTransports.get(backend);
|
|
700
786
|
backend.prepareExecution = async (ctx) => {
|
|
701
787
|
const now = Date.now();
|
|
702
788
|
const requestedModel = canonicalModelId(ctx.modelId) ?? ctx.modelId;
|
|
@@ -851,7 +937,12 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
851
937
|
requestedModel: ctx.modelId,
|
|
852
938
|
ladder,
|
|
853
939
|
});
|
|
854
|
-
|
|
940
|
+
const runsWithoutShim = launch.kind === "verified" && ctx.executionMode === "side-question";
|
|
941
|
+
if (degradation && runsWithoutShim) {
|
|
942
|
+
logger.warn(`[multi-clawd] pool ${poolId}: side-question runs ${ctx.modelId} undegraded on ${(launched ?? chosen).id} — every account is limited for it, and the degrade ladder (${degradation.model}) ` +
|
|
943
|
+
`can only be applied by the shim, which OpenClaw does not run for side-questions`);
|
|
944
|
+
}
|
|
945
|
+
else if (degradation) {
|
|
855
946
|
env.MULTI_CLAWD_MODEL_OVERRIDE = degradation.model;
|
|
856
947
|
const line = `pool ${poolId}: degrading ${ctx.modelId} → ${degradation.model} on ${chosen.id} (${degradation.reason})`;
|
|
857
948
|
logger.info(`[multi-clawd] ${line}`);
|
|
@@ -861,7 +952,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
861
952
|
logger.info(`[multi-clawd] pool ${poolId}: pinned lane keeps ${ctx.modelId} despite exhausted pool (will fail over via the chain)`);
|
|
862
953
|
}
|
|
863
954
|
}
|
|
864
|
-
return
|
|
955
|
+
return preparedForLaunch(launch, ctx, env, transport);
|
|
865
956
|
};
|
|
866
957
|
api.registerCliBackend(backend);
|
|
867
958
|
api.registerProvider(buildCatalogProvider(poolAccount));
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { isAbsolute, join } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import { CLAUDE_ARGV0_ENV, CLAUDE_BIN_ENV } from "./claude-command.js";
|
|
8
|
+
const EXPECTED_FIRST_ARG = "-p";
|
|
9
|
+
const SYSTEM_PROMPT_FILE_ARG = "--append-system-prompt-file";
|
|
10
|
+
const STDERR_TAIL_CHARS = 8192;
|
|
11
|
+
const KILL_GRACE_MS = 1000;
|
|
12
|
+
const EXIT_WAIT_MS = 3000;
|
|
13
|
+
export function composePromptForStdin(prompt, context) {
|
|
14
|
+
const prepended = context?.prependContext ? `${context.prependContext}\n\n${prompt}` : prompt;
|
|
15
|
+
return context?.appendContext ? `${prepended}\n\n${context.appendContext}` : prepended;
|
|
16
|
+
}
|
|
17
|
+
export function assertWrappableInvocation(ctx, configuredCommand) {
|
|
18
|
+
const registered = realpathOf(configuredCommand);
|
|
19
|
+
const handed = realpathOf(ctx.command);
|
|
20
|
+
if (!registered || !handed || registered !== handed) {
|
|
21
|
+
throw new Error(`[multi-clawd] refusing to run ${ctx.command} through the shim: this backend only wraps ` +
|
|
22
|
+
`its registered claude executable ${configuredCommand}`);
|
|
23
|
+
}
|
|
24
|
+
if (ctx.args[0] !== EXPECTED_FIRST_ARG) {
|
|
25
|
+
throw new Error(`[multi-clawd] refusing to wrap an invocation with a leading argv (${String(ctx.args[0])}): ` +
|
|
26
|
+
`the shim can only exec ${configuredCommand} itself`);
|
|
27
|
+
}
|
|
28
|
+
if (ctx.argv0 !== undefined && ctx.argv0 !== configuredCommand) {
|
|
29
|
+
throw new Error(`[multi-clawd] refusing argv0 ${JSON.stringify(ctx.argv0)}: only the registered command ` +
|
|
30
|
+
`${configuredCommand} may name the claude executable`);
|
|
31
|
+
}
|
|
32
|
+
return { executable: handed, argv0: configuredCommand };
|
|
33
|
+
}
|
|
34
|
+
function realpathOf(path) {
|
|
35
|
+
if (!isAbsolute(path))
|
|
36
|
+
return undefined;
|
|
37
|
+
try {
|
|
38
|
+
return realpathSync(path);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function isPlainRecord(value) {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
function toError(reason, fallback) {
|
|
48
|
+
if (reason instanceof Error)
|
|
49
|
+
return reason;
|
|
50
|
+
return new Error(typeof reason === "string" && reason ? reason : fallback);
|
|
51
|
+
}
|
|
52
|
+
function redact(text, env) {
|
|
53
|
+
let out = text;
|
|
54
|
+
for (const [name, value] of Object.entries(env)) {
|
|
55
|
+
if (!value || value.length < 8)
|
|
56
|
+
continue;
|
|
57
|
+
if (!/TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|AUTH/i.test(name))
|
|
58
|
+
continue;
|
|
59
|
+
out = out.split(value).join("[REDACTED]");
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
function appendTail(tail, chunk) {
|
|
64
|
+
const next = tail + chunk;
|
|
65
|
+
return next.length > STDERR_TAIL_CHARS ? next.slice(-STDERR_TAIL_CHARS) : next;
|
|
66
|
+
}
|
|
67
|
+
function signalTree(child, signal) {
|
|
68
|
+
if (!child.pid)
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
if (process.platform !== "win32")
|
|
72
|
+
process.kill(-child.pid, signal);
|
|
73
|
+
else
|
|
74
|
+
child.kill(signal);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
try {
|
|
78
|
+
child.kill(signal);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function createShimExecute(options) {
|
|
85
|
+
const nodePath = options.nodePath ?? process.execPath;
|
|
86
|
+
return async function* executeThroughShim(ctx) {
|
|
87
|
+
const invocation = assertWrappableInvocation(ctx, options.configuredCommand);
|
|
88
|
+
ctx.assertCurrent?.();
|
|
89
|
+
ctx.abortSignal?.throwIfAborted();
|
|
90
|
+
const capability = ctx.liveSession;
|
|
91
|
+
if (capability?.restart)
|
|
92
|
+
await capability.restart();
|
|
93
|
+
ctx.assertCurrent?.();
|
|
94
|
+
let child;
|
|
95
|
+
let exited = false;
|
|
96
|
+
let resolveExit;
|
|
97
|
+
const exitPromise = new Promise((r) => (resolveExit = r));
|
|
98
|
+
let exitCode = null;
|
|
99
|
+
let exitSignal = null;
|
|
100
|
+
let spawnError;
|
|
101
|
+
let killTimer;
|
|
102
|
+
const terminate = () => {
|
|
103
|
+
if (!child || exited)
|
|
104
|
+
return;
|
|
105
|
+
signalTree(child, "SIGTERM");
|
|
106
|
+
if (!killTimer) {
|
|
107
|
+
killTimer = setTimeout(() => {
|
|
108
|
+
if (child && !exited)
|
|
109
|
+
signalTree(child, "SIGKILL");
|
|
110
|
+
}, KILL_GRACE_MS);
|
|
111
|
+
killTimer.unref?.();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const handle = capability
|
|
115
|
+
? {
|
|
116
|
+
generation: randomUUID(),
|
|
117
|
+
fingerprint: capability.fingerprint,
|
|
118
|
+
isIdle: () => exited || !child,
|
|
119
|
+
close: () => terminate(),
|
|
120
|
+
waitForExit: () => (child ? exitPromise : Promise.resolve()),
|
|
121
|
+
}
|
|
122
|
+
: undefined;
|
|
123
|
+
let registered = false;
|
|
124
|
+
let promptDir;
|
|
125
|
+
const onAbort = () => terminate();
|
|
126
|
+
let stderrTail = "";
|
|
127
|
+
let stdoutNoise = "";
|
|
128
|
+
let stderrPartial = "";
|
|
129
|
+
try {
|
|
130
|
+
if (capability && handle) {
|
|
131
|
+
capability.register(handle);
|
|
132
|
+
registered = true;
|
|
133
|
+
capability.activate(handle);
|
|
134
|
+
}
|
|
135
|
+
const args = [...ctx.args];
|
|
136
|
+
const systemPrompt = ctx.systemPrompt.trim();
|
|
137
|
+
if (systemPrompt) {
|
|
138
|
+
promptDir = mkdtempSync(join(tmpdir(), "multi-clawd-system-prompt-"));
|
|
139
|
+
const file = join(promptDir, "system-prompt.md");
|
|
140
|
+
writeFileSync(file, systemPrompt, { mode: 0o600 });
|
|
141
|
+
args.push(SYSTEM_PROMPT_FILE_ARG, file);
|
|
142
|
+
}
|
|
143
|
+
const env = {
|
|
144
|
+
...ctx.env,
|
|
145
|
+
[CLAUDE_BIN_ENV]: invocation.executable,
|
|
146
|
+
[CLAUDE_ARGV0_ENV]: invocation.argv0,
|
|
147
|
+
};
|
|
148
|
+
ctx.assertCurrent?.();
|
|
149
|
+
ctx.abortSignal?.throwIfAborted();
|
|
150
|
+
const spawned = spawn(nodePath, [options.shimPath, ...args], {
|
|
151
|
+
cwd: ctx.cwd,
|
|
152
|
+
env,
|
|
153
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
154
|
+
detached: process.platform !== "win32",
|
|
155
|
+
windowsHide: true,
|
|
156
|
+
});
|
|
157
|
+
child = spawned;
|
|
158
|
+
spawned.once("error", (err) => {
|
|
159
|
+
spawnError = err;
|
|
160
|
+
exited = true;
|
|
161
|
+
resolveExit();
|
|
162
|
+
});
|
|
163
|
+
spawned.once("close", (code, signal) => {
|
|
164
|
+
exitCode = code;
|
|
165
|
+
exitSignal = signal;
|
|
166
|
+
exited = true;
|
|
167
|
+
resolveExit();
|
|
168
|
+
});
|
|
169
|
+
ctx.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
170
|
+
spawned.stderr.setEncoding("utf8");
|
|
171
|
+
spawned.stderr.on("data", (chunk) => {
|
|
172
|
+
stderrTail = appendTail(stderrTail, chunk);
|
|
173
|
+
if (!options.log)
|
|
174
|
+
return;
|
|
175
|
+
const text = stderrPartial + chunk;
|
|
176
|
+
const lines = text.split("\n");
|
|
177
|
+
stderrPartial = lines.pop() ?? "";
|
|
178
|
+
for (const line of lines) {
|
|
179
|
+
if (line.startsWith("[multi-clawd shim]"))
|
|
180
|
+
options.log(line);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
spawned.stdin.on("error", () => {
|
|
184
|
+
});
|
|
185
|
+
spawned.stdin.end(composePromptForStdin(ctx.prompt, ctx.promptContext));
|
|
186
|
+
const lines = createInterface({ input: spawned.stdout, crlfDelay: Infinity });
|
|
187
|
+
for await (const raw of lines) {
|
|
188
|
+
const line = raw.trim();
|
|
189
|
+
if (!line)
|
|
190
|
+
continue;
|
|
191
|
+
let record;
|
|
192
|
+
try {
|
|
193
|
+
record = JSON.parse(line);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
stdoutNoise = appendTail(stdoutNoise, `${line}\n`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!isPlainRecord(record)) {
|
|
200
|
+
stdoutNoise = appendTail(stdoutNoise, `${line}\n`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
yield record;
|
|
204
|
+
}
|
|
205
|
+
await exitPromise;
|
|
206
|
+
if (ctx.abortSignal?.aborted) {
|
|
207
|
+
throw toError(ctx.abortSignal.reason, "Claude CLI run was aborted.");
|
|
208
|
+
}
|
|
209
|
+
if (spawnError) {
|
|
210
|
+
throw new Error(`[multi-clawd] failed to start the shim: ${spawnError.message}`);
|
|
211
|
+
}
|
|
212
|
+
if (exitSignal || exitCode !== 0) {
|
|
213
|
+
const candidates = [stderrTail.trim(), stdoutNoise.trim()].filter(Boolean);
|
|
214
|
+
const detail = candidates[0] ?? `CLI failed (${exitSignal ? `signal ${exitSignal}` : `exit ${exitCode}`}).`;
|
|
215
|
+
throw new Error(redact(detail, env));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
ctx.abortSignal?.removeEventListener("abort", onAbort);
|
|
220
|
+
if (child && !exited) {
|
|
221
|
+
terminate();
|
|
222
|
+
await Promise.race([
|
|
223
|
+
exitPromise,
|
|
224
|
+
new Promise((r) => setTimeout(r, EXIT_WAIT_MS).unref?.()),
|
|
225
|
+
]);
|
|
226
|
+
}
|
|
227
|
+
if (killTimer)
|
|
228
|
+
clearTimeout(killTimer);
|
|
229
|
+
if (!child)
|
|
230
|
+
resolveExit();
|
|
231
|
+
if (registered && capability && handle) {
|
|
232
|
+
try {
|
|
233
|
+
capability.remove(handle);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (promptDir) {
|
|
239
|
+
try {
|
|
240
|
+
rmSync(promptDir, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
package/dist/shim.js
CHANGED
|
@@ -6,21 +6,36 @@ 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, chooseLaunchableRetryAccount, materializeRetryAccount, parseRetryRoster, retryArming, RETRY_ROSTER_ENV, } from "./retry-plan.js";
|
|
9
|
+
import { CLAUDE_ARGV0_ENV, CLAUDE_BIN_ENV, parseClaudeBinOverride, resolveExecutable, } from "./claude-command.js";
|
|
9
10
|
import { effectiveConfigDir, handoverForLaunch, handoverSnapshotTo, parseSessionDirs, SESSION_DIRS_ENV, snapshotResumeTranscript, } from "./session-handover.js";
|
|
10
11
|
function resolveClaudeCommand() {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
return { command: override, prependArgs: [] };
|
|
12
|
+
const raw = process.env[CLAUDE_BIN_ENV];
|
|
13
|
+
const argv0 = process.env[CLAUDE_ARGV0_ENV] || undefined;
|
|
14
|
+
delete process.env[CLAUDE_BIN_ENV];
|
|
15
|
+
delete process.env[CLAUDE_ARGV0_ENV];
|
|
16
|
+
const override = parseClaudeBinOverride(raw);
|
|
17
|
+
if (!override) {
|
|
18
|
+
if (argv0)
|
|
19
|
+
refuseLaunch(`${CLAUDE_ARGV0_ENV} is set without ${CLAUDE_BIN_ENV}`);
|
|
20
|
+
return { command: "claude", prependArgs: [] };
|
|
22
21
|
}
|
|
23
|
-
|
|
22
|
+
const requested = override.kind === "argv" ? override.command : override.value;
|
|
23
|
+
const command = resolveExecutable(requested, process.env.PATH);
|
|
24
|
+
if (!command) {
|
|
25
|
+
refuseLaunch(`${CLAUDE_BIN_ENV}=${requested} is not an executable file`);
|
|
26
|
+
}
|
|
27
|
+
if (argv0 && override.kind === "argv" && override.prependArgs.length > 0) {
|
|
28
|
+
refuseLaunch(`${CLAUDE_ARGV0_ENV} cannot apply to an interpreter-form ${CLAUDE_BIN_ENV}`);
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
command,
|
|
32
|
+
prependArgs: override.kind === "argv" ? override.prependArgs : [],
|
|
33
|
+
...(argv0 ? { argv0 } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function refuseLaunch(why) {
|
|
37
|
+
process.stderr.write(`[multi-clawd shim] refusing to launch claude: ${why}\n`);
|
|
38
|
+
process.exit(127);
|
|
24
39
|
}
|
|
25
40
|
let stateFile = process.env.MULTI_CLAWD_STATE_FILE;
|
|
26
41
|
let accountId = process.env.MULTI_CLAWD_ACCOUNT_ID ?? "unknown";
|
|
@@ -79,7 +94,7 @@ function persistState() {
|
|
|
79
94
|
process.stderr.write(`[multi-clawd shim] state write failed: ${String(err)}\n`);
|
|
80
95
|
}
|
|
81
96
|
}
|
|
82
|
-
const { command, prependArgs } = resolveClaudeCommand();
|
|
97
|
+
const { command, prependArgs, argv0: claudeArgv0 } = resolveClaudeCommand();
|
|
83
98
|
let childArgs = [...prependArgs, ...process.argv.slice(2)];
|
|
84
99
|
const modelOverride = process.env.MULTI_CLAWD_MODEL_OVERRIDE;
|
|
85
100
|
if (modelOverride) {
|
|
@@ -107,6 +122,7 @@ if (!retryArmed && retryRoster.length > 0 && arming.reason) {
|
|
|
107
122
|
let child = spawn(command, childArgs, {
|
|
108
123
|
stdio: ["pipe", "pipe", "inherit"],
|
|
109
124
|
env: process.env,
|
|
125
|
+
...(claudeArgv0 ? { argv0: claudeArgv0 } : {}),
|
|
110
126
|
});
|
|
111
127
|
const STDIN_REPLAY_CAP_BYTES = 8 * 1024 * 1024;
|
|
112
128
|
let stdinReplay = [];
|
|
@@ -286,6 +302,7 @@ function attemptRetry() {
|
|
|
286
302
|
child = spawn(command, childArgs, {
|
|
287
303
|
stdio: ["pipe", "pipe", "inherit"],
|
|
288
304
|
env: buildRetryEnv(process.env, target),
|
|
305
|
+
...(claudeArgv0 ? { argv0: claudeArgv0 } : {}),
|
|
289
306
|
});
|
|
290
307
|
attachChildHandlers();
|
|
291
308
|
for (const chunk of stdinReplay)
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.1.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",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/multi-clawd",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.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",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"./dist/index.js"
|
|
31
31
|
],
|
|
32
32
|
"compat": {
|
|
33
|
-
"pluginApi": ">=2026.
|
|
34
|
-
"minGatewayVersion": "2026.
|
|
33
|
+
"pluginApi": ">=2026.8.1",
|
|
34
|
+
"minGatewayVersion": "2026.8.1"
|
|
35
35
|
}
|
|
36
36
|
},
|
|
37
37
|
"main": "./dist/index.js",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"version": "npm run sync-manifest && npm run sync-readme-tag && git add openclaw.plugin.json README.md"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
|
-
"openclaw": ">=2026.
|
|
67
|
+
"openclaw": ">=2026.8.1"
|
|
68
68
|
},
|
|
69
69
|
"peerDependenciesMeta": {
|
|
70
70
|
"openclaw": {
|