OpenCode Plugin Statefile Protocol
OpenCode Plugin Statefile Protocol
This document specifies the plugin-side protocol used by .opencode/plugins/c2c.ts
to stream live OpenCode session state into c2c oc-plugin stream-write-statefile.
The OpenCode plugin is responsible for observing the current session and emitting a
small, stable event stream. The c2c binary is responsible for maintaining the
canonical on-disk statefile.
Scope
This protocol covers:
- the subprocess contract between the OpenCode plugin and
c2c - the JSONL event stream written to stdin
- the plugin-side canonical schema
- the merge semantics for patches
- which OpenCode events are consumed by the plugin in v1
This protocol does not define the OCaml statefile format on disk.
Transport
- Command:
c2c oc-plugin stream-write-statefile - Direction: plugin writes to child stdin
- Encoding: UTF-8 JSON Lines
- One JSON object per line
- No shell wrapping
The plugin starts one long-lived subprocess and keeps stdin open for the lifetime of the OpenCode process.
Resync Semantics
The plugin emits two event types:
state.snapshotstate.patch
Rules:
- Every successful writer start must be followed by one full
state.snapshotbefore anystate.patch. state.snapshotis authoritative replacement state for that writer lifetime.state.patchevents are only valid after a precedingstate.snapshotfrom the same writer lifetime.- If the writer dies or stdin breaks, the plugin makes no further guarantees until
a fresh writer start emits a new
state.snapshot.
v1 does not define sequence numbers or replay.
Event Types
state.snapshot
Full state replacement.
Example:
{
"event": "state.snapshot",
"ts": "2026-04-21T14:05:00.123Z",
"state": {
"c2c_session_id": "opencode-c2c",
"c2c_alias": "opencode-mire-kiva",
"root_opencode_session_id": null,
"opencode_pid": 12345,
"plugin_started_at": "2026-04-21T14:00:00.000Z",
"state_last_updated_at": "2026-04-21T14:05:00.123Z",
"agent": {
"is_idle": null,
"turn_count": 0,
"step_count": 0,
"last_step": null,
"provider_id": null,
"model_id": null
},
"tui_focus": {
"ty": "unknown",
"details": null
},
"prompt": {
"has_text": null
},
"pendingQuestion": null
}
}
state.patch
Partial state update.
Example:
{
"event": "state.patch",
"ts": "2026-04-21T14:05:01.456Z",
"patch": {
"root_opencode_session_id": "ses_abc123",
"agent": {
"is_idle": true,
"turn_count": 3,
"step_count": 5,
"last_step": {
"event_type": "session.idle",
"at": "2026-04-21T14:05:01.456Z",
"details": {
"session_id": "ses_abc123"
}
}
},
"tui_focus": {
"ty": "prompt",
"details": null
},
"prompt": {
"has_text": false
},
"state_last_updated_at": "2026-04-21T14:05:01.456Z"
}
}
Patch Merge Semantics
Consumers must apply patches as a deep object merge:
- omitted fields mean unchanged
- object values merge recursively
- scalar values replace the previous value
nullmeans explicit clear-to-null- arrays, if ever introduced, replace the previous array entirely unless a later protocol version says otherwise
v1 intentionally avoids arrays.
Canonical Schema
Top-level fields
c2c_session_id: stringc2c_alias: string | nullroot_opencode_session_id: string | nullopencode_pid: numberplugin_started_at: stringstate_last_updated_at: stringpendingQuestion: null | { id: string, text: string, header: string, options: string[] }
All timestamps are ISO 8601 UTC strings with millisecond precision.
agent
is_idle: boolean | nullturn_count: numberstep_count: numberlast_step: null | { event_type: string, at: string, details: object | null }provider_id: string | nullmodel_id: string | null
v1 counter meanings:
turn_count: number of observed rootsession.idlecompletionsstep_count: number of handled state-relevant events from this set only:- root
session.created - root
session.idle - root
permission.asked - root
permission.updated
- root
tui_focus
ty: "permission" | "question" | "prompt" | "menu" | "unknown"details: object | null
prompt
has_text: boolean | null
pendingQuestion
nullwhen no human question is pending- otherwise
{ id: string, text: string, header: string, options: string[] }
v1 emission rule:
- the plugin emits a fresh
state.snapshotwhenquestion.askedcreates a pending question - the plugin emits another
state.snapshotwhen that pending question is cleared by answer, reject, or timeout
Root-Session Rules
The published state represents the root OpenCode session only.
Bootstrap rules:
- If a root
session.createdevent is observed (parentIDabsent), that session becomesroot_opencode_session_id. - If the plugin attached late and no root
session.createdwas seen, the first acceptablesession.idlemay bootstrap the root. - Once the root is known, sub-session events must not replace it.
Permission rule:
- Permission events continue to be handled by the plugin for approval flow.
- Only permission events whose
sessionIDmatches the tracked root may mutate the published state stream.
v1 Detail Payload Shapes
To keep the protocol stable and small, the plugin emits compact detail objects.
Session step details
Used for root session.created / session.idle:
{ "session_id": "ses_abc123" }
Permission focus details
Used for root permission.asked / permission.updated:
{
"id": "perm-123",
"title": "bash",
"type": "bash"
}
The plugin must not emit raw upstream event payloads, prompt text, or large nested fragments in v1.
Consumed OpenCode Events
Implemented in v1
| Event | Effect |
|---|---|
session.created |
Sets root session when event is for a root session; updates prompt focus and step metadata |
session.idle |
Sets idle state, increments turn/step counts, and may bootstrap root if attaching late |
permission.asked |
Sets permission focus and root-scoped step metadata when event belongs to the root session |
permission.updated |
Same as permission.asked |
question.asked |
Captures the first pending question into pendingQuestion and emits a full replacement snapshot |
Observed but not part of the v1 state contract
These events may still be consumed by other plugin logic, but they do not currently extend the published state schema:
question.repliedmessage.updatedmessage.part.updatedsession.statussession.updatedcommand.executedtui.prompt.append
These stay out of the stable state contract until payload shape and value are confirmed in code/tests.
Unknown and Null Semantics
nullmeans the field is intentionally unknown or explicitly cleared- omitted patch fields mean unchanged
prompt.has_textstarts asnull, notfalseprovider_idandmodel_idremainnullunless explicit upstream fields are observedpendingQuestionstarts asnulland returns tonullafter answer, reject, or timeout
Failure Behavior
v1 is best-effort:
- spawn failure is non-fatal
- stdin write failure is non-fatal
- child exit is non-fatal
- the plugin continues delivering inbox messages and handling permission flows
If the writer becomes unavailable, the plugin stops emitting state until a later plugin lifecycle start can create a new writer and send a new snapshot.
Future versions may add reconnect/backoff behavior.