@drakon-systems/multi-clawd 2.4.0 → 2.5.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,103 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { acquireShimLock } from "./shim-lock.js";
5
+ export const HISTORY_FILE = "history.jsonl";
6
+ export const HISTORY_MAX_BYTES = 64 * 1024;
7
+ export const HISTORY_MAX_EVENTS = 500;
8
+ export function historyFile(stateFile) {
9
+ return join(dirname(stateFile), HISTORY_FILE);
10
+ }
11
+ const safeAccount = (value) => typeof value === "string" &&
12
+ value.length > 0 && value.length <= 100 && !/[\x00-\x1f\x7f]/.test(value);
13
+ const safeModel = (value) => typeof value === "string" &&
14
+ value.length > 0 && value.length <= 100 && /^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$/.test(value);
15
+ function valid(raw) {
16
+ if (!raw || typeof raw !== "object")
17
+ return undefined;
18
+ const e = raw;
19
+ if (typeof e.at !== "string" || !Number.isFinite(Date.parse(e.at)) ||
20
+ !["served", "retry", "rotation"].includes(String(e.kind)) || !safeAccount(e.accountId) ||
21
+ (e.toAccountId !== undefined && !safeAccount(e.toAccountId)) ||
22
+ (e.reason !== undefined && !["model_limit", "auth_failure", "transient", "pool_selection"].includes(String(e.reason))))
23
+ return undefined;
24
+ return { at: e.at, kind: e.kind, accountId: e.accountId,
25
+ ...(e.toAccountId ? { toAccountId: e.toAccountId } : {}),
26
+ ...(safeModel(e.modelId) ? { modelId: e.modelId } : {}),
27
+ ...(e.reason ? { reason: e.reason } : {}) };
28
+ }
29
+ export function readHistory(file) {
30
+ let raw;
31
+ try {
32
+ raw = readFileSync(file, "utf8");
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ return raw.split("\n").flatMap((line) => {
38
+ try {
39
+ const event = valid(JSON.parse(line));
40
+ return event ? [event] : [];
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ });
46
+ }
47
+ export function recordHistory(stateFile, event) {
48
+ if (!stateFile)
49
+ return;
50
+ let release;
51
+ let tmp;
52
+ try {
53
+ const checked = valid(event);
54
+ if (!checked)
55
+ return;
56
+ const file = historyFile(stateFile);
57
+ release = acquireShimLock(file);
58
+ if (!release)
59
+ return;
60
+ const events = [...readHistory(file), checked].slice(-HISTORY_MAX_EVENTS);
61
+ let lines = events.map((e) => JSON.stringify(e) + "\n");
62
+ let bytes = lines.reduce((sum, line) => sum + Buffer.byteLength(line), 0);
63
+ while (bytes > HISTORY_MAX_BYTES && lines.length)
64
+ bytes -= Buffer.byteLength(lines.shift());
65
+ mkdirSync(dirname(file), { recursive: true });
66
+ tmp = `${file}.tmp-${process.pid}-${randomUUID()}`;
67
+ writeFileSync(tmp, lines.join(""), { mode: 0o600 });
68
+ renameSync(tmp, file);
69
+ }
70
+ catch { }
71
+ finally {
72
+ if (tmp)
73
+ try {
74
+ unlinkSync(tmp);
75
+ }
76
+ catch { }
77
+ release?.();
78
+ }
79
+ }
80
+ export function parseHistorySince(value) {
81
+ const match = /^(\d+)([dh])$/.exec(value);
82
+ if (!match || Number(match[1]) < 1 || Number(match[1]) > 3650) {
83
+ throw new Error("--since must be a positive duration such as 7d or 24h");
84
+ }
85
+ return Number(match[1]) * (match[2] === "d" ? 86_400_000 : 3_600_000);
86
+ }
87
+ export function renderHistory(events, accountIds, since, nowMs = Date.now()) {
88
+ const cutoff = nowMs - parseHistorySince(since);
89
+ const recent = events.filter((e) => Date.parse(e.at) >= cutoff && Date.parse(e.at) <= nowMs);
90
+ const ids = [...new Set([...accountIds, ...recent.map((e) => e.accountId)])];
91
+ const lines = [`Account history (last ${since}):`];
92
+ if (!ids.length)
93
+ return [...lines, " No accounts configured."].join("\n");
94
+ for (const id of ids) {
95
+ const own = recent.filter((e) => e.accountId === id);
96
+ const count = (kind) => own.filter((e) => e.kind === kind).length;
97
+ const last = own.filter((e) => e.kind === "served")
98
+ .reduce((latest, e) => !latest || Date.parse(e.at) > Date.parse(latest) ? e.at : latest, undefined);
99
+ const n = (k, one, many) => `${k} ${k === 1 ? one : many}`;
100
+ lines.push(` ${id}: ${n(count("served"), "served launch", "served launches")}, ${n(count("retry"), "retry", "retries")}, ${n(count("rotation"), "rotation", "rotations")}; last served ${last ?? "never in this window"}`);
101
+ }
102
+ return lines.join("\n");
103
+ }
package/dist/index.js CHANGED
@@ -7,8 +7,10 @@ import { MODEL_ALIASES, buildCatalogEntries, canonicalModelId, isModernClaudeMod
7
7
  import { decideDegradation, matchesPin } from "./degrade.js";
8
8
  import { resolveExecMode, permissionModeArgs } from "./exec-policy.js";
9
9
  import { resolveBaseModelIds } from "./catalog-source.js";
10
- import { allCredentialFailed, classifyAccountHealth, credentialFailureFor, lastAuthSuccessAt, overdueProbeAccount, sevenDayReset, } from "./health.js";
10
+ import { allCredentialFailed, classifyAccountHealth, credentialFailureFor, lastAuthSuccessAt, overdueProbeAccount, poolStrategy, sevenDayReset, } from "./health.js";
11
11
  import { decideStickySelection } from "./sticky.js";
12
+ import { HISTORY_FILE, readHistory, recordHistory } from "./history.js";
13
+ import { spreadStatsFromHistory, spreadWindowMs, withSpreadStats } from "./spread.js";
12
14
  import { DEFAULT_SESSION_AFFINITY_TTL_MS, launchSessionId, readSessionPin, SESSION_AFFINITY_ENV, } from "./session-affinity.js";
13
15
  import { mergeHealthStates, parseStoredState, recordCredentialFailure, } from "./shim-core.js";
14
16
  import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js";
@@ -18,6 +20,7 @@ import { RETRY_ROSTER_ENV } from "./retry-plan.js";
18
20
  import { SESSION_DIRS_ENV } from "./session-handover.js";
19
21
  import { addAlert, alertKeysWithPrefix, clearAlert, pendingAlertText, } from "./alerts.js";
20
22
  import { healthStateFile, usageStateFile, clearAccountCredentialFailure } from "./credential-state.js";
23
+ import { persistHealthState, readHealthStateFile } from "./health-journal.js";
21
24
  import { createUsagePollController, effectiveUsagePollInterval, effectiveUsageWarnThreshold, usageAlertPrefix, usagePoolAlertKey, } from "./usage-poll.js";
22
25
  export { healthStateFile, usageStateFile, clearAccountCredentialFailure };
23
26
  import { accountConfigDir, AccountCredentialError, buildAccountChildEnv, credentialFailureText, parseSetupTokenFile, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
@@ -188,22 +191,8 @@ const refProbeTrackers = new Map();
188
191
  const lastProbeStatus = new Map();
189
192
  function recordAccountCredentialFailure(accountId, reason, nowMs) {
190
193
  const file = healthStateFile(accountId);
191
- let state;
192
- try {
193
- state = parseStoredState(readFileSync(file, "utf8")) ?? { accountId, windows: {} };
194
- }
195
- catch {
196
- state = { accountId, windows: {} };
197
- }
198
- const next = mergeHealthStates(state, recordCredentialFailure(state, reason, nowMs), nowMs);
199
- try {
200
- mkdirSync(dirname(file), { recursive: true });
201
- const tmp = `${file}.tmp-${process.pid}`;
202
- writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
203
- renameSync(tmp, file);
204
- }
205
- catch {
206
- }
194
+ const state = readHealthStateFile(file) ?? { accountId, windows: {} };
195
+ persistHealthState(file, recordCredentialFailure(state, reason, nowMs));
207
196
  }
208
197
  export async function runLoginHealthProbe(accounts, logger, deps = {}) {
209
198
  const now = deps.nowMs ?? Date.now();
@@ -740,7 +729,7 @@ export default definePluginEntry({
740
729
  },
741
730
  });
742
731
  function readHealthState(accountId) {
743
- return mergeStoredHealth(readStateFileLenient(healthStateFile(accountId)), readStateFileLenient(usageStateFile(accountId)));
732
+ return mergeStoredHealth(readHealthStateFile(healthStateFile(accountId)), readStateFileLenient(usageStateFile(accountId)));
744
733
  }
745
734
  function readStateFileLenient(file) {
746
735
  try {
@@ -757,6 +746,9 @@ function mergeStoredHealth(shim, usage) {
757
746
  return shim;
758
747
  return mergeHealthStates(shim, usage);
759
748
  }
749
+ function poolHistoryFile() {
750
+ return join(homedir(), ".openclaw", "state", "multi-clawd", HISTORY_FILE);
751
+ }
760
752
  function readStickyEntry(file) {
761
753
  try {
762
754
  const parsed = JSON.parse(readFileSync(file, "utf8"));
@@ -824,6 +816,11 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
824
816
  defaultModel: pool.defaultModel,
825
817
  };
826
818
  const minDwellMs = pool.minDwellMs;
819
+ const strategy = poolStrategy(pool.strategy);
820
+ const affinityOn = pool.sessionAffinity === true || strategy === "spread";
821
+ if (strategy === "spread" && pool.sessionAffinity === false) {
822
+ logger.warn(`[multi-clawd] pool "${poolId}": strategy "spread" keeps each session on the account that first served it — sessionAffinity: false is ignored`);
823
+ }
827
824
  const stickyFile = join(homedir(), ".openclaw", "state", "multi-clawd", `pool-${poolId}.sticky.json`);
828
825
  const backend = buildBackend(poolAccount, execMode, deps?.launch);
829
826
  const { launch, transport } = launchTransports.get(backend);
@@ -849,20 +846,23 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
849
846
  throw new Error(`[multi-clawd] ${text}`);
850
847
  }
851
848
  const previousSticky = readStickyEntry(stickyFile);
852
- const poolVerdicts = verdicts.map((v) => ({
849
+ const rawVerdicts = verdicts.map((v) => ({
853
850
  id: v.id,
854
851
  verdict: v.health.verdict,
855
852
  observedAt: v.health.observedAt,
856
- sevenDayResetsAt: pool.strategy === "reset-aware"
853
+ sevenDayResetsAt: strategy === "reset-aware"
857
854
  ? sevenDayReset(states.find((s) => s.id === v.id)?.state, options, now)
858
855
  : undefined,
859
856
  }));
857
+ const poolVerdicts = strategy === "spread"
858
+ ? withSpreadStats(rawVerdicts, spreadStatsFromHistory(readHistory(poolHistoryFile()), now, spreadWindowMs(pool.spreadWindowMs)))
859
+ : rawVerdicts;
860
860
  const decision = decideStickySelection({
861
861
  verdicts: poolVerdicts,
862
862
  sticky: previousSticky,
863
863
  nowMs: now,
864
864
  minDwellMs,
865
- strategy: pool.strategy,
865
+ strategy,
866
866
  });
867
867
  const chosen = members.find((a) => a.id === decision.account) ?? members[0];
868
868
  const previousAccount = previousSticky?.account ?? members[0].id;
@@ -878,7 +878,9 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
878
878
  logger.info(`[multi-clawd] pool ${poolId}: no proven account can serve ${requestedModel} — ` +
879
879
  `launching on ${decision.account} to re-test a login last rejected by the Claude CLI`);
880
880
  }
881
- else if (decision.account !== previousAccount) {
881
+ else if (strategy !== "spread" && decision.account !== previousAccount) {
882
+ recordHistory(join(homedir(), ".openclaw", "state", "multi-clawd", `${previousAccount}.json`), { at: new Date(now).toISOString(), kind: "rotation", accountId: previousAccount,
883
+ toAccountId: decision.account, modelId: requestedModel, reason: "pool_selection" });
882
884
  const home = verdicts[0];
883
885
  const line = decision.account === members[0].id
884
886
  ? `pool ${poolId}: returning home to ${decision.account}`
@@ -975,7 +977,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
975
977
  env[RETRY_ROSTER_ENV] = JSON.stringify(roster);
976
978
  }
977
979
  env[SESSION_DIRS_ENV] = JSON.stringify([...new Set(configuredMembers.map(accountConfigDir))]);
978
- if (pool.sessionAffinity) {
980
+ if (affinityOn) {
979
981
  env[SESSION_AFFINITY_ENV] = JSON.stringify({
980
982
  file: join(homedir(), ".openclaw", "state", "multi-clawd", `pool-${poolId}.sessions.json`),
981
983
  ttlMs: typeof pool.sessionAffinityTtlMs === "number" &&
@@ -1012,7 +1014,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
1012
1014
  logger.info(`[multi-clawd] pool ${poolId}: pinned lane keeps ${ctx.modelId} despite exhausted pool (will fail over via the chain)`);
1013
1015
  }
1014
1016
  }
1015
- const affinityTransport = pool.sessionAffinity && transport && launch.kind === "verified"
1017
+ const affinityTransport = affinityOn && transport && launch.kind === "verified"
1016
1018
  ? async function* (execution) {
1017
1019
  const id = launchSessionId(execution.args);
1018
1020
  const pin = id ? readSessionPin(join(homedir(), ".openclaw", "state", "multi-clawd", `pool-${poolId}.sessions.json`), id, Date.now(), typeof pool.sessionAffinityTtlMs === "number" && pool.sessionAffinityTtlMs > 0
@@ -1112,6 +1114,7 @@ export function startDirectOrderSync(params) {
1112
1114
  healthOptions,
1113
1115
  minDwellMs: params.pool?.minDwellMs,
1114
1116
  strategy: params.pool?.strategy,
1117
+ spreadWindowMs: params.pool?.spreadWindowMs,
1115
1118
  command: params.directRoute?.openclawCommand,
1116
1119
  });
1117
1120
  if (directController?.signature === signature && directTimer) {
@@ -1127,6 +1130,8 @@ export function startDirectOrderSync(params) {
1127
1130
  healthOptions,
1128
1131
  minDwellMs: params.pool?.minDwellMs,
1129
1132
  strategy: params.pool?.strategy,
1133
+ readHistory: () => readHistory(poolHistoryFile()),
1134
+ spreadWindowMs: params.pool?.spreadWindowMs,
1130
1135
  configOrder: params.configOrder,
1131
1136
  readSticky: () => readStickyEntry(stickyFile),
1132
1137
  writeSticky: (entry) => writeStickyEntry(stickyFile, entry, logger),
@@ -1228,17 +1233,7 @@ export function startUsagePoll(params) {
1228
1233
  }
1229
1234
  },
1230
1235
  readHealth: (id) => {
1231
- let shim;
1232
- try {
1233
- shim = parseStoredState(readFileSync(healthStateFile(id), "utf8"));
1234
- if (!shim)
1235
- throw new Error("not valid health-state JSON");
1236
- }
1237
- catch (err) {
1238
- if (err.code !== "ENOENT")
1239
- throw err;
1240
- shim = undefined;
1241
- }
1236
+ const shim = readHealthStateFile(healthStateFile(id), { strictSnapshot: true });
1242
1237
  let usage;
1243
1238
  try {
1244
1239
  usage = parseStoredState(readFileSync(usageStateFile(id), "utf8"));
@@ -66,6 +66,8 @@ export function chooseRetryAccount(params) {
66
66
  return account;
67
67
  verdicts.push({ id: account.id, verdict: health.verdict, observedAt: health.observedAt });
68
68
  }
69
+ if (params.allowOverdueProbe === false)
70
+ return undefined;
69
71
  const probe = overdueProbeAccount(verdicts, params.nowMs);
70
72
  return probe ? params.roster.find((a) => a.id === probe) : undefined;
71
73
  }
package/dist/shim-core.js CHANGED
@@ -192,9 +192,9 @@ export function limitFamilyFor(displayName, modelId) {
192
192
  return displayName.trim().toLowerCase() === family ? family : undefined;
193
193
  }
194
194
  const LIMIT_TEXT_RE = /reached your (.{1,40}?) limit/i;
195
- export function parseModelLimitError(line) {
196
- if (!/reached your /i.test(line))
197
- return undefined;
195
+ const USAGE_LIMIT_API_ERROR = "usage_limit_reached";
196
+ const AUTH_FAILED_ERROR_KIND = "authentication_failed";
197
+ function parseRecord(line) {
198
198
  let record;
199
199
  try {
200
200
  record = JSON.parse(line);
@@ -204,12 +204,15 @@ export function parseModelLimitError(line) {
204
204
  }
205
205
  if (typeof record !== "object" || record === null)
206
206
  return undefined;
207
- const r = record;
208
- const isErrorRecord = r.type === "error" ||
207
+ return record;
208
+ }
209
+ function isErrorRecord(r) {
210
+ return (r.type === "error" ||
209
211
  r.is_error === true ||
210
- (typeof r.subtype === "string" && r.subtype.startsWith("error"));
211
- if (!isErrorRecord)
212
- return undefined;
212
+ (typeof r.subtype === "string" && r.subtype.startsWith("error")) ||
213
+ (r.type === "assistant" && r.is_api_error_message === true));
214
+ }
215
+ function errorRecordTexts(r) {
213
216
  const texts = [];
214
217
  if (typeof r.result === "string")
215
218
  texts.push(r.result);
@@ -220,13 +223,193 @@ export function parseModelLimitError(line) {
220
223
  if (typeof msg === "string")
221
224
  texts.push(msg);
222
225
  }
223
- for (const text of texts) {
226
+ if (r.type === "assistant" && r.is_api_error_message === true && Array.isArray(r.message?.content)) {
227
+ for (const block of r.message.content) {
228
+ if (block && block.type === "text" && typeof block.text === "string")
229
+ texts.push(block.text);
230
+ }
231
+ }
232
+ return texts;
233
+ }
234
+ const TRANSIENT_TEXT_PATTERNS = [
235
+ { re: /repeated 529 overloaded errors/i, kind: "overloaded" },
236
+ { re: /overloaded_error/i, kind: "overloaded" },
237
+ { re: /the api is at capacity/i, kind: "overloaded" },
238
+ { re: /server-side issue, usually temporary/i, kind: "server_error" },
239
+ { re: /temporarily limiting requests \(not your usage limit\)/i, kind: "rate_limit_transient" },
240
+ { re: /request rejected \(429\)/i, kind: "rate_limit_transient" },
241
+ ];
242
+ const TRANSIENT_TEXT_MAX_CHARS = 160;
243
+ export function parseTransientApiError(line) {
244
+ if (!/("error"|is_error|api_error_status|overloaded|server-side|temporarily limiting|request rejected|at capacity)/i.test(line)) {
245
+ return undefined;
246
+ }
247
+ const r = parseRecord(line);
248
+ if (!r || !isErrorRecord(r))
249
+ return undefined;
250
+ if (parseModelLimitError(line) || parseAuthFailure(line))
251
+ return undefined;
252
+ const joined = errorRecordTexts(r).join("\n");
253
+ const status = typeof r.api_error_status === "number" ? r.api_error_status : undefined;
254
+ const text = joined.trim().slice(0, TRANSIENT_TEXT_MAX_CHARS);
255
+ const kindFromCli = typeof r.error === "string" ? r.error : undefined;
256
+ if (status === 529 || kindFromCli === "overloaded")
257
+ return { kind: "overloaded", status, text };
258
+ if ((status !== undefined && status >= 500) || kindFromCli === "server_error") {
259
+ return { kind: "server_error", status, text };
260
+ }
261
+ for (const { re, kind } of TRANSIENT_TEXT_PATTERNS) {
262
+ if (re.test(joined))
263
+ return { kind, status, text };
264
+ }
265
+ if (status === 429)
266
+ return { kind: "rate_limit_transient", status, text };
267
+ return undefined;
268
+ }
269
+ export function parseApiRetryNotice(line) {
270
+ if (!line.includes('"api_retry"'))
271
+ return undefined;
272
+ let record;
273
+ try {
274
+ record = JSON.parse(line);
275
+ }
276
+ catch {
277
+ return undefined;
278
+ }
279
+ if (typeof record !== "object" || record === null)
280
+ return undefined;
281
+ const r = record;
282
+ if (r.type !== "system" || r.subtype !== "api_retry")
283
+ return undefined;
284
+ const delay = typeof r.retry_delay_ms === "number" && r.retry_delay_ms >= 0 ? r.retry_delay_ms : 0;
285
+ return { retryDelayMs: delay };
286
+ }
287
+ export function parseModelLimitError(line) {
288
+ if (!/(reached your |usage_limit_reached)/i.test(line))
289
+ return undefined;
290
+ const r = parseRecord(line);
291
+ if (!r || !isErrorRecord(r))
292
+ return undefined;
293
+ for (const text of errorRecordTexts(r)) {
224
294
  const match = LIMIT_TEXT_RE.exec(text);
225
295
  if (match)
226
296
  return { displayName: match[1].trim() };
227
297
  }
298
+ if (r.api_error === USAGE_LIMIT_API_ERROR)
299
+ return { displayName: USAGE_LIMIT_API_ERROR };
228
300
  return undefined;
229
301
  }
302
+ export const LIMIT_PASSTHROUGH_MARKER = " (rate limit: 429)";
303
+ const HOST_DIAGNOSTIC_ERROR_PREFIX = "[ede_diagnostic]";
304
+ export function markForwardedLimitLine(line) {
305
+ if (!parseModelLimitError(line))
306
+ return line;
307
+ let record;
308
+ try {
309
+ record = JSON.parse(line);
310
+ }
311
+ catch {
312
+ return line;
313
+ }
314
+ if (typeof record !== "object" || record === null || Array.isArray(record))
315
+ return line;
316
+ const r = record;
317
+ const subtype = typeof r.subtype === "string" ? r.subtype : "";
318
+ if (r.type !== "result" || !(r.is_error === true || subtype.startsWith("error")))
319
+ return line;
320
+ const mark = (text) => text.includes(LIMIT_PASSTHROUGH_MARKER) ? undefined : `${text.trimEnd()}${LIMIT_PASSTHROUGH_MARKER}`;
321
+ if (Array.isArray(r.errors)) {
322
+ const idx = r.errors.findIndex((e) => typeof e === "string" && e.trim() !== "" && !e.trim().startsWith(HOST_DIAGNOSTIC_ERROR_PREFIX));
323
+ if (idx >= 0) {
324
+ const marked = mark(r.errors[idx]);
325
+ if (marked === undefined)
326
+ return line;
327
+ const errors = [...r.errors];
328
+ errors[idx] = marked;
329
+ return JSON.stringify({ ...r, errors });
330
+ }
331
+ }
332
+ if (typeof r.result === "string" && r.result.trim() !== "") {
333
+ const marked = mark(r.result);
334
+ return marked === undefined ? line : JSON.stringify({ ...r, result: marked });
335
+ }
336
+ if (typeof r.error === "object" && r.error !== null && !Array.isArray(r.error)) {
337
+ const err = r.error;
338
+ if (typeof err.message === "string" && err.message.trim() !== "") {
339
+ const marked = mark(err.message);
340
+ return marked === undefined ? line : JSON.stringify({ ...r, error: { ...err, message: marked } });
341
+ }
342
+ }
343
+ if (typeof r.error === "string" && r.error.trim() !== "") {
344
+ const marked = mark(r.error);
345
+ return marked === undefined ? line : JSON.stringify({ ...r, error: marked });
346
+ }
347
+ return line;
348
+ }
349
+ const RESULT_LINE_PREFIX = Buffer.from('{"type":"result"', "utf8");
350
+ const FORWARD_HOLD_CAP_BYTES = 256 * 1024;
351
+ function couldBeResultLine(pending) {
352
+ let i = 0;
353
+ while (i < pending.length && (pending[i] === 0x20 || pending[i] === 0x09 || pending[i] === 0x0d))
354
+ i++;
355
+ const rest = pending.subarray(i);
356
+ const n = Math.min(rest.length, RESULT_LINE_PREFIX.length);
357
+ return rest.subarray(0, n).equals(RESULT_LINE_PREFIX.subarray(0, n));
358
+ }
359
+ function forwardCompleteLine(line, onMark) {
360
+ const text = line.toString("utf8");
361
+ const ending = text.endsWith("\r\n") ? "\r\n" : text.endsWith("\n") ? "\n" : "";
362
+ const body = text.slice(0, text.length - ending.length).trim();
363
+ const marked = markForwardedLimitLine(body);
364
+ if (marked === body)
365
+ return line;
366
+ onMark?.();
367
+ return Buffer.from(marked + ending, "utf8");
368
+ }
369
+ export function createForwardWriter(write, onMark) {
370
+ let pending;
371
+ let streaming = false;
372
+ return {
373
+ push(chunk) {
374
+ const out = [];
375
+ let start = 0;
376
+ while (start < chunk.length) {
377
+ const nl = chunk.indexOf(0x0a, start);
378
+ const end = nl < 0 ? chunk.length : nl + 1;
379
+ const piece = chunk.subarray(start, end);
380
+ start = end;
381
+ if (streaming) {
382
+ out.push(piece);
383
+ if (nl >= 0)
384
+ streaming = false;
385
+ continue;
386
+ }
387
+ const line = pending ? Buffer.concat([pending, piece]) : piece;
388
+ pending = undefined;
389
+ if (nl >= 0) {
390
+ out.push(forwardCompleteLine(line, onMark));
391
+ }
392
+ else if (!couldBeResultLine(line) || line.length > FORWARD_HOLD_CAP_BYTES) {
393
+ out.push(line);
394
+ streaming = true;
395
+ }
396
+ else {
397
+ pending = Buffer.from(line);
398
+ }
399
+ }
400
+ if (out.length > 0)
401
+ write(out.length === 1 ? out[0] : Buffer.concat(out));
402
+ },
403
+ flush() {
404
+ if (pending) {
405
+ const line = pending;
406
+ pending = undefined;
407
+ write(forwardCompleteLine(line, onMark));
408
+ }
409
+ streaming = false;
410
+ },
411
+ };
412
+ }
230
413
  export function recordModelLimit(state, modelId, now, resetsAt, family) {
231
414
  return {
232
415
  ...state,
@@ -300,36 +483,16 @@ export function parseAuthFailure(line) {
300
483
  if (!/(authenticat|oauth|logged in|\/login|unauthori|api key|access token|bearer token)/i.test(line)) {
301
484
  return undefined;
302
485
  }
303
- let record;
304
- try {
305
- record = JSON.parse(line);
306
- }
307
- catch {
308
- return undefined;
309
- }
310
- if (typeof record !== "object" || record === null)
311
- return undefined;
312
- const r = record;
313
- const isErrorRecord = r.type === "error" ||
314
- r.is_error === true ||
315
- (typeof r.subtype === "string" && r.subtype.startsWith("error"));
316
- if (!isErrorRecord)
486
+ const r = parseRecord(line);
487
+ if (!r || !isErrorRecord(r))
317
488
  return undefined;
318
- const texts = [];
319
- if (typeof r.result === "string")
320
- texts.push(r.result);
321
- if (typeof r.error === "string")
322
- texts.push(r.error);
323
- if (typeof r.error === "object" && r.error !== null) {
324
- const msg = r.error.message;
325
- if (typeof msg === "string")
326
- texts.push(msg);
327
- }
328
- for (const text of texts) {
489
+ for (const text of errorRecordTexts(r)) {
329
490
  if (AUTH_FAILURE_PATTERNS.some((p) => p.test(text))) {
330
491
  return { reason: text.trim().slice(0, AUTH_REASON_MAX_CHARS) };
331
492
  }
332
493
  }
494
+ if (r.error === AUTH_FAILED_ERROR_KIND)
495
+ return { reason: AUTH_FAILED_ERROR_KIND };
333
496
  return undefined;
334
497
  }
335
498
  export function recordCredentialFailure(state, reason, now) {
@@ -0,0 +1,106 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute } from "node:path";
4
+ const WAIT_MS = 150;
5
+ const LEASE_MS = 5_000;
6
+ const pause = new Int32Array(new SharedArrayBuffer(4));
7
+ export function acquireShimLock(file, waitMs = WAIT_MS, hooks = {}) {
8
+ if (!file || !isAbsolute(file))
9
+ return undefined;
10
+ const lock = `${file}.lock`;
11
+ const deadline = Date.now() + waitMs;
12
+ try {
13
+ mkdirSync(dirname(lock), { recursive: true });
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ do {
19
+ try {
20
+ const fd = openSync(lock, "wx", 0o600);
21
+ const token = randomUUID();
22
+ const content = Buffer.from(`${process.pid} ${Date.now()} ${token}\n`);
23
+ let ino;
24
+ try {
25
+ ino = fstatSync(fd).ino;
26
+ writeFileSync(fd, content);
27
+ }
28
+ catch {
29
+ try {
30
+ if (ino !== undefined && statSync(lock).ino === ino) {
31
+ const found = readFileSync(lock);
32
+ if (found.includes(Buffer.from(token)) || found.length === 0)
33
+ unlinkSync(lock);
34
+ }
35
+ }
36
+ catch { }
37
+ try {
38
+ closeSync(fd);
39
+ }
40
+ catch { }
41
+ return undefined;
42
+ }
43
+ const release = () => {
44
+ try {
45
+ if (statSync(lock).ino === ino && readFileSync(lock).equals(content))
46
+ unlinkSync(lock);
47
+ }
48
+ catch { }
49
+ };
50
+ try {
51
+ (hooks.close ?? closeSync)(fd);
52
+ }
53
+ catch {
54
+ try {
55
+ closeSync(fd);
56
+ }
57
+ catch { }
58
+ return release;
59
+ }
60
+ return release;
61
+ }
62
+ catch (err) {
63
+ if (err.code !== "EEXIST")
64
+ return undefined;
65
+ }
66
+ let staleFd;
67
+ try {
68
+ staleFd = openSync(lock, "r");
69
+ const before = fstatSync(staleFd);
70
+ hooks.beforeStaleSnapshot?.();
71
+ const observed = readFileSync(staleFd);
72
+ const [pidText, timeText] = observed.toString("utf8").trim().split(" ");
73
+ const pid = Number(pidText);
74
+ const age = Date.now() - Number(timeText);
75
+ let dead = false;
76
+ if (Number.isSafeInteger(pid) && pid > 0) {
77
+ try {
78
+ process.kill(pid, 0);
79
+ }
80
+ catch (err) {
81
+ dead = err.code === "ESRCH";
82
+ }
83
+ }
84
+ if (dead || (Number.isFinite(age) && age > LEASE_MS) || Date.now() - before.mtimeMs > LEASE_MS) {
85
+ hooks.beforeStaleUnlink?.();
86
+ const current = statSync(lock);
87
+ if (current.ino === before.ino && current.dev === before.dev && readFileSync(lock).equals(observed)) {
88
+ unlinkSync(lock);
89
+ continue;
90
+ }
91
+ }
92
+ }
93
+ catch { }
94
+ finally {
95
+ if (staleFd !== undefined)
96
+ try {
97
+ closeSync(staleFd);
98
+ }
99
+ catch { }
100
+ }
101
+ if (Date.now() >= deadline)
102
+ break;
103
+ Atomics.wait(pause, 0, 0, Math.min(10, deadline - Date.now()));
104
+ } while (Date.now() <= deadline);
105
+ return undefined;
106
+ }