@spunto/build 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/build",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Spunto's shared Build engine — the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import { contextWindowOf } from "./model-window"
23
+ import { claudeTurnLimit } from "./turn-limit"
23
24
 
24
25
  export const AGENT_PROTOCOLS = ["none", "claude-json", "claude-stream", "jsonl"] as const
25
26
  export type AgentProtocol = (typeof AGENT_PROTOCOLS)[number]
@@ -39,7 +40,10 @@ export const TASK_EVENT_TYPES = [
39
40
  // { scope: "call" | "session", model?, inputTokens?, outputTokens?, cacheReadTokens?,
40
41
  // cacheCreationTokens?, contextTokens?, costUsd?, turns?, durationMs? } — see `summarizeUsage`
41
42
  "usage",
42
- "session.ended", // { status, stopReason?, summary? }
43
+ // { status, stopReason?, summary?, limit? } — `limit` ({ kind, status, message, resetsAt }) when
44
+ // the turn ended because the account hit a usage/rate limit or the API was overloaded, see
45
+ // `turn-limit.ts`
46
+ "session.ended",
43
47
  // { tasks: [{ id, kind, description }] } — what the harness still runs in the background, whole
44
48
  "session.background",
45
49
  "raw", // anything an adapter could not place
@@ -375,6 +379,13 @@ function adaptClaudeJson(obj: Record<string, unknown>): NormalizedEvent[] {
375
379
  }
376
380
  }
377
381
  }
382
+ // The synthetic message Claude Code writes when the API refused the call carries the one
383
+ // structured reset time of a usage limit; the `result` that follows does not. Kept on the
384
+ // message so ingestion can carry it over to the turn's end (`stampLimitReset`).
385
+ if (type === "assistant") {
386
+ const limit = claudeTurnLimit(obj)
387
+ if (limit) for (const e of events) if (e.type === "message") e.payload.limit = limit
388
+ }
378
389
  // What the call that produced this message cost, and how full the context was when it ran.
379
390
  // Only assistant messages carry it — a `user` line is tool results being handed back.
380
391
  if (type === "assistant") {
@@ -408,9 +419,12 @@ function adaptClaudeJson(obj: Record<string, unknown>): NormalizedEvent[] {
408
419
  {
409
420
  type: "session.ended",
410
421
  payload: {
411
- status: obj.subtype === "success" ? "succeeded" : "failed",
422
+ // `is_error` too: a turn the API refused (a usage limit, measured) still says
423
+ // `subtype: "success"` — it is the *harness* that ended cleanly, not the turn.
424
+ status: obj.subtype === "success" && obj.is_error !== true ? "succeeded" : "failed",
412
425
  stopReason: typeof obj.stop_reason === "string" ? obj.stop_reason : null,
413
426
  summary: clip(obj.result).text,
427
+ ...limitField(obj),
414
428
  },
415
429
  },
416
430
  ]
@@ -433,6 +447,11 @@ function adaptClaudeJson(obj: Record<string, unknown>): NormalizedEvent[] {
433
447
  return [{ type: "raw", payload: { json: obj } }]
434
448
  }
435
449
 
450
+ function limitField(obj: Record<string, unknown>): { limit?: ReturnType<typeof claudeTurnLimit> } {
451
+ const limit = claudeTurnLimit(obj)
452
+ return limit ? { limit } : {}
453
+ }
454
+
436
455
  function num(value: unknown): number | null {
437
456
  return typeof value === "number" && Number.isFinite(value) ? value : null
438
457
  }
@@ -31,6 +31,10 @@ export {
31
31
  TASK_EVENT_TYPES,
32
32
  userTurnLine,
33
33
  } from "./agent-stream"
34
+ // Turns that ended on a usage/rate limit rather than on finished work, and when the limit lifts.
35
+ export { claudeTurnLimit, resetFromText, TURN_LIMIT_KINDS } from "./turn-limit"
36
+ export type { TurnLimit, TurnLimitKind } from "./turn-limit"
37
+
34
38
  export type {
35
39
  AgentProtocol,
36
40
  NormalizedEvent,
@@ -0,0 +1,197 @@
1
+ /**
2
+ * A turn that ended because the **account** ran out, not because the work did.
3
+ *
4
+ * Measured on 2026-10-17, when every running task of an org stopped within the same three minutes:
5
+ * Claude Code ends the turn with a `result` that says `subtype: "success"` — and, next to it,
6
+ * `is_error: true`, `api_error_status: 429`, `api_error: "usage_limit_reached"` and, as `result`,
7
+ * the sentence the terminal would have printed (« You've hit your session limit · resets 9pm
8
+ * (UTC) »). Read as a success, that put every task in review as if it had finished, and someone
9
+ * had to type "continue" into each conversation once the quota came back.
10
+ *
11
+ * Three kinds, because they do not wait the same way:
12
+ *
13
+ * - `usage` — the plan's quota (five-hour session limit, weekly limit, out of extra usage). Comes
14
+ * back at a time the harness usually knows (`resetsAt`), and not a minute before;
15
+ * - `rate` — a 429 that is not a quota: a burst limit on an API key. Seconds to minutes;
16
+ * - `overloaded` — the API's 529. The CLI already retried on its own before giving up, so this
17
+ * is a "later", not a "never".
18
+ *
19
+ * Anything else that ends a turn on an API error (a 401, a 500, an invalid request) is **not** a
20
+ * limit: waiting would not fix it, and pretending it might would hide it behind a countdown.
21
+ *
22
+ * Pure functions: the clock is always a parameter.
23
+ */
24
+
25
+ export const TURN_LIMIT_KINDS = ["usage", "rate", "overloaded"] as const
26
+ export type TurnLimitKind = (typeof TURN_LIMIT_KINDS)[number]
27
+
28
+ export type TurnLimit = {
29
+ kind: TurnLimitKind
30
+ /** The HTTP status the harness reported, when it did (429, 529). */
31
+ status: number | null
32
+ /** What the harness said, as it said it — shown to a human, never parsed again. */
33
+ message: string
34
+ /** When the limit lifts, ISO 8601, if the line said so in a form we can trust. */
35
+ resetsAt: string | null
36
+ }
37
+
38
+ const USAGE_TEXT =
39
+ /usage limit|session limit|weekly limit|opus limit|(?:5|five)[- ]hour limit|limit reached|out of extra usage|hit your limit/i
40
+ const RATE_TEXT = /rate[ _-]?limit|too many requests|\b429\b/i
41
+ const OVERLOADED_TEXT = /overloaded|\b529\b/i
42
+
43
+ /**
44
+ * The limit a Claude Code stream line reports, or null.
45
+ *
46
+ * Read off three shapes, all recorded on 2026-10-17 (see `turn-limit.test.ts`):
47
+ *
48
+ * - the `result` that ends the turn — what decides the task, but it carries no reset time;
49
+ * - the synthetic assistant message just before it (`model: "<synthetic>"`,
50
+ * `is_api_error_message: true`), which does: `api_error_params.rate_limit_info.resetsAt`,
51
+ * in unix seconds;
52
+ * - the older single-string form, `Claude AI usage limit reached|<unix seconds>`.
53
+ *
54
+ * A `result` that is not an error is never a limit, whatever its text says: an agent that writes
55
+ * "I hit the rate limit of the GitHub API" in its summary has finished its turn.
56
+ */
57
+ export function claudeTurnLimit(obj: Record<string, unknown>): TurnLimit | null {
58
+ if (obj.type === "result") {
59
+ const failed = obj.is_error === true || (typeof obj.subtype === "string" && obj.subtype !== "success")
60
+ if (!failed) return null
61
+ return classify({
62
+ status: num(obj.api_error_status),
63
+ code: str(obj.api_error),
64
+ text: str(obj.result) ?? "",
65
+ resetsAt: null,
66
+ })
67
+ }
68
+
69
+ if (obj.type === "assistant") {
70
+ const message = (obj.message ?? {}) as Record<string, unknown>
71
+ const synthetic = obj.is_api_error_message === true || message.model === "<synthetic>"
72
+ if (!synthetic) return null
73
+ const params = (obj.api_error_params ?? {}) as Record<string, unknown>
74
+ const info = (params.rate_limit_info ?? {}) as Record<string, unknown>
75
+ const content = Array.isArray(message.content) ? message.content : []
76
+ const text = content
77
+ .map((b) => (b && typeof b === "object" && (b as Record<string, unknown>).type === "text" ? str((b as Record<string, unknown>).text) : null))
78
+ .filter(Boolean)
79
+ .join("\n")
80
+ return classify({
81
+ status: num(obj.api_error_status),
82
+ code: str(obj.api_error) ?? str(obj.error),
83
+ text,
84
+ resetsAt: epochToIso(info.resetsAt),
85
+ })
86
+ }
87
+
88
+ return null
89
+ }
90
+
91
+ function classify(input: { status: number | null; code: string | null; text: string; resetsAt: string | null }): TurnLimit | null {
92
+ const { status, code, text } = input
93
+ // The old one-line form carries its own reset: `Claude AI usage limit reached|1760000000`.
94
+ const piped = /\|(\d{10})\s*$/.exec(text)
95
+ const message = (piped ? text.slice(0, piped.index) : text).trim().slice(0, 500)
96
+ const resetsAt = input.resetsAt ?? (piped ? epochToIso(Number(piped[1])) : null)
97
+
98
+ let kind: TurnLimitKind | null = null
99
+ if (code === "usage_limit_reached" || USAGE_TEXT.test(text)) kind = "usage"
100
+ else if (status === 529 || code === "overloaded_error" || OVERLOADED_TEXT.test(text)) kind = "overloaded"
101
+ else if (status === 429 || code === "rate_limit" || code === "rate_limit_error" || RATE_TEXT.test(text)) kind = "rate"
102
+ if (!kind) return null
103
+ return { kind, status, message, resetsAt }
104
+ }
105
+
106
+ /**
107
+ * When the limit lifts, read out of the sentence the harness printed — « resets 9pm (UTC) »,
108
+ * « resets 9:30pm (Europe/Paris) », « resets Oct 20, 5am (UTC) » — relative to `now` (the moment
109
+ * the line was written, not the moment it is read).
110
+ *
111
+ * The fallback for a turn whose structured reset time we never saw. A time with no date is the
112
+ * next one at or after `now`; a zone we cannot resolve is a null, not a guess — a countdown to the
113
+ * wrong hour is worse than "unknown", which the caller already knows how to wait out.
114
+ */
115
+ export function resetFromText(text: string, now: Date): string | null {
116
+ const m =
117
+ /resets?\s+(?:at\s+)?(?:([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i.exec(text)
118
+ if (!m) return null
119
+ const [, monthName, dayText, hourText, minuteText, meridiem, zoneText] = m
120
+ let hour = Number(hourText)
121
+ const minute = minuteText ? Number(minuteText) : 0
122
+ if (!meridiem && !minuteText) return null // « resets 3 » is not a time
123
+ if (meridiem) {
124
+ if (hour < 1 || hour > 12) return null
125
+ hour = (hour % 12) + (meridiem.toLowerCase() === "pm" ? 12 : 0)
126
+ }
127
+ if (hour > 23 || minute > 59) return null
128
+ const zone = zoneText?.trim() || "UTC"
129
+ if (!isZone(zone)) return null
130
+
131
+ const today = partsIn(now.getTime(), zone)
132
+ if (monthName && dayText) {
133
+ const month = MONTHS.indexOf(monthName.slice(0, 3).toLowerCase())
134
+ if (month < 0) return null
135
+ // Same year, unless that date is already well behind us — then it is next year's.
136
+ let at = zonedToUtc(today.year, month, Number(dayText), hour, minute, zone)
137
+ if (at < now.getTime() - 24 * 3600_000) at = zonedToUtc(today.year + 1, month, Number(dayText), hour, minute, zone)
138
+ return new Date(at).toISOString()
139
+ }
140
+ let at = zonedToUtc(today.year, today.month, today.day, hour, minute, zone)
141
+ if (at <= now.getTime()) at = zonedToUtc(today.year, today.month, today.day + 1, hour, minute, zone)
142
+ return new Date(at).toISOString()
143
+ }
144
+
145
+ const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]
146
+
147
+ function isZone(zone: string): boolean {
148
+ try {
149
+ new Intl.DateTimeFormat("en-US", { timeZone: zone })
150
+ return true
151
+ } catch {
152
+ return false
153
+ }
154
+ }
155
+
156
+ /** Wall-clock fields of an instant in a zone. `month` is 0-based, like `Date.UTC`. */
157
+ function partsIn(t: number, zone: string): { year: number; month: number; day: number; hour: number; minute: number } {
158
+ const parts = new Intl.DateTimeFormat("en-US", {
159
+ timeZone: zone,
160
+ hourCycle: "h23",
161
+ year: "numeric",
162
+ month: "numeric",
163
+ day: "numeric",
164
+ hour: "numeric",
165
+ minute: "numeric",
166
+ }).formatToParts(new Date(t))
167
+ const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? 0)
168
+ return { year: get("year"), month: get("month") - 1, day: get("day"), hour: get("hour"), minute: get("minute") }
169
+ }
170
+
171
+ /** The instant a wall-clock time in `zone` names. Two passes settle a DST edge. */
172
+ function zonedToUtc(year: number, month: number, day: number, hour: number, minute: number, zone: string): number {
173
+ const wanted = Date.UTC(year, month, day, hour, minute)
174
+ let guess = wanted
175
+ for (let i = 0; i < 2; i++) {
176
+ const p = partsIn(guess, zone)
177
+ const seen = Date.UTC(p.year, p.month, p.day, p.hour, p.minute)
178
+ guess += wanted - seen
179
+ }
180
+ return guess
181
+ }
182
+
183
+ function epochToIso(value: unknown): string | null {
184
+ const n = num(value)
185
+ // Seconds since the epoch, sanity-bounded (2020 – 2100): a field that changed unit would
186
+ // otherwise produce a reset in 1970 or in the year 50 000.
187
+ if (n === null || n < 1_577_836_800 || n > 4_102_444_800) return null
188
+ return new Date(n * 1000).toISOString()
189
+ }
190
+
191
+ function num(value: unknown): number | null {
192
+ return typeof value === "number" && Number.isFinite(value) ? value : null
193
+ }
194
+
195
+ function str(value: unknown): string | null {
196
+ return typeof value === "string" && value ? value : null
197
+ }