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,385 @@
1
+ """``coord terminal`` — persistent, fleet-wide plain shell sessions (#952).
2
+
3
+ Keystone for the "TUI: Fleet Terminal Manager" milestone (#26). Unlike the
4
+ standalone Terminal view's ephemeral local ``$SHELL`` pty
5
+ (``tui/src/app/terminal.rs``), and unlike interactive ``claude`` sessions
6
+ (``coord-<assignment_id>`` tmux sessions, ``coord/interactive.py``), a
7
+ *terminal* session is a free-floating shell with **no** issue/repo/assignment
8
+ attached to it. It lives entirely in tmux — there is no on-disk sessions
9
+ file, no board row, no pipeline/merge-queue involvement — so it is created,
10
+ listed, killed, and attached purely by talking to tmux (locally or over
11
+ ``ssh``, reusing the :class:`~coord.interactive.TmuxHost` seam).
12
+
13
+ Naming convention: ``coord-term-<slug>`` (:data:`~coord.interactive.
14
+ TERM_SESSION_PREFIX`) — a prefix distinct from ``coord-<assignment_id>``
15
+ (:data:`~coord.interactive.TMUX_SESSION_PREFIX`) so the two kinds of tmux
16
+ session never collide and each side filters only its own:
17
+ :func:`~coord.interactive.list_coord_tmux_sessions` (assignment discovery,
18
+ consumed by ``coord sessions``/reattach) explicitly excludes
19
+ ``coord-term-*``; :func:`list_tmux_terminal_sessions` here matches *only*
20
+ that prefix.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json as _json
26
+ import secrets
27
+ import socket
28
+ import subprocess
29
+ import sys
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+
33
+ import click
34
+
35
+ from coord.commands._common import _CONFIG_OPTION, _load_config
36
+ from coord.interactive import TERM_SESSION_PREFIX, TmuxHost, tmux_available, tmux_session_alive
37
+
38
+ __all__ = [
39
+ "terminal_group",
40
+ "terminal_session_name",
41
+ "parse_terminal_slug",
42
+ "generate_slug",
43
+ "parse_machine_qualified_name",
44
+ "list_tmux_terminal_sessions",
45
+ ]
46
+
47
+
48
+ # ── pure helpers (unit-testable without Click / a real tmux) ────────────────
49
+
50
+
51
+ def terminal_session_name(slug: str) -> str:
52
+ """Return the canonical tmux session name for a terminal *slug*."""
53
+ return f"{TERM_SESSION_PREFIX}{slug}"
54
+
55
+
56
+ def parse_terminal_slug(session_name: str) -> str | None:
57
+ """Return the slug for a ``coord-term-<slug>`` session name.
58
+
59
+ Returns ``None`` when *session_name* does not carry the terminal
60
+ prefix (e.g. a ``coord-<assignment_id>`` session) or has an empty slug.
61
+ """
62
+ if not session_name.startswith(TERM_SESSION_PREFIX):
63
+ return None
64
+ slug = session_name[len(TERM_SESSION_PREFIX):]
65
+ return slug or None
66
+
67
+
68
+ def generate_slug() -> str:
69
+ """Return a short, human-typeable random slug (6 hex chars)."""
70
+ return secrets.token_hex(3)
71
+
72
+
73
+ def parse_machine_qualified_name(target: str) -> tuple[str | None, str]:
74
+ """Split ``"machine:name"`` into ``(machine, name)``; bare ``"name"`` → ``(None, name)``.
75
+
76
+ Only the FIRST ``:`` is significant — slugs never contain one.
77
+ """
78
+ if ":" in target:
79
+ machine, _, name = target.partition(":")
80
+ return (machine or None), name
81
+ return None, target
82
+
83
+
84
+ def list_tmux_terminal_sessions(*, host: TmuxHost = TmuxHost(None)) -> list[dict]:
85
+ """Return live ``coord-term-*`` sessions on *host* as parsed dicts.
86
+
87
+ Each entry: ``{"name": <slug>, "attached": bool, "created": <iso8601>?}``.
88
+ Uses a single ``tmux list-sessions -F …`` call; returns ``[]`` when tmux
89
+ is unavailable, has no server running, or has no matching sessions —
90
+ callers never need to distinguish those cases.
91
+ """
92
+ try:
93
+ result = subprocess.run(
94
+ host.cmd([
95
+ "list-sessions", "-F",
96
+ "#{session_name}\t#{session_attached}\t#{session_created}",
97
+ ]),
98
+ capture_output=True,
99
+ text=True,
100
+ timeout=5.0,
101
+ )
102
+ except (subprocess.SubprocessError, OSError):
103
+ return []
104
+ if result.returncode != 0:
105
+ return []
106
+
107
+ sessions: list[dict] = []
108
+ for raw_line in result.stdout.splitlines():
109
+ parts = raw_line.split("\t")
110
+ if len(parts) < 3:
111
+ continue
112
+ name, attached_raw, created_raw = (p.strip() for p in parts[:3])
113
+ slug = parse_terminal_slug(name)
114
+ if slug is None:
115
+ continue
116
+ entry: dict = {
117
+ "name": slug,
118
+ "attached": attached_raw not in ("", "0"),
119
+ }
120
+ if created_raw.isdigit():
121
+ entry["created"] = (
122
+ datetime.fromtimestamp(int(created_raw), tz=timezone.utc).isoformat()
123
+ )
124
+ sessions.append(entry)
125
+ return sessions
126
+
127
+
128
+ def _local_short_hostname() -> str:
129
+ return socket.gethostname().split(".")[0].lower()
130
+
131
+
132
+ def _is_local_machine(name: str, host: str) -> bool:
133
+ local_hn = _local_short_hostname()
134
+ return name.lower() == local_hn or host.split(".")[0].lower() == local_hn
135
+
136
+
137
+ def _resolve_machine_host(
138
+ machine_name: str | None, config_path: Path
139
+ ) -> tuple[TmuxHost, str | None]:
140
+ """Resolve *machine_name* (from config) to a :class:`TmuxHost`.
141
+
142
+ Returns ``(TmuxHost(ssh_target=None), None)`` when *machine_name* is
143
+ ``None`` (the local/default path — no config lookup needed). Exits the
144
+ process with an error message when *machine_name* is given but not
145
+ found in ``coordinator.yml``'s ``machines`` table.
146
+ """
147
+ if not machine_name:
148
+ return TmuxHost(ssh_target=None), None
149
+
150
+ cfg = _load_config(config_path)
151
+ machine = next((m for m in cfg.machines if m.name == machine_name), None)
152
+ if machine is None:
153
+ click.echo(f"error: machine {machine_name!r} not in config", err=True)
154
+ sys.exit(1)
155
+
156
+ if _is_local_machine(machine.name, machine.host):
157
+ return TmuxHost(ssh_target=None), machine.name
158
+ return TmuxHost(ssh_target=machine.host), machine.name
159
+
160
+
161
+ # ── Click group ──────────────────────────────────────────────────────────────
162
+
163
+
164
+ @click.group(
165
+ "terminal",
166
+ help=(
167
+ "Persistent, fleet-wide plain shell sessions hosted in tmux "
168
+ "(coord-term-* named sessions, #952). Distinct from `coord sessions` "
169
+ "(interactive claude/assignment sessions) — these carry no "
170
+ "issue/repo/assignment and never enter the board or pipeline."
171
+ ),
172
+ )
173
+ def terminal_group() -> None:
174
+ pass
175
+
176
+
177
+ @terminal_group.command(
178
+ "new",
179
+ help=(
180
+ "Create a persistent shell on MACHINE (default: local). "
181
+ "Runs `tmux new-session -d` locally, or over ssh on a fleet machine."
182
+ ),
183
+ )
184
+ @click.argument("machine", required=False, default=None)
185
+ @click.option(
186
+ "--name", "name_opt", default=None,
187
+ help="Slug for the session (auto-generated when omitted).",
188
+ )
189
+ @_CONFIG_OPTION
190
+ def terminal_new(machine: str | None, name_opt: str | None, config_path: Path) -> None:
191
+ if not tmux_available():
192
+ click.echo("error: tmux is not available on this machine.", err=True)
193
+ sys.exit(1)
194
+
195
+ host, resolved_machine = _resolve_machine_host(machine, config_path)
196
+
197
+ slug = name_opt
198
+ if slug is None:
199
+ # Auto-generate, retrying a handful of times on an (unlikely)
200
+ # collision with an existing session on the same host.
201
+ for _ in range(5):
202
+ candidate = generate_slug()
203
+ if not tmux_session_alive(terminal_session_name(candidate), host=host):
204
+ slug = candidate
205
+ break
206
+ else:
207
+ slug = generate_slug()
208
+ elif tmux_session_alive(terminal_session_name(slug), host=host):
209
+ where = f" on {resolved_machine}" if resolved_machine else ""
210
+ click.echo(f"error: a terminal named {slug!r} already exists{where}.", err=True)
211
+ sys.exit(1)
212
+
213
+ sname = terminal_session_name(slug)
214
+ try:
215
+ result = subprocess.run(
216
+ host.cmd(["new-session", "-d", "-s", sname]),
217
+ capture_output=True,
218
+ text=True,
219
+ timeout=15.0,
220
+ )
221
+ except (subprocess.SubprocessError, OSError) as exc:
222
+ click.echo(f"error: failed to create tmux session: {exc}", err=True)
223
+ sys.exit(1)
224
+ if result.returncode != 0:
225
+ click.echo(
226
+ f"error: tmux new-session failed: {(result.stderr or '').strip()}",
227
+ err=True,
228
+ )
229
+ sys.exit(1)
230
+
231
+ where = resolved_machine or "local"
232
+ target = f"{resolved_machine}:{slug}" if resolved_machine else slug
233
+ click.echo(f"Created terminal '{slug}' on {where}.")
234
+ click.echo(f" attach with: coord terminal attach {target}")
235
+
236
+
237
+ @terminal_group.command(
238
+ "list",
239
+ help=(
240
+ "List persistent coord-term-* terminal sessions. "
241
+ "Use --json for machine-readable output (consumed by coord-tui)."
242
+ ),
243
+ )
244
+ @click.option(
245
+ "--json", "output_json", is_flag=True, default=False,
246
+ help="Output as JSON.",
247
+ )
248
+ @click.option(
249
+ "--remote", is_flag=True, default=False,
250
+ help="Also sweep every fleet machine over ssh (parallelised, bounded per-host probe).",
251
+ )
252
+ @_CONFIG_OPTION
253
+ def terminal_list(output_json: bool, remote: bool, config_path: Path) -> None:
254
+ try:
255
+ cfg = _load_config(config_path)
256
+ except Exception: # noqa: BLE001
257
+ cfg = None
258
+
259
+ local_hn = _local_short_hostname()
260
+ local_machine_name = local_hn
261
+ local_host_str = local_hn
262
+ if cfg is not None:
263
+ m = next(
264
+ (mm for mm in cfg.machines if _is_local_machine(mm.name, mm.host)), None
265
+ )
266
+ if m is not None:
267
+ local_machine_name = m.name
268
+ local_host_str = m.host
269
+
270
+ entries: list[dict] = [
271
+ {**s, "machine": local_machine_name, "host": local_host_str}
272
+ for s in list_tmux_terminal_sessions(host=TmuxHost(None))
273
+ ]
274
+
275
+ if remote and cfg is not None:
276
+ remotes = [
277
+ m for m in cfg.machines if not _is_local_machine(m.name, m.host)
278
+ ]
279
+ if remotes:
280
+ import concurrent.futures as _cf # noqa: PLC0415
281
+
282
+ def _probe(machine: object) -> tuple[object, list[dict]]:
283
+ try:
284
+ found = list_tmux_terminal_sessions(
285
+ host=TmuxHost(ssh_target=machine.host, batch=True) # type: ignore[attr-defined]
286
+ )
287
+ return machine, found
288
+ except Exception: # noqa: BLE001
289
+ return machine, []
290
+
291
+ with _cf.ThreadPoolExecutor(max_workers=min(8, len(remotes))) as ex:
292
+ for machine, found in ex.map(_probe, remotes):
293
+ for s in found:
294
+ entries.append(
295
+ {**s, "machine": machine.name, "host": machine.host} # type: ignore[attr-defined]
296
+ )
297
+
298
+ if output_json:
299
+ click.echo(_json.dumps(entries))
300
+ return
301
+
302
+ if not entries:
303
+ click.echo("No persistent terminal sessions.")
304
+ return
305
+
306
+ for e in entries:
307
+ attached_tag = " [attached]" if e.get("attached") else ""
308
+ target = f"{e['machine']}:{e['name']}"
309
+ click.echo(f" {target}{attached_tag}")
310
+ click.echo(f" attach with: coord terminal attach {target}")
311
+ click.echo(f" kill with: coord terminal kill {target}")
312
+
313
+
314
+ @terminal_group.command(
315
+ "kill",
316
+ help="Kill a persistent terminal session. TARGET is name or machine:name.",
317
+ )
318
+ @click.argument("target")
319
+ @_CONFIG_OPTION
320
+ def terminal_kill(target: str, config_path: Path) -> None:
321
+ machine, name = parse_machine_qualified_name(target)
322
+ host, resolved_machine = _resolve_machine_host(machine, config_path)
323
+ sname = terminal_session_name(name)
324
+
325
+ if not tmux_session_alive(sname, host=host):
326
+ where = f" on {resolved_machine}" if resolved_machine else ""
327
+ click.echo(f"error: no terminal named {name!r} found{where}.", err=True)
328
+ sys.exit(1)
329
+
330
+ try:
331
+ result = subprocess.run(
332
+ host.cmd(["kill-session", "-t", sname]),
333
+ capture_output=True,
334
+ text=True,
335
+ timeout=10.0,
336
+ )
337
+ except (subprocess.SubprocessError, OSError) as exc:
338
+ click.echo(f"error: failed to kill tmux session: {exc}", err=True)
339
+ sys.exit(1)
340
+ if result.returncode != 0:
341
+ click.echo(
342
+ f"error: tmux kill-session failed: {(result.stderr or '').strip()}",
343
+ err=True,
344
+ )
345
+ sys.exit(1)
346
+
347
+ where = f" on {resolved_machine}" if resolved_machine else ""
348
+ click.echo(f"Killed terminal '{name}'{where}.")
349
+
350
+
351
+ @terminal_group.command(
352
+ "attach",
353
+ help=(
354
+ "Attach to a persistent terminal session. TARGET is name or "
355
+ "machine:name. Type this into a local PTY exactly as `coord "
356
+ "reattach` is used for claude sessions."
357
+ ),
358
+ )
359
+ @click.argument("target")
360
+ @_CONFIG_OPTION
361
+ def terminal_attach(target: str, config_path: Path) -> None:
362
+ import os # noqa: PLC0415
363
+
364
+ machine, name = parse_machine_qualified_name(target)
365
+ host, resolved_machine = _resolve_machine_host(machine, config_path)
366
+ sname = terminal_session_name(name)
367
+
368
+ if not tmux_session_alive(sname, host=host):
369
+ where = f" on {resolved_machine}" if resolved_machine else ""
370
+ click.echo(f"error: no terminal named {name!r} found{where}.", err=True)
371
+ sys.exit(1)
372
+
373
+ if host.ssh_target is None and os.environ.get("TMUX"):
374
+ # Already inside a tmux client — attach-session refuses to nest;
375
+ # switch-client moves the current client into the target session.
376
+ cmd = ["tmux", "switch-client", "-t", sname]
377
+ else:
378
+ cmd = list(host.cmd(["attach-session", "-t", sname], tty=True))
379
+
380
+ try:
381
+ result = subprocess.run(cmd)
382
+ except (subprocess.SubprocessError, OSError) as exc:
383
+ click.echo(f"error: failed to attach: {exc}", err=True)
384
+ sys.exit(1)
385
+ sys.exit(result.returncode)