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,683 @@
1
+ """Provider registry: construct and resolve worker-command providers.
2
+
3
+ Public API
4
+ ----------
5
+ ``build_provider(name, definition, models_cfg) -> Provider``
6
+ Instantiate the correct concrete :class:`~.base.Provider` subclass from a
7
+ :class:`~coord.config.ProviderDef`'s ``type`` field. Raises
8
+ :class:`ValueError` for unknown types.
9
+
10
+ ``resolve_provider_name(spec_provider, repo_provider, providers_cfg, issue_labels=None) -> str``
11
+ Apply the precedence chain
12
+ ``spec → providers.labels (issue_labels) → repo → providers.default → "claude"``
13
+ (#1889 inserted the label link) and return the winning provider name.
14
+
15
+ ``get_provider(provider_name, cfg=None) -> Provider``
16
+ #1710: the single coordinator-side helper that turns a bare
17
+ ``provider_name`` string (e.g. ``Assignment.provider_name``, persisted at
18
+ dispatch time — see ``coord/models.py``) into a ready-to-use
19
+ :class:`~.base.Provider` instance, so log consumers (``coord.progress``,
20
+ ``coord.usage``, ``coord.failure_class``, ...) can call
21
+ ``provider.parse_log()`` instead of importing ``coord.worker_events``
22
+ directly and assuming every worker log is claude-shaped.
23
+
24
+ ``provider_def_to_wire(definition) -> dict`` / ``build_provider_from_wire(name, wire_def) -> Provider``
25
+ #1796: the dispatch-payload counterpart to ``build_provider``. A
26
+ config-free agent (no local ``coordinator.yml``, no board service —
27
+ docs/EPHEMERAL_WORKERS.md) has no ``providers.definitions`` registry to
28
+ resolve ``AssignmentSpec.provider`` against. ``provider_def_to_wire``
29
+ (coordinator-side, called from ``coord/dispatch.py``) serializes the
30
+ resolved :class:`~coord.config.ProviderDef` into the JSON-safe dict
31
+ carried as ``AssignmentSpec.provider_def``; ``build_provider_from_wire``
32
+ (agent-side, called from ``coord.agent.AgentServer``) reconstructs the
33
+ same :class:`Provider` instance from it, with no local config needed.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ from typing import TYPE_CHECKING, Any, Iterable
40
+
41
+ from coord.config import IMPLICIT_PROVIDER_TYPES, ProviderDef, provider_capability
42
+ from coord.providers.base import Capabilities, Provider, WorkerSummary
43
+ from coord.providers.claude import ClaudeProvider
44
+ from coord.providers.claude_pty import ClaudePtyProvider
45
+ from coord.providers.opencode import OpenCodeProvider
46
+
47
+ if TYPE_CHECKING:
48
+ from coord.config import Config, ModelsConfig, ProvidersConfig
49
+ from coord.models import Machine
50
+
51
+ __all__ = [
52
+ "Capabilities",
53
+ "ClaudeProvider",
54
+ "ClaudePtyProvider",
55
+ "OpenCodeProvider",
56
+ "Provider",
57
+ "WorkerSummary",
58
+ "build_provider",
59
+ "build_provider_from_wire",
60
+ "describe_provider_choice",
61
+ "get_provider",
62
+ "guard_provider_machine_capability",
63
+ "guard_unattended_dispatch",
64
+ "machine_supports_provider",
65
+ "machines_supporting_provider",
66
+ "provider_def_to_wire",
67
+ "provider_type_for",
68
+ "resolve_default_provider",
69
+ "resolve_provider_name",
70
+ ]
71
+
72
+ _log = logging.getLogger(__name__)
73
+
74
+ # Built-in provider types constructible with no ``ProviderDef`` at all (no
75
+ # model/env/extra_args threading — see #1706 comments on ``build_provider``).
76
+ # Used by :func:`get_provider` as the fallback when no ``Config`` is passed
77
+ # (many coordinator-side log consumers only ever had a bare provider_name
78
+ # string in scope — see #1710) or when the name isn't in
79
+ # ``cfg.providers.definitions`` (predates the definition, or a config-free
80
+ # test/CLI context).
81
+ _BUILTIN_PROVIDER_TYPES: dict[str, type[Provider]] = {
82
+ "claude": ClaudeProvider,
83
+ "claude-pty": ClaudePtyProvider,
84
+ "opencode": OpenCodeProvider,
85
+ }
86
+
87
+
88
+ def build_provider(
89
+ name: str,
90
+ definition: "ProviderDef",
91
+ models_cfg: "ModelsConfig | None",
92
+ ) -> Provider:
93
+ """Construct a :class:`Provider` from *definition*.
94
+
95
+ Args:
96
+ name: The provider's logical name (key in ``providers.definitions``).
97
+ Used only for error messages.
98
+ definition: The parsed :class:`~coord.config.ProviderDef` for this
99
+ provider.
100
+ models_cfg: The coordinator's :class:`~coord.config.ModelsConfig` (may
101
+ be ``None`` when called outside a full config context, e.g. tests).
102
+
103
+ Returns:
104
+ A ready-to-use :class:`Provider` instance.
105
+
106
+ Raises:
107
+ ValueError: When ``definition.type`` is not a known provider type.
108
+ """
109
+ ptype = definition.type
110
+ if ptype == "claude":
111
+ # #1706: thread model / env / extra_args from the provider
112
+ # definition into the instance. build_command() / env() apply
113
+ # them with the documented precedence (explicit resolved_model >
114
+ # spec.model > definition.model for the model fallback; env and
115
+ # extra_args are additive).
116
+ return ClaudeProvider(
117
+ binary=definition.binary,
118
+ model=definition.model,
119
+ env=definition.env,
120
+ extra_args=definition.extra_args,
121
+ )
122
+ if ptype == "claude-pty":
123
+ # #425: interactive `claude` driven through a PTY — the
124
+ # subscription-billed escape hatch from the 2026-06-15 metering
125
+ # change. #1706: same model / env / extra_args threading as the
126
+ # "claude" branch above.
127
+ return ClaudePtyProvider(
128
+ binary=definition.binary,
129
+ model=definition.model,
130
+ env=definition.env,
131
+ extra_args=definition.extra_args,
132
+ )
133
+ if ptype == "opencode":
134
+ # #325: OpenCode (sst/opencode) worker backend — uses the operator's
135
+ # own API keys, runs `opencode run BRIEFING`. `attach_url` is wired
136
+ # here because it is OpenCode-specific (not a cross-provider concern).
137
+ # #1706: model / env / extra_args threaded the same way as the other
138
+ # provider types.
139
+ return OpenCodeProvider(
140
+ binary=definition.binary,
141
+ attach_url=definition.attach_url,
142
+ model=definition.model,
143
+ env=definition.env,
144
+ extra_args=definition.extra_args,
145
+ )
146
+ raise ValueError(
147
+ f"Unknown provider type {ptype!r} (provider name: {name!r}). "
148
+ f"Supported types: ['claude', 'claude-pty', 'opencode']"
149
+ )
150
+
151
+
152
+ def provider_def_to_wire(definition: "ProviderDef") -> "dict[str, Any]":
153
+ """Serialize *definition* to the JSON-safe dict carried on the wire as
154
+ ``AssignmentSpec.provider_def`` (#1796).
155
+
156
+ Called from ``coord/dispatch.py`` alongside the existing ``"provider"``
157
+ name field, so a config-free agent (no local ``coordinator.yml``, no
158
+ board service — docs/EPHEMERAL_WORKERS.md) receives everything
159
+ :func:`build_provider` needs to construct the SAME provider instance the
160
+ coordinator resolved, without a local ``providers.definitions`` registry
161
+ to look the name up in. See :func:`build_provider_from_wire` for the
162
+ agent-side reconstruction.
163
+
164
+ Args:
165
+ definition: The resolved :class:`~coord.config.ProviderDef`.
166
+
167
+ Returns:
168
+ A plain ``dict`` with JSON-safe values only (``env``/``extra_args``
169
+ copied, not aliased, so the caller's definition is never mutated
170
+ through the returned dict).
171
+ """
172
+ return {
173
+ "type": definition.type,
174
+ "binary": definition.binary,
175
+ "model": definition.model,
176
+ "attach_url": definition.attach_url,
177
+ "env": dict(definition.env),
178
+ "extra_args": list(definition.extra_args),
179
+ }
180
+
181
+
182
+ def build_provider_from_wire(name: str, wire_def: "dict[str, Any]") -> Provider:
183
+ """Agent-side counterpart to :func:`provider_def_to_wire` (#1796).
184
+
185
+ Reconstructs the :class:`~coord.config.ProviderDef` the coordinator
186
+ resolved at dispatch time from the plain dict carried in
187
+ ``AssignmentSpec.provider_def``, then builds a live :class:`Provider` via
188
+ :func:`build_provider` — the SAME dispatch table used coordinator-side —
189
+ so a config-free agent (no local ``providers.definitions`` registry to
190
+ look *name* up in — docs/EPHEMERAL_WORKERS.md) gets a provider instance
191
+ equivalent to the one the coordinator resolved, instead of being unable
192
+ to honour an explicitly requested ``spec.provider`` at all.
193
+
194
+ This is the fix for #1796: before it existed, a config-free agent that
195
+ could not resolve ``spec.provider`` locally silently fell back to the
196
+ legacy ``claude -p`` spawn path with no error — an explicitly requested
197
+ provider (e.g. ``opencode``) never ran, and every surface (coordinator
198
+ log, board, assignment record) still reported the requested name. See
199
+ ``coord.agent.AgentServer._resolve_provider``, which calls this function
200
+ and REFUSES the assignment instead when neither the local registry nor
201
+ ``wire_def`` can resolve the name.
202
+
203
+ Args:
204
+ name: The provider's logical name (``AssignmentSpec.provider``).
205
+ Used only for error messages, mirroring :func:`build_provider`.
206
+ wire_def: The dict from ``AssignmentSpec.provider_def`` — see
207
+ :func:`provider_def_to_wire` for its shape.
208
+
209
+ Raises:
210
+ ValueError: *wire_def* is malformed in any way this function can
211
+ detect — not a dict, missing/empty ``"type"``, an ``"env"``/
212
+ ``"extra_args"`` whose shape can't be coerced to a dict/list
213
+ (e.g. a string or int — raises :class:`TypeError` from the
214
+ plain ``dict()``/``list()`` calls below, caught and re-raised
215
+ as :class:`ValueError` here) — or *wire_def* names an unknown
216
+ provider type — the last case mirrors :func:`build_provider`'s
217
+ own error for that case. Every case is a :class:`ValueError` so
218
+ :meth:`coord.agent.AgentServer._resolve_provider` (this
219
+ function's only caller) can turn ANY malformed wire payload
220
+ into a clean refused-assignment 400 rather than an uncaught 500
221
+ — the whole point of #1796 is "never silently misbehave", and a
222
+ raw 500 from a coordinator-generated (trusted) but malformed
223
+ payload would be exactly that for the operator watching it fail.
224
+ """
225
+ if not isinstance(wire_def, dict) or not wire_def.get("type"):
226
+ raise ValueError(
227
+ f"malformed provider_def for provider {name!r}: expected a dict "
228
+ f"with a non-empty 'type' key, got {wire_def!r}"
229
+ )
230
+ try:
231
+ definition = ProviderDef(
232
+ type=wire_def["type"],
233
+ binary=wire_def.get("binary"),
234
+ model=wire_def.get("model"),
235
+ attach_url=wire_def.get("attach_url"),
236
+ env=dict(wire_def.get("env") or {}),
237
+ extra_args=list(wire_def.get("extra_args") or []),
238
+ )
239
+ except (TypeError, ValueError) as e:
240
+ # #1796 review (non-blocking): a wire "env" that isn't dict-shaped
241
+ # (e.g. a string or int) or an "extra_args" that isn't list-shaped
242
+ # raises TypeError from dict()/list() above, not ValueError — catch
243
+ # both here so every malformed shape becomes the same clean refusal
244
+ # instead of a handful of them propagating as an uncaught 500 out of
245
+ # AgentServer.assign() (agent_app.py only catches ValueError).
246
+ raise ValueError(
247
+ f"malformed provider_def for provider {name!r}: {e}"
248
+ ) from e
249
+ return build_provider(name, definition, models_cfg=None)
250
+
251
+
252
+ def resolve_provider_name(
253
+ spec_provider: str | None,
254
+ repo_provider: str | None,
255
+ providers_cfg: "ProvidersConfig",
256
+ issue_labels: "list[str] | None" = None,
257
+ ) -> str:
258
+ """Return the effective provider name using the precedence chain.
259
+
260
+ Precedence (highest to lowest):
261
+ 1. *spec_provider* — per-assignment override (``AssignmentSpec.provider``).
262
+ 2. A ``providers.labels`` match against *issue_labels* (#1889) — e.g.
263
+ ``harness:opencode`` -> ``opencode``. See
264
+ :meth:`coord.config.ProvidersConfig.provider_for_labels` for the
265
+ match rule. ``None``/empty *issue_labels* (the default) skips this
266
+ link entirely, reproducing pre-#1889 behavior exactly.
267
+ 3. *repo_provider* — per-repo default (``Repo.provider`` in config).
268
+ 4. ``providers_cfg.default`` — global default (defaults to ``"claude"``).
269
+
270
+ Args:
271
+ spec_provider: Provider name from the assignment spec, or ``None``.
272
+ repo_provider: Provider name from the repo config, or ``None``.
273
+ providers_cfg: The parsed :class:`~coord.config.ProvidersConfig`.
274
+ issue_labels: The target issue's GitHub label names, or ``None``.
275
+ #1889: every caller gates this to ``type="work"`` proposals only
276
+ (pass ``None`` for plan/review/smoke dispatches) — the same
277
+ restriction ``models.labels`` uses (#1430), so a harness-eval
278
+ label meant for the eventual work dispatch never leaks into a
279
+ cheap/read-only stage.
280
+
281
+ Returns:
282
+ The winning provider name (always a non-empty string).
283
+ """
284
+ if spec_provider is not None:
285
+ return spec_provider
286
+ if issue_labels:
287
+ label_provider = providers_cfg.provider_for_labels(issue_labels)
288
+ if label_provider is not None:
289
+ return label_provider
290
+ if repo_provider is not None:
291
+ return repo_provider
292
+ return providers_cfg.default
293
+
294
+
295
+ def describe_provider_choice(
296
+ spec_provider: str | None,
297
+ repo_provider: str | None,
298
+ providers_cfg: "ProvidersConfig",
299
+ issue_labels: "list[str] | None" = None,
300
+ ) -> str:
301
+ """Format a one-line explanation of why the effective provider was chosen.
302
+
303
+ #1707: mirrors ``coord.config.describe_model_choice``'s shape — state the
304
+ winning name AND which link of the
305
+ ``spec → label → repo → providers.default`` precedence chain
306
+ (:func:`resolve_provider_name`) supplied it, so
307
+ ``coord assign --dry-run --provider ...`` (and any other dry-run/status
308
+ caller) never leaves an operator guessing whether a provider came from an
309
+ explicit ``--provider``, a ``providers.labels`` match, a repo default, or
310
+ the global fallback — the exact ambiguity #1454 fixed for models.
311
+
312
+ #1889: when a ``providers.labels`` match won, names the matched label
313
+ (and any other configured label present on the issue that it shadowed —
314
+ mirrors :func:`coord.config.describe_model_choice`'s
315
+ *shadowed_labels* phrasing, #1633), so a route that might look
316
+ surprising is self-explaining at dispatch time instead of read from
317
+ source — the exact transparency gap #1798 called out for the sibling
318
+ model-label lever.
319
+
320
+ Args:
321
+ spec_provider: Provider name from the assignment spec / CLI flag, or
322
+ ``None``.
323
+ repo_provider: Provider name from the repo config, or ``None``.
324
+ providers_cfg: The parsed :class:`~coord.config.ProvidersConfig`.
325
+ issue_labels: The target issue's GitHub label names, or ``None`` —
326
+ see :func:`resolve_provider_name`'s docstring for the
327
+ ``type="work"``-only gating every caller applies.
328
+
329
+ Returns:
330
+ E.g. ``"opencode (explicit --provider)"``,
331
+ ``"opencode (via label 'harness:opencode')"``, or
332
+ ``"claude (providers.default)"``.
333
+ """
334
+ name = resolve_provider_name(spec_provider, repo_provider, providers_cfg, issue_labels)
335
+ if spec_provider is not None:
336
+ return f"{name} (explicit --provider)"
337
+ if issue_labels:
338
+ _, matched_label, shadowed_labels = providers_cfg.provider_for_labels_with_reason(
339
+ issue_labels
340
+ )
341
+ if matched_label:
342
+ if shadowed_labels:
343
+ shadowed_str = ", ".join(repr(label) for label in shadowed_labels)
344
+ return (
345
+ f"{name} (via label {matched_label!r}, "
346
+ f"shadowing {shadowed_str})"
347
+ )
348
+ return f"{name} (via label {matched_label!r})"
349
+ if repo_provider is not None:
350
+ return f"{name} (repo default: Repo.provider)"
351
+ return f"{name} (providers.default)"
352
+
353
+
354
+ def provider_type_for(provider_name: str, providers_cfg: "ProvidersConfig") -> str:
355
+ """Resolve *provider_name* to its backend ``type`` (#1711).
356
+
357
+ Falls back to *provider_name* itself when the name isn't registered in
358
+ ``providers_cfg.definitions`` (a typo'd name, or one removed from config
359
+ after an assignment was dispatched with it) — mirrors
360
+ :func:`guard_unattended_dispatch`'s posture of not fabricating a refusal
361
+ for an already-broken reference and letting the existing "unknown
362
+ provider" error path surface it instead.
363
+ """
364
+ definition = providers_cfg.definitions.get(provider_name)
365
+ return definition.type if definition is not None else provider_name
366
+
367
+
368
+ def machine_supports_provider(
369
+ machine: "Machine", provider_name: str, providers_cfg: "ProvidersConfig",
370
+ ) -> bool:
371
+ """Whether *machine* can run *provider_name* (#1711).
372
+
373
+ ``claude``/``claude-pty`` — by resolved TYPE, not registered name, see
374
+ :data:`coord.config.IMPLICIT_PROVIDER_TYPES` — are the implicit
375
+ baseline every machine is assumed to support (unchanged from before
376
+ #1711). Any other backend type requires ``machine.capabilities`` to
377
+ include ``coord.config.provider_capability(type)``, e.g.
378
+ ``"provider:opencode"``.
379
+ """
380
+ ptype = provider_type_for(provider_name, providers_cfg)
381
+ if ptype in IMPLICIT_PROVIDER_TYPES:
382
+ return True
383
+ return provider_capability(ptype) in machine.capabilities
384
+
385
+
386
+ def machines_supporting_provider(
387
+ machines: Iterable["Machine"], provider_name: str, providers_cfg: "ProvidersConfig",
388
+ ) -> list[str]:
389
+ """Names of every machine in *machines* that can run *provider_name*,
390
+ sorted (#1711). Used to compose the "here's where you CAN dispatch
391
+ this" half of :func:`guard_provider_machine_capability`'s refusal.
392
+ """
393
+ return sorted(
394
+ m.name for m in machines
395
+ if machine_supports_provider(m, provider_name, providers_cfg)
396
+ )
397
+
398
+
399
+ def guard_provider_machine_capability(
400
+ *,
401
+ provider_name: str,
402
+ machine: "Machine",
403
+ all_machines: Iterable["Machine"],
404
+ providers_cfg: "ProvidersConfig",
405
+ where: str = "dispatch",
406
+ ) -> None:
407
+ """STRUCTURAL PROVIDER-AVAILABILITY GATE (#1711).
408
+
409
+ Refuses to route *provider_name* to *machine* when *machine* can't run
410
+ it (see :func:`machine_supports_provider`) — e.g. an ``opencode``
411
+ assignment routed to a machine that never declared
412
+ ``provider:opencode`` in ``coordinator.yml``. Before this gate, that
413
+ combination failed only at spawn time, deep inside the agent process,
414
+ with an ENOENT-shaped subprocess error discovered minutes into a
415
+ worker — after the assignment row was created and the worktree built.
416
+ This turns it into a clean refusal at the same dispatch chokepoint as
417
+ the #437 TOS gate (:func:`guard_unattended_dispatch`), before any of
418
+ that happens.
419
+
420
+ A machine's own repo/capabilities are matched exactly as
421
+ :func:`machine_supports_provider` defines it — this function adds only
422
+ the refusal message, naming *machine*, the requested provider and its
423
+ resolved type, and every OTHER configured machine that DOES advertise
424
+ the required capability (so an operator can immediately re-target the
425
+ dispatch), or states plainly that no machine advertises it yet.
426
+
427
+ This is a **declaration** check only — whether *machine* actually has
428
+ the backing binary installed is a separate, best-effort concern
429
+ (:mod:`coord.prereqs`, surfaced via ``coord doctor``); a machine that
430
+ lies about a capability it doesn't actually have is caught there, not
431
+ here (mirrors the existing ``rust``/``gtk``/``browser`` split between
432
+ "declared" and "probed-and-met").
433
+
434
+ Args:
435
+ provider_name: The already-resolved effective provider name (spec →
436
+ repo → ``providers.default``), as returned by
437
+ :func:`resolve_provider_name` or :func:`guard_unattended_dispatch`.
438
+ machine: The machine the dispatch is being routed to.
439
+ all_machines: Every configured machine (``config.machines``) —
440
+ scanned to compose the "these machines DO support it" hint.
441
+ providers_cfg: The coordinator's :class:`~coord.config.ProvidersConfig`.
442
+ where: Short description of the calling site, interpolated into the
443
+ error message (mirrors :func:`guard_unattended_dispatch`).
444
+
445
+ Raises:
446
+ ValueError: When *machine* cannot run *provider_name*.
447
+ """
448
+ if machine_supports_provider(machine, provider_name, providers_cfg):
449
+ return
450
+ ptype = provider_type_for(provider_name, providers_cfg)
451
+ cap = provider_capability(ptype)
452
+ candidates = [
453
+ name for name in machines_supporting_provider(all_machines, provider_name, providers_cfg)
454
+ if name != machine.name
455
+ ]
456
+ if candidates:
457
+ hint = f"machines that DO advertise {cap!r}: {', '.join(candidates)}"
458
+ else:
459
+ hint = f"no configured machine advertises {cap!r} yet"
460
+ raise ValueError(
461
+ f"refusing {where}: machine {machine.name!r} cannot run provider "
462
+ f"{provider_name!r} (type {ptype!r}) — it does not advertise "
463
+ f"{cap!r} in coordinator.yml machines[].capabilities; {hint}. Add "
464
+ f"{cap!r} to a machine that has the {ptype} binary installed, or "
465
+ f"dispatch to one of the machines named above."
466
+ )
467
+
468
+
469
+ def get_provider(
470
+ provider_name: str | None,
471
+ cfg: "Config | None" = None,
472
+ ) -> Provider:
473
+ """Resolve a bare *provider_name* string to a :class:`Provider` instance.
474
+
475
+ #1710: this is the seam coordinator-side log consumers (``coord.progress``,
476
+ ``coord.usage``, ``coord.failure_class``, ...) use to turn the
477
+ already-*resolved* provider name persisted on an assignment
478
+ (``Assignment.provider_name`` — see ``coord/models.py``, set via
479
+ :func:`resolve_provider_name` at dispatch time) into a live
480
+ :class:`Provider` so they can call ``provider.parse_log()`` instead of
481
+ reaching into :mod:`coord.worker_events` directly and assuming every log
482
+ is claude-shaped.
483
+
484
+ Unlike :func:`build_provider`, *cfg* is optional: many call sites (e.g.
485
+ ``coord/notify.py``'s completion-capture helpers) only ever had a bare
486
+ ``provider_name`` string in scope, never a loaded
487
+ :class:`~coord.config.Config` — see the #1710 inventory. Log parsing
488
+ itself doesn't depend on ``ProviderDef.{model,env,extra_args}`` (none of
489
+ the concrete providers' ``parse_log()`` reads those instance attributes),
490
+ so a config-free construction is always correct for this purpose, even
491
+ though it can't honour a custom ``binary`` override.
492
+
493
+ Resolution order:
494
+
495
+ 1. ``provider_name is None`` → ``"claude"`` (the documented implicit
496
+ default for assignments dispatched before #324, or via a path that
497
+ never set the field).
498
+ 2. *cfg* given and *provider_name* found in ``cfg.providers.definitions``
499
+ → :func:`build_provider` (honours ``binary``/``model``/``env``/
500
+ ``extra_args`` from the definition).
501
+ 3. *provider_name* is one of the built-in type names (``"claude"``,
502
+ ``"claude-pty"``, ``"opencode"``) → a bare, no-``ProviderDef``
503
+ instance of that type.
504
+ 4. Anything else (a name that is neither configured nor a built-in type —
505
+ a typo, or a provider removed from config after dispatch) →
506
+ :class:`ClaudeProvider`, with a **logged warning**. #1710's whole point
507
+ is that a silent fallback here is the bug; the warning names the
508
+ assignment's actual provider so an operator can tell "misparsed
509
+ because we guessed wrong" apart from "genuinely claude."
510
+
511
+ Args:
512
+ provider_name: The *resolved* provider name, or ``None``.
513
+ cfg: The coordinator's :class:`~coord.config.Config`, when available.
514
+
515
+ Returns:
516
+ A ready-to-use :class:`Provider` instance. Never raises — an unknown
517
+ name degrades to :class:`ClaudeProvider` (loudly) rather than
518
+ blocking whatever best-effort log parsing called this.
519
+ """
520
+ name = provider_name or "claude"
521
+ if cfg is not None:
522
+ definition = cfg.providers.definitions.get(name)
523
+ if definition is not None:
524
+ try:
525
+ return build_provider(name, definition, cfg.models)
526
+ except ValueError:
527
+ # Unknown ProviderDef.type — fall through to the built-in /
528
+ # warning path below rather than raising out of a best-effort
529
+ # log-parsing caller.
530
+ pass
531
+ ctor = _BUILTIN_PROVIDER_TYPES.get(name)
532
+ if ctor is not None:
533
+ return ctor()
534
+ _log.warning(
535
+ "get_provider: unknown provider_name %r — no matching entry in "
536
+ "providers.definitions and not a built-in provider type. Falling "
537
+ "back to ClaudeProvider for log parsing; if this assignment's "
538
+ "worker is NOT claude, its log will misparse silently downstream "
539
+ "of this warning (#1710).",
540
+ name,
541
+ )
542
+ return ClaudeProvider()
543
+
544
+
545
+ def resolve_default_provider(
546
+ providers_cfg: "ProvidersConfig",
547
+ models_cfg: "ModelsConfig | None" = None,
548
+ ) -> Provider:
549
+ """Instantiate the coordinator's default provider for unattended oneshot use.
550
+
551
+ Resolves the effective provider name from ``providers_cfg.default``,
552
+ instantiates it via :func:`build_provider`, then checks
553
+ ``capabilities().human_attended_only``. Raises :class:`ValueError` if
554
+ the resolved provider is human-attended-only (e.g.
555
+ :class:`~coord.providers.claude_pty.ClaudePtyProvider`) — oneshot callers
556
+ (brain planning, dashboard assistant) are unattended and must never route
557
+ through such a provider.
558
+
559
+ Falls back to a plain :class:`ClaudeProvider` when the resolved name is
560
+ not found in ``providers_cfg.definitions`` (which shouldn't happen in
561
+ practice because :class:`~coord.config.ProvidersConfig` always materialises
562
+ the implicit ``"claude"`` entry).
563
+
564
+ This is the **shared** implementation used by both :mod:`coord.brain` and
565
+ :mod:`coord.dashboard.server`. Any change to default-provider resolution
566
+ or the human-attended guard belongs here — not duplicated in callers.
567
+
568
+ Args:
569
+ providers_cfg: The coordinator's
570
+ :class:`~coord.config.ProvidersConfig`.
571
+ models_cfg: Optional :class:`~coord.config.ModelsConfig`, forwarded
572
+ to :func:`build_provider`.
573
+
574
+ Returns:
575
+ A ready-to-use :class:`Provider` instance whose
576
+ ``capabilities().human_attended_only`` is ``False``.
577
+
578
+ Raises:
579
+ ValueError: When the resolved provider reports
580
+ ``capabilities().human_attended_only=True``. The error message
581
+ names the provider and points the operator at
582
+ ``coord assign --interactive``.
583
+ """
584
+ name = providers_cfg.default
585
+ definition = providers_cfg.definitions.get(name)
586
+ if definition is None:
587
+ return ClaudeProvider()
588
+ provider = build_provider(name, definition, models_cfg)
589
+ caps = provider.capabilities()
590
+ if caps.human_attended_only:
591
+ raise ValueError(
592
+ f"refusing unattended oneshot call: provider {name!r} reports "
593
+ f"capabilities().human_attended_only=True — this backend is "
594
+ f"licensed only for human-attended interactive use (Anthropic "
595
+ f"ToS §3.7) and must NEVER be selected for unattended "
596
+ f"automation (brain planning, dashboard assistant). Configure "
597
+ f"a non-human-attended provider (e.g. 'claude') as "
598
+ f"providers.default, or launch a human-attended session with "
599
+ f"`coord assign --interactive`."
600
+ )
601
+ return provider
602
+
603
+
604
+ def guard_unattended_dispatch(
605
+ *,
606
+ spec_provider: str | None,
607
+ repo_provider: str | None,
608
+ providers_cfg: "ProvidersConfig",
609
+ models_cfg: "ModelsConfig | None" = None,
610
+ where: str = "unattended dispatch",
611
+ issue_labels: "list[str] | None" = None,
612
+ ) -> str:
613
+ """STRUCTURAL TOS-COMPLIANCE GATE for unattended dispatch (#437).
614
+
615
+ Resolves the effective provider name with :func:`resolve_provider_name`
616
+ (precedence: spec → label → repo → providers.default), then
617
+ instantiates the provider via :func:`build_provider` and inspects its
618
+ :class:`~coord.providers.base.Capabilities`. Raises :class:`ValueError`
619
+ if ``capabilities().human_attended_only`` is ``True`` — that flag means
620
+ the backend (currently :class:`~coord.providers.claude_pty.ClaudePtyProvider`,
621
+ interactive subscription-billed Claude Code) is only licensed for
622
+ human-attended use under Anthropic ToS §3.7 and must NEVER be selected
623
+ for autonomous routing.
624
+
625
+ This gate is called from every unattended dispatch path
626
+ (``coord.dispatch.dispatch``, ``coord.review.dispatch_review``,
627
+ ``coord.reconcile._reassign``). The human-attended escape hatch
628
+ (``coord assign --interactive``) deliberately skips this gate.
629
+
630
+ Args:
631
+ spec_provider: Per-spec/per-proposal provider override, or ``None``.
632
+ repo_provider: Per-repo provider override (``Repo.provider``), or
633
+ ``None``.
634
+ providers_cfg: The coordinator's
635
+ :class:`~coord.config.ProvidersConfig`.
636
+ models_cfg: Optional :class:`~coord.config.ModelsConfig`, forwarded
637
+ to :func:`build_provider`.
638
+ where: Short description of the calling site (e.g.
639
+ ``"coord approve / dispatch"``) — interpolated into the error
640
+ message so the human knows which path refused.
641
+ issue_labels: The target issue's GitHub label names, or ``None``
642
+ (#1889) — forwarded to :func:`resolve_provider_name` for
643
+ ``providers.labels`` resolution. Callers gate this to
644
+ ``type="work"`` proposals only, mirroring ``models.labels``
645
+ (#1430); ``None`` reproduces pre-#1889 behavior exactly.
646
+
647
+ Returns:
648
+ The effective provider name (also returned for callers that want to
649
+ thread it onward to the wire payload).
650
+
651
+ Raises:
652
+ ValueError: When the effective provider opts out of unattended use.
653
+ The message names the provider, explains why, and points the
654
+ user at ``coord assign --interactive``.
655
+ """
656
+ name = resolve_provider_name(spec_provider, repo_provider, providers_cfg, issue_labels)
657
+ definition = providers_cfg.definitions.get(name)
658
+ if definition is None:
659
+ # Unknown name (not in registry) → fall through; the agent's own
660
+ # unknown-provider handling kicks in. Don't fabricate a refusal
661
+ # for a typo'd provider name; let the existing error path surface
662
+ # it as a validation failure at the agent.
663
+ return name
664
+ try:
665
+ provider = build_provider(name, definition, models_cfg)
666
+ except ValueError:
667
+ # build_provider raises on unknown TYPE; the caller will hit the
668
+ # same error path on dispatch. Don't shadow it here.
669
+ return name
670
+ caps = provider.capabilities()
671
+ if caps.human_attended_only:
672
+ raise ValueError(
673
+ f"refusing {where}: provider {name!r} reports "
674
+ f"capabilities().human_attended_only=True — this backend is "
675
+ f"licensed only for human-attended interactive use (Anthropic "
676
+ f"ToS §3.7) and must NEVER be selected for unattended "
677
+ f"automation. To launch a human-attended session, run "
678
+ f"`coord assign --interactive <machine> <repo> <issue>` from "
679
+ f"the operator's terminal; the human drives and closes the "
680
+ f"session. To dispatch unattended, configure a non-human-"
681
+ f"attended provider (e.g. `claude`)."
682
+ )
683
+ return name