@spunto/build 0.11.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/README.md +2 -0
- package/package.json +1 -1
- package/src/agent-stream/agent-stream.ts +65 -2
- package/src/agent-stream/index.ts +5 -0
- package/src/agent-stream/turn-limit.ts +197 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/build",
|
|
3
|
-
"version": "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
|
-
|
|
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
|
|
@@ -122,6 +126,37 @@ export function interruptLine(protocol: AgentProtocol, requestId: string): strin
|
|
|
122
126
|
return `${JSON.stringify({ type: "control_request", request_id: requestId, request: { subtype: "interrupt" } })}\n`
|
|
123
127
|
}
|
|
124
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Ask the harness to **name the conversation**, or null for a dialect that cannot be asked.
|
|
131
|
+
*
|
|
132
|
+
* Claude Code used to do this on its own within seconds of the first turn, headless included —
|
|
133
|
+
* that title is what `sessionTitleProbe` reads. Measured on 2.1.296 (October 2026), it no longer
|
|
134
|
+
* does for a session driven over `--input-format stream-json`: the transcript gets `atis-latch`
|
|
135
|
+
* and `last-prompt` lines and never an `ai-title`, so every task kept its placeholder. What it
|
|
136
|
+
* still answers is the SDK's own `generate_session_title` control request — the call its
|
|
137
|
+
* TypeScript SDK makes — with `{"title":…}` in a `control_response` on stdout, and `persist`
|
|
138
|
+
* writes the same `ai-title` line to the transcript as before.
|
|
139
|
+
*
|
|
140
|
+
* Asking is not naming: the harness still picks the words, from the human's prompt (the
|
|
141
|
+
* `description`), exactly as it would have unprompted. Nothing of ours ends up in the title.
|
|
142
|
+
*
|
|
143
|
+
* The request id carries `TITLE_REQUEST_PREFIX` so the answer can be told apart from every other
|
|
144
|
+
* `control_response` (an interrupt's, say) without keeping state between write and read.
|
|
145
|
+
*/
|
|
146
|
+
export function sessionTitleRequestLine(protocol: AgentProtocol, requestId: string, description: string): string | null {
|
|
147
|
+
if (protocol !== "claude-stream") return null
|
|
148
|
+
const text = description.trim()
|
|
149
|
+
if (!text) return null
|
|
150
|
+
return `${JSON.stringify({
|
|
151
|
+
type: "control_request",
|
|
152
|
+
request_id: `${TITLE_REQUEST_PREFIX}${requestId}`,
|
|
153
|
+
request: { subtype: "generate_session_title", description: text, persist: true },
|
|
154
|
+
})}\n`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Marks the `control_request`s that ask for a title, and so the `control_response`s that answer. */
|
|
158
|
+
const TITLE_REQUEST_PREFIX = "spunto-title-"
|
|
159
|
+
|
|
125
160
|
/**
|
|
126
161
|
* A shell that prints the line where the harness wrote **the name it gave the conversation**, or
|
|
127
162
|
* null for a dialect that has no such thing.
|
|
@@ -143,6 +178,9 @@ export function interruptLine(protocol: AgentProtocol, requestId: string): strin
|
|
|
143
178
|
* `--no-session-persistence` writes no transcript, and then this finds nothing — which is a
|
|
144
179
|
* normal answer, not an error.
|
|
145
180
|
*
|
|
181
|
+
* Since Claude Code 2.1.296 an interactive session only writes that line once asked
|
|
182
|
+
* (`sessionTitleRequestLine`); a one-shot `claude-json` run has no stdin to be asked on.
|
|
183
|
+
*
|
|
146
184
|
* `jsonl` gets no probe on purpose: a harness speaking our vocabulary says the name in band,
|
|
147
185
|
* `{"type":"session.title","title":"…"}`, and pays nothing for it.
|
|
148
186
|
*/
|
|
@@ -341,6 +379,13 @@ function adaptClaudeJson(obj: Record<string, unknown>): NormalizedEvent[] {
|
|
|
341
379
|
}
|
|
342
380
|
}
|
|
343
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
|
+
}
|
|
344
389
|
// What the call that produced this message cost, and how full the context was when it ran.
|
|
345
390
|
// Only assistant messages carry it — a `user` line is tool results being handed back.
|
|
346
391
|
if (type === "assistant") {
|
|
@@ -374,21 +419,39 @@ function adaptClaudeJson(obj: Record<string, unknown>): NormalizedEvent[] {
|
|
|
374
419
|
{
|
|
375
420
|
type: "session.ended",
|
|
376
421
|
payload: {
|
|
377
|
-
|
|
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",
|
|
378
425
|
stopReason: typeof obj.stop_reason === "string" ? obj.stop_reason : null,
|
|
379
426
|
summary: clip(obj.result).text,
|
|
427
|
+
...limitField(obj),
|
|
380
428
|
},
|
|
381
429
|
},
|
|
382
430
|
]
|
|
383
431
|
return events
|
|
384
432
|
}
|
|
385
433
|
|
|
434
|
+
// The answer to `sessionTitleRequestLine`. Only ours, recognised by request id: a
|
|
435
|
+
// `control_response` is also how an interrupt is acknowledged, and that one is no name.
|
|
436
|
+
if (type === "control_response") {
|
|
437
|
+
const response = (obj.response ?? {}) as Record<string, unknown>
|
|
438
|
+
const inner = (response.response ?? {}) as Record<string, unknown>
|
|
439
|
+
const requestId = typeof response.request_id === "string" ? response.request_id : ""
|
|
440
|
+
const title = response.subtype === "success" && requestId.startsWith(TITLE_REQUEST_PREFIX) ? cleanTitle(inner.title) : null
|
|
441
|
+
if (title) return [{ type: "session.title", payload: { title } }]
|
|
442
|
+
}
|
|
443
|
+
|
|
386
444
|
// Known-but-uninteresting (`rate_limit_event`, `system/thinking_tokens`) and anything the CLI
|
|
387
445
|
// grows next land here rather than being dropped: an unknown event is information about the
|
|
388
446
|
// harness, and the UI keeps it behind a toggle.
|
|
389
447
|
return [{ type: "raw", payload: { json: obj } }]
|
|
390
448
|
}
|
|
391
449
|
|
|
450
|
+
function limitField(obj: Record<string, unknown>): { limit?: ReturnType<typeof claudeTurnLimit> } {
|
|
451
|
+
const limit = claudeTurnLimit(obj)
|
|
452
|
+
return limit ? { limit } : {}
|
|
453
|
+
}
|
|
454
|
+
|
|
392
455
|
function num(value: unknown): number | null {
|
|
393
456
|
return typeof value === "number" && Number.isFinite(value) ? value : null
|
|
394
457
|
}
|
|
@@ -25,11 +25,16 @@ export {
|
|
|
25
25
|
parseStreamLine,
|
|
26
26
|
readsTitleOutOfBand,
|
|
27
27
|
sessionTitleProbe,
|
|
28
|
+
sessionTitleRequestLine,
|
|
28
29
|
splitCompleteLines,
|
|
29
30
|
summarizeUsage,
|
|
30
31
|
TASK_EVENT_TYPES,
|
|
31
32
|
userTurnLine,
|
|
32
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
|
+
|
|
33
38
|
export type {
|
|
34
39
|
AgentProtocol,
|
|
35
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
|
+
}
|