Skip to content

fix: submit image prompts the TUI wrapped, and stop a slow db-path lookup from killing OpenCode's durable channel - #1115

Merged
Juliusolsson05 merged 3 commits into
mainfrom
fix/image-absorption-wrap-and-dbpath-retry
Sep 22, 2026
Merged

Juliusolsson05 merged 3 commits into
mainfrom
fix/image-absorption-wrap-and-dbpath-retry

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Closes #1113. Carries the package fix for #1114 (Juliusolsson05/opencode-terminal-headless#6).

Both bugs were diagnosed from the debug bundle the owner captured at the moment they happened (2026-09-21T22-09-23-834-168b7a32, note: "Sending logic broke") plus the app's own incident log.


1. Claude: a word-wrapped [Image #N] pill makes image prompts fail as "never submitted"

22:06:59.341  submit.begin   {provider: claude, source: with-images}
22:07:00.360  input.write    {origin: delivery, writes: 3, bytes: 178}
22:07:04.369  submit.result  {ok:false, code:"absorption-timeout", stage:"absorption",
                              bodyWritten:true, enterWritten:false, retryable:false, durationMs:5027}

The prompt and the image were in the composer. The recorded screen at that instant:

❯ Lets figure out why this is happening wiht opencode [Image
  #1]

imagePlaceholderCount matched /\[Image #\d+\]/ against the raw composer. The pill has exactly one internal space, and a word wrap can only break a line at a space — so when the pill lands on the wrap column, neither half matches. The count never rises above its baseline, pollClaudeImagesAbsorbed spends its whole 5 s budget, and delivery returns absorption-timeout with retrySafe: false. The user gets "the prompt was never submitted — resend is blocked" and has to clear the agent's composer by hand.

Counted across every frame in the bundle:

frames literal match whitespace-normalized
seq 21–23 (the failing send) 0 1
seq 51–59 (the send that worked) 1–2 1–2

Same pane, same session, same terminal width, ten minutes apart. The only difference is where that one space fell — which is why this presented as intermittent rather than as a broken feature.

pasteAbsorbedVia, the text half of the same protocol, has always compared against normalizeWhitespace(screen). That is precisely why wrapped text sends never had this problem. The two halves had drifted; that inconsistency was the defect. Both placeholder counters now normalize.

placeholderCount is fixed by analogy, not from a recording — and its failure mode is worse: [Pasted text #1] has two spaces to wrap at, and a collapsed paste has no inline tail to fall back on, so a missed placeholder there is a guaranteed timeout with the draft stranded.

Tests

testing/fixtures/image-absorption/ holds two real baseline/after pairs from the bundle — one wrapped delivery that failed, one unwrapped delivery that succeeded — and promptDelivery.imageWrap.recorded.test.ts replays them through deliverClaudePrompt, the real main-process entry point, stubbing only the PTY sink and the JSONL acceptance observer.

This is a recorded test rather than a screen literal on purpose: anyone writing a fake screen puts [Image #1] on one line, which is exactly the case that always worked. A literal would have passed against the broken detector.

Fail-first was verified: before the fix the wrapped case failed at 5025 ms with absorption-timeout while unwrapped passed — so the test isolates the wrap and nothing else.


2. OpenCode: a 20 s db-path timeout disabled the durable channel permanently

OpenCode durable channel (db_path_unavailable):
`…/runtime/opencode/darwin-arm64/opencode db path` failed: Command failed: …/opencode db path

The binary is fine — I measured it at 0.37 s warm, 2.15 s cold. The app's incident log shows the timeout firing:

when provider.start.end (opencode)
2026-09-20T22:52:30.453Z 20087 ms (= the 20 000 ms budget + overhead)
2026-09-21T17:14:03.303Z 24095 ms
2026-09-21T17:14:03.304Z 23830 ms

Healthy runs in the same log: 4–2300 ms.

Full analysis and the fix are in the package PR. In short: a ~143 MB Bun binary started several times over during a multi-pane restore overruns a flat 20 s budget; execFile's kill yields Command failed: <cmd> with empty stderr (hence the unactionable banner); the resolver memoises so every pane in the window shares the one rejection (both panes above report in the same millisecond); and openDurable() treated a null path as terminal, so one slow startup cost those panes their committed transcript until the app restarted.

The package now distinguishes a timeout from a bad install from a spawn failure in the message, and retries the lookup in the background on its own ladder. This repo's side is one thing: passing the resolver, since the app owns every child process — the package never spawns one.


Verification

🤖 Generated with Claude Code

…rompts submit

Sending a prompt with an attachment could end with "The prompt was never
submitted — it is sitting unsent in the agent's composer", for a prompt whose
text and image were both sitting complete in Claude's composer.

From the bundle the owner captured while it was failing:

    22:06:59.341  submit.begin   {provider: claude, source: with-images}
    22:07:00.360  input.write    {origin: delivery, writes: 3, bytes: 178}
    22:07:04.369  submit.result  {ok:false, code:"absorption-timeout",
                                  bodyWritten:true, enterWritten:false,
                                  retryable:false, durationMs:5027}

and the recorded composer at that instant:

    ❯ Lets figure out why this is happening wiht opencode [Image
      #1]

`imagePlaceholderCount` matched `/\[Image #\d+\]/` against the raw composer.
The pill has exactly one internal space and a word wrap can only break at a
space, so whenever it landed on the wrap column the count never rose,
`pollClaudeImagesAbsorbed` spent its whole 5 s budget and delivery returned
`absorption-timeout` with `retrySafe: false`. Counted across every recorded
frame: 0 literal / 1 normalized through the failing send, 1-2 / 1-2 through
the one that worked — same pane, same session, same width, ten minutes apart.
That is why it looked intermittent rather than broken.

The text half of the same protocol, `pasteAbsorbedVia`, has always compared
against `normalizeWhitespace(screen)`, which is exactly why wrapped TEXT sends
never carried this bug. The two halves were inconsistent; that inconsistency
was the defect. Both placeholder counters now normalize.

`placeholderCount` is fixed by analogy rather than from a recording, and its
failure mode is worse: `[Pasted text #1]` has TWO spaces to wrap at, and a
collapsed paste has no inline tail to fall back on, so a missed placeholder is
a guaranteed timeout with the draft stranded.

Also wires the host-owned db-path resolver the package now asks for (#1114).

Fixes #1113.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e debug probes contradicting production

Adversarial review found no behavioural defect in the wrap fix — the premise
holds, normalization is monotone so it can only add matches, and a sweep of
10,263 recorded frames from 72 debug bundles found exactly 4 divergences, all
of them the bug frames. Both reviewers independently verified the recorded test
is fail-first and that the fixture is byte-identical to its source bundle.

What they did find were comments asserting things that are false, in a repo
whose CLAUDE.md makes the comment the deliverable:

- "`pasteAbsorbedVia` has always normalized, which is why wrapped TEXT sends
  never carried this bug" — wrong, and it contradicted a comment 25 lines
  above. Only the INLINE branch normalized; the placeholder branch matched the
  raw screen exactly as the image counter did. Inlined pastes were immune;
  COLLAPSED ones were not. The sibling change is a fix, not a precaution.
- "a word wrap can only break at a space" — unbounded. Claude's Ink wraps with
  `hard: true`, so a token longer than the pane is broken mid-token. For this
  pill that needs a composer under ~11 columns, which is why it holds here; it
  does NOT hold for the inline tail needle, which a reviewer showed failing at
  14 of 81 pane widths for a prompt ending in a path. That is #1118.
- "`\s+` catches a pill that survives normalization with an odd gap" — nothing
  does; after `normalizeWhitespace` no run longer than one space can exist.
- the recorded test's "replaying `after` for both phases would quietly test
  nothing" — it FAILS loudly instead, which the reviewer confirmed by doing it.

The delta-safety argument also answered the wrong axis: the real hole is that
`deliverClaudeImagePrompt` takes the image baseline before the short-text write
has painted (#1119), which this change widens slightly and does not cause.

The fixture README claimed "nothing else differs" between the two deliveries.
Disproved point by point: different routes (51 vs 120 chars), idle vs mid-turn,
and the pane was resized 64 -> 120 columns between them. The conclusion
survives — both got their text in, so image absorption alone decided their
fate — and the resize frame actually strengthens it.

Finally, both Claude paste debug probes still defaulted to the raw literal, so
the first tool anyone opens on the next stranded draft would have reported 0
placeholders for the screen production now reads as 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Adversarial review round — two reviewers

No behavioural defect found in the fix. Both reviewers verified the fail-first claim by reverting the one-line change and watching the wrapped case die at ~5 s while unwrapped stayed green, and both verified the fixture against its source bundle byte-for-byte (frames, screenHash, tsIso, and both submitResult blocks against incident-events.jsonl). One went further and proved imageCount: 1 from the recorded input.write byte counts rather than taking it on trust.

One reviewer swept 10,263 recorded frames from 72 debug bundles through the old and new counters. Exactly 4 diverge — all four are the bug frames from the source bundle. Zero paste-counter divergences across 78 frames containing [Pasted. They also measured the composer bound rather than assuming it: of 5,835 frames containing ❯, 5,827 resolve through the divider-bounded path, so scrollback leakage is not a live path.

The change also closes a pre-existing hole: a wrapped pill previously counted 0 at baseline, so a later unwrapped pill could confirm on a 0→1 delta that was not a transition at all.

What did need fixing: three comments that state falsehoods

In a repo whose CLAUDE.md makes the comment the deliverable, these are findings, not nits.

  • "pasteAbsorbedVia has always normalized, which is why wrapped TEXT sends never carried this bug." Wrong, and it contradicted a comment 25 lines above it. Only the inline branch normalized; the placeholder branch matched the raw screen exactly as the image counter did. Inlined pastes were immune; collapsed ones were not — and a collapsed paste has no inline tail to fall back on, so a missed placeholder there is a guaranteed timeout. The sibling change is a fix, not a precaution.
  • "A word wrap can only break at a space." Stated without its bound. Claude's Ink calls wrapAnsi(…, { hard: true }) (vendor/claude-code-src/full/ink/wrap-text.ts), so a token longer than the pane is broken mid-token. For a 10-character pill that needs a composer under ~11 columns, which is why the premise holds here — but it does not hold for the inline tail needle. Now bounded explicitly.
  • "\s+ catches a pill that survives normalization with an odd gap." Nothing does: after normalizeWhitespace no run longer than one space can exist, so \s+ is provably equivalent to a literal space here.
  • The recorded test's "replaying after for both phases would quietly test nothing" — it FAILS loudly instead, which the reviewer confirmed by doing it. The reconstruction is still required, for a different reason.

A real bug the review surfaced, filed rather than fixed here

#1118 — the inline tail needle is defeated by a hard wrap inside a long token. Driving the real detector over a width sweep, a 110-char prompt ending in an absolute path never absorbs at pane widths 46–50 and 90–98 (14 of 81). Byte-for-byte the #1113 experience, on the text path. Different signal, different fix.

#1119 — deliverClaudeImagePrompt takes the image baseline before the short-text write has painted, so a prompt whose own text contains [Image #N] confirms before any attachment exists. Pre-existing; this PR widens it only in that a wrapped literal now counts. The docstring's delta-safety argument answered the wrong axis and now says so.

Fixture README

Claimed "nothing else differs" between the two deliveries. Disproved point by point: different routes (51 vs 120 chars → raw write vs bracketed paste), idle vs mid-turn, and the pane was resized 64 → 120 columns between them. The conclusion survives — both got their text into the composer, so image absorption alone decided their fate — and the resize frame actually strengthens the story. Also corrected: 2 m 17 s apart, not ten minutes; three image deliveries in the bundle, not two.

One more thing the review caught

Both Claude paste debug probes still defaulted to the raw literal, so the first tool anyone opens on the next stranded-draft report would have said "0 placeholders" for exactly the screen production now reads as 1. Fixed.

tsc -b clean; 5527 app tests pass. The one failure, control.system.test.ts, reproduces on a clean origin/main worktree (#1107).

🤖 Generated with Claude Code

Picks up opencode-terminal-headless#6 as merged: the db-path retry plus the
two defects its review found — `db_path_retrying` so a healed pane is not
marked broken for life, and `db_path_recovered_late` naming the rows the dark
window dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 merged commit 6923305 into main Sep 22, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the fix/image-absorption-wrap-and-dbpath-retry branch September 22, 2026 00:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(claude): a word-wrapped [Image #N] pill makes every image prompt fail as absorption-timeout

1 participant