codex2opencode is intentionally small. The architecture has three layers:
- CLI commands that define user-visible behavior
- bridge internals that invoke Opencode, parse JSONL, manage thread identity, and persist state
- a thin Codex skill that converts natural-language intent into CLI calls
The bridge is local-only and depends on the local opencode CLI as the execution backend.
The normal ask flow is:
- resolve the canonical workspace root
- derive a deterministic thread key from workspace root and optional thread name
- acquire a non-blocking per-thread file lock
- load existing thread state unless
--newwas requested - build
opencode run ... --format json --dir <workspace> - stream-parse JSONL output to collect text,
sessionID, and event counts - export the returned session with
opencode export <sessionID>to verify it - persist updated thread state and a run artifact
- append a structured bridge log entry
- print the accumulated text output to stdout
If the run succeeds but export verification fails, the bridge still persists the returned sessionID and a run artifact, but marks the thread state as session_unverified and returns a session error. That keeps the created Opencode session recoverable instead of silently orphaning it.
- owns the end-to-end request flow
- persists the latest Opencode
sessionID - supports native Opencode
--forkand--title - emits run records for observability
- returns Opencode text output directly
- reads and prints the current thread-state JSON for the selected workspace/thread
- deletes the saved thread-state file and lock file for the selected workspace/thread
- attempts
opencode session delete <sessionID>for the mapped remote session - succeeds even if the remote session is already missing
- removes stale thread files, old lock files, and run directories
- skips active thread keys that are currently locked
- never bulk-deletes unrelated Opencode sessions
- diagnoses bridge path layout
- checks whether
opencode --versionis readable - checks whether
opencode debug pathsis readable - checks whether
opencode debug configis parseable - verifies whether the mapped session can still export
- reports whether the selected thread state is
ok,missing,error, ororphaned
Thread identity is derived by:
- canonicalizing
--workspacewithPath(...).expanduser().resolve() - combining canonical workspace root with optional thread name
- hashing that tuple into a stable SHA-256 thread key
This gives stable thread reuse across repeated invocations in the same repo while still allowing named subthreads.
State root:
~/.codex/codex2opencode/
threads/
runs/
logs/
Key files:
threads/<thread_key>.json: current thread statethreads/<thread_key>.lock: lock file used for same-thread exclusionruns/<thread_key>/*.json: per-run artifactslogs/bridge.log: append-only JSONL log
Thread state fields currently include:
- workspace identity
- optional thread name
- Opencode
sessionID - optional Opencode title
- creation and last-used timestamps
- last status and last run mode
- bridge version
- Opencode version
- last error
- message count from the last verified export
- last export timestamp
Run artifacts currently include:
- run id
- timing
- run mode
- prompt hash
- returned session id
- whether export verification succeeded
- stdout/stderr previews
- event counts from the JSONL stream
Writes use temp files plus replace semantics to avoid partial-state corruption. Local write failures are translated into bridge state errors instead of leaking raw OSError exceptions.
Example thread-state document:
{
"thread_key": "<sha256>",
"workspace_root": "/path/to/repo",
"thread_name": "docs",
"opencode_session_id": "<opencode-session-id>",
"opencode_title": "docs-review",
"created_at": "2026-03-28T00:00:00Z",
"last_used_at": "2026-03-28T03:25:11Z",
"last_status": "ok",
"last_run_mode": "resume",
"bridge_version": "0.1.0",
"opencode_version": "opencode 1.x.x",
"last_error": null,
"message_count": 4,
"last_exported_at": "2026-03-28T03:25:11Z"
}Example run-record document:
{
"run_id": "<uuid>",
"thread_key": "<sha256>",
"started_at": "2026-03-28T03:25:10Z",
"ended_at": "2026-03-28T03:25:11Z",
"duration_ms": 842,
"run_mode": "resume",
"prompt_sha256": "<sha256>",
"session_id": "<opencode-session-id>",
"export_verified": true,
"stdout_preview": "Opencode reply preview",
"stderr_preview": "",
"event_counts": {
"text": 2,
"step_start": 1
}
}bridge/codex2opencode/opencode_cli.py is the only module that should know how to:
- build
opencode run,export, andsession deletecommand lines - invoke diagnostic commands like
--version,debug paths, anddebug config - interpret missing-binary behavior for diagnostic reads
Expected Opencode contract:
opencode run --format jsonemits JSONL events- one or more events carry
sessionID - text-bearing events can be concatenated into the user-visible reply
opencode export <sessionID>returns JSON metadata for verification
If Opencode returns malformed JSONL, missing sessionID, or malformed export JSON, the bridge treats that as a contract failure rather than silently guessing.
bridge/codex2opencode/event_stream.py is responsible for:
- parsing JSON lines one by one
- tolerating malformed lines without crashing the whole process
- counting event types for run artifacts
- extracting accumulated text output
- surfacing whether a usable
sessionIDwas observed
The parser does not perform subprocess I/O or state mutation.
Concurrency protection is intentionally narrow:
- locking is per thread key, not global
- different threads can run independently
- the same thread key cannot be updated concurrently
Locking uses fcntl.flock(...), so the current implementation targets macOS/POSIX rather than Windows.
Exit codes are centralized in bridge/codex2opencode/errors.py.
Important categories:
- generic Opencode invocation failures
- Opencode timeout
- lock conflict
- invalid arguments
- state corruption or local persistence failure
- event stream contract failure
- session verification failure
Current mapping:
BridgeError->1TimeoutError->2LockConflictError->3InvalidArgsError->4StateError->5StreamError->6SessionError->7
The CLI catches bridge-domain errors, logs them, prints a short stderr message, and exits with the mapped code.
doctor prints JSON with:
- bridge version and resolved workspace
- bridge root and key path locations
- Opencode binary status and version
debug pathsreadabilitydebug configparseability- selected thread-state status
- remote session verification result
- lock-path health
Thread-state statuses have distinct meaning:
missing: no local mapping exists yetok: state loads and remote session verification succeeds or is absenterror: state is corrupted or another bridge failure blocked inspectionorphaned: state loads but the mapped remote session no longer exports
skills/codex-to-opencode/SKILL.md is intentionally not part of the bridge core.
Its job is only to:
- identify user intent like "ask Opencode" or "给 Opencode 看看"
- choose default thread, named thread, fresh thread, or fork mode
- call
codex2opencode - surface stdout/stderr
The skill must not own:
- Opencode JSONL parsing
- state-file mutation
- retries
- export verification
- custom lock handling
- prefer simple stdlib-based implementation inside the bridge
- keep user-visible behavior deterministic
- favor explicit recovery over hidden repair
- keep docs complete from the first releaseable version
- keep this repository single-purpose and sibling-scoped with
codex2claude