Repository navigation
fix: submit image prompts the TUI wrapped, and stop a slow db-path lookup from killing OpenCode's durable channel - #1115
Conversation
…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>
Adversarial review round — two reviewersNo behavioural defect found in the fix. Both reviewers verified the fail-first claim by reverting the one-line change and watching the 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 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 falsehoodsIn a repo whose CLAUDE.md makes the comment the deliverable, these are findings, not nits.
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 — Fixture READMEClaimed "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 caughtBoth 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.
🤖 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>
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"The prompt and the image were in the composer. The recorded screen at that instant:
imagePlaceholderCountmatched/\[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,pollClaudeImagesAbsorbedspends its whole 5 s budget, and delivery returnsabsorption-timeoutwithretrySafe: 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:
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 againstnormalizeWhitespace(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.placeholderCountis 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 realbaseline/afterpairs from the bundle — one wrapped delivery that failed, one unwrapped delivery that succeeded — andpromptDelivery.imageWrap.recorded.test.tsreplays them throughdeliverClaudePrompt, 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
wrappedcase failed at 5025 ms withabsorption-timeoutwhileunwrappedpassed — so the test isolates the wrap and nothing else.2. OpenCode: a 20 s db-path timeout disabled the durable channel permanently
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:
provider.start.end(opencode)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 yieldsCommand 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); andopenDurable()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
npx tsc -bclean on both projects.opencode-terminal-headless; 5525 passed in the app suite.src/main/workflows/control.system.test.ts, reproduced onorigin/mainin a clean worktree — it is the wall-clock flake tracked in bug(test): two system tests synchronize on wall-clock time and fail under load #1107, not this change.🤖 Generated with Claude Code