@bridge4dev/runner 0.42.0 → 0.44.1

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,178 @@
1
+ /**
2
+ * What a failed turn is allowed to do next — as DATA, not as branching (#252, #257).
3
+ *
4
+ * It lives beside `rate-limits.ts` and for the same reason: two CLIs describe the
5
+ * same class of accident in two vocabularies, a third provider is expected, and
6
+ * one table is easier to audit than two adapters' worth of `if`.
7
+ *
8
+ * ## How to read the table
9
+ *
10
+ * Every cause either CLI is known to report has its own row, including the ones
11
+ * that are never retried. Owner's instruction, 2026-08-16: the config should list
12
+ * them all with an explicit `retry: true | false`, so the answer to «why did the
13
+ * session not pick itself back up» is one line in one file rather than an absence.
14
+ *
15
+ * The dividing line is **whose fault it is**. Automatic retry is for failures that
16
+ * belong to the provider's own servers and have nothing to do with what the user
17
+ * asked for — a busy cluster, a 500, a dropped connection. Everything that depends
18
+ * on the account or the request — a dead key, an empty wallet, a refused policy, a
19
+ * context that no longer fits — is `retry: false` and stops the session, because
20
+ * asking again produces the same answer while burning tokens and, for policy and
21
+ * billing, sending a bad signal toward the account.
22
+ *
23
+ * ## The unit is the TURN, not the request
24
+ *
25
+ * The tempting table — «529 and 5xx are retryable» — is written for a single HTTP
26
+ * call. Ours is a turn: one turn is many calls, and by the time a 529 surfaces the
27
+ * agent may have run six rounds of tools, with a `git commit` and a `git push`
28
+ * already on disk. So a row here is a PERMISSION, never an instruction: the caller
29
+ * still has to ask «did this turn already do something» and narrow it. `refine()`
30
+ * below is that step, and it is not optional.
31
+ *
32
+ * ## Why codes and not sentences
33
+ *
34
+ * Both CLIs hand us a machine-readable cause and the runner used to throw it away.
35
+ * Sentences are not matched at all — see the note on `match`. We already have a
36
+ * scar from matching prose: `isAuthError` had to be strengthened because a bare
37
+ * `401` matched an MCP server's error and «unauthorized» contains «auth».
38
+ *
39
+ * ## Unknown means STOP
40
+ *
41
+ * A code that matches no row behaves exactly as it does today: the session stops
42
+ * and calls a human. The costs are wildly asymmetric — a wrong STOP costs one
43
+ * click, a wrong RETRY costs a second `git push`, a duplicated MCP write, tokens
44
+ * paid twice, and a transcript with duplicate tool_use ids (which is itself a 400
45
+ * that no retry can clear). It also makes an endless retry loop impossible by
46
+ * construction rather than by a counter someone might raise later.
47
+ */
48
+ /** Verified against Claude Code CLI 2.1.233 and codex-cli 0.147.0 on 2026-08-16. */
49
+ export type RetryBucket =
50
+ /** Nothing ran. The same turn may be sent again. */
51
+ 'retry'
52
+ /** Work was partly done. Resume it; never re-send the prompt. */
53
+ | 'continue'
54
+ /** A repeat cannot help. Stop and tell the person. */
55
+ | 'stop';
56
+ export type BackoffProfile = 'standard' | 'slow';
57
+ export interface ErrorRule {
58
+ /** Stable id — also the key the «same failure twice» guard compares. */
59
+ id: string;
60
+ provider: 'claude' | 'codex';
61
+ match: {
62
+ /** The provider's own machine-readable cause. */
63
+ codeIn?: readonly string[];
64
+ /** HTTP status, when the provider reported one. */
65
+ statusFrom?: number;
66
+ statusTo?: number;
67
+ };
68
+ /**
69
+ * The switch: may this cause EVER be retried automatically?
70
+ *
71
+ * `false` means today's behaviour — the session stops and asks for a person.
72
+ * Turning one on is a deliberate act with a reason written in `note`.
73
+ */
74
+ retry: boolean;
75
+ /** Read only when `retry` is true. */
76
+ bucket?: Exclude<RetryBucket, 'stop'>;
77
+ attempts?: number;
78
+ backoff?: BackoffProfile;
79
+ /**
80
+ * `false` → the row is INERT: counted, never acted on.
81
+ *
82
+ * How a provider we have not run yet gets into the table safely. Gemini and the
83
+ * Chinese providers are expected next, and several of them answer with HTTP 200
84
+ * and put the failure in the body, so their rows must not go live on a guess.
85
+ */
86
+ verified: boolean;
87
+ note: string;
88
+ }
89
+ /**
90
+ * The one place a provider is added, and the one place a retry is switched on.
91
+ *
92
+ * First match wins, so named causes precede status ranges.
93
+ */
94
+ export declare const ERROR_RULES: readonly ErrorRule[];
95
+ export interface FailureSignal {
96
+ provider: 'claude' | 'codex';
97
+ /**
98
+ * The provider's machine-readable cause — `assistant.error` for Claude,
99
+ * `codexErrorInfo` for Codex.
100
+ *
101
+ * **A decision can only start here or at `status`.** If both are absent the
102
+ * answer is `stop`, no matter what the text says.
103
+ */
104
+ code?: string | null;
105
+ /** HTTP status, when the provider reported one. */
106
+ status?: number | null;
107
+ /**
108
+ * The CLI's own sentence about the failure.
109
+ *
110
+ * Read in ONE direction: it can turn a `retry` into a `continue`, and it can
111
+ * never turn anything into a `retry`.
112
+ */
113
+ text?: string | null;
114
+ /** Did the turn emit anything at all before it broke — text, thinking, a tool call? */
115
+ produced?: boolean;
116
+ /** Did the turn run something a repeat cannot take back — git, or a write through MCP? */
117
+ irreversible?: boolean;
118
+ }
119
+ export interface RetryDecision {
120
+ bucket: RetryBucket;
121
+ attempts: number;
122
+ backoff: BackoffProfile;
123
+ /** Which row decided, or `null` when nothing matched and the default applied. */
124
+ ruleId: string | null;
125
+ }
126
+ /**
127
+ * What may this failure do next?
128
+ *
129
+ * First match wins. A row that is inert (`verified: false`) or switched off
130
+ * (`retry: false`) resolves to `stop` — but still reports its id, so the feed and
131
+ * the logs can say WHICH known cause stopped the session rather than «unknown».
132
+ */
133
+ export declare function classifyFailure(signal: FailureSignal): RetryDecision;
134
+ /**
135
+ * The ceiling no row can raise: total automatic retries in one session's life.
136
+ *
137
+ * Every other guard depends on the table being right. This one does not — it is
138
+ * arithmetic. If a code were misclassified, if a provider started answering with
139
+ * a cause we read wrongly, if two mechanisms both decided to retry, the session
140
+ * still cannot spin: after this many attempts it stops and asks for a person,
141
+ * exactly as it does today.
142
+ *
143
+ * Reset when a HUMAN sends a message, on the same reasoning as `clearAutoResume`:
144
+ * somebody typing into the session is the clearest possible evidence that the
145
+ * work is on track again.
146
+ */
147
+ export declare const MAX_RETRIES_PER_SESSION = 12;
148
+ /**
149
+ * Has this exact failure already been retried, and come straight back?
150
+ *
151
+ * Cheap insurance against a deterministic failure wearing a transient face — a
152
+ * context overflow surfacing as a 500, a malformed transcript that will fail
153
+ * identically forever. One repeat of the same row is enough to conclude the
154
+ * repeat is not helping, and spending the remaining attempts proves nothing.
155
+ */
156
+ export declare function isRepeatOfSameFailure(previousRuleId: string | null | undefined, decision: RetryDecision): boolean;
157
+ /**
158
+ * How long to wait before attempt `attempt` (1-based).
159
+ *
160
+ * standard: max(10s, U(0, min(300s, 30s · 2^(n-1))))
161
+ * slow: max(30s, U(0, min(600s, 60s · 2^(n-1))))
162
+ *
163
+ * Full jitter, with a floor. The floor matters: pure full jitter can collapse to
164
+ * nearly zero, and a zero-length wait before restarting a process is pointless —
165
+ * the process takes longer than that to come up.
166
+ *
167
+ * The base is tens of seconds rather than one, because our attempt is not an HTTP
168
+ * request: it is a process re-reading a conversation, costing seconds and input
169
+ * tokens. By the time we see the failure at all, the CLI has already run its own
170
+ * retry ladder — `api_retry` reports up to ten of them — so answering that with
171
+ * another try one second later would just be noise.
172
+ *
173
+ * Jitter is not decoration: one machine runs several sessions and there are many
174
+ * machines, all watching the same API. Without it a recovering provider gets a
175
+ * synchronised volley from the whole fleet.
176
+ */
177
+ export declare function retryDelayMs(profile: BackoffProfile, attempt: number, random?: () => number): number;
178
+ //# sourceMappingURL=error-policy.d.ts.map
@@ -0,0 +1,370 @@
1
+ /**
2
+ * What a failed turn is allowed to do next — as DATA, not as branching (#252, #257).
3
+ *
4
+ * It lives beside `rate-limits.ts` and for the same reason: two CLIs describe the
5
+ * same class of accident in two vocabularies, a third provider is expected, and
6
+ * one table is easier to audit than two adapters' worth of `if`.
7
+ *
8
+ * ## How to read the table
9
+ *
10
+ * Every cause either CLI is known to report has its own row, including the ones
11
+ * that are never retried. Owner's instruction, 2026-08-16: the config should list
12
+ * them all with an explicit `retry: true | false`, so the answer to «why did the
13
+ * session not pick itself back up» is one line in one file rather than an absence.
14
+ *
15
+ * The dividing line is **whose fault it is**. Automatic retry is for failures that
16
+ * belong to the provider's own servers and have nothing to do with what the user
17
+ * asked for — a busy cluster, a 500, a dropped connection. Everything that depends
18
+ * on the account or the request — a dead key, an empty wallet, a refused policy, a
19
+ * context that no longer fits — is `retry: false` and stops the session, because
20
+ * asking again produces the same answer while burning tokens and, for policy and
21
+ * billing, sending a bad signal toward the account.
22
+ *
23
+ * ## The unit is the TURN, not the request
24
+ *
25
+ * The tempting table — «529 and 5xx are retryable» — is written for a single HTTP
26
+ * call. Ours is a turn: one turn is many calls, and by the time a 529 surfaces the
27
+ * agent may have run six rounds of tools, with a `git commit` and a `git push`
28
+ * already on disk. So a row here is a PERMISSION, never an instruction: the caller
29
+ * still has to ask «did this turn already do something» and narrow it. `refine()`
30
+ * below is that step, and it is not optional.
31
+ *
32
+ * ## Why codes and not sentences
33
+ *
34
+ * Both CLIs hand us a machine-readable cause and the runner used to throw it away.
35
+ * Sentences are not matched at all — see the note on `match`. We already have a
36
+ * scar from matching prose: `isAuthError` had to be strengthened because a bare
37
+ * `401` matched an MCP server's error and «unauthorized» contains «auth».
38
+ *
39
+ * ## Unknown means STOP
40
+ *
41
+ * A code that matches no row behaves exactly as it does today: the session stops
42
+ * and calls a human. The costs are wildly asymmetric — a wrong STOP costs one
43
+ * click, a wrong RETRY costs a second `git push`, a duplicated MCP write, tokens
44
+ * paid twice, and a transcript with duplicate tool_use ids (which is itself a 400
45
+ * that no retry can clear). It also makes an endless retry loop impossible by
46
+ * construction rather than by a counter someone might raise later.
47
+ */
48
+ /**
49
+ * The one place a provider is added, and the one place a retry is switched on.
50
+ *
51
+ * First match wins, so named causes precede status ranges.
52
+ */
53
+ export const ERROR_RULES = [
54
+ // ══ Claude — `assistant.error` / `api_retry.error`, a closed enum of ten ══
55
+ // ── Their servers. Retried. ──
56
+ {
57
+ id: 'claude.overloaded',
58
+ provider: 'claude',
59
+ match: { codeIn: ['overloaded'] },
60
+ retry: true,
61
+ bucket: 'retry',
62
+ attempts: 5,
63
+ backoff: 'slow',
64
+ verified: true,
65
+ note: '529. Their cluster is busy; nothing to do with this session. The reason #252 exists.',
66
+ },
67
+ {
68
+ id: 'claude.server_error',
69
+ provider: 'claude',
70
+ match: { codeIn: ['server_error'] },
71
+ retry: true,
72
+ bucket: 'retry',
73
+ attempts: 5,
74
+ backoff: 'slow',
75
+ verified: true,
76
+ note: '500/502/503 on their side. Same weather, same treatment.',
77
+ },
78
+ // ── The account or the request. Never retried. ──
79
+ {
80
+ id: 'claude.rate_limit',
81
+ provider: 'claude',
82
+ match: { codeIn: ['rate_limit'] },
83
+ retry: false,
84
+ verified: true,
85
+ note: 'A 429 is not their servers failing — it is this account being asked to slow down, and on ' +
86
+ 'a subscription it usually means the plan window is spent. That case already has a better ' +
87
+ 'answer than a repeat: the session pauses until the window reopens (#258). Retrying into ' +
88
+ 'a limit changes nothing and hammering one is how a longer one is earned.',
89
+ },
90
+ {
91
+ id: 'claude.authentication_failed',
92
+ provider: 'claude',
93
+ match: { codeIn: ['authentication_failed'] },
94
+ retry: false,
95
+ verified: true,
96
+ note: 'The login is gone or rejected. A person has to sign in; a repeat cannot.',
97
+ },
98
+ {
99
+ id: 'claude.oauth_org_not_allowed',
100
+ provider: 'claude',
101
+ match: { codeIn: ['oauth_org_not_allowed'] },
102
+ retry: false,
103
+ verified: true,
104
+ note: 'The organization is not permitted. An administrative answer, not a transient one.',
105
+ },
106
+ {
107
+ id: 'claude.billing_error',
108
+ provider: 'claude',
109
+ match: { codeIn: ['billing_error'] },
110
+ retry: false,
111
+ verified: true,
112
+ note: 'Empty wallet. Repeating a declined charge helps nobody and looks bad on the account.',
113
+ },
114
+ {
115
+ id: 'claude.invalid_request',
116
+ provider: 'claude',
117
+ match: { codeIn: ['invalid_request'] },
118
+ retry: false,
119
+ verified: true,
120
+ note: 'The API refused the request itself. Deterministic — it will refuse the identical request ' +
121
+ 'identically. Often a malformed transcript, which a repeat can only make worse.',
122
+ },
123
+ {
124
+ id: 'claude.model_not_found',
125
+ provider: 'claude',
126
+ match: { codeIn: ['model_not_found'] },
127
+ retry: false,
128
+ verified: true,
129
+ note: 'The chosen model does not exist for this account. A settings problem.',
130
+ },
131
+ {
132
+ id: 'claude.max_output_tokens',
133
+ provider: 'claude',
134
+ match: { codeIn: ['max_output_tokens'] },
135
+ retry: false,
136
+ verified: true,
137
+ note: 'The answer hit its own ceiling. Nothing failed on their side, and the same turn would hit ' +
138
+ 'the same ceiling again.',
139
+ },
140
+ {
141
+ id: 'claude.unknown',
142
+ provider: 'claude',
143
+ match: { codeIn: ['unknown'] },
144
+ retry: false,
145
+ verified: true,
146
+ note: 'The CLI could not name the cause either. Listed rather than left to the default so that ' +
147
+ 'reading the table answers the question, and so nobody later mistakes silence for an ' +
148
+ 'oversight and «fixes» it into a retry.',
149
+ },
150
+ // ══ Codex — `turn.error.codexErrorInfo`, plus the HTTP status when given ══
151
+ // ── Their servers. Retried. ──
152
+ {
153
+ id: 'codex.http_connection',
154
+ provider: 'codex',
155
+ match: { codeIn: ['httpConnectionFailed'] },
156
+ retry: true,
157
+ bucket: 'retry',
158
+ attempts: 3,
159
+ backoff: 'standard',
160
+ verified: true,
161
+ note: 'The request never landed. Narrowed to «resume» by the guard if the turn had begun.',
162
+ },
163
+ {
164
+ id: 'codex.stream_connection',
165
+ provider: 'codex',
166
+ match: { codeIn: ['responseStreamConnectionFailed'] },
167
+ retry: true,
168
+ bucket: 'continue',
169
+ attempts: 1,
170
+ backoff: 'standard',
171
+ verified: true,
172
+ note: 'The stream died after it had started — the same shape as Claude’s «mid-response». Resumed, ' +
173
+ 'never re-sent: work may already be on disk.',
174
+ },
175
+ {
176
+ id: 'codex.http_5xx',
177
+ provider: 'codex',
178
+ match: { statusFrom: 500, statusTo: 599 },
179
+ retry: true,
180
+ bucket: 'retry',
181
+ attempts: 5,
182
+ backoff: 'slow',
183
+ verified: true,
184
+ note: 'Their side. Matched after the named causes so a code always wins over a number.',
185
+ },
186
+ // ── The account, the request, or the content. Never retried. ──
187
+ {
188
+ id: 'codex.usage_limit',
189
+ provider: 'codex',
190
+ match: { codeIn: ['usageLimitExceeded'] },
191
+ retry: false,
192
+ verified: true,
193
+ note: 'Out of plan. That is a pause until the window reopens (#258), not a repeat.',
194
+ },
195
+ {
196
+ id: 'codex.session_budget',
197
+ provider: 'codex',
198
+ match: { codeIn: ['sessionBudgetExceeded'] },
199
+ retry: false,
200
+ verified: true,
201
+ note: 'The session spent the budget it was given. Raising it is a human decision.',
202
+ },
203
+ {
204
+ id: 'codex.context_window',
205
+ provider: 'codex',
206
+ match: { codeIn: ['contextWindowExceeded'] },
207
+ retry: false,
208
+ verified: true,
209
+ note: 'The conversation no longer fits. Deterministic: the identical turn overflows identically. ' +
210
+ 'The remedy is a compact, and choosing to compact is not ours to make silently.',
211
+ },
212
+ {
213
+ id: 'codex.policy',
214
+ provider: 'codex',
215
+ match: { codeIn: ['cyberPolicy'] },
216
+ retry: false,
217
+ verified: true,
218
+ note: 'Refused on content policy — about what was asked, not about their weather. Re-sending a ' +
219
+ 'refused request is the one repeat that actively harms the account.',
220
+ },
221
+ {
222
+ id: 'codex.http_4xx',
223
+ provider: 'codex',
224
+ match: { statusFrom: 400, statusTo: 499 },
225
+ retry: false,
226
+ verified: true,
227
+ note: 'Our request, not their servers. 429 included: Codex does not honour Retry-After, so a ' +
228
+ 'repeat is pure noise.',
229
+ },
230
+ ];
231
+ const DEFAULT_DECISION = {
232
+ bucket: 'stop',
233
+ attempts: 0,
234
+ backoff: 'standard',
235
+ ruleId: null,
236
+ };
237
+ function matches(rule, signal) {
238
+ if (rule.provider !== signal.provider)
239
+ return false;
240
+ const { codeIn, statusFrom, statusTo } = rule.match;
241
+ if (codeIn) {
242
+ return signal.code !== null && signal.code !== undefined && codeIn.includes(signal.code);
243
+ }
244
+ if (statusFrom !== undefined && statusTo !== undefined) {
245
+ return (signal.status !== null &&
246
+ signal.status !== undefined &&
247
+ signal.status >= statusFrom &&
248
+ signal.status <= statusTo);
249
+ }
250
+ return false;
251
+ }
252
+ /** Phrases with which the CLI says «the answer above is partial». */
253
+ const PARTIAL_PHRASES = ['may be incomplete', 'mid-response'];
254
+ /**
255
+ * Tighten a permission to fit what the turn actually did.
256
+ *
257
+ * Strictly one-way: every branch either keeps the bucket or makes it more
258
+ * cautious, and nothing here can produce a `retry`. Being too careful costs the
259
+ * agent a look at its own work; being too bold costs a second `git push`.
260
+ * `auto-resume.ts` records what the bold version felt like — a re-sent prompt
261
+ * ran a fifteen-minute command twice, four minutes apart.
262
+ */
263
+ function refine(bucket, signal) {
264
+ // Something irreversible already happened this turn. Neither repeating nor
265
+ // resuming is safe enough to do without a person looking.
266
+ if (signal.irreversible === true)
267
+ return 'stop';
268
+ // Work was produced, so «send it again» is off the table whatever the cause says.
269
+ if (signal.produced === true && bucket === 'retry')
270
+ return 'continue';
271
+ // The CLI said the answer above is partial. It is allowed to make us more
272
+ // careful even when we counted no output ourselves — the phase it reports is
273
+ // its own, and it knows things our counting does not.
274
+ if (bucket === 'retry') {
275
+ const text = signal.text ?? '';
276
+ if (PARTIAL_PHRASES.some((phrase) => text.includes(phrase)))
277
+ return 'continue';
278
+ }
279
+ return bucket;
280
+ }
281
+ /**
282
+ * What may this failure do next?
283
+ *
284
+ * First match wins. A row that is inert (`verified: false`) or switched off
285
+ * (`retry: false`) resolves to `stop` — but still reports its id, so the feed and
286
+ * the logs can say WHICH known cause stopped the session rather than «unknown».
287
+ */
288
+ export function classifyFailure(signal) {
289
+ // No machine-readable cause, no retry. This is the single most important line
290
+ // in the file: it is what makes it impossible for a sentence — an agent's own
291
+ // prose, a quoted error in a report, a document about this feature — to start
292
+ // a retry. Text only ever narrows a decision that a code already opened.
293
+ const hasCode = signal.code !== null && signal.code !== undefined && signal.code !== '';
294
+ const hasStatus = signal.status !== null && signal.status !== undefined;
295
+ if (!hasCode && !hasStatus)
296
+ return DEFAULT_DECISION;
297
+ for (const rule of ERROR_RULES) {
298
+ if (!matches(rule, signal))
299
+ continue;
300
+ if (!rule.verified || !rule.retry) {
301
+ return { ...DEFAULT_DECISION, ruleId: rule.id };
302
+ }
303
+ const bucket = refine(rule.bucket ?? 'retry', signal);
304
+ return {
305
+ bucket,
306
+ // A narrowed permission also loses the generous allowance: resuming replays
307
+ // context and can still double an effect if the agent misjudges what it did.
308
+ attempts: bucket === 'stop' ? 0 : bucket === 'continue' ? 1 : Math.max(0, rule.attempts ?? 0),
309
+ backoff: rule.backoff ?? 'standard',
310
+ ruleId: rule.id,
311
+ };
312
+ }
313
+ return DEFAULT_DECISION;
314
+ }
315
+ /**
316
+ * The ceiling no row can raise: total automatic retries in one session's life.
317
+ *
318
+ * Every other guard depends on the table being right. This one does not — it is
319
+ * arithmetic. If a code were misclassified, if a provider started answering with
320
+ * a cause we read wrongly, if two mechanisms both decided to retry, the session
321
+ * still cannot spin: after this many attempts it stops and asks for a person,
322
+ * exactly as it does today.
323
+ *
324
+ * Reset when a HUMAN sends a message, on the same reasoning as `clearAutoResume`:
325
+ * somebody typing into the session is the clearest possible evidence that the
326
+ * work is on track again.
327
+ */
328
+ export const MAX_RETRIES_PER_SESSION = 12;
329
+ /**
330
+ * Has this exact failure already been retried, and come straight back?
331
+ *
332
+ * Cheap insurance against a deterministic failure wearing a transient face — a
333
+ * context overflow surfacing as a 500, a malformed transcript that will fail
334
+ * identically forever. One repeat of the same row is enough to conclude the
335
+ * repeat is not helping, and spending the remaining attempts proves nothing.
336
+ */
337
+ export function isRepeatOfSameFailure(previousRuleId, decision) {
338
+ return (previousRuleId !== null &&
339
+ previousRuleId !== undefined &&
340
+ decision.ruleId !== null &&
341
+ previousRuleId === decision.ruleId);
342
+ }
343
+ /**
344
+ * How long to wait before attempt `attempt` (1-based).
345
+ *
346
+ * standard: max(10s, U(0, min(300s, 30s · 2^(n-1))))
347
+ * slow: max(30s, U(0, min(600s, 60s · 2^(n-1))))
348
+ *
349
+ * Full jitter, with a floor. The floor matters: pure full jitter can collapse to
350
+ * nearly zero, and a zero-length wait before restarting a process is pointless —
351
+ * the process takes longer than that to come up.
352
+ *
353
+ * The base is tens of seconds rather than one, because our attempt is not an HTTP
354
+ * request: it is a process re-reading a conversation, costing seconds and input
355
+ * tokens. By the time we see the failure at all, the CLI has already run its own
356
+ * retry ladder — `api_retry` reports up to ten of them — so answering that with
357
+ * another try one second later would just be noise.
358
+ *
359
+ * Jitter is not decoration: one machine runs several sessions and there are many
360
+ * machines, all watching the same API. Without it a recovering provider gets a
361
+ * synchronised volley from the whole fleet.
362
+ */
363
+ export function retryDelayMs(profile, attempt, random = Math.random) {
364
+ const floor = profile === 'slow' ? 30_000 : 10_000;
365
+ const base = profile === 'slow' ? 60_000 : 30_000;
366
+ const cap = profile === 'slow' ? 600_000 : 300_000;
367
+ const ceiling = Math.min(cap, base * 2 ** Math.max(0, attempt - 1));
368
+ return Math.max(floor, Math.round(random() * ceiling));
369
+ }
370
+ //# sourceMappingURL=error-policy.js.map
@@ -37,4 +37,26 @@ export declare function rateWindowKeyFromMinutes(minutes: number | null): AgentR
37
37
  * reads as «nothing spent» and is the safe end: it never invents a warning.
38
38
  */
39
39
  export declare function clampPercent(value: number): number;
40
+ /**
41
+ * Claude's `utilization` is a FRACTION (0–1), not a percentage.
42
+ *
43
+ * This cost the owner a wrong number on screen: a weekly window truly at 95%
44
+ * flipped to «1%» for a minute or two whenever an event carried the field, and
45
+ * the next `/usage` probe put 95 back. `clampPercent(0.95)` is 0.95, and the
46
+ * panel rounds that to 1.
47
+ *
48
+ * The scale is not documented — `utilization?: number` is all the SDK types
49
+ * say — so it was read out of the shipped CLI binary (2.1.233), where the
50
+ * warning thresholds are:
51
+ *
52
+ * five_hour: [{utilization: 0.9}]
53
+ * seven_day: [{utilization: 0.75}, {utilization: 0.5}, {utilization: 0.25}]
54
+ *
55
+ * plus a bare `utilization < 0.7`. A field measured in percent would not be
56
+ * compared against 0.25 to mean «a quarter spent».
57
+ *
58
+ * `/usage` is the other unit and needs no conversion: its text prints `52% used`
59
+ * and `parseUsageText` already yields 52. One place converts, and it is this one.
60
+ */
61
+ export declare function percentFromUtilization(utilization: number): number;
40
62
  //# sourceMappingURL=rate-limits.d.ts.map
@@ -56,4 +56,28 @@ export function clampPercent(value) {
56
56
  return 0;
57
57
  return Math.min(100, Math.max(0, value));
58
58
  }
59
+ /**
60
+ * Claude's `utilization` is a FRACTION (0–1), not a percentage.
61
+ *
62
+ * This cost the owner a wrong number on screen: a weekly window truly at 95%
63
+ * flipped to «1%» for a minute or two whenever an event carried the field, and
64
+ * the next `/usage` probe put 95 back. `clampPercent(0.95)` is 0.95, and the
65
+ * panel rounds that to 1.
66
+ *
67
+ * The scale is not documented — `utilization?: number` is all the SDK types
68
+ * say — so it was read out of the shipped CLI binary (2.1.233), where the
69
+ * warning thresholds are:
70
+ *
71
+ * five_hour: [{utilization: 0.9}]
72
+ * seven_day: [{utilization: 0.75}, {utilization: 0.5}, {utilization: 0.25}]
73
+ *
74
+ * plus a bare `utilization < 0.7`. A field measured in percent would not be
75
+ * compared against 0.25 to mean «a quarter spent».
76
+ *
77
+ * `/usage` is the other unit and needs no conversion: its text prints `52% used`
78
+ * and `parseUsageText` already yields 52. One place converts, and it is this one.
79
+ */
80
+ export function percentFromUtilization(utilization) {
81
+ return clampPercent(utilization * 100);
82
+ }
59
83
  //# sourceMappingURL=rate-limits.js.map
@@ -479,6 +479,36 @@ export type AgentEvent = {
479
479
  * for a window to open, which is what «waiting» means.
480
480
  */
481
481
  limitBlocked?: boolean;
482
+ /**
483
+ * Why it failed, in the provider's own machine-readable vocabulary (#252).
484
+ *
485
+ * Carried because the cause and the ending arrive on DIFFERENT messages:
486
+ * Claude puts the closed enum on the assistant message (`assistant.error`)
487
+ * and closes the turn with a separate `result`, which has no such field.
488
+ * The adapter remembers the last one it saw for the turn and hands it over
489
+ * here, so the supervisor can decide without re-reading the transcript or
490
+ * guessing from prose.
491
+ *
492
+ * Absent means «no machine-readable cause», which `classifyFailure` reads
493
+ * as «stop» — today's behaviour. Never inferred from text.
494
+ */
495
+ failureCode?: string;
496
+ /** HTTP status, when the provider named one alongside the cause. */
497
+ failureStatus?: number;
498
+ /**
499
+ * Did this turn put anything at all on the wire before it broke — a text
500
+ * block, a thought, a tool call?
501
+ *
502
+ * The discriminator #257 turns on. `false` means the turn is safe to send
503
+ * again; `true` means files may already be written and commands already
504
+ * run, so it may only be RESUMED.
505
+ */
506
+ produced?: boolean;
507
+ /**
508
+ * Did this turn run something a repeat cannot take back — git, or a write
509
+ * through MCP? Narrows even «resume» down to «ask a person».
510
+ */
511
+ irreversible?: boolean;
482
512
  } | {
483
513
  type: 'error';
484
514
  message: string;