Date: 2026-07-28
Subject: opencode 1.18.7 (Bun-built binary at C:\Users\lenticetsai\.bun\bin\opencode.exe)
Host: Windows 11 Pro 10.0.26200 x64, PowerShell 7
Purpose: establish the factual basis for the dcli-opencode adapter design.
Everything below marked [observed] was run live on this host. Anything marked [unverified] was NOT confirmed and must not be relied on until it is. Two independent studies were run (this agent + a Codex delegate); where they disagreed, the live observation wins and the disagreement is called out.
opencode --help [observed] — top level:
completion generate shell completion script
acp start ACP (Agent Client Protocol) server
mcp manage MCP servers
[project] start opencode tui [default]
attach <url> attach to a running opencode server
run [message..] run opencode with a message
debug debugging and troubleshooting tools
providers manage AI providers and credentials [alias: auth]
agent manage agents
upgrade [target]
uninstall
serve starts a headless opencode server
web start opencode server and open web interface
models [provider]
stats
export [sessionID]
import <file>
github manage GitHub agent
pr <number>
session manage sessions
plugin <module> [alias: plug]
db database tools
Global options [observed]: --print-logs, --log-level DEBUG|INFO|WARN|ERROR, --pure
(run without external plugins), --port (default 0), --hostname (default 127.0.0.1),
--mdns, --mdns-domain, --cors, -m/--model provider/model, -c/--continue,
-s/--session <id>, --fork, --prompt, --agent, --auto, --mini, --no-replay,
--replay-limit.
run [message..] — --command, -c/--continue, -s/--session, --fork, --share,
-m/--model, --agent, --format default|json, -f/--file <array>, --title,
--attach <url>, -p/--password, -u/--username, --dir, --port, --variant,
--thinking, -i/--interactive, --auto.
serve — --port (0), --hostname (127.0.0.1), --mdns, --mdns-domain, --cors.
acp — same plus --cwd (defaults to the invocation directory).
web — same as serve.
attach <url> — --dir, -c, -s, --fork, -p, -u, --mini, --no-replay, --replay-limit.
session — list (-n/--max-count, --format table|json), delete <sessionID>.
export [sessionID] — --sanitize (redact sensitive transcript and file data).
import <file> — file path or share URL.
agent — create (--path, --description, --mode all|primary|subagent,
--permissions/--tools, -m/--model), list.
models [provider] — --verbose (includes cost metadata), --refresh (refresh from models.dev).
stats — --days, --tools, --models, --project.
providers — list|ls, login [url] (-p/--provider, -m/--method), logout [provider].
mcp — add [name] (--url, --env KEY=VALUE, --header KEY=VALUE), list|ls,
auth [name], logout [name], debug <name>.
plugin <module> — -g/--global, -f/--force.
db [query] — --format json|tsv; db path.
github — install, run (--event, --token).
pr <number> — fetch/checkout a GitHub PR branch then run opencode.
debug subcommands [observed]: config, lsp, rg, file, scrap (list known projects),
skill, snapshot (track / patch <hash> / diff <hash>), startup, agent <name>,
v2, info, paths, wait (wait indefinitely — a deliberate hang, useful as a test fixture).
--permissions available values [observed], from agent create --help:
bash, read, edit, glob, grep, webfetch, task, todowrite, websearch, lsp, skill.
agent create --permissions is a coarse allow-list. It is NOT the same mechanism as the
per-session PermissionRuleset in §5, which is finer-grained (permission + pattern + action)
and is the one the adapter should use.
opencode debug paths:
home C:\Users\lenticetsai
data C:\Users\lenticetsai\.local\share\opencode
bin C:\Users\lenticetsai\.cache\opencode\bin
log C:\Users\lenticetsai\.local\share\opencode\log
repos C:\Users\lenticetsai\.local\share\opencode\repos
cache C:\Users\lenticetsai\.cache\opencode
config C:\Users\lenticetsai\.config\opencode
state C:\Users\lenticetsai\.local\state\opencode
tmp %LOCALAPPDATA%\Temp\opencode
opencode debug info → opencode version: 1.18.7, os: Windows_NT 10.0.26200 x64, plugins list.
opencode db path → ...\.local\share\opencode\opencode.db (SQLite; tables include project,
session, message, part, permission, todo, workspace, event).
Rule: the SQLite DB is an opencode implementation detail and contains credentials/transcripts.
dcli-opencode must never read it on a normal path. doctor --deep may run narrow read-only queries
via opencode db "<SQL>" --format json for forensics, with redaction.
[
{
"id": "ses_058dcef55ffe7FOY2l7lq2rzGP",
"title": "Implement GitHub issue #32 ccodex",
"updated": 1785216338058,
"created": 1785215520938,
"projectId": "bfaf8a5bcc60ba23694623bfb7163f0b243c2cab",
"directory": "D:\\Documents\\GitHub\\ccodex"
}
]created/updated are epoch milliseconds.
opencode export <sessionID> [unverified] — the Codex study observed it failing to return
within ~34s on this host and produced no output. Do not depend on export for job completion.
NDJSON on stdout, one JSON object per line. Envelope: {type, timestamp, sessionID, part}.
Observed type values: step_start, tool_use, text, step_finish, error.
Success (opencode run --format json --model opencode-go/deepseek-v4-flash --auto "Reply with exactly: PONG",
exit 0, 3 lines):
{"type":"step_start","timestamp":1785226103906,"sessionID":"ses_0583c13e6ffedjsVSHrdAEIC0n","part":{"id":"prt_...","messageID":"msg_fa7c3f0710019AsBJDsx327Nd6","sessionID":"ses_...","snapshot":"f9edf04e705154c4c1859095796742b91b0c9c21","type":"step-start"}}
{"type":"text","timestamp":1785226104623,"sessionID":"ses_...","part":{"id":"prt_...","messageID":"msg_...","sessionID":"ses_...","type":"text","text":"PONG","time":{"start":1785226104577,"end":1785226104618}}}
{"type":"step_finish","timestamp":1785226107590,"sessionID":"ses_...","part":{"id":"prt_...","reason":"stop","snapshot":"f9edf04e...","messageID":"msg_...","sessionID":"ses_...","type":"step-finish","tokens":{"total":19946,"input":19931,"output":3,"reasoning":12,"cache":{"write":0,"read":0}},"cost":0.00279454}}Note the top-level type uses underscores (step_start) while part.type uses hyphens
(step-start). Both appear in the same object. Parsers must not assume one form.
Tool call [observed] — tool_use.part:
{"type":"tool","tool":"bash","callID":"call_00_PkbJG9g8cbtKfAxD5VeV6144",
"state":{"status":"completed","input":{"command":"git rev-parse --abbrev-ref HEAD"},
"output":"master\n","metadata":{"output":"master\n","exit":0,"truncated":false},
"title":"git rev-parse --abbrev-ref HEAD","time":{"start":...,"end":...}},
"id":"prt_...","sessionID":"ses_...","messageID":"msg_..."}A multi-step turn emits step_start, tool_use, step_finish(reason="tool-calls") then
step_start, text, step_finish(reason="stop") [observed]. So step_finish alone is NOT
completion evidence — only reason == "stop" is, and only for the final assistant message.
Error event [observed] (opencode Zen credit exhaustion), process exit 1:
{"type":"error","timestamp":1785225663014,"sessionID":"ses_058424e82ffewMbbne4x34FCeU",
"error":{"name":"APIError","data":{
"message":"Insufficient balance. Manage your billing here: https://opencode.ai/workspace/wrk_.../billing",
"statusCode":401,"isRetryable":false,
"responseHeaders":{"cf-ray":"...","content-type":"text/plain;charset=UTF-8","server":"cloudflare"},
"responseBody":"{\"type\":\"error\",\"error\":{\"type\":\"CreditsError\",\"message\":\"Insufficient balance...\"}}",
"metadata":{"url":"https://opencode.ai/zen/v1/responses"}}}}Note this is a billing/credit failure reported as HTTP 401. Classifying 401 as auth
would be wrong here. The responseBody.error.type (CreditsError) is the discriminator, not the
status code. This is a concrete argument against bare-status-code classification.
The Codex study [observed] a nonexistent model producing
{"type":"error","error":{"name":"UnknownError","data":{"message":"Unexpected server error. Check server logs for details.","ref":"err_53303ea6"}}}
with exit 1 — i.e. error payloads can be entirely generic.
| Scenario | Exit |
|---|---|
| Successful run with final text | 0 |
| Unknown flag (help dumped to stdout) | 1 |
| Nonexistent provider/model | 1 |
| Credit/billing failure (401 in event) | 1 |
opencode has no granular exit-code contract. dcli-opencode must define its own (see design spec).
Captured assistant text contained mojibake (??) when read through a default PowerShell pipeline.
Read child stdout as UTF-8 explicitly; never rely on the console code page.
[observed] The local build agent resolves permission: "*" → allow but
external_directory → ask (plus doom_loop → ask). Given:
opencode run --format json --model opencode-go/deepseek-v4-flash `
"Read the file <path outside cwd>\outside.txt and tell me its contents."Result: still running after 120 s with ZERO bytes on stdout — no event, no prompt marker, no diagnostic. Killed by the harness. The identical prompt targeting a path inside cwd completed in ~30 s.
Consequences, and they are severe:
- An
askpermission is externally indistinguishable from a slow model turn. The only detection mechanism on the CLI path is a timeout. - The CLI path can never answer the prompt — the run is unrecoverable, and whatever partial work happened is lost.
- The only CLI-level mitigation is the process-global
--auto, which opencode's own help labels(dangerous!). There is no CLI flag for scoped, per-job permission policy.
A wrapper whose headline promise is "never hangs" cannot be built on the CLI path alone. This single observation is what reverses the backend decision (see ADR-002).
opencode serve --port 47311 --hostname 127.0.0.1Healthy in ~2 s. Stdout:
Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.
opencode server listening on http://127.0.0.1:47311
GET /doc returns OpenAPI 3.1.0, info.title: "opencode", 162 paths. The relevant subset:
| Method | Path | Notes |
|---|---|---|
| GET | /global/health |
{healthy: true, version: "1.18.7"} |
| GET | /event, /global/event |
SSE event stream |
| POST | /global/dispose, /instance/dispose |
graceful shutdown |
| Method | Path | Notes |
|---|---|---|
| GET,POST | /session |
create/list |
| GET,DELETE,PATCH | /session/{id} |
|
| GET | /session/status |
map of id → idle / busy / retry{attempt,message,next,action{reason,provider,title,message,label,link}} — lists only sessions with work in flight, see below |
| GET,POST | /session/{id}/message |
POST is synchronous for a whole turn |
| POST | /session/{id}/prompt_async |
204, fire-and-forget |
| POST | /session/{id}/abort, /interrupt |
|
| POST | /session/{id}/fork, /summarize, /init, /command, /shell |
|
| GET | /session/{id}/diff |
SnapshotFileDiff[] |
| POST | /session/{id}/revert, /unrevert |
/session/status omits finished sessions entirely [verified live, 1.18.10]. Polled across one
turn it returned {"ses_…":{"type":"busy"}} while the turn ran and then {} — not an idle entry —
once it was over. So absent from the map is the completion signal, and reading absence as "unknown"
means a completion is never observed: a turn that failed 5 s in (a 403 RegionError from the provider)
was polled for the whole hard-timeout budget and reported as timed_out with zero bytes. Absence is
only meaningful after the prompt has been accepted, hence the registration grace period in the adapter.
Amendment 2026-08-04 (ticket 81) — an unresolvable status is bounded, never progress. Absence is
handled above, but a status the parse cannot resolve at all (a non-object body, an unexpected shape, or
a poll that keeps failing) still yielded unknown and the loop treated it as "still working" — an
unbounded wait, invariant #3. Now: unknown is not emitted as a backend_status fact (that fact's state
enum is busy|idle|retrying, so it never was legal), consecutive unresolved polls are bounded to
UNRESOLVED_STATUS_LIMIT_MS / POLL_INTERVAL_MS (12 at the defaults, minimum 2), and on exhaustion the
adapter emits backend_error with class_hint: backend_status_unresolved so the job goes terminal as
failed naming the ambiguity — never done, never a silent empty result. A failing poll also no longer
leaves the previous status cache in place: it records unknown, because leaving it looked like "never
polled" and broke the reconnect loop out, reporting a partial turn as clean.
A failed turn is a normal message carrying info.error [verified live]. There is no separate
failure event once the SSE stream has closed; GET /session/{id}/message is the only place the error
is visible, as info.error.data.{message,statusCode}. Nothing else reports it, so a turn that the
provider refused otherwise looks like a successful turn with no text.
POST /session body [observed]:
{ "parentID": "ses_...", "title": "...", "agent": "build",
"model": { "providerID": "...", "id": "...", "variant": "..." },
"metadata": {}, "permission": [ /* PermissionRuleset */ ], "workspaceID": "wrk_..." }| Method | Path | Contract |
|---|---|---|
| GET | /permission |
PermissionRequest[] = {id: "per_...", sessionID, permission, patterns[], metadata, always[], tool{messageID, callID}} |
| POST | /permission/{requestID}/reply |
body {reply: "once"|"always"|"reject", message?} → bool; 404 PermissionNotFoundError |
| GET | /question |
QuestionRequest[] = {id: "que_...", sessionID, questions[], tool} |
| POST | /question/{requestID}/reply |
body {answers: QuestionAnswer[]} ("answers in order of questions, each an array of selected labels") |
| POST | /question/{requestID}/reject |
|
| POST | /session/{id}/permissions/{permissionID} |
per-session persistent grant |
Both accept optional directory and workspace query parameters.
| Method | Path | Contract |
|---|---|---|
| GET,POST,DELETE | /experimental/worktree |
Worktree = {name, branch?, directory}; create body {name?, startCommand?}; delete body {directory} |
| POST | /experimental/worktree/reset |
|
| GET | /vcs, /vcs/status, /vcs/diff, /vcs/diff/raw |
VcsFileDiff = {file, patch?, additions, deletions, status: added|deleted|modified} |
| POST | /vcs/apply |
body {patch} → {applied: bool} |
GET /agent, GET /skill, GET /command, GET /lsp, GET /provider, GET /project,
GET /project/current, GET /path, GET /find, GET /file, GET,PATCH /config,
GET /experimental/capabilities, GET /experimental/tool/ids, GET,POST /mcp,
plus a parallel /api/** v2 surface and /tui/** control endpoints.
Event component names in the schema include EventPermissionAsked, EventPermissionReplied,
EventQuestionAsked, EventQuestionReplied, EventQuestionRejected, EventSessionIdle,
EventSessionError, EventSessionStatus, EventSessionDiff, EventFileEdited,
EventWorktreeReady, EventWorktreeFailed, EventInstallationUpdated.
PermissionRuleset = PermissionRule[]
PermissionRule = { permission: string, pattern: string, action: "allow"|"deny"|"ask" }
Test: created a session with permission: [{permission:"*", pattern:"*", action:"allow"}], then
sent the exact prompt that hung indefinitely in §5.
Result: completed in 88 s, returned SECRETVALUE=42 (the out-of-tree file's contents),
parts: step-start, reasoning, text, step-finish. GET /permission was empty throughout.
So a per-session ruleset overrides the config's ask, per job, with no global --auto.
deny is equally expressible. This is the mechanism that makes a no-hang guarantee achievable.
Caveat, and it matters: this proves one wildcard allow rule overrides one observed ask.
It does NOT prove rule precedence, ordering, pattern-matching semantics, or deny behavior across
tools and versions. Those need contract tests before fine-grained rulesets are trusted. Broad
allow must be an explicit opt-in mode, never a default.
POST /session/{id}/message accepts format (OutputFormat = OutputFormatText | OutputFormatJsonSchema), where OutputFormatJsonSchema = {type: "json_schema", schema, retryCount?}.
The JSONSchema component is unconstrained ({"type": "object"}).
Observed with opencode-go/deepseek-v4-flash and a small object schema:
- The POST returned
parts: []— no text, no error part, nothing usable. - Afterwards
GET /session/{id}/messagepermanently 400s for that session:Expected OutputFormatJsonSchema, got {"type":"json_schema","schema":{...},"retryCount":2} at [0]["info"]["format"]— note the server injectedretryCount: 2on write and then rejected its own stored value on read. - A sibling session created without
formatreads back fine.
Cause not isolated — it may be model-side (this provider may not support structured outputs) rather than purely a server bug. Either way:
Do not use native structured output. Request plain text, and do wrapper-side JSON extraction + validation. Do not "fall back" to text within the same session — the session is already corrupted.
opencode acp starts a JSON-RPC-over-stdio ACP server, aimed at editors/interactive clients.
Not recommended as an adapter backend: it adds a second stateful protocol and subprocess
lifecycle while the HTTP surface already exposes everything needed. Revisit only if it ever offers
permission/cancellation semantics HTTP lacks.
Providers with credentials: opencode (Zen — credit-exhausted, see §4), opencode-go, nvidia.
opencode models lists ~40 opencode/* entries (claude-, gpt-5.x, gemini-3.x, glm-5.x,
deepseek-v4-), 16 opencode-go/*, and a large nvidia/* catalogue.
Model used for all live probes: opencode-go/deepseek-v4-flash (cheap, working).
opencode/* (Zen) returns the §4 CreditsError on this host.
--variant is documented as "provider-specific reasoning effort, e.g. high, max, minimal" — an
unbounded string, deliberately not an enum. This is why it must not be mapped onto a shared
--effort flag (see ADR-004).
Do not build behavior on these until they are tested:
- Rule precedence / ordering / pattern semantics of
PermissionRuleset, anddenybehavior (§7). - Whether
--port 0reliably reports the bound port, and where. - Basic-auth behavior with
OPENCODE_SERVER_PASSWORD/--password/--usernameon every endpoint. directory/workspacequery-parameter routing correctness per endpoint.- SSE framing, reconnect, replay, and whether events can be missed.
- Whether
POST /session/{id}/messagecan block for an unbounded turn (assume yes). - What a permission request looks like on the SSE stream vs
GET /permissionpolling. - Real auth failure, quota/rate-limit, network outage, and provider-overload payloads.
opencode exportshape and its observed hang on this host.- Empty prompt / process exits 0 with no assistant text.
- Whether
-f/--fileattachments are reliably surfaced to the model as text. - Whether
--agentwithout--modelhonors the agent's configured model on both CLI and HTTP. - Whether external plugins (
context-modeis active here) alter the event stream;--purebehavior. - Whether opencode leaves descendants after hard termination.
/experimental/worktreelifecycle, and/docstability across opencode versions.- Whether a wrapper-generated read-only ruleset resists adversarial prompts.
opencode --version ; opencode --help
foreach ($c in 'run','serve','session','export','import','agent','models','stats','debug',
'providers','mcp','attach','acp','web','plugin','db','github','pr') {
opencode $c --help
}
opencode session list --help ; opencode agent create --help ; opencode mcp add --help
opencode debug paths ; opencode debug info ; opencode db path
opencode session list -n 2 --format json
opencode models
# NDJSON event stream
opencode run --format json --model opencode-go/deepseek-v4-flash --auto "Reply with exactly: PONG"
# tool_use event
opencode run --format json --model opencode-go/deepseek-v4-flash --auto `
"Run the bash command 'git rev-parse --abbrev-ref HEAD' and report only its output."
# THE HANG (bound it, or it never returns)
opencode run --format json --model opencode-go/deepseek-v4-flash `
"Read the file <outside-cwd>\outside.txt and tell me its contents."
# HTTP surface
opencode serve --port 47311 --hostname 127.0.0.1
Invoke-RestMethod http://127.0.0.1:47311/doc | ConvertTo-Json -Depth 40 > openapi.json
Invoke-RestMethod http://127.0.0.1:47311/global/health
Invoke-RestMethod -Method Post http://127.0.0.1:47311/global/dispose