20 KiB
OpenCode-native: feature-gap closure plan
Status: implemented (single PR) · Owner: Dhruv Gupta · Harness: opencode-native
Implementation status (this PR)
All gaps from the review are closed in one PR:
- ✅ Compaction (P0) — real
/compact(v1/summarize) + auto-compaction surfacing - ✅ MCP —
spec.mcp_servers→ opencode.json +permission:ask(policies route through the engine) - ✅ Cost tracking (P1) —
external_session_usagefrom per-message cost/tokens - ✅ Resume — text-prefix replay from the Omnigent transcript (no more silent cross-host amnesia)
- ✅ Fork (P1) — text-preamble fork (reuses resume rehydration)
- ✅ In-harness session-cmd sync — TUI model-switch mirror + (compact/fork/resume above)
- ✅ Elicitation — tool-approval round-trip verified + tested (the review's "double-check")
- ✅ Policies — confirmed wired to the TOOL_CALL engine;
permission:askcloses the MCP coverage hole
Each was live-verified against opencode serve 1.17.7 where the wire was uncertain.
Bonus (not in the original gap list) — question.asked interactive input:
opencode's question tool (the model asking the user a multiple-choice
question, distinct from tool-approval) blocks the turn until answered. This was
characterized live against opencode serve 1.17.7 (built+run from source at
HEAD b60c0a5) so the integration is grounded in the real wire, not the schema
name:
- Real event is
question.asked(notquestion.v2.asked, despite theQuestionV2*schema names). Payload:{id, sessionID, questions:[{question, header, options:[{label, description}], multiple}], tool:{messageID, callID}}. - Reply is GLOBAL, not session-scoped:
POST /question/{id}/replywith{answers: [[label], …]}(one inner list per question; single-choice → a one-element list). Live-verified:{"answers": [["Tabs"]]}→200→question.replied→session.idle.POST /question/{id}/rejectunblocks without an answer. (The session-scoped path returns the web SPA, not an API route.) - The web AskUserQuestion card already parses exactly this shape via
_parse_questions_with_options({question, header, options:[{label, description}], multiSelect}), so the forward leg is a near-direct mapping.
Landed in this PR (foundation): the live-verified client methods
OpenCodeClient.reply_question(request_id, answers) /
reject_question(request_id) (unit-tested), wrapping the two endpoints above.
Deferred to a follow-up (the web round-trip): wiring a forwarder
_on_question_asked handler + a server form-elicitation hook that publishes
the AskUserQuestion card and replies via the client methods. Two parts cannot be
closed from opencode source alone and need the live web UI:
- TUI coexistence (race safety). Like the permission card, a TUI user can
answer the same question directly; the handler must reuse the
_signal_terminal_resolved_harness_elicitationrace guard (first-answer-wins) or a naive web intercept breaks TUI interactivity. - Answer mapping.
ElicitationResult.contentis an MCP-shaped{field: value}map; opencode wants opencode's ordered[[label]]. Single-question single-select is a deterministic, safe map; multi-question ordering must be verified against a real web verdict before shipping.
The tool-approval elicitation path (permission.asked) is unaffected by this
gap. See the QA plan for the manual web round-trip needed to promote the
follow-up.
Background
opencode-native (native-server harness: runner spawns opencode serve, an
SSE forwarder translates events, a typed HTTP client injects prompts) merged in
PR #576. A post-merge review of the harness feature matrix flagged gaps. This
doc records a live recon of opencode 1.17.7's actual API/event surface, then
gives a per-area gap analysis + plan grounded in that evidence. Reference
sibling throughout is codex-native (same native-server shape); the
authoritative capability list is the harness-integration-guide skill's
native-harness matrix.
Gap-matrix verdicts for the opencode row (✓ = works, ✗ = missing, ? = unknown):
| Capability | Matrix | Resolved verdict |
|---|---|---|
| Connects to Omnigent MCP | ✗ | was missing → built: launches the shared serve-mcp relay → sys_*/load_skill/web_fetch/comment/policy tools |
| Model override | ✓ | works (per-prompt) |
| Streaming (forwarder) | complete-only | by design for native-server |
| Elicitation (web) | ✓ | solid (verified) + a separate question.asked surface — foundation landed, web round-trip is a follow-up |
| Policies | ? | Wired across phases — TOOL_CALL via reactive permission.asked; REQUEST + TOOL_RESULT via the policy-bridge plugin (chat.message/tool.execute.after → /policies/evaluate). Tool-name-targeted policies were silently bypassed until the parse fix (action read as the literal "permission"). Per-policy name-set coverage still partial (block_skills, github/google shell gating). See the policy-coverage note |
| Cost tracking (P1) | ? | was missing → built (external_session_usage) |
| Interrupt | ✓ | works (abort) |
| Bidirectional sync (TUI→Omni) | ✓ | works |
| In-harness session-cmd sync | ✗ | was missing → built: compact + fork + resume + model-switch (both ways) + clear |
| Resume/fork from Omnigent transcript | ✗ | was missing → built (text-prefix replay; fork reuses it) |
| Compaction | ? | was missing (web /compact faked success) → built (P0) |
| Reasoning (P1) | matrix said ✓ but was NOT wired | → built: reasoning parts → transient reasoning deltas |
| Images | matrix said ✓ but was NOT wired | → built: image parts → image content blocks; non-image files text-flattened |
Recon: opencode 1.17.7 (live)
Method: ran opencode serve locally (the pinned 1.17.7 is installed on the
dev box), pulled its OpenAPI from GET /doc (390 KB), and drove one live
big-pickle turn capturing the GET /event SSE stream. Raw artifacts:
scratchpad/oc-recon/{openapi-1.17.7.json, events.ndjson, RECON-FINDINGS.md}.
This dispatched the "needs a live server to confirm" blocker on every item.
Key surfaces discovered (all confirmed present in 1.17.7):
- Compaction events: auto-compaction emits
session.next.compaction.started{sessionID, messageID, reason: auto|manual}+…ended{…, text, recent}; an explicit compaction emitssession.compacted{sessionID}(completion only). Trigger: the v2POST /api/session/{id}/compactreturns 503 "Session compact is not available yet" in 1.17.x (verified live) — so use the v1POST /session/{id}/summarize, which requires{providerID, modelID}(read from the session'smodel) and emitssession.compacted. - Cost/context (live-confirmed shape):
message.updatedassistantinfocarriescost(USD) +tokens:{input,output,reasoning,cache:{read,write}};Sessioncarries cumulativecost+tokens; context window =Model.limit.context. Eventsession.next.context.updated. - MCP:
opencode.jsonmcpblock —McpLocalConfig {type:"local", command:[…], cwd?, environment?, enabled?, timeout?}/McpRemoteConfig {type:"remote", url, headers?, oauth?, enabled?}. Runtime API also:GET/POST /mcp,/mcp/{name}/connect,/mcp/{name}/auth. - Permission config:
opencode.jsonpermission— either a scalar"ask"|"allow"|"deny"(applies to all tools) or a per-tool map. We synthesizeopencode.json, so we control it. - Resume/history:
POST /sync/history,/sync/replay,/sync/start;POST /session/{id}/message;GET /session/{id}/message;POST /session/{id}/fork(branch atmessageID). - Session commands:
POST /session/{id}/command,GET /command, eventcommand.executed;/session/{id}/revert+/unrevert(= undo/redo). - Questions (elicitation gap): a surface separate from permissions —
question.v2.asked {questions[], tool}+/session/{id}/question/{rid}/reply|reject. The forwarder ignores it today. "Always" decisions persist server-side via/api/permission/saved.
Two clarifications (raised in review)
1. "The compact button" = the /compact slash command. There is no separate
button. /compact is a built-in slash command in both the web composer
(web BUILTIN_SLASH_COMMANDS["/compact"]) and the REPL
(omnigent/repl/_repl.py @_cmd("/compact")). The web sends it as
postEvent({type:"compact"}) (web/src/store/chatStore.ts:1253) →
server _COMPACT_TYPE (sessions.py) → runner control dispatch
(runner/app.py ~11523). The runner dispatch only branches on
claude-native/codex-native; opencode falls to a 204 no-op, so the server then
runs its own AP-side compaction on the Omnigent conversation store — which is
NOT what opencode sends to the model. Net: /compact on an opencode session
emits a response.compaction.completed marker while opencode's real context is
untouched (a correctness lie). opencode has a real POST .../compact, so we can
make /compact genuinely compact opencode. Recommendation: make it real.
2. "Will policies just WORK either way?" — yes, with native config + force-ask.
- Precedent: codex/claude-native expose Omnigent tools via a relay — one
omnigentMCP server (serve-mcp) that proxies the active toolset; every call (incl. MCP) hits the central proxy + policy engine. Guaranteed, but it means porting the wholebridge.json/tool_relay.jsonrelay to opencode (L). - Native config path: we synthesize
opencode.json, so we write both themcpblock andpermission: "ask". opencode then emitspermission.askedfor tool calls (incl. MCP tools), which the forwarder already routes through Omnigent'sTOOL_CALLpolicy engine (opencode_native_permissions.py+runner/app.py_build_opencode_policy_evaluator) — the same path that already gates opencode's built-in tools (confirmed wired + tested). So policies work under native config, provided we force opencode to ask. Caveat: a tool opencode is configured to auto-allow would bypass the gate — but we own that config, so we don't auto-allow. - Recommendation: native
opencode.jsonMCP +permission: ask. Far smaller than the relay, and policies still "just work." Revisit the relay only if a future requirement needs central TOOL_RESULT gating or proxy-side redaction (opencode's reactive model can't pre-gate tools opencode never asks about).
Per-area plan
Each area: current state → gap → recon evidence → approach → effort/risk.
All land in opencode_native_forwarder.py / opencode_native_provider.py /
runner/app.py unless noted; server-side contracts are reused as-is.
1. Compaction — P0
- Current: nothing. Auto-compaction is invisible to Omnigent; explicit
/compactfakes success (see clarification 1). - Approach (two parts):
- Surface auto-compaction (additive, no server change): handle
session.next.compaction.started→ postexternal_compaction_statusin_progress;…ended→completed. Reuses claude-native's existing inbound wire contract (response.compaction.*). Also drives the web "compacting" marker. - Make
/compactreal: add_handle_opencode_native_compactto the runner control dispatch (mirror_handle_codex_native_compact, but HTTP not tmux) that resolves the session's model and callsPOST /session/{id}/summarizevia the client, returning 200 so the server stops running the AP-side fake (204 when no live server → graceful fallback; 503 on failure). Completion flows back through thesession.compacted/…endedhandler.
- Surface auto-compaction (additive, no server change): handle
- Effort: S–M · Risk: low for surfacing; medium for the dispatch (touches the shared runner control path + the server's compact-fallback semantics — scope carefully so codex/claude are unaffected).
2. MCP
- Current: none; agent MCP tools absent in opencode.
- Approach: in
opencode_native_provider.py, addbuild_opencode_mcp_block(spec.mcp_servers): stdio →{type:"local", command:[cmd,*args], environment:env}; http →{type:"remote", url, headers}(+ resolvedatabricks_profile→Authorization: Bearerheader, reusingresolve_databricks_gateway's pattern). Merge into the synthesizedopencode.jsonalongsideprovider/modelin therunner/app.pyspawn flow. Setpermission: "ask"so MCP tool calls route through the policy engine (clarification 2). Secrets ride the existing atomic-0600 writer. - Effort: S–M · Risk: low (gated on
spec.mcp_servers; reuses the 0600 writer + spawn chokepoint).
3. Resume — high
- Current: resumes only by the persisted opencode
external_session_id. Same-host relaunch works (per-sessionXDG_DATA_HOMEpersists opencode's store). Cross-host / wiped-store resume silently starts an empty session — the web transcript shows history but the agent has amnesia, no error. - Approach: when
get_session(external_session_id)returnsNoneon a resume that had an id, (C) at minimum surface the failure instead of silent amnesia, then (A) rehydrate from the Omnigent transcript:GET /v1/sessions/{id}/items(mirror codex's paginated fetch) → seed a fresh opencode session viaPOST /session/{id}/messageand/or the/sync/history//sync/replayprimitives. Confirm the/sync/historybody shape against the live server before committing to it. - Effort: M · Risk: medium — hinges on how opencode accepts back-dated/non-executing history (token cost, tool-call representation). Ship (C) first.
4. Cost tracking — P1
- Current: none;
message.updatedcost/tokens dropped. Context ring, cost badge, and cost-budget policy all dead for opencode. - Approach: in the forwarder, accumulate
info.cost+info.tokensper assistantmessage.updated; postexternal_session_usage {context_tokens, context_window, cumulative_cost_usd, cumulative_*_tokens, model}(context_window fromModel.limit.context) on message.updated +session.idle. Reuses codex'sexternal_session_usagecontract verbatim; server prices viacumulative_cost_usddirectly. Live-confirmed token/cost shape. - Effort: M · Risk: low (additive; cosmetic worst case).
5. Fork — P1
- Current:
transport.fork()+POST /session/{id}/forkexist but are wired to nothing; opencode is absent from_FORK_HISTORY_NATIVE_HARNESSES. - Approach: add
opencode-nativeto_FORK_HISTORY_NATIVE_HARNESSES(sessions.py); addfork_source_*fields to the opencode launch config + a fork branch in_auto_create_opencode_terminalthat callsclient.fork(source, {messageID})for same-harness sources, falling back to the resume-rehydration path (#3) for cross-family sources. Simpler than codex (opencode has a first-class fork endpoint). Build on #3. - Effort: M · Risk: low–medium.
6. In-harness session-cmd sync
- Current: neither direction. Omnigent
/compact(and clear/fork/resume) don't reach opencode; TUI-typed/model,/compact,/undodon't mirror back. - Approach: Omnigent→opencode via
POST /session/{id}/command(the matrix's "clear/fork/resume/switch"); the/compacthalf is covered by #1. opencode→Omnigent: handlecommand.executed(+ mirror/modeltomodel_override, surface/compact//undoasslash_commanditems). Overlaps #1/#3/#5; do last. - Effort: M–L · Risk: low–medium.
7. Elicitation (verify) + Policies (verify/harden)
- Elicitation: ✓ solid (full permission.v2 round-trip, fail-closed, tested). Harden: (C1) the typed
transport.reply_permissionis dead code parallel to the live forwarder path — unify or delete to prevent drift; (C2) a failedPOST .../replyis swallowed → opencode-side hang — retry/reconcile viaGET /session/{id}/permission. New (C3): handle the separatequestion.askedinput-request surface (currently ignored) as a form elicitation — foundation landed (reply_question/reject_question, live-verified + tested); the forwarder handler + server form-hook + TUI race guard remain (see the bonus section). Effort S (C1) / M (C2, C3). - Policies: wired to the TOOL_CALL engine (allow/deny/ask honored), reactive via
permission.asked. Honest coverage limits (audited after the file/shell-approval bug):- Phase: TOOL_CALL fires via the reactive
permission.askedpath; REQUEST + TOOL_RESULT now fire via the Omnigent policy-bridge plugin (omnigent-policy.js, generated bywrite_opencode_policy_plugin). opencode exposes first-class plugin lifecycle hooks, so the plugin bridgeschat.message→PHASE_REQUEST(gate the prompt; DENY throws = aborts the turn) andtool.execute.after→PHASE_TOOL_RESULT(DENY redacts the output) to/policies/evaluate— the same contract claude'sUserPromptSubmit/PostToolUsehooks use. Registered via the synthesizedopencode.jsonplugin:[…]field; coordinates stamped asOMNIGENT_*env onopencode serve. So prompt-injection / PII-in-prompt / per-prompt-cost (REQUEST) and tool-output gating (TOOL_RESULT) now enforce on TUI-typed turns too. Best-effort (transport errors fail OPEN). Known limit: the auth token is a launch snapshot (like codex'spolicy_hook.json) — long-session expiry degrades to fail-open; a refreshable token file is the follow-up. (permission.askcould later supersede the reactive TOOL_CALL path, but that already works, so it's left as-is.) - Tool name: opencode's
permission.askedcarries the action inpermission(v1) as a CATEGORY (bash/edit/read/grep/glob/skill/webfetch/…). The parser read onlyaction/type, so the policy tool name was the literal"permission"and no tool-name-targeted policy matched (file/shell approval, skill block, github/google gating all silently ALLOWed). Fixed: parser readspermission/patterns;ask_on_os_toolsgained the opencode categories. - Per-policy name-set gaps still open:
block_skillsdoesn't recognize opencode'sskillcategory (and the skill name rides inpatterns, not the forwarded args — Omnigentload_skillvia the relay IS covered); the github/google policies gate shell commands via a defaultsys_os_shell-only set (misses every native harness's shell tool — broad/config-dependent, not opencode-specific);risk_score's risk table is keyed by canonical names, so opencode categories score as default. - Name-agnostic policies (rate-limit, cost-budget, allow/deny-all) were unaffected throughout.
- Phase: TOOL_CALL fires via the reactive
Recommended sequence
- P0 compaction (surface auto-compaction + make
/compactreal) - MCP (native config +
permission: ask) - Resume (surface failure → rehydrate from transcript)
- Cost tracking (P1)
- Fork (P1; builds on resume)
- Session-cmd sync (builds on 1/3/5)
- Elicitation/policy hardening (C1–C3 + force-ask)
Each is an independent, reviewable PR. 1–5 reuse existing server contracts (no server changes except the compact-dispatch arm in #1).
Open questions
/sync/historyrequest-body shape — verify against the live server before choosing it for resume rehydration (vs. re-injecting viaPOST /session/{id}/message).- opencode's behavior for back-dated/non-executing history messages (cost, ordering, tool-call representation) — gates resume Option A.
- Whether to ever build the MCP relay (central TOOL_RESULT gating) — deferred; native config + force-ask is the plan.
Resolved: real event isquestion.v2payload — capture a real fixture to shape the form-elicitation mapping (C3).question.askedwith{questions:[{question, header, options:[{label,description}], multiple}], tool}; reply via GLOBALPOST /question/{id}/reply {answers:[[label]]}(live-verified). Foundation client methods landed; the web round-trip + TUI race guard remain the follow-up (see the bonus section above).