@drakon-systems/multi-clawd 1.8.10 → 1.9.1

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.
@@ -0,0 +1,70 @@
1
+ import { classifyAccountHealth } from "./health.js";
2
+ export const RETRY_ROSTER_ENV = "MULTI_CLAWD_RETRY_ACCOUNTS";
3
+ const CREDENTIAL_ENV_KEYS = ["CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN"];
4
+ export function parseRetryRoster(raw) {
5
+ if (!raw)
6
+ return [];
7
+ let parsed;
8
+ try {
9
+ parsed = JSON.parse(raw);
10
+ }
11
+ catch {
12
+ return [];
13
+ }
14
+ if (!Array.isArray(parsed))
15
+ return [];
16
+ const out = [];
17
+ for (const entry of parsed) {
18
+ if (!entry || typeof entry !== "object")
19
+ continue;
20
+ const e = entry;
21
+ if (typeof e.id !== "string" || !e.id)
22
+ continue;
23
+ if (typeof e.stateFile !== "string" || !e.stateFile)
24
+ continue;
25
+ const env = {};
26
+ if (e.env && typeof e.env === "object") {
27
+ for (const [k, v] of Object.entries(e.env)) {
28
+ if (typeof v === "string")
29
+ env[k] = v;
30
+ }
31
+ }
32
+ out.push({ id: e.id, stateFile: e.stateFile, env });
33
+ }
34
+ return out;
35
+ }
36
+ export function retryArming(argv, roster) {
37
+ if (roster.length === 0) {
38
+ return { armed: false, reason: "no secret-free sibling account to retry onto" };
39
+ }
40
+ if (argv.includes("--resume")) {
41
+ return {
42
+ armed: false,
43
+ reason: "resumed session — its Claude session lives in this account's config dir",
44
+ };
45
+ }
46
+ if (!argv.some((a) => a === "stream-json" || a === "--output-format=stream-json")) {
47
+ return { armed: false, reason: "not a stream-json launch" };
48
+ }
49
+ return { armed: true };
50
+ }
51
+ export function chooseRetryAccount(params) {
52
+ for (const account of params.roster) {
53
+ const state = params.readState(account.stateFile);
54
+ const verdict = classifyAccountHealth(state, params.options ?? {}, params.nowMs, params.modelId).verdict;
55
+ if (verdict === "ok" || verdict === "no_data")
56
+ return account;
57
+ }
58
+ return undefined;
59
+ }
60
+ export function buildRetryEnv(base, account) {
61
+ const env = { ...base };
62
+ for (const key of CREDENTIAL_ENV_KEYS)
63
+ delete env[key];
64
+ for (const [k, v] of Object.entries(account.env))
65
+ env[k] = v;
66
+ env.MULTI_CLAWD_ACCOUNT_ID = account.id;
67
+ env.MULTI_CLAWD_STATE_FILE = account.stateFile;
68
+ env[RETRY_ROSTER_ENV] = "";
69
+ return env;
70
+ }
@@ -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
+ }
@@ -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
@@ -5,6 +5,8 @@ import { classifyStateReadFailure, clearCredentialFailure, createLineScanner, me
5
5
  import { rewriteModelArg } from "./degrade.js";
6
6
  import { parseModelLimitError, recordModelLimit } from "./shim-core.js";
7
7
  import { canonicalModelId } from "./models.js";
8
+ import { buildRetryEnv, chooseRetryAccount, parseRetryRoster, retryArming, RETRY_ROSTER_ENV, } from "./retry-plan.js";
9
+ import { handoverForLaunch } from "./session-handover.js";
8
10
  function resolveClaudeCommand() {
9
11
  const override = process.env.MULTI_CLAWD_CLAUDE_BIN;
10
12
  if (override) {
@@ -20,8 +22,8 @@ function resolveClaudeCommand() {
20
22
  }
21
23
  return { command: "claude", prependArgs: [] };
22
24
  }
23
- const stateFile = process.env.MULTI_CLAWD_STATE_FILE;
24
- const accountId = process.env.MULTI_CLAWD_ACCOUNT_ID ?? "unknown";
25
+ let stateFile = process.env.MULTI_CLAWD_STATE_FILE;
26
+ let accountId = process.env.MULTI_CLAWD_ACCOUNT_ID ?? "unknown";
25
27
  let state = { accountId, windows: {} };
26
28
  function preserveCorruptState(raw) {
27
29
  if (!stateFile)
@@ -87,11 +89,48 @@ if (modelOverride) {
87
89
  process.stderr.write(`[multi-clawd shim] degrading model for this launch → ${modelOverride}\n`);
88
90
  }
89
91
  }
90
- const child = spawn(command, childArgs, {
92
+ {
93
+ const note = handoverForLaunch(childArgs, process.env);
94
+ if (note)
95
+ process.stderr.write(`[multi-clawd shim] ${note}\n`);
96
+ }
97
+ const retryRoster = parseRetryRoster(process.env[RETRY_ROSTER_ENV]);
98
+ const arming = retryArming(childArgs, retryRoster);
99
+ let retryArmed = arming.armed;
100
+ if (!retryArmed && retryRoster.length > 0 && arming.reason) {
101
+ process.stderr.write(`[multi-clawd shim] in-turn retry unavailable: ${arming.reason}\n`);
102
+ }
103
+ let child = spawn(command, childArgs, {
91
104
  stdio: ["pipe", "pipe", "inherit"],
92
105
  env: process.env,
93
106
  });
94
- process.stdin.pipe(child.stdin);
107
+ const STDIN_REPLAY_CAP_BYTES = 8 * 1024 * 1024;
108
+ let stdinReplay = [];
109
+ let stdinReplayBytes = 0;
110
+ let stdinEnded = false;
111
+ if (retryArmed) {
112
+ process.stdin.on("data", (chunk) => {
113
+ if (retryArmed) {
114
+ stdinReplayBytes += chunk.length;
115
+ if (stdinReplayBytes > STDIN_REPLAY_CAP_BYTES) {
116
+ retryArmed = false;
117
+ stdinReplay = [];
118
+ releaseHeldOutput();
119
+ }
120
+ else {
121
+ stdinReplay.push(chunk);
122
+ }
123
+ }
124
+ child.stdin.write(chunk);
125
+ });
126
+ process.stdin.on("end", () => {
127
+ stdinEnded = true;
128
+ child.stdin.end();
129
+ });
130
+ }
131
+ else {
132
+ process.stdin.pipe(child.stdin);
133
+ }
95
134
  function effectiveModelId() {
96
135
  const idx = childArgs.indexOf("--model");
97
136
  if (idx < 0 || idx + 1 >= childArgs.length)
@@ -109,7 +148,7 @@ function guessLimitResetsAt() {
109
148
  return undefined;
110
149
  }
111
150
  let sawAuthFailure = false;
112
- const scanner = createLineScanner((line) => {
151
+ function observeLine(line) {
113
152
  try {
114
153
  const event = parseRateLimitEvent(line);
115
154
  if (event) {
@@ -135,12 +174,154 @@ const scanner = createLineScanner((line) => {
135
174
  }
136
175
  catch {
137
176
  }
138
- });
139
- child.stdout.on("data", (chunk) => {
177
+ }
178
+ const scanner = createLineScanner(observeLine);
179
+ const HOLD_CAP_BYTES = 256 * 1024;
180
+ const HOLD_MAX_MS = Number(process.env.MULTI_CLAWD_HOLD_MAX_MS ?? 10_000);
181
+ let held = [];
182
+ let heldBytes = 0;
183
+ let holdTail = "";
184
+ let retryUsed = false;
185
+ function isPreambleRecord(line) {
186
+ if (line.length === 0)
187
+ return true;
188
+ try {
189
+ const record = JSON.parse(line);
190
+ if (record.type === "system" || record.type === "rate_limit_event")
191
+ return true;
192
+ if (record.type === "assistant" && record.message?.model === "<synthetic>")
193
+ return true;
194
+ return false;
195
+ }
196
+ catch {
197
+ return false;
198
+ }
199
+ }
200
+ function releaseHeldOutput() {
201
+ if (held.length > 0) {
202
+ process.stdout.write(held.join(""));
203
+ held = [];
204
+ heldBytes = 0;
205
+ }
206
+ if (holdTail.length > 0) {
207
+ process.stdout.write(holdTail);
208
+ scanner.push(holdTail);
209
+ holdTail = "";
210
+ }
211
+ retryArmed = false;
212
+ stdinReplay = [];
213
+ }
214
+ function readSiblingState(file) {
215
+ try {
216
+ return parseStoredState(readFileSync(file, "utf8"));
217
+ }
218
+ catch {
219
+ return undefined;
220
+ }
221
+ }
222
+ function attemptRetry() {
223
+ if (!retryArmed || retryUsed)
224
+ return false;
225
+ const model = effectiveModelId();
226
+ const target = chooseRetryAccount({
227
+ roster: retryRoster,
228
+ readState: readSiblingState,
229
+ modelId: model,
230
+ nowMs: Date.now(),
231
+ });
232
+ if (!target) {
233
+ process.stderr.write(`[multi-clawd shim] model limit on ${accountId} with no healthy sibling to retry onto — ` +
234
+ `passing the failure through to the host's chain\n`);
235
+ releaseHeldOutput();
236
+ return false;
237
+ }
238
+ retryUsed = true;
239
+ retryArmed = false;
240
+ process.stderr.write(`[multi-clawd shim] model limit on ${accountId}${model ? ` for ${model}` : ""} — ` +
241
+ `retrying this turn on ${target.id} (nothing forwarded downstream yet)\n`);
242
+ held = [];
243
+ heldBytes = 0;
244
+ holdTail = "";
245
+ const previous = child;
246
+ previous.stdout.removeAllListeners();
247
+ previous.removeAllListeners("close");
248
+ previous.removeAllListeners("error");
249
+ try {
250
+ previous.kill("SIGTERM");
251
+ }
252
+ catch {
253
+ }
254
+ accountId = target.id;
255
+ stateFile = target.stateFile;
256
+ state = { accountId, windows: {} };
257
+ sawAuthFailure = false;
258
+ child = spawn(command, childArgs, {
259
+ stdio: ["pipe", "pipe", "inherit"],
260
+ env: buildRetryEnv(process.env, target),
261
+ });
262
+ attachChildHandlers();
263
+ for (const chunk of stdinReplay)
264
+ child.stdin.write(chunk);
265
+ if (stdinEnded)
266
+ child.stdin.end();
267
+ stdinReplay = [];
268
+ return true;
269
+ }
270
+ function onArmedChunk(chunk) {
271
+ holdTail += chunk.toString("utf8");
272
+ while (retryArmed) {
273
+ const idx = holdTail.indexOf("\n");
274
+ if (idx < 0)
275
+ break;
276
+ const raw = holdTail.slice(0, idx + 1);
277
+ holdTail = holdTail.slice(idx + 1);
278
+ const line = raw.trim();
279
+ observeLine(line);
280
+ if (parseModelLimitError(line) && attemptRetry())
281
+ return;
282
+ held.push(raw);
283
+ heldBytes += raw.length;
284
+ if (!isPreambleRecord(line) || heldBytes > HOLD_CAP_BYTES) {
285
+ releaseHeldOutput();
286
+ return;
287
+ }
288
+ }
289
+ if (!retryArmed && holdTail.length > 0) {
290
+ const rest = holdTail;
291
+ holdTail = "";
292
+ process.stdout.write(rest);
293
+ scanner.push(rest);
294
+ }
295
+ }
296
+ if (retryArmed) {
297
+ const holdTimer = setTimeout(() => {
298
+ if (!retryArmed)
299
+ return;
300
+ process.stderr.write(`[multi-clawd shim] in-turn retry window closed after ${HOLD_MAX_MS}ms — releasing the stream\n`);
301
+ releaseHeldOutput();
302
+ }, HOLD_MAX_MS);
303
+ holdTimer.unref?.();
304
+ }
305
+ function onPassthroughChunk(chunk) {
140
306
  process.stdout.write(chunk);
141
307
  scanner.push(chunk.toString("utf8"));
142
- });
143
- child.stdout.on("end", () => scanner.flush());
308
+ }
309
+ function attachChildHandlers() {
310
+ const armedForThisChild = retryArmed;
311
+ child.stdout.on("data", (chunk) => {
312
+ if (armedForThisChild && retryArmed)
313
+ onArmedChunk(chunk);
314
+ else
315
+ onPassthroughChunk(chunk);
316
+ });
317
+ child.stdout.on("end", () => {
318
+ if (retryArmed)
319
+ releaseHeldOutput();
320
+ scanner.flush();
321
+ });
322
+ child.on("close", onChildClose);
323
+ child.on("error", onChildError);
324
+ }
144
325
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
145
326
  process.on(signal, () => {
146
327
  child.kill(signal);
@@ -159,7 +340,7 @@ function clearRecordedAuthFailureOnSuccess() {
159
340
  catch {
160
341
  }
161
342
  }
162
- child.on("close", (code, signal) => {
343
+ function onChildClose(code, signal) {
163
344
  if (signal) {
164
345
  process.kill(process.pid, signal);
165
346
  return;
@@ -167,8 +348,9 @@ child.on("close", (code, signal) => {
167
348
  if ((code ?? 0) === 0)
168
349
  clearRecordedAuthFailureOnSuccess();
169
350
  process.exit(code ?? 0);
170
- });
171
- child.on("error", (err) => {
351
+ }
352
+ function onChildError(err) {
172
353
  process.stderr.write(`[multi-clawd shim] failed to spawn claude: ${String(err)}\n`);
173
354
  process.exit(127);
174
- });
355
+ }
356
+ attachChildHandlers();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "1.8.10",
4
+ "version": "1.9.1",
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,74 @@
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
+ "directRoute": {
209
+ "type": "object",
210
+ "additionalProperties": false,
211
+ "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.",
212
+ "properties": {
213
+ "agents": {
214
+ "type": "array",
215
+ "items": {
216
+ "type": "string",
217
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"
218
+ },
219
+ "description": "Agents whose anthropic auth order is managed. Default [\"main\"]."
220
+ },
221
+ "manageOrder": {
222
+ "type": "boolean",
223
+ "description": "Keep the order in pool-health order. Default true. false = credentials are still synced by setup/update, the order is left to you."
224
+ },
225
+ "intervalMs": {
226
+ "type": "number",
227
+ "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)."
228
+ },
229
+ "openclawCommand": {
230
+ "type": "string",
231
+ "description": "OpenClaw CLI used for auth-store reads/writes. Default \"openclaw\" on PATH."
232
+ }
233
+ }
169
234
  }
170
235
  }
171
236
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/multi-clawd",
3
- "version": "1.8.10",
3
+ "version": "1.9.1",
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",