@drakon-systems/multi-clawd 1.0.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.
@@ -0,0 +1,202 @@
1
+ import { isModernClaudeModelId } from "./models.js";
2
+ export function maskSessionKey(key) {
3
+ const segs = key.split(":");
4
+ const last = segs[segs.length - 1];
5
+ const looksLikeId = /^\d{5,}$/.test(last) || /^[0-9a-f]{6,}(-[0-9a-f]+)*$/i.test(last);
6
+ if (!looksLikeId)
7
+ return key;
8
+ const tail = last.length >= 4 ? last.slice(-4) : last;
9
+ segs[segs.length - 1] = `…${tail}`;
10
+ return segs.join(":");
11
+ }
12
+ const CLAUDE_SHORT_ALIASES = new Set(["opus", "sonnet", "haiku"]);
13
+ const ACCOUNT_PIN_RE = /^claw\d+$/;
14
+ export function isClaudeModelId(modelId) {
15
+ const id = modelId.trim();
16
+ if (!id)
17
+ return false;
18
+ if (CLAUDE_SHORT_ALIASES.has(id))
19
+ return true;
20
+ if (/^(opus|sonnet|haiku)-/.test(id))
21
+ return true;
22
+ return isModernClaudeModelId(id);
23
+ }
24
+ function parseRef(ref) {
25
+ const idx = ref.indexOf("/");
26
+ if (idx < 0)
27
+ return { modelId: ref };
28
+ return { provider: ref.slice(0, idx), modelId: ref.slice(idx + 1) };
29
+ }
30
+ export function offPoolClaudeRef(ref, poolId) {
31
+ if (typeof ref !== "string" || ref.length === 0)
32
+ return null;
33
+ const { provider, modelId } = parseRef(ref);
34
+ if (!isClaudeModelId(modelId))
35
+ return null;
36
+ if (provider === undefined)
37
+ return null;
38
+ if (provider === poolId)
39
+ return null;
40
+ if (provider === "anthropic" || provider === "claude-cli")
41
+ return "strong";
42
+ if (ACCOUNT_PIN_RE.test(provider))
43
+ return "warn";
44
+ return null;
45
+ }
46
+ function classifyBypass(ref, poolId) {
47
+ const severity = offPoolClaudeRef(ref, poolId);
48
+ if (!severity)
49
+ return null;
50
+ const { provider } = parseRef(ref);
51
+ if (provider === "anthropic") {
52
+ return `routes direct to the Anthropic API/native, bypassing the ${poolId} pool — no cross-account failover`;
53
+ }
54
+ if (provider === "claude-cli") {
55
+ return `routes direct to the claude CLI, bypassing the ${poolId} pool — no cross-account failover`;
56
+ }
57
+ return `pins a single pool account; cross-account failover won't fire — use ${poolId}/ for the pool`;
58
+ }
59
+ function extractModelRefs(surface, value) {
60
+ if (typeof value === "string")
61
+ return [{ surface, ref: value }];
62
+ if (value && typeof value === "object") {
63
+ const out = [];
64
+ const v = value;
65
+ if (typeof v.primary === "string")
66
+ out.push({ surface: `${surface}.primary`, ref: v.primary });
67
+ if (Array.isArray(v.fallbacks)) {
68
+ v.fallbacks.forEach((f, i) => {
69
+ if (typeof f === "string")
70
+ out.push({ surface: `${surface}.fallbacks[${i}]`, ref: f });
71
+ });
72
+ }
73
+ return out;
74
+ }
75
+ return [];
76
+ }
77
+ function collectCronRefs(surface, node, depth, out) {
78
+ if (!node || depth > 4)
79
+ return;
80
+ if (Array.isArray(node)) {
81
+ node.forEach((item, i) => collectCronRefs(`${surface}[${i}]`, item, depth + 1, out));
82
+ return;
83
+ }
84
+ if (typeof node !== "object")
85
+ return;
86
+ const obj = node;
87
+ if ("model" in obj) {
88
+ for (const r of extractModelRefs(`${surface}.model`, obj.model)) {
89
+ out.push({ ...r, allowlist: false });
90
+ }
91
+ }
92
+ for (const [k, v] of Object.entries(obj)) {
93
+ if (k === "model")
94
+ continue;
95
+ if (v && typeof v === "object")
96
+ collectCronRefs(`${surface}.${k}`, v, depth + 1, out);
97
+ }
98
+ }
99
+ const RESERVED_AGENT_KEYS = new Set(["defaults", "list", "models"]);
100
+ function collectChainRefs(config) {
101
+ const out = [];
102
+ const push = (surface, value) => {
103
+ for (const r of extractModelRefs(surface, value))
104
+ out.push({ ...r, allowlist: false });
105
+ };
106
+ const cfg = (config ?? {});
107
+ const agents = (cfg.agents ?? {});
108
+ const defaults = (agents.defaults ?? {});
109
+ push("agents.defaults.model", defaults.model);
110
+ const subagents = (defaults.subagents ?? {});
111
+ if (subagents.model !== undefined)
112
+ push("agents.defaults.subagents.model", subagents.model);
113
+ for (const [k, v] of Object.entries(subagents)) {
114
+ if (k === "model")
115
+ continue;
116
+ if (v && typeof v === "object" && "model" in v) {
117
+ push(`agents.defaults.subagents.${k}.model`, v.model);
118
+ }
119
+ }
120
+ for (const [name, v] of Object.entries(agents)) {
121
+ if (RESERVED_AGENT_KEYS.has(name))
122
+ continue;
123
+ if (!v || typeof v !== "object")
124
+ continue;
125
+ const agent = v;
126
+ if ("model" in agent)
127
+ push(`agents.${name}.model`, agent.model);
128
+ if ("cron" in agent)
129
+ collectCronRefs(`agents.${name}.cron`, agent.cron, 0, out);
130
+ }
131
+ if (Array.isArray(agents.list)) {
132
+ agents.list.forEach((a, i) => {
133
+ if (!a || typeof a !== "object")
134
+ return;
135
+ const agent = a;
136
+ const label = typeof agent.id === "string" ? agent.id : String(i);
137
+ if ("model" in agent)
138
+ push(`agents.list[${label}].model`, agent.model);
139
+ if ("cron" in agent)
140
+ collectCronRefs(`agents.list[${label}].cron`, agent.cron, 0, out);
141
+ });
142
+ }
143
+ for (const section of ["crons", "schedules", "jobs", "scheduled"]) {
144
+ if (cfg[section] !== undefined)
145
+ collectCronRefs(section, cfg[section], 0, out);
146
+ }
147
+ const allowlist = defaults.models;
148
+ if (allowlist && typeof allowlist === "object") {
149
+ for (const key of Object.keys(allowlist)) {
150
+ out.push({ surface: `agents.defaults.models["${key}"]`, ref: key, allowlist: true });
151
+ }
152
+ }
153
+ return out;
154
+ }
155
+ export function auditEffectiveChain(config, poolId) {
156
+ if (!poolId)
157
+ return [];
158
+ const findings = [];
159
+ for (const { surface, ref, allowlist } of collectChainRefs(config)) {
160
+ const reason = classifyBypass(ref, poolId);
161
+ if (!reason)
162
+ continue;
163
+ findings.push({ surface, ref, severity: allowlist ? "note" : "warn", reason });
164
+ }
165
+ return findings;
166
+ }
167
+ const POOL_PROVIDER = "clawd";
168
+ export function auditSessionOverrides(sessions, poolConfigured) {
169
+ if (!poolConfigured)
170
+ return [];
171
+ const findings = [];
172
+ for (const [sessionKey, entry] of Object.entries(sessions ?? {})) {
173
+ if (!entry || typeof entry !== "object")
174
+ continue;
175
+ if (/:subagent:/.test(sessionKey))
176
+ continue;
177
+ const source = entry.modelOverrideSource;
178
+ if (typeof source !== "string" || source === "auto")
179
+ continue;
180
+ const provider = entry.providerOverride ?? entry.modelProvider;
181
+ const model = entry.modelOverride;
182
+ if (!provider || !model) {
183
+ const missing = !provider && !model ? "provider and model" : !provider ? "provider" : "model";
184
+ findings.push({
185
+ surface: `session ${sessionKey}`,
186
+ ref: `${provider ?? "?"}/${model ?? "?"}`,
187
+ severity: "warn",
188
+ reason: `session override present (source=${source}) but ${missing} field missing — schema drift, cannot verify pool routing`,
189
+ });
190
+ continue;
191
+ }
192
+ const ref = `${provider}/${model}`;
193
+ const severity = offPoolClaudeRef(ref, POOL_PROVIDER);
194
+ if (!severity)
195
+ continue;
196
+ const reason = severity === "strong"
197
+ ? `off-pool /model pin — routes direct to ${provider}, bypassing the ${POOL_PROVIDER} pool; no cross-account failover`
198
+ : `/model pin to a single pool account (${provider}); cross-account failover won't fire — use ${POOL_PROVIDER}/ for the pool`;
199
+ findings.push({ surface: `session ${sessionKey}`, ref, severity: "warn", reason });
200
+ }
201
+ return findings;
202
+ }
@@ -0,0 +1,32 @@
1
+ export function decideDegradation(params) {
2
+ const { verdicts, requestedModel, ladder } = params;
3
+ if (ladder.length === 0)
4
+ return undefined;
5
+ if (ladder.includes(requestedModel))
6
+ return undefined;
7
+ const allExhausted = verdicts.length > 0 && verdicts.every((v) => v.verdict === "exhausted");
8
+ if (!allExhausted)
9
+ return undefined;
10
+ return {
11
+ model: ladder[0],
12
+ reason: `pool exhausted for ${requestedModel}`,
13
+ };
14
+ }
15
+ export function matchesPin(pins, launch) {
16
+ return pins.some((pin) => {
17
+ const checks = [];
18
+ if (pin.agentDirIncludes)
19
+ checks.push(launch.agentDir.includes(pin.agentDirIncludes));
20
+ if (pin.workspaceDirIncludes)
21
+ checks.push(launch.workspaceDir.includes(pin.workspaceDirIncludes));
22
+ return checks.length > 0 && checks.every(Boolean);
23
+ });
24
+ }
25
+ export function rewriteModelArg(argv, model) {
26
+ const out = [...argv];
27
+ for (let i = 0; i < out.length - 1; i += 1) {
28
+ if (out[i] === "--model")
29
+ out[i + 1] = model;
30
+ }
31
+ return out;
32
+ }
@@ -0,0 +1,15 @@
1
+ export function resolveExecMode(config) {
2
+ if (typeof config !== "object" || config === null)
3
+ return undefined;
4
+ const tools = config.tools;
5
+ if (typeof tools !== "object" || tools === null)
6
+ return undefined;
7
+ const exec = tools.exec;
8
+ if (typeof exec !== "object" || exec === null)
9
+ return undefined;
10
+ const mode = exec.mode;
11
+ return typeof mode === "string" ? mode : undefined;
12
+ }
13
+ export function permissionModeArgs(execMode) {
14
+ return execMode === "full" ? ["--permission-mode", "bypassPermissions"] : [];
15
+ }
package/dist/health.js ADDED
@@ -0,0 +1,83 @@
1
+ import { modelWindowKey } from "./shim-core.js";
2
+ const DEFAULT_UTILIZATION_THRESHOLD = 0.85;
3
+ const DEFAULT_STALE_AFTER_MS = 6 * 60 * 60 * 1000;
4
+ export const MODEL_REJECTED_TTL_MS = 60 * 60 * 1000;
5
+ const MODEL_WINDOW_PREFIX = "model:";
6
+ export const MAX_RESET_HORIZON_MS = 8 * 24 * 60 * 60 * 1000;
7
+ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
8
+ const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
9
+ const threshold = options.utilizationThreshold ?? DEFAULT_UTILIZATION_THRESHOLD;
10
+ if (!state)
11
+ return { verdict: "no_data" };
12
+ const requestedWindowKey = requestedModel !== undefined ? modelWindowKey(requestedModel) : undefined;
13
+ let worst = { verdict: "ok" };
14
+ let hasLiveEvidence = false;
15
+ for (const [window, w] of Object.entries(state.windows)) {
16
+ const resetMs = typeof w.resetsAt === "number" ? w.resetsAt * 1000 : undefined;
17
+ const resetBearing = resetMs !== undefined && resetMs > nowMs;
18
+ if (resetBearing && nowMs - w.seenAt > MAX_RESET_HORIZON_MS) {
19
+ console.warn(`[multi-clawd] health: window ${window} on ${state.accountId} exceeded 8d ` +
20
+ `reset-horizon cap (resetsAt=${new Date(resetMs).toISOString()}) — ` +
21
+ `possible clock skew / resetsAt parse bug`);
22
+ continue;
23
+ }
24
+ if (window.startsWith(MODEL_WINDOW_PREFIX)) {
25
+ const modelFresh = resetBearing || nowMs - w.seenAt <= MODEL_REJECTED_TTL_MS;
26
+ if (!modelFresh)
27
+ continue;
28
+ hasLiveEvidence = true;
29
+ const canonicalWindow = modelWindowKey(window.slice(MODEL_WINDOW_PREFIX.length));
30
+ if (!requestedWindowKey || canonicalWindow !== requestedWindowKey)
31
+ continue;
32
+ if (w.status !== "rejected")
33
+ continue;
34
+ if (resetMs !== undefined) {
35
+ if (resetMs > nowMs) {
36
+ return {
37
+ verdict: "exhausted",
38
+ resumeAt: resetMs,
39
+ reason: `${requestedModel} limit rejected until ${new Date(resetMs).toISOString()}`,
40
+ };
41
+ }
42
+ continue;
43
+ }
44
+ return {
45
+ verdict: "exhausted",
46
+ resumeAt: w.seenAt + MODEL_REJECTED_TTL_MS,
47
+ reason: `${requestedModel} limit hit ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; TTL block)`,
48
+ };
49
+ }
50
+ const fresh = resetBearing || nowMs - w.seenAt <= staleAfterMs;
51
+ if (!fresh)
52
+ continue;
53
+ hasLiveEvidence = true;
54
+ if (w.status === "rejected" && resetBearing) {
55
+ return {
56
+ verdict: "exhausted",
57
+ resumeAt: resetMs,
58
+ reason: `${window} rejected until ${new Date(resetMs).toISOString()}`,
59
+ };
60
+ }
61
+ if (worst.verdict === "ok" &&
62
+ typeof w.utilization === "number" &&
63
+ w.utilization >= threshold &&
64
+ (resetBearing || resetMs === undefined)) {
65
+ worst = {
66
+ verdict: "near_limit",
67
+ reason: `${window} utilization ${w.utilization} >= ${threshold}`,
68
+ };
69
+ }
70
+ }
71
+ if (worst.verdict === "ok" && !hasLiveEvidence)
72
+ return { verdict: "no_data" };
73
+ return worst;
74
+ }
75
+ export function choosePoolAccount(pool) {
76
+ const usable = pool.find((a) => a.verdict === "ok" || a.verdict === "no_data");
77
+ if (usable)
78
+ return usable.id;
79
+ return pool.find((a) => a.verdict === "near_limit")?.id;
80
+ }
81
+ export function pickPoolAccountForLaunch(pool) {
82
+ return choosePoolAccount(pool) ?? pool[0].id;
83
+ }