duet-cli 0.2.4__tar.gz → 0.2.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duet-cli
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Two CLI agents in conversation. One Python file. Stdlib only.
5
5
  Author: Volkan Altan
6
6
  License-Expression: MIT
@@ -24,12 +24,12 @@ Dynamic: license-file
24
24
  **Two CLI agents in conversation. One Python file. Stdlib only.**
25
25
 
26
26
  `duet` runs two command-line coding agents in alternating turns until they
27
- agree. By default that is Claude and Codex; Gemini and Copilot are also
28
- supported, and you can pair two agents from the same backend. One agent plans
29
- or reviews while the other implements; each keeps its own session memory across
30
- turns, and every run leaves a transcript you can inspect.
27
+ agree. By default that is Claude and Codex; Gemini, Copilot, and OpenCode are
28
+ also supported, and you can pair two agents from the same backend. One agent
29
+ plans or reviews while the other implements; each keeps its own session memory
30
+ across turns, and every run leaves a transcript you can inspect.
31
31
 
32
- ## Use it three ways
32
+ ## Use it four ways
33
33
 
34
34
  ### 1. Inside Claude Code — `/duet`
35
35
 
@@ -93,7 +93,26 @@ CLI, so install that first (see below) and make sure `command -v duet` passes in
93
93
  Codex's shell. Full guide:
94
94
  [docs/CODEX_PLUGIN.md](https://github.com/volkan/duet/blob/main/docs/CODEX_PLUGIN.md).
95
95
 
96
- ### 3. From the terminal `duet`
96
+ ### 3. Inside OpenCode`/duet`
97
+
98
+ OpenCode custom commands are drop-in files — no marketplace step:
99
+
100
+ ```bash
101
+ mkdir -p ~/.config/opencode/command
102
+ cp plugins/duet-opencode/command/duet.md ~/.config/opencode/command/duet.md
103
+ ```
104
+
105
+ Then invoke `/duet` in the OpenCode TUI (or `opencode run --command duet "..."`
106
+ non-interactively). Like the other plugins it shells out to the `duet` CLI, so
107
+ install that first and make sure `command -v duet` passes in OpenCode's shell.
108
+ The command runs on OpenCode's `build` agent; plain `/duet` runs the same
109
+ `claude -p /review` kickoff, and `/duet 'npm test 2>&1' --turns 4` seeds from
110
+ any command. Full guide:
111
+ [docs/OPENCODE_PLUGIN.md](https://github.com/volkan/duet/blob/main/docs/OPENCODE_PLUGIN.md).
112
+ (duet can also drive OpenCode as a backend — `--partner opencode:coder` — so
113
+ OpenCode can be one of the two looped agents too.)
114
+
115
+ ### 4. From the terminal — `duet`
97
116
 
98
117
  ```bash
99
118
  pipx install duet-cli # recommended; the command it installs is `duet`
@@ -172,8 +191,9 @@ coder fixes them). Run one with `duet --config examples/pr-review.yaml`.
172
191
  ## How it works
173
192
 
174
193
  Each agent keeps its own conversation memory across turns (Claude via
175
- `--resume`, Codex via `codex exec resume`, Gemini and Copilot via their JSON
176
- session ids). On each turn duet sends one agent's latest reply to the other.
194
+ `--resume`, Codex via `codex exec resume`, Gemini, Copilot, and OpenCode via
195
+ their JSON session ids). On each turn duet sends one agent's latest reply to the
196
+ other.
177
197
 
178
198
  To converge, an agent must include an `LGTM rationale:` explaining why the work
179
199
  is done, followed by the sentinel `<<<LGTM>>>` on its own line — a bare
@@ -186,7 +206,7 @@ stderr logs, and the `wt/` worktree when `--worktree` is on. Inspect a run with
186
206
  `duet --status <run-id>`, list runs with `duet --list`, and start a fresh run
187
207
  from saved state with `duet --continue <run> --task "next thing"`.
188
208
 
189
- - **Backends:** `claude`, `codex`, `gemini`, `copilot`
209
+ - **Backends:** `claude`, `codex`, `gemini`, `copilot`, `opencode`
190
210
  - **Roles:** `planner`, `coder`, `reviewer`, `triage-reviewer`, or a custom one
191
211
  - **Reasoning:** `--reasoning minimal|low|medium|high|xhigh|max`
192
212
 
@@ -3,12 +3,12 @@
3
3
  **Two CLI agents in conversation. One Python file. Stdlib only.**
4
4
 
5
5
  `duet` runs two command-line coding agents in alternating turns until they
6
- agree. By default that is Claude and Codex; Gemini and Copilot are also
7
- supported, and you can pair two agents from the same backend. One agent plans
8
- or reviews while the other implements; each keeps its own session memory across
9
- turns, and every run leaves a transcript you can inspect.
6
+ agree. By default that is Claude and Codex; Gemini, Copilot, and OpenCode are
7
+ also supported, and you can pair two agents from the same backend. One agent
8
+ plans or reviews while the other implements; each keeps its own session memory
9
+ across turns, and every run leaves a transcript you can inspect.
10
10
 
11
- ## Use it three ways
11
+ ## Use it four ways
12
12
 
13
13
  ### 1. Inside Claude Code — `/duet`
14
14
 
@@ -72,7 +72,26 @@ CLI, so install that first (see below) and make sure `command -v duet` passes in
72
72
  Codex's shell. Full guide:
73
73
  [docs/CODEX_PLUGIN.md](https://github.com/volkan/duet/blob/main/docs/CODEX_PLUGIN.md).
74
74
 
75
- ### 3. From the terminal `duet`
75
+ ### 3. Inside OpenCode`/duet`
76
+
77
+ OpenCode custom commands are drop-in files — no marketplace step:
78
+
79
+ ```bash
80
+ mkdir -p ~/.config/opencode/command
81
+ cp plugins/duet-opencode/command/duet.md ~/.config/opencode/command/duet.md
82
+ ```
83
+
84
+ Then invoke `/duet` in the OpenCode TUI (or `opencode run --command duet "..."`
85
+ non-interactively). Like the other plugins it shells out to the `duet` CLI, so
86
+ install that first and make sure `command -v duet` passes in OpenCode's shell.
87
+ The command runs on OpenCode's `build` agent; plain `/duet` runs the same
88
+ `claude -p /review` kickoff, and `/duet 'npm test 2>&1' --turns 4` seeds from
89
+ any command. Full guide:
90
+ [docs/OPENCODE_PLUGIN.md](https://github.com/volkan/duet/blob/main/docs/OPENCODE_PLUGIN.md).
91
+ (duet can also drive OpenCode as a backend — `--partner opencode:coder` — so
92
+ OpenCode can be one of the two looped agents too.)
93
+
94
+ ### 4. From the terminal — `duet`
76
95
 
77
96
  ```bash
78
97
  pipx install duet-cli # recommended; the command it installs is `duet`
@@ -151,8 +170,9 @@ coder fixes them). Run one with `duet --config examples/pr-review.yaml`.
151
170
  ## How it works
152
171
 
153
172
  Each agent keeps its own conversation memory across turns (Claude via
154
- `--resume`, Codex via `codex exec resume`, Gemini and Copilot via their JSON
155
- session ids). On each turn duet sends one agent's latest reply to the other.
173
+ `--resume`, Codex via `codex exec resume`, Gemini, Copilot, and OpenCode via
174
+ their JSON session ids). On each turn duet sends one agent's latest reply to the
175
+ other.
156
176
 
157
177
  To converge, an agent must include an `LGTM rationale:` explaining why the work
158
178
  is done, followed by the sentinel `<<<LGTM>>>` on its own line — a bare
@@ -165,7 +185,7 @@ stderr logs, and the `wt/` worktree when `--worktree` is on. Inspect a run with
165
185
  `duet --status <run-id>`, list runs with `duet --list`, and start a fresh run
166
186
  from saved state with `duet --continue <run> --task "next thing"`.
167
187
 
168
- - **Backends:** `claude`, `codex`, `gemini`, `copilot`
188
+ - **Backends:** `claude`, `codex`, `gemini`, `copilot`, `opencode`
169
189
  - **Roles:** `planner`, `coder`, `reviewer`, `triage-reviewer`, or a custom one
170
190
  - **Reasoning:** `--reasoning minimal|low|medium|high|xhigh|max`
171
191
 
@@ -28,8 +28,12 @@ Each agent keeps its own session across turns:
28
28
  UUID makes resume robust to parallel Codex sessions sharing the cwd, but
29
29
  `--last` is still keyed on cwd — use `--worktree` to isolate duet's Codex
30
30
  cwd from the host repo when no UUID is available.
31
+ - Gemini: `gemini -p ... --output-format json`; subsequent turns use
32
+ `gemini --resume <session_id>` from the JSON response.
31
33
  - Copilot: `copilot -p ... --output-format json`; subsequent turns use
32
34
  `copilot --resume=<sessionId>` from the JSONL result event.
35
+ - OpenCode: `opencode run --format json`; subsequent turns use
36
+ `opencode run -s <sessionID>` from the JSONL event stream.
33
37
 
34
38
  Transcript is always logged to runs/<ts>/transcript.md for humans, but each
35
39
  prompt sent to an agent is just the latest counterpart message — keeping
@@ -223,6 +227,18 @@ COPILOT_REASONING_MAP = {
223
227
  }
224
228
  COPILOT_REASONING_PROMPT_PREFIX = CLAUDE_REASONING_PROMPT_PREFIX
225
229
 
230
+ # OpenCode exposes reasoning effort through `run --variant <value>`, documented
231
+ # as "provider-specific reasoning effort (e.g. high, max, minimal)". The valid
232
+ # set is model/provider-dependent, but OpenCode silently ignores a variant a
233
+ # model doesn't define rather than erroring, so we pass duet's level through
234
+ # unchanged (identity map): a model that knows the variant honors it, one that
235
+ # doesn't is unaffected. This keeps the mapping forward-compatible without duet
236
+ # tracking each provider's variant vocabulary.
237
+ OPENCODE_REASONING_MAP = {level: level for level in REASONING_LEVELS}
238
+ # `--variant` is the authoritative effort control; the high/xhigh/max prompt
239
+ # nudges are belt-and-braces, same as Claude/Gemini/Copilot.
240
+ OPENCODE_REASONING_PROMPT_PREFIX = CLAUDE_REASONING_PROMPT_PREFIX
241
+
226
242
  # Map duet's existing Claude-flavored permission mode knob to Gemini CLI's
227
243
  # approval vocabulary. Unknown values pass through so advanced users can try
228
244
  # newer Gemini modes without waiting for a duet release.
@@ -1498,6 +1514,145 @@ def call_copilot(agent: Agent, system_prompt: str, message: str,
1498
1514
  return text, new_sid
1499
1515
 
1500
1516
 
1517
+ def _parse_opencode_jsonl(stdout: str) -> tuple[str, Optional[str], Optional[str]]:
1518
+ """Return (assistant_text, sessionID, error_message) from `opencode run --format json`.
1519
+
1520
+ OpenCode emits a JSONL event stream on stdout. Every event carries a
1521
+ top-level `sessionID`; assistant reply text arrives as one or more
1522
+ ``{"type":"text","part":{"type":"text","text":...}}`` events; failures
1523
+ arrive as ``{"type":"error","error":{...}}`` events. Crucially, `opencode
1524
+ run` exits 0 even on model/tool errors — the failure lives in the event
1525
+ stream, not the exit code — so the caller must treat a returned
1526
+ error_message as fatal.
1527
+
1528
+ Text parts that carry an id are keyed by it (last write wins) so a streamed
1529
+ part re-emitted as it grows doesn't duplicate; the rare id-less part gets a
1530
+ unique synthetic key (``__noid_<n>``) so distinct parts (e.g. text split
1531
+ around a tool call) are still concatenated in arrival order without ever
1532
+ aliasing a real id. Non-JSON lines are skipped rather than fatal: OpenCode
1533
+ can print a one-time DB-migration banner on a fresh machine, and a genuine
1534
+ failure still surfaces via a missing sessionID or an error event.
1535
+ """
1536
+ parts: dict[str, str] = {}
1537
+ session_id: Optional[str] = None
1538
+ error_message: Optional[str] = None
1539
+ for raw_line in stdout.splitlines():
1540
+ line = raw_line.strip()
1541
+ if not line:
1542
+ continue
1543
+ try:
1544
+ event = json.loads(line)
1545
+ except json.JSONDecodeError:
1546
+ continue
1547
+ if not isinstance(event, dict):
1548
+ continue
1549
+ sid = event.get("sessionID")
1550
+ if sid:
1551
+ session_id = str(sid)
1552
+ etype = event.get("type")
1553
+ if etype == "text":
1554
+ part = event.get("part")
1555
+ if isinstance(part, dict) and part.get("type") == "text":
1556
+ text = part.get("text")
1557
+ if isinstance(text, str):
1558
+ # Real OpenCode parts always carry a stable id (prt_…). The
1559
+ # synthetic key keeps a rare id-less part distinct without
1560
+ # ever aliasing a real id like "0".
1561
+ raw_id = part.get("id")
1562
+ pid = str(raw_id) if raw_id else f"__noid_{len(parts)}"
1563
+ parts[pid] = text
1564
+ elif etype == "error":
1565
+ err = event.get("error")
1566
+ if isinstance(err, str):
1567
+ # OpenCode documents a dict error payload, but tolerate a
1568
+ # plain-string one so the real cause is surfaced verbatim
1569
+ # instead of degrading to "unknown error".
1570
+ msg = err
1571
+ else:
1572
+ data = err.get("data") if isinstance(err, dict) else None
1573
+ msg = data.get("message") if isinstance(data, dict) else None
1574
+ if not msg and isinstance(err, dict):
1575
+ msg = err.get("name")
1576
+ error_message = str(msg or "unknown error")
1577
+ return "\n".join(parts.values()).rstrip(), session_id, error_message
1578
+
1579
+
1580
+ def call_opencode(agent: Agent, system_prompt: str, message: str,
1581
+ cwd: pathlib.Path, timeout: int, dry: bool,
1582
+ first_turn: bool, reasoning: Optional[str] = None,
1583
+ stderr_log_path: Optional[pathlib.Path] = None,
1584
+ pid_file_path: Optional[pathlib.Path] = None) -> tuple[str, Optional[str]]:
1585
+ """Returns (assistant_text, new_session_id) from the OpenCode CLI JSONL stream."""
1586
+ # OpenCode resume is id-keyed (`-s <sessionID>`) and the id is stable across
1587
+ # turns, so there's no first-turn command bifurcation; the argument keeps the
1588
+ # adapter dispatch signature aligned with Codex/Gemini/Copilot.
1589
+ _ = first_turn
1590
+ eff_cwd = agent.cwd_override or cwd
1591
+ if reasoning:
1592
+ prefix = backend_adapter("opencode").reasoning_prompt_prefix.get(reasoning, "")
1593
+ system_prompt = prefix + system_prompt
1594
+ if dry:
1595
+ new_sid = agent.session_id or f"dry-opencode-{agent.name}-{int(time.time())}"
1596
+ wt_note = f" wt={eff_cwd}" if agent.cwd_override else ""
1597
+ rn = f" reasoning={reasoning}" if reasoning else ""
1598
+ return (
1599
+ f"[dry-run opencode/{agent.name}{wt_note}{rn}] received {len(message)} chars\n"
1600
+ "LGTM rationale: dry-run accepted the harness path and has no real "
1601
+ "agent output to review.\n"
1602
+ f"{DEFAULT_SENTINEL}"
1603
+ ), new_sid
1604
+
1605
+ full_prompt = f"=== ROLE ===\n{system_prompt}\n\n=== MESSAGE FROM PARTNER ===\n{message}"
1606
+ # OpenCode permissions are native; like Copilot we run non-interactively and
1607
+ # auto-approve so tool-using turns don't hang waiting for an approval that
1608
+ # can never arrive. `--sandbox` / `permission_mode` do not apply. All options
1609
+ # precede the positional message (the `run [message..]` positional is last).
1610
+ cmd = [
1611
+ "opencode", "run",
1612
+ "--format", "json",
1613
+ "--dir", str(eff_cwd),
1614
+ "--dangerously-skip-permissions",
1615
+ ]
1616
+ if agent.session_id:
1617
+ cmd += ["-s", agent.session_id]
1618
+ if reasoning:
1619
+ variant = backend_adapter("opencode").reasoning_map.get(reasoning, reasoning)
1620
+ if variant:
1621
+ cmd += ["--variant", variant]
1622
+ if agent.model:
1623
+ cmd += ["-m", agent.model]
1624
+ cmd += agent.extra_args
1625
+ cmd.append(full_prompt)
1626
+ rc, out, err = _agent_run(
1627
+ cmd,
1628
+ backend="opencode",
1629
+ cwd=eff_cwd,
1630
+ stdin=None,
1631
+ timeout=timeout,
1632
+ stderr_log_path=stderr_log_path,
1633
+ pid_file_path=pid_file_path,
1634
+ )
1635
+ if rc != 0:
1636
+ reason = FINISHED_TIMEOUT if rc == 124 else FINISHED_AGENT_ERROR
1637
+ raise AgentRunError(reason, f"opencode exited {rc}\nstderr:\n{err}")
1638
+ text, new_sid, error_message = _parse_opencode_jsonl(out)
1639
+ # `opencode run` exits 0 even when the model/tool errored; the failure is an
1640
+ # error event in the JSONL stream. Surface it as agent_error rather than
1641
+ # forwarding an empty or partial reply.
1642
+ if error_message:
1643
+ raise AgentRunError(
1644
+ FINISHED_AGENT_ERROR,
1645
+ f"opencode reported an error event: {error_message}",
1646
+ )
1647
+ if not new_sid:
1648
+ raise AgentRunError(
1649
+ FINISHED_AGENT_ERROR,
1650
+ "opencode JSONL output did not include a sessionID; duet requires an "
1651
+ "OpenCode CLI build that emits JSON session events for multi-turn memory.",
1652
+ )
1653
+ return text, new_sid
1654
+
1655
+
1501
1656
  def _call_claude_backend(agent: Agent, system_prompt: str, message: str,
1502
1657
  cfg: DuetConfig, first_turn: bool,
1503
1658
  stderr_log_path: Optional[pathlib.Path],
@@ -1566,6 +1721,24 @@ def _call_copilot_backend(agent: Agent, system_prompt: str, message: str,
1566
1721
  )
1567
1722
 
1568
1723
 
1724
+ def _call_opencode_backend(agent: Agent, system_prompt: str, message: str,
1725
+ cfg: DuetConfig, first_turn: bool,
1726
+ stderr_log_path: Optional[pathlib.Path],
1727
+ pid_file_path: Optional[pathlib.Path],
1728
+ reasoning: Optional[str]) -> tuple[str, Optional[str]]:
1729
+ # OpenCode's `run` operates on the whole project under `--dir`; it has no
1730
+ # per-call extra-root flag, so cfg.add_dirs is intentionally not plumbed
1731
+ # through (mirrors Codex, which also ignores add_dirs).
1732
+ return call_opencode(
1733
+ agent, system_prompt, message, cfg.cwd,
1734
+ cfg.per_turn_timeout, cfg.dry_run,
1735
+ first_turn=first_turn,
1736
+ reasoning=reasoning,
1737
+ stderr_log_path=stderr_log_path,
1738
+ pid_file_path=pid_file_path,
1739
+ )
1740
+
1741
+
1569
1742
  BACKENDS = {
1570
1743
  "claude": BackendAdapter(
1571
1744
  name="claude",
@@ -1596,6 +1769,13 @@ BACKENDS = {
1596
1769
  reasoning_map=COPILOT_REASONING_MAP,
1597
1770
  reasoning_prompt_prefix=COPILOT_REASONING_PROMPT_PREFIX,
1598
1771
  ),
1772
+ "opencode": BackendAdapter(
1773
+ name="opencode",
1774
+ binary="opencode",
1775
+ call=_call_opencode_backend,
1776
+ reasoning_map=OPENCODE_REASONING_MAP,
1777
+ reasoning_prompt_prefix=OPENCODE_REASONING_PROMPT_PREFIX,
1778
+ ),
1599
1779
  }
1600
1780
  SUPPORTED_BACKENDS = set(BACKENDS)
1601
1781
 
@@ -3368,9 +3548,13 @@ def _build_arg_parser() -> argparse.ArgumentParser:
3368
3548
  ap.add_argument("--task-from-cmd", metavar="CMD",
3369
3549
  help="run shell command with cwd=--cwd and use stdout as the task")
3370
3550
  ap.add_argument("--partner", default="codex:coder",
3371
- help="partner agent spec, e.g. codex:coder, claude:reviewer, gemini:coder, copilot:coder (default codex:coder)")
3551
+ help="partner agent spec, e.g. codex:coder, claude:reviewer, gemini:coder, copilot:coder, opencode:coder (default codex:coder)")
3372
3552
  ap.add_argument("--lead", default="claude:planner",
3373
- help="lead agent spec, e.g. claude:planner, gemini:reviewer, copilot:planner (default; ignored if --resume-claude given)")
3553
+ help="lead agent spec, e.g. claude:planner, gemini:reviewer, copilot:planner, opencode:planner (default; ignored if --resume-claude given)")
3554
+ ap.add_argument("--lead-model", metavar="MODEL", default=None,
3555
+ help="model name to pass to the lead agent's backend via --model")
3556
+ ap.add_argument("--partner-model", metavar="MODEL", default=None,
3557
+ help="model name to pass to the partner agent's backend via --model")
3374
3558
  ap.add_argument("--cwd", default=".", help="working dir for both agents")
3375
3559
  ap.add_argument("--turns", type=int, default=DEFAULT_TURNS, help=f"max turns (default {DEFAULT_TURNS})")
3376
3560
  ap.add_argument("--sentinel", default=DEFAULT_SENTINEL,
@@ -3383,9 +3567,9 @@ def _build_arg_parser() -> argparse.ArgumentParser:
3383
3567
  "proposals; YAML key: `verify_cmd:`.")
3384
3568
  ap.add_argument("--runs-dir", default=None, help="where to save transcripts")
3385
3569
  ap.add_argument("--sandbox", default="workspace-write",
3386
- help="codex --sandbox: read-only|workspace-write|danger-full-access. No-op for claude/gemini/copilot.")
3570
+ help="codex --sandbox: read-only|workspace-write|danger-full-access. No-op for claude/gemini/copilot/opencode.")
3387
3571
  ap.add_argument("--permission-mode", default="acceptEdits",
3388
- help="claude --permission-mode; gemini maps default/acceptEdits/plan/bypassPermissions to --approval-mode. No-op for copilot.")
3572
+ help="claude --permission-mode; gemini maps default/acceptEdits/plan/bypassPermissions to --approval-mode. No-op for copilot/opencode.")
3389
3573
  ap.add_argument("--config", help="optional YAML/JSON config (overrides flags except --resume-*)")
3390
3574
  ap.add_argument("--worktree", action="store_true",
3391
3575
  help="run the partner agent in a throwaway git worktree on a fresh branch; "
@@ -3542,9 +3726,18 @@ def _build_cfg_from_cli(args: argparse.Namespace, ap: argparse.ArgumentParser,
3542
3726
  parser=ap,
3543
3727
  stdin_cache=stdin_cache,
3544
3728
  )
3729
+ agents = [
3730
+ dataclasses.replace(
3731
+ parse_partner(args.lead, default_role="planner"),
3732
+ model=args.lead_model or None,
3733
+ ),
3734
+ dataclasses.replace(
3735
+ parse_partner(args.partner, default_role="coder"),
3736
+ model=args.partner_model or None,
3737
+ ),
3738
+ ]
3545
3739
  agents = apply_resume_overrides(
3546
- [parse_partner(args.lead, default_role="planner"),
3547
- parse_partner(args.partner, default_role="coder")],
3740
+ agents,
3548
3741
  resume_claude=args.resume_claude,
3549
3742
  resume_codex=args.resume_codex,
3550
3743
  rename_slots=True,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duet-cli
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Two CLI agents in conversation. One Python file. Stdlib only.
5
5
  Author: Volkan Altan
6
6
  License-Expression: MIT
@@ -24,12 +24,12 @@ Dynamic: license-file
24
24
  **Two CLI agents in conversation. One Python file. Stdlib only.**
25
25
 
26
26
  `duet` runs two command-line coding agents in alternating turns until they
27
- agree. By default that is Claude and Codex; Gemini and Copilot are also
28
- supported, and you can pair two agents from the same backend. One agent plans
29
- or reviews while the other implements; each keeps its own session memory across
30
- turns, and every run leaves a transcript you can inspect.
27
+ agree. By default that is Claude and Codex; Gemini, Copilot, and OpenCode are
28
+ also supported, and you can pair two agents from the same backend. One agent
29
+ plans or reviews while the other implements; each keeps its own session memory
30
+ across turns, and every run leaves a transcript you can inspect.
31
31
 
32
- ## Use it three ways
32
+ ## Use it four ways
33
33
 
34
34
  ### 1. Inside Claude Code — `/duet`
35
35
 
@@ -93,7 +93,26 @@ CLI, so install that first (see below) and make sure `command -v duet` passes in
93
93
  Codex's shell. Full guide:
94
94
  [docs/CODEX_PLUGIN.md](https://github.com/volkan/duet/blob/main/docs/CODEX_PLUGIN.md).
95
95
 
96
- ### 3. From the terminal `duet`
96
+ ### 3. Inside OpenCode`/duet`
97
+
98
+ OpenCode custom commands are drop-in files — no marketplace step:
99
+
100
+ ```bash
101
+ mkdir -p ~/.config/opencode/command
102
+ cp plugins/duet-opencode/command/duet.md ~/.config/opencode/command/duet.md
103
+ ```
104
+
105
+ Then invoke `/duet` in the OpenCode TUI (or `opencode run --command duet "..."`
106
+ non-interactively). Like the other plugins it shells out to the `duet` CLI, so
107
+ install that first and make sure `command -v duet` passes in OpenCode's shell.
108
+ The command runs on OpenCode's `build` agent; plain `/duet` runs the same
109
+ `claude -p /review` kickoff, and `/duet 'npm test 2>&1' --turns 4` seeds from
110
+ any command. Full guide:
111
+ [docs/OPENCODE_PLUGIN.md](https://github.com/volkan/duet/blob/main/docs/OPENCODE_PLUGIN.md).
112
+ (duet can also drive OpenCode as a backend — `--partner opencode:coder` — so
113
+ OpenCode can be one of the two looped agents too.)
114
+
115
+ ### 4. From the terminal — `duet`
97
116
 
98
117
  ```bash
99
118
  pipx install duet-cli # recommended; the command it installs is `duet`
@@ -172,8 +191,9 @@ coder fixes them). Run one with `duet --config examples/pr-review.yaml`.
172
191
  ## How it works
173
192
 
174
193
  Each agent keeps its own conversation memory across turns (Claude via
175
- `--resume`, Codex via `codex exec resume`, Gemini and Copilot via their JSON
176
- session ids). On each turn duet sends one agent's latest reply to the other.
194
+ `--resume`, Codex via `codex exec resume`, Gemini, Copilot, and OpenCode via
195
+ their JSON session ids). On each turn duet sends one agent's latest reply to the
196
+ other.
177
197
 
178
198
  To converge, an agent must include an `LGTM rationale:` explaining why the work
179
199
  is done, followed by the sentinel `<<<LGTM>>>` on its own line — a bare
@@ -186,7 +206,7 @@ stderr logs, and the `wt/` worktree when `--worktree` is on. Inspect a run with
186
206
  `duet --status <run-id>`, list runs with `duet --list`, and start a fresh run
187
207
  from saved state with `duet --continue <run> --task "next thing"`.
188
208
 
189
- - **Backends:** `claude`, `codex`, `gemini`, `copilot`
209
+ - **Backends:** `claude`, `codex`, `gemini`, `copilot`, `opencode`
190
210
  - **Roles:** `planner`, `coder`, `reviewer`, `triage-reviewer`, or a custom one
191
211
  - **Reasoning:** `--reasoning minimal|low|medium|high|xhigh|max`
192
212
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "duet-cli"
7
- version = "0.2.4"
7
+ version = "0.2.6"
8
8
  description = "Two CLI agents in conversation. One Python file. Stdlib only."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -454,6 +454,130 @@ class TestAgentFinishReasons(unittest.TestCase):
454
454
  self.assertEqual(ctx.exception.finished_reason, duet.FINISHED_AGENT_ERROR)
455
455
  self.assertIn("copilot exited 2", str(ctx.exception))
456
456
 
457
+ def test_opencode_error_event_maps_to_agent_error(self) -> None:
458
+ # `opencode run` exits 0 on a model error, so call_opencode must catch
459
+ # the error event in the JSONL stream and not forward a broken reply.
460
+ def fake_run(cmd, **kwargs):
461
+ return (
462
+ 0,
463
+ '{"type":"error","sessionID":"ses_x","error":{"data":{"message":"boom"}}}',
464
+ "",
465
+ )
466
+
467
+ agent = duet.Agent(name="opencode-partner", backend="opencode", role="coder")
468
+ with mock.patch.object(duet, "_run", fake_run):
469
+ with self.assertRaises(duet.AgentRunError) as ctx:
470
+ duet.call_opencode(
471
+ agent, "sys", "msg", _ROOT, 60,
472
+ dry=False, first_turn=True,
473
+ )
474
+
475
+ self.assertEqual(ctx.exception.finished_reason, duet.FINISHED_AGENT_ERROR)
476
+ self.assertIn("boom", str(ctx.exception))
477
+
478
+ def test_opencode_missing_session_id_maps_to_agent_error(self) -> None:
479
+ def fake_run(cmd, **kwargs):
480
+ return (
481
+ 0,
482
+ '{"type":"text","part":{"type":"text","text":"ok","id":"p1"}}',
483
+ "",
484
+ )
485
+
486
+ agent = duet.Agent(name="opencode-partner", backend="opencode", role="coder")
487
+ with mock.patch.object(duet, "_run", fake_run):
488
+ with self.assertRaises(duet.AgentRunError) as ctx:
489
+ duet.call_opencode(
490
+ agent, "sys", "msg", _ROOT, 60,
491
+ dry=False, first_turn=True,
492
+ )
493
+
494
+ self.assertEqual(ctx.exception.finished_reason, duet.FINISHED_AGENT_ERROR)
495
+ self.assertIn("sessionID", str(ctx.exception))
496
+
497
+ def test_opencode_rc_124_maps_to_timeout(self) -> None:
498
+ def fake_run(cmd, **kwargs):
499
+ return 124, "", "[duet] TIMEOUT after 1s"
500
+
501
+ agent = duet.Agent(name="opencode-partner", backend="opencode", role="coder")
502
+ with mock.patch.object(duet, "_run", fake_run):
503
+ with self.assertRaises(duet.AgentRunError) as ctx:
504
+ duet.call_opencode(
505
+ agent, "sys", "msg", _ROOT, 1,
506
+ dry=False, first_turn=True,
507
+ )
508
+
509
+ self.assertEqual(ctx.exception.finished_reason, duet.FINISHED_TIMEOUT)
510
+ self.assertIn("opencode exited 124", str(ctx.exception))
511
+
512
+ def test_opencode_error_event_precedes_missing_session_id(self) -> None:
513
+ # An error event with no sessionID must surface the error text, not the
514
+ # generic missing-sessionID message — locks the check ordering in
515
+ # call_opencode (mirrors the Gemini precedence test).
516
+ def fake_run(cmd, **kwargs):
517
+ return (
518
+ 0,
519
+ '{"type":"error","error":{"data":{"message":"boom"}}}',
520
+ "",
521
+ )
522
+
523
+ agent = duet.Agent(name="opencode-partner", backend="opencode", role="coder")
524
+ with mock.patch.object(duet, "_run", fake_run):
525
+ with self.assertRaises(duet.AgentRunError) as ctx:
526
+ duet.call_opencode(
527
+ agent, "sys", "msg", _ROOT, 60,
528
+ dry=False, first_turn=True,
529
+ )
530
+
531
+ self.assertEqual(ctx.exception.finished_reason, duet.FINISHED_AGENT_ERROR)
532
+ self.assertIn("boom", str(ctx.exception))
533
+ self.assertNotIn("sessionID", str(ctx.exception))
534
+
535
+ def test_opencode_command_construction_and_resume(self) -> None:
536
+ # Capture the constructed argv to pin the flags the reasoning-check and
537
+ # dry-run exit codes can't see: the `-s` resume flag, whose silent loss
538
+ # would orphan multi-turn memory while still returning rc=0, and the
539
+ # `-m provider/model` form OpenCode requires for model selection.
540
+ captured: list[list[str]] = []
541
+
542
+ def fake_run(cmd, **kwargs):
543
+ captured.append(list(cmd))
544
+ return (
545
+ 0,
546
+ '{"type":"text","sessionID":"ses_new","part":{"type":"text","text":"ok","id":"p1"}}',
547
+ "",
548
+ )
549
+
550
+ agent = duet.Agent(
551
+ name="opencode-partner",
552
+ backend="opencode",
553
+ role="coder",
554
+ model="anthropic/claude-sonnet-4-6",
555
+ )
556
+ with mock.patch.object(duet, "_run", fake_run):
557
+ # First turn: no session_id yet.
558
+ duet.call_opencode(agent, "sys", "msg", _ROOT, 60,
559
+ dry=False, first_turn=True)
560
+ first = captured[-1]
561
+ self.assertEqual(first[:2], ["opencode", "run"])
562
+ self.assertIn("--format", first)
563
+ self.assertEqual(first[first.index("--format") + 1], "json")
564
+ self.assertIn("--dir", first)
565
+ self.assertIn("--dangerously-skip-permissions", first)
566
+ self.assertIn("-m", first)
567
+ self.assertEqual(first[first.index("-m") + 1], "anthropic/claude-sonnet-4-6")
568
+ self.assertNotIn("-s", first) # nothing to resume on turn 1
569
+ # The prompt is the trailing positional arg (options come first).
570
+ self.assertIn("=== MESSAGE FROM PARTNER ===", first[-1])
571
+ self.assertTrue(first[-1].rstrip().endswith("msg"))
572
+
573
+ # Second turn: resume by the parsed session id.
574
+ agent.session_id = "ses_prev"
575
+ duet.call_opencode(agent, "sys", "msg2", _ROOT, 60,
576
+ dry=False, first_turn=False)
577
+ second = captured[-1]
578
+ self.assertIn("-s", second)
579
+ self.assertEqual(second[second.index("-s") + 1], "ses_prev")
580
+
457
581
 
458
582
  # ---------- _parse_codex_session_id ----------
459
583
 
@@ -572,6 +696,102 @@ class TestParseCopilotJsonl(unittest.TestCase):
572
696
  duet._parse_copilot_jsonl("not json")
573
697
 
574
698
 
699
+ # ---------- _parse_opencode_jsonl ----------
700
+
701
+
702
+ class TestParseOpencodeJsonl(unittest.TestCase):
703
+ def test_extracts_text_and_session_id(self) -> None:
704
+ out = "\n".join([
705
+ '{"type":"step_start","sessionID":"ses_abc","part":{"type":"step-start"}}',
706
+ '{"type":"text","sessionID":"ses_abc","part":{"type":"text","text":"PONG","id":"p1"}}',
707
+ '{"type":"step_finish","sessionID":"ses_abc","part":{"type":"step-finish"}}',
708
+ ])
709
+
710
+ text, session_id, error = duet._parse_opencode_jsonl(out)
711
+
712
+ self.assertEqual(text, "PONG")
713
+ self.assertEqual(session_id, "ses_abc")
714
+ self.assertIsNone(error)
715
+
716
+ def test_concatenates_multiple_distinct_text_parts(self) -> None:
717
+ # Text split around a tool call arrives as two parts with distinct ids;
718
+ # both are kept, in arrival order.
719
+ out = "\n".join([
720
+ '{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"Let me check.","id":"p1"}}',
721
+ '{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"Done.","id":"p2"}}',
722
+ ])
723
+
724
+ text, session_id, error = duet._parse_opencode_jsonl(out)
725
+
726
+ self.assertEqual(text, "Let me check.\nDone.")
727
+ self.assertEqual(session_id, "ses_x")
728
+ self.assertIsNone(error)
729
+
730
+ def test_streamed_part_last_write_wins(self) -> None:
731
+ # A part re-emitted as it grows (same id) must not duplicate; last wins.
732
+ out = "\n".join([
733
+ '{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"PO","id":"p1"}}',
734
+ '{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"PONG","id":"p1"}}',
735
+ ])
736
+
737
+ text, _, _ = duet._parse_opencode_jsonl(out)
738
+
739
+ self.assertEqual(text, "PONG")
740
+
741
+ def test_error_event_is_surfaced(self) -> None:
742
+ # `opencode run` exits 0 even on model errors; the failure is an error
743
+ # event in the stream, so the parser must report it.
744
+ out = ('{"type":"error","sessionID":"ses_x","error":{"name":"UnknownError",'
745
+ '"data":{"message":"Model not found: foo."}}}')
746
+
747
+ text, session_id, error = duet._parse_opencode_jsonl(out)
748
+
749
+ self.assertEqual(text, "")
750
+ self.assertEqual(session_id, "ses_x")
751
+ self.assertEqual(error, "Model not found: foo.")
752
+
753
+ def test_non_json_banner_lines_are_skipped(self) -> None:
754
+ # A fresh machine can print a one-time DB-migration banner; tolerate it
755
+ # rather than treating the whole turn as malformed.
756
+ out = "\n".join([
757
+ "Performing one time database migration...",
758
+ '{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"ok","id":"p1"}}',
759
+ ])
760
+
761
+ text, session_id, error = duet._parse_opencode_jsonl(out)
762
+
763
+ self.assertEqual(text, "ok")
764
+ self.assertEqual(session_id, "ses_x")
765
+ self.assertIsNone(error)
766
+
767
+ def test_string_error_payload_is_surfaced(self) -> None:
768
+ # OpenCode documents a dict error payload, but a plain-string one must
769
+ # surface verbatim rather than degrading to "unknown error".
770
+ out = '{"type":"error","sessionID":"ses_x","error":"Model not found: foo"}'
771
+
772
+ text, session_id, error = duet._parse_opencode_jsonl(out)
773
+
774
+ self.assertEqual(text, "")
775
+ self.assertEqual(session_id, "ses_x")
776
+ self.assertEqual(error, "Model not found: foo")
777
+
778
+ def test_idless_part_does_not_alias_real_id(self) -> None:
779
+ # An id-less text part must not collide with a real part whose id is the
780
+ # bare integer the old `len(parts)` fallback would have produced ("0").
781
+ # Both texts must survive, in arrival order.
782
+ out = "\n".join([
783
+ '{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"A"}}',
784
+ '{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"B","id":"0"}}',
785
+ ])
786
+
787
+ text, _, _ = duet._parse_opencode_jsonl(out)
788
+
789
+ self.assertEqual(text, "A\nB")
790
+
791
+ def test_empty_returns_no_session(self) -> None:
792
+ self.assertEqual(duet._parse_opencode_jsonl(""), ("", None, None))
793
+
794
+
575
795
  # ---------- _parse_gemini_session_id ----------
576
796
 
577
797
 
@@ -758,9 +978,11 @@ class TestReasoningHelpers(unittest.TestCase):
758
978
  self.assertIn(level, duet.CODEX_REASONING_MAP)
759
979
  self.assertIn(level, duet.GEMINI_REASONING_MAP)
760
980
  self.assertIn(level, duet.COPILOT_REASONING_MAP)
981
+ self.assertIn(level, duet.OPENCODE_REASONING_MAP)
761
982
  self.assertIn(level, duet.CLAUDE_REASONING_PROMPT_PREFIX)
762
983
  self.assertIn(level, duet.GEMINI_REASONING_PROMPT_PREFIX)
763
984
  self.assertIn(level, duet.COPILOT_REASONING_PROMPT_PREFIX)
985
+ self.assertIn(level, duet.OPENCODE_REASONING_PROMPT_PREFIX)
764
986
 
765
987
  def test_codex_max_maps_to_xhigh(self) -> None:
766
988
  # Codex documents `xhigh` but not `max`; duet keeps `max` as a
@@ -784,6 +1006,13 @@ class TestReasoningHelpers(unittest.TestCase):
784
1006
  with self.subTest(level=level):
785
1007
  self.assertEqual(duet.GEMINI_REASONING_MAP[level], "")
786
1008
 
1009
+ def test_opencode_reasoning_map_is_identity(self) -> None:
1010
+ # OpenCode tolerates/ignores unknown `--variant` values, so duet passes
1011
+ # each level through unchanged for forward-compatibility.
1012
+ for level in duet.REASONING_LEVELS:
1013
+ with self.subTest(level=level):
1014
+ self.assertEqual(duet.OPENCODE_REASONING_MAP[level], level)
1015
+
787
1016
 
788
1017
  # ---------- parse_partner ----------
789
1018
 
@@ -825,6 +1054,45 @@ class TestParsePartner(unittest.TestCase):
825
1054
  self.assertEqual(agent.name, "copilot-coder")
826
1055
 
827
1056
 
1057
+ # ---------- _build_cfg_from_cli ----------
1058
+
1059
+
1060
+ class TestBuildCfgFromCli(unittest.TestCase):
1061
+ def _parse(self, *argv: str):
1062
+ parser = duet._build_arg_parser()
1063
+ return parser, parser.parse_args(list(argv))
1064
+
1065
+ def test_lead_and_partner_models_attach_to_cli_agents(self) -> None:
1066
+ parser, args = self._parse(
1067
+ "--task", "x",
1068
+ "--lead", "claude:reviewer",
1069
+ "--partner", "codex:coder",
1070
+ "--lead-model", "claude-opus-4-5",
1071
+ "--partner-model", "gpt-5",
1072
+ )
1073
+
1074
+ cfg = duet._build_cfg_from_cli(args, parser, {})
1075
+
1076
+ self.assertEqual(cfg.agents[0].backend, "claude")
1077
+ self.assertEqual(cfg.agents[0].role, "reviewer")
1078
+ self.assertEqual(cfg.agents[0].model, "claude-opus-4-5")
1079
+ self.assertEqual(cfg.agents[1].backend, "codex")
1080
+ self.assertEqual(cfg.agents[1].role, "coder")
1081
+ self.assertEqual(cfg.agents[1].model, "gpt-5")
1082
+
1083
+ def test_empty_cli_model_flags_normalize_to_none(self) -> None:
1084
+ parser, args = self._parse(
1085
+ "--task", "x",
1086
+ "--lead-model", "",
1087
+ "--partner-model", "",
1088
+ )
1089
+
1090
+ cfg = duet._build_cfg_from_cli(args, parser, {})
1091
+
1092
+ self.assertIsNone(cfg.agents[0].model)
1093
+ self.assertIsNone(cfg.agents[1].model)
1094
+
1095
+
828
1096
  # ---------- apply_resume_overrides ----------
829
1097
 
830
1098
 
@@ -878,6 +1146,32 @@ class TestApplyResumeOverrides(unittest.TestCase):
878
1146
  self.assertEqual(agents[1].role, "coder")
879
1147
  self.assertEqual(agents[1].session_id, "codex-sid")
880
1148
 
1149
+ def test_resume_codex_move_preserves_model(self) -> None:
1150
+ agents = duet.apply_resume_overrides(
1151
+ [
1152
+ duet.Agent(
1153
+ name="codex-planner",
1154
+ backend="codex",
1155
+ role="planner",
1156
+ model="gpt-5",
1157
+ ),
1158
+ duet.Agent(
1159
+ name="claude-coder",
1160
+ backend="claude",
1161
+ role="coder",
1162
+ model="claude-sonnet-4-6",
1163
+ ),
1164
+ ],
1165
+ resume_codex="codex-sid",
1166
+ rename_slots=True,
1167
+ )
1168
+
1169
+ self.assertEqual(agents[0].backend, "claude")
1170
+ self.assertEqual(agents[0].model, "claude-sonnet-4-6")
1171
+ self.assertEqual(agents[1].backend, "codex")
1172
+ self.assertEqual(agents[1].session_id, "codex-sid")
1173
+ self.assertEqual(agents[1].model, "gpt-5")
1174
+
881
1175
  def test_resume_claude_partner_moves_to_lead(self) -> None:
882
1176
  agents = duet.apply_resume_overrides(
883
1177
  [
File without changes
File without changes