@drakon-systems/multi-clawd 1.8.11 → 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.
- package/README.md +76 -1
- package/SECURITY.md +1 -0
- package/dist/account-env.js +5 -0
- package/dist/chain-audit.js +9 -7
- package/dist/direct-report.js +119 -0
- package/dist/direct-route.js +156 -0
- package/dist/direct-sync.js +335 -0
- package/dist/explain-core.js +44 -2
- package/dist/index.js +102 -1
- package/dist/openclaw-runner.js +42 -0
- package/dist/session-handover.js +117 -0
- package/dist/setup-core.js +25 -0
- package/dist/shim.js +6 -0
- package/openclaw.plugin.json +66 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +236 -4
- package/scripts/doctor.mjs +117 -2
- package/scripts/setup.mjs +74 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
export const SESSION_DIRS_ENV = "MULTI_CLAWD_SESSION_DIRS";
|
|
5
|
+
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
6
|
+
export function resumeSessionId(argv) {
|
|
7
|
+
for (let i = 0; i < argv.length; i++) {
|
|
8
|
+
const arg = argv[i];
|
|
9
|
+
let value;
|
|
10
|
+
if (arg === "--resume" || arg === "-r")
|
|
11
|
+
value = argv[i + 1];
|
|
12
|
+
else if (arg.startsWith("--resume="))
|
|
13
|
+
value = arg.slice("--resume=".length);
|
|
14
|
+
if (value !== undefined)
|
|
15
|
+
return SESSION_ID_RE.test(value) ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
export function effectiveConfigDir(env) {
|
|
20
|
+
const dir = env.CLAUDE_CONFIG_DIR?.trim();
|
|
21
|
+
return dir ? dir : join(homedir(), ".claude");
|
|
22
|
+
}
|
|
23
|
+
export function parseSessionDirs(raw) {
|
|
24
|
+
if (!raw)
|
|
25
|
+
return [];
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
if (!Array.isArray(parsed))
|
|
29
|
+
return [];
|
|
30
|
+
return parsed.filter((d) => typeof d === "string" && d.trim().length > 0);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export const nodeHandoverFs = {
|
|
37
|
+
listDirs(dir) {
|
|
38
|
+
try {
|
|
39
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
40
|
+
.filter((d) => d.isDirectory())
|
|
41
|
+
.map((d) => d.name);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
mtimeMs(path) {
|
|
48
|
+
try {
|
|
49
|
+
const st = statSync(path);
|
|
50
|
+
return st.isFile() ? st.mtimeMs : undefined;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
export function planSessionHandover(params) {
|
|
58
|
+
const fs = params.fs ?? nodeHandoverFs;
|
|
59
|
+
const file = `${params.sessionId}.jsonl`;
|
|
60
|
+
const dirs = [params.targetDir, ...params.memberDirs.filter((d) => d !== params.targetDir)];
|
|
61
|
+
let newest;
|
|
62
|
+
for (const dir of dirs) {
|
|
63
|
+
const projects = join(dir, "projects");
|
|
64
|
+
for (const sub of fs.listDirs(projects)) {
|
|
65
|
+
const path = join(projects, sub, file);
|
|
66
|
+
const mtime = fs.mtimeMs(path);
|
|
67
|
+
if (mtime === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
if (!newest || mtime > newest.mtime)
|
|
70
|
+
newest = { dir, sub, path, mtime };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!newest)
|
|
74
|
+
return { action: "none", reason: "not-found" };
|
|
75
|
+
if (newest.dir === params.targetDir)
|
|
76
|
+
return { action: "none", reason: "already-local" };
|
|
77
|
+
return {
|
|
78
|
+
action: "copy",
|
|
79
|
+
from: newest.path,
|
|
80
|
+
to: join(params.targetDir, "projects", newest.sub, file),
|
|
81
|
+
fromDir: newest.dir,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function applySessionHandover(plan) {
|
|
85
|
+
const tmp = join(dirname(plan.to), `.${basename(plan.to)}.handover-${process.pid}`);
|
|
86
|
+
mkdirSync(dirname(plan.to), { recursive: true, mode: 0o700 });
|
|
87
|
+
try {
|
|
88
|
+
copyFileSync(plan.from, tmp);
|
|
89
|
+
renameSync(tmp, plan.to);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
rmSync(tmp, { force: true });
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function handoverForLaunch(argv, env, deps = {}) {
|
|
97
|
+
const sessionId = resumeSessionId(argv);
|
|
98
|
+
if (!sessionId)
|
|
99
|
+
return undefined;
|
|
100
|
+
const memberDirs = parseSessionDirs(env[SESSION_DIRS_ENV]);
|
|
101
|
+
if (memberDirs.length === 0)
|
|
102
|
+
return undefined;
|
|
103
|
+
const targetDir = effectiveConfigDir(env);
|
|
104
|
+
try {
|
|
105
|
+
const plan = planSessionHandover({ sessionId, targetDir, memberDirs, fs: deps.fs });
|
|
106
|
+
if (plan.action === "none") {
|
|
107
|
+
return plan.reason === "not-found"
|
|
108
|
+
? `resume handover: session ${sessionId.slice(0, 8)} is in no pool account's config dir — leaving the launch as-is`
|
|
109
|
+
: undefined;
|
|
110
|
+
}
|
|
111
|
+
(deps.apply ?? applySessionHandover)(plan);
|
|
112
|
+
return `resume handover: session ${sessionId.slice(0, 8)} copied from ${plan.fromDir} into ${targetDir} (newer copy)`;
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
return `resume handover failed for session ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`;
|
|
116
|
+
}
|
|
117
|
+
}
|
package/dist/setup-core.js
CHANGED
|
@@ -157,3 +157,28 @@ export function existingAccountDefaults(config, id) {
|
|
|
157
157
|
export function looksLikeSecretRef(id) {
|
|
158
158
|
return id.includes("://");
|
|
159
159
|
}
|
|
160
|
+
export function buildDirectSetting(account, choice) {
|
|
161
|
+
switch (choice.kind) {
|
|
162
|
+
case "skip":
|
|
163
|
+
return undefined;
|
|
164
|
+
case "reuse":
|
|
165
|
+
if (account.native || (!account.oauthTokenRef && !account.oauthTokenFile)) {
|
|
166
|
+
throw new Error(`account "${account.id}" has no setup-token to reuse — a native or config-dir login needs its own \`claude setup-token\` for the direct route`);
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
case "ref":
|
|
170
|
+
if (!isSecretRefShape(choice.ref)) {
|
|
171
|
+
throw new Error('secret ref must be { "source": "...", "provider": "...", "id": "..." }');
|
|
172
|
+
}
|
|
173
|
+
return { tokenRef: choice.ref };
|
|
174
|
+
case "file": {
|
|
175
|
+
const path = choice.path?.trim();
|
|
176
|
+
if (!path)
|
|
177
|
+
throw new Error("a token file path is required");
|
|
178
|
+
return { tokenFile: path };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export function canReuseCliTokenForDirect(account) {
|
|
183
|
+
return !account.native && Boolean(account.oauthTokenRef || account.oauthTokenFile);
|
|
184
|
+
}
|
package/dist/shim.js
CHANGED
|
@@ -6,6 +6,7 @@ import { rewriteModelArg } from "./degrade.js";
|
|
|
6
6
|
import { parseModelLimitError, recordModelLimit } from "./shim-core.js";
|
|
7
7
|
import { canonicalModelId } from "./models.js";
|
|
8
8
|
import { buildRetryEnv, chooseRetryAccount, parseRetryRoster, retryArming, RETRY_ROSTER_ENV, } from "./retry-plan.js";
|
|
9
|
+
import { handoverForLaunch } from "./session-handover.js";
|
|
9
10
|
function resolveClaudeCommand() {
|
|
10
11
|
const override = process.env.MULTI_CLAWD_CLAUDE_BIN;
|
|
11
12
|
if (override) {
|
|
@@ -88,6 +89,11 @@ if (modelOverride) {
|
|
|
88
89
|
process.stderr.write(`[multi-clawd shim] degrading model for this launch → ${modelOverride}\n`);
|
|
89
90
|
}
|
|
90
91
|
}
|
|
92
|
+
{
|
|
93
|
+
const note = handoverForLaunch(childArgs, process.env);
|
|
94
|
+
if (note)
|
|
95
|
+
process.stderr.write(`[multi-clawd shim] ${note}\n`);
|
|
96
|
+
}
|
|
91
97
|
const retryRoster = parseRetryRoster(process.env[RETRY_ROSTER_ENV]);
|
|
92
98
|
const arming = retryArming(childArgs, retryRoster);
|
|
93
99
|
let retryArmed = arming.armed;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.9.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.
|
|
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",
|
package/scripts/cli.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
19
19
|
import { readFileSync, existsSync as existsSyncEarly } from "node:fs";
|
|
20
20
|
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import { homedir as osHomedir } from "node:os";
|
|
21
22
|
import { fileURLToPath } from "node:url";
|
|
22
23
|
import readline from "node:readline/promises";
|
|
23
24
|
|
|
@@ -37,6 +38,7 @@ ${BOLD}🦞 multi-clawd${RESET} — multi-account Claude failover for OpenClaw
|
|
|
37
38
|
${BOLD}login${RESET} log a configured account in (or re-auth it) — right dir, right env
|
|
38
39
|
${BOLD}explain${RESET} your setup in plain English — accounts, pool, fallback chain
|
|
39
40
|
${BOLD}chain${RESET} audit your model routing — what actually serves each turn
|
|
41
|
+
${BOLD}direct${RESET} the direct anthropic/* route — status, or \`direct sync\` to store profiles
|
|
40
42
|
${BOLD}update${RESET} update the plugin to the latest version
|
|
41
43
|
${BOLD}doctor${RESET} health check (add --probe for a live turn)
|
|
42
44
|
${BOLD}hermes${RESET} sync or diagnose Hermes Agent's Anthropic credential pool
|
|
@@ -143,6 +145,195 @@ async function cliSkewNote(cli = cliVersion(), plugin = installedVersion()) {
|
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
|
|
148
|
+
|
|
149
|
+
/** The OpenClaw config as parsed JSON, or undefined when unreadable. */
|
|
150
|
+
function readOpenclawConfigSync() {
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(readFileSync(join(osHomedir(), ".openclaw", "openclaw.json"), "utf8"));
|
|
153
|
+
} catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Everything the direct-route surfaces need, loaded from dist. None of these
|
|
160
|
+
* modules import the `openclaw` peer, so they load from a global install.
|
|
161
|
+
* Returns undefined when no account mentions `direct` — callers then print
|
|
162
|
+
* nothing new (the compatibility contract).
|
|
163
|
+
*/
|
|
164
|
+
async function loadDirect(config) {
|
|
165
|
+
const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
|
|
166
|
+
const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
|
|
167
|
+
if (!accounts.some((a) => a && a.direct !== undefined && a.direct !== false)) return undefined;
|
|
168
|
+
let dr, ds, rep, run, shim;
|
|
169
|
+
try {
|
|
170
|
+
dr = await import(resolve(__dirname, "..", "dist", "direct-route.js"));
|
|
171
|
+
ds = await import(resolve(__dirname, "..", "dist", "direct-sync.js"));
|
|
172
|
+
rep = await import(resolve(__dirname, "..", "dist", "direct-report.js"));
|
|
173
|
+
run = await import(resolve(__dirname, "..", "dist", "openclaw-runner.js"));
|
|
174
|
+
shim = await import(resolve(__dirname, "..", "dist", "shim-core.js"));
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error(distFailure("direct", "direct-report.js", err));
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
const { readFileSync: rf } = await import("node:fs");
|
|
180
|
+
const { homedir } = await import("node:os");
|
|
181
|
+
const stateDir = join(homedir(), ".openclaw", "state", "multi-clawd");
|
|
182
|
+
const pool = pc.pool;
|
|
183
|
+
const directRoute = pc.directRoute ?? {};
|
|
184
|
+
const agents = Array.isArray(directRoute.agents) && directRoute.agents.length > 0 ? directRoute.agents : ["main"];
|
|
185
|
+
const configOrder = Array.isArray(config?.auth?.order?.anthropic) ? config.auth.order.anthropic : undefined;
|
|
186
|
+
const readHealth = (id) => {
|
|
187
|
+
try {
|
|
188
|
+
return shim.parseStoredState(rf(join(stateDir, `${id}.json`), "utf8"));
|
|
189
|
+
} catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
let sticky;
|
|
194
|
+
try {
|
|
195
|
+
sticky = JSON.parse(rf(join(stateDir, "direct-order.sticky.json"), "utf8"));
|
|
196
|
+
} catch {
|
|
197
|
+
/* none */
|
|
198
|
+
}
|
|
199
|
+
const runner = run.createOpenclawRunner(directRoute.openclawCommand ?? "openclaw");
|
|
200
|
+
const healthOptions = {
|
|
201
|
+
utilizationThreshold: pool?.utilizationThreshold,
|
|
202
|
+
staleAfterMs: pool?.staleAfterMs,
|
|
203
|
+
rotateOnOverage: pool?.rotateOnOverage,
|
|
204
|
+
};
|
|
205
|
+
const gather = (agentId, opts = {}) =>
|
|
206
|
+
rep.gatherDirectStatus({
|
|
207
|
+
accounts,
|
|
208
|
+
poolAccounts: pool?.accounts ?? [],
|
|
209
|
+
agentId,
|
|
210
|
+
runner,
|
|
211
|
+
readHealth,
|
|
212
|
+
healthOptions,
|
|
213
|
+
configOrder,
|
|
214
|
+
sticky,
|
|
215
|
+
minDwellMs: pool?.minDwellMs,
|
|
216
|
+
nowMs: Date.now(),
|
|
217
|
+
...opts,
|
|
218
|
+
});
|
|
219
|
+
return { dr, ds, rep, runner, accounts, pool, directRoute, agents, configOrder, readHealth, gather, healthOptions, sticky };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* `direct` — status (default) or `direct sync`: store each opted-in account's
|
|
224
|
+
* setup-token as an OpenClaw `anthropic` profile through OpenClaw's own CLI
|
|
225
|
+
* (secrets apply for refs — no copy; paste-token on stdin for files), then
|
|
226
|
+
* put every managed agent's order in pool-health order right away instead of
|
|
227
|
+
* waiting for the gateway's next tick.
|
|
228
|
+
*/
|
|
229
|
+
async function direct(args = []) {
|
|
230
|
+
const { readFileSync: rf, mkdtempSync, writeFileSync, rmSync, statSync } = await import("node:fs");
|
|
231
|
+
const { homedir, tmpdir } = await import("node:os");
|
|
232
|
+
const config = readOpenclawConfigSync();
|
|
233
|
+
if (!config) {
|
|
234
|
+
console.error("direct: could not read your OpenClaw config");
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
const ctx = await loadDirect(config);
|
|
238
|
+
const sub = args.find((a) => !a.startsWith("--")) ?? "status";
|
|
239
|
+
console.log(`\n${BOLD}🦞 multi-clawd — direct anthropic/* route${RESET}\n`);
|
|
240
|
+
if (!ctx) {
|
|
241
|
+
console.log(" Not configured: no account sets `direct`, so anthropic/* turns use OpenClaw's own");
|
|
242
|
+
console.log(" anthropic profiles and the pool steers CLI turns only. See the README (\"Direct route\").\n");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (sub === "sync") {
|
|
246
|
+
const dryRun = args.includes("--dry-run");
|
|
247
|
+
const resync = args.includes("--resync");
|
|
248
|
+
const { members, problems } = ctx.dr.collectDirectMembers(ctx.accounts, ctx.pool?.accounts ?? []);
|
|
249
|
+
for (const p of problems) console.log(` ⚠️ ${p.accountId}: skipped — ${p.reason}`);
|
|
250
|
+
if (members.length === 0) {
|
|
251
|
+
console.log(" nothing to sync.\n");
|
|
252
|
+
process.exit(problems.length > 0 ? 1 : 0);
|
|
253
|
+
}
|
|
254
|
+
const agentId = ctx.agents[0];
|
|
255
|
+
if (!dryRun && !args.includes("--yes")) {
|
|
256
|
+
console.log(` Will store missing anthropic profiles for agent ${agentId}${resync ? " (and re-store existing ones)" : ""}:`);
|
|
257
|
+
for (const m of members) console.log(` ${m.accountId} → ${m.profileId} (${ctx.rep.describeDirectSource(m)})`);
|
|
258
|
+
console.log(` ${DIM}Refs are stored as SecretRefs (secrets apply — the token is never copied). Token files go`);
|
|
259
|
+
console.log(` through paste-token on stdin; OpenClaw also records those in its config (auth.profiles/auth.order).${RESET}`);
|
|
260
|
+
if (!(await askYes(" Proceed?"))) return;
|
|
261
|
+
}
|
|
262
|
+
const expand = (p) => (p === "~" ? homedir() : p.startsWith("~/") ? join(homedir(), p.slice(2)) : p);
|
|
263
|
+
const { results, listError } = await ctx.ds.syncDirectProfiles({
|
|
264
|
+
members,
|
|
265
|
+
agentId,
|
|
266
|
+
runner: ctx.runner,
|
|
267
|
+
resync,
|
|
268
|
+
dryRun,
|
|
269
|
+
readTokenFile: (p) => {
|
|
270
|
+
const abs = expand(p);
|
|
271
|
+
const mode = statSync(abs).mode & 0o777;
|
|
272
|
+
if (mode & 0o077) console.log(` ⚠️ ${abs} is mode ${mode.toString(8)} — chmod 600 it.`);
|
|
273
|
+
return rf(abs, "utf8");
|
|
274
|
+
},
|
|
275
|
+
writePlanFile: (plan) => {
|
|
276
|
+
const dir = mkdtempSync(join(tmpdir(), "multi-clawd-plan-"));
|
|
277
|
+
const path = join(dir, "plan.json");
|
|
278
|
+
writeFileSync(path, JSON.stringify(plan, null, 2), { mode: 0o600 });
|
|
279
|
+
return { path, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
if (listError) {
|
|
283
|
+
console.error(` ❌ could not read OpenClaw's anthropic profiles: ${listError}`);
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
const icon = { present: "✅", adopted: "✅", stored: "✅", "would-store": "📝", "adopt-missing": "❌", failed: "❌" };
|
|
287
|
+
for (const r of results) {
|
|
288
|
+
console.log(` ${icon[r.action] ?? "•"} ${r.accountId} → ${r.profileId}: ${r.action}${r.detail ? ` (${r.detail})` : ""}`);
|
|
289
|
+
}
|
|
290
|
+
const failed = results.filter((r) => r.action === "failed" || r.action === "adopt-missing");
|
|
291
|
+
if (!dryRun && ctx.directRoute.manageOrder !== false) {
|
|
292
|
+
for (const agent of ctx.agents) {
|
|
293
|
+
const status = await ctx.gather(agent, { skipCooldowns: true });
|
|
294
|
+
if (!status || status.errors.length > 0) {
|
|
295
|
+
console.log(` ⚠️ agent ${agent}: order not checked (${status?.errors.join("; ") ?? "no status"})`);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (!status.pendingOrder) {
|
|
299
|
+
console.log(` ✅ agent ${agent}: anthropic order already in pool-health order`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const r = await ctx.runner(ctx.ds.orderSetArgs(agent, status.pendingOrder), { timeoutMs: 60000 });
|
|
303
|
+
console.log(
|
|
304
|
+
r.code === 0
|
|
305
|
+
? ` ✅ agent ${agent}: anthropic order → ${status.pendingOrder.join(" → ")}`
|
|
306
|
+
: ` ❌ agent ${agent}: order set ${ctx.ds.safeCliError(r)}`,
|
|
307
|
+
);
|
|
308
|
+
if (r.code !== 0) failed.push({ action: "failed" });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
console.log("");
|
|
312
|
+
process.exit(failed.length > 0 ? 1 : 0);
|
|
313
|
+
}
|
|
314
|
+
// status
|
|
315
|
+
const ec = await import(resolve(__dirname, "..", "dist", "explain-core.js"));
|
|
316
|
+
let problems = 0;
|
|
317
|
+
for (const agent of ctx.agents) {
|
|
318
|
+
const status = await ctx.gather(agent);
|
|
319
|
+
if (!status) continue;
|
|
320
|
+
console.log(`${DIM}agent ${agent}${RESET}`);
|
|
321
|
+
console.log(ec.renderDirectSection(status.explain, Date.now()).join("\n"));
|
|
322
|
+
for (const v of status.verdicts) console.log(` health ${v.accountId}: ${v.verdict}`);
|
|
323
|
+
if (status.pendingOrder) {
|
|
324
|
+
console.log(` ⏳ pending order (the gateway applies it within a minute, or run \`multi-clawd direct sync\`):`);
|
|
325
|
+
console.log(` ${status.pendingOrder.join(" → ")}`);
|
|
326
|
+
}
|
|
327
|
+
for (const e of status.errors) {
|
|
328
|
+
problems++;
|
|
329
|
+
console.log(` ⚠️ ${e}`);
|
|
330
|
+
}
|
|
331
|
+
problems += status.explain.problems.length + status.explain.members.filter((m) => m.stored === false).length;
|
|
332
|
+
console.log("");
|
|
333
|
+
}
|
|
334
|
+
process.exit(problems > 0 ? 1 : 0);
|
|
335
|
+
}
|
|
336
|
+
|
|
146
337
|
/**
|
|
147
338
|
* `chain` — one place that answers "what actually serves my turns, and does it
|
|
148
339
|
* match what I meant?".
|
|
@@ -178,6 +369,14 @@ async function chain(args = []) {
|
|
|
178
369
|
const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
|
|
179
370
|
const poolId = pc.pool?.id?.trim() || (pc.pool ? "clawd" : undefined);
|
|
180
371
|
const chainCfg = config?.agents?.defaults?.model;
|
|
372
|
+
// v1.9: anthropic/* is pooled when the direct route serves two accounts.
|
|
373
|
+
let directPooled = false;
|
|
374
|
+
try {
|
|
375
|
+
const dr = await import(resolve(__dirname, "..", "dist", "direct-route.js"));
|
|
376
|
+
directPooled = dr.directRoutePools(Array.isArray(pc.accounts) ? pc.accounts : [], pc.directRoute);
|
|
377
|
+
} catch {
|
|
378
|
+
/* older dist: no direct route */
|
|
379
|
+
}
|
|
181
380
|
|
|
182
381
|
console.log(`\n${BOLD}🦞 multi-clawd — model routing${RESET}\n`);
|
|
183
382
|
|
|
@@ -188,7 +387,10 @@ async function chain(args = []) {
|
|
|
188
387
|
if (rungs.length === 0) console.log(" (none configured)");
|
|
189
388
|
rungs.forEach((r, i) => {
|
|
190
389
|
const pooled = poolId && r.startsWith(`${poolId}/`);
|
|
191
|
-
|
|
390
|
+
const directRung = directPooled && /^anthropic\//i.test(r);
|
|
391
|
+
console.log(
|
|
392
|
+
` ${i + 1}. ${r}${pooled ? ` ${DIM}→ pooled${RESET}` : directRung ? ` ${DIM}→ pooled (direct route)${RESET}` : ""}`,
|
|
393
|
+
);
|
|
192
394
|
});
|
|
193
395
|
console.log("");
|
|
194
396
|
|
|
@@ -209,7 +411,7 @@ async function chain(args = []) {
|
|
|
209
411
|
`${f.surface} → ${f.ref}`,
|
|
210
412
|
);
|
|
211
413
|
|
|
212
|
-
const configFindings = ca.auditEffectiveChain(config, poolId);
|
|
414
|
+
const configFindings = ca.auditEffectiveChain(config, poolId, { directPooled });
|
|
213
415
|
section(
|
|
214
416
|
"OFF-POOL REFERENCES",
|
|
215
417
|
configFindings.filter((f) => f.severity === "warn"),
|
|
@@ -236,7 +438,7 @@ async function chain(args = []) {
|
|
|
236
438
|
console.log(`${DIM} (session store ${location.path} not audited: ${read.error} — see doctor)${RESET}`);
|
|
237
439
|
continue;
|
|
238
440
|
}
|
|
239
|
-
sessionFindings.push(...ca.auditSessionOverrides(read.entries, Boolean(poolId)));
|
|
441
|
+
sessionFindings.push(...ca.auditSessionOverrides(read.entries, Boolean(poolId), { directPooled }));
|
|
240
442
|
}
|
|
241
443
|
} catch {
|
|
242
444
|
/* no agents dir, or dist not built — doctor reports install health */
|
|
@@ -296,6 +498,7 @@ async function update() {
|
|
|
296
498
|
console.log(" ⏳ remember: the new version loads on the next gateway restart.");
|
|
297
499
|
}
|
|
298
500
|
await healWatchdogUnit();
|
|
501
|
+
await offerDirectSync();
|
|
299
502
|
await offerCliSelfUpdate(uc);
|
|
300
503
|
console.log(`\n${BOLD} health check${RESET}`);
|
|
301
504
|
const doc = spawnSync(process.execPath, [join(__dirname, "doctor.mjs")], { stdio: "inherit" });
|
|
@@ -306,6 +509,24 @@ async function update() {
|
|
|
306
509
|
console.log(`\n ✅ done — now on v${installedVersion() ?? "?"}`);
|
|
307
510
|
}
|
|
308
511
|
|
|
512
|
+
/**
|
|
513
|
+
* v1.9: when any account opts into the direct route, store any profile that
|
|
514
|
+
* is missing and bring the order into health order as part of the update —
|
|
515
|
+
* the same `direct sync` the operator can run on its own. Skipped silently
|
|
516
|
+
* when nothing is configured, so updates for CLI-only users are unchanged.
|
|
517
|
+
*/
|
|
518
|
+
async function offerDirectSync() {
|
|
519
|
+
const config = readOpenclawConfigSync();
|
|
520
|
+
const accounts = config?.plugins?.entries?.["multi-clawd"]?.config?.accounts ?? [];
|
|
521
|
+
if (!accounts.some((a) => a && a.direct !== undefined && a.direct !== false)) return;
|
|
522
|
+
if (!(await askYes("\n Sync direct-route (anthropic/*) profiles and order now?"))) {
|
|
523
|
+
console.log(` ${DIM}Skipped — run \`multi-clawd direct sync\` when you're ready.${RESET}`);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const r = spawnSync(process.execPath, [join(__dirname, "cli.mjs"), "direct", "sync", "--yes"], { stdio: "inherit" });
|
|
527
|
+
if (r.status !== 0) console.log(" ⚠ direct sync reported problems — see above (`multi-clawd direct` for status).");
|
|
528
|
+
}
|
|
529
|
+
|
|
309
530
|
/**
|
|
310
531
|
* `update` upgrades the PLUGIN; this finishes the job by offering to upgrade
|
|
311
532
|
* the CLI too, so "update" means what a user reasonably assumes it means.
|
|
@@ -475,9 +696,17 @@ async function explain() {
|
|
|
475
696
|
/* no sticky state */
|
|
476
697
|
}
|
|
477
698
|
}
|
|
699
|
+
// v1.9: the direct anthropic/* route, when any account opted in.
|
|
700
|
+
let direct;
|
|
701
|
+
const dctx = await loadDirect(config);
|
|
702
|
+
if (dctx) {
|
|
703
|
+
const status = await dctx.gather(dctx.agents[0]);
|
|
704
|
+
direct = status?.explain;
|
|
705
|
+
for (const e of status?.errors ?? []) console.log(`${DIM}(direct route: ${e})${RESET}`);
|
|
706
|
+
}
|
|
478
707
|
console.log(`\n${BOLD}🦞 multi-clawd — your setup, in plain English${RESET}\n`);
|
|
479
708
|
console.log(
|
|
480
|
-
ec.renderExplanation({ accounts, pool, chain, health: healthRows, stickyAccount, nowMs: now }),
|
|
709
|
+
ec.renderExplanation({ accounts, pool, chain, health: healthRows, stickyAccount, nowMs: now, direct }),
|
|
481
710
|
);
|
|
482
711
|
console.log(`\n${DIM}(health checks: multi-clawd doctor · change things: multi-clawd setup)${RESET}`);
|
|
483
712
|
}
|
|
@@ -599,6 +828,9 @@ switch (cmd) {
|
|
|
599
828
|
case "chain":
|
|
600
829
|
await chain(rest);
|
|
601
830
|
break;
|
|
831
|
+
case "direct":
|
|
832
|
+
await direct(rest);
|
|
833
|
+
break;
|
|
602
834
|
case "doctor":
|
|
603
835
|
runSibling("doctor.mjs", rest);
|
|
604
836
|
break;
|