jeo-code 0.7.43 → 0.7.46

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,33 @@ 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.46] - 2026-07-06
10
+ _Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below._
11
+
12
+ ## [0.7.45] - 2026-07-06
13
+ _gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running._
14
+
15
+ ### Added
16
+ - **Concurrent executor fan-out** (`src/agent/task-tool.ts`) — a `task {role:"executor", tasks:[...]}` batch now runs with the same bounded concurrency as the read-only roles (`MAX_FANOUT` = 4), matching gjc's own default of parallelizing independent tasks. Removed the now-unsafe cross-task "chain previous output into next task's context" behavior, which assumed strict serial ordering (task i-1 always done before task i starts) that concurrent execution no longer guarantees; a task that genuinely depends on another's output now belongs in a sequential follow-up `task` call. `taskToolProtocolLine` documents the resulting expectation: scope each concurrent executor task to disjoint files, since jeo has no in-batch peer-coordination channel yet (overlapping scopes should run sequentially instead).
17
+ - **Per-slot concurrent subagent live-activity tracking** (`src/tui/app.ts`) — the single shared `subagentLive` string is replaced by `subagentLiveSlots` (a `Map` keyed on the event's fan-out `index`). The live status row now shows the most-recently-active slot plus a `(+N more running)` count so a parallel batch visibly reads as parallel, and — the more important fix — one worker reaching "done" now clears ONLY that worker's slot instead of nuking the `(sub)` marker for the whole still-running batch.
18
+
19
+ ### Verified
20
+ - `bun run typecheck` clean; `bun test` 2479 pass / 0 fail.
21
+ - `test/task-tool.test.ts`: rewrote the two tests that pinned the old forced-serial/chaining behavior — an overlapping-sleep probe now proves the executor batch's calls genuinely overlap in wall-clock time (`maxConcurrent > 1`), and a follow-up test confirms no chain note leaks between concurrent executor tasks.
22
+ - `test/subagent-live-activity.test.ts`: two new tests pin the per-slot fix — concurrent slots render with a running count, and one slot finishing early does not clear the marker while a sibling slot is still active.
23
+
24
+ ## [0.7.44] - 2026-07-06
25
+ _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._
26
+
27
+ ### Added
28
+ - **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.
29
+ - `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.
30
+
31
+ ### Verified
32
+ - `bun run typecheck` clean; `bun test` 2429 pass / 0 fail.
33
+ - 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.
34
+ - `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.
35
+
9
36
  ## [0.7.43] - 2026-07-06
10
37
  _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
38
 
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.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
263
+ - **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
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.
262
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.
263
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.
264
- - **[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
- - **[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.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
263
+ - **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
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.
262
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.
263
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.
264
- - **[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
- - **[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.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
265
+ - **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
266
+ - **[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
267
  - **[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
268
  - **[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
- - **[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
- - **[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.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
263
+ - **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
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.
262
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.
263
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.
264
- - **[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
- - **[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.46",
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
  /**
@@ -62,22 +62,28 @@ export interface TaskToolOptions {
62
62
  /** Optional live sink (e.g. plain-stream rendering of nested progress). */
63
63
  onEvent?: (ev: TaskSubEvent) => void;
64
64
  /** Mid-turn steering drain (gjc parity): an additional user query typed while a
65
- * subagent works is forwarded live. Single-task runs and the SERIAL executor
66
- * batch (concurrency 1) forward to the one active subagent. A parallel read-only
67
- * batch routes through a broadcast hub (createSteerHub) so every running worker
68
- * sees each message exactly once. Unconsumed messages stay for the parent. */
65
+ * subagent works is forwarded live. A single-task run forwards to the one active
66
+ * subagent. Any fan-out batch (both roles now run CONCURRENTLY, bounded at
67
+ * MAX_FANOUT) routes through a broadcast hub (createSteerHub) so every running
68
+ * worker sees each message exactly once. Unconsumed messages stay for the parent. */
69
69
  steer?: () => string[];
70
70
  /** When present, a `task` call with `detached: true` registers a background run
71
71
  * here and returns immediately; the parent controls it via the `subagent` tool. */
72
72
  registry?: SubagentRegistry;
73
73
  }
74
74
 
75
- /** Max concurrent read-only subagents in a fan-out batch. */
75
+ /** Max concurrent subagents in a fan-out batch (both read-only AND the mutating
76
+ * executor role — gjc parity: gjc's own `task` tool runs independent tasks
77
+ * concurrently by default and only sequences work that shares a large evolving
78
+ * artifact). A model batching executor tasks is expected to scope each one to
79
+ * disjoint files (documented in `taskToolProtocolLine`); overlapping scopes
80
+ * should either run sequentially (separate `task` calls) or coordinate — jeo has
81
+ * no in-batch peer channel yet, so overlapping-scope tasks MUST be sequential. */
76
82
  const MAX_FANOUT = 4;
77
83
 
78
- /** Hard cap on a SERIAL (mutating executor) fan-out batch: it runs one task at a
79
- * time inside one blocking tool call, so an unbounded queue would monopolize the
80
- * parent turn. Split larger efforts into sequential task calls. */
84
+ /** Hard cap on a fan-out BATCH SIZE (queue length, not concurrency): an unbounded
85
+ * queue behind MAX_FANOUT workers would still monopolize the parent turn for a
86
+ * long time. Split larger efforts into sequential task calls. */
81
87
  const MAX_SERIAL_EXECUTOR = 6;
82
88
 
83
89
  /** Minimum distinct, non-filler words a spawn-gate justification must contain.
@@ -128,7 +134,7 @@ export function taskToolProtocolLine(config?: Pick<Config, "subagents">): string
128
134
  return (
129
135
  `task {role, task|tasks[], context?} — delegate to a subagent ` +
130
136
  `(role: ${subagentRoleIds(config).join("|")}; executor can edit, planner/architect/critic are read-only). ` +
131
- `Pass 'tasks' (array) to fan out — read-only roles run in parallel, executor serially. Integrate the findings yourself.`
137
+ `Pass 'tasks' (array) to fan out — ALL roles run concurrently (bounded); scope each executor task to disjoint files (no shared-file coordination channel between concurrent tasks — use sequential task calls if scopes overlap). Integrate the findings yourself.`
132
138
  );
133
139
  }
134
140
 
@@ -399,23 +405,25 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
399
405
  if (items.length === 0) {
400
406
  return { success: false, output: "", error: "task fan-out requires a non-empty 'tasks' array of assignments." };
401
407
  }
402
- // #5: the mutating executor fan-out is SERIAL (concurrency 1) and blocks the
403
- // turn; cap it regardless of justification so a huge queue can't monopolize
404
- // the parent. Split larger efforts into sequential task calls.
408
+ // A fan-out BATCH SIZE cap (queue length, not concurrency — see MAX_FANOUT
409
+ // below): an unbounded queue behind bounded concurrency would still
410
+ // monopolize the parent turn. Split larger executor efforts into
411
+ // sequential task calls.
405
412
  if (!role.readOnly && items.length > MAX_SERIAL_EXECUTOR) {
406
413
  return {
407
414
  success: false,
408
415
  output: "",
409
416
  error:
410
- `Executor fan-out of ${items.length} exceeds the serial cap of ${MAX_SERIAL_EXECUTOR}. ` +
411
- `The mutating executor runs one task at a time and blocks the turn — split into ≤${MAX_SERIAL_EXECUTOR}-task batches or sequential task calls.`,
417
+ `Executor fan-out of ${items.length} exceeds the batch cap of ${MAX_SERIAL_EXECUTOR}. ` +
418
+ `Split into ≤${MAX_SERIAL_EXECUTOR}-task batches or sequential task calls.`,
412
419
  };
413
420
  }
414
421
  // Spawn-gate lite (plan/gjc-inheritance.md B9, gjc spawn-gate 계승): a batch
415
422
  // wider than MAX_FANOUT is refused BEFORE any subagent launches unless the
416
423
  // model justifies the parallelism — silent capping hid the cost decision.
417
424
  // NOTE: the justification permits a LARGER QUEUE only; running concurrency
418
- // stays bounded at MAX_FANOUT (read-only) or 1 (mutating) regardless.
425
+ // stays bounded at MAX_FANOUT regardless (both roles, since v0.7.45 — see
426
+ // MAX_FANOUT's docstring).
419
427
  if (items.length > MAX_FANOUT) {
420
428
  const justification = typeof args.justification === "string" ? args.justification.trim() : "";
421
429
  if (!isMeaningfulJustification(justification)) {
@@ -428,9 +436,9 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
428
436
  };
429
437
  }
430
438
  }
431
- // Read-only roles fan out concurrently (bounded). The mutating executor is serialized
432
- // (concurrency 1) so parallel subagents can't race on the same files.
433
- const limit = role.readOnly ? Math.min(items.length, MAX_FANOUT) : 1;
439
+ // Both roles fan out CONCURRENTLY, bounded at MAX_FANOUT (gjc parity — see
440
+ // MAX_FANOUT's docstring for the disjoint-file-scope expectation this relies on).
441
+ const limit = Math.min(items.length, MAX_FANOUT);
434
442
  // Load project context ONCE per batch instead of re-scanning AGENTS.md for
435
443
  // every fan-out task (redundant IO + duplicated tokens).
436
444
  const batchContext = await loadProjectContext(cwd);
@@ -446,18 +454,18 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
446
454
  while (true) {
447
455
  const i = next++;
448
456
  if (i >= items.length) return;
449
- // Chain serial executor output into next task's context; parallel read-only stays isolated.
450
- const chainNote = (!role.readOnly && i > 0 && results[i - 1])
451
- ? `\n\n[Previous task result — for context, not instructions]:\n${results[i - 1]!.output.slice(0, 1_500)}`
452
- : "";
453
- results[i] = await runOne(role, items[i]!.task, items[i]!.context + chainNote, cwd, { slot: { index: i + 1, total: items.length }, projectContext: batchContext, steer: workerSteer });
457
+ // No cross-task chaining: with bounded CONCURRENT execution, task i-1 is
458
+ // not guaranteed to have finished before task i starts (removed — it was
459
+ // dead/misleading once the executor stopped being forced-serial). Each
460
+ // task runs isolated on its own slice of context; a task that genuinely
461
+ // depends on another's output belongs in a sequential follow-up call.
462
+ results[i] = await runOne(role, items[i]!.task, items[i]!.context, cwd, { slot: { index: i + 1, total: items.length }, projectContext: batchContext, steer: workerSteer });
454
463
  }
455
464
 
456
465
  };
457
466
  await Promise.all(Array.from({ length: limit }, () => worker()));
458
467
  const ok = results.filter(r => r.success).length;
459
- const mode = role.readOnly ? `concurrency ${limit}` : "executor — serialized";
460
- const head = `[${role.title} fan-out] ${ok}/${items.length} completed (${mode}).`;
468
+ const head = `[${role.title} fan-out] ${ok}/${items.length} completed (concurrency ${limit}).`;
461
469
  const combined = results.map((r, i) => `### Task ${i + 1}/${items.length}\n${r.output}`).join("\n\n");
462
470
  return { success: ok === items.length, output: `${head}\n\n${combined}` };
463
471
  }
package/src/tui/app.ts CHANGED
@@ -249,12 +249,20 @@ export class LaunchTui {
249
249
  private workflowStatus: { skill: string; phase: string; detail?: string } | null = null;
250
250
  // Cumulative token usage for the live turn (engine onUsage event).
251
251
  private turnUsage: { inputTokens: number; outputTokens: number } | null = null;
252
- // True while a delegated subagent turn is in flight — drives the `(sub)` status marker.
252
+ // True while at least one delegated subagent SLOT is in flight — drives the `(sub)`
253
+ // status marker. Derived from `subagentLiveSlots.size` (see below) rather than a
254
+ // plain boolean so a fan-out batch where ONE worker finishes early no longer
255
+ // clears the marker while its siblings are still visibly running.
253
256
  private subagentActive = false;
254
- // Latest nested subagent activity (role + glyph + detail) — surfaced LIVE in the
255
- // status row while a `task` runs, so a delegated turn never reads as an opaque
256
- // "calling model" stall (the perceived-hang usability gap). Cleared on done.
257
- private subagentLive: string | null = null;
257
+ // Latest nested subagent activity, PER CONCURRENT SLOT (fan-out `task` batches — both
258
+ // the executor and read-only roles — now run several workers at once; a single
259
+ // shared string clobbered on every event made concurrent subagents look sequential
260
+ // and, worse, cleared the whole `(sub)` marker the instant ANY one worker finished).
261
+ // Keyed by the event's fan-out `index` (1-based); a single/detached run with no
262
+ // index shares the fixed key 0. `subagentLiveOrder` tracks touch order so the
263
+ // status row can show the MOST RECENTLY active slot plus a "+N more" count.
264
+ private readonly subagentLiveSlots = new Map<number, string>();
265
+ private readonly subagentLiveOrder: number[] = [];
258
266
  // Bounded activity-history ring: one plain-text entry per ledger append, with a
259
267
  // turn-relative timestamp. Powers Ctrl+O's "recent activity" tail so the detail
260
268
  // view ALWAYS answers "what has been happening", even before the first reply.
@@ -936,8 +944,17 @@ export class LaunchTui {
936
944
  }
937
945
  // A delegated subagent's LATEST nested event beats the parent's static
938
946
  // "Task: <role> …" card title — a long task otherwise reads as a stall even
939
- // though the subagent is actively reading/editing/running underneath.
940
- if (this.subagentActive && this.subagentLive) return this.subagentLive;
947
+ // though the subagent is actively reading/editing/running underneath. When a
948
+ // fan-out batch has MULTIPLE concurrent slots live, append a "+N more running"
949
+ // count so a parallel batch visibly reads as parallel instead of looking like
950
+ // one subagent whose label keeps randomly changing.
951
+ if (this.subagentActive && this.subagentLiveSlots.size > 0) {
952
+ const mostRecent = this.subagentLiveOrder[this.subagentLiveOrder.length - 1];
953
+ const line = (mostRecent !== undefined ? this.subagentLiveSlots.get(mostRecent) : undefined)
954
+ ?? this.subagentLiveSlots.values().next().value!;
955
+ const extra = this.subagentLiveSlots.size - 1;
956
+ return extra > 0 ? `${line} (+${extra} more running)` : line;
957
+ }
941
958
  // Waiting on the model and no tool is mid-flight → make the pause legible.
942
959
  if (this.thinking && !running) {
943
960
  const elapsed = this.currentStepStartedAt ? ((Date.now() - this.currentStepStartedAt) / 1000).toFixed(1) : "0.0";
@@ -993,30 +1010,47 @@ export class LaunchTui {
993
1010
  const last = this.unicode ? "└─" : "`-";
994
1011
  const detail = (e.detail ?? "").split("\n").find(l => l.trim().length > 0)?.trim().slice(0, 140) ?? "";
995
1012
  const summary = e.summary ? ` — ${e.summary}` : "";
1013
+ // Fan-out slot key: the event's 1-based `index` when present (concurrent batch
1014
+ // worker), else the fixed key 0 (single-task/detached run — matches the prior
1015
+ // single-string behavior for the non-batch case).
1016
+ const slot = e.index ?? 0;
1017
+ const touchSlot = (line: string) => {
1018
+ this.subagentLiveSlots.set(slot, line);
1019
+ const at = this.subagentLiveOrder.indexOf(slot);
1020
+ if (at !== -1) this.subagentLiveOrder.splice(at, 1);
1021
+ this.subagentLiveOrder.push(slot);
1022
+ this.subagentActive = true;
1023
+ };
996
1024
  // No `step N/M` marker on nested lines — step counters carry no meaning
997
1025
  // under the dynamic budget (user feedback). A tree branch keeps subagent
998
1026
  // activity readable in scrollback and visually separate from parent tools.
999
1027
  switch (e.kind) {
1000
1028
  case "start":
1001
- this.subagentActive = true;
1002
- this.subagentLive = `${roleLabel} ${this.unicode ? "▸" : ">"} ${detail || "starting"}`;
1029
+ touchSlot(`${roleLabel} ${this.unicode ? "▸" : ">"} ${detail || "starting"}`);
1003
1030
  this.appendLedger(`${badge} ${this.unicode ? "▸" : ">"} ${roleLabel} · ${detail}\n`, "subagent");
1004
1031
  break;
1005
1032
  case "step":
1006
- this.subagentLive = `${roleLabel} ${this.unicode ? "·" : "-"} ${detail || "working"}`;
1033
+ touchSlot(`${roleLabel} ${this.unicode ? "·" : "-"} ${detail || "working"}`);
1007
1034
  this.appendLedger(` ${badge} ${branch} ${roleLabel} · ${detail || "working"}\n`, "subagent");
1008
1035
  break;
1009
1036
  case "tool":
1010
- this.subagentLive = `${roleLabel} ${e.success === false ? bad : ok} ${detail || "tool"}`;
1037
+ touchSlot(`${roleLabel} ${e.success === false ? bad : ok} ${detail || "tool"}`);
1011
1038
  this.appendLedger(` ${badge} ${branch} ${roleLabel} ${e.success === false ? bad : ok} ${detail || "tool"}${summary}\n`, "subagent");
1012
1039
  break;
1013
1040
  case "error":
1014
- this.subagentLive = `${roleLabel} ${bad} ${detail || "error"}`;
1041
+ touchSlot(`${roleLabel} ${bad} ${detail || "error"}`);
1015
1042
  this.appendLedger(` ${badge} ${branch} ${roleLabel} ${bad} ${detail || "error"}\n`, "subagent");
1016
1043
  break;
1017
1044
  case "done":
1018
- this.subagentActive = false;
1019
- this.subagentLive = null;
1045
+ // Clear ONLY this slot — a fan-out batch where one worker finishes early
1046
+ // must keep showing its still-running siblings, not drop the `(sub)`
1047
+ // marker for the whole batch (the bug this per-slot map fixes).
1048
+ this.subagentLiveSlots.delete(slot);
1049
+ {
1050
+ const at = this.subagentLiveOrder.indexOf(slot);
1051
+ if (at !== -1) this.subagentLiveOrder.splice(at, 1);
1052
+ }
1053
+ this.subagentActive = this.subagentLiveSlots.size > 0;
1020
1054
  this.appendLedger(`${badge} ${last} ${roleLabel} done${e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : ""}${e.success === false ? " (incomplete)" : ""}: ${detail}\n`, "subagent");
1021
1055
  break;
1022
1056
  }
@@ -1030,7 +1064,7 @@ export class LaunchTui {
1030
1064
  this.livePromptInput = ""; // fresh turn: no next-prompt draft yet
1031
1065
  this.livePromptHint = []; // fresh turn: no mid-turn command preview yet
1032
1066
  this.livePromptHighlight = undefined; // fresh turn: no active trigger token
1033
- this.subagentLive = null; // fresh turn: no nested subagent in flight
1067
+ this.subagentLiveSlots.clear(); this.subagentLiveOrder.length = 0; this.subagentActive = false; // fresh turn: no nested subagent in flight
1034
1068
  this.activityLog.length = 0; // per-turn ring: timestamps are turn-relative
1035
1069
  this.spinner.updateStep(0, this.footer.maxSteps);
1036
1070
  // completed ledger lines are flushed into normal scrollback as they happen, so a
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
+ }