@drakon-systems/multi-clawd 1.10.1 → 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 +66 -19
- package/dist/account-env.js +41 -3
- package/dist/account-identity.js +5 -0
- package/dist/claude-command.js +69 -0
- package/dist/credential-store.js +222 -0
- package/dist/direct-route.js +2 -1
- package/dist/explain-core.js +1 -0
- package/dist/health.js +30 -5
- package/dist/index.js +173 -39
- package/dist/login-health.js +137 -40
- package/dist/retry-plan.js +38 -3
- package/dist/shim-core.js +3 -1
- package/dist/shim-transport.js +247 -0
- package/dist/shim.js +35 -15
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -4
- package/scripts/cli.mjs +1 -1
- package/scripts/doctor.mjs +116 -49
package/dist/shim-core.js
CHANGED
|
@@ -137,7 +137,9 @@ export function mergeHealthStates(disk, live, now, pruneAfterMs = PRUNE_AFTER_MS
|
|
|
137
137
|
if (live.credential && (!credential || live.credential.seenAt >= credential.seenAt)) {
|
|
138
138
|
credential = live.credential;
|
|
139
139
|
}
|
|
140
|
-
if (now !== undefined &&
|
|
140
|
+
if (now !== undefined &&
|
|
141
|
+
credential?.status === "ok" &&
|
|
142
|
+
now - credential.seenAt > pruneAfterMs) {
|
|
141
143
|
credential = undefined;
|
|
142
144
|
}
|
|
143
145
|
const updatedAt = Math.max(disk.updatedAt ?? 0, live.updatedAt ?? 0);
|
|
@@ -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
|
@@ -5,22 +5,37 @@ import { classifyStateReadFailure, clearCredentialFailure, clearModelRejection,
|
|
|
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,
|
|
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) {
|
|
@@ -95,6 +110,7 @@ if (modelOverride) {
|
|
|
95
110
|
process.stderr.write(`[multi-clawd shim] ${note}\n`);
|
|
96
111
|
}
|
|
97
112
|
const retryRoster = parseRetryRoster(process.env[RETRY_ROSTER_ENV]);
|
|
113
|
+
delete process.env[RETRY_ROSTER_ENV];
|
|
98
114
|
const resumeSnapshot = retryRoster.length > 0 && parseSessionDirs(process.env[SESSION_DIRS_ENV]).length > 0
|
|
99
115
|
? snapshotResumeTranscript(childArgs, process.env)
|
|
100
116
|
: undefined;
|
|
@@ -106,6 +122,7 @@ if (!retryArmed && retryRoster.length > 0 && arming.reason) {
|
|
|
106
122
|
let child = spawn(command, childArgs, {
|
|
107
123
|
stdio: ["pipe", "pipe", "inherit"],
|
|
108
124
|
env: process.env,
|
|
125
|
+
...(claudeArgv0 ? { argv0: claudeArgv0 } : {}),
|
|
109
126
|
});
|
|
110
127
|
const STDIN_REPLAY_CAP_BYTES = 8 * 1024 * 1024;
|
|
111
128
|
let stdinReplay = [];
|
|
@@ -233,11 +250,13 @@ function attemptRetry() {
|
|
|
233
250
|
if (!retryArmed || retryUsed)
|
|
234
251
|
return false;
|
|
235
252
|
const model = effectiveModelId();
|
|
236
|
-
const target =
|
|
253
|
+
const target = chooseLaunchableRetryAccount({
|
|
237
254
|
roster: retryRoster,
|
|
238
255
|
readState: readSiblingState,
|
|
239
256
|
modelId: model,
|
|
240
257
|
nowMs: Date.now(),
|
|
258
|
+
materialize: (account) => materializeRetryAccount(account, (path) => readFileSync(path, "utf8")),
|
|
259
|
+
onSkip: (id, reason) => process.stderr.write(`[multi-clawd shim] not retrying onto ${id}: its declared credential did not resolve (${reason})\n`),
|
|
241
260
|
});
|
|
242
261
|
if (!target) {
|
|
243
262
|
process.stderr.write(`[multi-clawd shim] model limit on ${accountId} with no healthy sibling to retry onto — ` +
|
|
@@ -283,6 +302,7 @@ function attemptRetry() {
|
|
|
283
302
|
child = spawn(command, childArgs, {
|
|
284
303
|
stdio: ["pipe", "pipe", "inherit"],
|
|
285
304
|
env: buildRetryEnv(process.env, target),
|
|
305
|
+
...(claudeArgv0 ? { argv0: claudeArgv0 } : {}),
|
|
286
306
|
});
|
|
287
307
|
attachChildHandlers();
|
|
288
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": "1.
|
|
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": "1.
|
|
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": {
|
package/scripts/cli.mjs
CHANGED
|
@@ -753,7 +753,7 @@ async function usageCommand(args) {
|
|
|
753
753
|
for (const a of accounts) {
|
|
754
754
|
const row = { id: a.id, label: a.label };
|
|
755
755
|
if (a.oauthTokenFile || a.oauthTokenRef) {
|
|
756
|
-
row.skipped = "token
|
|
756
|
+
row.skipped = "setup-token login — not polled: the usage endpoint needs the user:profile scope, setup-tokens carry only user:inference (stream telemetry only)";
|
|
757
757
|
} else if (!a.native && !a.configDir) {
|
|
758
758
|
row.skipped = "no login dir";
|
|
759
759
|
} else {
|
package/scripts/doctor.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* 1. plugin install + manifest/config key agreement (the --force trap)
|
|
7
7
|
* 2. compiled-artifact freshness (stale dist detection)
|
|
8
8
|
* 3. claude CLI availability + PATH sanity
|
|
9
|
-
* 4. per-account credential
|
|
9
|
+
* 4. per-account credential health (judged on expiry and proof, never on
|
|
10
|
+
* presence), credential split-store detection, and which Claude login each
|
|
10
11
|
* account actually authenticates as (and whether two share one)
|
|
11
12
|
* 5. per-account rate-limit telemetry (state files, age, windows)
|
|
12
13
|
* 6. pool configuration, sticky state, and the account the next turn runs on
|
|
@@ -280,55 +281,73 @@ try {
|
|
|
280
281
|
|
|
281
282
|
// ── 4. account credentials (values never printed) ──────────────────────────
|
|
282
283
|
console.log("account credentials");
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
"
|
|
289
|
-
"
|
|
290
|
-
|
|
291
|
-
|
|
284
|
+
// "cli": judging a credential source is doctor's own diagnostic (see the
|
|
285
|
+
// identity import below). The store module carries the system I/O; both must
|
|
286
|
+
// come from the same build so the io matches the judge.
|
|
287
|
+
const { assessAccountCredential } = await importDist(
|
|
288
|
+
"login-health.js",
|
|
289
|
+
["assessAccountCredential"],
|
|
290
|
+
"cli",
|
|
291
|
+
);
|
|
292
|
+
const { createSystemCredentialIo, findCredentialCopies, describeSplitStore } = await importDist(
|
|
293
|
+
"credential-store.js",
|
|
294
|
+
["createSystemCredentialIo", "findCredentialCopies", "describeSplitStore"],
|
|
295
|
+
"cli",
|
|
296
|
+
);
|
|
297
|
+
const { summarizeWindowUsage, classifyAccountHealth, overdueProbeAccount, lastAuthSuccessAt } =
|
|
298
|
+
await importDist("health.js", [
|
|
299
|
+
"summarizeWindowUsage",
|
|
300
|
+
"classifyAccountHealth",
|
|
301
|
+
"overdueProbeAccount",
|
|
302
|
+
"lastAuthSuccessAt",
|
|
303
|
+
]);
|
|
292
304
|
// "cli", like the chain audits: resolving WHICH login backs an account is
|
|
293
305
|
// doctor's own diagnostic, not a description of the running plugin's
|
|
294
306
|
// behaviour. Asking the installed copy meant a fix to the resolver could not
|
|
295
307
|
// take effect until the plugin itself was reinstalled — so the CLI reported a
|
|
296
308
|
// bug it had already been taught not to make.
|
|
297
|
-
const {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
309
|
+
const {
|
|
310
|
+
resolveAccountIdentity,
|
|
311
|
+
describeIdentity,
|
|
312
|
+
findDuplicateLogins,
|
|
313
|
+
maskEmail,
|
|
314
|
+
nativeDefaultConfigDir,
|
|
315
|
+
} = await importDist(
|
|
316
|
+
"account-identity.js",
|
|
317
|
+
["resolveAccountIdentity", "describeIdentity", "findDuplicateLogins", "maskEmail", "nativeDefaultConfigDir"],
|
|
318
|
+
"cli",
|
|
319
|
+
);
|
|
303
320
|
const { decideStickySelection } = await importDist("sticky.js", ["decideStickySelection"]);
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
321
|
+
// Keychain reads name the account the Claude CLI uses (`-a`), never just the
|
|
322
|
+
// service. Token values are reduced to expiry metadata where they are read and
|
|
323
|
+
// are never printed.
|
|
324
|
+
const io = createSystemCredentialIo();
|
|
325
|
+
// A native account's child runs with CLAUDE_CONFIG_DIR CLEARED (the pool strips
|
|
326
|
+
// it before every launch), so its login lives under the real home whatever
|
|
327
|
+
// this shell says. Doctor run inside an agent session served by the pool
|
|
328
|
+
// inherits the serving account's dir; honouring it resolved "native" from that
|
|
329
|
+
// account and reported two distinct logins as one.
|
|
330
|
+
const nativeDir = nativeDefaultConfigDir(process.env, HOME);
|
|
331
|
+
if (nativeDir.ignored) {
|
|
332
|
+
note(
|
|
333
|
+
`CLAUDE_CONFIG_DIR=${nativeDir.ignored} is set in this shell — ignored: a native account runs ` +
|
|
334
|
+
`with it cleared, so native is resolved from ${nativeDir.dir}`,
|
|
335
|
+
);
|
|
319
336
|
}
|
|
320
|
-
// A native account's child sets no CLAUDE_CONFIG_DIR, so it authenticates
|
|
321
|
-
// against whatever the default dir is in the ENV THE GATEWAY RUNS IN. Doctor
|
|
322
|
-
// reads the same variable and prints the path it used, so a box that exports
|
|
323
|
-
// CLAUDE_CONFIG_DIR globally shows its real source rather than a guess.
|
|
324
337
|
const identityIo = {
|
|
325
338
|
readFile: (p) => readFileSync(expandHome(p), "utf8"),
|
|
326
339
|
expandHome,
|
|
327
|
-
defaultConfigDir:
|
|
328
|
-
? expandHome(process.env.CLAUDE_CONFIG_DIR)
|
|
329
|
-
: join(HOME, ".claude"),
|
|
340
|
+
defaultConfigDir: nativeDir.dir,
|
|
330
341
|
homeDir: HOME,
|
|
331
342
|
};
|
|
343
|
+
/**
|
|
344
|
+
* Last time the CLI's login for this account was accepted. The shim's file
|
|
345
|
+
* only: the usage poll authenticates with the credentials FILE, which on a
|
|
346
|
+
* split store is not the copy the CLI reads, so it proves nothing here.
|
|
347
|
+
*/
|
|
348
|
+
function authProofAt(accountId) {
|
|
349
|
+
return lastAuthSuccessAt(readJson(join(STATE_DIR, `${accountId}.json`)));
|
|
350
|
+
}
|
|
332
351
|
const accounts = pluginConfig.accounts ?? [];
|
|
333
352
|
const identities = [];
|
|
334
353
|
if (accounts.length === 0) warn("no accounts configured");
|
|
@@ -361,24 +380,51 @@ for (const account of accounts) {
|
|
|
361
380
|
// credential EXISTS, and #8 is exactly the case where a present credential
|
|
362
381
|
// is a session the Claude CLI has already rejected. A recorded runtime
|
|
363
382
|
// failure is the stronger evidence, so it is reported as such.
|
|
383
|
+
// The record stands until a successful turn (or a re-login) clears it, so
|
|
384
|
+
// its age changes the wording, never the verdict.
|
|
364
385
|
const recorded = readJson(join(STATE_DIR, `${account.id}.json`))?.credential;
|
|
365
386
|
if (recorded?.status === "failed") {
|
|
366
|
-
const ageMin = Math.round((Date.now() - recorded.seenAt) / 60000);
|
|
387
|
+
const ageMin = Math.max(0, Math.round((Date.now() - recorded.seenAt) / 60000));
|
|
388
|
+
const age = ageMin < 90 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h`;
|
|
367
389
|
bad(
|
|
368
|
-
`${account.id}: the Claude CLI rejected this login ${
|
|
390
|
+
`${account.id}: the Claude CLI rejected this login ${age} ago${
|
|
369
391
|
recorded.reason ? ` (${recorded.reason})` : ""
|
|
370
|
-
}
|
|
392
|
+
} and no successful turn has been seen since — ` +
|
|
393
|
+
`fix with \`multi-clawd login ${account.id}\`, or prove an out-of-band fix with ` +
|
|
394
|
+
`\`multi-clawd doctor --probe\``,
|
|
371
395
|
);
|
|
372
|
-
|
|
373
|
-
}
|
|
374
|
-
if (account.oauthTokenRef) {
|
|
396
|
+
} else if (account.oauthTokenRef) {
|
|
375
397
|
warn(`${account.id}: oauthTokenRef — validated by the gateway's async probe, not doctor`);
|
|
376
|
-
|
|
398
|
+
} else {
|
|
399
|
+
const check = assessAccountCredential(account, io, { proof: { lastSuccessAt: authProofAt(account.id) } });
|
|
400
|
+
if (check.status === "ok") ok(`${account.id}: credential verified — ${check.detail ?? "ok"}`);
|
|
401
|
+
else if (check.status === "unverified") {
|
|
402
|
+
// Not a failure (an idle account's access token expires routinely), and
|
|
403
|
+
// never "alive": a dead refresh token looks exactly like this at rest.
|
|
404
|
+
warn(
|
|
405
|
+
`${account.id}: credential UNVERIFIED — ${check.reason}. The next turn on it refreshes or ` +
|
|
406
|
+
`fails; prove it now with \`multi-clawd doctor --probe\``,
|
|
407
|
+
);
|
|
408
|
+
} else if (check.status === "unknown") warn(`${account.id}: cannot verify (${check.reason ?? "no source"})`);
|
|
409
|
+
else bad(`${account.id}: ${check.reason}`);
|
|
410
|
+
}
|
|
411
|
+
// Credential split-store: more than one copy of this login's OAuth
|
|
412
|
+
// credential. Refresh tokens rotate, so once the copies diverge only the one
|
|
413
|
+
// refreshed last can still refresh — and the copy a tool happens to read
|
|
414
|
+
// decides whether it sees a live login or a dead one. Warn-level: the CLI's
|
|
415
|
+
// own copy may be fine today; this is the early warning.
|
|
416
|
+
const split = findCredentialCopies(account, io);
|
|
417
|
+
if (split.status === "split") {
|
|
418
|
+
const text = describeSplitStore(split, Date.now());
|
|
419
|
+
warn(`credential split-store — ${text.headline}`);
|
|
420
|
+
for (const line of text.copies) console.log(` · ${line}`);
|
|
421
|
+
console.log(` remedy: ${text.remedy}`);
|
|
422
|
+
} else if (split.status !== "n/a" && VERBOSE) {
|
|
423
|
+
note(`${account.id}: credential split-store — ${split.status === "single" ? "one copy" : "no stored copy"}`);
|
|
424
|
+
}
|
|
425
|
+
if (split.incomplete) {
|
|
426
|
+
note(`${account.id}: credential split-store check incomplete — the Keychain could not be listed, so same-service duplicates may be missed`);
|
|
377
427
|
}
|
|
378
|
-
const check = checkAccountCredential(account, io);
|
|
379
|
-
if (check.status === "ok") ok(`${account.id}: credential source looks alive`);
|
|
380
|
-
else if (check.status === "unknown") warn(`${account.id}: cannot verify (${check.reason ?? "no source"})`);
|
|
381
|
-
else bad(`${account.id}: ${check.reason}`);
|
|
382
428
|
}
|
|
383
429
|
|
|
384
430
|
// Two ids, one login. This is the failure the rest of doctor cannot see: every
|
|
@@ -449,6 +495,27 @@ else {
|
|
|
449
495
|
const members = (pool.accounts ?? []).filter((id) => accounts.some((a) => a.id === id));
|
|
450
496
|
if (members.length < 2) bad(`pool "${pool.id ?? "clawd"}" has ${members.length} valid member(s); needs ≥ 2`);
|
|
451
497
|
else ok(`pool "${pool.id ?? "clawd"}": ${members.join(" → ")}`);
|
|
498
|
+
// Setup-token members: what they do and do not get, said once, plainly.
|
|
499
|
+
const tokenMembers = members
|
|
500
|
+
.map((id) => accounts.find((a) => a.id === id))
|
|
501
|
+
.filter((a) => a && !a.native && (a.oauthTokenFile || a.oauthTokenRef));
|
|
502
|
+
if (tokenMembers.length > 0 && pool.usagePoll?.enabled !== false) {
|
|
503
|
+
note(
|
|
504
|
+
`usage poll: ${tokenMembers.map((a) => a.id).join(", ")} not polled — a setup-token carries only the ` +
|
|
505
|
+
`user:inference scope and the usage endpoint needs user:profile (upstream claude-code #11985). ` +
|
|
506
|
+
`Rotation for ${tokenMembers.length === 1 ? "it" : "them"} runs on stream telemetry: each turn's ` +
|
|
507
|
+
`rate-limit events still trigger the threshold and 5-hour-warning rules, but an idle account is ` +
|
|
508
|
+
`not re-measured between turns and there is no 95% usage alert.`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
const refOnly = tokenMembers.filter((a) => !a.oauthTokenFile);
|
|
512
|
+
if (refOnly.length > 0) {
|
|
513
|
+
note(
|
|
514
|
+
`in-turn retry: ${refOnly.map((a) => a.id).join(", ")} (oauthTokenRef) cannot be a mid-turn retry ` +
|
|
515
|
+
`target — the shim never receives secret values, and a reference has no file it could read. ` +
|
|
516
|
+
`${refOnly.length === 1 ? "It rotates" : "They rotate"} at the next launch instead.`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
452
519
|
const sticky = readJson(join(STATE_DIR, `pool-${pool.id ?? "clawd"}.sticky.json`));
|
|
453
520
|
|
|
454
521
|
// WHICH ACCOUNT IS ACTUALLY BEING USED. Everything above describes the pool
|