@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 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.0.0/SETUP-AGENT.md
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.6, the `claude` CLI on `PATH`, and a
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: [SHIM_PATH, ...BASE_ARGS, ...permissionModeArgs(execMode)],
432
- resumeArgs: [SHIM_PATH, ...BASE_ARGS, ...permissionModeArgs(execMode), "--resume", "{sessionId}"],
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(_ctx) {
461
- return { env: await buildAccountEnv(account) };
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
- if (degradation) {
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 { env };
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 override = process.env.MULTI_CLAWD_CLAUDE_BIN;
12
- if (override) {
13
- try {
14
- const parsed = JSON.parse(override);
15
- if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((p) => typeof p === "string")) {
16
- return { command: parsed[0], prependArgs: parsed.slice(1) };
17
- }
18
- }
19
- catch {
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
- return { command: "claude", prependArgs: [] };
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)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "2.0.0",
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.0.0",
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.3.24-beta.2",
34
- "minGatewayVersion": "2026.3.24-beta.2"
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.6"
67
+ "openclaw": ">=2026.8.1"
68
68
  },
69
69
  "peerDependenciesMeta": {
70
70
  "openclaw": {