code-coordinator 0.5.46__py3-none-any.whl

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.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
@@ -0,0 +1,896 @@
1
+ """OpenCodeProvider: the ``opencode run`` concrete provider.
2
+
3
+ OpenCode (https://github.com/sst/opencode) is an open-source terminal coding
4
+ assistant that supports multiple AI backends (Anthropic, OpenAI, etc.) via the
5
+ user's own API keys. This provider wraps ``opencode run`` for use as a coord
6
+ worker backend.
7
+
8
+ **CORRECTED AGAINST REAL CAPTURED OUTPUT (#1704).** #1703 ran a real
9
+ ``opencode`` 1.18.11 binary against real models (free and paid) and recorded
10
+ every finding in ``docs/OPENCODE_VERIFICATION.md``, replacing
11
+ ``tests/fixtures/opencode_run_sample.jsonl`` with a verbatim successful
12
+ capture and adding ``tests/fixtures/opencode_run_failure_sample.jsonl`` for a
13
+ verbatim failing one. This module is a follow-up correction: every
14
+ behavioural detail below cites that document.
15
+
16
+ **#1705 — per-spec-type agent definitions, deny-list enforcement PROVEN.**
17
+ #1704 left two things explicitly unfinished; both are done now:
18
+
19
+ * The concrete opencode agent definitions ``--agent`` references by name now
20
+ exist, one committed markdown file per ``spec.type``, under
21
+ :data:`AGENTS_ROOT` (``coord/agents/opencode/agents/<spec.type>.md``).
22
+ Only ``work`` is authored so far (scope of #1705 — ``review`` and other
23
+ types are separate issues, see :func:`_agent_definition_path`). Dispatching
24
+ an unauthored ``spec.type`` now raises :class:`OpenCodeAgentNotFoundError`
25
+ from :meth:`OpenCodeProvider.build_command` — a hard, named Python-side
26
+ error instead of a silent permissive fallback or an opaque CLI failure.
27
+ * ``capabilities().enforces_deny_list`` is now ``True`` for the reason
28
+ :meth:`capabilities`'s docstring gives in full: real opencode 1.18.11 runs
29
+ (not argv assertions) proved a ``gh`` bash call, an edit outside the
30
+ worktree, and an edit under ``tests/acceptance/**`` are all genuinely
31
+ blocked by ``coord/agents/opencode/agents/work.md``'s permission block —
32
+ see the named tests in ``tests/test_providers.py``.
33
+
34
+ **Agent-file discovery mechanism, empirically verified (not guessed):**
35
+ :meth:`env` sets ``OPENCODE_CONFIG_DIR`` to :data:`AGENTS_ROOT` so opencode
36
+ discovers ``agents/<spec.type>.md`` there. Two things were confirmed against
37
+ the real binary before relying on this, because both are safety-relevant:
38
+
39
+ 1. A *flat* ``<dir>/<name>.md`` layout (no ``agents/`` subdirectory) is
40
+ **silently invisible** to ``--agent`` — opencode only scans
41
+ ``<OPENCODE_CONFIG_DIR>/agents/*.md`` (and the singular ``agent/`` alias).
42
+ This is why the committed file lives at
43
+ ``coord/agents/opencode/agents/work.md`` rather than the flatter
44
+ ``coord/agents/opencode/work.md`` a first guess might reach for.
45
+ 2. ``OPENCODE_CONFIG_DIR`` outranks a *worktree-local* ``.opencode/agents/``
46
+ directory of the same agent name. Verified by planting a conflicting
47
+ ``.opencode/agents/work.md`` (wide-open ``bash``/``edit``/
48
+ ``external_directory`` permissions) in a throwaway worktree and
49
+ confirming the resolved rule list — and a live ``gh issue list`` call —
50
+ were byte-for-byte unaffected. This matters because the worktree a
51
+ worker runs in is checked out from a repo coord does not fully control;
52
+ without this precedence a target repo could ship its own permissive
53
+ ``work`` agent and silently shadow coord's deny-baseline one.
54
+
55
+ Differences from :class:`~.claude.ClaudeProvider`:
56
+
57
+ * ``build_command()`` invokes
58
+ ``opencode run --format json [--attach URL] [--model M] [--session S]
59
+ --agent NAME --auto BRIEFING``. No ``-p``, no ``--input-format``, no
60
+ stream-json flags — see the method docstring for exactly why each flag is
61
+ there and what it replaces.
62
+ * ``initial_input()`` returns ``b""`` — no stdin payload needed since the
63
+ briefing travels on argv (confirmed: ``run [message..]`` is a genuine
64
+ positional argv message, no stdin protocol observed —
65
+ ``OPENCODE_VERIFICATION.md`` "Flag surface").
66
+ * ``capabilities().enforces_deny_list=True`` — see above and
67
+ :meth:`capabilities`.
68
+ * ``capabilities().billing_mode="byo_key"`` — confirmed: OpenCode bills
69
+ against the operator's own configured provider credentials, not
70
+ Anthropic's ``claude -p`` credit pool (``OPENCODE_VERIFICATION.md``
71
+ "Flag surface" / "every ASSUMPTION" table).
72
+ * ``capabilities().cost_reporting=True`` and
73
+ ``capabilities().true_system_prompt=True`` — both flipped from the
74
+ first-pass ``False`` now that real evidence backs them; see
75
+ :meth:`capabilities` for the citations.
76
+ * ``env()`` always sets ``OPENCODE_CONFIG_DIR`` (agent-file discovery, see
77
+ above) and ``OPENCODE_CONFIG`` (the OpenRouter upstream-routing pin, see
78
+ ``coord/agents/opencode/routing.jsonc`` and :data:`ROUTING_PIN_PATH`).
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ import json
84
+ from pathlib import Path
85
+ from typing import TYPE_CHECKING
86
+
87
+ from coord.providers.base import Capabilities, Provider, WorkerSummary
88
+
89
+ if TYPE_CHECKING:
90
+ from coord.agent import AssignmentSpec
91
+
92
+
93
+ # ── Module-level constants ─────────────────────────────────────────────────────
94
+
95
+ #: Default binary name for the OpenCode CLI.
96
+ #:
97
+ #: Confirmed: binary name ``opencode`` is correct
98
+ #: (``OPENCODE_VERIFICATION.md`` "Machine / version"). Override via
99
+ #: ``ProviderDef(type="opencode", binary="/path/to/opencode")``.
100
+ DEFAULT_OPENCODE_BINARY = "opencode"
101
+
102
+ #: Sentinel string whose presence in the log signals successful completion.
103
+ #:
104
+ #: CORRECTED (#1704): there is no ``session.complete`` event — confirmed by
105
+ #: running the *unmodified* first-pass provider against the real fixture in
106
+ #: #1703 (it extracted nothing). The real terminal signal for a successful
107
+ #: run is the last ``step_finish`` event, whose nested ``part.reason`` is
108
+ #: ``"stop"`` (as opposed to ``"tool-calls"``, which ends every intermediate
109
+ #: turn and is followed by another ``step_start``). See
110
+ #: ``OPENCODE_VERIFICATION.md`` "The real terminal/completion signal".
111
+ #:
112
+ #: This is a plain substring match — ``docs/OPENCODE_VERIFICATION.md``
113
+ #: explicitly flags that as fragile against key reordering and recommends a
114
+ #: structural check (parse the line, test ``part.reason == "stop"``)
115
+ #: instead. :func:`_update_opencode_summary` below *does* do the structural
116
+ #: check for :attr:`WorkerSummary.stop_reason`; this constant is only used
117
+ #: by the reap thread's log-tailing early-completion heuristic (see
118
+ #: ``coord.agent._reap``), where the accepted risk is a missed/late
119
+ #: detection (the reap loop falls back to ``proc.wait()`` returning when the
120
+ #: process actually exits), never a wrong pass/fail verdict.
121
+ #:
122
+ #: A failing run has **no** terminal ``step_finish`` at all — it ends with a
123
+ #: top-level ``error`` event instead — so this marker deliberately does not
124
+ #: (and structurally cannot) match a failure. Failure detection is the
125
+ #: process exit code plus :func:`_update_opencode_summary`'s handling of the
126
+ #: ``error`` event, not this marker.
127
+ RESULT_MARKER = '"reason":"stop"'
128
+
129
+ #: Directory holding coord's committed opencode agent-discovery artifacts:
130
+ #: ``agents/<spec.type>.md`` (per-spec-type agent definitions) and
131
+ #: ``routing.jsonc`` (the OpenRouter upstream-routing pin). Computed from
132
+ #: this module's own file location so it resolves correctly regardless of
133
+ #: where coord itself is installed/checked out — mirrors the pattern
134
+ #: ``tests/fixtures/`` paths use relative to ``tests/``.
135
+ #:
136
+ #: **Why an env var pointing here, not a copy into the worker's worktree**
137
+ #: (the other option the #1705 issue named): the worktree belongs to a
138
+ #: repo coord does not fully control, and ``OPENCODE_CONFIG_DIR`` was
139
+ #: empirically confirmed to outrank a same-named agent in that worktree's
140
+ #: own ``.opencode/agents/`` (see the module docstring) — so pointing at
141
+ #: this directory is both simpler (no per-dispatch file copy / no need to
142
+ #: touch the worktree-setup code path in ``coord/agent.py``) and safer (a
143
+ #: target repo cannot shadow it).
144
+ AGENTS_ROOT = Path(__file__).resolve().parent.parent / "agents" / "opencode"
145
+
146
+ #: The OpenRouter upstream-routing pin (#1705 added scope) — see that
147
+ #: file's own header comment for the full mechanism citation. Threaded
148
+ #: onto the worker's environment as ``OPENCODE_CONFIG`` by :meth:`env`.
149
+ ROUTING_PIN_PATH = AGENTS_ROOT / "routing.jsonc"
150
+
151
+
152
+ class OpenCodeAgentNotFoundError(RuntimeError):
153
+ """Raised when no committed opencode agent definition exists for a
154
+ ``spec.type`` being dispatched.
155
+
156
+ #1705: dispatching an unauthored spec type must be a **hard, named
157
+ error**, never a silent permissive fallback (e.g. running with
158
+ opencode's allow-everything built-in default agent) and never a bare
159
+ CLI-level failure a few seconds into a worker's log that's hard to
160
+ triage. Only ``work`` is authored as of #1705 — other write-capable
161
+ spec types (``review``, ``smoke``, ``conflict-fix``, ...) are separate,
162
+ deliberately un-sped-up follow-up issues (see the module docstring).
163
+ """
164
+
165
+ def __init__(self, spec_type: str, expected_path: Path) -> None:
166
+ self.spec_type = spec_type
167
+ self.expected_path = expected_path
168
+ super().__init__(
169
+ f"no opencode agent definition for spec.type={spec_type!r}: "
170
+ f"expected {expected_path} to exist. Author it under "
171
+ f"{AGENTS_ROOT}/agents/ before dispatching this spec type "
172
+ "through the opencode provider — see coord/agents/opencode/agents/work.md "
173
+ "for the reference shape (deny-baseline permission block + "
174
+ "system prompt) and #1705 for the naming/discovery contract."
175
+ )
176
+
177
+
178
+ def _agent_definition_path(spec_type: str) -> Path:
179
+ """Return the expected committed agent-file path for *spec_type*.
180
+
181
+ Pure path computation, no filesystem access — split out from
182
+ :func:`_agent_name_for_type` so tests can assert on the expected path
183
+ independently of the existence check.
184
+ """
185
+ return AGENTS_ROOT / "agents" / f"{spec_type}.md"
186
+
187
+
188
+ def _agent_name_for_type(spec_type: str) -> str:
189
+ """Map ``spec.type`` to the opencode ``--agent`` name coord will pass.
190
+
191
+ **Naming contract (#1705, corrected from #1704's provisional
192
+ ``coord-<type>`` guess): the ``--agent`` value is exactly ``spec_type``**
193
+ (e.g. ``"work"``), because opencode's markdown agent-discovery derives
194
+ the agent name from the filename minus its extension (confirmed against
195
+ a real opencode 1.18.11 binary: a file at ``<OPENCODE_CONFIG_DIR>/agents/
196
+ work.md`` is discovered as an agent literally named ``work``, not
197
+ ``coord-work``) — see :data:`AGENTS_ROOT` and the module docstring.
198
+
199
+ Raises:
200
+ OpenCodeAgentNotFoundError: when ``coord/agents/opencode/agents/
201
+ <spec_type>.md`` does not exist. This is a **hard error**, not
202
+ a silent fallback — see that exception's docstring. As of
203
+ #1705 only ``spec_type == "work"`` has a committed file; every
204
+ other spec type raises until its own follow-up issue lands one.
205
+ """
206
+ path = _agent_definition_path(spec_type)
207
+ if not path.is_file():
208
+ raise OpenCodeAgentNotFoundError(spec_type, path)
209
+ return spec_type
210
+
211
+
212
+ class OpenCodeProvider(Provider):
213
+ """Concrete provider for ``opencode run`` (OpenCode workers).
214
+
215
+ Corrected against a real ``opencode`` 1.18.11 capture (#1703 →
216
+ ``docs/OPENCODE_VERIFICATION.md``). #1705 finished what #1704 left
217
+ open: the ``work`` agent definition is authored and its deny-list
218
+ enforcement is proven against a real binary (see the module docstring
219
+ and :meth:`capabilities`); ``enforces_deny_list`` is now ``True``.
220
+
221
+ Args:
222
+ binary: Override the worker binary name/path. ``None`` falls back to
223
+ :data:`DEFAULT_OPENCODE_BINARY` (``"opencode"``).
224
+ attach_url: When set, passes ``--attach <attach_url>`` so the worker
225
+ connects to an already-running OpenCode server instead of starting
226
+ a new session. Corresponds to ``ProviderDef.attach_url`` in
227
+ ``coordinator.yml``. ``None`` omits the flag (default headless
228
+ ``opencode run`` starts its own session). Confirmed end-to-end
229
+ against a real ``opencode serve`` (``OPENCODE_VERIFICATION.md``
230
+ "Flag surface").
231
+ model: Fallback model id from the provider definition
232
+ (``ProviderDef.model``) — e.g. an opencode ``provider/model``
233
+ string such as ``"zhipuai/glm-4.6"``. Used only when neither an
234
+ explicit ``resolved_model`` nor ``spec.model`` is set — see
235
+ :meth:`build_command`.
236
+ env: Extra environment variables from the provider definition
237
+ (``ProviderDef.env``, already ``${VAR}``-expanded by config
238
+ parsing) — this is how an operator points OpenCode's own
239
+ provider config (e.g. API keys) at a specific backend. Merged
240
+ with the (non-overridable — see :meth:`env`)
241
+ ``OPENCODE_CONFIG_DIR`` / ``OPENCODE_CONFIG`` entries by
242
+ :meth:`env`.
243
+ extra_args: Additional argv entries from the provider definition
244
+ (``ProviderDef.extra_args``). Inserted after this method's own
245
+ flags and before the trailing positional briefing argument.
246
+ **Caveat confirmed in #1703:** array-typed opencode flags (e.g.
247
+ ``--file``) greedily consume argv tokens the way yargs does —
248
+ ``opencode run --file X "message"`` fails ("File not found:
249
+ <message text>") because ``--file`` swallows the message. Such a
250
+ flag only works placed *after* the briefing. If a future
251
+ ``extra_args`` entry needs an array-typed flag, it cannot go
252
+ through this constructor as-is; this is flagged here rather than
253
+ silently mis-ordered.
254
+ """
255
+
256
+ def __init__(
257
+ self,
258
+ binary: str | None = None,
259
+ *,
260
+ attach_url: str | None = None,
261
+ model: str | None = None,
262
+ env: dict[str, str] | None = None,
263
+ extra_args: list[str] | None = None,
264
+ ) -> None:
265
+ self._binary = binary
266
+ self._attach_url = attach_url
267
+ self._model = model
268
+ self._env = dict(env) if env else {}
269
+ self._extra_args = list(extra_args) if extra_args else []
270
+
271
+ # ── Capabilities ──────────────────────────────────────────────────────────
272
+
273
+ def capabilities(self) -> Capabilities:
274
+ """Capabilities corrected against real captured evidence (#1703/#1704).
275
+
276
+ Chosen values and rationale
277
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
278
+
279
+ ``resume=True``
280
+ Confirmed: ``--session <id>`` resumes a prior session (the
281
+ resumed run's ``sessionID`` matched exactly and the model
282
+ demonstrably had context of the prior turn); ``--continue``
283
+ resumes the most recent session the same way. See
284
+ ``OPENCODE_VERIFICATION.md`` "Session id / resume".
285
+
286
+ ``inject=False``
287
+ **Still unknown** — mid-session stdin message injection into an
288
+ already-running ``run --format json`` process was not exercised
289
+ in the verification pass (see "Remaining unknowns"). Stays
290
+ conservatively ``False`` until a future pass tests it.
291
+
292
+ ``cost_reporting=True``
293
+ Confirmed: cost is real and present on every ``step_finish``
294
+ event (``part.cost``), verified non-zero across a real paid
295
+ model (``deepseek/deepseek-chat``, 4 steps,
296
+ ``0.00106932``/``0.0000449456``/``0.0000837256``/``0.0000344624``).
297
+ The committed fixture uses a free-tier model so its own
298
+ ``cost`` values are ``0`` — that's the model, not a parsing
299
+ gap: :func:`_update_opencode_summary` sums ``part.cost`` across
300
+ every ``step_finish`` event (there is no cumulative field), and
301
+ the free-tier fixture's sum is correctly ``0.0``. See
302
+ ``OPENCODE_VERIFICATION.md`` "Token usage and cost — field
303
+ paths".
304
+
305
+ ``true_system_prompt=True``
306
+ Confirmed via the live-fetched ``https://opencode.ai/config.json``
307
+ schema: ``--agent <name>`` selects an agent definition whose
308
+ ``AgentConfig.prompt: string`` field is a genuine system-prompt
309
+ equivalent — not merely prepended user-message text. See
310
+ ``OPENCODE_VERIFICATION.md`` "every ASSUMPTION" table. As of
311
+ #1705 this is no longer just a mechanism claim: ``work.md``'s
312
+ ``prompt`` body is what a real dispatched worker actually runs
313
+ under (see :func:`_agent_name_for_type`).
314
+
315
+ ``enforces_deny_list=True``
316
+ **SAFETY GATE — flipped in #1705, justified by real runs, not
317
+ argv assertions.** OpenCode has a real per-tool
318
+ ``allow``/``ask``/``deny`` permission system with genuine bash
319
+ command pattern matching, empirically proven in #1703
320
+ (``OPENCODE_VERIFICATION.md`` "Permission enforcement —
321
+ empirical evidence"). #1705 closed the two gaps #1704 left
322
+ open:
323
+
324
+ 1. ``coord/agents/opencode/agents/work.md`` is a committed
325
+ deny-baseline agent config (catch-all rules first, specific
326
+ overrides after — #1703's last-match-wins ordering trap,
327
+ respected).
328
+ 2. Enforcement is proven **end-to-end against the real
329
+ opencode 1.18.11 binary**, not asserted from argv, by named
330
+ tests in ``tests/test_providers.py`` that each ``opencode
331
+ run`` a real task through :meth:`build_command`/:meth:`env`
332
+ (the actual production argv/env, not a hand-rolled one) and
333
+ inspect the resulting NDJSON log for a genuine tool-call
334
+ denial: ``test_opencode_work_agent_blocks_gh_end_to_end`` (a
335
+ ``gh issue list`` bash call),
336
+ ``test_opencode_work_agent_blocks_external_directory_access_end_to_end``
337
+ (a read outside the worktree), and
338
+ ``test_opencode_work_agent_blocks_tests_acceptance_edit_end_to_end``
339
+ (an edit under the sealed oracle prefix) — plus a positive
340
+ control, ``test_opencode_work_agent_allows_normal_edit_and_git_end_to_end``,
341
+ confirming the deny rules don't collaterally block legitimate
342
+ edit/git use. All are skipped (not failed) when the
343
+ ``opencode`` binary isn't on ``PATH``, matching this repo's
344
+ existing convention for optional real-binary tests (see
345
+ e.g. ``tests/test_graph_health.py``).
346
+
347
+ **Load-bearing operational finding from authoring those
348
+ tests, not covered by #1703's flag-surface table: opencode
349
+ resolves its working directory from the inherited ``PWD``
350
+ environment variable, not the real process cwd.** A bare
351
+ ``subprocess.Popen(argv, cwd=X)`` with a stale ``PWD`` (e.g.
352
+ copied from a long-running daemon's own environment, exactly
353
+ what ``coord.agent._worker_subprocess_env`` does via
354
+ ``dict(os.environ)``) makes every opencode tool call operate
355
+ against the stale directory, not ``X`` — verified directly
356
+ against the real binary. Production dispatch is unaffected
357
+ only because ``coord.agent._maybe_bash_wrap``'s
358
+ ``bash -c 'exec ...'`` wrapper (``bash_wrap_spawn``, default
359
+ ``True``, added for the unrelated #299 daemon-spawn-freeze
360
+ mitigation) resets ``$PWD`` before ``exec``-ing into
361
+ opencode — also verified directly. This makes opencode
362
+ dispatch silently depend on a flag whose docstring gives no
363
+ hint that opencode needs it; flagged here rather than
364
+ silently relied upon. Out of scope to fix here: the fix (a
365
+ ``--dir`` flag or explicit ``PWD`` correction) needs either
366
+ the assignment's worktree path threaded into
367
+ :meth:`build_command` or a ``PWD`` correction at the
368
+ :meth:`env` call site, and both require the worktree path,
369
+ which :class:`~coord.agent.AssignmentSpec` does not carry —
370
+ only ``coord.agent.AgentServer._spawn`` (forbidden to touch
371
+ under #1705's briefing) knows it. Reported to the
372
+ coordinator rather than worked around.
373
+
374
+ Scope stays exactly what #1705 asked for: only ``spec.type ==
375
+ "work"`` has a committed agent file. Every other write-capable
376
+ type (``review``, ``conflict-fix``, ``smoke``, ...) still
377
+ cannot dispatch through this provider — not because of this
378
+ capability flag anymore, but because
379
+ :func:`_agent_name_for_type` raises
380
+ :class:`OpenCodeAgentNotFoundError` for any ``spec.type``
381
+ without a committed file, which :meth:`build_command` does not
382
+ catch. ``coord.agent.AgentServer.assign``'s
383
+ ``WRITE_CAPABLE_SPEC_TYPES`` gate (unchanged by #1705) now
384
+ passes this provider through for ``work``; the per-type file
385
+ gap is what actually still blocks the rest, deliberately.
386
+
387
+ ``billing_mode="byo_key"``
388
+ Confirmed: OpenCode uses the operator's own provider credentials
389
+ (``opencode providers login`` / ``opencode stats`` showing real
390
+ accumulated cost against those credentials). Not subject to
391
+ the 2026-06-15 Anthropic metering change (#322).
392
+
393
+ ``human_attended_only=False``
394
+ Confirmed by inference, not a ToS determination: every capture
395
+ in #1703 (``run --format json``, no TTY) behaved as pure batch
396
+ automation — no prompts blocking on a TTY, and an ``ask``
397
+ permission rule with no ``--auto`` fails *closed* (auto-rejects
398
+ with a stderr line) rather than hanging. See
399
+ ``OPENCODE_VERIFICATION.md`` "``--auto`` semantics" scenario 1.
400
+ """
401
+ return Capabilities(
402
+ resume=True,
403
+ inject=False,
404
+ cost_reporting=True,
405
+ true_system_prompt=True,
406
+ # SAFETY: flipped in #1705 — see docstring above for the named
407
+ # end-to-end tests this is justified by.
408
+ enforces_deny_list=True,
409
+ billing_mode="byo_key",
410
+ human_attended_only=False,
411
+ )
412
+
413
+ # ── Core methods ──────────────────────────────────────────────────────────
414
+
415
+ def build_command(
416
+ self,
417
+ spec: "AssignmentSpec",
418
+ *,
419
+ resolved_model: str | None = None,
420
+ system_prompt: str | None = None,
421
+ allowed_tools: str | None = None,
422
+ permission_mode: str = "acceptEdits",
423
+ ) -> list[str]:
424
+ """Build the ``opencode run`` argv for *spec*.
425
+
426
+ Confirmed shape (#1703, ``OPENCODE_VERIFICATION.md`` "Flag
427
+ surface")::
428
+
429
+ opencode run --format json [--attach URL] [--model MODEL]
430
+ [--session SESSION_ID] --agent NAME --auto BRIEFING
431
+
432
+ ``BRIEFING`` is the final positional argument (a real positional
433
+ argv message, confirmed — not a stdin protocol; see
434
+ :meth:`initial_input`).
435
+
436
+ Flags, each cited against captured evidence:
437
+
438
+ * ``--format json`` — always. Confirmed: ``--format default``
439
+ emits "a human-formatted TUI-style transcript, not usable by a
440
+ parser"; only ``json`` emits the NDJSON event stream
441
+ :meth:`parse_log` understands. Without it :meth:`result_marker`
442
+ has nothing to match and :meth:`parse_log` extracts nothing.
443
+ * ``--attach <url>`` — unchanged from the first pass, confirmed
444
+ end-to-end against a real ``opencode serve``.
445
+ * ``--model <value>`` — unchanged from the first pass, confirmed
446
+ with two real ``provider/model`` strings. Precedence: explicit
447
+ *resolved_model* > ``spec.model`` > the provider definition's
448
+ ``model`` (threaded in via ``__init__``, #1706). Omitted when
449
+ all three are ``None``.
450
+ * ``--session <id>`` — unchanged from the first pass, confirmed to
451
+ resume by id with matching ``sessionID`` and restored context.
452
+ Omitted when ``spec.resume_session_id`` is ``None``.
453
+ * ``--agent <name>`` — introduced in #1704, **now backed by a real
454
+ committed agent file (#1705).** Confirmed: ``--agent`` selects a
455
+ named agent definition whose ``prompt``/``permission`` fields are
456
+ a genuine system-prompt + tool-permission equivalent
457
+ (schema-confirmed against ``https://opencode.ai/config.json``).
458
+ This is why it **replaces** the ignored *system_prompt* /
459
+ *allowed_tools* kwargs below rather than sitting alongside them —
460
+ there is no opencode flag that accepts raw system-prompt text or
461
+ a raw tool allowlist string the way ``claude -p`` does; the unit
462
+ of configuration is a named agent. The name is computed by
463
+ :func:`_agent_name_for_type` from ``spec.type`` — see that
464
+ function's docstring for the naming contract. **Raises
465
+ :class:`OpenCodeAgentNotFoundError`** (propagates out of this
466
+ method — callers must not treat that as "fall back to something
467
+ permissive") when ``spec.type`` has no committed agent file yet;
468
+ as of #1705 that's every type except ``"work"``.
469
+ * ``--auto`` — introduced in #1704, always passed. Confirmed:
470
+ ``--auto`` converts an agent's ``"ask"`` permission rules to
471
+ auto-approve without overriding an explicit ``"deny"`` rule
472
+ (``OPENCODE_VERIFICATION.md`` "``--auto`` semantics", scenarios
473
+ 2 & 3). coord's workers are headless/unattended by design, so
474
+ this flag is unconditional — the same way ``permission_mode``
475
+ defaults to ``"acceptEdits"`` for :class:`~.claude.ClaudeProvider`.
476
+ **Safety note:** opencode's permission model is deny-*list*
477
+ semantics inverted from ``claude -p``'s allow-list — ``--auto``
478
+ only ever *widens* what an ``"ask"`` rule would otherwise block;
479
+ it can never widen past an explicit ``"deny"``. As of #1705,
480
+ with ``work.md``'s deny-baseline config actually in force
481
+ (``bash: {"*": "deny", "git status*"/"git commit*"/"git push*"/...:
482
+ "allow", "gh *": "deny", ...}``), this is exactly what keeps a
483
+ real ``work`` dispatch headless instead of hanging on
484
+ ``work.md``'s narrow bash allow-list / ``external_directory: deny``
485
+ ``ask``-adjacent rules — see :meth:`capabilities`'s
486
+ ``enforces_deny_list`` note for the tests that prove the explicit
487
+ ``deny`` entries still hold under ``--auto``.
488
+
489
+ Ignored kwargs
490
+ ~~~~~~~~~~~~~~
491
+ *system_prompt* and *allowed_tools* are accepted (matching the
492
+ Provider ABC signature) but **silently ignored** — replaced by
493
+ ``--agent`` as described above. *permission_mode* is accepted but
494
+ also ignored: it is a ``claude -p``-specific vocabulary
495
+ (``"acceptEdits"``/``"bypassPermissions"``/...) with no opencode
496
+ equivalent; ``--auto`` is unconditional instead of varying by value.
497
+
498
+ Args:
499
+ spec: The assignment spec being dispatched.
500
+ resolved_model: The resolved model identifier to pass. When
501
+ provided, takes precedence over ``spec.model``. ``None``
502
+ falls back to ``spec.model``, then to the provider
503
+ definition's ``model``; if all three are ``None``, the
504
+ ``--model`` flag is omitted (OpenCode picks its configured
505
+ default).
506
+ system_prompt: Accepted but **ignored** — see "Ignored kwargs".
507
+ allowed_tools: Accepted but **ignored** — see "Ignored kwargs".
508
+ permission_mode: Accepted but **ignored** — see "Ignored kwargs".
509
+ """
510
+ binary = self._binary if self._binary is not None else DEFAULT_OPENCODE_BINARY
511
+
512
+ # Precedence: explicit resolved_model > spec.model > provider-
513
+ # definition model (ProviderDef.model, threaded in via __init__).
514
+ if resolved_model is not None:
515
+ effective_model = resolved_model
516
+ elif spec.model is not None:
517
+ effective_model = spec.model
518
+ else:
519
+ effective_model = self._model
520
+
521
+ argv: list[str] = [binary, "run"]
522
+
523
+ # Confirmed (#1703): only --format json emits a parseable NDJSON
524
+ # stream; --format default is a human transcript.
525
+ argv.extend(["--format", "json"])
526
+
527
+ # When attach_url is set, connect to a running OpenCode server instead
528
+ # of starting a new session. Confirmed end-to-end against a real
529
+ # `opencode serve`.
530
+ if self._attach_url:
531
+ argv.extend(["--attach", self._attach_url])
532
+
533
+ # Confirmed: --model selects the AI model by 'provider/model' string.
534
+ if effective_model:
535
+ argv.extend(["--model", effective_model])
536
+
537
+ # Confirmed: --session resumes a prior session by ID.
538
+ if spec.resume_session_id:
539
+ argv.extend(["--session", spec.resume_session_id])
540
+
541
+ # New in #1704: --agent replaces system_prompt/allowed_tools (see
542
+ # docstring above and _agent_name_for_type).
543
+ argv.extend(["--agent", _agent_name_for_type(spec.type)])
544
+
545
+ # New in #1704: unconditional, headless-safety flag (see docstring).
546
+ argv.append("--auto")
547
+
548
+ # #1706: provider-definition extra_args go after this method's own
549
+ # flags but BEFORE the trailing positional briefing — OpenCode's
550
+ # argv parsing assumes the briefing is the last argument (and, per
551
+ # #1703, array-typed flags like --file must come AFTER it — see the
552
+ # __init__ docstring's caveat about extra_args).
553
+ if self._extra_args:
554
+ argv.extend(self._extra_args)
555
+
556
+ # Briefing is the final positional argument — passed on argv, NOT stdin.
557
+ # Multi-line briefings are safe here because subprocess.Popen passes
558
+ # the list directly to execv() (no shell interpolation).
559
+ argv.append(spec.briefing)
560
+ return argv
561
+
562
+ def oneshot_command(
563
+ self,
564
+ *,
565
+ system_prompt: str,
566
+ output_format: str | None = "json",
567
+ ) -> list[str]:
568
+ """Best-effort one-shot argv for the OpenCode backend.
569
+
570
+ LIMITATION, unchanged from the first pass and still real: OpenCode
571
+ takes its briefing as a positional argv argument, not via stdin
572
+ (confirmed, #1703), and this method's signature — inherited from the
573
+ :class:`~.base.Provider` ABC — does not receive the user message, so
574
+ it cannot be appended here either. Callers (brain planning, the
575
+ dashboard assistant) pipe the user message via stdin the way they do
576
+ for :class:`~.claude.ClaudeProvider`; OpenCode will not see it. Real
577
+ one-shot semantics (a single prompt/response round with the message
578
+ actually delivered) are not achievable through this ABC method for
579
+ this backend — callers that need that should configure a
580
+ :class:`~.claude.ClaudeProvider` backend instead, exactly as the
581
+ first-pass docstring already said.
582
+
583
+ What *is* corrected here (#1704): the returned argv now includes
584
+ ``--format json`` when *output_format* is ``"json"`` (or whatever
585
+ string is passed — forwarded verbatim, mirroring
586
+ :meth:`~.claude.ClaudeProvider.oneshot_command`'s behaviour), so at
587
+ least the output stream is the structured NDJSON shape
588
+ :meth:`parse_log` understands rather than the unparseable human
589
+ transcript. This does **not** produce the ``{"result": ...}``
590
+ top-level shape the brain's JSON-extraction path expects — opencode
591
+ has no such wrapper (confirmed, #1703) — so callers still fall back
592
+ to raw-stdout handling for this backend, as the first-pass docstring
593
+ already documented. ``--auto`` is always appended for the same
594
+ headless-safety reason as :meth:`build_command`.
595
+
596
+ *system_prompt* is still silently ignored: there is no CLI flag to
597
+ inject ad hoc system-prompt text, only ``--agent <name>`` selecting
598
+ a *pre-configured* agent (see :meth:`build_command`), and this
599
+ method's signature has no assignment-spec context to compute an
600
+ agent name from.
601
+
602
+ Args:
603
+ system_prompt: Accepted but **ignored** — see above.
604
+ output_format: ``"json"`` (or any other string) is forwarded as
605
+ ``--format <value>``. ``None`` omits the flag entirely,
606
+ falling back to opencode's human-transcript default —
607
+ matching the dashboard-assistant streaming use case the
608
+ same way ``output_format=None`` does for
609
+ :class:`~.claude.ClaudeProvider`.
610
+ """
611
+ binary = self._binary if self._binary is not None else DEFAULT_OPENCODE_BINARY
612
+ argv = [binary, "run"]
613
+ if output_format is not None:
614
+ argv.extend(["--format", output_format])
615
+ argv.append("--auto")
616
+ return argv
617
+
618
+ def initial_input(self, spec: "AssignmentSpec") -> bytes:
619
+ """Return an empty bytes object — the briefing travels on argv.
620
+
621
+ Confirmed (#1703): ``run [message..]`` is a genuine positional argv
622
+ message; no stdin-delivery protocol was observed. Unlike
623
+ :class:`~.claude.ClaudeProvider`, OpenCode receives its briefing as
624
+ the final positional argument in :meth:`build_command`. Returning
625
+ ``b""`` (falsy) signals to the spawn path that nothing should be
626
+ written to the worker's stdin pipe.
627
+ """
628
+ # Briefing is already embedded in the argv by build_command.
629
+ # Return empty bytes so the spawn path's ``if initial_input:`` guard
630
+ # skips the stdin write.
631
+ return b""
632
+
633
+ def result_marker(self) -> str:
634
+ """Return the completion sentinel for OpenCode NDJSON logs.
635
+
636
+ CORRECTED (#1704): see :data:`RESULT_MARKER`'s docstring for the
637
+ full citation and the documented substring-fragility risk.
638
+ """
639
+ return RESULT_MARKER
640
+
641
+ def env(self) -> dict[str, str]:
642
+ """Extra environment variables for the worker subprocess.
643
+
644
+ Two parts:
645
+
646
+ 1. ``ProviderDef.env`` (already ``${VAR}``-expanded by config
647
+ parsing, #1706) — how an operator points a named ``opencode``
648
+ provider definition at a specific set of credentials without
649
+ baking them into the machine's agent unit.
650
+ 2. **#1705, always set, not operator-overridable:**
651
+ ``OPENCODE_CONFIG_DIR`` (:data:`AGENTS_ROOT` — where the
652
+ committed per-spec-type agent files live, see the module
653
+ docstring for why an env var rather than a worktree file copy)
654
+ and ``OPENCODE_CONFIG`` (:data:`ROUTING_PIN_PATH` — the
655
+ OpenRouter routing pin, see ``coord/agents/opencode/routing.jsonc``).
656
+ These are applied **after** ``self._env`` so a
657
+ ``ProviderDef.env`` entry cannot accidentally (or maliciously,
658
+ via a compromised ``coordinator.yml`` edit that isn't the
659
+ security-reviewed agent-file path) shadow the deny-list
660
+ discovery mechanism — see :class:`OpenCodeAgentNotFoundError`'s
661
+ docstring for why silently losing this would be bad. Verified
662
+ empirically that ``OPENCODE_CONFIG_DIR`` is inert for a provider
663
+ the operator hasn't configured credentials for (real opencode
664
+ 1.18.11 run against a non-OpenRouter model with both variables
665
+ set completed normally) — see
666
+ ``test_opencode_routing_pin_inert_without_openrouter_credential_end_to_end``.
667
+
668
+ Returns a fresh dict each call.
669
+ """
670
+ merged = dict(self._env)
671
+ merged["OPENCODE_CONFIG_DIR"] = str(AGENTS_ROOT)
672
+ merged["OPENCODE_CONFIG"] = str(ROUTING_PIN_PATH)
673
+ return merged
674
+
675
+ def parse_log(
676
+ self, log_path: str | Path, tail_bytes: int = 65536
677
+ ) -> WorkerSummary:
678
+ """Parse an OpenCode NDJSON log file into a :class:`WorkerSummary`.
679
+
680
+ CORRECTED (#1704) against the real event schema captured in #1703
681
+ (``docs/OPENCODE_VERIFICATION.md`` "The real event schema" through
682
+ "Session id / resume"). ``opencode run --format json`` emits one
683
+ JSON object per line to stdout; the agent writes this stream
684
+ verbatim to the log file.
685
+
686
+ This method is **deliberately permissive** — it silently skips any
687
+ line that is blank, not valid JSON, or an unrecognised event shape,
688
+ and it NEVER raises regardless of log content. This is unchanged
689
+ from the first pass and still required: a truncated tail read
690
+ (``tail_bytes > 0``) can produce a leading incomplete JSON line,
691
+ which must be skipped, not raised.
692
+
693
+ Real event shapes handled (see
694
+ ``tests/fixtures/opencode_run_sample.jsonl`` and
695
+ ``tests/fixtures/opencode_run_failure_sample.jsonl``):
696
+
697
+ * Every event (any ``type``) carries a top-level ``sessionID``
698
+ string from the first line onward — confirmed, there is no
699
+ separate ``session.start``/``session.init`` event. The first
700
+ ``sessionID`` seen sets :attr:`WorkerSummary.session_id`.
701
+ * ``{"type":"tool_use","part":{"tool":"...","state":{"status":...,
702
+ "input":{...}}}}`` — the tool name updates
703
+ :attr:`WorkerSummary.tools_used` /
704
+ :attr:`WorkerSummary.last_tool`; a ``bash`` call's
705
+ ``input.command`` is recorded in
706
+ :attr:`WorkerSummary.bash_commands`; an ``edit``/``write`` call's
707
+ ``input.filePath`` is recorded in
708
+ :attr:`WorkerSummary.files_edited` (``edit`` confirmed against the
709
+ real fixture; ``write`` extrapolated from the same ``filePath``
710
+ convention ``read``/``edit`` both use — not independently
711
+ confirmed, but the extraction is a no-op, never a crash, if that
712
+ extrapolation is wrong). A ``state.status == "error"`` whose
713
+ ``state.error`` message mentions "permission" (the confirmed
714
+ permission-denial error shape quotes the matching rules verbatim)
715
+ is recorded in :attr:`WorkerSummary.permission_denials`.
716
+ * ``{"type":"text","part":{"text":"..."}}`` — the assistant's final
717
+ answer; confirmed to be the last ``text`` event before the
718
+ terminal ``step_finish``, so each one overwrites
719
+ :attr:`WorkerSummary.result_text` and the last write wins.
720
+ * ``{"type":"step_finish","part":{"reason":...,"tokens":{...},
721
+ "cost":...}}`` — one per completed turn/step. Each occurrence
722
+ increments :attr:`WorkerSummary.num_turns` by one and *sums*
723
+ (never overwrites) ``part.cost`` into
724
+ :attr:`WorkerSummary.total_cost_usd` and
725
+ ``part.tokens.{input,output}`` /
726
+ ``part.tokens.cache.{write,read}`` into
727
+ :attr:`WorkerSummary.input_tokens` /
728
+ :attr:`WorkerSummary.output_tokens` /
729
+ :attr:`WorkerSummary.cache_creation_tokens` /
730
+ :attr:`WorkerSummary.cache_read_tokens` — confirmed there is
731
+ **no cumulative session-total field anywhere in the stream**, so
732
+ summing per-step is the only correct total. ``part.reason``
733
+ overwrites :attr:`WorkerSummary.stop_reason` every time, so it
734
+ ends at whatever the *last* ``step_finish`` reported — ``"stop"``
735
+ for a normal completion.
736
+ * ``{"type":"error","error":{"name":...,"data":{"message":...}}}``
737
+ — a run-level failure (confirmed: the whole request/stream failed,
738
+ no more events follow). Sets
739
+ :attr:`WorkerSummary.is_error` ``= True``,
740
+ :attr:`WorkerSummary.stop_reason` ``= "error"``,
741
+ :attr:`WorkerSummary.terminal_reason` from ``error.name``, and
742
+ :attr:`WorkerSummary.result_text` from ``error.data.message``
743
+ (the real captured message is itself a JSON string containing
744
+ literal escaped quotes — preserved verbatim, not stripped).
745
+
746
+ Known, named gap: **no field in any observed event carries a model
747
+ identifier** (confirmed absent across every event type in both
748
+ fixtures and every capture in #1703's pass).
749
+ :attr:`WorkerSummary.model_used` is therefore never set by this
750
+ method and stays ``None`` — this is evidenced absence, not an
751
+ unexamined assumption.
752
+
753
+ Args:
754
+ log_path: Path to the worker's log file.
755
+ tail_bytes: When > 0, only the last *tail_bytes* of the file are
756
+ read (cheap live-polling). Pass ``0`` for a full parse.
757
+
758
+ Returns:
759
+ A :class:`WorkerSummary` with whatever fields could be extracted.
760
+ Returns a blank summary for a missing, empty, or unreadable file.
761
+ """
762
+ summary = WorkerSummary()
763
+ p = Path(log_path)
764
+ if not p.exists():
765
+ return summary
766
+ try:
767
+ size = p.stat().st_size
768
+ with open(p, "r", encoding="utf-8", errors="replace") as f:
769
+ if tail_bytes and size > tail_bytes:
770
+ f.seek(size - tail_bytes)
771
+ f.readline() # discard the leading partial line
772
+ text = f.read()
773
+ except OSError:
774
+ return summary
775
+
776
+ for line in text.splitlines():
777
+ if not line or not line.strip():
778
+ continue
779
+ try:
780
+ data = json.loads(line)
781
+ except (json.JSONDecodeError, ValueError, TypeError):
782
+ # Non-JSON lines (e.g. "# argv=..." header comment written by the
783
+ # agent, or plain-text error output) are silently skipped.
784
+ continue
785
+ if not isinstance(data, dict):
786
+ continue
787
+ _update_opencode_summary(summary, data)
788
+
789
+ return summary
790
+
791
+
792
+ # ── Internal summary helper ────────────────────────────────────────────────────
793
+
794
+
795
+ def _update_opencode_summary(summary: WorkerSummary, data: dict) -> None:
796
+ """Fold a single parsed NDJSON object into *summary* in-place.
797
+
798
+ All field accesses use ``.get()`` with defensive type checks so that
799
+ unexpected shapes produce at most a no-op, never a ``KeyError`` /
800
+ ``AttributeError``. Event shapes are the real ones captured in #1703 —
801
+ see :meth:`OpenCodeProvider.parse_log`'s docstring for the full mapping
802
+ and citations.
803
+ """
804
+ # Confirmed: sessionID is present on every event from line 1 onward —
805
+ # capture the first one seen regardless of event type.
806
+ if summary.session_id is None:
807
+ sid = data.get("sessionID")
808
+ if isinstance(sid, str) and sid:
809
+ summary.session_id = sid
810
+
811
+ event_type = data.get("type")
812
+ if not isinstance(event_type, str):
813
+ return
814
+
815
+ part = data.get("part")
816
+ part = part if isinstance(part, dict) else {}
817
+
818
+ if event_type == "tool_use":
819
+ tool = part.get("tool")
820
+ if isinstance(tool, str) and tool:
821
+ summary.tools_used.append(tool)
822
+ summary.last_tool = tool
823
+
824
+ state = part.get("state")
825
+ state = state if isinstance(state, dict) else {}
826
+ tool_input = state.get("input")
827
+ tool_input = tool_input if isinstance(tool_input, dict) else {}
828
+
829
+ if tool == "bash":
830
+ cmd = tool_input.get("command")
831
+ if isinstance(cmd, str) and cmd:
832
+ summary.bash_commands.append(cmd)
833
+ elif tool in ("edit", "write"):
834
+ fp = tool_input.get("filePath")
835
+ if isinstance(fp, str) and fp:
836
+ summary.files_edited.append(fp)
837
+
838
+ if state.get("status") == "error":
839
+ err = state.get("error")
840
+ if isinstance(err, str) and err and "permission" in err.lower():
841
+ summary.permission_denials.append(err)
842
+ return
843
+
844
+ if event_type == "text":
845
+ text = part.get("text")
846
+ if isinstance(text, str):
847
+ summary.result_text = text
848
+ return
849
+
850
+ if event_type == "step_finish":
851
+ reason = part.get("reason")
852
+ if isinstance(reason, str) and reason:
853
+ summary.stop_reason = reason
854
+
855
+ summary.num_turns += 1
856
+
857
+ tokens = part.get("tokens")
858
+ tokens = tokens if isinstance(tokens, dict) else {}
859
+ in_tok = tokens.get("input")
860
+ if isinstance(in_tok, int):
861
+ summary.input_tokens += in_tok
862
+ out_tok = tokens.get("output")
863
+ if isinstance(out_tok, int):
864
+ summary.output_tokens += out_tok
865
+
866
+ cache = tokens.get("cache")
867
+ cache = cache if isinstance(cache, dict) else {}
868
+ cache_write = cache.get("write")
869
+ if isinstance(cache_write, int):
870
+ summary.cache_creation_tokens += cache_write
871
+ cache_read = cache.get("read")
872
+ if isinstance(cache_read, int):
873
+ summary.cache_read_tokens += cache_read
874
+
875
+ cost = part.get("cost")
876
+ if isinstance(cost, (int, float)):
877
+ summary.total_cost_usd += float(cost)
878
+ return
879
+
880
+ if event_type == "error":
881
+ summary.is_error = True
882
+ summary.stop_reason = "error"
883
+ error_obj = data.get("error")
884
+ error_obj = error_obj if isinstance(error_obj, dict) else {}
885
+ name = error_obj.get("name")
886
+ if isinstance(name, str) and name:
887
+ summary.terminal_reason = name
888
+ err_data = error_obj.get("data")
889
+ err_data = err_data if isinstance(err_data, dict) else {}
890
+ message = err_data.get("message")
891
+ if isinstance(message, str):
892
+ summary.result_text = message
893
+ return
894
+
895
+ # step_start and any other/future event type carry nothing else this
896
+ # method extracts — the sessionID capture above already ran.