jeo-code 0.7.43 → 0.7.44

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/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.7.44] - 2026-07-06
10
+ _Root-caused a real production hang reported from `jeo`'s OpenAI Codex OAuth subagent path: after roughly 20-30 minutes of active streamed traffic, `chatgpt.com`'s backend severs the live SSE connection mid-response (an infra connection-duration cap, not a broken network) and Bun's fetch/undici surfaces it as `Error: The socket connection was closed unexpectedly …`. `retryableStream` (model-manager.ts) only auto-retries losing the FIRST streamed chunk — once any chunk had reached the caller it deliberately stopped retrying (a full re-call would replay already-emitted content) — so this class of drop propagated straight out of the engine as a raw, unretried turn-ending error every time, even though nothing had been committed to history yet and a plain resend is exactly as safe as a fresh call._
11
+
12
+ ### Added
13
+ - **Engine-level transient-network recovery ladder** (`src/agent/engine.ts`) — a mid-stream socket drop (`isTransientStreamDropError`, `src/util/retry.ts`: Bun/undici `"socket connection was closed"`/`ConnectionClosed`, Node's `"socket hang up"`/`"other side closed"`/`UND_ERR_SOCKET`) now resends the SAME step with capped exponential backoff (`GUARD_LIMITS.MAX_TRANSIENT_NETWORK_RETRIES` = 5, base 1s doubling to a 15s ceiling, `JEO_TRANSIENT_NETWORK_BACKOFF_BASE_MS` override) instead of ending the turn — visible via `onNotice` (`"connection dropped mid-response (…) — auto-retry #N in Ns"`), Esc/Ctrl-C cancellable mid-wait, and a real terminal error still surfaces once the bounded budget is exhausted (a persistent outage is not spun on forever). Deliberately narrower than `defaultRetryable`: a rate-limit/5xx/timeout error already ran the FULL model-manager `withRetry` budget before reaching the engine, so this ladder only matches the socket-death signature that layer structurally cannot retry.
14
+ - `defaultRetryable` (`src/util/retry.ts`) now also classifies the same socket-drop message shapes as a transient network fault, so a drop BEFORE the first streamed chunk (still inside model-manager's own retry budget) is retried there too.
15
+
16
+ ### Verified
17
+ - `bun run typecheck` clean; `bun test` 2429 pass / 0 fail.
18
+ - New `test/transient-network-recovery.test.ts`: mid-stream socket drop resends and recovers; the bounded retry budget exhausts to a terminal error (not an infinite spin); Esc/Ctrl-C cancels the backoff wait; a deterministic safety refusal is still routed to the refusal ladder, never this one.
19
+ - `test/retry.test.ts`: `defaultRetryable` now accepts the Bun `"socket connection was closed…"` message and the Node `"socket hang up"`/`"other side closed"`/`UND_ERR_SOCKET` equivalents.
20
+
9
21
  ## [0.7.43] - 2026-07-06
10
22
  _TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves._
11
23
 
package/README.ja.md CHANGED
@@ -259,11 +259,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
259
259
  ## 変更履歴 (Changelog)
260
260
 
261
261
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
262
+ - **[0.7.44]** (2026-07-06) — Root-caused a real production hang reported from `jeo`'s OpenAI Codex OAuth subagent path: after roughly 20-30 minutes of active streamed traffic, `chatgpt.com`'s backend severs the live SSE connection mid-response (an infra connection-duration cap, not a broken network) and Bun's fetch/undici surfaces it as `Error: The socket connection was closed unexpectedly …`. `retryableStream` (model-manager.ts) only auto-retries losing the FIRST streamed chunk — once any chunk had reached the caller it deliberately stopped retrying (a full re-call would replay already-emitted content) — so this class of drop propagated straight out of the engine as a raw, unretried turn-ending error every time, even though nothing had been committed to history yet and a plain resend is exactly as safe as a fresh call.
262
263
  - **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
263
264
  - **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
264
265
  - **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
265
266
  - **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
266
- - **[0.7.39]** (2026-07-05) — Fixes an unrecovered Anthropic refusal on Claude Fable 5: the new `stop_details.category: "reasoning_extraction"` refusal shape wasn't recognized, so the turn died with a raw, unfriendly error instead of engaging jeo's existing context-reset refusal-recovery ladder.
267
267
 
268
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
269
269
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -259,11 +259,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
259
259
  ## 변경 이력 (Changelog)
260
260
 
261
261
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
262
+ - **[0.7.44]** (2026-07-06) — Root-caused a real production hang reported from `jeo`'s OpenAI Codex OAuth subagent path: after roughly 20-30 minutes of active streamed traffic, `chatgpt.com`'s backend severs the live SSE connection mid-response (an infra connection-duration cap, not a broken network) and Bun's fetch/undici surfaces it as `Error: The socket connection was closed unexpectedly …`. `retryableStream` (model-manager.ts) only auto-retries losing the FIRST streamed chunk — once any chunk had reached the caller it deliberately stopped retrying (a full re-call would replay already-emitted content) — so this class of drop propagated straight out of the engine as a raw, unretried turn-ending error every time, even though nothing had been committed to history yet and a plain resend is exactly as safe as a fresh call.
262
263
  - **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
263
264
  - **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
264
265
  - **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
265
266
  - **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
266
- - **[0.7.39]** (2026-07-05) — Fixes an unrecovered Anthropic refusal on Claude Fable 5: the new `stop_details.category: "reasoning_extraction"` refusal shape wasn't recognized, so the turn died with a raw, unfriendly error instead of engaging jeo's existing context-reset refusal-recovery ladder.
267
267
 
268
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
269
269
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -261,11 +261,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
261
261
  ## Changelog
262
262
 
263
263
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
264
+ - **[0.7.44]** (2026-07-06) — Root-caused a real production hang reported from `jeo`'s OpenAI Codex OAuth subagent path: after roughly 20-30 minutes of active streamed traffic, `chatgpt.com`'s backend severs the live SSE connection mid-response (an infra connection-duration cap, not a broken network) and Bun's fetch/undici surfaces it as `Error: The socket connection was closed unexpectedly …`. `retryableStream` (model-manager.ts) only auto-retries losing the FIRST streamed chunk — once any chunk had reached the caller it deliberately stopped retrying (a full re-call would replay already-emitted content) — so this class of drop propagated straight out of the engine as a raw, unretried turn-ending error every time, even though nothing had been committed to history yet and a plain resend is exactly as safe as a fresh call.
264
265
  - **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
265
266
  - **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
266
267
  - **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
267
268
  - **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
268
- - **[0.7.39]** (2026-07-05) — Fixes an unrecovered Anthropic refusal on Claude Fable 5: the new `stop_details.category: "reasoning_extraction"` refusal shape wasn't recognized, so the turn died with a raw, unfriendly error instead of engaging jeo's existing context-reset refusal-recovery ladder.
269
269
 
270
270
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
271
271
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -259,11 +259,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
259
259
  ## 更新日志 (Changelog)
260
260
 
261
261
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
262
+ - **[0.7.44]** (2026-07-06) — Root-caused a real production hang reported from `jeo`'s OpenAI Codex OAuth subagent path: after roughly 20-30 minutes of active streamed traffic, `chatgpt.com`'s backend severs the live SSE connection mid-response (an infra connection-duration cap, not a broken network) and Bun's fetch/undici surfaces it as `Error: The socket connection was closed unexpectedly …`. `retryableStream` (model-manager.ts) only auto-retries losing the FIRST streamed chunk — once any chunk had reached the caller it deliberately stopped retrying (a full re-call would replay already-emitted content) — so this class of drop propagated straight out of the engine as a raw, unretried turn-ending error every time, even though nothing had been committed to history yet and a plain resend is exactly as safe as a fresh call.
262
263
  - **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
263
264
  - **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
264
265
  - **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
265
266
  - **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
266
- - **[0.7.39]** (2026-07-05) — Fixes an unrecovered Anthropic refusal on Claude Fable 5: the new `stop_details.category: "reasoning_extraction"` refusal shape wasn't recognized, so the turn died with a raw, unfriendly error instead of engaging jeo's existing context-reset refusal-recovery ladder.
267
267
 
268
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
269
269
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,8 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.7.43",
4
-
5
-
3
+ "version": "0.7.44",
6
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
7
5
  "type": "module",
8
6
  "main": "src/cli.ts",
@@ -22,7 +22,7 @@ import { createLspTool } from "./lsp-tool";
22
22
  import { createLspRenameTool } from "./lsp-rename-tool";
23
23
  import { createDebugTool } from "./debug-tool";
24
24
  import { createBrowserTool } from "./browser-tool";
25
- import { isRateLimitError, waitAbortable } from "../util/retry";
25
+ import { isRateLimitError, isTransientStreamDropError, waitAbortable } from "../util/retry";
26
26
  import { isContextOverflowError, isRefusalError, friendlyProviderError } from "../util/provider-error";
27
27
  import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
28
28
  import { truncateToolOutput, formatToolResultBody } from "./tool-output";
@@ -530,6 +530,14 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
530
530
  // no-progress clock keeps ticking and terminates the turn at JEO_TURN_MAX_MS).
531
531
  const MAX_REFUSAL_RETRIES = GUARD_LIMITS.MAX_REFUSAL_RETRIES;
532
532
  let refusalRetries = 0;
533
+ // Transient-network ladder (mid-stream Bun/undici socket-closed, ECONNRESET, 5xx, …):
534
+ // `retryableStream` (model-manager.ts) only auto-retries losing the FIRST chunk — once
535
+ // any chunk has streamed to the caller it deliberately stops retrying (a full re-call
536
+ // would replay already-emitted content). A drop AFTER the first chunk therefore used to
537
+ // propagate straight out of invokeCallLlm and end the turn with a raw "Error: …", even
538
+ // though nothing was committed to `history` (the failed step never pushed) — a plain
539
+ // resend is exactly as safe as the refusal ladder's rung-1 free resend below.
540
+ let transientNetworkRetries = 0;
533
541
  let lastSig = "";
534
542
  let repeatCount = 0;
535
543
  // Cycle guard (the A↔B ping-pong the exact-repeat guard cannot see): the recent
@@ -799,6 +807,27 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
799
807
  await waitAbortable(delayMs, opts.signal);
800
808
  continue; // loop top surfaces "Cancelled." when the wait was aborted
801
809
  }
810
+ // Mid-stream transient-network drop (see `transientNetworkRetries` above): bounded
811
+ // resend with capped exponential backoff. Deliberately narrower than a general
812
+ // `defaultRetryable` check — a rate-limit/5xx/timeout error already ran the FULL
813
+ // model-manager `withRetry` budget (visible as its own "auto-retry #N" notices)
814
+ // before reaching here, so re-retrying it in this ladder too would silently double
815
+ // that budget. `isTransientStreamDropError` matches only the mid-stream socket-death
816
+ // class that `retryableStream` structurally cannot retry once a chunk has streamed.
817
+ if (isTransientStreamDropError(err)) {
818
+ transientNetworkRetries++;
819
+ if (transientNetworkRetries <= GUARD_LIMITS.MAX_TRANSIENT_NETWORK_RETRIES) {
820
+ const baseRaw = Number(jeoEnv("TRANSIENT_NETWORK_BACKOFF_BASE_MS") ?? NaN);
821
+ const baseMs = Number.isFinite(baseRaw) && baseRaw >= 0 ? baseRaw : GUARD_LIMITS.TRANSIENT_NETWORK_BACKOFF_BASE_MS;
822
+ const delayMs = Math.min(baseMs * 2 ** (transientNetworkRetries - 1), GUARD_LIMITS.TRANSIENT_NETWORK_BACKOFF_MAX_MS);
823
+ const escHint = ev.onModelStream ? " (Esc to cancel)" : "";
824
+ ev.onNotice?.(
825
+ `connection dropped mid-response (${friendlyProviderError(err)}) — auto-retry #${transientNetworkRetries} in ${Math.max(1, Math.round(delayMs / 1000))}s${escHint}`,
826
+ );
827
+ await waitAbortable(delayMs, opts.signal);
828
+ continue; // loop top surfaces "Cancelled." when the wait was aborted
829
+ }
830
+ }
802
831
  const message = friendlyProviderError(err);
803
832
  // The error IS the turn's doneReason and every caller displays that — emitting a
804
833
  // separate error event here printed the same message twice (live stream + reply).
@@ -54,6 +54,16 @@ export const GUARD_LIMITS = Object.freeze({
54
54
  MAX_PARSE_BOUNCES: 2,
55
55
  /** Recent-signature window scanned for an A↔B (≤2 distinct calls) cycle. */
56
56
  CYCLE_WINDOW: 6,
57
+ /** Mid-stream transient-network drops (Bun/undici socket-closed, ECONNRESET, stream
58
+ * idle, 5xx, …) tolerated per turn before surfacing a terminal error. Distinct from
59
+ * the refusal ladder: these are infra/transport faults, not content-classifier trips,
60
+ * so a bounded retry (not unbounded backoff) is correct — a persistent failure past
61
+ * this many attempts is a real outage worth surfacing, not spinning on forever. */
62
+ MAX_TRANSIENT_NETWORK_RETRIES: 5,
63
+ /** First transient-network retry wait (ms); doubles per attempt. */
64
+ TRANSIENT_NETWORK_BACKOFF_BASE_MS: 1_000,
65
+ /** Ceiling on the transient-network retry wait (ms). */
66
+ TRANSIENT_NETWORK_BACKOFF_MAX_MS: 15_000,
57
67
  });
58
68
 
59
69
  /**
package/src/util/retry.ts CHANGED
@@ -80,7 +80,18 @@ export function defaultRetryable(err: unknown): boolean {
80
80
  // OVERALL-deadline message ("stream exceeded the overall deadline") is a hard
81
81
  // wall-clock cap and is deliberately NOT matched here (it must fail fast).
82
82
  lowerMessage.includes("stream idle") ||
83
- lowerMessage.includes("no chunk")
83
+ lowerMessage.includes("no chunk") ||
84
+ // A live TCP/TLS socket dying mid-request/mid-stream — Bun's fetch/undici
85
+ // ("The socket connection was closed unexpectedly …", the ConnectionClosed
86
+ // class) and Node's equivalent ("socket hang up", "other side closed",
87
+ // UND_ERR_SOCKET). Field case: OpenAI Codex OAuth SSE streams through
88
+ // chatgpt.com's backend die mid-stream after ~20-30min of active traffic
89
+ // (an infra-side connection-duration cap, not an idle timeout) — the
90
+ // request itself is fine, a fresh connection succeeds.
91
+ lowerMessage.includes("socket connection was closed") ||
92
+ lowerMessage.includes("socket hang up") ||
93
+ lowerMessage.includes("other side closed") ||
94
+ lowerMessage.includes("und_err_socket")
84
95
  ) {
85
96
  return true;
86
97
  }
@@ -284,3 +295,20 @@ export function isUsageLimitError(err: unknown): boolean {
284
295
  export function isRefusalError(err: unknown): boolean {
285
296
  return /stop_reason=refusal|finish_reason=content_filter|\(content_filter\)|(?:block|finish)Reason=(?:SAFETY|PROHIBITED_CONTENT|BLOCKLIST|RECITATION|SPII)|Refusal \(\w+\)/i.test(errorMessageOf(err));
286
297
  }
298
+ /** True for a live TCP/TLS socket dying mid-request/mid-stream — Bun's fetch/undici
299
+ * ("The socket connection was closed unexpectedly …", the ConnectionClosed class)
300
+ * and Node's equivalent ("socket hang up", "other side closed", UND_ERR_SOCKET).
301
+ * DELIBERATELY NARROWER than `defaultRetryable`: a rate-limit/5xx/timeout error has
302
+ * already been through the model-manager's own `withRetry` budget (with visible
303
+ * `onRetry` notices) by the time it reaches the engine, so re-retrying it there would
304
+ * silently double the retry budget. A mid-stream socket drop is structurally
305
+ * DIFFERENT — `retryableStream` (model-manager.ts) only auto-retries losing the FIRST
306
+ * chunk; once any chunk has streamed it deliberately stops (a full re-call would
307
+ * replay already-emitted content), so this class NEVER gets a model-manager retry at
308
+ * all and needs its own engine-level recovery (see `transientNetworkRetries`). Field
309
+ * case: OpenAI Codex OAuth SSE through chatgpt.com's backend dies mid-stream after
310
+ * ~20-30min of active traffic (an infra connection-duration cap, not a broken network —
311
+ * a fresh request succeeds). */
312
+ export function isTransientStreamDropError(err: unknown): boolean {
313
+ return /socket connection was closed|socket hang up|other side closed|und_err_socket/i.test(errorMessageOf(err));
314
+ }