Skip to content

Commit 3633219

Browse files
authored
feat(dispatcher): add opencode CLI support (#89)
## Summary Add first-class opencode (`anomalyco/opencode`) support to the dispatcher's agent abstraction layer. opencode is a provider-agnostic open-source coding agent CLI that, like codex, mints its own session id per invocation. Mirrors the pattern established in #88: capture the CLI-minted id from the JSON stream into a sidecar, feed it back on resume. ## Why After #88 fixed the codex branch with a session-id-capture pattern, opencode's identical wrinkle (CLI mints the id; caller can't pre-mint) became cheap to address with the same approach. README #87 already lists opencode as the next CLI to integrate; this closes that follow-up. ## Design opencode JSON shape (verified against `opencode run --format json --pure "test"` on v1.14.46): ```json {"type":"step_start","timestamp":1778415469963,"sessionID":"ses_1ee2d8d...","part":...} ``` Every event carries `"sessionID":"ses_<base62>"` — not gated on a specific event type. The capture filter records the first occurrence (which arrives in the very first event), validates the format with `^ses_[A-Za-z0-9]+$`, and writes it to `${pid_dir}/opencode-session-${dispatcher_session_id}`. - `run_agent` opencode case: `opencode run --format json [--title <session_name>] [PROMPT]` piped through `_opencode_capture_session`. - `resume_agent` opencode case: reads the captured id via `_opencode_session_id` and calls `opencode run --session <id> --format json [PROMPT]`. Falls back to `run_agent` when the sidecar is missing or malformed (defense-in-depth: even though pid_dir is mode 0700, a malformed sidecar value would otherwise be passed to `opencode run --session ...` as raw shell input). ## Test Plan - [x] **Live verification**: installed opencode v1.14.46 locally, ran `opencode run --format json --pure "test"`, confirmed JSON shape; ran resume with the captured id, confirmed it works. - [x] 31 new cases in `tests/unit/test-lib-agent-opencode.sh`: - Source-of-truth grep (opencode case present, uses `run --format json`, resume uses `--session`) - run_agent argv shape + sessionID capture (stub emits the actual opencode JSON shape) - resume_agent uses captured sessionID - Fallback when sidecar missing (logs diagnostic, calls run_agent shape, creates fresh sidecar) - Crash path (opencode exits before any JSON event → no sidecar, exit code surfaced) - **Malformed-sidecar regression** (manually planted `ses_with;injection\`hazard` → regex rejects, fallback triggers, garbage never reaches `opencode run --session`) - [x] Codex tests still pass (27/27) - [x] Full unit suite: 37/37 test files pass - [x] `bash -n` clean on `lib-agent.sh` - [x] shellcheck: only SC1091-info for `lib-config.sh` source - [x] Code-reviewer agent run: zero findings ≥ 80 confidence - [ ] CI checks pass ## Backwards Compatibility - `AGENT_CMD=opencode` was previously routed to the generic `<cli> -p <prompt>` fallback. With this PR it's routed to a first-class branch — the new behavior is strictly more correct (no `-p` flag misuse). - `claude` / `codex` / `kiro` branches unchanged. - Sidecar paths are new files under an already-existing per-user dir (mode 0700). ## Open follow-ups (not in scope) - `--dangerously-skip-permissions` flag wiring when `AGENT_PERMISSION_MODE=bypassPermissions`. The codex branch has the same gap; would prefer a single PR that addresses both consistently rather than per-CLI ad-hoc fixes. ## Checklist - [x] Design described in commit message + this PR body - [x] Test cases documented (31 cases with thorough behavioral + regression coverage) - [x] Build/tests pass - [x] Code review passed (zero blocking findings)
1 parent e1f9383 commit 3633219

4 files changed

Lines changed: 464 additions & 15 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A fully automated development pipeline that turns GitHub issues into merged pull requests — no human intervention required. It scans for issues labeled `autonomous`, dispatches a **Dev Agent** to implement the feature with tests in an isolated worktree, and hands off to a **Review Agent** for code review with optional E2E verification. The entire cycle runs unattended on a cron schedule.
44

5-
Supports multiple coding agent CLIs — Claude Code, Codex CLI, Kiro CLI, Cursor Agent, Gemini CLI, and most CLIs with a `-p <prompt>` non-interactive flag — via a pluggable agent abstraction layer.
5+
Supports multiple coding agent CLIs — Claude Code, Codex CLI, Kiro CLI, opencode, Cursor Agent, Gemini CLI, and most CLIs with a `-p <prompt>` non-interactive flag — via a pluggable agent abstraction layer.
66

77
## Getting Started
88

@@ -168,7 +168,7 @@ The file is a bash script that's `source`d at every dispatcher tick and wrapper
168168
| `REPO` | Yes | `owner/repo-name` | The GitHub repo the pipeline watches. |
169169
| `REPO_OWNER`, `REPO_NAME` | Yes | Split form of `REPO` | Used for App-token scoping. |
170170
| `PROJECT_DIR` | Yes | Absolute path to the project root on the dispatcher box | Where the agent runs. |
171-
| `AGENT_CMD` | No (default `claude`) | `claude`, `codex`, or `kiro` | The CLI used to spawn dev/review agents. Other CLIs work via the generic `<cli> -p <prompt>` fallback. See the Supported Agent CLIs table for resume semantics per CLI. |
171+
| `AGENT_CMD` | No (default `claude`) | `claude`, `codex`, `kiro`, or `opencode` | The CLI used to spawn dev/review agents. Other CLIs work via the generic `<cli> -p <prompt>` fallback. See the Supported Agent CLIs table for resume semantics per CLI. |
172172
| `AGENT_DEV_MODEL`, `AGENT_REVIEW_MODEL` | No (default empty / `sonnet`) | Model name passed to the agent CLI | Empty = let the CLI pick. The review model defaults to `sonnet` to keep review costs predictable. |
173173
| `AGENT_PERMISSION_MODE` | No (default `auto`) | `auto`, `plan`, or `bypassPermissions` | `bypassPermissions` grants the agent unrestricted shell access — only use in a trusted sandbox. |
174174
| `AGENT_TIMEOUT` | No (default `4h`) | coreutils `timeout` units (e.g. `30m`, `2h`, `1d`) | Wall-clock cap on each agent invocation. Prevents hung CLI processes (stale `--resume`, MCP stdio deadlock) from monopolizing wrapper PID slots. |
@@ -373,9 +373,9 @@ The dispatcher is an [OpenClaw](https://github.com/OpenClaw/OpenClaw) skill that
373373
| Kiro CLI | `kiro-cli` | `chat --no-interactive [--agent <name>]` | (falls back to new) | Basic support |
374374
| Cursor Agent | `agent` | `-p "<prompt>"` | `--resume=<chat-id>` | Generic fallback (untested explicit branch) |
375375
| Gemini CLI | `gemini` | `-p "<prompt>"` | (no documented resume flag) | Generic fallback (untested explicit branch) |
376-
| opencode (`anomalyco/opencode`) | `opencode` | `run "<prompt>"` (positional) | `run --session <id>` — but **caller cannot pre-mint the session id**; opencode mints its own. Needs an explicit `lib-agent.sh` adapter (capture session id from `--format json` stdout, persist sidecar) before resume works. | Generic fallback only (currently); explicit branch tracked as a follow-up |
376+
| opencode | `opencode` | `run --format json [PROMPT]` | `run --session <sessionID>` (captured from JSON stream) | Full support |
377377

378-
Configure via `AGENT_CMD` in `scripts/autonomous.conf`. The `claude`, `codex`, and `kiro` rows have explicit branches in `scripts/lib-agent.sh`; the others run through the generic `<cli> -p <prompt>` fallback. Any CLI not listed should still work if it accepts a `-p <prompt>` non-interactive flag — the abstraction layer is intentionally permissive.
378+
Configure via `AGENT_CMD` in `scripts/autonomous.conf`. The `claude`, `codex`, `kiro`, and `opencode` rows have explicit branches in `scripts/lib-agent.sh`; the others run through the generic `<cli> -p <prompt>` fallback. Any CLI not listed should still work if it accepts a `-p <prompt>` non-interactive flag — the abstraction layer is intentionally permissive.
379379

380380
## Development Workflow (Hook System)
381381

skills/autonomous-dispatcher/scripts/autonomous.conf.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ REPO_NAME="repo-name"
1111
PROJECT_DIR="/path/to/project"
1212

1313
# === Agent Configuration ===
14+
# AGENT_CMD: which coding-agent CLI to spawn for dev/review wrappers.
15+
# First-class support: claude, codex, kiro, opencode. Other CLIs work via
16+
# the generic `<cli> -p <prompt>` fallback. See README "Supported Agent
17+
# CLIs" for resume semantics per CLI.
1418
AGENT_CMD="claude"
1519
AGENT_DEV_MODEL=""
1620
AGENT_REVIEW_MODEL="sonnet"

skills/autonomous-dispatcher/scripts/lib-agent.sh

Lines changed: 118 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
#!/bin/bash
22
# lib-agent.sh — Agent CLI abstraction layer.
33
#
4-
# Supports: claude (default), codex, kiro, and generic fallback.
4+
# Supports: claude (default), codex, kiro, opencode, and generic fallback.
55
# Source this file in autonomous-dev.sh and autonomous-review.sh.
66
#
77
# Session-id semantics differ across CLIs:
8-
# claude — caller pre-mints `--session-id <UUID>`; same id used for both
9-
# run and resume. Cleanest fit for the dispatcher's session_id.
10-
# codex — CLI mints its own thread_id per `codex exec` invocation. We
11-
# capture it from `--json` stdout via _codex_capture_thread and
12-
# persist a sidecar under pid_dir_for_project() keyed by the
13-
# dispatcher's session_id, then feed it back to
14-
# `codex exec resume <thread_id>` on resume.
15-
# kiro — no session model; every invocation is a fresh conversation.
16-
# resume_agent falls back to run_agent.
17-
# * — generic <cli> -p <prompt> fallback; resume falls back to new.
8+
# claude — caller pre-mints `--session-id <UUID>`; same id used for
9+
# both run and resume. Cleanest fit for the dispatcher's
10+
# session_id.
11+
# codex — CLI mints its own thread_id per `codex exec` invocation.
12+
# We capture it from `--json` stdout via
13+
# _codex_capture_thread and persist a sidecar under
14+
# pid_dir_for_project() keyed by the dispatcher's
15+
# session_id, then feed it back to
16+
# `codex exec resume <thread_id>` on resume.
17+
# kiro — no session model; every invocation is a fresh conversation.
18+
# resume_agent falls back to run_agent.
19+
# opencode — same CLI-minted-session-id wrinkle as codex but with a
20+
# `sessionID` field on every JSON event. Captured the same
21+
# way (_opencode_capture_session) and fed back to
22+
# `opencode run --session <id>` on resume.
23+
# * — generic <cli> -p <prompt> fallback; resume falls back to
24+
# a fresh run.
1825

1926
# Load project config via the shared helper (closes #58).
2027
# Note: ${BASH_SOURCE[0]:-$0} (NOT readlink -f) so the symlink-vendor
@@ -149,6 +156,74 @@ _codex_thread_id() {
149156
printf '%s\n' "$tid"
150157
}
151158

159+
# Opencode session-id capture/recall.
160+
#
161+
# opencode `run` mints its own session id (`ses_<base62>`) per invocation
162+
# and accepts `--session <id>` only for resuming an existing one — same
163+
# CLI-minted-id wrinkle as codex. Mirror the codex helpers, with two
164+
# differences:
165+
# - Field name is `sessionID` (camelCase, capital ID), not `thread_id`.
166+
# - The id is on EVERY event in the JSON stream, not gated on a single
167+
# event type. We still capture the first occurrence, which arrives in
168+
# the very first event.
169+
170+
_opencode_session_file() {
171+
local session_id="$1"
172+
local pid_dir
173+
pid_dir=$(pid_dir_for_project) || return 1
174+
printf '%s/opencode-session-%s\n' "$pid_dir" "$session_id"
175+
}
176+
177+
# _opencode_capture_session <dispatcher_session_id>
178+
#
179+
# Pipeline filter: pass-through awk filter that streams stdin → stdout
180+
# unchanged and writes the first observed sessionID to a sidecar. Same
181+
# pattern + safety properties as _codex_capture_thread.
182+
#
183+
# opencode emits one JSON event per line with a `"sessionID":"ses_..."`
184+
# field on every event. Format verified against opencode v1.14.46 output.
185+
_opencode_capture_session() {
186+
local session_id="$1"
187+
local sess_file
188+
sess_file=$(_opencode_session_file "$session_id") || { cat; return 0; }
189+
awk -v out="$sess_file" '
190+
BEGIN {
191+
# opencode session ids look like `ses_<base62>` (e.g. ses_1ee2d8d...).
192+
# Tracked together with the regex below so the math stays in sync.
193+
prefix = "\"sessionID\":\""
194+
}
195+
{
196+
print
197+
fflush()
198+
if (!captured) {
199+
if (match($0, /"sessionID":"ses_[A-Za-z0-9]+"/)) {
200+
sid = substr($0, RSTART + length(prefix), RLENGTH - length(prefix) - 1)
201+
# Same CWE-59 defense as _codex_capture_thread.
202+
cmd = "test -L \"" out "\" && exit 0; printf \"%s\\n\" \"" sid "\" > \"" out "\""
203+
system(cmd)
204+
captured = 1
205+
}
206+
}
207+
}'
208+
}
209+
210+
# _opencode_session_id <dispatcher_session_id>
211+
#
212+
# Read the captured opencode sessionID from the sidecar. Echo + rc=0 on
213+
# hit, echo nothing + rc=1 on miss/malformed. The `^ses_[A-Za-z0-9]+$`
214+
# regex matches the documented opencode format and protects the
215+
# downstream `opencode run --session <id>` invocation from injection.
216+
_opencode_session_id() {
217+
local session_id="$1"
218+
local sess_file sid
219+
sess_file=$(_opencode_session_file "$session_id") || return 1
220+
[[ -L "$sess_file" ]] && return 1
221+
[[ -f "$sess_file" ]] || return 1
222+
sid=$(head -n1 "$sess_file" 2>/dev/null)
223+
[[ "$sid" =~ ^ses_[A-Za-z0-9]+$ ]] || return 1
224+
printf '%s\n' "$sid"
225+
}
226+
152227
# Acquire PID guard: prevent duplicate instances for the same issue.
153228
# Checks for symlink attacks, running processes, then writes current PID.
154229
# Args: $1=pid_file, $2=label (e.g. "autonomous-dev"), $3=issue_number
@@ -214,6 +289,21 @@ run_agent() {
214289
${model:+--model "$model"} \
215290
"$prompt"
216291
;;
292+
opencode)
293+
# opencode `run [message..]` is the headless invocation. opencode
294+
# mints its own session id and emits it on every event in the JSON
295+
# event stream, so we capture it the same way as codex.
296+
#
297+
# The session_name we got from the dispatcher is passed to --title
298+
# so opencode's session list shows a human-readable handle alongside
299+
# the ses_<base62> id.
300+
_run_with_timeout "$AGENT_CMD" run --format json \
301+
${model:+--model "$model"} \
302+
${session_name:+--title "$session_name"} \
303+
"$prompt" \
304+
| _opencode_capture_session "$session_id"
305+
return "${PIPESTATUS[0]}"
306+
;;
217307
*)
218308
_run_with_timeout "$AGENT_CMD" -p "$prompt"
219309
;;
@@ -271,6 +361,23 @@ resume_agent() {
271361
# is treated as fresh instructions.
272362
run_agent "$session_id" "$prompt" "$model" "$session_name"
273363
;;
364+
opencode)
365+
# `opencode run --session <id> [PROMPT]` resumes the conversation.
366+
# Same pattern as the codex branch: read the captured opencode
367+
# session id from the sidecar, fall back to a new run if missing
368+
# (run_agent crashed before the first JSON event reached us).
369+
local _opencode_sid
370+
if _opencode_sid=$(_opencode_session_id "$session_id"); then
371+
_run_with_timeout "$AGENT_CMD" run --format json --session "$_opencode_sid" \
372+
${model:+--model "$model"} \
373+
"$prompt" \
374+
| _opencode_capture_session "$session_id"
375+
return "${PIPESTATUS[0]}"
376+
else
377+
echo "[lib-agent] no captured opencode sessionID for session $session_id; starting a new opencode session" >&2
378+
run_agent "$session_id" "$prompt" "$model" "$session_name"
379+
fi
380+
;;
274381
*)
275382
# Agents without resume support start a new session
276383
run_agent "$session_id" "$prompt" "$model" "$session_name"

0 commit comments

Comments
 (0)