OpenCode CLI Adapter
The OpenCode adapter connects Sortie to the OpenCode CLI via subprocess management. It runs OpenCode 2.x, published on npm as @opencode/cli, and checks at the start of every session that agent.command reports a 2.x version; see version check. It launches opencode run --format json, reads newline-delimited stdout envelopes, reads the runtime’s permission warnings from stderr, and normalizes the stream into Sortie’s own event vocabulary. Registered under kind "opencode".
Each turn spawns a fresh subprocess. The adapter emits activity-visible events so the orchestrator stall watchdog can observe progress. Session start does run a version query (see version check), but that query checks no credential and performs no authentication preflight. The CLI accepts no MCP configuration path, so on a local launch the adapter translates the generated configuration into OpenCode’s own form and delivers it in the turn’s environment; see MCP.
See also: WORKFLOW.md configuration for the full agent schema, environment variables for runtime environment behavior, error reference for all agent error kinds, how to write a prompt template for template authoring.
Configuration
The adapter reads from two configuration sections in WORKFLOW.md front matter: the generic agent block (shared by all adapters) and the opencode extension block.
Version check
Session start runs the configured command with --version, bounded the same way the post-turn export launch is (see read_timeout_ms below), and reads only the first non-empty line of the output. It scans that line’s whitespace-separated fields, dropping one leading v from each, for the first one that matches a bare semantic version, and requires that version’s leading number to be 2.
A version on a later line is never found: if the first non-empty line carries no such field, or the output was truncated, the check fails. The check runs once per session, over SSH when the launch is remote, and is never repeated or cached. A later major than 2 fails the check like any other, so install @opencode/cli@2 to stay on a supported major.
Errors:
| Condition | Error kind | Message |
|---|---|---|
| The version query could not be started | response_error | could not start the agent runtime to read its version |
| Session start’s own context ended before the query’s process started running | turn_cancelled | session start cancelled |
| Session start’s own context ended before the query finished | response_error | the session start ended while the agent runtime was reporting its version |
| The version query did not finish within its bound | response_timeout | the agent runtime did not report its version within <N> ms |
| The command exited unexpectedly | port_exit | An SSH connection failure on a remote launch, otherwise the early exit report |
| The command’s output carries no version Sortie can read | agent_not_found | the configured OpenCode command reported no version Sortie can read: <line or "no output"> |
The reported major is not 2 | agent_not_found | OpenCode <version> is not supported; install OpenCode 2.x, published on npm as @opencode/cli |
agent section
These fields control the orchestrator’s scheduling behavior. They are not passed to the OpenCode CLI.
| Field | Type | Default | Description |
|---|---|---|---|
kind | string | - | Must be "opencode" to select this adapter. |
command | string or list | opencode | Path or name of the OpenCode binary. Resolved from PATH at session start. agent.command reaches this kind only when it is the default kind; a rule-routed opencode session beside another default kind runs opencode itself. See agent.command for the list form. |
max_turns | integer | 20 | Maximum Sortie turns per worker session. The orchestrator runs a turn up to this many times, re-checking tracker state after each turn. |
max_sessions | integer | 0 (unlimited) | Maximum completed sessions per issue before the orchestrator stops retrying. 0 disables the budget. |
max_concurrent_agents | integer | 10 | Global concurrency limit across all issues. |
max_concurrent_agents_by_state | map | {} | Per-state concurrency limits. Keys are state names, lowercased for matching. See agent.max_concurrent_agents_by_state for how an invalid entry is handled. |
turn_timeout_ms | integer | 3600000 (1 hour) | Total timeout for a single turn. The orchestrator cancels the turn when exceeded. |
read_timeout_ms | integer | 5000 (5 seconds) | Bounds the wait for the turn’s first JSON envelope, which is never shorter than 60 seconds, so a value below 60000 does not shorten it. OpenCode prints nothing until the model starts answering, so the wait carries the provider’s latency. The same wait applies to the turn of a credential-verification session. Doubled and capped at 30 seconds, this value also bounds the version query every session start makes, the post-turn export subprocess, and, on a credential-verification session, the session delete cleanup call; those three have no 60-second minimum. It does not bound anything after the first envelope arrives. Falls back to 30 seconds when unset or not positive. |
stall_timeout_ms | integer | 300000 (5 minutes) | Maximum time between consecutive emitted events before the orchestrator treats the turn as stalled. 0 or negative disables stall detection. |
stop_grace_ms | integer | 5000 (5 seconds) | How long the adapter waits for the subprocess to exit on its own after a graceful termination signal, before it force-terminates the process group. Must be positive. |
max_retry_backoff_ms | integer | 300000 (5 minutes) | Maximum delay cap for exponential backoff between retry attempts. |
agent:
kind: opencode
command: opencode
max_turns: 5
max_sessions: 3
max_concurrent_agents: 4
stall_timeout_ms: 300000
max_concurrent_agents_by_state:
in progress: 3
to do: 1opencode extension section
These fields are adapter-specific. Each maps to a CLI flag or to a member of the inline configuration document the adapter delivers in OPENCODE_CONFIG_CONTENT; see managed environment.
| Field | Type | Default | Description |
|---|---|---|---|
model | string | (CLI default) | Value forwarded to --model. A non-empty effort or variant is appended after a #. |
agent | string | (none) | OpenCode agent name forwarded to --agent. |
effort | string | (none) | Reasoning level, carried on every turn, credential verification included, in OpenCode’s one model-variant slot. See adapter pass-through configuration for how an unset value is read. Folded into model as a #-separated suffix, so it needs an opencode.model that carries no # of its own; see settings refused at session start. Setting it together with variant is an error; see validate-time checks. |
variant | string | (none) | Fills the same slot as effort, and setting both is an error. Folded into model as a #-separated suffix under the same requirements as effort. |
thinking | boolean | false | Adds --thinking. |
dangerously_skip_permissions | boolean | true | Adds --dangerously-skip-permissions when true. When false, the runtime refuses every permissioned tool call instead of performing it, and access outside the workspace is refused unless a tool list names external_directory; see validate-time checks and access outside the workspace. |
disable_autocompact | boolean | true | When true, sets the inline configuration document’s compaction.auto to false. |
allowed_tools | list of strings | [] | Builds an allowlist policy: listed keys become allow, every known key not listed becomes deny, unknown keys are forwarded unchanged. The policy rides in the inline configuration document’s permission member. |
denied_tools | list of strings | [] | Adds deny entries to the same policy allowed_tools builds. Overlap with allowed_tools is refused at session start; see settings refused at session start. |
mcp_config | string | (none; read by the worker) | Path to an operator-supplied MCP server configuration file, resolved relative to the WORKFLOW.md directory when not absolute. Its servers are merged into the generated configuration, and the adapter translates the merged result into OpenCode’s own server entries on a local launch. See MCP. |
The adapter always adds run --format json --standalone, and delivers the prompt on standard input.
opencode:
model: <provider>/<model-id>
effort: high
dangerously_skip_permissions: true
disable_autocompact: true
allowed_tools:
- read
- edit
- globSettings refused at session start
Session start refuses the settings below before it launches anything. Each fails with error kind agent_not_found and starts no subprocess.
| Condition | Message |
|---|---|
allowed_tools and denied_tools name at least one of the same keys | allowed_tools and denied_tools overlap: <keys>, with the shared keys sorted and comma-separated |
opencode.pure is true | opencode.pure is not supported by OpenCode 2.x; remove it |
opencode.effort or opencode.variant is set without opencode.model | opencode.<key> needs opencode.model on OpenCode 2.x; set opencode.model or remove opencode.<key>, with <key> the one that is set, effort or variant |
opencode.model already carries a #-separated suffix and opencode.effort or opencode.variant is also set | opencode.model already names a variant after #; remove that suffix or remove opencode.<key> |
opencode.effort and opencode.variant are both set | opencode.effort and opencode.variant both set the model variant; set one of them |
The overlap and the effort/variant conflict are also validate-time checks.
agent.max_turns vs. OpenCode inner turn scope
The adapter exposes no OpenCode-specific inner turn or step-budget field.
| Field | Controls | Scope |
|---|---|---|
agent.max_turns | Sortie’s orchestrator turn loop | How many times the orchestrator runs a turn per worker session. |
(none) | OpenCode inner turn budget | The adapter does not expose an OpenCode equivalent to claude-code.max_turns or copilot-cli.max_autopilot_continues. Each turn executes one opencode run process and lets the CLI run until it exits. |
Use turn_timeout_ms to bound wall-clock time for a single turn. There is no adapter-level cap on OpenCode’s internal step count within that turn.
Permission policy
The adapter synthesizes one managed permission policy from allowed_tools and denied_tools and injects it as the permission member of the inline configuration document (see managed environment). The policy is separate from the --dangerously-skip-permissions flag, but with dangerously_skip_permissions: false the adapter also writes a policy when both lists are empty; see access outside the workspace.
| Input | Adapter behavior |
|---|---|
No allowed_tools, no denied_tools | With dangerously_skip_permissions: true, sets no policy, and OpenCode falls back to on-disk config and its own defaults. With false, sets only external_directory to ask. |
allowed_tools only | Sets each listed key to allow, then sets every known key not listed to deny. |
denied_tools only | Sets the listed keys to deny. With dangerously_skip_permissions: false and external_directory not listed, it also sets external_directory to ask. Other keys fall through to OpenCode defaults or operator config. |
| Both fields present | Starts with the allowlist behavior above, then applies deny overrides from denied_tools. |
| Overlap between the two fields | Session start fails. |
| Unknown permission key | Forwards the key verbatim and logs it at debug level. |
The adapter’s known permission-key set is:
| Key | Included in blanket deny when allowed_tools is non-empty |
|---|---|
bash | Yes |
codesearch | Yes |
doom_loop | Yes |
edit | Yes |
external_directory | Yes |
glob | Yes |
grep | Yes |
list | Yes |
lsp | Yes |
opencode_list_mcp_resources | Yes |
opencode_models | Yes |
opencode_read_mcp_resource | Yes |
opencode_session_move | Yes |
opencode_session_rename | Yes |
question | Yes |
read | Yes |
skill | Yes |
task | Yes |
todowrite | Yes |
webfetch | Yes |
websearch | Yes |
This is the set this version of the adapter knows about, not a catalogue of OpenCode’s tools. A key OpenCode adds later is unknown to the adapter until the adapter learns it, and an unknown key you write is forwarded unchanged. The adapter forwards every key unchanged, so a key takes effect only where the runtime itself recognizes it.
Access outside the workspace
With dangerously_skip_permissions: false, the agent cannot read or write files outside the session workspace unless you allow it. The runtime refuses the access and reports it to the model like any other refused tool call. To allow it, name external_directory in allowed_tools.
A non-empty allowed_tools denies every known key it does not list, so list each tool the agent needs (read, edit, bash, and so on) alongside external_directory. The other way to allow it is dangerously_skip_permissions: true, which approves every permissioned call and not only this one.
The adapter decides external_directory as follows:
| Configuration | Action written for external_directory |
|---|---|
dangerously_skip_permissions: true | None. The adapter writes no external_directory rule of its own. |
false, neither list names the key, allowed_tools empty | ask, which the runtime refuses because no one can answer. |
false, allowed_tools non-empty and not naming the key | deny, from the blanket deny described above. |
false, allowed_tools names the key | allow. |
false, denied_tools names the key | deny. |
On a local launch, an external_directory rule in your own OpenCode configuration files does not apply in this mode, because the adapter writes a value for the key and its document loads last; see managed environment. On an SSH worker, an OPENCODE_PERMISSION value set in the remote login environment is applied after the adapter’s document and wins for every key it names.
Managed environment
The adapter sets one environment variable on every subprocess it launches, the version check, the usage export, and the credential-verification session delete included:
| Variable | Value |
|---|---|
OPENCODE_DISABLE_AUTOUPDATE | true |
Sharing, compaction, the permission policy, and tool servers ride together in one inline configuration document, delivered through OPENCODE_CONFIG_CONTENT on every turn, local or remote, whether or not the session carries tool servers.
share is always "disabled". compaction.auto is false only when opencode.disable_autocompact is true. permission carries the policy above and mcp carries the translated tool servers, a member present when the session delivers any; see MCP. agent.title.disable is true, the title-agent switch described in Token accounting.
A remote turn carries the same document. When the launch requests a reverse forward, the whole document is sent once the forward’s remote port is known, because the mcp member names that port. The version check, usage export, and session delete invocations never carry it, on either kind of launch.
The credential-verification session’s document sets permission to deny every tool, whatever opencode.allowed_tools, opencode.denied_tools, and opencode.dangerously_skip_permissions say, so the request carries no tool definitions, calls no tool, and writes nothing into the issue workspace. The check makes one model request. It still carries the instruction files and the built-in system prompt, because the runtime offers no switch that drops them. When the check falls back to the full context, it uses the configured policy.
On a local launch, before adding its managed values, the adapter strips OPENCODE_AUTO_SHARE, OPENCODE_DISABLE_AUTOCOMPACT, OPENCODE_DISABLE_AUTOUPDATE, OPENCODE_DISABLE_LSP_DOWNLOAD, OPENCODE_PERMISSION, and OPENCODE_CONFIG_CONTENT out of the inherited environment, so an operator-side value never reaches the subprocess. It does not remove permission rules from opencode.json, so OpenCode still deep-merges the adapter policy with on-disk configuration, the adapter’s own document loading last.
On a remote launch the adapter strips nothing. It sends its own OPENCODE_CONFIG_CONTENT, which replaces any value of that name in the remote login environment, and leaves every other variable there, OPENCODE_PERMISSION included, as it finds it.
Validate-time checks
When agent.kind is opencode, the sortie validate pipeline runs OpenCode-specific config checks in addition to the generic preflight validation. They build no adapter and launch no subprocess, and the same checks run at startup and on every workflow reload, so the verdict is identical in all three places.
Errors
| Check | Condition | Message |
|---|---|---|
opencode.allowed_tools.overlap | allowed_tools and denied_tools name at least one of the same keys | allowed_tools and denied_tools overlap: <keys> |
opencode.effort.conflict | effort and variant are both set to a non-empty value | opencode.effort and opencode.variant both set the model variant; set one of them |
Session start reports each of these with the same message, so the two paths can never disagree.
Warnings
| Check | Condition | Message |
|---|---|---|
opencode.dangerously_skip_permissions.auto_reject | dangerously_skip_permissions is explicitly false | opencode.dangerously_skip_permissions is set to false, so the runtime refuses every permissioned tool call instead of performing it |
This is a warning rather than an error. Warnings leave valid true and the exit code 0. The runtime refuses the request itself, so the setting never leaves a turn waiting for a person; it does stop the agent from using any permissioned tool. A refusal reaches the model as an errored tool result, and the turn continues. An absent or true value draws nothing.
Session lifecycle
Session start
Refuses unusable settings, validates the workspace path, resolves the launch target, checks the OpenCode version, and initializes adapter-owned session state. No turn subprocess is started, but the version query below does start and exit a subprocess of its own, and so does the session export a continued session reads.
- Refuses the settings listed under settings refused at session start.
- Validates that the workspace path is a non-empty absolute path pointing to an existing directory.
- Resolves the configured command from
PATH, defaulting toopencodewhenagent.commandis empty. In SSH mode, resolves the localsshbinary instead and stores the remote command string for later use. - Resolves the session’s tool delivery from the generated MCP configuration, on a local launch and an SSH launch alike. On an SSH launch this starts Sortie’s HTTP tool server and opens one short extra SSH connection to learn whether the worker host accepts the reverse forward. See MCP.
- Queries the resolved command’s version, over SSH when the launch is remote, and refuses the session when it is unreadable or not a 2.x release; see version check.
- When continuation is requested, reads the saved session’s export, over SSH when the launch is remote, to fix the session’s earlier spend as history; see resumed sessions. A failed read does not fail session start.
- Builds the inline configuration document every turn of the session will carry, from the settings and the tool servers resolved in step 4.
- The session has no running process ID or subprocess yet, and takes the session ID saved from a previous run as its own when continuation is requested.
Session start performs no provider-auth probe.
Errors:
| Condition | Error kind |
|---|---|
| Empty or non-existent workspace path | invalid_workspace_cwd |
| Workspace path is not a directory | invalid_workspace_cwd |
| Agent command is empty or whitespace-only | agent_not_found |
Local OpenCode binary not found in PATH | agent_not_found |
| SSH binary not found (SSH mode) | agent_not_found |
| Generated MCP configuration unreadable or not expressible | response_error |
| A refused setting | agent_not_found; see settings refused at session start |
| Version query fails, times out, or reports a version other than 2.x | See version check for the full set of conditions, kinds, and messages |
Turn
Spawns one OpenCode subprocess, reads its stdout, and delivers normalized events to the orchestrator.
- Builds the managed environment and the per-turn argument list.
- Adds
run --format json --standaloneto every invocation and delivers the prompt on the subprocess’s standard input.--standalonekeeps the launch from joining a shared background service another client started. - Adds
--session <id>when the session already has an OpenCode session ID. - Launches the subprocess locally or through SSH, with the full parent process environment plus the managed environment, which includes
OPENCODE_CONFIG_CONTENTon every turn. On a local launch,PWDin that environment names the same verified workspace path the subprocess’s own working directory is set to, so the subprocess lands in the workspace. - Isolates the subprocess in its own process group before start, then arms a graceful process-group signal for cancellation, bounded by
stop_grace_ms. - Reads stdout and stderr concurrently while waiting for the subprocess to exit.
- Applies a startup timer set to the larger of
read_timeout_msand 60 seconds. A stdout line that fails to parse as a JSON envelope still resets the timer before the first accepted envelope arrives, but does not itself count as the runtime having responded; see early exit report. - On the first JSON envelope with
sessionID, adopts the session ID if unset or verifies it matches the resumed session. Emitssession_startedonce per session. - Maps JSON envelopes and tolerated plain-text lines into events.
- Once the subprocess has been reaped, gives stderr collection up to five seconds to finish, then gives whatever stdout is still in flight another five seconds to arrive, before settling token usage from the turn’s step reports and the session export,
opencode session export --standalone --sanitize <sessionID>; see Token accounting. - The turn ends based on the terminal error envelope, cancellation state, startup timeout, or process exit status.
A turn that instead ends by cancellation, a read timeout, or a stdout read error still attempts this export before reporting its outcome; see Accumulation logic for the one ending that does not.
Session stop
Marks the session closed and terminates the currently running turn subprocess, if any.
- Marks the session closed and detaches the active turn runtime from session state.
- Sends a graceful process-group signal when a turn is still running.
- Waits up to
stop_grace_msfor the subprocess to exit. - Force-kills the process group if it is still alive after the grace window.
- Reports the orchestrator’s own deadline error instead of a clean stop if that deadline expires first.
- On a credential-verification session that reached a session ID, runs
opencode session delete --standalone <id>in the workspace before returning. A failed delete, including exit1for an identifier the runtime does not recognize, is only logged; it never fails the run.
Safe to call when no subprocess is active.
Process shutdown
Before start, the subprocess is isolated in its own process group. A graceful process-group signal is armed for cancellation, bounded by stop_grace_ms. On Unix, graceful shutdown is SIGTERM and force kill is SIGKILL to the process group.
On Windows, graceful shutdown is CTRL_BREAK_EVENT to the process group, and the subprocess is assigned to a Job Object with KILL_ON_JOB_CLOSE so force termination kills the full descendant tree.
The subprocess starts suspended and is resumed only after that assignment succeeds, so nothing it spawns can run before the job takes effect; this covers the auxiliary launches described below, not only turns. A failed assignment logs WARN process group assignment failed and the launch runs without a job; a failed resume logs WARN process resume failed and reports port_exit. A failure to start the subprocess or to create its stdout or stderr pipe reports port_exit too.
A launch whose context is already done when the resume is reached, by cancellation or by its deadline, is never resumed: Sortie terminates the subprocess before it runs and logs no resume warning. A turn ends this way as turn_cancelled.
Shutdown is turn-scoped, not session-scoped. Session stop performs an explicit graceful-to-force sequence. Turn cancellation is stricter: when the turn is cancelled, the graceful signal fires immediately, and the adapter’s cancellation path also force-kills the process group during teardown if the process is still alive. After the subprocess exits, the adapter performs a best-effort group kill to clean up surviving children.
Event stream
The adapter reads stdout as newline-delimited envelopes. Most lines are JSON objects from opencode run --format json. Permission rejection warnings can also appear as plain text on stdout even in JSON mode. Reading stdout allows lines up to 10 MB long, to accommodate large tool payloads.
What the adapter emits
The adapter maps each envelope onto Sortie’s normalized event vocabulary, so what reaches the orchestrator, the logs, and the dashboard is the same set of events every adapter produces. OpenCode’s own envelope types and their fields are OpenCode’s to define; see external references.
Two behaviours are the adapter’s own. Every stdout line that fails to parse becomes a malformed event, truncated, rather than failing the turn, and it still resets the startup read timer, but it does not count as the runtime having responded; see early exit report. A permission request the runtime refuses surfaces twice, as a tool_result carrying the tool error and as a notification; no consent was granted, and the refusal does not end the turn. Sortie scans stderr for those refusals only after the process exits.
Token accounting
The adapter reads token usage from two sources, and Sortie’s usage component settles each turn from them. The live source is the tokens object of each step_finish report on stdout: one report per model request, identified by the part’s id. The records source is the session export, opencode session export --standalone --sanitize <sessionID>, which a second subprocess prints once the turn’s subprocess is gone: one assistant message per finished request, with that request’s tokens, model, and cost. Both sources count the same requests, so their figures are never added. Reported counts are cumulative over the whole session the orchestrator opened, across every turn of it, and never decrease.
The adapter disables OpenCode’s title agent by setting agent.title.disable to true in the OPENCODE_CONFIG_CONTENT document, so a turn makes no session-title request. That request is billed by the provider but recorded in no assistant message, so neither source would have counted it. The session keeps OpenCode’s default title. A remote turn carries the same document, so it makes no title request either.
For how provenance, completeness, and lower bounds work across every agent kind, see spend measurement. For this kind’s declaration beside every other kind’s, see the usage reporting table.
Accumulation logic
- Once the main
opencode runsubprocess has exited or been killed and waited for, the adapter runs the session export when a session ID is known, in the same workspace. This runs on every turn ending: normal completion, a non-zero exit, a stdouterrorenvelope, cancellation, a read timeout, and a stdout read error. A session ID mismatch is the one ending that does not run it, because the work ran in a session other than the one the export would read. - A session ID, once learned, persists for the rest of the session: either from a resumed session’s saved ID, or from the first JSON envelope, in this turn or an earlier one, that carries one. A read timeout recovers a figure only on a resumed session or a later turn, because it fires only before that turn’s own first JSON envelope arrives, so it never recovers one on the first turn of a new session. Cancellation and a stdout read error on that same first turn recover a figure only if an envelope carrying the session ID had already arrived before the turn ended.
- The export subprocess runs with only
OPENCODE_DISABLE_AUTOUPDATE=true. It never carriesOPENCODE_CONFIG_CONTENT, so it never carries the tool policy or the tool servers either. agent.read_timeout_msbounds the turn’s whole read: the session’s export and the export of every subagent session the turn ran. With the workflow default of5000, that read has 5 seconds. An export that runs out of time counts as failed.- The adapter refuses an export larger than 64 MiB, one that is not valid JSON, and one whose own
info.idis not the current session. From an accepted export it takes everyassistantmessage whosefinishfield is present and non-empty, not just the most recent message. A message with an empty or absentfinishis excluded regardless of its token counts: OpenCode saves an assistant message with zero tokens before it calls the model, so a turn killed mid-request exports a placeholder, not a measurement. Each message carriestype,finish,tokens,model, andcostat its own top level. On a resumed session, the messages the session held before the run are history and never count. - From each step report and each message it reads
tokens.input,tokens.output,tokens.reasoning,tokens.cache.read, andtokens.cache.write. Every one must be present and an integer. A count that is absent, null, or not an integer makes the report or message unknown, never zero, and so does a missingtokensobject or a reading with no input and no output tokens. input_tokensisinput + cache.read + cache.write;output_tokensisoutput + reasoning.cache_read_tokensandcache_write_tokenscarry the two cache counts separately as disjoint subsets of input.total_tokensis computed asinput_tokens + output_tokensrather than read fromtokens.total, which counts cache and reasoning tokens on a different basis.
How a turn settles depends on the step report that ends it, a step_finish whose reason is anything other than tool-calls:
| The turn | Figure | Provenance | Per-model split |
|---|---|---|---|
Ended by its final step report, with no stdout error envelope and every step report known | The sum of the turn’s step reports, each request counted once at its largest reading; complete | output, read live | From the export, when its new messages add up to exactly that sum |
Has no final step report, or ended by an error envelope, cancellation, a stop, a kill, or a read failure, and the export holds messages the run has not counted | Those messages; complete only when the runtime ended the turn itself and every new message is known | journal, read records | By each message’s model |
| As above, but the export failed, timed out, or holds no new message | The sum of the turn’s step reports, as a lower bound; no figure when the turn has no step report | output, read live | None |
Some OpenCode 2.x releases print no step_finish for a turn’s final text step, so on those releases the export settles every completed turn. An unknown step report keeps its turn from settling on step reports. An unknown export message stops the count at that message: the messages before it count, and the turn is a lower bound.
A later turn whose export holds requests an earlier turn left uncounted counts them, so the run’s total converges on the export’s. A failed or empty export never lowers a figure the run has already reported. A failed export logs one DEBUG record, opencode session export failed, with the error and never the export’s content; a failed subagent export adds child_session_id.
The adapter emits at most one token_usage event per turn, when the turn produces a figure. A session is recorded as measured once any of its turns produces one, a lower bound included; a session no turn produced a figure for is recorded as unmeasured.
A turn that served model output counts toward unaccounted_turns unless its figure is complete. That leaves out a turn that settled on a lower bound or produced no figure, and every turn the runtime did not end itself: one that was stopped, failed, or cut short. A later turn settled complete from the export takes in the turns before it that the runtime completed, and they stop counting, unless a stopped or failed turn came between. No later turn does this once a turn has run short of a subagent session’s records, or once a resumed session’s history was fixed at a turn’s end.
Subagent sessions
A turn that hands work to a subagent runs that work in a child session. The stream shows one tool_use for the call, with state.input.agent set and the child’s session ID at state.metadata.metadata.sessionID, and no event from the child. The parent’s export holds none of the child’s messages, so at the end of the turn the adapter also exports every child session the turn named, once each, through the same command and under the same bound. Each child message counts once, in the turn that ran it, toward the run’s total, agent.max_tokens, and the cost limits, and joins the per-model split under its own model. The journal source then appears in the turn’s provenance beside the source that settled the parent’s own figure.
The turn is complete only when every subagent call completed, named its session, and had its export read with every message known. Otherwise the turn is a lower bound, and the child sessions that did read still count.
Resumed sessions
When a session continues a saved OpenCode session, session start reads that session’s export once, under the same read_timeout_ms bound. Every message it holds becomes history, matched by message ID whatever its creation time, so the session’s earlier spend never lands in this run’s total. A new session starts with no history and needs no read.
When that read fails, the history is unknown rather than empty. A turn the final step report settles still counts its own requests, but no other turn can count from the export until a turn-end export is read; that read fixes the history after its turn settles, so those turns stand as lower bounds.
Model tracking
The main stdout stream does not supply a stable final model identifier, and the step reports name no model. The adapter reads the model from the export only: each counted message’s model.providerID and model.id, joined as providerID/modelID. Each counted message adds to a per-model count under that key, and the turn’s reported model is the one with the largest share of the tokens the turn split. A message whose payload lacks either value adds to no entry, so per-model attribution works only when the export includes both (see the model label).
Reported cost
Sortie reads each counted message’s cost as that request’s amount in US dollars, under the message’s model. It never reads the cost of a step_finish report or the export’s session-level cost. The rates in force (token_rates over the price table) price every model they list from the normalized token counters; a model they do not list is priced at its reported amount.
A model’s amount is a lower bound when a counted message’s cost is absent, null, negative, or 0 beside tokens, as a free model reports it, when the turn’s figure carries no per-model split, and when a subagent session ran on that model, because a child session’s cost is never added to the parent’s. A cost priced from a lower-bound amount is recorded as partial.
API timing
The adapter does not emit per-request API timing and does not report how long an API request took on completion, failure, or token events. The export subprocess runs after the main turn exits inside its own timeout window, but its duration is not surfaced as a separate metric.
Usage limits
When each turn ends, the adapter runs opencode api experimental.usage.limits and reports the limits of the signed-in OpenCode console subscription. The command answers from OpenCode’s own sign-in, uses OpenCode’s background service, and starts it when it is not running. It runs on the host that runs the agent, the worker host for an SSH worker, and Sortie’s host contacts no other host for it. A turn that was cancelled or timed out runs nothing. A failed command reports nothing and leaves the limits already reported in place.
| Console limit | Label |
|---|---|
rolling | Rolling limit |
weekly | Weekly limit |
monthly | Monthly limit |
The share used is the limit’s percent, and the reset time is its resetsAt. A limit the console marks as limited has status exhausted; every other limit has no status.
The adapter stops asking for the rest of the session when the command cannot answer: a release without the command, an OpenCode that is not signed in to its console, or a command that outlives its timeout. An account with no console subscription reports nothing.
A free usage limit that a run reaches is reported separately, from the run’s error event, as Free usage limit with status exhausted. The error’s retry delay gives its reset time. See free-tier refusals.
Tool call tracking
Correlation
OpenCode’s CLI envelope already carries terminal tool state. The adapter does not correlate a start event with a later completion event.
- Parses the
tool_useenvelope. - Reads the tool name from
part.tool. - Computes duration from
part.state.time.end - part.state.time.start. - Marks the tool result as an error when
part.state.statusequalserror, compared case-insensitively.
callID is parsed but not used for cross-event correlation.
Tool kind and argument
Each tool_result event also carries the kind of work the tool did and its one main argument, read from part.state.input, for five tools:
| OpenCode tool | Kind | Input member read as the argument |
|---|---|---|
shell | shell | command |
read | read | path |
write | edit | path |
edit | edit | path |
skill | skill | id |
For a skill call the argument is the skill’s name. Any other tool name carries the name alone, with no kind and no argument. A call whose input is not an object, or whose named member is absent or not a string, carries the kind and no argument. The adapter reads no other input member, so the argument never carries the call’s other inputs.
The served members are in the HTTP API reference, and how they show is in the session view.
Tool error detail
When part.state.status is error, the adapter copies part.state.error into the normalized event message and truncates it to 500 runes. It does not strip XML wrappers, ANSI sequences, or stderr text.
A rejected permission request reaches the message field as whatever the runtime wrote into part.state.error; the adapter neither recognizes nor rewrites that text. The separate notification for a rejection comes from a stderr line beginning ! permission requested:, matched after the process exits and after the escape sequences the runtime styles that line with are stripped.
Error handling
Turn outcome
An error kind is absent only on a turn_completed outcome; every other outcome carries one.
| Condition | Exit reason | Error kind | Description |
|---|---|---|---|
No JSON envelope arrived within the larger of read_timeout_ms and 60 seconds of launch | turn_failed | response_timeout | Message is timed out waiting for first event. The subprocess gets the graceful process-group signal and is force-killed after stop_grace_ms; its stderr is re-emitted at WARN level. |
A JSON envelope carried a sessionID other than the one already adopted | turn_failed | response_error | Message is session id mismatch: expected "...", got "...". The turn is aborted rather than reconciled, and the subprocess is stopped the same graceful way, force-killed after stop_grace_ms. |
Stdout error envelope observed, whatever the process exit status | turn_failed | turn_failed | Structured logical failure, authoritative over the exit code. Message is the envelope’s own detail; see masked failures. On OpenCode’s hosted free tier, a refusal naming a denied bash or read tool gains a trailing clause naming the setting that denied it; see free-tier refusals. |
| Turn cancelled, or the session stopped, while the subprocess was still running | turn_cancelled | turn_cancelled | Message is turn cancelled. Cancellation outranks the process-exit classification. A subprocess that had already exited on its own is classified by its own exit status instead, however long the wait for its output ran. |
No error envelope, and the process exited before writing a line the adapter decodes as a JSON envelope, whatever its exit status | turn_failed | port_exit | The early exit report: the agent runtime exited before responding: exit status N, followed by the end of the process’s stderr. A stdout line the adapter cannot decode as an envelope does not count as a response, even if the process later exits 0. |
No error envelope, exit 0, at least one text, reasoning, or tool_use part parsed | turn_completed | (none) | Normal completion. |
No error envelope, exit 0, output written but no such part parsed | turn_failed | turn_failed | The model produced nothing this turn. Message is agent exited without producing output: no message from the agent and no tool call. |
No error envelope, exit code 127 after writing output | turn_failed | agent_not_found | The agent binary was not found. Message is exit code 127. Not retried. |
No error envelope, non-zero exit other than 127 after writing output | turn_failed | port_exit | Process-level failure. Message is exit code N; when a signal Sortie did not send ended the process, the error text also names the signal, for example signal: killed. |
The adapter never trusts exit code 0 as sufficient proof of success. A terminal stdout error envelope is authoritative.
Free-tier refusals
OpenCode’s hosted free-tier model refuses any request whose tool set lacks its shell tool or its read tool, and that refusal reaches the adapter as the stdout error envelope row above rather than as the permission-refusal notice.
When the session’s own opencode.allowed_tools or opencode.denied_tools is what denied one or both of bash and read, the adapter appends a clause naming the denied tool (or tools) to the vendor’s own refusal text; a refusal from a model that is not free-tier limited, or from tool settings that do not touch either required tool, gets no such clause.
The recognition depends on the vendor’s current error wording, so an upstream rewording can make the clause stop appearing without changing the underlying refusal.
Masked failures
One failure can surface as two stdout error envelopes: one carrying the actionable diagnostic and one carrying OpenCode’s generic placeholder, Unexpected server error. Check server logs for details.. Their order on the stream is not guaranteed, so the adapter keeps whichever carries detail. When the placeholder is the only failure detail on the stream, it reaches the operator unchanged, and the adapter does not try to name the cause.
Stdout read failure
If reading stdout encounters an error while the turn is still active, the adapter:
- Stops reading and kills the process group.
- Settles token usage from the step reports and the session export, the same as any other turn ending; see Accumulation logic.
- Re-emits collected stderr lines at WARN level.
- Emits
turn_failedwith messagestdout read error: <detail>and reports an error of kindport_exitwith the textstdout scanner error.
If a cancellation or stop had already begun when the adapter killed the process, the turn ends as turn_cancelled instead: stderr is not re-emitted, but the process is still killed and usage is still recovered the same way.
Stall detection
The adapter does not run its own inter-event stall timer. The first-envelope wait, the larger of read_timeout_ms and 60 seconds, only covers startup, although plain-text stdout lines reset that timer before the first JSON line arrives.
After the first JSON envelope, stall detection is orchestrator-owned. The adapter emits notification or malformed events for plain-text warnings, unknown JSON types, and normal OpenCode envelopes so the orchestrator’s stall_timeout_ms watchdog can observe output activity. When the orchestrator cancels a stalled turn, the adapter tears down the process and the turn ends as turn_cancelled.
Session resume mechanism
OpenCode continuation is flag-based. The adapter persists the OpenCode session ID and passes it back on the next subprocess launch.
| Turn state | Stored session ID | CLI flag |
|---|---|---|
| Fresh session before first JSON envelope | Empty | (no --session flag) |
| Subsequent turn in the same worker session | Known | --session <sessionID> |
| Continuation after worker restart | The session ID saved from a previous run | --session <sessionID> |
If a resumed turn emits a different sessionID from the one already stored, the adapter aborts the turn with response_error, stops the subprocess gracefully, and emits turn_failed. session_started is emitted only once per session, on the first accepted JSON envelope.
SSH remote execution
When the worker configuration includes ssh_hosts, the adapter launches the local ssh client and runs OpenCode on the remote host. The process model stays launch-per-turn: each turn is a separate SSH invocation that wraps one remote opencode subprocess, and each session export, the turn’s own and any subagent session’s, uses another SSH invocation.
How it works
- Session start resolves the local
sshbinary, then runs the remote command’s version query over that same SSH path to check its OpenCode version; see version check. A version query failure fails session start the same way it does on a local launch, and needs the remote host’sddutility (step 4). - Each turn sends
OPENCODE_DISABLE_AUTOUPDATEand the inline configuration document inOPENCODE_CONFIG_CONTENTto the remote shell on the SSH session’s standard input, together with any variableworker.ssh_pass_envnames. The document carries the same members a local turn’s does, the tool servers included; see managed environment and MCP. A turn that delivers Sortie’s tools also setsSORTIE_TOOL_SERVER_BEARERand anySORTIE_MCP_HEADER_<n>variables the same way. - The remote shell enters the workspace, exports what the launch sent it, and only then runs the configured command with the turn’s arguments, each step chained on the success of the one before it.
- Because every remote launch on this kind sends the managed variables, every remote host needs the standard
ddutility. A host without it fails the launch withsortie: dd is required on the remote host to receive environment variableson standard error. - Each session export uses the same SSH path with the export arguments described in Token accounting, so a remote turn is measured the same way as a local one.
- When Sortie stopped the turn, by cancellation, a timeout, or shutdown, the turn waits over a second SSH connection for the remote OpenCode process tree to exit before it runs the export, for at most one second plus
agent.stop_grace_msrounded up to whole seconds. That wait needs theflockutility on the remote host. Without it the export can run while the stopped process still holds OpenCode’s session database, fail, and leave the turn’s step reports as a lower bound; the next turn’s export counts the rest.
SSH options
The adapter uses the shared sshutil transport defaults. A launch that delivers Sortie’s tools adds a reverse forward and the options that go with it; see delivery over SSH.
| Option | Value | Purpose |
|---|---|---|
StrictHostKeyChecking | Configurable (default: accept-new) | Host key verification policy. Set via worker.ssh_strict_host_key_checking. Allowed values: accept-new, yes, no. |
BatchMode | yes | Disables interactive prompts. |
ConnectTimeout | 30 | Connection timeout in seconds. |
ServerAliveInterval | 15 | Keepalive interval in seconds. |
ServerAliveCountMax | 3 | Number of missed keepalives before disconnect. |
Shell quoting
The workspace path and the adapter-generated OpenCode arguments are single-quoted with standard POSIX escaping before they are embedded in the remote shell command. A string agent.command is treated as a pre-formed shell fragment, so quoting inside it is the operator’s responsibility; each element of a list command is single-quoted. Environment-variable values are not part of that command string.
Exit codes
SSH exit code 255 indicates a connection failure and maps to port_exit unless OpenCode already emitted a terminal stdout error envelope. Exit code 127 means the remote binary is not in PATH; the process wrote nothing to stdout, so the turn takes the early exit report under port_exit, carrying exit status 127 and the remote shell’s own message. Exit code 0 is still not sufficient to prove success, because OpenCode can emit a terminal error envelope and still exit 0.
Authentication
Sortie does not manage OpenCode credentials and runs no authentication preflight for this adapter. The subprocess inherits the Sortie process environment, so whichever provider credentials OpenCode expects must already be present there. Which providers OpenCode supports, and which variable each one reads, is OpenCode’s to document; see external references.
OPENCODE_DISABLE_AUTOUPDATE and the configuration document. The remote host must already be authenticated for the model you select, or the variables that authenticate it must be listed under worker.ssh_pass_env. A run that works locally can fail on a remote host for this reason alone.MCP
The OpenCode CLI accepts no MCP configuration path as an argument, and it does not read the mcpServers key the generated .sortie/mcp.json is written under.
The adapter delivers the servers rather than the file: session start reads the generated configuration and renders its servers into OpenCode’s own server-entry shape, keyed under mcp, with a stdio server becoming a local entry and an HTTP server a remote one. A server entry that omits its enable flag is rendered enabled, matching the runtime’s own default. An HTTP entry names each header value by an environment variable, {env:SORTIE_MCP_HEADER_<n>}, instead of holding it; Sortie sets those variables in the turn’s environment.
The turn sets those entries on the turn subprocess through the runtime’s inline-configuration environment variable, OPENCODE_CONFIG_CONTENT. The mcp entries live inside the one inline document that also carries the tool policy, sharing, and compaction (see managed environment), so the variable is set whether or not the session carries servers.
The runtime merges the document with whatever project or global configuration the operator already has, rather than replacing it, and the auxiliary export and session delete invocations the adapter also runs rebuild their environment without it, so none of them spawns a tool sidecar of its own. Any OPENCODE_CONFIG_CONTENT inherited from the orchestrator’s own environment is stripped first, on every one of those.
Tool servers over SSH
A remote turn carries the same document with an mcp member that differs from a local one. sortie-tools is a remote entry at the address of the launch’s reverse forward, with an Authorization header of Bearer {env:SORTIE_TOOL_SERVER_BEARER}. Servers from mcp_config that run as a local program (stdio) are left out. Operator HTTP servers appear with their URLs.
The document is sent on the SSH session’s standard input after the forward’s remote port is known. If the worker host refuses the forward, the document carries no sortie-tools entry, the session declares a gap, and its first-turn prompt carries no tool advertisement. See delivery over SSH for the forward, the bearer, and the gap reasons. The rest of the document, the permission policy, sharing, compaction, and the title-agent switch, applies as it does locally. OPENCODE_DISABLE_AUTOUPDATE reaches the session too, and so does anything worker.ssh_pass_env names.
Startup failures
The run projection this adapter reads carries no MCP startup signal, so a server that fails to start produces no distinct diagnostic here. It surfaces only indirectly, as the agent’s own tool calls failing. OpenCode starts every configured server without waiting for a tool call first: it starts each server’s connection in the background while it prepares the session, without waiting for either the process to spawn or the connection to finish before the turn proceeds.
mcp_config
opencode.mcp_config names an operator-supplied MCP server configuration file. The worker reads it, merges its servers with the sortie-tools entry into the generated copy, and the adapter translates the merged result, so an operator’s own servers reach an OpenCode session alongside Sortie’s. Over SSH only the HTTP servers reach it. A relative path resolves against the directory containing WORKFLOW.md. An unreadable path, a file that is not valid JSON, or a file already declaring a server named sortie-tools fails the attempt before the session starts.
Two more conditions fail the session with response_error when the merged configuration reaches the adapter, and the message names the offending server: an entry that carries neither command nor url, carries both, or declares a type contradicting the fields it carries; and an entry carrying a key outside the modeled set, which is type, command, args, env, url, headers, and enabled.
Both come from shared validation, so a file that fails here fails a codex session the same way.
Key differences from other adapters
| Aspect | Claude Code | Copilot CLI | Codex | OpenCode |
|---|---|---|---|---|
| Kind | claude-code | copilot-cli | codex | opencode |
| Default command | claude | copilot | codex app-server | opencode |
| Subprocess model | New process per turn | New process per turn | Persistent process across turns | New process per turn, plus a usage-recovery subprocess after each turn |
| Protocol | CLI flags + JSONL stdout | CLI flags + JSONL stdout | JSON-RPC 2.0 over stdin/stdout | CLI flags + newline-delimited stdout envelopes |
| Output format flag | --output-format stream-json | --output-format json | JSON-RPC notifications | --format json |
| Session ID source | UUID generated by adapter | UUID generated by adapter | Thread ID from thread/start response | Discovered from the first JSON envelope, or resumed via --session |
| Resume mechanism | --resume <UUID> | --session-id <uuid> on the first turn, --resume <uuid> after; never --continue | thread/resume or automatic within session | --session <sessionID> only |
| Input token reporting | Provisional per request from assistant events, settled per model by the result events’ model_usage_since_last_result | Per model from session.usage_checkpoint stream lines, less the session’s position before the turn | From thread/tokenUsage/updated, baseline-subtracted | From step_finish step reports, with opencode session export --standalone --sanitize settling a turn they do not cover |
| Model reporting | From assistant events | From the modelMetrics keys of session.usage_checkpoint lines, or each journal record’s own model | From the thread-open response, updated on reroute | Recovered from export providerID/modelID |
| Token accounting source | result event model_usage_since_last_result, with the session journal on disk settling a turn it does not cover | session.usage_checkpoint stream lines, with the session-state journal on disk settling a turn they leave open | thread/tokenUsage/updated notification, with the thread’s rollout file settling a turn it leaves unsettled | step_finish step reports on stdout, and an export subprocess after main turn exit for the per-model split, interrupted turns, and subagent sessions |
| Permission control | --permission-mode or --dangerously-skip-permissions | --autopilot + --no-ask-user + explicit tool scoping | approvalPolicy and sandbox policy in JSON-RPC | --dangerously-skip-permissions plus a synthesized policy in the inline configuration document |
| Sandbox enforcement | None at adapter level | None at adapter level | OS-level sandbox plus configurable policy | No adapter-level sandbox; permission policy only |
| Sortie’s tools | Derived config file on --mcp-config, an inline document over SSH | Derived config file on --additional-mcp-config, an inline document over SSH | Generated servers re-expressed as command-line overrides; on an SSH launch the same overrides ride the SSH session’s standard input and name HTTP servers only | Generated servers re-expressed as an inline configuration document in the turn environment; on an SSH launch the document names Sortie’s HTTP tool server and operator HTTP servers only (see MCP) |
| Authentication | ANTHROPIC_API_KEY and provider routing flags | GitHub token variables or gh auth | CODEX_API_KEY or cached Codex auth | OpenCode-managed provider auth from env, auth store, .env, or opencode.json; a remote session uses the host’s own, or whatever worker.ssh_pass_env names |
| Provider multiplexing | Anthropic direct, Bedrock, Vertex | GitHub only | OpenAI or cached Codex auth | Multi-provider through OpenCode model/provider config |
| Inner turn limit | claude-code.max_turns | copilot-cli.max_autopilot_continues | None | None exposed by the adapter |
| Exit-code reliability | Structured result event plus process exit | Structured result.exitCode plus process exit | JSON-RPC turn status | Process exit alone is unreliable. Terminal stdout error can still exit 0. |
| Non-JSON stdout tolerance | Not required | Not required | Not applicable | Required. Permission warnings can appear as plain text in --format json mode. |
External references
- OpenCode CLI documentation: official command reference for
opencode run,opencode export, and session flags - OpenCode configuration reference:
opencode.jsonschema, provider auth store, and permission policy fields anomalyco/opencodeon GitHub: source repository, releases, and issue tracker- OpenCode permissions documentation: semantics of the permission policy this adapter synthesizes
Related pages
- WORKFLOW.md configuration reference: full
agentschema andopencodeextension block - Environment variables reference: runtime environment behavior and configuration overrides
- Error reference: all agent error kinds with retry behavior
- How to control agent costs: orchestrator-level cost caps that matter most for OpenCode
- How to scale agents with SSH: remote execution setup and host pool configuration
- How to write a prompt template: template variables, conditionals, and built-in functions
- State machine reference: orchestration states, turn lifecycle, and stall detection
Was this page helpful?