Context
Fourth concrete CLIRuntimeBase subclass — spawns opencode run --format json (repo: anomalyco/opencode) and maps its per-line envelope NDJSON output to AgentEvent instances.
src/middleware/
├── types.ts ← AgentRuntime, AgentEvent, etc. (PR #4)
├── cli-runtime-base.ts ← CLIRuntimeBase abstract class (PR #6)
└── runtimes/
├── claude.ts + claude.test.ts ← ClaudeCliRuntime (#8, PR #9)
├── gemini.ts + gemini.test.ts ← GeminiCliRuntime (#10, PR #11)
├── codex.ts + codex.test.ts ← CodexCliRuntime (#12, PR #13)
└── opencode.ts + opencode.test.ts ← THIS ISSUE
Dependencies : types module (PR #4 , merged), CLIRuntimeBase (PR #6 , merged).
Reference implementations : runtimes/claude.ts, runtimes/gemini.ts, runtimes/codex.ts for established patterns.
Specification
Constructor
constructor ( ) {
super ( "opencode" ) ;
}
supportsStdinPrompt (no override needed)
OpenCode supports stdin prompt delivery for >10KB prompts (same threshold as Claude). The default true from CLIRuntimeBase is correct — do not override .
buildArgs(params: AgentExecuteParams): string[]
Scenario
Args
New session
["run", "--format", "json", params.prompt]
Session resume
["run", "--format", "json", "--session", params.sessionId, params.prompt]
Key behaviors:
Subcommand is run (unlike Codex's exec)
--format json enables NDJSON output (not --quiet — flag does not exist, confirmed OpenCode CLI source analysis)
Prompt is always the last positional argument
--session <id> flag for session resume (prompt is still included on resume)
No --mcp-config flag — MCP handled via config file (see MCP section below)
execute() override
Override with the standard pattern plus pending events buffer drain :
async * execute ( params : AgentExecuteParams ) : AsyncIterable < AgentEvent > {
this. resetState ( ) ;
const mcpConfigManager = /* ... setup if mcpServers present ... */ ;
try {
await mcpConfigManager ?. setup ( ) ;
for await ( const event of super . execute ( params ) ) {
if ( event . type === "done" ) {
this . enrichDoneEvent ( event ) ;
}
yield event ;
// Drain buffered events (from tool_use → tool_result pairs)
while ( this . pendingEvents . length > 0 ) {
yield this . pendingEvents . shift ( ) ! ;
}
}
} finally {
await mcpConfigManager ?. teardown ( ) ;
}
}
The pending events buffer is needed because OpenCode emits tool_use events only when the tool has completed/errored, with the result included in the same event. A single NDJSON line must produce both AgentToolUseEvent and AgentToolResultEvent, but extractEvent() returns a single event. The tool_result is buffered and drained after yielding the tool_use.
extractEvent(line: string): AgentEvent | null
Every NDJSON line from OpenCode has a per-line envelope:
{ "type" : " <event_type>" , "timestamp" : " ..." , "sessionID" : " sess-abc" , ...data }
Envelope processing (before type dispatch):
Parse JSON, validate object with type string field
Extract sessionID from envelope → store in currentSessionId (first non-undefined wins, or always update)
Event type mapping (5+1 types):
OpenCode type
Maps to
Details
"text"
AgentTextEvent
{ type: "text", text: content } — complete text parts, no delta tracking needed
"tool_use"
AgentToolUseEvent + buffered AgentToolResultEvent
See tool_use handling below
"step_start"
null
Lifecycle boundary — skip
"step_finish"
null
Capture tokens and cost for usage; capture reason for stop reason
"reasoning"
null
Skip — requires --thinking flag, not emitted by default
"error"
AgentErrorEvent
{ type: "error", message: parsed.message }
text event handling
OpenCode text events carry complete text parts (not cumulative snapshots like Codex). Each event is a standalone chunk that can be directly emitted:
// Accumulate for done event enrichment
this . accumulatedText += content ;
return { type : "text" , text : content } satisfies AgentTextEvent ;
No delta tracking or lastEmittedTextLength needed (simpler than Codex).
tool_use event handling
OpenCode only emits tool_use when the tool has completed or errored. The event contains both input and result:
{
"type" : " tool_use" ,
"timestamp" : " ..." ,
"sessionID" : " sess-abc" ,
"name" : " read_file" ,
"callID" : " call-123" ,
"input" : { "path" : " /tmp/test.txt" },
"state" : {
"output" : " file contents..." ,
"error" : " "
}
}
Mapping :
Return AgentToolUseEvent:
{
type : "tool_use" ,
toolName : parsed . name ?? "" ,
toolId : parsed . callID ?? `opencode-tool-${ this . toolCounter ++ } ` ,
input : parsed . input ?? { } ,
}
Push AgentToolResultEvent to this.pendingEvents:
{
type : "tool_result" ,
toolId : /* same toolId as above */ ,
output : parsed . state ?. output ?? "" ,
isError : typeof parsed . state ?. error === "string" && parsed . state . error . length > 0 ,
}
The execute() override drains pendingEvents after yielding each event from super.execute().
step_finish event handling
Captures usage and cost data for done event enrichment:
{
"type" : " step_finish" ,
"timestamp" : " ..." ,
"sessionID" : " sess-abc" ,
"tokens" : {
"input" : 500 ,
"output" : 200 ,
"reasoning" : 50 ,
"total" : 750 ,
"cache" : { "read" : 100 , "write" : 25 }
},
"cost" : 0.0042 ,
"reason" : " end_turn"
}
Store for done event enrichment:
tokens.input → usage.inputTokens
tokens.output → usage.outputTokens
tokens.cache.read → usage.cacheReadTokens (if > 0)
tokens.cache.write → usage.cacheWriteTokens (if > 0)
cost → result.totalCostUsd
reason → result.stopReason
buildEnv(params: AgentExecuteParams): Record<string, string>
Returns empty record {}. No provider-specific env var injection needed (auth handled via params.env in base class).
MCP configuration — OpenCodeMcpConfigManager
Exported class managing the OpenCode config file lifecycle for MCP server configuration.
OpenCode reads MCP config from its settings file. Uses the merge-restore pattern (same as Gemini and Codex):
Config location : .opencode/config.json in the working directory (project-level settings)
Format : JSON with mcpServers key matching Record<string, McpServerConfig>
{
"mcpServers" : {
"remoteclaw" : {
"command" : " node" ,
"args" : [" /path/to/server.js" ],
"env" : { "KEY" : " value" }
}
}
}
Lifecycle :
setup():
Ensure .opencode/ directory exists
Read existing config (if any), save original content
Deep-merge mcpServers into existing config
Write merged config
teardown():
Restore original file content, OR
Remove created file (and directory if we created it)
Constructor : constructor(workingDirectory: string | undefined, mcpServers: Record<string, McpServerConfig>) — same signature as GeminiMcpConfigManager.
Done event enrichment
private enrichDoneEvent ( event : AgentDoneEvent ) : void {
const { result } = event ;
result . text = this . accumulatedText ;
result . sessionId = this . currentSessionId ;
if ( this . lastStepFinish ) {
const { tokens, cost, reason } = this . lastStepFinish ;
if ( tokens ) {
const usage : AgentUsage = {
inputTokens : tokens . input ?? 0 ,
outputTokens : tokens . output ?? 0 ,
...( tokens . cache ?. read > 0 ? { cacheReadTokens : tokens . cache . read } : { } ) ,
...( tokens . cache ?. write > 0 ? { cacheWriteTokens : tokens . cache . write } : { } ) ,
} ;
result . usage = usage ;
}
if ( cost !== undefined ) {
result . totalCostUsd = cost ;
}
if ( reason !== undefined ) {
result . stopReason = reason ;
}
}
}
State management
Per-execution state (reset before each run):
Field
Type
Purpose
currentSessionId
string | undefined
Extracted from envelope sessionID
accumulatedText
string
Concatenated text event content
lastStepFinish
StepFinishData | undefined
Usage/cost/reason from last step_finish
pendingEvents
AgentEvent[]
Buffer for tool_result events from tool_use pairs
toolCounter
number
Fallback tool ID generator (opencode-tool-N)
Test file specification
Create src/middleware/runtimes/opencode.test.ts following the established pattern from codex.test.ts and gemini.test.ts.
Test helper
class TestableOpenCodeCliRuntime extends OpenCodeCliRuntime {
public testBuildArgs ( params : AgentExecuteParams ) : string [ ] {
return this . buildArgs ( params ) ;
}
public testExtractEvent ( line : string ) : AgentEvent | null {
return this . extractEvent ( line ) ;
}
public testBuildEnv ( params : AgentExecuteParams ) : Record < string , string > {
return this . buildEnv ( params ) ;
}
public get testSupportsStdinPrompt ( ) : boolean {
return this . supportsStdinPrompt ;
}
public get testPendingEvents ( ) : AgentEvent [ ] {
return ( this as any ) . pendingEvents ;
}
}
Test categories
supportsStdinPrompt (1 test) :
Returns true (default from base class)
buildArgs (~6 tests) :
Produces run --format json <prompt> for new session
Produces run --format json --session <id> <prompt> for session resume
Includes prompt on session resume (unlike Codex which excludes it)
Always starts with run
Always includes --format json
Does not include --mcp-config flag
extractEvent (~16 tests) :
Extracts sessionID from envelope and returns appropriate event
Maps text event to AgentTextEvent with complete text chunk
Accumulates text across multiple text events
Maps tool_use event to AgentToolUseEvent and buffers AgentToolResultEvent
Uses callID as toolId when present
Generates fallback opencode-tool-N IDs when callID is missing
Marks tool_result as error when state.error is non-empty
Stores usage from step_finish and returns null
Stores cost from step_finish and returns null
Stores stop reason from step_finish and returns null
Skips step_start events
Skips reasoning events
Maps error event to AgentErrorEvent
Skips unknown event types
Handles missing/malformed fields gracefully
buildEnv (2 tests) :
Returns empty record
Does not inject auth vars regardless of params
Done event enrichment (~5 tests) :
Enriches done event with accumulated text, session ID, and usage
Includes cacheReadTokens and cacheWriteTokens when present
Omits cache tokens when zero
Sets totalCostUsd and stopReason from step_finish
Handles missing step_finish gracefully (no usage, no cost)
MCP config file management (~4 tests) :
Creates JSON config file when mcpServers has entries
Cleans up created file on teardown
Preserves existing config and restores on teardown
Writes correct JSON structure with mcpServers key
Total: ~34 tests
Key differences from other runtimes
Aspect
Claude
Gemini
Codex
OpenCode
Subcommand
none (-p)
none (-p)
exec
run
Prompt delivery
positional + stdin
-p flag
positional only
positional + stdin
Session resume
--resume <id>
-r <id>
exec resume <id>
--session <id>
Prompt on resume
yes
yes
no
yes
Event envelope
stream_event wrapper
bare JSON
bare JSON
per-line { type, timestamp, sessionID }
Session ID source
envelope session_id
init event
thread.started
envelope sessionID (every event)
Text events
deltas (text_delta)
deltas (delta: true)
cumulative (needs delta)
complete parts (no delta tracking)
Tool events
separate start/delta/stop
separate use/result
separate start/completed
single event (use + result combined)
Usage source
result line / message_delta
result.stats
turn.completed
step_finish.tokens
Cost tracking
yes (cost_usd)
no
no
yes (step_finish.cost)
Cache write tokens
yes
no
no
yes (tokens.cache.write)
Done event
result line
result event
turn.completed
process exit (no explicit done)
MCP config
inline JSON (--mcp-config)
.gemini/settings.json
~/.codex/config.toml
.opencode/config.json
supportsStdinPrompt
true (default)
false
false
true (default)
Acceptance criteria
References
src/middleware/types.ts — AgentRuntime, AgentEvent, AgentUsage, McpServerConfig
src/middleware/cli-runtime-base.ts — CLIRuntimeBase abstract class
src/middleware/runtimes/codex.ts — closest reference (NDJSON, config file MCP, similar structure)
src/middleware/runtimes/gemini.ts — reference for JSON-based MCP config manager
OpenCode source: anomalyco/opencode (packages/opencode/src/cli/cmd/run.ts)
Known bug: --command flag suppresses JSON output (JSON output missing when using --command flag with opencode run --format json anomalyco/opencode#2923 ) — do not use
Permissions are auto-rejected in non-interactive mode (no permission denial tracking needed)
reasoning events require --thinking flag — skipped by default
Context
Fourth concrete
CLIRuntimeBasesubclass — spawnsopencode run --format json(repo:anomalyco/opencode) and maps its per-line envelope NDJSON output toAgentEventinstances.Dependencies: types module (PR #4, merged), CLIRuntimeBase (PR #6, merged).
Reference implementations:
runtimes/claude.ts,runtimes/gemini.ts,runtimes/codex.tsfor established patterns.Specification
Constructor
supportsStdinPrompt(no override needed)OpenCode supports stdin prompt delivery for >10KB prompts (same threshold as Claude). The default
truefromCLIRuntimeBaseis correct — do not override.buildArgs(params: AgentExecuteParams): string[]["run", "--format", "json", params.prompt]["run", "--format", "json", "--session", params.sessionId, params.prompt]Key behaviors:
run(unlike Codex'sexec)--format jsonenables NDJSON output (not--quiet— flag does not exist, confirmed OpenCode CLI source analysis)--session <id>flag for session resume (prompt is still included on resume)--mcp-configflag — MCP handled via config file (see MCP section below)execute()overrideOverride with the standard pattern plus pending events buffer drain:
The pending events buffer is needed because OpenCode emits
tool_useevents only when the tool has completed/errored, with the result included in the same event. A single NDJSON line must produce bothAgentToolUseEventandAgentToolResultEvent, butextractEvent()returns a single event. Thetool_resultis buffered and drained after yielding thetool_use.extractEvent(line: string): AgentEvent | nullEvery NDJSON line from OpenCode has a per-line envelope:
{ "type": "<event_type>", "timestamp": "...", "sessionID": "sess-abc", ...data }Envelope processing (before type dispatch):
typestring fieldsessionIDfrom envelope → store incurrentSessionId(first non-undefined wins, or always update)Event type mapping (5+1 types):
type"text"AgentTextEvent{ type: "text", text: content }— complete text parts, no delta tracking needed"tool_use"AgentToolUseEvent+ bufferedAgentToolResultEvent"step_start"null"step_finish"nulltokensandcostfor usage; capturereasonfor stop reason"reasoning"null--thinkingflag, not emitted by default"error"AgentErrorEvent{ type: "error", message: parsed.message }textevent handlingOpenCode
textevents carry complete text parts (not cumulative snapshots like Codex). Each event is a standalone chunk that can be directly emitted:No delta tracking or
lastEmittedTextLengthneeded (simpler than Codex).tool_useevent handlingOpenCode only emits
tool_usewhen the tool has completed or errored. The event contains both input and result:{ "type": "tool_use", "timestamp": "...", "sessionID": "sess-abc", "name": "read_file", "callID": "call-123", "input": { "path": "/tmp/test.txt" }, "state": { "output": "file contents...", "error": "" } }Mapping:
Return
AgentToolUseEvent:Push
AgentToolResultEventtothis.pendingEvents:The
execute()override drainspendingEventsafter yielding each event fromsuper.execute().step_finishevent handlingCaptures usage and cost data for done event enrichment:
{ "type": "step_finish", "timestamp": "...", "sessionID": "sess-abc", "tokens": { "input": 500, "output": 200, "reasoning": 50, "total": 750, "cache": { "read": 100, "write": 25 } }, "cost": 0.0042, "reason": "end_turn" }Store for done event enrichment:
tokens.input→usage.inputTokenstokens.output→usage.outputTokenstokens.cache.read→usage.cacheReadTokens(if > 0)tokens.cache.write→usage.cacheWriteTokens(if > 0)cost→result.totalCostUsdreason→result.stopReasonbuildEnv(params: AgentExecuteParams): Record<string, string>Returns empty record
{}. No provider-specific env var injection needed (auth handled viaparams.envin base class).MCP configuration —
OpenCodeMcpConfigManagerExported class managing the OpenCode config file lifecycle for MCP server configuration.
OpenCode reads MCP config from its settings file. Uses the merge-restore pattern (same as Gemini and Codex):
.opencode/config.jsonin the working directory (project-level settings)mcpServerskey matchingRecord<string, McpServerConfig>{ "mcpServers": { "remoteclaw": { "command": "node", "args": ["/path/to/server.js"], "env": { "KEY": "value" } } } }Lifecycle:
setup():.opencode/directory existsmcpServersinto existing configteardown():Constructor:
constructor(workingDirectory: string | undefined, mcpServers: Record<string, McpServerConfig>)— same signature asGeminiMcpConfigManager.Done event enrichment
State management
Per-execution state (reset before each run):
currentSessionIdstring | undefinedsessionIDaccumulatedTextstringtextevent contentlastStepFinishStepFinishData | undefinedstep_finishpendingEventsAgentEvent[]tool_resultevents fromtool_usepairstoolCounternumberopencode-tool-N)Test file specification
Create
src/middleware/runtimes/opencode.test.tsfollowing the established pattern fromcodex.test.tsandgemini.test.ts.Test helper
Test categories
supportsStdinPrompt(1 test):true(default from base class)buildArgs(~6 tests):run --format json <prompt>for new sessionrun --format json --session <id> <prompt>for session resumerun--format json--mcp-configflagextractEvent(~16 tests):sessionIDfrom envelope and returns appropriate eventtextevent toAgentTextEventwith complete text chunktexteventstool_useevent toAgentToolUseEventand buffersAgentToolResultEventcallIDastoolIdwhen presentopencode-tool-NIDs whencallIDis missingtool_resultas error whenstate.erroris non-emptystep_finishand returnsnullstep_finishand returnsnullstep_finishand returnsnullstep_starteventsreasoningeventserrorevent toAgentErrorEventbuildEnv(2 tests):Done event enrichment (~5 tests):
cacheReadTokensandcacheWriteTokenswhen presenttotalCostUsdandstopReasonfromstep_finishstep_finishgracefully (no usage, no cost)MCP config file management (~4 tests):
mcpServershas entriesmcpServerskeyTotal: ~34 tests
Key differences from other runtimes
-p)-p)execrun-pflag--resume <id>-r <id>exec resume <id>--session <id>stream_eventwrapper{ type, timestamp, sessionID }session_idiniteventthread.startedsessionID(every event)text_delta)delta: true)resultline /message_deltaresult.statsturn.completedstep_finish.tokenscost_usd)step_finish.cost)tokens.cache.write)resultlineresulteventturn.completed--mcp-config).gemini/settings.json~/.codex/config.toml.opencode/config.jsonsupportsStdinPrompttrue(default)falsefalsetrue(default)Acceptance criteria
src/middleware/runtimes/opencode.tsexportsOpenCodeCliRuntimeextendingCLIRuntimeBasesrc/middleware/runtimes/opencode.tsexportsOpenCodeMcpConfigManager"opencode"tosuper()supportsStdinPromptis NOT overridden (defaults totrue)buildArgs()produces correct args for new session and resume (with--sessionflag)buildArgs()includes prompt on resume (unlike Codex)extractEvent()parses per-line envelope and extractssessionIDextractEvent()maps all 6 event types correctly (text, tool_use, step_start, step_finish, reasoning, error)textevents emit complete text parts (no delta tracking)tool_useevents emitAgentToolUseEventand bufferAgentToolResultEventinpendingEventsexecute()override drainspendingEventsafter each yielded eventstep_finishusage maps toAgentUsageincludingcacheWriteTokensstep_finish.costmaps toresult.totalCostUsdstep_finish.reasonmaps toresult.stopReasonbuildEnv()returns empty recordOpenCodeMcpConfigManageruses merge-restore pattern on.opencode/config.jsontext,sessionId,usage,totalCostUsd,stopReasonsrc/middleware/runtimes/opencode.test.tscovers all specified categories (~34 tests)npx vitest run src/middleware/runtimes/opencode.test.tsReferences
src/middleware/types.ts—AgentRuntime,AgentEvent,AgentUsage,McpServerConfigsrc/middleware/cli-runtime-base.ts—CLIRuntimeBaseabstract classsrc/middleware/runtimes/codex.ts— closest reference (NDJSON, config file MCP, similar structure)src/middleware/runtimes/gemini.ts— reference for JSON-based MCP config manageranomalyco/opencode(packages/opencode/src/cli/cmd/run.ts)--commandflag suppresses JSON output (JSON output missing when using--commandflag withopencode run --format jsonanomalyco/opencode#2923) — do not usereasoningevents require--thinkingflag — skipped by default