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,513 @@
1
+ """Session/daemon lifecycle commands: `notify`, `resume`, `done`, `web`,
2
+ `serve`, `housekeeping`. Extracted from coord/cli.py (#747)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import socket
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+
14
+ from coord.commands._common import (
15
+ SERVE_PORT,
16
+ _CONFIG_OPTION,
17
+ _load_config,
18
+ server_extra_guard,
19
+ )
20
+
21
+
22
+ def _print_housekeeping_result(resp: dict) -> None:
23
+ dry = resp.get("dry_run")
24
+ archived_a = resp.get("archived_assignments", 0)
25
+ archived_n = resp.get("archived_notifications", 0)
26
+ days = resp.get("retention_days")
27
+ if not archived_a and not archived_n:
28
+ click.echo(
29
+ f"housekeeping: nothing to archive (no terminal rows older than {days}d)."
30
+ )
31
+ return
32
+ verb = "would archive" if dry else "archived"
33
+ suffix = " (dry-run — nothing moved)" if dry else ""
34
+ click.echo(
35
+ f"housekeeping: {verb} {archived_a} assignment(s) + "
36
+ f"{archived_n} notification(s) (terminal, older than {days}d).{suffix}"
37
+ )
38
+
39
+
40
+ @click.command(
41
+ "housekeeping",
42
+ help=(
43
+ "#762: archive stale terminal board rows so the /board payload + DB stay "
44
+ "bounded (an unbounded board overran the TUI fetch timeout and blanked "
45
+ "the board).\n\n"
46
+ "Moves terminal assignments older than COORD_ARCHIVE_RETENTION_DAYS "
47
+ "(default 30) + their notifications into assignments_archive / "
48
+ "notifications_archive — it NEVER deletes, and never touches active, "
49
+ "recent, merge-queued, open-issue-latest, or review-linked rows. Routes "
50
+ "through the daemon (the canonical DB lives there)."
51
+ ),
52
+ )
53
+
54
+
55
+ @click.option(
56
+ "--dry-run",
57
+ is_flag=True,
58
+ help="Report what would be archived without moving anything.",
59
+ )
60
+
61
+
62
+ def housekeeping(dry_run: bool) -> None:
63
+ """#762: archive stale terminal board rows (active/recent/referenced kept)."""
64
+ from coord.board_service import daemon_reroute_target # noqa: PLC0415
65
+
66
+ _svc = daemon_reroute_target("COORD_HOUSEKEEPING_ON_DAEMON")
67
+ if _svc is not None:
68
+ from coord.client import post_record # noqa: PLC0415
69
+
70
+ try:
71
+ resp = post_record(_svc, "/housekeeping", {"dry_run": dry_run}, timeout=180.0)
72
+ except Exception as exc: # noqa: BLE001
73
+ click.echo(f"error: housekeeping via daemon failed: {exc}", err=True)
74
+ sys.exit(1)
75
+ _print_housekeeping_result(resp)
76
+ return
77
+
78
+ from coord import housekeeping as _hk # noqa: PLC0415
79
+
80
+ _print_housekeeping_result(_hk.sweep(dry_run=dry_run))
81
+
82
+
83
+ @click.command(help="Poll agents and post completion/failure comments on GitHub.")
84
+ @_CONFIG_OPTION
85
+ def notify(config_path: Path) -> None:
86
+ # #906: `coord notify` reads dispatched assignments and writes back
87
+ # mark_notified/save_plan/update_claude_session_id — all local-DB
88
+ # operations that are empty/no-op on a thin client. Route the whole
89
+ # command to the daemon so it runs against the canonical DB + real agent
90
+ # fleet. COORD_NOTIFY_ON_DAEMON guards the daemon against re-routing to
91
+ # itself (same pattern as coord merge / reconcile-merges / diagnose /
92
+ # housekeeping).
93
+ from coord.board_service import daemon_reroute_target # noqa: PLC0415
94
+
95
+ _svc = daemon_reroute_target("COORD_NOTIFY_ON_DAEMON")
96
+ if _svc is not None:
97
+ from coord.client import post_record # noqa: PLC0415
98
+
99
+ try:
100
+ resp = post_record(_svc, "/notify", {}, timeout=180.0)
101
+ except Exception as exc: # noqa: BLE001
102
+ click.echo(f"error: notify via daemon failed: {exc}", err=True)
103
+ sys.exit(1)
104
+ output = resp.get("output") or ""
105
+ if output:
106
+ click.echo(output, nl=False)
107
+ if resp.get("error"):
108
+ click.echo(f"error: {resp['error']}", err=True)
109
+ code = resp.get("exit_code") or 0
110
+ if code:
111
+ sys.exit(int(code))
112
+ return
113
+
114
+ from coord.board_service import read_board, write_board
115
+ from coord.hooks import is_round_complete, run_hooks
116
+ from coord.notify import run as run_notify
117
+
118
+ cfg = _load_config(config_path)
119
+ posted, stuck, needs_attention, stalled, liveness = run_notify(cfg)
120
+ if not posted and not stuck and not needs_attention and not stalled and not liveness:
121
+ click.echo("No new transitions to notify.")
122
+ return
123
+ if posted:
124
+ click.echo(f"Posted {len(posted)} completion/failure comment(s):")
125
+ for t in posted:
126
+ click.echo(
127
+ f" [{t.event}] {t.machine_name} → {t.repo_name} "
128
+ f"#{t.issue_number} (assignment {t.assignment_id}, exit {t.exit_code})"
129
+ )
130
+ if stuck:
131
+ click.echo(f"Posted {len(stuck)} stuck detection(s):")
132
+ for s in stuck:
133
+ click.echo(
134
+ f" [stuck] {s.machine_name} → {s.repo_name} "
135
+ f"#{s.issue_number} (assignment {s.assignment_id})"
136
+ )
137
+ click.echo(f" {s.stuck_message}")
138
+ if needs_attention:
139
+ click.echo(f"Posted {len(needs_attention)} needs-attention detection(s):")
140
+ for n in needs_attention:
141
+ click.echo(
142
+ f" [needs-attention:{n.reason}] {n.machine_name} → {n.repo_name} "
143
+ f"#{n.issue_number} (assignment {n.assignment_id})"
144
+ )
145
+ click.echo(f" {n.detail}")
146
+ if stalled:
147
+ click.echo(f"Posted {len(stalled)} stalled-pipeline detection(s):")
148
+ for s in stalled:
149
+ click.echo(
150
+ f" [stalled:{s.reason}] {s.machine_name} → {s.repo_name} "
151
+ f"#{s.issue_number} (assignment {s.assignment_id})"
152
+ )
153
+ click.echo(f" {s.detail}")
154
+ if liveness:
155
+ click.echo(f"Posted {len(liveness)} liveness-auditor stall detection(s):")
156
+ for lv in liveness:
157
+ click.echo(
158
+ f" [liveness] {lv.machine_name} → {lv.repo_name} "
159
+ f"#{lv.issue_number} (assignment {lv.assignment_id}, "
160
+ f"{lv.consecutive_blocked} consecutive blocked verdicts)"
161
+ )
162
+ board = read_board()
163
+
164
+ if is_round_complete(board) and cfg.hooks.on_round_complete:
165
+ click.echo("\nRound complete — running hooks:")
166
+ for result in run_hooks("on_round_complete", cfg, board):
167
+ status = "ok" if result.ok else "FAILED"
168
+ click.echo(f" [{status}] {result.hook}: {result.message}")
169
+
170
+ write_board(board)
171
+
172
+
173
+ @click.command(help="Recover board state after a crash or restart.")
174
+ @_CONFIG_OPTION
175
+ def resume(config_path: Path) -> None:
176
+ from coord.board_service import is_remote, read_board, write_board
177
+ from coord.reconcile import reconcile
178
+
179
+ cfg = _load_config(config_path)
180
+ if not is_remote():
181
+ # Informational only (local mode): distinguish "no board saved yet" from
182
+ # "loaded an existing board" — read_board() below does the actual
183
+ # local/remote read.
184
+ from coord.state import load_board as _peek_local_board # noqa: PLC0415
185
+
186
+ if _peek_local_board() is None:
187
+ click.echo("No saved board found. Rebuilding from dispatched ledger...")
188
+ board = read_board()
189
+
190
+ click.echo(f"Board round: {board.round_number}")
191
+ click.echo(f" active: {len(board.active)} assignment(s)")
192
+ click.echo(f" completed: {len(board.completed)} assignment(s)")
193
+
194
+ if board.active:
195
+ click.echo("\nReconciling with agent servers...")
196
+ changed = reconcile(board, cfg)
197
+ if changed:
198
+ click.echo(f" {len(changed)} assignment(s) finished since last check:")
199
+ from coord.merge_queue import enqueue as _mq_enqueue
200
+ for aid in changed:
201
+ a = board.find_by_id(aid)
202
+ if a:
203
+ click.echo(f" {a.machine_name} → {a.repo_name} #{a.issue_number}: [{a.status}]")
204
+ if a.status == "done":
205
+ repo_cfg = cfg.repo(a.repo_name)
206
+ if repo_cfg is not None and a.branch:
207
+ entry = _mq_enqueue(
208
+ a,
209
+ repo_github=repo_cfg.github,
210
+ target_branch=repo_cfg.default_branch,
211
+ )
212
+ if entry is not None:
213
+ click.echo(
214
+ f" → enqueued for merge ({entry.branch} → {entry.target_branch})"
215
+ )
216
+ elif a.status == "done" and not a.branch:
217
+ click.echo(
218
+ " → no branch captured; skip merge enqueue"
219
+ )
220
+ else:
221
+ click.echo(" all active assignments still running")
222
+
223
+ removed = board.gc()
224
+ if removed:
225
+ click.echo(f"\nGC: pruned {removed} old completed assignment(s)")
226
+
227
+ write_board(board)
228
+ click.echo(f"\nBoard saved ({len(board.active)} active, {len(board.completed)} completed)")
229
+
230
+ if board.active:
231
+ click.echo("\nActive assignments:")
232
+ for a in board.active:
233
+ click.echo(f" {a.machine_name} → {a.repo_name} #{a.issue_number}: {a.issue_title}")
234
+
235
+
236
+ @click.command(help="End the session — run housekeeping hooks and show summary.")
237
+ @_CONFIG_OPTION
238
+ def done(config_path: Path) -> None:
239
+ from coord.board_service import read_board, write_board
240
+ from coord.hooks import run_hooks
241
+
242
+ cfg = _load_config(config_path)
243
+ board = read_board()
244
+
245
+ if board.active:
246
+ click.echo(
247
+ f"warning: {len(board.active)} assignment(s) still active. "
248
+ f"They will continue running on their agent servers.",
249
+ err=True,
250
+ )
251
+
252
+ if cfg.hooks.on_session_end:
253
+ click.echo("Running session-end hooks:")
254
+ for result in run_hooks("on_session_end", cfg, board):
255
+ status = "ok" if result.ok else "FAILED"
256
+ click.echo(f" [{status}] {result.hook}: {result.message}")
257
+ else:
258
+ from coord.hooks import _summary_report
259
+ click.echo(_summary_report(cfg, board))
260
+
261
+ # Repo housekeeping: pull latest and run configured commands
262
+ hostname = socket.gethostname().split(".")[0]
263
+ local_machine = next(
264
+ (m for m in cfg.machines if m.name == hostname or m.host.split(".")[0] == hostname),
265
+ None,
266
+ )
267
+
268
+ if local_machine:
269
+ for repo in cfg.repos:
270
+ if not repo.housekeeping:
271
+ continue
272
+ repo_path_str = local_machine.repo_path(repo.name)
273
+ if not repo_path_str:
274
+ click.echo(f" {repo.name}: no local path configured, skipping housekeeping")
275
+ continue
276
+ repo_path = Path(repo_path_str).expanduser()
277
+ if not repo_path.exists():
278
+ click.echo(f" {repo.name}: path {repo_path} does not exist, skipping")
279
+ continue
280
+
281
+ # Pull latest
282
+ click.echo(f"\n{repo.name}: pulling latest...")
283
+ try:
284
+ subprocess.run(
285
+ ["git", "pull", "--ff-only"],
286
+ cwd=str(repo_path), check=True, capture_output=True, text=True,
287
+ )
288
+ except subprocess.CalledProcessError as e:
289
+ click.echo(f" git pull failed: {e.stderr.strip()}", err=True)
290
+ # Continue with housekeeping anyway — might still work
291
+
292
+ # Run housekeeping commands
293
+ for cmd in repo.housekeeping:
294
+ click.echo(f" running: {cmd}")
295
+ try:
296
+ result = subprocess.run(
297
+ cmd, shell=True, cwd=str(repo_path),
298
+ capture_output=True, text=True, timeout=300,
299
+ )
300
+ if result.returncode != 0:
301
+ click.echo(f" failed (exit {result.returncode}): {result.stderr.strip()}", err=True)
302
+ else:
303
+ click.echo(f" done")
304
+ except subprocess.TimeoutExpired:
305
+ click.echo(f" timed out after 300s", err=True)
306
+ except Exception as e:
307
+ click.echo(f" error: {e}", err=True)
308
+ else:
309
+ click.echo("\nCould not determine local machine — skipping repo housekeeping")
310
+
311
+ write_board(board)
312
+
313
+ # Write session end summary — use the usage module so the output matches `coord usage`.
314
+ import datetime
315
+ from coord.state import write_session_end, load_session
316
+ from coord.usage import build_session_usage, format_usage_report
317
+
318
+ sess = load_session()
319
+ started_at: float | None = None
320
+ if sess and sess.get("started_at"):
321
+ try:
322
+ dt = datetime.datetime.fromisoformat(
323
+ sess["started_at"].rstrip("Z").replace("Z", "+00:00")
324
+ )
325
+ started_at = dt.replace(tzinfo=datetime.timezone.utc).timestamp()
326
+ except (ValueError, AttributeError):
327
+ pass
328
+
329
+ all_assignments = list(board.active) + list(board.completed)
330
+ session_usage = build_session_usage(all_assignments, started_at=started_at)
331
+ total_cost = session_usage.total_cost_usd
332
+
333
+ click.echo("")
334
+ click.echo(format_usage_report(session_usage))
335
+
336
+ completed_ids = [a.assignment_id for a in board.completed if a.assignment_id]
337
+ issues_closed = list(set(a.issue_number for a in board.completed))
338
+ write_session_end(
339
+ completed_ids=completed_ids,
340
+ issues_closed=issues_closed,
341
+ total_cost_usd=total_cost,
342
+ )
343
+ click.echo(f"\nSession saved (${total_cost:.2f} total cost)")
344
+
345
+ click.echo("\nSession ended. Board saved.")
346
+
347
+
348
+ @click.command(help="Start the web dashboard (port 7434).")
349
+ @_CONFIG_OPTION
350
+ @click.option("--host", "bind_host", default="0.0.0.0", show_default=True)
351
+ @click.option("--port", "bind_port", default=7434, show_default=True, type=int)
352
+ @click.option(
353
+ "--token",
354
+ "token",
355
+ default=None,
356
+ envvar="COORD_WEB_TOKEN",
357
+ help=(
358
+ "Bearer token gating the /ws/terminal PTY bridge (#1065); clients "
359
+ "connect with ?token=<token> (browsers can't set custom WS upgrade "
360
+ "headers). Resolves flag > $COORD_WEB_TOKEN > ~/.coord/web_token. "
361
+ "Prefer the file/env (a --token on the command line leaks via `ps`). "
362
+ "Unset -> open (tailnet ACL only)."
363
+ ),
364
+ )
365
+ @click.option(
366
+ "--fixture",
367
+ "fixture_path",
368
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
369
+ default=None,
370
+ help=(
371
+ "#1538: serve a deterministic seeded board from a JSON fixture instead "
372
+ "of ~/.coord/coord.db — the web twin of coord-tui's make_test_app. "
373
+ "Every endpoint answers through the REAL compute_pipeline/serialization; "
374
+ "writes are recorded (GET /api/fixture/actions), never executed. No DB, "
375
+ "no fleet, no network, no money. See coord/dashboard/fixture.py."
376
+ ),
377
+ )
378
+ @click.option(
379
+ "--dist",
380
+ "dist_path",
381
+ type=click.Path(exists=False, file_okay=False, path_type=Path),
382
+ default=None,
383
+ envvar="COORD_WEB_DIST",
384
+ help=(
385
+ "#1543: serve the built React webapp from this directory instead of "
386
+ "the one bundled inside the installed package "
387
+ "(coord/dashboard/webapp/dist). Resolves flag > $COORD_WEB_DIST. "
388
+ "Lets a build hook keep a checkout's dist/ in sync with merged main "
389
+ "and have it go live here without touching ~/.coord-venv, so a "
390
+ "webapp change never upgrades coord-agent/coord-serve's shared "
391
+ "venv or needs a PyPI release. Missing/empty falls back to the "
392
+ "legacy single-file dashboard, same as the bundled default. See "
393
+ "docs/PHONE_WEBAPP.md."
394
+ ),
395
+ )
396
+ def web(
397
+ config_path: Path,
398
+ bind_host: str,
399
+ bind_port: int,
400
+ token: str | None,
401
+ fixture_path: Path | None,
402
+ dist_path: Path | None,
403
+ ) -> None:
404
+ # #1237: function-local + guarded — see _start_agent_server for why.
405
+ with server_extra_guard("web"):
406
+ import uvicorn
407
+ from coord.dashboard.server import build_app
408
+ from coord.dashboard.terminal import resolve_web_token
409
+
410
+ fixture = None
411
+ if fixture_path is not None:
412
+ from coord.config import ConfigError # noqa: PLC0415
413
+ from coord.dashboard.fixture import FixtureError, load_fixture # noqa: PLC0415
414
+
415
+ try:
416
+ fixture = load_fixture(fixture_path)
417
+ except FixtureError as exc:
418
+ raise click.ClickException(str(exc)) from exc
419
+ # A fixture server must start on a machine with no coord.db and no
420
+ # coordinator.yml at all (#1538 acceptance), so a missing/invalid
421
+ # config is not fatal here — the fixture's own `config` block, or
422
+ # Config defaults, take over. Deliberately `config.load` rather than
423
+ # `_load_config`: the latter exits the process on ConfigError, fetches
424
+ # a thin client's config over the network, and snapshots the result to
425
+ # the DB — all three are exactly what fixture mode must not do.
426
+ from coord.config import load as _load_config_file # noqa: PLC0415
427
+
428
+ fallback = None
429
+ try:
430
+ fallback = _load_config_file(config_path)
431
+ except (ConfigError, OSError):
432
+ pass
433
+ cfg = fixture.config(fallback)
434
+ else:
435
+ cfg = _load_config(config_path)
436
+
437
+ token = resolve_web_token(token)
438
+ app = build_app(cfg, token=token, fixture=fixture, dist_path=dist_path)
439
+ click.echo(f"coord web: dashboard at http://{bind_host}:{bind_port}")
440
+ if dist_path is not None:
441
+ click.echo(f" serving webapp bundle from {dist_path} (#1543)")
442
+ if fixture is not None:
443
+ click.echo(
444
+ f" fixture mode: seeded board from {fixture_path} — reads are "
445
+ "deterministic, writes are RECORDED not executed "
446
+ "(GET /api/fixture/actions)."
447
+ )
448
+ if not token:
449
+ click.echo(
450
+ " warning: no bearer token — the /ws/terminal PTY bridge is open "
451
+ "to anyone who can reach this port. Fine for dev; set one for a "
452
+ "production dashboard (echo <secret> > ~/.coord/web_token).",
453
+ err=True,
454
+ )
455
+ uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
456
+
457
+
458
+ @click.command(
459
+ help=(
460
+ "Start the portable control-center daemon (#584, port 7435). Serves the "
461
+ "board (GET /board) + config (GET /config) and records results (POST "
462
+ "/result, /completion, #590) against the one shared ~/.coord/coord.db, so "
463
+ "any Tailscale machine renders and drives the same board. Run this on "
464
+ "the always-on host that owns the DB. Optional bearer token (flag > "
465
+ "$COORD_SERVE_TOKEN > ~/.coord/serve_token)."
466
+ )
467
+ )
468
+
469
+
470
+ @_CONFIG_OPTION
471
+ @click.option("--host", "bind_host", default="0.0.0.0", show_default=True)
472
+ @click.option("--port", "bind_port", default=SERVE_PORT, show_default=True, type=int)
473
+ @click.option(
474
+ "--token",
475
+ "token",
476
+ default=None,
477
+ envvar="COORD_SERVE_TOKEN",
478
+ help=(
479
+ "Shared bearer token; clients must send Authorization: Bearer <token>. "
480
+ "Resolves flag > $COORD_SERVE_TOKEN > ~/.coord/serve_token. Prefer the "
481
+ "file/env (a --token on the command line leaks via `ps`). Unset → open "
482
+ "(tailnet ACL only)."
483
+ ),
484
+ )
485
+
486
+
487
+ def serve(config_path: Path, bind_host: str, bind_port: int, token: str | None) -> None:
488
+ # #1237: function-local + guarded — see _start_agent_server for why.
489
+ with server_extra_guard("serve"):
490
+ import uvicorn
491
+
492
+ from coord.dao import SqliteStore
493
+ from coord.db import DB_PATH
494
+ from coord.serve_app import build_app as build_serve_app
495
+ from coord.serve_app import resolve_serve_token
496
+
497
+ cfg = _load_config(config_path)
498
+ token = resolve_serve_token(token)
499
+ store = SqliteStore(DB_PATH)
500
+ app = build_serve_app(store, cfg, token=token)
501
+ auth = "bearer-token" if token else "OPEN (tailnet ACL only)"
502
+ click.echo(
503
+ f"coord serve: control center at http://{bind_host}:{bind_port} "
504
+ f"(config={cfg.path}, db={DB_PATH}, auth={auth})"
505
+ )
506
+ if not token:
507
+ click.echo(
508
+ " warning: no bearer token — endpoints are open to anyone who can "
509
+ "reach this port. Fine for dev; the production daemon should set one "
510
+ "(echo <secret> > ~/.coord/serve_token). See AGENT_OPERATIONS.md.",
511
+ err=True,
512
+ )
513
+ uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")