@bridge4dev/runner 0.68.0 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -353,6 +353,45 @@ class ClaudeSession {
353
353
  */
354
354
  turnFailureCode = null;
355
355
  turnFailureStatus = null;
356
+ /**
357
+ * The category the provider's own safety filter named, when it refused
358
+ * (#435, first item — recognition and nothing more).
359
+ *
360
+ * Two machine paths carry it and both are typed in the SDK the runner has
361
+ * (`@anthropic-ai/claude-agent-sdk@0.3.226`): the `model_refusal_no_fallback`
362
+ * system frame with `api_refusal_category`, and an ordinary assistant frame
363
+ * with `stop_reason: 'refusal'` and `stop_details.category`
364
+ * (`cyber | bio | frontier_llm | reasoning_extraction | general_harms`).
365
+ * Neither is read from prose — the ban on matching sentences is the whole
366
+ * reason `error-policy.ts` exists.
367
+ *
368
+ * Null on a CLI older than these frames, which reads as «we do not know» and
369
+ * leaves the session behaving exactly as it does today.
370
+ */
371
+ turnRefusalCategory = null;
372
+ /**
373
+ * Is there a turn for a refusal to attach itself to? (#455)
374
+ *
375
+ * Its only reader is the one-shot refusal mark. A refusal that arrives
376
+ * BETWEEN turns has no ending to mark, and a mark left waiting is spent by
377
+ * the next turn — which may be a perfectly good one, run after the window
378
+ * reopened, and would be filed as refused. Codex has had this guard since
379
+ * #258; Claude had not, and S1 wrote it down as a finding.
380
+ *
381
+ * Tracked on its own and not read off `turnClosed`, which looks like the
382
+ * same thing and is not: `turnClosed` is cleared by the first message of
383
+ * ANY kind after a `result`, and `rate_limit_event` — the very message a
384
+ * refusal arrives on, sent by the SDK with every request — is one of them.
385
+ * So it always said «a turn is open» by the time the refusal was read.
386
+ *
387
+ * Opened by anything that means work (a prompt going in, the agent
388
+ * speaking, a status, a tool result), closed by the `result` that ends the
389
+ * turn. It starts `true`, and `system:init` reopens it: a session is
390
+ * started in order to run a turn, and that is the behaviour the #258 test
391
+ * pins. The case it exists for — a refusal arriving after a turn has ended
392
+ * — is the one it answers exactly.
393
+ */
394
+ turnInFlight = true;
356
395
  /**
357
396
  * A `result` has gone out and no new message has arrived since (#406).
358
397
  *
@@ -483,6 +522,7 @@ class ClaudeSession {
483
522
  */
484
523
  resumingTurn() {
485
524
  this.aborting = false;
525
+ this.turnInFlight = true;
486
526
  // #252: the same «every path that starts a turn» property makes this the one
487
527
  // correct place to forget what the PREVIOUS turn did. Reset any earlier and
488
528
  // `turn_end` would report facts that had already been wiped; any later — say,
@@ -882,6 +922,29 @@ class ClaudeSession {
882
922
  rateLimitsAvailable = false;
883
923
  /** A refusal seen since the last turn ended, waiting to be reported with it. */
884
924
  limitBlockPending = false;
925
+ /**
926
+ * The wall itself, with its own clock — not a flag that one turn spends (#455).
927
+ *
928
+ * 2026-09-19, 01:29:45 UTC, run `827b4db2`, stage S2. Two turns hit the same
929
+ * weekly wall two seconds apart. The provider sent the refusal event on the
930
+ * FIRST one only; the CLI then finished the tool call it had started, ran
931
+ * into the same wall again, and that second turn arrived with the flag
932
+ * already spent. Bare, it read as an ordinary API error, matched
933
+ * `claude.invalid_request` and took the session — and the stage, and the run
934
+ * — down with it. The same night did it again to another run's stage O3.
935
+ *
936
+ * So the mark is a STATE, not a one-shot: while a window has refused and its
937
+ * reset is still ahead, any turn that ends on an API error is a refused turn.
938
+ * It comes down when its own reset passes, or when the provider says
939
+ * something that is not a refusal about the SAME window — an `allowed` event
940
+ * about the five-hour window must not take down a weekly wall.
941
+ *
942
+ * **A refusal that names no reset does not raise it.** There would be no
943
+ * clock to take it down, and a wall that never falls would swallow every
944
+ * genuine API error for the rest of the session. That case keeps today's
945
+ * one-shot behaviour, and #258's rule against inventing a reset stands.
946
+ */
947
+ limitWall = null;
885
948
  /**
886
949
  * The one key the window map is written under, from every source.
887
950
  *
@@ -1083,6 +1146,22 @@ class ClaudeSession {
1083
1146
  this.limitBlockPending = false;
1084
1147
  return blocked;
1085
1148
  }
1149
+ /**
1150
+ * Is a refused window still refusing? (#455)
1151
+ *
1152
+ * Reads its own clock and forgets itself once the reset has passed, so the
1153
+ * caller never has to remember to. Returns the window so the feed line can
1154
+ * say until when.
1155
+ */
1156
+ standingLimitWall(now = Date.now()) {
1157
+ if (this.limitWall === null)
1158
+ return null;
1159
+ if (this.limitWall.until <= now) {
1160
+ this.limitWall = null;
1161
+ return null;
1162
+ }
1163
+ return this.limitWall;
1164
+ }
1086
1165
  emitRateLimits(blocked = null) {
1087
1166
  // Signed by THIS session's account (#422 §8, S2 item 4): the windows come
1088
1167
  // from its own stream and from readings measured under its own home. The
@@ -1227,8 +1306,25 @@ class ClaudeSession {
1227
1306
  status: typeof info['status'] === 'string' ? info['status'] : null,
1228
1307
  });
1229
1308
  }
1230
- if (refused)
1231
- this.limitBlockPending = true;
1309
+ if (refused) {
1310
+ // The one-shot flag keeps its old job: mark the turn that is running
1311
+ // RIGHT NOW. It is deliberately not set between turns — a refusal with
1312
+ // no turn around it has no ending to mark, and the flag would instead be
1313
+ // spent by the next turn, which may be a perfectly good one after the
1314
+ // window reopened. Codex has had this guard since #258; Claude had not.
1315
+ //
1316
+ if (this.turnInFlight)
1317
+ this.limitBlockPending = true;
1318
+ // …and the wall is what covers every turn after that one, for as long as
1319
+ // the provider says the window is shut (#455).
1320
+ const until = resetsAt === null ? Number.NaN : Date.parse(resetsAt);
1321
+ if (Number.isFinite(until) && until > Date.now())
1322
+ this.limitWall = { key, until };
1323
+ }
1324
+ else if (this.limitWall?.key === key) {
1325
+ // Not a refusal, and about the very window that refused: the wall is down.
1326
+ this.limitWall = null;
1327
+ }
1232
1328
  this.emitRateLimits(refused ? { key, resetsAt } : null);
1233
1329
  }
1234
1330
  /** Fire-and-forget capability refresh — never throws into the caller. */
@@ -1797,9 +1893,36 @@ class ClaudeSession {
1797
1893
  beginTurnFacts() {
1798
1894
  this.turnFailureCode = null;
1799
1895
  this.turnFailureStatus = null;
1896
+ this.turnRefusalCategory = null;
1800
1897
  this.turnProduced = false;
1801
1898
  this.turnIrreversible = false;
1802
1899
  }
1900
+ /**
1901
+ * Record a refusal by the provider's safety filter, from either frame.
1902
+ *
1903
+ * It does two things and no more, because #435 is a much larger ticket and
1904
+ * this is only its first item: it gives the turn a cause of its own —
1905
+ * `model_refusal`, ours and not the SDK's, because the SDK's closed enum has
1906
+ * no member for this — and it keeps the category so the feed can name it and
1907
+ * the platform can store it.
1908
+ *
1909
+ * The cause matters beyond the wording. Without it a refusal that named
1910
+ * nothing else ended as `terminal_reason: 'api_error'`, which the adapter
1911
+ * spells `server_error`, which the policy table retries FIVE times — and
1912
+ * re-sending a request the filter already refused is the one repeat the
1913
+ * project has written down as actively harmful to the account.
1914
+ */
1915
+ noteRefusal(category, explanation) {
1916
+ this.turnRefusalCategory = category ?? 'unknown';
1917
+ this.turnFailureCode = 'model_refusal';
1918
+ this.emit({
1919
+ type: 'notice',
1920
+ level: 'warn',
1921
+ text: `The model’s safety filter refused to answer (${this.turnRefusalCategory})` +
1922
+ (explanation ? ` — ${maskString(explanation).slice(0, 300)}` : '') +
1923
+ '. This is the provider’s filter, not a fault in the task or the connection.',
1924
+ });
1925
+ }
1803
1926
  /**
1804
1927
  * Remember a tool call as work that happened, and judge whether it can be
1805
1928
  * taken back.
@@ -2275,6 +2398,11 @@ class ClaudeSession {
2275
2398
  this.turnClosed = false;
2276
2399
  this.beginTurnFacts();
2277
2400
  }
2401
+ // #455. Deliberately NOT folded into the guard above: that one is
2402
+ // cleared by every message, and `rate_limit_event` is a message. A
2403
+ // refusal must not be able to open the turn it is then attached to.
2404
+ if (msg.type !== 'result' && msg.type !== 'rate_limit_event')
2405
+ this.turnInFlight = true;
2278
2406
  // Ticket #126: where we are in the transcript, remembered BEFORE the
2279
2407
  // message is interpreted.
2280
2408
  //
@@ -2326,6 +2454,37 @@ class ClaudeSession {
2326
2454
  this.emit({ type: 'compaction', phase: 'finished', ok: true });
2327
2455
  }
2328
2456
  }
2457
+ else if (msg.subtype === 'model_refusal_fallback') {
2458
+ /**
2459
+ * Another model took the turn, so the turn is not refused — and
2460
+ * recording the category here branded the WHOLE turn a refusal
2461
+ * even when the fallback answered and the turn later broke on
2462
+ * something else entirely (independent QA of this stage). A line
2463
+ * in the feed, and nothing else.
2464
+ */
2465
+ this.emit({
2466
+ type: 'notice',
2467
+ level: 'info',
2468
+ text: `The model’s safety filter refused (${msg.api_refusal_category ?? 'unknown'}); ` +
2469
+ `${msg.fallback_model} took the turn instead.`,
2470
+ });
2471
+ }
2472
+ else if (msg.subtype === 'model_refusal_no_fallback') {
2473
+ /**
2474
+ * The provider's safety filter refused the answer (#435, п. 1).
2475
+ *
2476
+ * Until now this fell through to `onTaskMessage`, whose `default:
2477
+ * break` dropped it — so the one frame that says WHY was thrown
2478
+ * away and the turn ended as «the connection to the model
2479
+ * failed». The connection was fine in every case examined; on
2480
+ * the night of 2026-09-17 this machine saw seventeen of them.
2481
+ *
2482
+ * Only `…_no_fallback` is recorded as the turn's cause: it is
2483
+ * the one that kills the turn. Its sibling `…_fallback` is
2484
+ * handled one branch above and says nothing about the ending.
2485
+ */
2486
+ this.noteRefusal(msg.api_refusal_category, msg.api_refusal_explanation);
2487
+ }
2329
2488
  else if (msg.subtype === 'api_retry') {
2330
2489
  // #252. The CLI hit a retryable error and is ALREADY retrying it
2331
2490
  // itself — this is telemetry, not a failure. Two jobs here, and the
@@ -2397,6 +2556,23 @@ class ClaudeSession {
2397
2556
  const failure = msg.error;
2398
2557
  if (typeof failure === 'string' && failure)
2399
2558
  this.turnFailureCode = failure;
2559
+ /**
2560
+ * The second machine path to the same fact (#435, п. 1), and the
2561
+ * one that needs no cast: `msg.message` is a typed `BetaMessage`,
2562
+ * and `refusal` is a member of its `stop_reason` union.
2563
+ *
2564
+ * `SDKAssistantMessageError` — the enum read one line above — has
2565
+ * no member for a refusal, which is exactly why this frame has to
2566
+ * be looked at separately rather than folded into `failureCode`.
2567
+ */
2568
+ // A SUBAGENT's refusal is not the turn's ending — the Task tool
2569
+ // call it lives in reports its own failure and the parent turn
2570
+ // carries on. Branding the whole turn with it named the wrong
2571
+ // cause and stored the wrong reason (independent QA of this
2572
+ // stage). The same guard the block below uses, two lines up.
2573
+ if (msg.message.stop_reason === 'refusal' && !fromSubagent) {
2574
+ this.noteRefusal(msg.message.stop_details?.category, msg.message.stop_details?.explanation);
2575
+ }
2400
2576
  for (const block of msg.message.content) {
2401
2577
  if (block.type === 'text' && !fromSubagent && block.text.trim()) {
2402
2578
  // An API-error message is the CLI TELLING us the turn broke, not
@@ -2513,9 +2689,29 @@ class ClaudeSession {
2513
2689
  this.turnClosed = false;
2514
2690
  this.beginTurnFacts();
2515
2691
  }
2692
+ // Whatever it was, it is over now (#455).
2693
+ this.turnInFlight = false;
2516
2694
  const gaveUp = msg.subtype === 'success' && terminalReason === 'api_error';
2695
+ /**
2696
+ * #455. A turn that ended on an API error while a refused window
2697
+ * is still refusing IS that refusal, whether or not the provider
2698
+ * troubled itself to send a second event about it.
2699
+ *
2700
+ * The wall is asked only on a turn that ended on an API error —
2701
+ * `terminal_reason: 'api_error'`, or a cause the API's own enum
2702
+ * named on the assistant frame. A turn that ran out of turns, or
2703
+ * one the person stopped, is not a refusal and is not dressed up
2704
+ * as one.
2705
+ */
2706
+ const wall = gaveUp || this.turnFailureCode !== null ? this.standingLimitWall() : null;
2707
+ // Named only when the refusal is what ENDED the turn — the same
2708
+ // condition the reported category uses further down. A turn that
2709
+ // met a refusal and then broke on something else is not a refused
2710
+ // turn, and a sentence saying it was would be a new lie in place
2711
+ // of the old one (independent QA of this stage).
2712
+ const refusal = this.turnFailureCode === 'model_refusal' ? this.turnRefusalCategory : null;
2517
2713
  const failure = gaveUp
2518
- ? API_ERROR_TURN_MESSAGE
2714
+ ? apiErrorTurnMessage(wall, refusal)
2519
2715
  : msg.subtype === 'success'
2520
2716
  ? ''
2521
2717
  : classifyError(msg.subtype, msg.errors);
@@ -2573,11 +2769,15 @@ class ClaudeSession {
2573
2769
  this.refreshUsage();
2574
2770
  }
2575
2771
  else {
2772
+ // #455: the one-shot flag OR the standing wall. Read in this
2773
+ // order and both read: the flag has to be consumed either way,
2774
+ // or it would outlive the turn it belongs to.
2775
+ const markedByEvent = this.consumeLimitBlock();
2576
2776
  this.emit({
2577
2777
  type: 'turn_end',
2578
2778
  ok: false,
2579
2779
  errorMessage: failure,
2580
- ...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
2780
+ ...(markedByEvent || wall !== null ? { limitBlocked: true } : {}),
2581
2781
  // #252: the cause and the ending arrive on different messages —
2582
2782
  // the closed enum rides on the assistant message, this `result`
2583
2783
  // has no such field. Handed over so the supervisor can decide
@@ -2598,6 +2798,16 @@ class ClaudeSession {
2598
2798
  : {}),
2599
2799
  ...(this.turnProduced ? { produced: true } : {}),
2600
2800
  ...(this.turnIrreversible ? { irreversible: true } : {}),
2801
+ // #435 п. 1: the reason travels up. Absent means «this runner
2802
+ // is older than the fix» — never «no refusal».
2803
+ //
2804
+ // Tied to the CAUSE and not merely to «a refusal happened
2805
+ // somewhere in this turn»: if the turn went on and broke on
2806
+ // something else, the refusal is not why it ended, and saying
2807
+ // it was would store a reason that is not true.
2808
+ ...(this.turnRefusalCategory !== null && this.turnFailureCode === 'model_refusal'
2809
+ ? { refusalCategory: this.turnRefusalCategory }
2810
+ : {}),
2601
2811
  });
2602
2812
  this.refreshUsage();
2603
2813
  }
@@ -2783,14 +2993,37 @@ function stringifyContent(content) {
2783
2993
  return content === undefined ? '' : JSON.stringify(content);
2784
2994
  }
2785
2995
  /**
2786
- * What the feed says about a turn Claude Code gave up on (#406).
2996
+ * What the feed says about a turn Claude Code gave up on (#406, rewritten for
2997
+ * #455 on the owner's decision of 2026-09-19).
2998
+ *
2999
+ * It used to be one sentence for every case: «The connection to the model
3000
+ * failed and Claude Code ended the turn on an API error». It was wrong in both
3001
+ * cases anybody has actually looked at — a spent plan limit and a refusal by
3002
+ * the model's safety filter — and wrong in the expensive direction: the person
3003
+ * spends an hour looking for a network fault that is not there. The connection
3004
+ * was in order every time.
3005
+ *
3006
+ * So it says what is known and stops where knowledge stops. A standing wall is
3007
+ * named with its reset; a recognised refusal is named as a refusal; and when
3008
+ * neither is known the sentence no longer asserts anything about the
3009
+ * connection.
2787
3010
  *
2788
- * Deliberately free of «mid-response» and «may be incomplete»: `refine()` in
2789
- * `error-policy.ts` matches those two phrases and narrows a retry to a single
2790
- * «carry on» attempt. Here the narrowing must come from `produced`, which is a
2791
- * fact about the turn, and not from the wording of a sentence.
3011
+ * Still deliberately free of «mid-response» and «may be incomplete»: `refine()`
3012
+ * in `error-policy.ts` matches those two phrases and narrows a retry to a
3013
+ * single «carry on» attempt. The narrowing must come from `produced`, which is
3014
+ * a fact about the turn, and not from the wording of a sentence.
2792
3015
  */
2793
- const API_ERROR_TURN_MESSAGE = 'The connection to the model failed and Claude Code ended the turn on an API error';
3016
+ function apiErrorTurnMessage(wall, refusalCategory) {
3017
+ if (wall !== null) {
3018
+ return ('The plan limit is spent — the provider refused this turn. It reopens at ' +
3019
+ `${new Date(wall.until).toISOString().replace('T', ' ').slice(0, 16)} UTC.`);
3020
+ }
3021
+ if (refusalCategory !== null) {
3022
+ return (`The model’s safety filter refused to answer (${refusalCategory}) and no fallback model ` +
3023
+ 'took the turn. Nothing is wrong with the connection or with the task.');
3024
+ }
3025
+ return 'Claude Code ended the turn on an API error.';
3026
+ }
2794
3027
  function classifyError(subtype, errors) {
2795
3028
  // Optional on purpose despite the SDK's type: a `result` without `errors`
2796
3029
  // threw from inside the event pump, which the loop's catch turned into a
@@ -1529,6 +1529,11 @@ class CodexSession {
1529
1529
  // agent that would refuse every turn with the same credential.
1530
1530
  if (this.endedOnAuthFailure(str(detail['message']) ?? ''))
1531
1531
  return;
1532
+ // #446. A provider body with a machine-readable cause ends the TURN,
1533
+ // which is the one door the retry policy stands behind. Everything
1534
+ // else keeps going the old way — straight to a failed session.
1535
+ if (this.endedOnProviderApiError(detail))
1536
+ return;
1532
1537
  this.classifyAndEmitFailure(new Error(str(detail['message']) ?? 'Codex reported an error'));
1533
1538
  return;
1534
1539
  }
@@ -2035,6 +2040,74 @@ class CodexSession {
2035
2040
  * the same sentence printed itself once per refusal — eight times in the
2036
2041
  * feed of the session this was found on.
2037
2042
  */
2043
+ /**
2044
+ * The provider rejected the request outright, and said so in a body we can
2045
+ * READ (#446).
2046
+ *
2047
+ * 2026-09-18, 20:27:44 UTC: OpenAI answered a turn with
2048
+ * `{"error":{"code":"unsupported_parameter","param":"access_programs.cyber"},
2049
+ * "status":400}` and the session died with the raw JSON in the feed, the
2050
+ * stage stopped as «unknown», and a run with three stages left went to
2051
+ * NEEDS_YOU. Nothing in this repository asks for that parameter — the CLI
2052
+ * adds it — and the same account, machine and model ran fifteen other
2053
+ * sessions that day without it.
2054
+ *
2055
+ * Two things had to change and this is the first. The machine cause never
2056
+ * reached the policy: this notification rebuilt the failure as
2057
+ * `new Error(detail.message)` and `classifyAndEmitFailure` threw everything
2058
+ * but the sentence away, so `classifyFailure` saw «no code» and answered
2059
+ * `stop` — which is the right answer to «no code» and the wrong one here.
2060
+ *
2061
+ * **Why this ends the TURN rather than calling the policy from here.** The
2062
+ * `error` branch is a dead end for retry by construction: `armApiRetry` has
2063
+ * exactly one call site, in `turn_end`, and with it live all the guards that
2064
+ * make an automatic retry safe — the session-wide ceiling, the open-card
2065
+ * check, the «same failure twice» rule, one counter, one feed line. A second
2066
+ * entrance next to it would be a second set of all of them. The machinery
2067
+ * for ending a turn from this notification already exists a few lines below,
2068
+ * where the limit refusal uses it, down to remembering the id so Codex's own
2069
+ * late ending is not counted twice.
2070
+ *
2071
+ * Returns false whenever there is nothing readable, or no turn to end — and
2072
+ * then the old path runs exactly as before.
2073
+ */
2074
+ endedOnProviderApiError(detail) {
2075
+ const api = readProviderApiError(detail);
2076
+ if (api === null)
2077
+ return false;
2078
+ if (this.activeTurnId === null)
2079
+ return false;
2080
+ const turnId = this.activeTurnId;
2081
+ this.selfSettledTurnId = turnId;
2082
+ this.activeTurnId = null;
2083
+ this.subagents.endTurn();
2084
+ const detailMessage = str(detail['message']);
2085
+ this.emit({
2086
+ type: 'turn_end',
2087
+ ok: false,
2088
+ // «This REQUEST», not «this turn»: the turn may well have run tools —
2089
+ // the incident's had committed a second earlier, and the same event
2090
+ // carries `produced` and `irreversible` to say so. Saying the turn did
2091
+ // nothing would be the same false statement this ticket exists to
2092
+ // remove, pointing the other way (independent QA of this stage).
2093
+ errorMessage: maskString(`The provider rejected the request outright (${api.code}` +
2094
+ `${api.param === undefined ? '' : `, ${api.param}`}). Nothing of this request reached ` +
2095
+ `a model.${api.message === undefined ? '' : ` Provider: ${api.message}`}`).slice(0, 500),
2096
+ failureCode: providerApiFailureCode(api),
2097
+ ...(api.status !== undefined ? { failureStatus: api.status } : {}),
2098
+ ...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
2099
+ ...(this.turnProduced ? { produced: true } : {}),
2100
+ ...(this.turnIrreversible ? { irreversible: true } : {}),
2101
+ });
2102
+ log.info('codex: provider rejected the request', {
2103
+ sessionId: this.spec.sessionId,
2104
+ code: api.code,
2105
+ param: api.param ?? null,
2106
+ status: api.status ?? null,
2107
+ raw: detailMessage === undefined ? null : maskString(detailMessage).slice(0, 200),
2108
+ });
2109
+ return true;
2110
+ }
2038
2111
  noteRateLimitRefusal(error) {
2039
2112
  if (!this.announceRateLimitRefusal(error))
2040
2113
  return false;
@@ -2177,12 +2250,33 @@ class CodexSession {
2177
2250
  // `responseStreamConnectionFailed`, `contextWindowExceeded`, …) and it is
2178
2251
  // the ONLY thing allowed to open the door to an automatic retry — the
2179
2252
  // message next to it is prose, and prose can be written by agents.
2180
- const failureCode = str(error['codexErrorInfo']);
2181
- const failureStatus = error['httpStatusCode'];
2253
+ // #446. The provider's OWN body, when the turn failure carries one: a
2254
+ // code from the API's dictionary, kept apart from Codex's by its `api:`
2255
+ // prefix, so a rule can be about one without ever matching the other.
2256
+ // Codex's own code wins when both are there — it is the more specific
2257
+ // statement about what happened, and the table is written around it.
2258
+ const api = readProviderApiError(error);
2259
+ const failureCode = str(error['codexErrorInfo']) ?? (api === null ? undefined : providerApiFailureCode(api));
2260
+ const rawStatus = error['httpStatusCode'];
2261
+ const failureStatus = typeof rawStatus === 'number' ? rawStatus : api?.status;
2262
+ /**
2263
+ * #435, first item, the Codex half — so the platform says ONE thing
2264
+ * about one event whichever agent met it.
2265
+ *
2266
+ * `cyberPolicy` is Codex's word for «the content filter refused», the
2267
+ * same class of event as Claude's `stop_reason: 'refusal'`. It stays
2268
+ * `retry: false` and the session still ends FAILED; what changes is that
2269
+ * the reason now travels, instead of the platform calling a content
2270
+ * refusal a broken connection.
2271
+ */
2272
+ const refusalCategory = failureCode === 'cyberPolicy' ? 'cyber' : null;
2182
2273
  this.emit({
2183
2274
  type: 'turn_end',
2184
2275
  ok: false,
2185
- errorMessage: maskString(str(error['message']) ?? 'The Codex turn failed').slice(0, 500),
2276
+ errorMessage: maskString(refusalCategory === null
2277
+ ? (str(error['message']) ?? 'The Codex turn failed')
2278
+ : 'The model’s safety filter refused to answer (cyber). This is the provider’s ' +
2279
+ 'filter, not a fault in the task or the connection.').slice(0, 500),
2186
2280
  // Without this the session goes FAILED — terminal — and the pause armed
2187
2281
  // one line earlier would ring into a dead row and throw the person's
2188
2282
  // queued words away instead of sending them.
@@ -2191,6 +2285,7 @@ class CodexSession {
2191
2285
  ...(typeof failureStatus === 'number' ? { failureStatus } : {}),
2192
2286
  ...(this.turnProduced ? { produced: true } : {}),
2193
2287
  ...(this.turnIrreversible ? { irreversible: true } : {}),
2288
+ ...(refusalCategory !== null ? { refusalCategory } : {}),
2194
2289
  });
2195
2290
  return;
2196
2291
  }
@@ -2701,6 +2796,74 @@ export function isAuthError(message) {
2701
2796
  function describe(error) {
2702
2797
  return String(error instanceof Error ? error.message : error);
2703
2798
  }
2799
+ /**
2800
+ * Read `{"error":{"code":…,"param":…},"status":…}` out of a notification.
2801
+ *
2802
+ * **Parsed as data, not matched as prose**, and the difference is the whole
2803
+ * point: `error-policy.ts` forbids deciding anything from a sentence, because
2804
+ * an agent's own output travels in the same stream and a report quoting a
2805
+ * provider's error would otherwise be able to fire a rule. JSON.parse is not
2806
+ * pattern matching — either the body is a structure with a `code` field in it
2807
+ * or this returns null and nothing happens.
2808
+ *
2809
+ * Two shapes, because the body arrives either way depending on how far up the
2810
+ * CLI wrapped it: as the notification's own record, or as JSON inside its
2811
+ * `message` string. The nested `error` object is required — a bare top-level
2812
+ * `code` on a Codex notification is Codex's OWN vocabulary
2813
+ * (`codexErrorInfo`), and mixing the two dictionaries is the thing #446 п. 3
2814
+ * asked not to do.
2815
+ */
2816
+ function readProviderApiError(root) {
2817
+ const fromRecord = (value) => {
2818
+ const inner = asRecord(asRecord(value)['error']);
2819
+ const code = str(inner['code']);
2820
+ if (code === undefined || code === '')
2821
+ return null;
2822
+ const param = str(inner['param']);
2823
+ const raw = asRecord(value)['status'] ?? inner['status'] ?? asRecord(value)['httpStatusCode'];
2824
+ const status = typeof raw === 'number' ? raw : undefined;
2825
+ const message = str(inner['message']);
2826
+ return {
2827
+ code,
2828
+ ...(param === undefined ? {} : { param }),
2829
+ ...(status === undefined ? {} : { status }),
2830
+ ...(message === undefined ? {} : { message }),
2831
+ };
2832
+ };
2833
+ const direct = fromRecord(root);
2834
+ if (direct !== null)
2835
+ return direct;
2836
+ const text = str(asRecord(root)['message']);
2837
+ if (text === undefined)
2838
+ return null;
2839
+ // The body is sometimes wrapped in a sentence («stream error: {…}»), so the
2840
+ // braces are located rather than assumed to be the whole string. Bounded by
2841
+ // the first `{` and the last `}`: anything else is not a body.
2842
+ const start = text.indexOf('{');
2843
+ const end = text.lastIndexOf('}');
2844
+ if (start === -1 || end <= start)
2845
+ return null;
2846
+ try {
2847
+ return fromRecord(JSON.parse(text.slice(start, end + 1)));
2848
+ }
2849
+ catch {
2850
+ return null;
2851
+ }
2852
+ }
2853
+ /**
2854
+ * The code a rule can match, with the provider's dictionary kept apart from
2855
+ * Codex's own (#446 п. 3).
2856
+ *
2857
+ * `api:` is the prefix, so `unsupported_parameter` can never be confused with
2858
+ * a `codexErrorInfo` member. The parameter's FIRST segment rides along when
2859
+ * there is one, so a rule can be about `access_programs` without being about
2860
+ * every `unsupported_parameter` there will ever be — and without being tied to
2861
+ * the exact sub-name (`access_programs.cyber`) the CLI happened to send.
2862
+ */
2863
+ function providerApiFailureCode(api) {
2864
+ const family = api.param === undefined ? '' : `:${api.param.split('.')[0] ?? api.param}`;
2865
+ return `api:${api.code}${family}`;
2866
+ }
2704
2867
  /**
2705
2868
  * "The thread you asked for is not there." Matched on the message and not on an
2706
2869
  * error class, because the same condition arrives as an RPC error or as a
@@ -76,6 +76,17 @@ export interface ErrorRule {
76
76
  bucket?: Exclude<RetryBucket, 'stop'>;
77
77
  attempts?: number;
78
78
  backoff?: BackoffProfile;
79
+ /**
80
+ * The provider threw the whole request away — nothing of it reached a model.
81
+ *
82
+ * Changes nothing about WHETHER we carry on; it changes what we SAY when we
83
+ * do. The default sentence tells the agent its answer was cut off part-way
84
+ * and to check what it had already finished — true for a dropped stream,
85
+ * false and expensive here: the agent goes looking for half-done work that
86
+ * does not exist, and the feed tells the person the connection failed when
87
+ * it did not (#446 п. 6, #455 owner's decision of 2026-09-19).
88
+ */
89
+ refusedWhole?: boolean;
79
90
  /**
80
91
  * `false` → the row is INERT: counted, never acted on.
81
92
  *
@@ -122,6 +133,12 @@ export interface RetryDecision {
122
133
  backoff: BackoffProfile;
123
134
  /** Which row decided, or `null` when nothing matched and the default applied. */
124
135
  ruleId: string | null;
136
+ /**
137
+ * The provider threw the whole request away — so whatever we say next must
138
+ * not talk about a connection or about half-finished work. See
139
+ * `ErrorRule.refusedWhole`.
140
+ */
141
+ refusedWhole: boolean;
125
142
  }
126
143
  /**
127
144
  * What may this failure do next?