@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.
- package/dist/adapters/claude-usage.d.ts +35 -4
- package/dist/adapters/claude-usage.js +138 -14
- package/dist/adapters/claude.js +218 -12
- package/dist/adapters/codex.js +46 -0
- package/dist/adapters/error-policy.d.ts +178 -0
- package/dist/adapters/error-policy.js +370 -0
- package/dist/adapters/rate-limits.d.ts +22 -0
- package/dist/adapters/rate-limits.js +24 -0
- package/dist/adapters/types.d.ts +30 -0
- package/dist/claude-settings.d.ts +107 -0
- package/dist/claude-settings.js +415 -0
- package/dist/index.js +88 -1
- package/dist/recipe-schema.d.ts +6 -6
- package/dist/regex-guard-hook.d.ts +3 -0
- package/dist/regex-guard-hook.js +48 -0
- package/dist/regex-guard.d.ts +86 -0
- package/dist/regex-guard.js +359 -0
- package/dist/supervisor.d.ts +16 -0
- package/dist/supervisor.js +151 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -4,7 +4,28 @@ export interface UsageRow {
|
|
|
4
4
|
/** «Weekly · Fable» for a per-model row; absent for the plain ones. */
|
|
5
5
|
label: string | null;
|
|
6
6
|
percent: number;
|
|
7
|
+
/** When this window resets, ISO UTC — or null when the line did not say (#289). */
|
|
8
|
+
resetsAt: string | null;
|
|
7
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* `· resets Aug 19, 4pm (Europe/Berlin)` → `2026-08-19T14:00:00.000Z` (#289).
|
|
12
|
+
*
|
|
13
|
+
* Three things the text does not say and one it says oddly:
|
|
14
|
+
*
|
|
15
|
+
* - **No year.** Taken as the one that lands NEAREST to `now`, which is what
|
|
16
|
+
* carries a weekly window across the December/January seam. Deliberately not
|
|
17
|
+
* «the nearest FUTURE one»: a five-hour window whose reset already passed is
|
|
18
|
+
* a normal thing to read, and preferring the future throws it a year ahead.
|
|
19
|
+
* A moment slightly in the past is honest — the panel says «resetting now»
|
|
20
|
+
* and the next probe corrects it.
|
|
21
|
+
* - **Minutes are optional** — the same output prints `4pm` and `4:20am`.
|
|
22
|
+
* - **The zone is a NAME, not an offset.** It is used as written; assuming the
|
|
23
|
+
* dev server's own zone is the exact bug #258 refused to accept.
|
|
24
|
+
*
|
|
25
|
+
* Forgiving by contract: anything unreadable is `null`, never a throw and never
|
|
26
|
+
* a guess. A percentage we can show beats a clock we invented.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseResetTail(tail: string, now?: Date): string | null;
|
|
8
29
|
/**
|
|
9
30
|
* Every percentage `/usage` printed, in the order it printed them.
|
|
10
31
|
*
|
|
@@ -14,13 +35,23 @@ export interface UsageRow {
|
|
|
14
35
|
*
|
|
15
36
|
* Deliberately forgiving: a line we cannot read is skipped, never thrown over.
|
|
16
37
|
*/
|
|
17
|
-
export declare function parseUsageText(text: string): UsageRow[];
|
|
38
|
+
export declare function parseUsageText(text: string, now?: Date): UsageRow[];
|
|
18
39
|
/**
|
|
19
40
|
* Merge fresh percentages into the windows we already know about.
|
|
20
41
|
*
|
|
21
|
-
* The RESET TIME
|
|
22
|
-
*
|
|
23
|
-
*
|
|
42
|
+
* The RESET TIME parsed here never OVERRIDES what the event said — the event is
|
|
43
|
+
* machine-readable and carries epoch seconds, the text carries whole minutes, so
|
|
44
|
+
* where both exist the event is simply the better number. It only FILLS a window
|
|
45
|
+
* the event has not dated, which since #289 is the ordinary case: the event
|
|
46
|
+
* describes one window per message (whichever is closest to its ceiling), so the
|
|
47
|
+
* other window would otherwise never get a clock at any percentage.
|
|
48
|
+
*
|
|
49
|
+
* This does not put a text-parsed time anywhere near the auto-pause alarm, and
|
|
50
|
+
* that is a property of the code, not a convention: the wake-up is scheduled
|
|
51
|
+
* exclusively from `blocked.resetsAt`, which `claude.ts` builds from the event's
|
|
52
|
+
* own epoch seconds and never reads back out of this map. #258's rule — a
|
|
53
|
+
* reworded CLI must never be able to move a wake-up — therefore still holds.
|
|
54
|
+
* `claude-usage.test.ts` pins it.
|
|
24
55
|
*/
|
|
25
56
|
export declare function applyUsagePercentages(windows: AgentRateLimitWindow[], rows: UsageRow[]): AgentRateLimitWindow[];
|
|
26
57
|
/**
|
|
@@ -25,15 +25,116 @@ import { log } from '../log.js';
|
|
|
25
25
|
* Current week (all models): 50% used · resets Aug 19, 3:59pm (Europe/Berlin)
|
|
26
26
|
* Current week (Fable): 0% used
|
|
27
27
|
*/
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
/**
|
|
29
|
+
* «Current session» is the five-hour window; every «Current week» is a weekly one.
|
|
30
|
+
*
|
|
31
|
+
* Each pattern now also captures the REST of the line, because the reset time
|
|
32
|
+
* lives in its tail (#289) — see `parseResetTail`. `[^\n]*` and not `.*`: the
|
|
33
|
+
* text is multi-line and a greedy dot would swallow the following rows.
|
|
34
|
+
*/
|
|
35
|
+
const SESSION_LINE = /current session:\s*(\d+(?:\.\d+)?)%\s*used([^\n]*)/i;
|
|
36
|
+
const WEEK_LINE = /current week(?:\s*\(([^)]+)\))?:\s*(\d+(?:\.\d+)?)%\s*used([^\n]*)/gi;
|
|
37
|
+
/** `· resets Aug 19, 4pm (Europe/Berlin)` — minutes optional, year absent, zone named. */
|
|
38
|
+
const RESET_TAIL = /resets\s+([A-Za-z]{3})\s+(\d{1,2}),\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i;
|
|
39
|
+
const MONTHS = {
|
|
40
|
+
jan: 0,
|
|
41
|
+
feb: 1,
|
|
42
|
+
mar: 2,
|
|
43
|
+
apr: 3,
|
|
44
|
+
may: 4,
|
|
45
|
+
jun: 5,
|
|
46
|
+
jul: 6,
|
|
47
|
+
aug: 7,
|
|
48
|
+
sep: 8,
|
|
49
|
+
oct: 9,
|
|
50
|
+
nov: 10,
|
|
51
|
+
dec: 11,
|
|
52
|
+
};
|
|
31
53
|
const clamp = (raw) => {
|
|
32
54
|
if (raw === undefined)
|
|
33
55
|
return null;
|
|
34
56
|
const value = Number(raw);
|
|
35
57
|
return Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : null;
|
|
36
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* A wall-clock moment in a NAMED zone → the instant it denotes.
|
|
61
|
+
*
|
|
62
|
+
* `Intl` can only go the other way, so this goes there and back: format the
|
|
63
|
+
* guess in the target zone, see how far the result drifted, and subtract that
|
|
64
|
+
* drift. One round-trip is enough because the offset we need is the one in
|
|
65
|
+
* force AT that moment, which is exactly what the formatter applied.
|
|
66
|
+
*
|
|
67
|
+
* Throws `RangeError` on a zone name it does not know — the caller turns that
|
|
68
|
+
* into «no clock», never into the runner's own zone (#258's «three different
|
|
69
|
+
* clocks» is the mistake this file must not repeat).
|
|
70
|
+
*/
|
|
71
|
+
function zonedToUtc(zone, year, month, day, hour, minute) {
|
|
72
|
+
const guess = Date.UTC(year, month, day, hour, minute);
|
|
73
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
74
|
+
timeZone: zone,
|
|
75
|
+
hour12: false,
|
|
76
|
+
year: 'numeric',
|
|
77
|
+
month: '2-digit',
|
|
78
|
+
day: '2-digit',
|
|
79
|
+
hour: '2-digit',
|
|
80
|
+
minute: '2-digit',
|
|
81
|
+
second: '2-digit',
|
|
82
|
+
}).formatToParts(new Date(guess));
|
|
83
|
+
const at = {};
|
|
84
|
+
for (const part of parts)
|
|
85
|
+
at[part.type] = part.value;
|
|
86
|
+
const asZone = Date.UTC(Number(at['year']), Number(at['month']) - 1, Number(at['day']),
|
|
87
|
+
// `hour12:false` renders midnight as 24 in some ICU versions; 24 % 24 = 0.
|
|
88
|
+
Number(at['hour']) % 24, Number(at['minute']), Number(at['second']));
|
|
89
|
+
return guess - (asZone - guess);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* `· resets Aug 19, 4pm (Europe/Berlin)` → `2026-08-19T14:00:00.000Z` (#289).
|
|
93
|
+
*
|
|
94
|
+
* Three things the text does not say and one it says oddly:
|
|
95
|
+
*
|
|
96
|
+
* - **No year.** Taken as the one that lands NEAREST to `now`, which is what
|
|
97
|
+
* carries a weekly window across the December/January seam. Deliberately not
|
|
98
|
+
* «the nearest FUTURE one»: a five-hour window whose reset already passed is
|
|
99
|
+
* a normal thing to read, and preferring the future throws it a year ahead.
|
|
100
|
+
* A moment slightly in the past is honest — the panel says «resetting now»
|
|
101
|
+
* and the next probe corrects it.
|
|
102
|
+
* - **Minutes are optional** — the same output prints `4pm` and `4:20am`.
|
|
103
|
+
* - **The zone is a NAME, not an offset.** It is used as written; assuming the
|
|
104
|
+
* dev server's own zone is the exact bug #258 refused to accept.
|
|
105
|
+
*
|
|
106
|
+
* Forgiving by contract: anything unreadable is `null`, never a throw and never
|
|
107
|
+
* a guess. A percentage we can show beats a clock we invented.
|
|
108
|
+
*/
|
|
109
|
+
export function parseResetTail(tail, now = new Date()) {
|
|
110
|
+
const match = RESET_TAIL.exec(tail);
|
|
111
|
+
if (!match)
|
|
112
|
+
return null;
|
|
113
|
+
const month = MONTHS[(match[1] ?? '').toLowerCase()];
|
|
114
|
+
if (month === undefined)
|
|
115
|
+
return null;
|
|
116
|
+
const day = Number(match[2]);
|
|
117
|
+
const minute = match[4] ? Number(match[4]) : 0;
|
|
118
|
+
const zone = (match[6] ?? '').trim();
|
|
119
|
+
let hour = Number(match[3]) % 12;
|
|
120
|
+
if ((match[5] ?? '').toLowerCase() === 'pm')
|
|
121
|
+
hour += 12;
|
|
122
|
+
try {
|
|
123
|
+
let best = null;
|
|
124
|
+
const year = now.getUTCFullYear();
|
|
125
|
+
for (const candidate of [year - 1, year, year + 1]) {
|
|
126
|
+
const at = zonedToUtc(zone, candidate, month, day, hour, minute);
|
|
127
|
+
if (best === null || Math.abs(at - now.getTime()) < Math.abs(best - now.getTime()))
|
|
128
|
+
best = at;
|
|
129
|
+
}
|
|
130
|
+
return best === null ? null : new Date(best).toISOString();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Unknown zone name. No clock is the right answer; the runner's own zone
|
|
134
|
+
// would be a wrong one.
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
37
138
|
/**
|
|
38
139
|
* Every percentage `/usage` printed, in the order it printed them.
|
|
39
140
|
*
|
|
@@ -43,11 +144,18 @@ const clamp = (raw) => {
|
|
|
43
144
|
*
|
|
44
145
|
* Deliberately forgiving: a line we cannot read is skipped, never thrown over.
|
|
45
146
|
*/
|
|
46
|
-
export function parseUsageText(text) {
|
|
147
|
+
export function parseUsageText(text, now = new Date()) {
|
|
47
148
|
const rows = [];
|
|
48
|
-
const session =
|
|
49
|
-
|
|
50
|
-
|
|
149
|
+
const session = SESSION_LINE.exec(text);
|
|
150
|
+
const sessionPercent = clamp(session?.[1]);
|
|
151
|
+
if (sessionPercent !== null) {
|
|
152
|
+
rows.push({
|
|
153
|
+
key: 'five_hour',
|
|
154
|
+
label: null,
|
|
155
|
+
percent: sessionPercent,
|
|
156
|
+
resetsAt: parseResetTail(session?.[2] ?? '', now),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
51
159
|
WEEK_LINE.lastIndex = 0;
|
|
52
160
|
for (let match = WEEK_LINE.exec(text); match !== null; match = WEEK_LINE.exec(text)) {
|
|
53
161
|
const percent = clamp(match[2]);
|
|
@@ -56,16 +164,32 @@ export function parseUsageText(text) {
|
|
|
56
164
|
const scope = (match[1] ?? '').trim();
|
|
57
165
|
// «all models» IS the weekly window; anything else is that model's own.
|
|
58
166
|
const isAll = scope === '' || /^all models$/i.test(scope);
|
|
59
|
-
rows.push({
|
|
167
|
+
rows.push({
|
|
168
|
+
key: 'seven_day',
|
|
169
|
+
label: isAll ? null : `Weekly · ${scope}`,
|
|
170
|
+
percent,
|
|
171
|
+
// The per-model rows print no tail at all, so this is legitimately null.
|
|
172
|
+
resetsAt: parseResetTail(match[3] ?? '', now),
|
|
173
|
+
});
|
|
60
174
|
}
|
|
61
175
|
return rows;
|
|
62
176
|
}
|
|
63
177
|
/**
|
|
64
178
|
* Merge fresh percentages into the windows we already know about.
|
|
65
179
|
*
|
|
66
|
-
* The RESET TIME
|
|
67
|
-
*
|
|
68
|
-
*
|
|
180
|
+
* The RESET TIME parsed here never OVERRIDES what the event said — the event is
|
|
181
|
+
* machine-readable and carries epoch seconds, the text carries whole minutes, so
|
|
182
|
+
* where both exist the event is simply the better number. It only FILLS a window
|
|
183
|
+
* the event has not dated, which since #289 is the ordinary case: the event
|
|
184
|
+
* describes one window per message (whichever is closest to its ceiling), so the
|
|
185
|
+
* other window would otherwise never get a clock at any percentage.
|
|
186
|
+
*
|
|
187
|
+
* This does not put a text-parsed time anywhere near the auto-pause alarm, and
|
|
188
|
+
* that is a property of the code, not a convention: the wake-up is scheduled
|
|
189
|
+
* exclusively from `blocked.resetsAt`, which `claude.ts` builds from the event's
|
|
190
|
+
* own epoch seconds and never reads back out of this map. #258's rule — a
|
|
191
|
+
* reworded CLI must never be able to move a wake-up — therefore still holds.
|
|
192
|
+
* `claude-usage.test.ts` pins it.
|
|
69
193
|
*/
|
|
70
194
|
export function applyUsagePercentages(windows, rows) {
|
|
71
195
|
// Keyed by window AND label: a per-model weekly row is its own line, not an
|
|
@@ -79,9 +203,9 @@ export function applyUsagePercentages(windows, rows) {
|
|
|
79
203
|
key: row.key,
|
|
80
204
|
windowMinutes: existing?.windowMinutes ?? (row.key === 'five_hour' ? 300 : 10_080),
|
|
81
205
|
usedPercent: row.percent,
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
resetsAt: existing?.resetsAt ?? null,
|
|
206
|
+
// Event first, text second, nothing third. See the docblock above for why
|
|
207
|
+
// this ordering is the whole safety argument.
|
|
208
|
+
resetsAt: existing?.resetsAt ?? row.resetsAt ?? null,
|
|
85
209
|
...(existing?.status ? { status: existing.status } : {}),
|
|
86
210
|
...(row.label ? { label: row.label } : {}),
|
|
87
211
|
});
|
package/dist/adapters/claude.js
CHANGED
|
@@ -6,7 +6,7 @@ import { log } from '../log.js';
|
|
|
6
6
|
import { mcpConfigPath } from '../paths.js';
|
|
7
7
|
import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
|
|
8
8
|
import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, } from './types.js';
|
|
9
|
-
import {
|
|
9
|
+
import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
|
|
10
10
|
import { applyUsagePercentages, parseUsageText, probeUsageText } from './claude-usage.js';
|
|
11
11
|
import { claudeCliPath } from '../agent-binary.js';
|
|
12
12
|
/** How often `/usage` may be read. Free, but still a process. */
|
|
@@ -301,6 +301,35 @@ class ClaudeSession {
|
|
|
301
301
|
* false, which is exactly the moment the meter needs a fresh number.
|
|
302
302
|
*/
|
|
303
303
|
contextProbedAfterFirstReply = false;
|
|
304
|
+
/**
|
|
305
|
+
* What the CLI last said, in machine-readable form, about why this turn broke.
|
|
306
|
+
*
|
|
307
|
+
* Kept because the cause and the ending travel on different messages: the
|
|
308
|
+
* closed enum rides on `assistant.error`, and the `result` that ends the turn
|
|
309
|
+
* has no such field. Without stashing it, the supervisor would be left with a
|
|
310
|
+
* sentence — and a decision made from a sentence can be made by prose (#252).
|
|
311
|
+
*
|
|
312
|
+
* Reset per turn in `endTaskTurn`, which already owns the turn epoch.
|
|
313
|
+
*/
|
|
314
|
+
turnFailureCode = null;
|
|
315
|
+
turnFailureStatus = null;
|
|
316
|
+
/**
|
|
317
|
+
* Did this turn put ANYTHING on the wire — text, thinking, or a tool call?
|
|
318
|
+
*
|
|
319
|
+
* The one fact that separates «send it again» from «resume it»: a 529 on the
|
|
320
|
+
* seventh request of a turn arrives after six rounds of tools have already
|
|
321
|
+
* run. Counted here rather than inferred from the error's wording, because
|
|
322
|
+
* the wording is prose and this is arithmetic.
|
|
323
|
+
*/
|
|
324
|
+
turnProduced = false;
|
|
325
|
+
/**
|
|
326
|
+
* Did this turn run something a repeat cannot take back?
|
|
327
|
+
*
|
|
328
|
+
* Deliberately coarse: any git subcommand that writes, and any MCP tool call.
|
|
329
|
+
* Over-including costs a stop the person clears with one click; under-
|
|
330
|
+
* including costs a second `git push`.
|
|
331
|
+
*/
|
|
332
|
+
turnIrreversible = false;
|
|
304
333
|
mode;
|
|
305
334
|
/**
|
|
306
335
|
* A launch-time mode this workspace does not allow, remembered so the feed
|
|
@@ -421,6 +450,12 @@ class ClaudeSession {
|
|
|
421
450
|
*/
|
|
422
451
|
resumingTurn() {
|
|
423
452
|
this.aborting = false;
|
|
453
|
+
// #252: the same «every path that starts a turn» property makes this the one
|
|
454
|
+
// correct place to forget what the PREVIOUS turn did. Reset any earlier and
|
|
455
|
+
// `turn_end` would report facts that had already been wiped; any later — say,
|
|
456
|
+
// in `endTaskTurn` — and one turn's «work was done» would veto every
|
|
457
|
+
// subsequent retry for the life of the session.
|
|
458
|
+
this.beginTurnFacts();
|
|
424
459
|
}
|
|
425
460
|
events = this.output;
|
|
426
461
|
constructor(spec, queryFn) {
|
|
@@ -701,6 +736,41 @@ class ClaudeSession {
|
|
|
701
736
|
limitBlockPending = false;
|
|
702
737
|
/** When `/usage` was last read, so a busy session does not spawn a process a second. */
|
|
703
738
|
usageProbedAt = 0;
|
|
739
|
+
/**
|
|
740
|
+
* The one key the window map is written under, from every source.
|
|
741
|
+
*
|
|
742
|
+
* Live 2026-08-15: the event wrote under the provider's raw name
|
|
743
|
+
* (`five_hour`) while the `/usage` merge wrote under `five_hour:` — so the
|
|
744
|
+
* same window arrived twice, once with the clock and once with the number,
|
|
745
|
+
* and the panel drew both rows. One scheme, one row.
|
|
746
|
+
*/
|
|
747
|
+
static windowKey(key, label) {
|
|
748
|
+
return `${key}:${label ?? ''}`;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* May a window named by the provider write the plan-wide row?
|
|
752
|
+
*
|
|
753
|
+
* Only `five_hour` and the bare `seven_day` may. The other four names the CLI
|
|
754
|
+
* uses — `seven_day_opus`, `seven_day_sonnet`, `seven_day_overage_included`,
|
|
755
|
+
* `overage` — describe a SINGLE MODEL's slice of the week, and letting them in
|
|
756
|
+
* silently overwrote the plan-wide number: `rateWindowKey()` folds every
|
|
757
|
+
* `seven_day*` onto `seven_day` (correctly — they really are all seven days),
|
|
758
|
+
* and the map is keyed by that folded name, so «Opus this week» and «the week»
|
|
759
|
+
* were the same cell, last writer winning.
|
|
760
|
+
*
|
|
761
|
+
* They are dropped rather than given rows of their own, and that is deliberate.
|
|
762
|
+
* The event names a FAMILY SLUG (`seven_day_opus`); `/usage` names a MODEL
|
|
763
|
+
* (`Current week (Fable)` → «Weekly · Fable»). Nothing in either stream maps
|
|
764
|
+
* one onto the other, so labelling the event's window would draw a second row
|
|
765
|
+
* for a window that already has one — the exact double-row defect fixed in
|
|
766
|
+
* 0.42.1. The per-model rows come from `/usage`, which names them properly.
|
|
767
|
+
*
|
|
768
|
+
* Dropping them costs nothing else: they still prove a plan exists, and the
|
|
769
|
+
* refusal payload (#258) is built from the event's own fields, not from here.
|
|
770
|
+
*/
|
|
771
|
+
static ownsPlanWindow(providerName) {
|
|
772
|
+
return providerName === 'five_hour' || providerName === 'seven_day';
|
|
773
|
+
}
|
|
704
774
|
/**
|
|
705
775
|
* Read the percentages out of the CLI's own `/usage` (#279).
|
|
706
776
|
*
|
|
@@ -731,7 +801,7 @@ class ClaudeSession {
|
|
|
731
801
|
const merged = applyUsagePercentages([...this.rateLimitWindows.values()], rows);
|
|
732
802
|
this.rateLimitWindows.clear();
|
|
733
803
|
for (const window of merged) {
|
|
734
|
-
this.rateLimitWindows.set(
|
|
804
|
+
this.rateLimitWindows.set(ClaudeSession.windowKey(window.key, window.label), window);
|
|
735
805
|
}
|
|
736
806
|
this.emitRateLimits();
|
|
737
807
|
})
|
|
@@ -779,17 +849,55 @@ class ClaudeSession {
|
|
|
779
849
|
for (const [name, window] of Object.entries(limits)) {
|
|
780
850
|
if (!window || typeof window.utilization !== 'number')
|
|
781
851
|
continue;
|
|
852
|
+
// Per-model weekly windows are not rows of their own here — see
|
|
853
|
+
// `ownsPlanWindow` for why the plan-wide row must not be written by them.
|
|
854
|
+
if (!ClaudeSession.ownsPlanWindow(name))
|
|
855
|
+
continue;
|
|
782
856
|
const key = rateWindowKey(name);
|
|
783
|
-
this.rateLimitWindows.set(
|
|
857
|
+
this.rateLimitWindows.set(ClaudeSession.windowKey(key), {
|
|
784
858
|
key,
|
|
785
859
|
windowMinutes: RATE_WINDOW_MINUTES[key] ?? null,
|
|
786
|
-
usedPercent:
|
|
860
|
+
usedPercent: percentFromUtilization(window.utilization),
|
|
787
861
|
resetsAt: typeof window.resets_at === 'string' ? window.resets_at : null,
|
|
788
862
|
});
|
|
789
863
|
}
|
|
790
864
|
}
|
|
791
865
|
this.emitRateLimits();
|
|
792
866
|
}
|
|
867
|
+
/**
|
|
868
|
+
* The CLI is retrying an API call by itself (#252).
|
|
869
|
+
*
|
|
870
|
+
* Not a failure and not one of our attempts — the turn is still alive. Two
|
|
871
|
+
* things happen here. The cause is stashed, because if the CLI does eventually
|
|
872
|
+
* give up we will need a machine-readable reason and the `result` that ends the
|
|
873
|
+
* turn carries none. And the feed gets told, because «Working…» for four silent
|
|
874
|
+
* minutes while the provider is down is precisely what sent the owner to the
|
|
875
|
+
* terminal to find out what was going on.
|
|
876
|
+
*/
|
|
877
|
+
onApiRetry(msg) {
|
|
878
|
+
const info = msg;
|
|
879
|
+
if (typeof info.error === 'string' && info.error)
|
|
880
|
+
this.turnFailureCode = info.error;
|
|
881
|
+
if (typeof info.error_status === 'number')
|
|
882
|
+
this.turnFailureStatus = info.error_status;
|
|
883
|
+
const attempt = typeof info.attempt === 'number' ? info.attempt : null;
|
|
884
|
+
const max = typeof info.max_retries === 'number' ? info.max_retries : null;
|
|
885
|
+
const delayMs = typeof info.retry_delay_ms === 'number' ? info.retry_delay_ms : null;
|
|
886
|
+
const status = typeof info.error_status === 'number' ? info.error_status : null;
|
|
887
|
+
// Throttled to the first attempt and then every third: a ten-step ladder
|
|
888
|
+
// would otherwise write ten lines into the feed for one hiccup. The first
|
|
889
|
+
// line is the one that answers «why is nothing happening».
|
|
890
|
+
if (attempt !== null && attempt !== 1 && attempt % 3 !== 0)
|
|
891
|
+
return;
|
|
892
|
+
const cause = status !== null ? `${status}` : (this.turnFailureCode ?? 'a network error');
|
|
893
|
+
const count = attempt !== null && max !== null ? ` ${attempt} of ${max}` : '';
|
|
894
|
+
const wait = delayMs !== null ? `, next in ${Math.round(delayMs / 1000)}s` : '';
|
|
895
|
+
this.emit({
|
|
896
|
+
type: 'notice',
|
|
897
|
+
level: 'info',
|
|
898
|
+
text: `The provider returned ${cause} — the CLI is retrying${count}${wait}`,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
793
901
|
onRateLimitEvent(msg) {
|
|
794
902
|
const info = msg.rate_limit_info;
|
|
795
903
|
if (!info || typeof info !== 'object')
|
|
@@ -817,14 +925,27 @@ class ClaudeSession {
|
|
|
817
925
|
// Keep whatever the previous event said about the percentage: the field
|
|
818
926
|
// comes and goes between events about the same window, and dropping it on
|
|
819
927
|
// the next silent one would make a number that was true flicker away.
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
928
|
+
//
|
|
929
|
+
// A per-model week (`seven_day_opus` and friends) is skipped entirely rather
|
|
930
|
+
// than folded onto the plan-wide row — `ownsPlanWindow` explains why. The
|
|
931
|
+
// refusal below is NOT skipped with it: a refusal is a refusal whichever
|
|
932
|
+
// window ran out, and #258's clock is built from this event's own `resetsAt`
|
|
933
|
+
// a line further down, never from the window map.
|
|
934
|
+
if (ClaudeSession.ownsPlanWindow(name)) {
|
|
935
|
+
const known = this.rateLimitWindows.get(ClaudeSession.windowKey(key));
|
|
936
|
+
this.rateLimitWindows.set(ClaudeSession.windowKey(key), {
|
|
937
|
+
key,
|
|
938
|
+
windowMinutes: RATE_WINDOW_MINUTES[key] ?? null,
|
|
939
|
+
// `utilization` is a FRACTION here, not a percentage — see
|
|
940
|
+
// `percentFromUtilization`. Reading it as a percentage showed a window
|
|
941
|
+
// at 95% as «1%» until the next `/usage` probe corrected it.
|
|
942
|
+
usedPercent: typeof utilization === 'number'
|
|
943
|
+
? percentFromUtilization(utilization)
|
|
944
|
+
: (known?.usedPercent ?? null),
|
|
945
|
+
resetsAt: resetsAt ?? known?.resetsAt ?? null,
|
|
946
|
+
status: typeof info['status'] === 'string' ? info['status'] : null,
|
|
947
|
+
});
|
|
948
|
+
}
|
|
828
949
|
if (refused)
|
|
829
950
|
this.limitBlockPending = true;
|
|
830
951
|
this.emitRateLimits(refused ? { key, resetsAt } : null);
|
|
@@ -1537,6 +1658,47 @@ class ClaudeSession {
|
|
|
1537
1658
|
this.taskPublishedAt = 0;
|
|
1538
1659
|
this.flushTasks();
|
|
1539
1660
|
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Forget what the LAST turn did, now that a new one is starting (#252).
|
|
1663
|
+
*
|
|
1664
|
+
* Separate from `endTaskTurn` on purpose: that one runs when a turn ENDS, and
|
|
1665
|
+
* the failure facts have to survive until `turn_end` has been emitted with
|
|
1666
|
+
* them. This runs when the next turn begins, which is the first moment they
|
|
1667
|
+
* are safely stale. Getting this backwards would let one turn's «work was
|
|
1668
|
+
* done» veto the next turn's retry forever.
|
|
1669
|
+
*/
|
|
1670
|
+
beginTurnFacts() {
|
|
1671
|
+
this.turnFailureCode = null;
|
|
1672
|
+
this.turnFailureStatus = null;
|
|
1673
|
+
this.turnProduced = false;
|
|
1674
|
+
this.turnIrreversible = false;
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Remember a tool call as work that happened, and judge whether it can be
|
|
1678
|
+
* taken back.
|
|
1679
|
+
*
|
|
1680
|
+
* The irreversible list is short and blunt: a write to someone else's world.
|
|
1681
|
+
* `git` read subcommands (`status`, `diff`, `log`, `show`) are excluded — the
|
|
1682
|
+
* agent runs them constantly and treating them as irreversible would suppress
|
|
1683
|
+
* nearly every retry.
|
|
1684
|
+
*/
|
|
1685
|
+
noteToolUse(name, input) {
|
|
1686
|
+
this.turnProduced = true;
|
|
1687
|
+
if (this.turnIrreversible)
|
|
1688
|
+
return;
|
|
1689
|
+
// Anything reaching outside this machine through MCP: we cannot know what it
|
|
1690
|
+
// did, so we must not assume it can be repeated.
|
|
1691
|
+
if (name.startsWith('mcp__')) {
|
|
1692
|
+
this.turnIrreversible = true;
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
if (name !== 'Bash')
|
|
1696
|
+
return;
|
|
1697
|
+
const command = typeof input['command'] === 'string' ? input['command'] : '';
|
|
1698
|
+
if (/\bgit\s+(commit|push|tag|merge|rebase|reset|revert|cherry-pick)\b/.test(command)) {
|
|
1699
|
+
this.turnIrreversible = true;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1540
1702
|
async onCanUseTool(toolName, input, opts) {
|
|
1541
1703
|
// The agent's interactive question tool assumes a terminal picker. There is
|
|
1542
1704
|
// none here — so the call is PARKED and the dashboard becomes the picker.
|
|
@@ -1963,6 +2125,23 @@ class ClaudeSession {
|
|
|
1963
2125
|
});
|
|
1964
2126
|
}
|
|
1965
2127
|
}
|
|
2128
|
+
else if (msg.subtype === 'api_retry') {
|
|
2129
|
+
// #252. The CLI hit a retryable error and is ALREADY retrying it
|
|
2130
|
+
// itself — this is telemetry, not a failure. Two jobs here, and the
|
|
2131
|
+
// second matters more than the first:
|
|
2132
|
+
//
|
|
2133
|
+
// - say so in the feed, because «Working…» for four silent minutes
|
|
2134
|
+
// while the provider is down is the thing that made the owner
|
|
2135
|
+
// open the CLI to find out what was happening;
|
|
2136
|
+
// - record the cause and status, so if the CLI eventually gives up
|
|
2137
|
+
// we already hold a machine-readable reason. The `result` that
|
|
2138
|
+
// ends the turn carries no such field.
|
|
2139
|
+
//
|
|
2140
|
+
// Deliberately NOT counted as one of our attempts, and it must not
|
|
2141
|
+
// arm our timer: retrying on top of a retry multiplies the wait and
|
|
2142
|
+
// doubles the traffic aimed at a provider that is already unwell.
|
|
2143
|
+
this.onApiRetry(msg);
|
|
2144
|
+
}
|
|
1966
2145
|
else {
|
|
1967
2146
|
// Ticket #113: subagents, background shells and dynamic workflows.
|
|
1968
2147
|
this.onTaskMessage(msg);
|
|
@@ -2010,14 +2189,31 @@ class ClaudeSession {
|
|
|
2010
2189
|
// field as a subagent would blank the transcript of every session on
|
|
2011
2190
|
// that CLI, which is a far worse failure than one leaked report.
|
|
2012
2191
|
const fromSubagent = typeof msg.parent_tool_use_id === 'string';
|
|
2192
|
+
// #252: the machine-readable cause the runner used to discard. It is
|
|
2193
|
+
// a closed enum (`overloaded`, `server_error`, `billing_error`, …),
|
|
2194
|
+
// and it is the ONLY thing allowed to open the door to a retry —
|
|
2195
|
+
// the sentence beside it is prose and prose can be written by agents.
|
|
2196
|
+
const failure = msg.error;
|
|
2197
|
+
if (typeof failure === 'string' && failure)
|
|
2198
|
+
this.turnFailureCode = failure;
|
|
2013
2199
|
for (const block of msg.message.content) {
|
|
2014
2200
|
if (block.type === 'text' && !fromSubagent && block.text.trim()) {
|
|
2201
|
+
// An API-error message is the CLI TELLING us the turn broke, not
|
|
2202
|
+
// the agent producing work — counting it as output would make
|
|
2203
|
+
// every failure look partial and suppress every clean retry.
|
|
2204
|
+
if (failure === undefined)
|
|
2205
|
+
this.turnProduced = true;
|
|
2015
2206
|
this.emit({ type: 'message', role: 'assistant', text: truncate(block.text) });
|
|
2016
2207
|
}
|
|
2017
2208
|
else if (block.type === 'thinking' && !fromSubagent && block.thinking.trim()) {
|
|
2209
|
+
this.turnProduced = true;
|
|
2018
2210
|
this.emit({ type: 'thinking', text: truncate(block.thinking, 8_000) });
|
|
2019
2211
|
}
|
|
2020
2212
|
else if (block.type === 'tool_use') {
|
|
2213
|
+
// Counted even from a subagent: a subagent's `git push` is still
|
|
2214
|
+
// a push, and this is about what the machine did, not about whose
|
|
2215
|
+
// prose belongs in the feed.
|
|
2216
|
+
this.noteToolUse(block.name, block.input);
|
|
2021
2217
|
this.emit({
|
|
2022
2218
|
type: 'tool',
|
|
2023
2219
|
phase: 'use',
|
|
@@ -2111,6 +2307,16 @@ class ClaudeSession {
|
|
|
2111
2307
|
ok: false,
|
|
2112
2308
|
errorMessage: failure,
|
|
2113
2309
|
...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
|
|
2310
|
+
// #252: the cause and the ending arrive on different messages —
|
|
2311
|
+
// the closed enum rides on the assistant message, this `result`
|
|
2312
|
+
// has no such field. Handed over so the supervisor can decide
|
|
2313
|
+
// from a code rather than from the sentence in `errorMessage`.
|
|
2314
|
+
...(this.turnFailureCode !== null ? { failureCode: this.turnFailureCode } : {}),
|
|
2315
|
+
...(this.turnFailureStatus !== null
|
|
2316
|
+
? { failureStatus: this.turnFailureStatus }
|
|
2317
|
+
: {}),
|
|
2318
|
+
...(this.turnProduced ? { produced: true } : {}),
|
|
2319
|
+
...(this.turnIrreversible ? { irreversible: true } : {}),
|
|
2114
2320
|
});
|
|
2115
2321
|
this.refreshUsage();
|
|
2116
2322
|
}
|
package/dist/adapters/codex.js
CHANGED
|
@@ -153,6 +153,16 @@ class CodexSession {
|
|
|
153
153
|
threadId = null;
|
|
154
154
|
threadModel = null;
|
|
155
155
|
activeTurnId = null;
|
|
156
|
+
/**
|
|
157
|
+
* Did this turn put anything on the wire before it broke, and was any of it
|
|
158
|
+
* beyond taking back? (#252, #257)
|
|
159
|
+
*
|
|
160
|
+
* The Claude adapter counts the same two facts for the same reason: a failure
|
|
161
|
+
* that arrives after six rounds of tools cannot be answered by re-sending the
|
|
162
|
+
* turn. Reset in `beginTurnWork`, called wherever a turn starts.
|
|
163
|
+
*/
|
|
164
|
+
turnProduced = false;
|
|
165
|
+
turnIrreversible = false;
|
|
156
166
|
/**
|
|
157
167
|
* Last turn this thread finished (ticket #126).
|
|
158
168
|
*
|
|
@@ -1099,6 +1109,9 @@ class CodexSession {
|
|
|
1099
1109
|
}
|
|
1100
1110
|
case 'turn/started': {
|
|
1101
1111
|
this.activeTurnId = str(params['turnId']) ?? str(asRecord(params['turn'])['id']) ?? null;
|
|
1112
|
+
// #252: Codex announces its turns, so this is the exact boundary. Any
|
|
1113
|
+
// later and one turn's «work was done» would veto every retry after it.
|
|
1114
|
+
this.beginTurnWork();
|
|
1102
1115
|
return;
|
|
1103
1116
|
}
|
|
1104
1117
|
case 'turn/completed': {
|
|
@@ -1276,6 +1289,8 @@ class CodexSession {
|
|
|
1276
1289
|
case 'commandExecution': {
|
|
1277
1290
|
const command = str(item['command']) ?? '';
|
|
1278
1291
|
if (!done) {
|
|
1292
|
+
// #252/#257: what separates «send the turn again» from «resume it».
|
|
1293
|
+
this.noteWork(command);
|
|
1279
1294
|
this.emit({
|
|
1280
1295
|
type: 'tool',
|
|
1281
1296
|
phase: 'use',
|
|
@@ -1438,6 +1453,26 @@ class CodexSession {
|
|
|
1438
1453
|
* calls a human instead. Guessing a time here is the one outcome worth
|
|
1439
1454
|
* avoiding: waking early burns a retry and changes nothing.
|
|
1440
1455
|
*/
|
|
1456
|
+
/** A new turn is starting — forget what the previous one did (#252). */
|
|
1457
|
+
beginTurnWork() {
|
|
1458
|
+
this.turnProduced = false;
|
|
1459
|
+
this.turnIrreversible = false;
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Record a command as work that happened, and judge whether it can be undone.
|
|
1463
|
+
*
|
|
1464
|
+
* The same blunt list as the Claude adapter: writing git subcommands only.
|
|
1465
|
+
* Read-only git is excluded deliberately — the agent runs `git status` all day
|
|
1466
|
+
* and treating that as a write would suppress nearly every retry.
|
|
1467
|
+
*/
|
|
1468
|
+
noteWork(command) {
|
|
1469
|
+
this.turnProduced = true;
|
|
1470
|
+
if (this.turnIrreversible)
|
|
1471
|
+
return;
|
|
1472
|
+
if (/\bgit\s+(commit|push|tag|merge|rebase|reset|revert|cherry-pick)\b/.test(command)) {
|
|
1473
|
+
this.turnIrreversible = true;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1441
1476
|
rateLimitRefusal(error) {
|
|
1442
1477
|
const haystack = JSON.stringify(error).toLowerCase();
|
|
1443
1478
|
const refused = haystack.includes('ratelimitreached') ||
|
|
@@ -1472,6 +1507,13 @@ class CodexSession {
|
|
|
1472
1507
|
const blocked = this.rateLimitRefusal(error);
|
|
1473
1508
|
if (blocked)
|
|
1474
1509
|
this.emitRateLimits(blocked);
|
|
1510
|
+
// #252: the machine-readable cause, beside the sentence. `codexErrorInfo`
|
|
1511
|
+
// is Codex's own enum (`usageLimitExceeded`, `httpConnectionFailed`,
|
|
1512
|
+
// `responseStreamConnectionFailed`, `contextWindowExceeded`, …) and it is
|
|
1513
|
+
// the ONLY thing allowed to open the door to an automatic retry — the
|
|
1514
|
+
// message next to it is prose, and prose can be written by agents.
|
|
1515
|
+
const failureCode = str(error['codexErrorInfo']);
|
|
1516
|
+
const failureStatus = error['httpStatusCode'];
|
|
1475
1517
|
this.emit({
|
|
1476
1518
|
type: 'turn_end',
|
|
1477
1519
|
ok: false,
|
|
@@ -1480,6 +1522,10 @@ class CodexSession {
|
|
|
1480
1522
|
// one line earlier would ring into a dead row and throw the person's
|
|
1481
1523
|
// queued words away instead of sending them.
|
|
1482
1524
|
...(blocked ? { limitBlocked: true } : {}),
|
|
1525
|
+
...(failureCode !== undefined ? { failureCode } : {}),
|
|
1526
|
+
...(typeof failureStatus === 'number' ? { failureStatus } : {}),
|
|
1527
|
+
...(this.turnProduced ? { produced: true } : {}),
|
|
1528
|
+
...(this.turnIrreversible ? { irreversible: true } : {}),
|
|
1483
1529
|
});
|
|
1484
1530
|
return;
|
|
1485
1531
|
}
|