Feature hasn't been suggested before.
Describe the enhancement you want to request
Use case
Some plugins have the model write small markers into its own replies. For example, a context-management plugin asks the model to tag messages with §N§ so it can refer back to them. These markers are meant for the plugin, not the user. On v1, experimental.text.complete lets a plugin rewrite an assistant text part when it completes, before it is saved, so the markers never reach the stored transcript or the UI.
On v2 there is no equivalent, so the markers are saved in session_message and shown in the timeline.
What v2 offers today (checked at upstream/v2 d1f8f5b, same as v2.0.22)
SessionHooks (packages/plugin/src/promise/session.ts) is prompt, context, compaction, generate, title, model.request, http.request, http.response, experimental.ws.handshake, experimental.ws.send, experimental.ws.receive and retry. Nothing runs between the parsed model reply and persistence, or before rendering.
- The TUI plugin API's only markdown seam is
markdown.registerCodeBlockRenderer(language, render) (packages/plugin/src/tui/context.ts), which handles fenced code blocks by language, not prose. The slot map has no per-message or per-part slot.
- The closest workaround is to replace the provider's raw response in
http.response (or edit frames in experimental.ws.receive) and strip the markers from the wire stream before OpenCode parses it. That works, but it is provider-specific: a marker can be split across SSE events or JSON-escaped deltas, and every protocol (Anthropic, OpenAI Responses, Gemini, ...) needs its own parser.
Proposal (either would cover it; the first is preferred)
- A display hook that runs before assistant text is rendered, in the TUI and the app, letting a plugin hide its own markers from what the user sees. The stored text and what is sent back to the model stay exactly as the provider returned them. This is the better fit because it doesn't change history: prompt-cache prefixes stay byte-stable, and the WebSocket continuation path, which compares stored output byte for byte, is unaffected.
- A
text.complete equivalent: a session hook that runs when an assistant text part completes, with the part text mutable, before it is persisted. This is what v1 does today.
Related issues
These cover nearby ground on v1 and are closed, so none of them tracks v2:
Feature hasn't been suggested before.
Describe the enhancement you want to request
Use case
Some plugins have the model write small markers into its own replies. For example, a context-management plugin asks the model to tag messages with
§N§so it can refer back to them. These markers are meant for the plugin, not the user. On v1,experimental.text.completelets a plugin rewrite an assistant text part when it completes, before it is saved, so the markers never reach the stored transcript or the UI.On v2 there is no equivalent, so the markers are saved in
session_messageand shown in the timeline.What v2 offers today (checked at
upstream/v2d1f8f5b, same as v2.0.22)SessionHooks(packages/plugin/src/promise/session.ts) isprompt,context,compaction,generate,title,model.request,http.request,http.response,experimental.ws.handshake,experimental.ws.send,experimental.ws.receiveandretry. Nothing runs between the parsed model reply and persistence, or before rendering.markdown.registerCodeBlockRenderer(language, render)(packages/plugin/src/tui/context.ts), which handles fenced code blocks by language, not prose. The slot map has no per-message or per-part slot.http.response(or edit frames inexperimental.ws.receive) and strip the markers from the wire stream before OpenCode parses it. That works, but it is provider-specific: a marker can be split across SSE events or JSON-escaped deltas, and every protocol (Anthropic, OpenAI Responses, Gemini, ...) needs its own parser.Proposal (either would cover it; the first is preferred)
text.completeequivalent: a session hook that runs when an assistant text part completes, with the part text mutable, before it is persisted. This is what v1 does today.Related issues
These cover nearby ground on v1 and are closed, so none of them tracks v2:
text.delta) #39526 (streamingtext.deltatransform hook): closed as not planned.experimental.text.completeoutput did not reach the TUI; fixed in v1, and that hook has no v2 equivalent.