Skip to content

Latest commit

 

History

History
273 lines (202 loc) · 8.01 KB

File metadata and controls

273 lines (202 loc) · 8.01 KB

Architecture

Overview

codex2opencode is intentionally small. The architecture has three layers:

  1. CLI commands that define user-visible behavior
  2. bridge internals that invoke Opencode, parse JSONL, manage thread identity, and persist state
  3. 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.

Main Flow

The normal ask flow is:

  1. resolve the canonical workspace root
  2. derive a deterministic thread key from workspace root and optional thread name
  3. acquire a non-blocking per-thread file lock
  4. load existing thread state unless --new was requested
  5. build opencode run ... --format json --dir <workspace>
  6. stream-parse JSONL output to collect text, sessionID, and event counts
  7. export the returned session with opencode export <sessionID> to verify it
  8. persist updated thread state and a run artifact
  9. append a structured bridge log entry
  10. 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.

Command Responsibilities

ask

  • owns the end-to-end request flow
  • persists the latest Opencode sessionID
  • supports native Opencode --fork and --title
  • emits run records for observability
  • returns Opencode text output directly

status

  • reads and prints the current thread-state JSON for the selected workspace/thread

forget

  • 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

gc

  • removes stale thread files, old lock files, and run directories
  • skips active thread keys that are currently locked
  • never bulk-deletes unrelated Opencode sessions

doctor

  • diagnoses bridge path layout
  • checks whether opencode --version is readable
  • checks whether opencode debug paths is readable
  • checks whether opencode debug config is parseable
  • verifies whether the mapped session can still export
  • reports whether the selected thread state is ok, missing, error, or orphaned

Thread Identity Model

Thread identity is derived by:

  • canonicalizing --workspace with Path(...).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.

Persistence Model

State root:

~/.codex/codex2opencode/
  threads/
  runs/
  logs/

Key files:

  • threads/<thread_key>.json: current thread state
  • threads/<thread_key>.lock: lock file used for same-thread exclusion
  • runs/<thread_key>/*.json: per-run artifacts
  • logs/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
  }
}

Opencode CLI Adapter

bridge/codex2opencode/opencode_cli.py is the only module that should know how to:

  • build opencode run, export, and session delete command lines
  • invoke diagnostic commands like --version, debug paths, and debug config
  • interpret missing-binary behavior for diagnostic reads

Expected Opencode contract:

  • opencode run --format json emits 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.

Event Stream Parser

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 sessionID was observed

The parser does not perform subprocess I/O or state mutation.

Concurrency Model

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.

Error Model

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 -> 1
  • TimeoutError -> 2
  • LockConflictError -> 3
  • InvalidArgsError -> 4
  • StateError -> 5
  • StreamError -> 6
  • SessionError -> 7

The CLI catches bridge-domain errors, logs them, prints a short stderr message, and exits with the mapped code.

Doctor Model

doctor prints JSON with:

  • bridge version and resolved workspace
  • bridge root and key path locations
  • Opencode binary status and version
  • debug paths readability
  • debug config parseability
  • selected thread-state status
  • remote session verification result
  • lock-path health

Thread-state statuses have distinct meaning:

  • missing: no local mapping exists yet
  • ok: state loads and remote session verification succeeds or is absent
  • error: state is corrupted or another bridge failure blocked inspection
  • orphaned: state loads but the mapped remote session no longer exports

Skill Layer

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

Design Constraints

  • 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