@oar-sh/oar 0.9.24 → 0.9.26

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.
Files changed (92) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +284 -0
  3. package/README.md +12 -11
  4. package/package.json +5 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
  7. package/server/claude-worker/claude-session-process.mjs +4 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +25 -11
  9. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  10. package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  12. package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
  13. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  14. package/server/db-schema.mjs +19 -0
  15. package/server/grok-worker/grok-turn-runner.mjs +3 -1
  16. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  17. package/server/migrations/0039-media-index.mjs +167 -0
  18. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  19. package/server/public/app/api-client.js +58 -0
  20. package/server/public/app/ask-user-view.js +2 -1
  21. package/server/public/app/attachments-view.js +66 -9
  22. package/server/public/app/bootstrap.js +85 -25
  23. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  24. package/server/public/app/context-media-tab.js +143 -0
  25. package/server/public/app/context-usage-view.mjs +2 -2
  26. package/server/public/app/conversation-view.js +90 -5
  27. package/server/public/app/image-composer-ui.js +518 -0
  28. package/server/public/app/image-turn-placeholder.mjs +233 -0
  29. package/server/public/app/journal-view.js +12 -2
  30. package/server/public/app/live-activity-cap.mjs +3 -3
  31. package/server/public/app/markdown-math.mjs +471 -0
  32. package/server/public/app/media-gallery-model.mjs +202 -0
  33. package/server/public/app/media-gallery-ui.js +524 -0
  34. package/server/public/app/media-view.js +79 -0
  35. package/server/public/app/openai-image-composer.mjs +492 -0
  36. package/server/public/app/openai-image-cost-ui.js +426 -0
  37. package/server/public/app/openai-image-spend.mjs +443 -0
  38. package/server/public/app/plan-usage-view.mjs +38 -6
  39. package/server/public/app/pwa-install.js +25 -4
  40. package/server/public/app/relay-error-ctas.mjs +5 -0
  41. package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
  42. package/server/public/app/relay-restart-ui.js +2 -2
  43. package/server/public/app/remote-relays-settings.js +33 -6
  44. package/server/public/app/router.js +49 -237
  45. package/server/public/app/session-history-ui.js +2 -0
  46. package/server/public/app/settings-modal.js +4 -0
  47. package/server/public/app/settings-tabs.js +10 -1
  48. package/server/public/app/socket-handlers.js +26 -0
  49. package/server/public/app/store.js +2 -0
  50. package/server/public/app/stream-view-preference.mjs +1 -1
  51. package/server/public/app/tab-strip-fade.mjs +1 -1
  52. package/server/public/app/thoughts-preference.mjs +1 -1
  53. package/server/public/app/tmux-inspector-view.js +29 -16
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +517 -123
  56. package/server/relay-tools.md +1 -0
  57. package/server/repositories/image-conversation-repository.mjs +69 -0
  58. package/server/repositories/media-repository.mjs +305 -0
  59. package/server/routes/media-routes.mjs +361 -0
  60. package/server/routes/messages-routes.mjs +1410 -360
  61. package/server/routes/relay-board-routes.mjs +46 -1
  62. package/server/routes/remote-relay-routes.mjs +1 -1
  63. package/server/routes/sessions-routes.mjs +361 -14
  64. package/server/server-runtime.mjs +21 -0
  65. package/server/services/database-file.mjs +6 -4
  66. package/server/services/generated-image-cleanup-service.mjs +2 -1
  67. package/server/services/generated-image-files.mjs +110 -0
  68. package/server/services/image-input-paths.mjs +389 -0
  69. package/server/services/image-operation-service.mjs +6 -2
  70. package/server/services/media-extract.mjs +653 -0
  71. package/server/services/media-index-service.mjs +221 -0
  72. package/server/services/openai-image-preview-turns.mjs +330 -0
  73. package/server/services/openai-image-spend-service.mjs +516 -0
  74. package/server/services/openai-image-stream.mjs +170 -0
  75. package/server/services/plan-usage-openai-image.mjs +148 -0
  76. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  77. package/server/services/relay-restart-queue.mjs +1 -1
  78. package/server/services/remote-relay-caller-context.mjs +6 -0
  79. package/server/services/remote-relay-dispatcher.mjs +409 -23
  80. package/server/services/remote-relay-registry-service.mjs +10 -2
  81. package/server/services/session-worker-stop-service.mjs +50 -18
  82. package/server/services/token-usage-service.mjs +15 -0
  83. package/shared/image-header.mjs +90 -0
  84. package/shared/math-instructions.mjs +30 -0
  85. package/shared/openai-image-options.mjs +299 -0
  86. package/shared/openai-image-presets.mjs +175 -0
  87. package/shared/openai-image-previews.mjs +40 -0
  88. package/shared/openai-image-prices.mjs +291 -0
  89. package/shared/openai-image-spend.mjs +90 -0
  90. package/shared/remote-relay-contract.mjs +126 -4
  91. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
  92. package/server/public/app/math-dollars.mjs +0 -89
@@ -168,7 +168,9 @@ export function createReasoningStreamHandlers({
168
168
  const reasoningId = reasoningIdFor(event);
169
169
  const deltaContent = String(event?.data?.deltaContent ?? event?.data?.delta ?? "");
170
170
  if (!deltaContent) return;
171
- const accumulated = (reasoningAccum.get(reasoningId) || "") + deltaContent;
171
+ // Capped before the gate: past the cap the text stops changing, so the
172
+ // same 16 KiB is not posted again and the accumulator stops growing.
173
+ const accumulated = capThought((reasoningAccum.get(reasoningId) || "") + deltaContent);
172
174
  reasoningAccum.set(reasoningId, accumulated);
173
175
  const lastSent = reasoningLastSent.get(reasoningId) || "";
174
176
  if (!shouldEmitDelta(accumulated, lastSent)) return;
package/CHANGELOG.md CHANGED
@@ -3,6 +3,290 @@
3
3
  All notable changes to OAR are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
+ ## [0.9.26] — 2026-11-11
7
+
8
+ Highlights: formulas from their delimiters only, and Settings sorted by
9
+ where they live. Replies render a formula only when it is written as
10
+ `$…$`, `\(…\)`, `$$…$$`, `\[…\]` or a fenced `math` block, so an id in
11
+ parentheses, a Windows path, a price or a shell variable stays text, a
12
+ formula KaTeX cannot read is shown as written instead of in red, and
13
+ the agent is told the notation that renders, except in Claude Cloud
14
+ chats and on the Copilot Extension engine. KaTeX moves to 0.19.1.
15
+ Theme, text scaling and the live-turn settings get an Appearance tab of
16
+ their own, and the relay name and the two restart settings move to
17
+ Relays, where an empty name shows the host name the relay goes by and a
18
+ rename made on another device shows at once. In Claude, Claude API and
19
+ DeepSeek chats a plan card comes only from a plan the agent hands over,
20
+ and a plan card shows up to 200,000 characters of the plan instead of
21
+ 12,000. Archive and delete no longer fail on Linux and macOS for a
22
+ worker that has already ended, a session an agent on a paired relay
23
+ started can always report back to it, and the tmux console draws a real
24
+ terminal.
25
+
26
+ ### Added
27
+
28
+ - **Fenced `math` blocks.** A code block with the language `math` is a
29
+ displayed formula once it is closed.
30
+ - **Agents are told how to write formulas.** Once per session, the agent
31
+ of a Claude, Claude API, DeepSeek, Cursor or Grok chat, and of a
32
+ Copilot or OpenAI chat on the SDK engine, learns that `$…$` in a
33
+ sentence and `$$…$$` on lines of their own render (`\(…\)` and `\[…\]`
34
+ too), that TeX in bare parentheses or brackets does not, and that a
35
+ dollar amount stays text. Claude Cloud chats and the Copilot Extension
36
+ engine do not get the note. The prompt a transcript shows leaves the
37
+ note out.
38
+
39
+ ### Changed
40
+
41
+ - **Settings: an Appearance tab, and the relay's own settings on
42
+ Relays.** Theme, Text scaling, Expand thoughts while an agent works,
43
+ Tool lines shown while an agent works, Stream view and Show Suspend
44
+ host action move from General to the new Appearance tab right after
45
+ it, under one line that says they are kept in this browser; Jump to
46
+ the end when I send a message and Messages kept per conversation stay
47
+ in General's Reading block. Relay name moves to the top of Relays,
48
+ above Public address, and After a relay restart, tell interrupted
49
+ sessions to carry on and Ask after a queued relay restart has waited
50
+ follow right below Public address; what stays of General's "This
51
+ relay" block (Default CWD, Autostart, the Windows helper) is headed
52
+ "This computer". Every setting keeps its value and default, and a
53
+ remembered tab or a link to General still opens General.
54
+ - **Formulas are read from their delimiters only.** `$…$` and `\(…\)`
55
+ inside a sentence, `$$…$$` and `\[…\]` as a display, and a fenced
56
+ `math` block; nothing is guessed from TeX in bare parentheses or
57
+ brackets any more. On one line `\[ … \]` needs a space inside both
58
+ ends, so `\[1\]` and `\[x\]` are escaped brackets. A `$` opens a
59
+ formula only where maths is written that way, so prices (`$5 and $9`,
60
+ `$5/$10`), shell variables (`$HOME/$PATH`) and Windows paths stay
61
+ text, and `\$` is always a dollar sign. Code spans and code blocks
62
+ never hold a formula. A formula KaTeX cannot parse is shown as
63
+ written, in the normal text colour, and a percent sign in a one-line
64
+ formula is shown. A message over 100,000 characters renders no
65
+ formulas. The rules are in
66
+ [Formulas in replies](docs/web-ui.md#formulas-in-replies).
67
+ - **KaTeX 0.19.1.** The page loads KaTeX 0.19.1 (was 0.16.22) and its
68
+ chemistry extension from jsDelivr, as before, now with integrity hashes;
69
+ its auto-render script is no longer loaded. KaTeX is not in the npm
70
+ package: a browser that cannot reach jsDelivr shows every formula as
71
+ written.
72
+ - **A plan card comes from a plan hand-over only** in Claude, Claude API
73
+ and DeepSeek chats. A plan-mode reply with two or more list lines no
74
+ longer gets a "Plan ready for review" card, so a session that stays in
75
+ plan mode no longer puts the buttons under every status reply. When the
76
+ plan of a hand-over cannot be read, the reply stands in for it. Cursor,
77
+ Grok, Copilot and OpenAI (BYOK) chats keep the card for such replies.
78
+
79
+ ### Fixed
80
+
81
+ - **The whole plan on the plan card.** The card cut every plan at 12,000
82
+ characters, without a mark. It now keeps up to 200,000; a longer plan
83
+ is cut, at a line break where it can, and ends with a line that says
84
+ how much is missing and, where the agent named it, the file that holds
85
+ the whole plan. A cut inside a code block closes the block first, also
86
+ in a plan file with Windows line ends.
87
+ - **Archive and delete no longer fail for a worker that has ended**
88
+ (Linux and macOS). Archiving or deleting an idle conversation could
89
+ answer "Couldn't stop this conversation's session" again and again:
90
+ the relay counted the old process id of a worker that had ended, by
91
+ then another program's, and processes that were ending by themselves.
92
+ Those no longer count, and a process the relay may not signal no longer
93
+ keeps it from stopping the others. A worker that really survives the
94
+ stop still leaves the conversation as it was.
95
+ - **A session an agent on a paired relay started can always report
96
+ back.** Its send to the session that started it was refused with
97
+ `REMOTE_RELAY_HOP_LIMIT` when its turn also acted on a prompt from a
98
+ session on its own relay. Another session on that relay, a third relay
99
+ and `create_session` are still held to the limit.
100
+ - **The relay name in Settings.** An empty Relay name showed "OAR" as its
101
+ placeholder; it now shows the host name, the name paired relays know
102
+ the relay by then. A rename made on another device shows in an open
103
+ Settings without reopening it.
104
+ - **The tmux console draws a terminal.** 🖥️ Inspect tmux console asked
105
+ jsDelivr and unpkg for xterm.js versions that were never published, so
106
+ it always fell back to plain text. It now loads `@xterm/xterm` 5.5.0
107
+ and `@xterm/addon-fit` 0.10.0 from jsDelivr, with integrity hashes,
108
+ and only the two scripts from unpkg when jsDelivr fails; a browser
109
+ that reaches neither still gets the plain text.
110
+ - **A long line in a code block no longer runs under its Copy button**,
111
+ which has a strip of its own above the first line.
112
+
113
+ ### Upgrade notes
114
+
115
+ - **No migration.** As before, the relay copies its database to the
116
+ backups folder before the first start of a new version; nothing else
117
+ waits.
118
+ - **Sessions still on 0.9.25's worker wait one worker start.** A session
119
+ whose worker still runs the old code is given a new worker between
120
+ turns, as in 0.9.25: a Claude, Claude API, DeepSeek or Cursor session
121
+ outside a git checkout before its next message, any other once its
122
+ agent has been idle for about 10 minutes. Its agent learns the formula
123
+ notation with the new worker, and Claude, Claude API and DeepSeek
124
+ chats get the new plan-card rule with it.
125
+ - **Your settings stay as they were.** The Appearance settings are kept
126
+ in the browser under the same keys, and the relay's own settings in its
127
+ database; only their place in Settings changes.
128
+ - **Older replies follow the new formula rules** when they are shown
129
+ again: a parenthesis 0.9.25 drew as a formula is text now, TeX
130
+ written in bare parentheses shows as written, and a one-line
131
+ `\[x^2\]` without a space inside its ends is read as escaped brackets.
132
+ - **KaTeX and xterm.js load in the browser,** not from the relay: from
133
+ jsDelivr, and the two xterm.js scripts, not its stylesheet, also from
134
+ unpkg; neither is in the npm package. A browser without access to them
135
+ shows formulas as written and the tmux console as plain text, whatever
136
+ the relay host can reach.
137
+ - **Mixed versions work together.** The report-back rule is checked by
138
+ the relay the started session runs on: that one needs 0.9.26, the relay
139
+ of the session that started it may run 0.9.25.
140
+ - **Going back to 0.9.25** needs no database step. Install it with
141
+ `oar update --to 0.9.25` or `npm install -g @oar-sh/oar@0.9.25` (in a
142
+ git checkout: `git checkout v0.9.25`, then a relay restart). The
143
+ settings go back to their old places with their values; a browser that
144
+ last had Appearance open opens General. Formulas follow 0.9.25's rules
145
+ again, Claude plan-mode replies with list lines get cards again, and
146
+ plan cards made on 0.9.26 keep their whole plan.
147
+
148
+ ## [0.9.25] — 2026-11-09
149
+
150
+ Highlights: a media gallery, and agents that make images. Every picture,
151
+ video and file of a session is now in one place, the Media tab of the 🧠
152
+ modal, and 🖼️ Media in the ︙ menu shows those of every session of the
153
+ relay, with filters and a search; the file viewer moves through the whole
154
+ gallery. Agents drive OpenAI Image sessions through `relay_tool`: they send
155
+ pictures to edit and set the size, quality, count, format and background.
156
+ While gpt-image works, the reply shows partial previews; the composer gets
157
+ a custom size, a count, a format, a background and presets, and the
158
+ gpt-image-2.5 and 2.6 models take xhigh and max quality. Every image turn
159
+ shows what it cost, a daily cap of USD 5 per relay unless you change it
160
+ keeps image spend in check, and a failed image turn costs at most two calls
161
+ instead of up to five. The relay's memory no longer grows while several
162
+ agents stream long thinking at once.
163
+
164
+ ### Added
165
+
166
+ - **Media gallery.** The 🧠 modal has two tabs, Context (where it opens)
167
+ and Media: the session's pictures and videos as a square grid under month
168
+ headers, three columns on a phone, with a Files tab for attached PDFs,
169
+ audio and documents. **🖼️ Media** in the ︙ menu shows the media of every
170
+ session of the relay, with filters (All, Mine, Agent, Generated, a
171
+ provider, a session, Hide archived) and a search. It collects uploads,
172
+ `@file:` pictures, generated images and the pictures agents put in their
173
+ replies; a picture shown in several messages appears once, at its newest
174
+ message, and a deleted local file is left out. A tap opens the file
175
+ viewer, which now moves through the whole gallery (it loads more at the
176
+ end) and offers **Open in session** and **Copy path / link**. The pictures
177
+ on screen that could not load while the relay was away load again once it
178
+ is back; one scrolled out of view waits for the next reconnect.
179
+ Owner only: not in shared views, and no agent reads it through
180
+ `relay_tool`, on this relay or a paired one.
181
+ - **Media index.** The relay keeps the gallery's index in its database
182
+ (migration 0039) and fills it for older messages in the background after
183
+ the upgrade, in short slices.
184
+ - **Agents drive OpenAI Image sessions.** `relay_tool`'s `send` and
185
+ `create_session` take `images`, up to 4 absolute paths of PNG, JPEG or
186
+ WebP files on the target relay, and `image`, the turn's size (any W×H
187
+ within the model's limits), quality, count (1 to 4), output format and
188
+ background; `update_session` with `image` sets a session's own defaults
189
+ (migration 0040). The target relay takes only files inside the session's
190
+ working directory or images one of its sessions generated, follows links
191
+ to the real file, and checks each file's first bytes. Replies list the
192
+ pictures as `images` with their paths there, and a reply without text
193
+ reads like `1 image · 1536x1024 · gpt-image-2`. A paired relay must run
194
+ this version (`imageInputs` in `relay_info`); an older one is refused
195
+ before anything is sent. A new image session without a model starts on
196
+ gpt-image-2.5-flare (else its dated snapshot, else gpt-image-2, else the
197
+ first gpt-image model listed), and is refused when none is listed.
198
+
199
+ - **Image previews.** While a gpt-image model makes an image, the reply's
200
+ bubble shows a timer with the size and quality, then two partial previews
201
+ as OpenAI streams them, then the final image. Previews reach your open
202
+ clients only, never the transcript, shared views, the media gallery or
203
+ agents, and their files go when the turn ends.
204
+ - **Image composer.** An image chat's composer has a Custom… size (any
205
+ W×H the model takes, checked as you type), a Count (1 to 4), a Format
206
+ and a Background for gpt-image models.
207
+
208
+ - **What image turns cost.** Every try of an OpenAI image turn is priced
209
+ as it ends, from the usage OpenAI reports and its published rates, and
210
+ estimated where OpenAI reports no usage for a try that may have been
211
+ billed; a model the relay has no price for counts at the dearest listed
212
+ price. The reply line ends with the cost, the chat menu shows what a
213
+ chat's images cost, and Check Usage has an OpenAI images card.
214
+ - **A daily cap for OpenAI images.** USD 5 per relay and day unless you
215
+ change it (Settings → Providers → OpenAI; 0 is no cap). A request that
216
+ would pass it is not sent, and once it is reached new image messages are
217
+ refused, from you and from agents alike; estimates count toward it. A
218
+ warning shows from 80 %. Allow for today lifts it until midnight.
219
+ - **Image presets.** Five built-in bundles of size, quality, format and
220
+ background, and your own (Save as preset… in the composer); agents name
221
+ one as image.preset.
222
+ - **xhigh and max image quality.** The gpt-image-2.5 and 2.6 models take
223
+ the two higher qualities, in the composer and for agents; other models
224
+ keep auto, low, medium and high. Sizes for gpt-image-2 and later need at
225
+ least 655,360 pixels (1024x640), as OpenAI requires.
226
+
227
+ ### Changed
228
+
229
+ - **OpenAI Image turns.** All attached images go to the edit, in order,
230
+ not only the first, and the format and background reach OpenAI. Quality
231
+ is checked per model family before the call (gpt-image: auto, low,
232
+ medium, high). OpenAI gets 3 minutes for a new image and 5 for
233
+ an edit (`openaiImageGenerateTimeoutMs`, `openaiImageEditTimeoutMs`).
234
+ - **Copilot hides image models.** Copilot's `gpt-image-*` and `dall-e-*`
235
+ ids, which its runtime cannot serve, are left out of its model list; a
236
+ Copilot chat on one is refused with a pointer to OpenAI Image.
237
+
238
+ ### Fixed
239
+
240
+ - **An image turn costs at most two calls.** A timeout, a server error or
241
+ a broken connection is tried once more, then the turn ends with a note
242
+ that says whether the request may have been billed; before, the turn went
243
+ back into the queue up to five times and ended as "Relay timeout after 5
244
+ attempts", each attempt a new billed request. When the second try works,
245
+ the reply notes that the first may have been billed as well. A relay
246
+ restart during the call no longer repeats it either.
247
+ - **The relay's own image route is local only.** A paired relay's token can
248
+ no longer call it directly, past the queue.
249
+ - **An agent's prompt header no longer reaches the image model.**
250
+ - **Relay memory no longer grows while several agents stream long thinking
251
+ at the same time.** A few sessions thinking at length at once could make
252
+ the relay hold more and more memory until it ran out. Thinking still shows
253
+ live; a slow connection may skip some of the updates in between, and the
254
+ whole thought arrives when it ends and after a reconnect, as before.
255
+
256
+ ### Upgrade notes
257
+
258
+ - **The first start takes a little longer on a big database.** Before it
259
+ listens, the relay copies its database to the backups folder, as before,
260
+ and migration 0039 creates the media index and queues every message for
261
+ it once: about 0.2 s per 200,000 messages on a small server. Migration
262
+ 0040 adds two columns. Nothing else waits.
263
+ - **The gallery fills in the background.** After the start the relay
264
+ indexes the older messages in slices of about 20 ms every half second,
265
+ in no particular order: about 3 minutes per 200,000 messages on a small
266
+ server, longer on a phone or with long messages. Until it ends, the
267
+ gallery shows what is indexed and "Indexing older messages… (N left)". A
268
+ restart picks up where it stopped.
269
+ - **The daily cap for OpenAI images is on after the update**: USD 5 per
270
+ relay and day, in Settings → Providers → OpenAI (0 is no cap). Without an
271
+ OpenAI key nothing changes, and Check Usage shows no OpenAI images card
272
+ until something was booked in the month.
273
+ - **Sessions still on 0.9.24's worker wait one worker start.** A session
274
+ whose worker still runs the old code is given a new worker before its
275
+ next message, as in 0.9.24. A Claude session's agent gets `images` and
276
+ `image` in `relay_tool` with its new worker.
277
+ - **Mixed versions work together**, except for images across relays: a
278
+ 0.9.25 relay refuses to send `images` or `image` to a paired 0.9.24 relay
279
+ and sends nothing, and a 0.9.24 relay cannot send them.
280
+ - **Going back to 0.9.24** needs no database step. Install it with
281
+ `npm install -g @oar-sh/oar@0.9.24` (in a git checkout:
282
+ `git checkout v0.9.24`, then a relay restart). 0.9.24 ignores the media
283
+ index and the image defaults and has no daily image cap, so image turns
284
+ are not limited there. The index's triggers stay in the database and
285
+ queue the messages written on 0.9.24, which a later 0.9.25 indexes. To
286
+ remove the index, drop its two triggers first, then its three tables, as
287
+ `server/migrations/0039-media-index.mjs` describes; a table dropped
288
+ before its trigger breaks every new message.
289
+
6
290
  ## [0.9.24] — 2026-11-08
7
291
 
8
292
  Highlights: a much smaller install. The Claude and Cursor agent SDKs leave
package/README.md CHANGED
@@ -45,29 +45,29 @@ flowchart LR
45
45
  <table>
46
46
  <tr>
47
47
  <td width="42%" rowspan="4" valign="top">
48
- <a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.24" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
48
+ <a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.26"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.26"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.26" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
49
49
  <br><sub>On a phone: the same conversations, installable as an app.</sub>
50
50
  </td>
51
51
  <td width="58%" valign="top">
52
- <a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.24" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
52
+ <a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.26"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.26"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.26" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
53
53
  <br><sub>Agents working in parallel, each with live token counts and its own Stop.</sub>
54
54
  </td>
55
55
  </tr>
56
56
  <tr>
57
57
  <td valign="top">
58
- <a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.24" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
58
+ <a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.26"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.26"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.26" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
59
59
  <br><sub>When the agent needs a decision, it asks, and waits for your answer.</sub>
60
60
  </td>
61
61
  </tr>
62
62
  <tr>
63
63
  <td valign="top">
64
- <a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.24" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
64
+ <a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.26"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.26"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.26" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
65
65
  <br><sub>Read the reasoning, not just the answer.</sub>
66
66
  </td>
67
67
  </tr>
68
68
  <tr>
69
69
  <td valign="top">
70
- <a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.24" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
70
+ <a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.26"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.26"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.26" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
71
71
  <br><sub>Agents that watch agents: woken by events, approving nothing.</sub>
72
72
  </td>
73
73
  </tr>
@@ -100,7 +100,7 @@ The runtime (the *provider* in the UI) is chosen in **New Chat** and stays fixed
100
100
  | ------- | ---------- | ------------- |
101
101
  | [**GitHub Copilot**](docs/providers.md#github-copilot--engine-choice) (default) | GitHub's coding agent, with every model your Copilot plan offers | The Copilot CLI, signed in on the relay host |
102
102
  | [**OpenAI (BYOK)**](docs/providers.md#openai-byok-and-openai-image) | Copilot's agent against OpenAI or a compatible endpoint | Your own API key |
103
- | [**OpenAI Image**](docs/providers.md#openai-byok-and-openai-image) | A chat whose replies are images | The same key |
103
+ | [**OpenAI Image**](docs/providers.md#openai-byok-and-openai-image) | A chat whose replies are images, with a daily spending cap; agents can use it too | The same key |
104
104
  | [**Claude**](docs/providers.md#claude-agent-sdk) | Claude Code on your machine | A Claude login, made from **⚙️ Settings** |
105
105
  | [**Claude API**](docs/providers.md#claude-api) | Claude Code on your machine, billed to a Claude Console API key; a Claude chat can switch between the two | A Claude Console API key |
106
106
  | [**Claude Cloud**](docs/providers.md#claude-cloud) (experimental) | Claude Code in a sandbox at Anthropic, on a GitHub repository | A claude.ai account with GitHub connected |
@@ -118,11 +118,11 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
118
118
  <table>
119
119
  <tr>
120
120
  <td width="62%" valign="top">
121
- <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.24" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
121
+ <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.26"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.26"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.26" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
122
122
  <br><sub>Send a message while the agent works, and it steers the running turn.</sub>
123
123
  </td>
124
124
  <td width="38%" valign="top">
125
- <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.24" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
125
+ <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.26"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.26"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.26" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
126
126
  <br><sub>What each plan has left, in one place.</sub>
127
127
  </td>
128
128
  </tr>
@@ -134,12 +134,13 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
134
134
  - **Background work**: a task panel lists the commands and sub-agents that outlive a reply, each with its own **Stop** and its full record; a command's console shows its output live, says when it has gone quiet and counts a test run's passes and failures; the panel and its consoles come back after a relay restart, and a queued restart shows what it waits for and asks after a maximum wait ([Background tasks](docs/web-ui.md#background-tasks), [After a relay restart](docs/web-ui.md#after-a-relay-restart)).
135
135
  - **Choose how the agent works**: `ask`, `plan`, `agent` or `autopilot`, plus model and reasoning effort, per message, from the composer's **⚙** ([The composer](docs/web-ui.md#the-composer), [Relay modes](docs/web-ui.md#relay-modes)).
136
136
  - **Files and git**: browse the workspace, preview files, review git changes in a diff viewer, and publish a dev server as a preview link ([Files, git, and previews](docs/web-ui.md#files-git-and-previews), [preview servers](docs/preview-servers.md)).
137
- - **Usage and context**: plan limits and credits of every provider, the estimated spend of DeepSeek and Claude API keys, and what fills the model's context window ([Usage and context](docs/web-ui.md#usage-and-context)).
137
+ - **Media**: the pictures and videos of a session, or of every session, in one gallery, with a file viewer that moves through all of them ([Media](docs/web-ui.md#media)).
138
+ - **Usage and context**: plan limits and credits of every provider, the estimated spend of DeepSeek and Claude API keys and of OpenAI images, and what fills the model's context window ([Usage and context](docs/web-ui.md#usage-and-context)).
138
139
  - **Your conversations everywhere**: the history lives on the relay, drafts follow you from device to device, and conversations can be searched, archived, grouped and pinned ([The conversation list](docs/web-ui.md#the-conversation-list), [drafts](docs/web-ui.md#drafts-across-devices), [session groups](docs/web-ui.md#session-groups), [pins](docs/web-ui.md#pinned-messages)).
139
140
  - **Share** a conversation as a read-only link, hide single messages and the host's paths from it, and see when it was opened ([Sharing](docs/web-ui.md#sharing)).
140
141
  - **On your phone**: install OAR as an app and get push notifications; back walks through the conversations you opened, a pinch zooms the whole app, and held sideways it keeps a phone's layout ([Notifications and the app](docs/web-ui.md#notifications-and-the-app), [back and forward](docs/web-ui.md#back-and-forward), [zoom](docs/web-ui.md#zoom), [a phone held sideways](docs/web-ui.md#a-phone-held-sideways)).
141
142
 
142
- The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.24](CHANGELOG.md#0924--2026-11-08) as the latest.
143
+ The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.26](CHANGELOG.md#0926--2026-11-11) as the latest.
143
144
 
144
145
  ## Remote access
145
146
 
@@ -190,7 +191,7 @@ flowchart LR
190
191
 
191
192
  You stay in charge: per paired relay you decide whether agents may only read, also prompt, or do everything, and in the `ask` and `plan` modes every write asks you first. A subscription only notifies; it approves nothing.
192
193
 
193
- <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.24" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
194
+ <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.26"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.26"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.26" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
194
195
 
195
196
  How pairing, permissions and limits work is in [Agents working together](docs/agents-together.md).
196
197
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.24",
3
+ "version": "0.9.26",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "server/",
26
26
  "!server/**/*.test.mjs",
27
27
  "!server/**/*-test-harness.mjs",
28
+ "!server/**/*-test-fixtures.mjs",
28
29
  "!server/**/fixtures/",
29
30
  "!server/README.md",
30
31
  "!server/data/",
@@ -36,6 +37,7 @@
36
37
  "shared/",
37
38
  "!shared/**/*.test.mjs",
38
39
  "!shared/**/*-test-harness.mjs",
40
+ "!shared/**/*-test-fixtures.mjs",
39
41
  "!shared/**/fixtures/",
40
42
  ".github/extensions/web-relay/",
41
43
  "!.github/extensions/web-relay/**/*.test.mjs",
@@ -72,7 +74,9 @@
72
74
  "@modelcontextprotocol/sdk": "1.32.2",
73
75
  "@playwright/test": "^1.60.0",
74
76
  "jsdom": "^30.0.1",
77
+ "katex": "0.19.1",
75
78
  "koffi": "3.3.4",
79
+ "marked": "15.0.12",
76
80
  "zod": "4.6.7"
77
81
  },
78
82
  "overrides": {
@@ -15,6 +15,7 @@ import {
15
15
  REMOTE_RELAY_THINKING_DISPLAYS,
16
16
  } from '../../shared/remote-relay-contract.mjs';
17
17
  import { RELAY_ASK_URGENCIES } from '../../shared/relay-asks.mjs';
18
+ import { OPENAI_IMAGE_BACKGROUNDS, OPENAI_IMAGE_OUTPUT_FORMATS } from '../../shared/openai-image-options.mjs';
18
19
  import {
19
20
  REMOTE_RELAY_CALL_FAILED,
20
21
  REMOTE_RELAY_TOOL_DESCRIPTION,
@@ -80,6 +81,20 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
80
81
  thinking_enabled: z.boolean().optional().describe(FIELDS.thinking_enabled.description),
81
82
  planOnly: z.boolean().optional().describe(FIELDS.planOnly.description),
82
83
  thinking_display: choice('thinking_display', REMOTE_RELAY_THINKING_DISPLAYS),
84
+ // A list, or one path as text, which the contract takes as well.
85
+ images: z.union([z.array(z.string()), z.string()]).optional().describe(FIELDS.images.description),
86
+ // Loose like quiet_hours: the contract's validator checks the values per
87
+ // model family and names an unknown key; the two fixed lists are enums as
88
+ // in the JSON schema.
89
+ image: z.looseObject({
90
+ size: z.string().optional().describe(FIELDS.image.properties.size.description),
91
+ quality: z.string().optional().describe(FIELDS.image.properties.quality.description),
92
+ count: z.number().optional().describe(FIELDS.image.properties.count.description),
93
+ output_format: z.enum([...OPENAI_IMAGE_OUTPUT_FORMATS]).optional().describe(FIELDS.image.properties.output_format.description),
94
+ background: z.enum([...OPENAI_IMAGE_BACKGROUNDS]).optional().describe(FIELDS.image.properties.background.description),
95
+ // Free text: the target relay knows its presets and resolves the name.
96
+ preset: z.string().optional().describe(FIELDS.image.properties.preset.description),
97
+ }).optional().describe(FIELDS.image.description),
83
98
  question_id: text('question_id'),
84
99
  // ask: the contract's validator refuses a question or detail that is too
85
100
  // long, or more than eight options, with a message the model can act on.
@@ -3,6 +3,7 @@ import { loadAgentSdk } from '../agent-sdk-loader.mjs';
3
3
  import { formatClaudeBackgroundCommandLimitLabel } from '../../shared/claude-background-command-limit.mjs';
4
4
  import { parseThinkingDisplay } from '../../shared/claude-thinking.mjs';
5
5
  import { PERSON_ONLY_QUESTION_SOURCE } from '../../shared/relay-asks.mjs';
6
+ import { MATH_INSTRUCTION_TEXT } from '../../shared/math-instructions.mjs';
6
7
  import { MEDIA_EMBED_INSTRUCTION_TEXT } from '../../shared/media-embed-instructions.mjs';
7
8
  import { REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS } from '../../shared/remote-relay-tool-core.mjs';
8
9
  import { createPreviewToolDefinition } from './claude-preview-tool.mjs';
@@ -205,10 +206,12 @@ export function longCommandInstructionText(backgroundCommandLimitMs = null) {
205
206
  export function systemPromptForRelayMode(relayMode, { backgroundCommandLimitMs = null } = {}) {
206
207
  const mode = String(relayMode || 'agent').trim().toLowerCase();
207
208
  // The media-embed capability rides every session — unlike the mode nudges it
208
- // is renderer knowledge the model cannot discover any other way. The long
209
- // command note rides every session too: the limit is the relay's setting.
209
+ // is renderer knowledge the model cannot discover any other way; so is the
210
+ // notation the web app renders formulas from. The long command note rides
211
+ // every session too: the limit is the relay's setting.
210
212
  const append = [
211
213
  MEDIA_EMBED_INSTRUCTION_TEXT,
214
+ MATH_INSTRUCTION_TEXT,
212
215
  longCommandInstructionText(backgroundCommandLimitMs),
213
216
  MODE_SYSTEM_PROMPT_APPEND[mode],
214
217
  ]
@@ -792,8 +795,9 @@ const EXIT_PLAN_DENY_MESSAGE = 'Plan received — it is shown to the user in the
792
795
  + 'End the turn now with a short closing message and do not start implementing; '
793
796
  + 'the user\'s decision will arrive as a new message.';
794
797
  // When the handler could not surface a board (no plan text reached it), the
795
- // model must restate the plan as its final message: plan-shaped final text
796
- // triggers the runner's fallback board, so the user still gets the review UI.
798
+ // model must restate the plan as its final message: after a hand-over without
799
+ // a card, the turn's reply is posted as the plan (publishCompletedTurn), so
800
+ // the user still gets the review UI.
797
801
  const EXIT_PLAN_DENY_NO_BOARD_MESSAGE = 'Plan received, but it could not be shown for review. '
798
802
  + 'Restate the complete plan as your final message, then end the turn without implementing; '
799
803
  + 'the user\'s decision will arrive as a new message.';
@@ -876,6 +876,8 @@ export function createClaudeSessionRunner({
876
876
  modelUsage: null,
877
877
  },
878
878
  planBoardPosted: false,
879
+ // The CLI handed a plan over (ExitPlanMode), whether or not a card came of it.
880
+ planBoardRequested: false,
879
881
  // The CLI process the turn ran in, as its usage report names it: taken
880
882
  // from the process here and again when the context is made active, and
881
883
  // read from the context at finalize — a late continuation flush
@@ -1569,6 +1571,7 @@ export function createClaudeSessionRunner({
1569
1571
  state: ctx.state,
1570
1572
  responseModel,
1571
1573
  planBoardPosted: ctx.planBoardPosted,
1574
+ planBoardRequested: ctx.planBoardRequested,
1572
1575
  consumedSteerIds,
1573
1576
  });
1574
1577
  }
@@ -3789,6 +3792,7 @@ export function createClaudeSessionRunner({
3789
3792
  onExitPlanMode: async (input) => {
3790
3793
  const ctx = processRef.activeCtx || processRef.pendingDelivered[0]?.ctx || null;
3791
3794
  if (!ctx?.message?.id) return false;
3795
+ ctx.planBoardRequested = true;
3792
3796
  const posted = await publisher.publishPlanBoard(ctx.message, input);
3793
3797
  if (posted) ctx.planBoardPosted = true;
3794
3798
  return posted;
@@ -6,7 +6,6 @@ import {
6
6
  classifyClaudeResultFailure,
7
7
  claudeFailureGuidance,
8
8
  } from './claude-turn-failure.mjs';
9
- import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
10
9
  import { PLAN_BOARD_ACTIONS } from '../../shared/plan-board-actions.mjs';
11
10
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
12
11
  import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
@@ -34,9 +33,10 @@ export function isStaleAttemptError(error) {
34
33
  export { buildSteerSettleFailure, steerSettleFailureText };
35
34
  export const STEER_SETTLE_FAILED_TEXT = steerSettleFailureText('folded');
36
35
 
37
- export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}) {
36
+ export function buildClaudePlanReadyBoardPayload({ message, planText = '', planFilePath = '' } = {}) {
38
37
  const summary = String(planText || '').trim();
39
38
  if (!summary) return null;
39
+ const filePath = String(planFilePath || '').trim();
40
40
  return {
41
41
  queueId: message?.id,
42
42
  messageId: message?.id,
@@ -52,6 +52,8 @@ export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}
52
52
  queueMessageId: message?.id || null,
53
53
  conversationId: message?.conversationId || null,
54
54
  relayMode: message?.relayMode || 'agent',
55
+ // Named on the card when the relay has to cut a very long plan.
56
+ ...(filePath ? { planFilePath: filePath } : {}),
55
57
  },
56
58
  };
57
59
  }
@@ -797,13 +799,13 @@ export function createClaudeTurnPublisher({
797
799
  * Post the plan-ready review board for an ExitPlanMode interception. The CLI
798
800
  * sends both `plan` and `planFilePath`; the inline plan is authoritative, the
799
801
  * plan file covers CLI versions that stop inlining it. Returns false when no
800
- * plan text could be recovered so the caller's plan-shaped-final-text
801
- * fallback still gets a chance to post the board.
802
+ * plan text could be recovered, so the turn's reply can stand in for it
803
+ * (see publishCompletedTurn).
802
804
  */
803
805
  async function publishPlanBoard(message, input) {
804
806
  let planText = String(input?.plan || input?.summary || '').trim();
807
+ const planFilePath = String(input?.planFilePath || '').trim();
805
808
  if (!planText) {
806
- const planFilePath = String(input?.planFilePath || '').trim();
807
809
  if (planFilePath) {
808
810
  try {
809
811
  planText = String(fs.readFileSync(planFilePath, 'utf8') || '').trim();
@@ -812,7 +814,7 @@ export function createClaudeTurnPublisher({
812
814
  }
813
815
  }
814
816
  }
815
- const boardPayload = buildClaudePlanReadyBoardPayload({ message, planText });
817
+ const boardPayload = buildClaudePlanReadyBoardPayload({ message, planText, planFilePath });
816
818
  if (!boardPayload) return false;
817
819
  await api('POST', '/api/relay-board', boardPayload).catch((error) => {
818
820
  dbg('plan board publish failed', error?.message || String(error));
@@ -822,18 +824,30 @@ export function createClaudeTurnPublisher({
822
824
 
823
825
  /**
824
826
  * Publish a completed (non-error, non-aborted) turn: final stream snapshot,
825
- * plan-mode fallback board, response row. Mirrors the pre-session-process
826
- * turn runner's tail exactly.
827
+ * plan-mode fallback board, response row.
828
+ *
829
+ * A plan card comes only from a plan hand-over (ExitPlanMode). The fallback
830
+ * covers one case: the CLI handed a plan over but its text could not be
831
+ * read, so the reply is the plan. A reply is never taken for a plan because
832
+ * of its list lines: in a session that stays in plan mode, every status
833
+ * reply with two bullets got a card.
827
834
  */
828
- async function publishCompletedTurn({ message, state, responseModel, planBoardPosted, consumedSteerIds = null }) {
835
+ async function publishCompletedTurn({
836
+ message,
837
+ state,
838
+ responseModel,
839
+ planBoardPosted,
840
+ planBoardRequested = false,
841
+ consumedSteerIds = null,
842
+ }) {
829
843
  const result = state.result;
830
844
  const finalText = String(
831
845
  state.resultTexts.join('\n\n') || result?.text || state.lastStreamedText || '',
832
846
  ).trim();
833
847
  if (
834
- !planBoardPosted
848
+ planBoardRequested
849
+ && !planBoardPosted
835
850
  && String(message.relayMode || '').trim().toLowerCase() === 'plan'
836
- && countPlanLikeLines(finalText) >= 2
837
851
  ) {
838
852
  const boardPayload = buildClaudePlanReadyBoardPayload({ message, planText: finalText });
839
853
  if (boardPayload) {