Skip to content

feat(tui): load stored images by reference, like desktop - #60116

Merged
kitlangton merged 8 commits into
devfrom
tui-media-uri
Oct 29, 2026
Merged

kitlangton merged 8 commits into
devfrom
tui-media-uri

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

The terminal app now asks for stored images by reference, as the desktop app does, and downloads one only when it shows it.
This only changes anything when the server has the experimental experimental.media_blob setting on. With it off, the server keeps sending images inline and the terminal app behaves exactly as before. opencode mini keeps inline images.

💡 Why
With media storage on, the server can send a 64-character media:<hash> reference in place of each image's bytes. Desktop opted in with #59104 (server side: #59103). The terminal app still downloaded every image in every transcript inline, even though image previews are off by default and most images are never drawn.

🆕 Now you can

  • Open a long session full of screenshots without downloading them. Bytes are fetched only when an image preview is on or the preview dialog is open, then cached by hash.
  • Undo, revert, fork or recall a prompt that had an image and get it back in the composer intact. The app reads each referenced image back into the draft before it cancels or reverts, because that step lets the server delete images nothing else uses. If the server says an image is already gone (404), undo and revert go ahead without it and show a warning; any other failed read stops them, since the image may still exist.

🚦 How it ships: behind the server's experimental experimental.media_blob setting, off by default.

- message reads -> every image inline as base64
+ message reads -> "media:<hash>"; bytes read on first show, cached by hash
- undo/revert -> cancel on server -> put prompt in composer
+ undo/revert -> read referenced images into the draft -> cancel on server -> put prompt in composer

The capability wrapper desktop added now lives in the shared client (withMediaRefs), so desktop and the terminal app use the same code. It still adds the header only to the message list, message get and inbox list reads, and in the terminal app only the transcript store uses it: the client handed to plugins keeps inline bytes.

📊 Proof: a new app test shows a transcript with a referenced image, checks that no image is downloaded while previews are off, then runs /undo and checks the image is read before the revert and the prompt comes back. Further app tests leave the session while an undo or queued-prompt undo waits on an image, refuse a revert, fail an image read before a fork, rewind past an image that is gone (404) and stop on a 503, leave and come back and type before the read finishes, undo in a fork that opened with a prompt, and check a plugin's message read carries no header; each fails without its fix. Unit tests cover the header on exactly those three reads, keeping references in the composer conversion, reading them back (and failing cleanly when a read fails), deduplicating referenced images and the byte cache.

👀 Read: packages/tui/src/routes/session/index.tsx. Undo, revert, fork and free-usage-limit recall read images through one helper (prompt/restore.ts), then finish, so undo and revert hand the prompt to appendToSessionPrompt (component/prompt/draft-stash.ts): it goes after anything typed in the composer currently mounted for that session, or into its saved draft if none is mounted. The rest is routine.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none" stroke="currentColor" stroke-width="1.5" font-family="ui-monospace,monospace" font-size="9">
<style>.p{animation:p 4s ease-in-out infinite}.r{animation:r 4s ease-in-out infinite}.d{animation:d 4s linear infinite}@keyframes p{0%,35%{opacity:0}50%,90%{opacity:1}100%{opacity:0}}@keyframes r{0%,35%{opacity:1}50%,90%{opacity:.25}100%{opacity:1}}@keyframes d{0%,20%{transform:translateX(0);opacity:0}25%{opacity:1}40%{transform:translateX(56px);opacity:1}45%,100%{transform:translateX(56px);opacity:0}}@media (prefers-reduced-motion:reduce){.p,.r,.d{animation:none}}</style>
<rect x="4" y="6" width="232" height="60" rx="6" opacity=".35"/>
<g class="r"><rect x="16" y="24" width="88" height="22" rx="4" opacity=".6"/><text x="24" y="38" fill="currentColor" stroke="none">media:3fa9…</text></g>
<circle class="d" cx="112" cy="35" r="3" fill="currentColor" stroke="none"/>
<g class="p"><rect x="176" y="16" width="48" height="40" rx="3"/><path d="M180 50l12-14 9 9 6-6 13 11" opacity=".7"/><circle cx="212" cy="26" r="3.5" opacity=".7"/></g>
<path d="M128 35h36m-5-4 5 4-5 4" opacity=".4"/>
</svg>

Review: an independent review found these, all fixed with a test that fails without the fix:

  • An undo still waiting when you switched tabs or ran /new lost the prompt and its images, and a queued-prompt cancel went to the wrong session. It now uses the session it started in and saves the prompt as that tab's draft.
  • Revert changed the composer before the server accepted the revert, and could write to another tab's composer. It now waits for the revert, then fills this session's composer.
  • Fork went ahead without the prompt when an image couldn't be read. It now reads first and doesn't fork on failure.
  • Plugins' client got references with no bytes. Only the transcript store asks for references now.
  • Free-limit recall checks the composer is still empty and not in shell mode after the read, and a second undo of the same queued prompt can't start while the first is running.
  • Also fixed: the image preview dialog no longer needs a connected client for ordinary images.
  • From the OpenCode bot: one unreadable image blocked undo and revert. A 404 now drops just that image with a warning; other failures still refuse. Fork still refuses on any failure.
  • From the final review: an undo whose read finished after you left the session, came back and typed went to the old, unmounted composer, so leaving again saved your typing over it. Composers now register per session, and a late undo or revert goes after whatever is typed in that session's current composer (or its draft). This replaced the per-view composer tracking. A composer that opens with a prompt already in it (a fork) registers too; it used to skip registration, so an undo there went to the draft and was lost.
  • Simplified after the review rounds: the restore paths share one helper, a closed composer keeps what was set on it so the queued-prompt undo no longer reads the draft stash itself, and the app tests share one fake server.
  • Design cleanups (Gemini design review, t809): no setPrompt prop through the timeline and message dialogs, no client.mediaRefApi (the store wraps client.api itself), fork uses the restore helper with allowMissing: false, the image source owns its decode failure, /undo and queued-prompt undo share undoPending, and comments now say a revert releases blobs only when it's committed. Same behaviour, net 30 fewer lines.

Requested on card t570 · follows desktop's #59104 · no user reports

Checks run
  • bun run typecheck in packages/client, packages/tui, packages/app: no type errors
  • packages/client: bun test test/media.test.ts: 6 pass
  • packages/tui: bun test --timeout 30000 test/session-media-refs.test.tsx test/prompt test/util/media.test.ts test/app-lifecycle.test.tsx test/mini test/session-fork-pending.test.tsx test/session-dialog-resync.test.tsx test/cli/tui test/plugin-keymap.test.tsx test/plugin-slot-output.test.tsx test/clear-session.test.tsx test/component: 1,075 of 1,077 pass, 2 skipped, 0 fail (now also test/prompt-offset.test.tsx test/prompt-paste.test.tsx test/stats.view.test.tsx test/cli/cmd/tui/notifications.test.ts), after rebasing onto dev
  • packages/app (solid conditions): api.test.ts, image.test.ts, export.test.ts, queue.test.ts, drafts.test.ts: 93 pass
  • oxlint and prettier --check on changed files: clean
  • pre-push bun run check (repo typecheck): 35 of 35 tasks pass

Added by the opencode bot (AI-generated).

Public API: adds withMediaRefs, mediaHashFromUri and MediaRequestOptions to @opencode/client/promise (backwards compatible)

@kitlangton
kitlangton marked this pull request as ready for review October 29, 2026 05:52

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change looks sound. I didn't run it end to end: it needs an image stored while experimental.media_blob is on, and the TUI driver can't paste one. The new client, codec, cache and app tests pass, along with the nearby fork, resync, clear-session and prompt suites. tsc -b is clean for tui and client.

Public API: @opencode/client/promise gains three exports: withMediaRefs, mediaHashFromUri and the MediaRequestOptions type. This is backwards compatible. The constants removed from packages/app were internal. The plugin client is unchanged and still gets inline bytes.

One point worth a look: if reading an image fails, undo now does nothing at all (see the inline comment).

if (!(await mutatePending("cancel", message.id))) return
const pending = pendingDeliveries().has(message.id)
const undo = async () => {
const restored = await restoredPromptInput(message, client.media.read)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If reading the image fails here (the blob is gone, a network error, or the server dropped the setting), the error stops /undo completely. The interrupt and revert never run, so the user can't rewind that message at all. Before this change, undo always worked. For fork, refusing makes sense. For undo and revert (and the queued-prompt undo at line 821), it may be better to still revert and put the prompt back without the images that couldn't be read, with a warning toast. That way one unreadable image doesn't leave the user stuck.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: a 404 from the media endpoint (the blob is really gone) now lets undo, queued-prompt undo and revert go ahead, restoring the prompt without that image and showing a warning. Any other failed read (network, 5xx, auth) still refuses with an error, since the blob may still exist and the rewind could sweep it; fork still refuses on any failure.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commits address the earlier point. If an image is already gone (404), undo and revert now go ahead without it and show a warning. Any other read failure still stops them, and fork still refuses on any failure. I checked the 404 detection against the generated client's error mapping. The new app tests for 404 and 503 pass, as do the codec and media tests, and tsc -b is clean for packages/tui. I didn't run it end to end, because the TUI driver can't paste an image. The public API is the same as in my last review: withMediaRefs, mediaHashFromUri and MediaRequestOptions are added to @opencode/client/promise, which is backwards compatible. Nothing new to raise.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 404 handling from my earlier point is still in place.

New in these commits: restoreToTab makes /undo, timeline revert and the message dialog's revert add the restored prompt after whatever is in the composer. Before, they replaced it. Pressing undo twice now leaves both prompts (and any unsent text) in the composer, not just the latest undone one. This changes behaviour for everyone, not only with experimental.media_blob on (see the inline comment). I saw it in a throwaway test built on this PR's fake-server fixture: I typed a draft, pressed <leader>u twice, and got the draft plus the undone prompt twice.

test/session-media-refs.test.tsx passes (15/15) and tsc -b is clean for packages/tui. I didn't run it end to end, because the TUI driver can't paste an image.

Public API: unchanged since my last review. withMediaRefs, mediaHashFromUri and MediaRequestOptions are added to @opencode/client/promise, which is backwards compatible.

return
}
composer.setMode("normal")
composer.set(appendPrompt(composer.current, prompt, widthMethod))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds the restored prompt after the composer's current text for every caller. Before, /undo, timeline revert and the message dialog's revert replaced the composer; only the queued-prompt undo added to it. So now pressing undo twice gives A\n\nB (or the same prompt twice), and an unsent draft is kept in front of the undone prompt. This happens even with experimental.media_blob off. I saw it with this PR's fixture: type a draft, press <leader>u twice, and the composer shows the draft plus the undone prompt twice. Could restoreToTab take a replace/append mode, so undo and revert keep replacing (both the mounted composer and the stashed draft) and only the queued-prompt undo adds to the text?

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These commits are mostly cleanup and look fine. The setPrompt props are gone, the transcript store wraps client.api with withMediaRefs itself, fork uses the shared restore helper (and still refuses if an image can't be read), and image decode failures are now tracked in createMediaSource. The 404 handling for my earlier point is still in place. The point about undo adding to the composer instead of replacing it is still open.

New: the two "finishes after leaving and returning joins what was typed since" tests in test/session-media-refs.test.tsx pass on their own, but they time out every time the file runs together with test/prompt (bun test test/prompt test/session-media-refs.test.tsx in packages/tui: "Timed out waiting for frame predicate after 20 passes"). They fail the same way on the previous head. They probably depend on process-wide state (the draft stash or the composer registry) or on timing (settled sleeps 50 ms). They also leave and return to the session twice each. Please make them independent of other test files and of timing, or cut them down to the core case.

tsc -b is clean for packages/tui. I didn't run it end to end, because the TUI driver can't paste an image. Public API: unchanged since my last review (withMediaRefs, mediaHashFromUri and MediaRequestOptions added to @opencode/client/promise, backwards compatible).

… (t809)

- Drop setPrompt drilling into DialogMessage/DialogTimeline; DialogMessage appends via appendToSessionPrompt.
- Remove client.mediaRefApi; the data store wraps client.api with withMediaRefs per request.
- DialogFork uses usePromptRestore with allowMissing: false.
- createMediaSource owns decode failure through onError.
- Share undoPending between queued-prompt undo and /undo.
- Rename restoreToTab to appendToSessionPrompt; fix comments about when blobs are released.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This push is a rebase onto dev. The only change to the PR's diff is that free-limit recall now also covers dev's provider.go-limit: it restores the prompt through the same image-reading helper and skips the Go upsell for that limit, which looks right. tsc -b is clean for packages/tui. The 404 fallback is still in place. Undo and revert still add to the composer instead of replacing it (thread still open).

The two "finishes after leaving and returning joins what was typed since" tests still time out for me when test/session-media-refs.test.tsx runs together with test/prompt (bun test --timeout 30000 test/prompt test/session-media-refs.test.tsx test/util/media.test.ts in packages/tui: 100 pass, 2 fail). They pass when the file runs alone.

I didn't run it end to end, because the TUI driver can't paste an image. Public API: unchanged (withMediaRefs, mediaHashFromUri and MediaRequestOptions added to @opencode/client/promise, backwards compatible).

…-ref tests (t570)

prompt-paste.test.tsx leaves its pasted text as the process-wide home draft. When it ran first (Windows CI),
leaveAndReturn's /sessions landed after that text instead of opening the sessions dialog.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This push is test-only. The two "leaving and returning" tests that failed when run together with test/prompt now pass: bun test test/prompt test/session-media-refs.test.tsx test/util/media.test.ts gives 102/102 in both file orders. The 404 fallback is still in place. The point about undo and revert adding to the composer instead of replacing it is still open. Public API: unchanged (withMediaRefs, mediaHashFromUri and MediaRequestOptions added to @opencode/client/promise, backwards compatible).

…ref tests (t570)

The fake server logs a request when it arrives, but the app only writes the draft or composer once the response is
back. Tests that waited on the call count could read before that, which failed on Linux CI. They now wait for the
draft, the composer text or the answered status.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This push is test-only: the media tests now wait for each rewind's outcome (the draft, the composer text or the response status) rather than for its request, which removes a timing race. test/session-media-refs.test.tsx passes together with test/prompt and test/util/media.test.ts (102/102, twice). The 404 fallback is still in place. The point about undo and revert adding to the composer instead of replacing it is still open. Public API: unchanged (withMediaRefs, mediaHashFromUri and MediaRequestOptions added to @opencode/client/promise, backwards compatible).

@kitlangton
kitlangton enabled auto-merge (squash) October 29, 2026 20:00
@kitlangton
kitlangton merged commit 84aed13 into dev Oct 29, 2026
20 checks passed
@kitlangton
kitlangton deleted the tui-media-uri branch October 29, 2026 20:04
@amrkmn amrkmn mentioned this pull request Oct 30, 2026
1 task
pascalandr added a commit to NeuralNomadsAI/CodeNomad that referenced this pull request Oct 30, 2026
…ct (#1209)

## Summary

- OpenCode **2.2.3** becomes both the **technical minimum** and the
**recommended** runtime. The minimum moves because upstream
[#60258](anomalyco/opencode#60258) (shipped in
2.2.2) changed the image tool contract that CodeNomad's Images gallery,
Design agent and Images settings consume: one name,
`experimental_image_generate`, for the tool, its permission action, its
job type and its completion notices' `metadata.source`, and the tool on
by default (only `experimental.image_generate: false` disables it). On
2.2.2+, the gallery lost every generation entry and the Design companion
fixture timed out; 2.2.1 passed.
- Images are implemented for the 2.2.3 contract only, with no fallback
to the old name: `IMAGE_TOOL` drives the gallery and its proof;
`imageGenerationSelection` reads unset as on; the Design agent and
skills name the new tool; the activation fixture exercises the image
tool instead of falling back to webfetch; the gallery fixture represents
the 2.2.3 transcript format.
- Older daemons (2.1.x, 2.2.1, 2.2.2) go through the existing
below-minimum path (`native_contract`). The 2.1.0 client/plugin/schema
pins stay.
- Help (all ten locales), READMEs, `CONTRIBUTING.md`, `dev-docs`
(compatibility, pruning, panel extensions, settings) and the
architecture skill describe the new minimum, its reason and default-on
image generation. The setup minimum-reason UI string is updated in all
locales.
## Contract review (v2.2.1 → v2.2.2 → v2.2.3; 40 + 101 commits)

Reviewed from the exact tags `v2.2.1` (`3ebcf9a32c`), `v2.2.2`
(`098aa7fd6e`) and `v2.2.3` (`082ac6281f`), the published npm packages,
and an authenticated OpenAPI dump from each official CLI running in its
own sandbox. The [changelog](https://opencode.ai/changelog) has entries
for both 2.2.2 (29 October, highlights only) and 2.2.3 (30 October, full
notes); everything below was also checked against the commit log and
source.

**Headline:** the HTTP contract CodeNomad consumes didn't change, but
2.2.2 **renamed the image tool** from `image_generate` to
`experimental_image_generate` and **turned it on by default**
([#60258](anomalyco/opencode#60258)).
CodeNomad's Images/Design features match the old name and the old
default, so they break on 2.2.2 and later (see [Design companion
finding](#design-companion-finding)). Nothing else found affects
CodeNomad.

- **Authenticated OpenAPI:** **134 paths and 162 operations** on all
three, the same operation set. Schemas 355 → 350 → 351.
  - **Added/removed operations:** none in either release.
- **Changed operations (2.2.2):** `GET /api/model`, `GET /api/event`,
`PUT /api/pty/{ptyID}`, `POST /api/shell`, `GET /api/shell/{id}/output`,
`POST /api/debug/heap-dump` and `GET /api/experimental/migration/v1`.
All of these are **representation-only**: the Effect 4.0.6 JSON Schema
output flattens nested `anyOf`, bounds integers with `maximum:
9007199254740991` (and `minimum` for signed integers), and rewrites the
numeric-string query pattern. The event-type and model-type enums are
unchanged.
- **Changed operations (2.2.3):** only the description of `POST
/api/credential`, which can now be refused by an `integration.connect`
policy.
- **Consumed schemas:** the same family selector as the 2.2.1 review
(`V2EventEncoded`, `Session.*`, `SessionStats.*`, `Form.*`,
`Permission.*`, `Prompt*`, `Event.*`, `Location.*`, PTY/Shell).
Re-derived from that description it selects 104 schemas at 2.2.1 (the
2.2.1 file says 103), all byte-identical from 2.1.4 to 2.2.1.
- In 2.2.2 the seven alias copies `Form.Fields_1..3` and
`Session.Message.Native_1..4` collapse into their base schemas, which
are byte-identical to the copies, leaving **97**. Of these, 71 are
byte-identical and 24 differ only in generator representation.
- Two changes are additive and optional: `Session.Info.time.busy`, the
start of the latest execution
([#59910](anomalyco/opencode#59910)), and the
input field `PromptInput.FileAttachment.source` (`Prompt.FileSource`,
inline or uri; CodeNomad doesn't send it).
- `V2EventEncoded`, `Session.Message.*` content, `Session.Inbox.*`,
`Form.*` semantics, `Permission.*` and `Event.SeqEncoded` are unchanged.
    - From 2.2.2 to 2.2.3 the family is byte-identical.
- **`Config.InfoEncoded`:** no semantic change in 2.2.2 (representation
only). 2.2.3 extends the managed-policy `action` enum (top-level and
`experimental.policies`) with `integration.connect`, `mcp.use`,
`skill.use` and `plugin.use`
([#60315](anomalyco/opencode#60315)); CodeNomad
writes no policies. The *meaning* of `experimental.image_generate`
changed: unset now means on, and only `false` turns it off. The schema
shape stayed the same and the OpenAPI carries no description, so no
contract signal reveals it.
- **Schedules (not consumed on dev):** `when` becomes optional;
`if`/`until` conditions (shell in 2.2.2, Code Mode in 2.2.3) are added;
outcomes gain `checked`/`expired`.
- **Admission:** every predicate in `compatibility/negotiate.ts`
(unchanged since the 2.2.1 review) holds on 2.2.1, 2.2.2 and 2.2.3.
- **Instance proxy:** no new routes and an unchanged allowlist. Proxied
routes whose representation changed: `GET /api/model`, `PUT
/api/pty/{id}`, `POST /api/shell` and `GET /api/shell/{id}/output`.
Integer bounds and an equivalent numeric pattern accept everything
CodeNomad sends.
- **Published declarations** (2.2.1 → 2.2.3):
- **Client:** 185 methods on all three; the method tree is identical.
2.2.2 adds the generated types `ScheduleCondition*`; `FormFields2` is
renamed to `FormFields1`, which is generated-type churn. 2.2.3 adds the
Promise media helpers `withMediaRefs`, `mediaHashFromUri` and
`MediaRequestOptions`
([#60116](anomalyco/opencode#60116)) and
persistent-PTY frame helpers in `solid/pty`. CodeNomad imports none of
these.
- **Schema:** 2.2.2 adds schedule conditions (`ShellCondition`,
`CheckMode`, …) and exports `SessionLegacy.part`. 2.2.3 adds
`CodeCondition*` and `policy.Action`.
- **Plugin:** 2.2.2 adds the GUI
`ComposerQuoteAnchor`/`ComposerFocusOptions`. 2.2.3 has no export
changes, but the optional peers `@opentui/*` become `>=0.8.0`
([#60385](anomalyco/opencode#60385)). The
published `dependencies.effect` moves from **4.0.1 to 4.0.6** (2.2.2).
- **Typecheck:** the server and UI typecheck with **0 errors** against
the 2.2.2 and 2.2.3 client/schema/plugin/effect declarations through
scratch `paths` overrides. The pinned 2.1.0 control is also clean. The
harness now also remaps `effect` itself; the 2.2.1 harness skipped it
because effect's `exports` are plain `.js` strings.
- **Host modules:** the three daemon-loaded bundles still import
`@opencode/plugin` (`Plugin`), `/effect` (`Plugin`, `Rpc`, `Location`),
`/promise/adapter` (`fromPromise`), `/rpc` (`Rpc`), `effect` and
`effect/encoding` (`Base64Url`) from the host. A probe plugin in an
isolated **2.2.3** daemon resolves every module and binding, as on
2.2.1. `runtime-modules.ts` is unchanged between the tags.
- **Host Effect: 4.0.1 → 4.0.6** (2.2.2,
[#59630](anomalyco/opencode#59630)). The 2.1.0
`@opencode/schema`/`@opencode/client` code bundled into the Missions and
pruning plugins now runs on host Effect 4.0.6. The plugin-runtime,
pruning, Missions-contract, permission-receipts and image-settings
native suites pass on 2.2.3.
- **Private native services:** the keys (`LocationServiceMap`, `Form`,
`Permission`, `Location`, `Job`, `Database`, `Session`, `Bus`) and the
members CodeNomad calls are unchanged. The changes are additive:
`Permission.Unattended` (a reference that denies unanswerable asks for
schedule checks), a `CorrectedError.message` getter, and `Session`
deciding fork media from the parent's location.
- **Storage** (three migrations): `session_part` (new table),
`session_v2.time_busy` and four `schedule` check columns.
- **`session_part`**
([#58915](anomalyco/opencode#58915)): history
reads now *prefer* `session_part` rows over
`session_message.data.content` for assistant messages. The projector
writes parts there only for messages that already have part rows, so new
messages stay inline. Every 2.2.3 fixture DB inspected has **0**
`session_part` rows, and upstream `dev` (`1e28374e48`, 30 October) has
no writer either.
  - The tables CodeNomad reads directly keep their shape.
- **Forward risk:** when a release starts writing parts, CodeNomad's
direct `session_message.data.content` readers and the pruning `UPDATE …
json_set(data,'$.content',…)` (`session-pruning/transaction.ts`) would
silently miss or be shadowed by part rows. See Recommendations.
- **Behavior changes checked:**
- **Image generation** (2.2.2): renamed and on by default. See the
[dedicated section](#design-companion-finding).
- **Code Mode:** inner tool progress no longer replaces the `execute`
call rows ([#60480](anomalyco/opencode#60480)),
and progress is throttled to at most one update per 100 ms
([#60120](anomalyco/opencode#60120)). Programs
see the same failure text as direct calls
([#60109](anomalyco/opencode#60109)), `fetch` in
Code Mode is now subject to `webfetch` permissions
([#59954](anomalyco/opencode#59954)), `for…in`
visits inherited keys
([#59933](anomalyco/opencode#59933)) and `null`
survives text encoding
([#57639](anomalyco/opencode#57639)). The
run-to-completion regression behind the 2.1.2 minimum passes on 2.2.3:
`{ total: 2000, beforeAwait: 1000, count: 1000 }`.
- **Permissions and MCP:** permission reasons and feedback are kept in
MCP tool failures
([#60102](anomalyco/opencode#60102)). Shell
permission checks moved fully off tree-sitter and now check redirects,
PowerShell method calls and dynamic directory changes
([#60332](anomalyco/opencode#60332),
[#60376](anomalyco/opencode#60376)), so some
shell commands may now ask where they didn't. The managed-policy split
(`integration.use` vs `mcp.use`/`skill.use`/`plugin.use`) is a changelog
"breaking change" for managed deployments only.
- **Sessions:** deleting a session removes its persistent terminals
([#60322](anomalyco/opencode#60322)), and a
persistent terminal with a missing cwd is rejected
([#60300](anomalyco/opencode#60300)). Forked and
imported sessions store media as blobs when `media_blob` is on
([#60118](anomalyco/opencode#60118)). The native
fork suite passes on 2.2.3.
- **Providers:** a built-in **Claude Agent** provider uses the user's
Claude Code login
([#58782](anomalyco/opencode#58782)).
Plugin-supplied model variants are preserved
([#60097](anomalyco/opencode#60097)), and the
dynamic provider npm fallback runs after external SDK hooks
([#60380](anomalyco/opencode#60380)). These are
source-reviewed and touch no consumed contract.
- **Persistent PTY binary** (the process daemon): 2.2.2 switches from
`@opencode-ai/pty` 0.3.1 to `@opencode/pty` **0.3.3**
([#60276](anomalyco/opencode#60276)).
`process-daemon-v0.3.3` contains only the release commit after 0.3.2 (a
version bump of the `@opencode/pty-*` binaries). **v0.4.0** (terminal
renaming, [#60324](anomalyco/opencode#60324)) is
**not** shipped in CLI 2.2.3, which pins 0.3.3. CodeNomad only lists
persistent PTYs (worktree deletion guard), whose schema is unchanged
apart from representation.
- **Client fix not inherited:** the 2.2.3 client evicts missing sessions
on 404 ([#60459](anomalyco/opencode#60459)).
CodeNomad stays on the 2.1.0 client.
- **`@opencode/ai` breaking changes** (media moved to `/experimental`,
one `tool()`): CodeNomad doesn't import `@opencode/ai`.
- Desktop/TUI-only changes (timeline performance, side-panel tabs,
spellcheck, question sounds, OpenTUI 0.8) don't affect CodeNomad.

**Verdict:**

- The **2.1.0 client/plugin pins stay**. Bumping them would bring
nothing CodeNomad consumes.
- No consumed HTTP/plugin contract broke; the breaking change is the
image tool contract of #60258, consumed by the Images gallery, the
Design agent and the Images settings.
- **Decision applied in this PR:** 2.2.3 becomes both the minimum and
the recommendation, and Images are implemented for the 2.2.3 contract
only (see Summary). This replaces the review's initial suggestion to
keep the 2.1.2 minimum and accept both tool names.

**Not verified here:**
- the full native suite matrix on 2.2.2/2.2.3; only the runs in Appendix
I were performed;
- the forward-compatibility of the `session_part` readers against a
runtime that writes parts (no such runtime exists yet);
- the Claude Agent provider and shell-permission changes at runtime
(source only);
- macOS/Linux.

---

## Design companion finding

**Symptom:** `scripts/test-design-companion-native.mjs <cli>` passes on
2.2.1 and fails on 2.2.2 and 2.2.3 with `Error: Timed out: series 1
generated and read`. Reproduced independently in a scratch checkout of
`origin/dev` (`f39642bc4`), with `CODENOMAD_NATIVE_FIXTURE_PARENT`
outside the home:

```
test-design-companion-native 2.2.1  exit=0  PASS AC-16, AC-52
test-design-companion-native 2.2.2  exit=1  Timed out: series 1 generated and read
test-design-companion-native 2.2.3  exit=1  Timed out: series 1 generated and read
```

**What the step waits for**
(`test-design-companion-native.mjs:238-241`): the fixture's loopback
chat provider answers a `RUN [...]` turn with two tool calls `{ tool:
"image_generate", args: { prompt, path: ".codenomad/design/icon/<name>"
} }`. The step then waits until the transcript holds two synthetic
notices with `metadata.source === "image_generate"` (line 209) and two
`read` parts of the generated files. No HTTP generate route, file watch
or media blob is involved; it is the agent tool path.

**Cause: an intentional upstream contract change, not an upstream bug.**
In 2.2.2, [#60258](anomalyco/opencode#60258)
(`d4e48f54da`, "turn experimental_image_generate on by default") changed
`packages/core/src/tool/plugin/image-generate.ts`:

- `export const name = "image_generate"` →
`"experimental_image_generate"`. That one constant is the tool name, the
permission action (`assertions: ["experimental_image_generate", "edit",
"read"]`), the Job `type`, and the completion notice's
`metadata.source`. `session/execution/restart.ts` also tags
restart-recovery notices `source: "experimental_image_generate"`.
- The opt-in became default-on: `if (!enabled || imageGenerate(entries)
=== false) return`. An unset `experimental.image_generate` now offers
the tool; only `false` removes it ("still honored for configs from when
the tool was opt-in; permissions are the supported way to turn it off").
- The 2.2.2 changelog states it: "agents can now use
`experimental_image_generate` without additional configuration".

2.2.2 also wraps the notice text as `<image id="…"
state="…">\n…\n</image>` and adds `id` to the tool output
([#60098](anomalyco/opencode#60098)).
CodeNomad's `Saved image: (.+) \(` parse still matches; `jobID`, `state`
and `path` metadata are unchanged.

**Evidence** (fixture DBs copied read-only to `evidence/`):

```
2.2.3, original fixture (calls "image_generate"):
  seq 5  tool image_generate  error  "No tool named \"image_generate\" is currently available. Please use a tool from the available tool list."  (x2)
  -> no image job, no notice, no read: the wait times out.

2.2.3, fixture with the tool name changed (scratch copy; CodeNomad server code unmodified):
  seq 5  tool experimental_image_generate completed {path:…/icon-minimal-a, model:"fiximg/image", jobID:"image_18cfb59…"}
  seq 18 synthetic {"source":"experimental_image_generate","jobID":"image_18cfb59…","state":"completed",…}
         <image id="image_18cfb59…" state="completed"> | Image request finished. Saved image: …
  seq 20 tool read completed (x2) … seq 40/48/49 refinement
  -> files, image calls and reads all correct, then
     AssertionError: generations.length 0 !== 3   (test line 260: gallery "generation" entries)
```

So the product bug is real and independent of the fixture: on 2.2.2+
CodeNomad's Images gallery finds **no generation entries**. Images still
appear through their `read` parts, but without prompt, model, lineage or
"proven" origin, and `galleryImageReference` refuses them as `changed`.

**CodeNomad code that assumes the 2.2.1 name or default** (origin/dev):

| Where | Assumption | Effect on 2.2.2+ |
| --- | --- | --- |
| `session-pruning/assets-gallery.ts:56,139` | `part.name ===
"image_generate"` | no generation entries; references refused `changed`
|
| `session-pruning/assets-gallery-proof.ts:104,130` | tool name and
notice `source === "image_generate"` | no calls or notices, so no
provenance proof |
| `opencode/image-settings.ts` `imageGenerationSelection(undefined) →
false` (doc: "Reads … as OpenCode does") | unset means off | Settings →
Images shows **Off** and `images.status()` returns `"disabled"` while
the tool is offered; help step "Turn on image generation"
(`docs/help/en/extensions.md:73`) is no longer needed |
| `opencode/design/opencode/agents/design.md`, `skills/design-core`,
`design-logo`, `design-ui-mockup` | the agent is told to call
`image_generate` | the model sees only `experimental_image_generate`;
the first call can fail with "No tool named …" |
| `scripts/test-design-companion-native.mjs:125,209,238,301` | fixture
calls/filters `image_generate` | red on 2.2.2+ |
| `scripts/test-design-activation-native.mjs:76,307` | ask rule on
action `image_generate`; tool detection by that name | **passes but
loses coverage**: `askTool` falls back to `webfetch` on 2.2.2/2.2.3
(logged) |
| `dev-docs/SESSION_PRUNING_RPC.md`, `PANEL_EXTENSIONS.md`,
`SETTINGS.md` | describe `image_generate` parts and opt-in | out of date
for 2.2.2+ |

User permission rules written for action `image_generate` no longer
match on 2.2.2+; that is an OpenCode-side change for users. CodeNomad's
help never names the permission action; the Design fixtures and agent
use `experimental_image_generate`.

**Fix in this PR** (2.2.3 contract only, no fallback):

1. One `IMAGE_TOOL = "experimental_image_generate"` constant drives the
gallery and its proof; the gallery reads the 2.2.3 notice format.
2. The native fixtures use the new tool; the activation fixture
exercises it (default on) instead of falling back to webfetch.
3. The Design agent and skills name `experimental_image_generate`.
4. `imageGenerationSelection` reads an unset
`experimental.image_generate` as on; only `false` disables it. Settings
→ Images and `images.status()` report it that way.

## Follow-up

- Hardening for `session_part` (independent of this upgrade, no 2.2.3
regression): make the direct readers and the pruning transaction fail
safe when `session_part` has rows for the target messages.

## Isolated Windows qualification

Base: dev `f39642bc4`; Node 25.2.1 (local; CI runs the pinned 24.20.0);
exact official CLI **2.2.3**; locked client/plugin **2.1.0**;
`CODENOMAD_NATIVE_FIXTURE_PARENT` outside the user profile for the
Design fixtures.

- **Before the image fix (recommendation-only change):** every native
suite passed on 2.2.3 except the Design companion end to end, which
passes on 2.2.1 and times out on 2.2.2/2.2.3 at "series 1 generated and
read" (`No tool named "image_generate" is currently available`).
- **After this change, on 2.2.3:** Design companion (3 files, 2 series,
all proven, lineage), Design activation (image tool offered to Design
and Build, default on), Design content, image settings (default on,
Off→On hot reload), session pruning including session images/media, Code
Mode jobs `{ total: 2000, beforeAwait: 1000, count: 1000 }`.
- **Full native set on 2.2.3 (before the image fix, unaffected areas):**
plugin runtime, automation, Location/worktrees/event relay, permission
receipts, Git init/degraded, Missions (contract, family control, native,
trajectory) and all 11 recurring journeys, sessions (environment, fork,
aside, interrupt, lifecycle, blank cleanup, compaction, prompt skills,
providers, service usage, web search, plugin packages, output limit,
Promise-tool failure, subagent depth, temporary workspaces), migrations
from 2.1.2/2.2.1/2.2.2, install/upgrade/legacy replacement.
- **Units and checks after the change:** root and server typecheck
clean; server **2,825 passed, 9 skipped, 1 cancelled** (`index.test.ts`
shutdown test timed out under full-suite load; **4/4** alone); gallery
107/107; Design 23/23; opencode-update 62 passed / 1 skipped; touched UI
units 8/8; browser `opencode-setup` + `websearch-settings` 38/38; native
plugin build 2/2; ten-locale help 31/31.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant