faberun 0.12.0 → 0.13.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.
@@ -77,13 +77,23 @@ const CAPABILITY_NAMES = new Set([
77
77
  * Claude-compatible adapters enforce it through hook settings; an adapter
78
78
  * that cannot prove enforcement must never receive it.
79
79
  *
80
- * @typedef {{foregroundOnly: boolean, maxToolOutputBytes: number|null, workspace: string, writeFiles: string[], writeRoots: string[], maxReadLines: number|null}} ToolPolicy
80
+ * @typedef {{foregroundOnly: boolean, maxToolOutputBytes: number|null, workspace: string, writeFiles: string[], writeRoots: string[], maxReadLines: number|null, maxReadBytes?: number|null}} ToolPolicy
81
81
  */
82
82
 
83
83
  /** Line count above which a whole-file read is denied by the tool policy hook. */
84
84
  export const READ_LINE_LIMIT = 1500;
85
+ /**
86
+ * Whole-file reads above this many bytes are denied at the tool boundary
87
+ * with the same retry hint (offset and limit). measured 2026-09-20 over 1189
88
+ * Read results in stored claude worker turns: p50 2.9 KB, p90 16.7 KB, p95
89
+ * 24 KB, max 57 KB; 28 exceeded 32 KiB. Every byte read is written to the
90
+ * cache once and re-read by every later request of the turn (median 49), so
91
+ * one 57 KB read costs about a tenth of a median turn by itself. The line
92
+ * threshold above misses a file of few very long lines; this one does not.
93
+ */
94
+ export const READ_BYTE_LIMIT = 32 * 1024;
85
95
 
86
- /** @typedef {{schema?: object, schemaPath?: string, continuationId?: string|null, toolPolicy?: ToolPolicy, env?: Record<string, string>}} CommandOptions */
96
+ /** @typedef {{schema?: object, schemaPath?: string, continuationId?: string|null, toolPolicy?: ToolPolicy, env?: Record<string, string>, maxTurns?: number}} CommandOptions */
87
97
 
88
98
  /** @typedef {{preferStructured?: boolean, exitCode?: number|null, signal?: string|null, stderr?: string}} NormalizeOptions */
89
99
 
@@ -262,15 +272,16 @@ export function normalizeProviderAvailability(runtimeOrHarness, response, exitCo
262
272
  * of waiting on the same runtime.
263
273
  *
264
274
  * @param {unknown} envelope
275
+ * @param {number} [now] the instant a wall-clock reset sentence is read against (tests inject it)
265
276
  * @returns {string|null}
266
277
  */
267
- export function exhaustedUntilOf(envelope) {
278
+ export function exhaustedUntilOf(envelope, now = Date.now()) {
268
279
  if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return null;
269
280
  const record = /** @type {Record<string, unknown>} */ (envelope);
270
281
  const error = record.error && typeof record.error === "object"
271
282
  ? /** @type {Record<string, unknown>} */ (record.error)
272
283
  : null;
273
- return resetTimestamp(record.exhaustedUntil ?? error?.resetAt ?? (typeof error?.message === "string" ? error.message : null));
284
+ return resetTimestamp(record.exhaustedUntil ?? error?.resetAt ?? (typeof error?.message === "string" ? error.message : null), now);
274
285
  }
275
286
 
276
287
  /**
@@ -287,7 +298,7 @@ function classifyAvailabilityText(text) {
287
298
  if (/insufficient balance/iu.test(text) || /\b402\b/u.test(text)) {
288
299
  return { available: false, exhaustedUntil: null, reason: "insufficient_balance" };
289
300
  }
290
- if (/quota|rate.?limit|usage limit|limit exhausted|1310/iu.test(text)) {
301
+ if (/quota|rate.?limit|usage limit|session limit|limit exhausted|1310/iu.test(text)) {
291
302
  return { available: false, exhaustedUntil: resetTimestamp(text), reason: "quota_exhausted" };
292
303
  }
293
304
  if (/auth|credential|unauthori[sz]ed|forbidden|invalid.*(?:key|token)|(?:api|access) key|login/iu.test(text)) {
@@ -296,8 +307,8 @@ function classifyAvailabilityText(text) {
296
307
  return null;
297
308
  }
298
309
 
299
- /** @param {unknown} value @returns {string|null} */
300
- function resetTimestamp(value) {
310
+ /** @param {unknown} value @param {number} [now] @returns {string|null} */
311
+ function resetTimestamp(value, now = Date.now()) {
301
312
  if (typeof value === "number" && Number.isFinite(value)) return new Date(value).toISOString();
302
313
  if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString();
303
314
  if (typeof value !== "string") return null;
@@ -305,7 +316,43 @@ function resetTimestamp(value) {
305
316
  const input = match?.[1] ?? value;
306
317
  const normalized = input.includes("T") || /(?:Z|[+-]\d{2}:?\d{2})$/u.test(input) ? input : `${input.replace(" ", "T")}Z`;
307
318
  const parsed = Date.parse(normalized);
308
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
319
+ if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
320
+ return wallClockReset(value, now);
321
+ }
322
+
323
+ /**
324
+ * A reset sentence that names a wall-clock time and a zone but no date --
325
+ * the Claude subscription's "resets 6:40pm (America/Sao_Paulo)", measured
326
+ * 2026-09-20 -- means the next occurrence of that time in that zone. The
327
+ * zone's offset is read at `now`; a daylight-saving change between now and
328
+ * the reset moves the answer by an hour, which is accepted and unmeasured.
329
+ */
330
+ const WALL_CLOCK_RESET = /resets?(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(([A-Za-z_]+(?:\/[A-Za-z0-9_+-]+)+|UTC|GMT)\)/iu;
331
+
332
+ /** @param {string} text @param {number} now @returns {string|null} */
333
+ function wallClockReset(text, now) {
334
+ const match = WALL_CLOCK_RESET.exec(text);
335
+ if (!match) return null;
336
+ let hour = Number(match[1]);
337
+ const minute = Number(match[2] ?? "0");
338
+ const meridiem = match[3]?.toLowerCase();
339
+ if (meridiem === "pm" && hour < 12) hour += 12;
340
+ if (meridiem === "am" && hour === 12) hour = 0;
341
+ if (hour > 23 || minute > 59) return null;
342
+ /** @type {Intl.DateTimeFormatPart[]} */
343
+ let parts;
344
+ try {
345
+ parts = new Intl.DateTimeFormat("en-US", { timeZone: match[4], hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }).formatToParts(new Date(now));
346
+ } catch {
347
+ // An unknown zone name is not a reset time; the caller falls back to the failover edge.
348
+ return null;
349
+ }
350
+ const part = (/** @type {Intl.DateTimeFormatPartTypes} */ type) => Number(parts.find((candidate) => candidate.type === type)?.value);
351
+ const zoneWallClock = Date.UTC(part("year"), part("month") - 1, part("day"), part("hour") % 24, part("minute"), part("second"));
352
+ const offset = zoneWallClock - Math.floor(now / 1000) * 1000;
353
+ let target = Date.UTC(part("year"), part("month") - 1, part("day"), hour, minute, 0) - offset;
354
+ if (target <= now) target += 24 * 60 * 60 * 1000;
355
+ return new Date(target).toISOString();
309
356
  }
310
357
 
311
358
  /**
@@ -179,6 +179,20 @@ export function normalizeClaudeResult(stdout, exitCode, signal, options = {}) {
179
179
  allowance,
180
180
  };
181
181
  }
182
+ // A turn the CLI stopped at `--max-turns` is the controller's own cap, not
183
+ // a provider failure: it keeps its own code so the retry policy can tell.
184
+ if (resultEvent.subtype === "error_max_turns") {
185
+ return {
186
+ ...failed(
187
+ "turn_limit",
188
+ "Claude stopped at the attempt's maxTurns (--max-turns)",
189
+ "failed",
190
+ typeof resultEvent.session_id === "string" ? resultEvent.session_id : null,
191
+ canonicalUsage(resultEvent.usage),
192
+ ),
193
+ allowance,
194
+ };
195
+ }
182
196
  if (resultEvent.is_error || exitCode !== 0) {
183
197
  return { ...failed("provider_error", result ?? `Claude exited with code ${exitCode}`), allowance };
184
198
  }
@@ -287,8 +301,14 @@ export function eventItem(event) {
287
301
  return item && typeof item === "object" && !Array.isArray(item) ? /** @type {Record<string, unknown>} */ (item) : null;
288
302
  }
289
303
 
290
- /** Provider-reported quota and rate-limit text: exhaustion, never an ordinary provider failure. */
291
- const QUOTA_TEXT_PATTERN = /429|1310|rate.?limit|usage limit|limit exhausted|quota|too many requests/iu;
304
+ /**
305
+ * Provider-reported quota and rate-limit text: exhaustion, never an ordinary
306
+ * provider failure. "session limit" is the Claude subscription's five-hour
307
+ * window: measured 2026-09-20, "You've hit your session limit · resets 6:40pm
308
+ * (America/Sao_Paulo)" settled as provider_error and two faberun nodes burnt
309
+ * both attempts inside a minute instead of holding until the reset.
310
+ */
311
+ const QUOTA_TEXT_PATTERN = /429|1310|rate.?limit|usage limit|session limit|limit exhausted|quota|too many requests/iu;
292
312
 
293
313
  /**
294
314
  * @param {string|null|undefined} text