@bridge4dev/runner 0.42.1 → 0.44.2

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.
@@ -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 is never taken from here — whatever the event said stays. A
22
- * window the event has not mentioned yet is created so the first probe is not
23
- * wasted, with no clock on it until the event supplies one.
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
- /** «Current session» is the five-hour window; every «Current week» is a weekly one. */
29
- const SESSION_LINE = /current session:\s*(\d+(?:\.\d+)?)%\s*used/i;
30
- const WEEK_LINE = /current week(?:\s*\(([^)]+)\))?:\s*(\d+(?:\.\d+)?)%\s*used/gi;
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 = clamp(SESSION_LINE.exec(text)?.[1]);
49
- if (session !== null)
50
- rows.push({ key: 'five_hour', label: null, percent: session });
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({ key: 'seven_day', label: isAll ? null : `Weekly · ${scope}`, percent });
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 is never taken from here — whatever the event said stays. A
67
- * window the event has not mentioned yet is created so the first probe is not
68
- * wasted, with no clock on it until the event supplies one.
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
- // The clock stays whatever the machine-readable event said — this file
83
- // never sets it, so a wording change here can never move a wake-up.
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
  });
@@ -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 { clampPercent, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
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) {
@@ -712,6 +747,30 @@ class ClaudeSession {
712
747
  static windowKey(key, label) {
713
748
  return `${key}:${label ?? ''}`;
714
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
+ }
715
774
  /**
716
775
  * Read the percentages out of the CLI's own `/usage` (#279).
717
776
  *
@@ -790,17 +849,55 @@ class ClaudeSession {
790
849
  for (const [name, window] of Object.entries(limits)) {
791
850
  if (!window || typeof window.utilization !== 'number')
792
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;
793
856
  const key = rateWindowKey(name);
794
857
  this.rateLimitWindows.set(ClaudeSession.windowKey(key), {
795
858
  key,
796
859
  windowMinutes: RATE_WINDOW_MINUTES[key] ?? null,
797
- usedPercent: clampPercent(window.utilization),
860
+ usedPercent: percentFromUtilization(window.utilization),
798
861
  resetsAt: typeof window.resets_at === 'string' ? window.resets_at : null,
799
862
  });
800
863
  }
801
864
  }
802
865
  this.emitRateLimits();
803
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
+ }
804
901
  onRateLimitEvent(msg) {
805
902
  const info = msg.rate_limit_info;
806
903
  if (!info || typeof info !== 'object')
@@ -828,14 +925,27 @@ class ClaudeSession {
828
925
  // Keep whatever the previous event said about the percentage: the field
829
926
  // comes and goes between events about the same window, and dropping it on
830
927
  // the next silent one would make a number that was true flicker away.
831
- const known = this.rateLimitWindows.get(ClaudeSession.windowKey(key));
832
- this.rateLimitWindows.set(ClaudeSession.windowKey(key), {
833
- key,
834
- windowMinutes: RATE_WINDOW_MINUTES[key] ?? null,
835
- usedPercent: typeof utilization === 'number' ? clampPercent(utilization) : (known?.usedPercent ?? null),
836
- resetsAt: resetsAt ?? known?.resetsAt ?? null,
837
- status: typeof info['status'] === 'string' ? info['status'] : null,
838
- });
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
+ }
839
949
  if (refused)
840
950
  this.limitBlockPending = true;
841
951
  this.emitRateLimits(refused ? { key, resetsAt } : null);
@@ -1548,6 +1658,47 @@ class ClaudeSession {
1548
1658
  this.taskPublishedAt = 0;
1549
1659
  this.flushTasks();
1550
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
+ }
1551
1702
  async onCanUseTool(toolName, input, opts) {
1552
1703
  // The agent's interactive question tool assumes a terminal picker. There is
1553
1704
  // none here — so the call is PARKED and the dashboard becomes the picker.
@@ -1974,6 +2125,23 @@ class ClaudeSession {
1974
2125
  });
1975
2126
  }
1976
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
+ }
1977
2145
  else {
1978
2146
  // Ticket #113: subagents, background shells and dynamic workflows.
1979
2147
  this.onTaskMessage(msg);
@@ -2021,14 +2189,31 @@ class ClaudeSession {
2021
2189
  // field as a subagent would blank the transcript of every session on
2022
2190
  // that CLI, which is a far worse failure than one leaked report.
2023
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;
2024
2199
  for (const block of msg.message.content) {
2025
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;
2026
2206
  this.emit({ type: 'message', role: 'assistant', text: truncate(block.text) });
2027
2207
  }
2028
2208
  else if (block.type === 'thinking' && !fromSubagent && block.thinking.trim()) {
2209
+ this.turnProduced = true;
2029
2210
  this.emit({ type: 'thinking', text: truncate(block.thinking, 8_000) });
2030
2211
  }
2031
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);
2032
2217
  this.emit({
2033
2218
  type: 'tool',
2034
2219
  phase: 'use',
@@ -2122,6 +2307,16 @@ class ClaudeSession {
2122
2307
  ok: false,
2123
2308
  errorMessage: failure,
2124
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 } : {}),
2125
2320
  });
2126
2321
  this.refreshUsage();
2127
2322
  }
@@ -67,6 +67,20 @@ export declare function parseElicitation(params: Record<string, unknown>): Agent
67
67
  * never enumerated on our side.
68
68
  */
69
69
  export declare function readEfforts(value: unknown): EffortOption[];
70
+ /**
71
+ * When a Codex window resets → ISO, or null if it did not say.
72
+ *
73
+ * Epoch SECONDS on the wire, captured live from codex-cli 0.147.0 on 2026-08-16:
74
+ * `resetsAt: 1787221437` is 2026-08-20. Read as a string it was silently dropped,
75
+ * which is why no Codex window has ever shown a reset time and why #258 had
76
+ * nothing to schedule a wake-up from on this agent.
77
+ *
78
+ * The range check is not decoration. A clock built from a misread number is
79
+ * worse than no clock at all: milliseconds mistaken for seconds would promise a
80
+ * reset in the year 58600, and #258 would arm a timer on it. Outside the range,
81
+ * we say we do not know — which is the honest answer and the safe one.
82
+ */
83
+ export declare function codexResetsAt(raw: unknown): string | null;
70
84
  export declare class CodexAdapter implements AgentAdapter {
71
85
  private readonly deps;
72
86
  readonly id: "codex";