jeo-code 0.7.42 → 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,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.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
+
21
+ ## [0.7.43] - 2026-07-06
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._
23
+
24
+ ### Added
25
+ - **`src/tui/terminal-image.ts`** — protocol detection (`detectImageProtocol`, env/TTY-signal based: `KITTY_WINDOW_ID`/`GHOSTTY_RESOURCES_DIR`/`WEZTERM_PANE`/`ITERM_SESSION_ID`/`TERM_PROGRAM`, plus a `$TERM` fallback for tmux/screen-wrapped kitty sessions), dependency-free dimension parsers for PNG/JPEG/GIF/WEBP/BMP (own implementations against each format's public header spec — not ported from gjc's native Rust decoder), aspect-ratio-preserving cell fitting (`fitImageToCells`, never upscales), and encoders for both protocols (`encodeKittyImage` — chunked at the spec's 4096-byte boundary for large payloads; `encodeIterm2Image` — cell-based `width=`/`height=`, never `"auto"`, so jeo's fit math always agrees with what the terminal actually draws). `JEO_IMAGE_PROTOCOL=kitty|iterm2|none` forces a protocol; `JEO_NO_IMAGE_PREVIEW=1` disables inline rendering outright (same shape as `JEO_NO_MULTILINE`/`JEO_NO_MEMORY`).
26
+ - **`src/tui/components/image-preview.ts`** — bridges `ImageAttachment[]` to the protocol layer: renders each attachment as an inline picture when the terminal/format combination supports it, else a dim `⧉ [mediaType] WxH` text caption (kitty's `f=100` decodes PNG only — a JPEG/GIF/WEBP/BMP attachment on a kitty-protocol terminal falls back to caption; iTerm2 decodes any host-native format, so those always render).
27
+ - **`LaunchTui.flushUserCard(text, images?)`** (`src/tui/app.ts`) — the turn-starting user card now accepts the turn's image attachments and, in inline mode on a supporting terminal, flushes them into scrollback via a new `flushImageAttachments` that bypasses `appendLedger`'s width-wrap path entirely (a raw base64 image escape is not text — `wrapTextWithAnsi`/`visibleWidth` would measure its payload as thousands of display columns and mangle it mid-sequence). Wired from `runTurn` in `src/commands/launch.ts` for the real submitted prompt (a skill run's suppressed/compact card, which never carried the user's own attachments, is unaffected).
28
+
29
+ ### Fixed
30
+ - **`truncateToWidth`** (`src/tui/components/width.ts`) now treats an inline-image escape line (detected via the new `isImageEscapeLine`) as opaque and returns it unchanged — a defensive fix for every current and future call site in the truncation pipeline (`terminal.ts#truncate`, the REPL's `logLines`, `Renderer.render`'s live-frame diff): naive column-counting would otherwise slice into a base64 payload mid-sequence, corrupting the image or leaving an unterminated escape that hangs the terminal waiting for its `ESC \`/BEL terminator.
31
+
32
+ ### Verified
33
+ - `bun run typecheck` clean; `bun test` 2424 pass / 0 fail (34 new: `test/terminal-image.test.ts` — protocol detection incl. env-override/disable/tmux-fallback matrix, all 5 dimension parsers against real per-format headers plus malformed-input rejection, cell-fit aspect-ratio math, both encoders incl. kitty's >4096-byte chunking, the `isImageEscapeLine` guard, and the full `renderInlineImage` row-cursor construction proved line-by-line for both protocols; `test/image-preview.test.ts` — multi-attachment stacking and mixed protocol-support fallback; 3 new `LaunchTui.flushUserCard` cases covering a rendered image, a suppressed-terminal fallback with zero escape leakage, and the already-finished no-op).
34
+ - Manual end-to-end smoke test against the real attached terminal (kitty): generated a genuine 4x4 PNG (real zlib-deflated IDAT, not just a magic-byte fixture), ran it through the actual `renderInlineImage` → wrote the resulting kitty APC sequence to `/dev/tty` — completed without hanging or corrupting the terminal. Repeated with an iTerm2 OSC 1337 sequence sent to the same (non-iTerm2) terminal to confirm an unsupported protocol degrades silently (BEL-terminated, ignored) rather than leaking garbage.
35
+
9
36
  ## [0.7.42] - 2026-07-06
10
37
  _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._
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.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.
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.
262
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.
263
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.
264
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.
265
- - **[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.
266
- - **[0.7.38]** (2026-07-05) — Ponytail review of 0.7.37's Telegram daemon: closes a real trust-boundary gap (any Telegram user could steer/cancel subagents, not just the paired chat) and a `getUpdates`-failure hot-loop, plus bloat/style cleanup — no new surface area, no config changes.
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.
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.
262
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.
263
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.
264
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.
265
- - **[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.
266
- - **[0.7.38]** (2026-07-05) — Ponytail review of 0.7.37's Telegram daemon: closes a real trust-boundary gap (any Telegram user could steer/cancel subagents, not just the paired chat) and a `getUpdates`-failure hot-loop, plus bloat/style cleanup — no new surface area, no config changes.
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.
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.
264
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.
265
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.
266
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.
267
- - **[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.
268
- - **[0.7.38]** (2026-07-05) — Ponytail review of 0.7.37's Telegram daemon: closes a real trust-boundary gap (any Telegram user could steer/cancel subagents, not just the paired chat) and a `getUpdates`-failure hot-loop, plus bloat/style cleanup — no new surface area, no config changes.
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.
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.
262
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.
263
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.
264
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.
265
- - **[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.
266
- - **[0.7.38]** (2026-07-05) — Ponytail review of 0.7.37's Telegram daemon: closes a real trust-boundary gap (any Telegram user could steer/cancel subagents, not just the paired chat) and a `getUpdates`-failure hot-loop, plus bloat/style cleanup — no new surface area, no config changes.
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.42",
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
  /**
@@ -938,7 +938,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
938
938
  // in scrollback. opts.userCard overrides it: null suppresses the card entirely
939
939
  // (skill runs), a string shows a compact label instead of the raw input.
940
940
  const cardText = opts?.userCard === undefined ? userInput : opts.userCard;
941
- if (tui && cardText && cardText.trim()) tui.flushUserCard(cardText);
941
+ // Images render alongside the card only for the real prompt (undefined
942
+ // userCard) — a skill run's compact/suppressed card never carried the
943
+ // user's own attachments in the first place (buildSkillTask calls above
944
+ // never pass `images`), so there is nothing to show either way.
945
+ if (tui && cardText && cardText.trim()) tui.flushUserCard(cardText, opts?.userCard === undefined ? images : undefined);
942
946
  tui?.setContextUsage(historyTokens(history), contextTokens);
943
947
 
944
948
  // Per-turn steering inbox (gjc parity): additional queries typed mid-turn land
package/src/tui/app.ts CHANGED
@@ -38,6 +38,9 @@ import { formatDuration, formatUsage } from "./components/duration";
38
38
  import { renderHud, type JeoPhase } from "./components/hud";
39
39
  import { formatTodoWriteCard } from "./components/todo-card";
40
40
  import { renderInputBox, type HighlightRange } from "./components/input-box";
41
+ import { renderImageAttachments } from "./components/image-preview";
42
+ import { detectImageProtocol } from "./terminal-image";
43
+ import type { ImageAttachment } from "../ai/types";
41
44
  import { jeoEnv } from "../util/env";
42
45
  import chalk from "chalk";
43
46
 
@@ -787,13 +790,35 @@ export class LaunchTui {
787
790
 
788
791
  /** Flush a `user` card into scrollback so a submitted query stays visible there
789
792
  * (gjc parity), instead of only as the transient HUD turn-title / a status notice.
790
- * Shared by the prompt that STARTS a turn and the mid-turn steering flush. */
791
- flushUserCard(text: string): void {
793
+ * Shared by the prompt that STARTS a turn and the mid-turn steering flush.
794
+ * `images` (clipboard/drag-drop/file-path attachments on THIS prompt) render as
795
+ * an inline picture directly below the card on a terminal that speaks the kitty
796
+ * or iTerm2 graphics protocol (gjc TUI-image parity); every other terminal, the
797
+ * alt screen, and non-TTY output keep the existing text-only card unchanged. */
798
+ flushUserCard(text: string, images?: ImageAttachment[]): void {
792
799
  const t = (text ?? "").trim();
793
800
  if (!t || this.finished) return;
794
801
  const cols = Math.max(20, size().cols - 1);
795
802
  const lines = this.renderUserCard(t, cols);
796
803
  if (lines.length) this.appendLedger(lines.join("\n"), "card");
804
+ if (images?.length) this.flushImageAttachments(images, cols);
805
+ }
806
+
807
+ /** Render `images` (see {@link flushUserCard}) straight into scrollback via
808
+ * `insertAbove`, bypassing `appendLedger`'s width-wrap path: an inline image
809
+ * escape sequence is not text — `wrapTextWithAnsi`/`visibleWidth` would measure
810
+ * its base64 payload as thousands of display columns and mangle it. Inline mode
811
+ * only (alt screen has no scrollback to flush into; non-TTY has no terminal to
812
+ * draw into) — every other mode already shows the "N image(s) attached" note
813
+ * inline in the prompt text itself via `[image #N]` tags, unchanged. */
814
+ private flushImageAttachments(images: ImageAttachment[], cols: number): void {
815
+ if (!this.inline || this.finished) return;
816
+ const protocol = detectImageProtocol(process.env, this.tty);
817
+ const muted = this.theme.color ? chalk.dim : (s: string) => s;
818
+ const lines = renderImageAttachments(images, { cols, protocol, muted });
819
+ if (lines.length === 0) return;
820
+ this.recordActivity(`⧉ ${images.length} image(s) rendered`);
821
+ this.renderer.insertAbove(`${lines.join("\n")}\n`);
797
822
  }
798
823
 
799
824
  /** Mid-turn steering query → a `user` card in scrollback (accepted input that is
@@ -0,0 +1,49 @@
1
+ /**
2
+ * User-attached image → scrollback block. Bridges `ImageAttachment[]` (the
3
+ * clipboard/drag-drop/file-path attachments already supported by
4
+ * `util/file-attachment.ts` and `util/clipboard-image.ts`) to the inline-image
5
+ * escape sequences in `terminal-image.ts`, producing one stacked block ready for
6
+ * `Renderer.insertAbove` — gjc TUI-parity: jeo could already ATTACH an image to a
7
+ * turn, this is the missing DISPLAY half (the transcript previously showed only a
8
+ * `⧉ N image(s) attached` count, never the picture itself).
9
+ */
10
+ import type { ImageAttachment } from "../../ai/types";
11
+ import { detectImageProtocol, renderInlineImage, type ImageProtocol } from "../terminal-image";
12
+
13
+ export interface ImagePreviewOptions {
14
+ /** Terminal columns available for the block (already margin-adjusted by the
15
+ * caller, same convention as every other card renderer in `tui/app.ts`). */
16
+ cols: number;
17
+ /** Cap on how many terminal rows a SINGLE image may occupy — keeps one huge
18
+ * screenshot from pushing the whole scrollback off-screen. */
19
+ maxRowsPerImage?: number;
20
+ /** Pre-detected protocol (tests / callers that already resolved it once per
21
+ * process). Defaults to live detection against `process.env`/`process.stdout`. */
22
+ protocol?: ImageProtocol;
23
+ /** Dim/muted painter applied to text captions (fallback path) so they read as
24
+ * secondary content, matching the rest of the TUI's caption styling. */
25
+ muted?: (s: string) => string;
26
+ }
27
+
28
+ /**
29
+ * Render every attachment as a stacked block of lines: an inline picture when the
30
+ * terminal protocol + format support it, else a dim text caption. Multiple
31
+ * attachments stack top-to-bottom, each keeping its own row reservation — see
32
+ * `terminal-image.ts#renderInlineImage` for why each image is self-contained
33
+ * (no dependency on being first/last in the stack).
34
+ */
35
+ export function renderImageAttachments(images: ImageAttachment[], opts: ImagePreviewOptions): string[] {
36
+ if (images.length === 0) return [];
37
+ const protocol = opts.protocol ?? detectImageProtocol(process.env, !!process.stdout.isTTY);
38
+ const maxColumns = Math.max(10, Math.min(60, opts.cols - 4));
39
+ const maxRows = Math.max(4, opts.maxRowsPerImage ?? 20);
40
+ const muted = opts.muted ?? ((s: string) => s);
41
+
42
+ const lines: string[] = [];
43
+ for (const img of images) {
44
+ const result = renderInlineImage(img, protocol, { maxColumns, maxRows });
45
+ if (result.rendered) lines.push(...result.lines);
46
+ else lines.push(...result.lines.map(muted));
47
+ }
48
+ return lines;
49
+ }
@@ -9,6 +9,7 @@
9
9
  * color escapes verbatim (never counting them) so colored/CJK lines truncate and
10
10
  * wrap without tearing an escape or miscounting a wide glyph.
11
11
  */
12
+ import { isImageEscapeLine } from "../terminal-image";
12
13
 
13
14
  const TAB_STOP = 8;
14
15
  // Sticky SGR matcher: scan a heavily color-escaped line in O(n) without slicing.
@@ -96,6 +97,14 @@ export function visibleWidth(s: string): number {
96
97
  export function truncateToWidth(s: string, cols: number): string {
97
98
  const limit = Math.max(0, cols);
98
99
  if (limit === 0) return "";
100
+ // An inline terminal-image escape (Kitty APC / iTerm2 OSC 1337, see
101
+ // `../terminal-image.ts#isImageEscapeLine`) is not text: its "width" by a naive
102
+ // column count is the base64 payload length (thousands of columns), and slicing
103
+ // into the payload corrupts the image or leaves an unterminated escape that
104
+ // hangs the terminal waiting for its `ESC \\`/BEL terminator. The image was
105
+ // already fit to the caller's column budget when the sequence was BUILT, so the
106
+ // correct "truncation" here is always a no-op.
107
+ if (isImageEscapeLine(s)) return s;
99
108
  // Fast path: no escapes, no wide chars, no tabs → plain slice by length.
100
109
  if (!s.includes("\x1b") && !/[\t\u0300-\uffff]/.test(s) && !/[\u{10000}-\u{10ffff}]/u.test(s)) {
101
110
  return s.length <= limit ? s : s.slice(0, limit);
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Inline terminal image protocols (gjc TUI-parity): detect whether the attached
3
+ * terminal can render a real bitmap inline, and encode `ImageAttachment` bytes into
4
+ * the matching escape sequence — Kitty graphics protocol (also implemented by
5
+ * Ghostty and WezTerm) or iTerm2's OSC 1337 inline-image protocol. Sixel is
6
+ * intentionally NOT implemented: producing a sixel stream from arbitrary PNG/JPEG
7
+ * bytes needs a pixel quantizer, which would be jeo's first native/binary
8
+ * dependency — every terminal jeo targets (kitty/ghostty/wezterm/iTerm2) already
9
+ * speaks one of the two protocols below, so a text caption is the correct fallback
10
+ * everywhere else (Terminal.app, most Linux console/VTE terminals, CI/non-TTY).
11
+ *
12
+ * Callers never need to know which protocol was chosen: {@link renderInlineImage}
13
+ * returns either a ready-to-write escape block or `null` (caller shows the caption).
14
+ */
15
+ // Inlined (not imported from `./terminal`) to keep this module free to be imported
16
+ // FROM `./components/width` (see `isImageEscapeLine` below) without a cycle:
17
+ // `./terminal` itself imports `truncateToWidth` from `./components/width`.
18
+ function cursorUp(n: number): string {
19
+ return n > 0 ? `\x1b[${n}A` : "";
20
+ }
21
+
22
+ export enum ImageProtocol {
23
+ Kitty = "kitty",
24
+ Iterm2 = "iterm2",
25
+ None = "none",
26
+ }
27
+
28
+ export type EnvLike = Record<string, string | undefined>;
29
+
30
+ export interface PixelDimensions {
31
+ widthPx: number;
32
+ heightPx: number;
33
+ }
34
+
35
+ export interface CellPixelSize {
36
+ widthPx: number;
37
+ heightPx: number;
38
+ }
39
+
40
+ /** `JEO_IMAGE_PROTOCOL=kitty|iterm2|none` forces the protocol regardless of
41
+ * detection — the escape hatch for a misdetected terminal or a multiplexer that
42
+ * doesn't forward `TERM_PROGRAM`. Unrecognized values are ignored (fall through
43
+ * to detection) rather than silently disabling images. */
44
+ function forcedProtocol(env: EnvLike): ImageProtocol | undefined {
45
+ const raw = env.JEO_IMAGE_PROTOCOL?.trim().toLowerCase();
46
+ if (!raw) return undefined;
47
+ if (raw === "kitty") return ImageProtocol.Kitty;
48
+ if (raw === "iterm2" || raw === "iterm") return ImageProtocol.Iterm2;
49
+ if (raw === "none" || raw === "off" || raw === "0" || raw === "false") return ImageProtocol.None;
50
+ return undefined;
51
+ }
52
+
53
+ /** `JEO_NO_IMAGE_PREVIEW=1` disables inline image rendering outright (same shape
54
+ * as `JEO_NO_MULTILINE`/`JEO_NO_MEMORY`) — every attachment falls back to its text
55
+ * caption. Distinct from `JEO_IMAGE_PROTOCOL=none` only in intent/discoverability;
56
+ * both end at {@link ImageProtocol.None}. */
57
+ export function imagePreviewDisabled(env: EnvLike = process.env): boolean {
58
+ const v = env.JEO_NO_IMAGE_PREVIEW;
59
+ return v === "1" || v === "true";
60
+ }
61
+
62
+ /**
63
+ * Detect the inline-image protocol the attached terminal speaks, from environment
64
+ * signals alone (no terminal query round-trip — keeps startup synchronous and
65
+ * side-effect-free, matching how `detectColorLevel` works). `env`/`isTty` are
66
+ * injectable for tests.
67
+ */
68
+ export function detectImageProtocol(env: EnvLike = process.env, isTty = false): ImageProtocol {
69
+ if (imagePreviewDisabled(env)) return ImageProtocol.None;
70
+ const forced = forcedProtocol(env);
71
+ if (forced !== undefined) return forced;
72
+ if (!isTty) return ImageProtocol.None;
73
+
74
+ if (env.KITTY_WINDOW_ID) return ImageProtocol.Kitty;
75
+ if (env.GHOSTTY_RESOURCES_DIR) return ImageProtocol.Kitty; // Ghostty speaks the kitty graphics protocol
76
+ if (env.WEZTERM_PANE) return ImageProtocol.Kitty; // WezTerm speaks the kitty graphics protocol
77
+ if (env.ITERM_SESSION_ID) return ImageProtocol.Iterm2;
78
+
79
+ const termProgram = (env.TERM_PROGRAM ?? "").toLowerCase();
80
+ if (termProgram === "kitty") return ImageProtocol.Kitty;
81
+ if (termProgram === "ghostty" || termProgram === "wezterm") return ImageProtocol.Kitty;
82
+ if (termProgram === "iterm.app") return ImageProtocol.Iterm2;
83
+
84
+ // tmux/screen strip TERM_PROGRAM but usually preserve $TERM as "tmux-256color" /
85
+ // "screen.xterm-kitty" etc. — fall back to sniffing $TERM for a kitty signature
86
+ // (gjc parity: the same fallback it applies for tmux-wrapped kitty sessions).
87
+ const term = (env.TERM ?? "").toLowerCase();
88
+ if (term.includes("kitty")) return ImageProtocol.Kitty;
89
+ if ((term.startsWith("tmux") || term.startsWith("screen")) && env.KITTY_LISTEN_ON) return ImageProtocol.Kitty;
90
+
91
+ return ImageProtocol.None;
92
+ }
93
+
94
+ let cellPixelSizeOverride: CellPixelSize | undefined;
95
+
96
+ /** Cell pixel size used to convert a target column/row box into scaled pixels for
97
+ * the fit computation. jeo does not query the terminal for real font metrics (that
98
+ * needs a synchronous escape round-trip on startup, which risks hanging on a
99
+ * terminal that never answers) — 9x18 is the common monospace-at-default-size
100
+ * figure (matches gjc's default) and only affects the ASPECT-RATIO fit, not
101
+ * correctness: a wrong guess makes the preview a bit larger/smaller, never
102
+ * corrupts output. Overridable for unusually large/small fonts. */
103
+ export function getCellPixelSize(env: EnvLike = process.env): CellPixelSize {
104
+ if (cellPixelSizeOverride) return cellPixelSizeOverride;
105
+ const w = Number.parseInt(env.JEO_CELL_WIDTH_PX ?? "", 10);
106
+ const h = Number.parseInt(env.JEO_CELL_HEIGHT_PX ?? "", 10);
107
+ return {
108
+ widthPx: Number.isFinite(w) && w > 0 ? w : 9,
109
+ heightPx: Number.isFinite(h) && h > 0 ? h : 18,
110
+ };
111
+ }
112
+
113
+ /** Test-only: pin the cell size so fit-math assertions don't depend on env parsing. */
114
+ export function setCellPixelSizeForTest(size: CellPixelSize | undefined): void {
115
+ cellPixelSizeOverride = size;
116
+ }
117
+
118
+ /** PNG: 8-byte signature + 4-byte length + "IHDR" (4 bytes) precede a big-endian
119
+ * `{width, height}` u32 pair — the format's own spec, fixed offsets 16 and 20. */
120
+ export function getPngDimensions(bytes: Uint8Array): PixelDimensions | null {
121
+ if (bytes.length < 24) return null;
122
+ if (bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) return null;
123
+ const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
124
+ return { widthPx: buf.readUInt32BE(16), heightPx: buf.readUInt32BE(20) };
125
+ }
126
+
127
+ /** JPEG: scan markers for the first SOFn (0xC0-0xC3, 0xC5-0xC7, 0xC9-0xCB, 0xCD-0xCF —
128
+ * excludes 0xC4/0xC8/0xCC, which are DHT/JPG/DAC, not frame headers); height/width
129
+ * are big-endian u16 at offsets 5/7 into that marker's segment. */
130
+ export function getJpegDimensions(bytes: Uint8Array): PixelDimensions | null {
131
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
132
+ const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
133
+ let offset = 2;
134
+ while (offset + 9 < buf.length) {
135
+ if (buf[offset] !== 0xff) { offset++; continue; }
136
+ const marker = buf[offset + 1]!;
137
+ const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
138
+ if (isSof) return { heightPx: buf.readUInt16BE(offset + 5), widthPx: buf.readUInt16BE(offset + 7) };
139
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) { offset += 2; continue; } // no-length markers
140
+ const segLen = buf.readUInt16BE(offset + 2);
141
+ if (segLen < 2) return null;
142
+ offset += 2 + segLen;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /** GIF87a/89a: little-endian u16 width/height at fixed offsets 6/8 in the Logical
148
+ * Screen Descriptor, right after the 6-byte signature. */
149
+ export function getGifDimensions(bytes: Uint8Array): PixelDimensions | null {
150
+ if (bytes.length < 10) return null;
151
+ const sig = Buffer.from(bytes.slice(0, 6)).toString("ascii");
152
+ if (sig !== "GIF87a" && sig !== "GIF89a") return null;
153
+ const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
154
+ return { widthPx: buf.readUInt16LE(6), heightPx: buf.readUInt16LE(8) };
155
+ }
156
+
157
+ /** WebP: RIFF container: dimensions live in the first sub-chunk, whose layout
158
+ * differs by codec — VP8 (lossy, 10-bit width/height at a fixed offset), VP8L
159
+ * (lossless, 14-bit packed into a little-endian u32), VP8X (extended, 24-bit
160
+ * little-endian triples, stored as size-1). */
161
+ export function getWebpDimensions(bytes: Uint8Array): PixelDimensions | null {
162
+ if (bytes.length < 30) return null;
163
+ const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
164
+ if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WEBP") return null;
165
+ const chunk = buf.toString("ascii", 12, 16);
166
+ if (chunk === "VP8 ") {
167
+ return { widthPx: buf.readUInt16LE(26) & 0x3fff, heightPx: buf.readUInt16LE(28) & 0x3fff };
168
+ }
169
+ if (chunk === "VP8L") {
170
+ if (bytes.length < 25) return null;
171
+ const bits = buf.readUInt32LE(21);
172
+ return { widthPx: (bits & 0x3fff) + 1, heightPx: ((bits >> 14) & 0x3fff) + 1 };
173
+ }
174
+ if (chunk === "VP8X") {
175
+ return {
176
+ widthPx: (buf[24]! | (buf[25]! << 8) | (buf[26]! << 16)) + 1,
177
+ heightPx: (buf[27]! | (buf[28]! << 8) | (buf[29]! << 16)) + 1,
178
+ };
179
+ }
180
+ return null;
181
+ }
182
+
183
+ /** BMP: "BM" signature, then a DIB header whose size (u32 at offset 14) picks the
184
+ * layout — the legacy 12-byte BITMAPCOREHEADER stores 16-bit dimensions at
185
+ * offset 18/20; every later header (BITMAPINFOHEADER=40 and newer) stores 32-bit
186
+ * signed dimensions at 18/22 (a negative height means top-down — take `abs`). */
187
+ export function getBmpDimensions(bytes: Uint8Array): PixelDimensions | null {
188
+ if (bytes.length < 26 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) return null;
189
+ const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
190
+ const dibSize = buf.readUInt32LE(14);
191
+ if (dibSize === 12) {
192
+ if (bytes.length < 22) return null;
193
+ return { widthPx: buf.readUInt16LE(18), heightPx: buf.readUInt16LE(20) };
194
+ }
195
+ if (bytes.length < 26) return null;
196
+ return { widthPx: buf.readInt32LE(18), heightPx: Math.abs(buf.readInt32LE(22)) };
197
+ }
198
+
199
+ /** Dispatch to the matching parser by IANA media type; `null` when unrecognized or
200
+ * the bytes are too short/malformed to carry a header. */
201
+ export function getImageDimensions(bytes: Uint8Array, mediaType: string): PixelDimensions | null {
202
+ switch (mediaType) {
203
+ case "image/png": return getPngDimensions(bytes);
204
+ case "image/jpeg": return getJpegDimensions(bytes);
205
+ case "image/gif": return getGifDimensions(bytes);
206
+ case "image/webp": return getWebpDimensions(bytes);
207
+ case "image/bmp": return getBmpDimensions(bytes);
208
+ default: return null;
209
+ }
210
+ }
211
+
212
+ export interface ImageFit {
213
+ columns: number;
214
+ rows: number;
215
+ }
216
+
217
+ /**
218
+ * Scale `dims` to fit within `maxColumns` x `maxRows` terminal cells, preserving
219
+ * aspect ratio (letterboxed, never cropped/stretched). Falls back to a 1:1
220
+ * guess (one column per ~1 cell-width of pixels) when `dims` is unknown so a
221
+ * dimension-less image (parser miss) still gets a sane box instead of a crash.
222
+ */
223
+ export function fitImageToCells(dims: PixelDimensions | null, maxColumns: number, maxRows: number, cell: CellPixelSize): ImageFit {
224
+ const maxCols = Math.max(1, Math.floor(maxColumns));
225
+ const maxRowsClamped = Math.max(1, Math.floor(maxRows));
226
+ if (!dims || dims.widthPx <= 0 || dims.heightPx <= 0) {
227
+ return { columns: maxCols, rows: maxRowsClamped };
228
+ }
229
+ const maxWidthPx = maxCols * cell.widthPx;
230
+ const maxHeightPx = maxRowsClamped * cell.heightPx;
231
+ const scale = Math.min(maxWidthPx / dims.widthPx, maxHeightPx / dims.heightPx, 1);
232
+ const fittedWidthPx = dims.widthPx * scale;
233
+ const fittedHeightPx = dims.heightPx * scale;
234
+ return {
235
+ columns: Math.max(1, Math.min(maxCols, Math.round(fittedWidthPx / cell.widthPx))),
236
+ rows: Math.max(1, Math.min(maxRowsClamped, Math.ceil(fittedHeightPx / cell.heightPx))),
237
+ };
238
+ }
239
+
240
+ const KITTY_CHUNK_SIZE = 4096;
241
+
242
+ /**
243
+ * Encode PNG bytes (base64) as a kitty graphics protocol APC sequence, chunked at
244
+ * 4096 base64 bytes per the spec (a payload above that MUST be split across
245
+ * multiple `m=1`-continued transmissions, terminated by one `m=0`). `a=T`
246
+ * (transmit-and-display in one step), `f=100` (PNG — the only compressed format
247
+ * the protocol decodes itself), `q=2` (suppress the terminal's OK/error response,
248
+ * which jeo never reads).
249
+ */
250
+ export function encodeKittyImage(base64Png: string, fit: ImageFit): string {
251
+ const params = `a=T,f=100,q=2,c=${fit.columns},r=${fit.rows}`;
252
+ if (base64Png.length <= KITTY_CHUNK_SIZE) {
253
+ return `\x1b_G${params};${base64Png}\x1b\\`;
254
+ }
255
+ const parts: string[] = [];
256
+ for (let offset = 0; offset < base64Png.length; offset += KITTY_CHUNK_SIZE) {
257
+ const chunk = base64Png.slice(offset, offset + KITTY_CHUNK_SIZE);
258
+ const isFirst = offset === 0;
259
+ const isLast = offset + KITTY_CHUNK_SIZE >= base64Png.length;
260
+ const head = isFirst ? `${params},m=${isLast ? 0 : 1}` : `m=${isLast ? 0 : 1}`;
261
+ parts.push(`\x1b_G${head};${chunk}\x1b\\`);
262
+ }
263
+ return parts.join("");
264
+ }
265
+
266
+ /**
267
+ * Encode arbitrary image bytes (base64, any format iTerm2's host OS decodes —
268
+ * PNG/JPEG/GIF all included) as an OSC 1337 inline-image sequence. `inline=1`
269
+ * renders in place instead of downloading; `width`/`height` are given in CELLS
270
+ * (not "auto") so iTerm2's own scaling always agrees with jeo's precomputed
271
+ * {@link ImageFit} — the row-accounting in the caller depends on that agreement.
272
+ */
273
+ export function encodeIterm2Image(base64Data: string, fit: ImageFit, name?: string): string {
274
+ const params = [`inline=1`, `width=${fit.columns}`, `height=${fit.rows}`, "preserveAspectRatio=1"];
275
+ if (name) params.push(`name=${Buffer.from(name).toString("base64")}`);
276
+ return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
277
+ }
278
+
279
+ /** True when `line` contains an inline-image escape sequence (Kitty APC or
280
+ * iTerm2 OSC 1337) within its first 96 characters — a possible leading
281
+ * `cursorUp(...)` prefix (see {@link renderInlineImage}) means the marker is not
282
+ * always at offset 0. Callers that measure/truncate/wrap terminal lines by
283
+ * DISPLAY WIDTH (`visibleWidth`, `truncateToWidth`, `wrapTextWithAnsi` in
284
+ * `components/width.ts`) MUST treat a line matching this as opaque and pass it
285
+ * through unchanged: the base64 payload is thousands of columns "wide" by a
286
+ * naive count, and slicing mid-sequence corrupts or hangs the terminal (an
287
+ * unterminated Kitty APC / OSC waits indefinitely for its `ESC \\` / BEL
288
+ * terminator). Not currently wired into `width.ts` — every current image call
289
+ * site (`insertAbove`) bypasses that pipeline entirely; exported so a future
290
+ * call site can opt in without re-deriving the detection. */
291
+ export function isImageEscapeLine(line: string): boolean {
292
+ const head = line.slice(0, 96);
293
+ return head.includes("\x1b_G") || head.includes("\x1b]1337;File=");
294
+ }
295
+
296
+ /** Human-readable stand-in for an attachment that can't be rendered inline
297
+ * (unsupported terminal, unsupported format for the detected protocol, or an
298
+ * undecodable dimension header). Mirrors the existing `⧉ N image(s) attached`
299
+ * wording style used elsewhere in the TUI. */
300
+ export function imageCaption(mediaType: string, dims: PixelDimensions | null, filename?: string): string {
301
+ const parts = [filename, `[${mediaType}]`, dims ? `${dims.widthPx}x${dims.heightPx}` : undefined].filter(Boolean);
302
+ return `⧉ ${parts.join(" ")}`;
303
+ }
304
+
305
+ export interface InlineImageResult {
306
+ /** Lines to feed into `Renderer.insertAbove` (or join with "\n" for a plain
307
+ * `console.log`/pipe path). The array length equals the image's rendered
308
+ * terminal-row footprint — every element is one physical row — so a caller
309
+ * computing "rows written" from `lines.length` (as `insertAbove` does by
310
+ * splitting on "\n") gets the correct height. */
311
+ lines: string[];
312
+ /** True when `lines` is a real inline image; false when it is a 1-line text
313
+ * caption (unsupported terminal/format, or an unparsable header). */
314
+ rendered: boolean;
315
+ }
316
+
317
+ /**
318
+ * Build the escape sequence (or text caption) for one image attachment, sized to
319
+ * fit `maxColumns` x `maxRows` cells.
320
+ *
321
+ * Row bookkeeping (why this shape, not just "the sequence + rows-1 blank lines"):
322
+ * both protocols draw the image ANCHORED AT THE CURSOR'S ROW at the moment the
323
+ * escape is processed and expand DOWNWARD — they do not retroactively fill rows
324
+ * already scrolled past. So the cursor must already sit at the image's TOP row
325
+ * before the escape is written. `lines` therefore reserves `fit.rows - 1` blank
326
+ * rows FIRST (each later joined with "\n", which is what actually advances the
327
+ * cursor down — matches every other multi-row block `insertAbove` is fed), then a
328
+ * final row that rewinds the cursor back up to the reserved block's top
329
+ * (`cursorUp(fit.rows - 1)`) immediately before the escape sequence.
330
+ *
331
+ * After drawing, Kitty/kitty-compatible terminals leave the REAL cursor
332
+ * `fit.rows - 1` rows below the top (i.e. on the image's last row) — added to the
333
+ * caller's mandatory trailing "\n" (one per `InlineImageResult`, not emitted
334
+ * here), that lands the cursor exactly `fit.rows` rows below where it started,
335
+ * matching a plain `fit.rows`-line block of text. iTerm2 instead auto-advances
336
+ * PAST the image (`fit.rows` rows below the top), so a synthetic `cursorUp(1)`
337
+ * is appended after its escape to cancel that extra row and reach the same
338
+ * `fit.rows - 1` offset — keeping both protocols' net row math identical.
339
+ */
340
+ export function renderInlineImage(
341
+ attachment: { mediaType: string; data: string },
342
+ protocol: ImageProtocol,
343
+ opts: { maxColumns: number; maxRows: number; filename?: string } = { maxColumns: 60, maxRows: 20 },
344
+ ): InlineImageResult {
345
+ const bytes = Buffer.from(attachment.data, "base64");
346
+ const dims = getImageDimensions(bytes, attachment.mediaType);
347
+ const caption = () => ({ lines: [imageCaption(attachment.mediaType, dims, opts.filename)], rendered: false });
348
+
349
+ if (protocol === ImageProtocol.None) return caption();
350
+ if (protocol === ImageProtocol.Kitty && attachment.mediaType !== "image/png") return caption(); // f=100 decodes PNG only
351
+ if (!dims) return caption(); // no protocol can size an image it can't measure
352
+
353
+ const cell = getCellPixelSize();
354
+ const fit = fitImageToCells(dims, opts.maxColumns, opts.maxRows, cell);
355
+ const seq =
356
+ protocol === ImageProtocol.Kitty
357
+ ? encodeKittyImage(attachment.data, fit)
358
+ : encodeIterm2Image(attachment.data, fit, opts.filename) + cursorUp(1); // cancel iTerm2's extra auto-advance row
359
+
360
+ const blankRows = Math.max(0, fit.rows - 1);
361
+ const lines = [...Array(blankRows).fill(""), cursorUp(blankRows) + seq];
362
+ return { lines, rendered: true };
363
+ }
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
+ }