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,382 @@
1
+ """PTY <-> WebSocket terminal bridge (#1065).
2
+
3
+ Human-attended attach to a live ``coord-<assignment_id>`` tmux session from
4
+ the phone dashboard. This module is deliberately split from
5
+ ``coord/dashboard/server.py`` so the ssh/tmux spawn machinery — the part that
6
+ needs a real terminal and can't run in CI — sits behind a small,
7
+ independently-testable seam (:class:`SessionAttacher`).
8
+
9
+ ToS §3.7 / #437: this bridge only ever relays a *live human* — keystrokes in,
10
+ PTY output out, plus resize. It performs no autonomous injection, scraping,
11
+ or decision-making of its own.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import os
18
+ import socket
19
+ import struct
20
+ import subprocess
21
+ from pathlib import Path
22
+ from typing import Protocol
23
+
24
+ from coord.config import Config
25
+ from coord.interactive import TmuxHost, tmux_session_name
26
+ from coord.models import Board
27
+
28
+ # Server-side bearer token sources for `coord web`, in precedence order.
29
+ # Mirrors coord.serve_app.resolve_serve_token()/SERVE_TOKEN_ENV/SERVE_TOKEN_FILE
30
+ # exactly, but kept distinct (COORD_WEB_TOKEN / web_token) so a box running
31
+ # both `coord serve` and `coord web` doesn't share -- or accidentally leak --
32
+ # one secret to the other's surface.
33
+ WEB_TOKEN_ENV = "COORD_WEB_TOKEN"
34
+ WEB_TOKEN_FILE = Path.home() / ".coord" / "web_token"
35
+
36
+
37
+ def resolve_web_token(flag_token: str | None = None) -> str | None:
38
+ """Resolve `coord web`'s bearer token: flag > ``COORD_WEB_TOKEN`` > file.
39
+
40
+ Returns ``None`` when none is configured -- the dashboard (and the
41
+ ``/ws/terminal`` bridge specifically) then runs open, same convention as
42
+ :func:`coord.serve_app.resolve_serve_token`. A blank/whitespace token is
43
+ treated as unset.
44
+ """
45
+ for src in (flag_token, os.environ.get(WEB_TOKEN_ENV)):
46
+ if src and src.strip():
47
+ return src.strip()
48
+ if WEB_TOKEN_FILE.exists():
49
+ try:
50
+ from_file = WEB_TOKEN_FILE.read_text().strip()
51
+ except OSError:
52
+ from_file = ""
53
+ if from_file:
54
+ return from_file
55
+ return None
56
+
57
+
58
+ def _local_short_hostname() -> str:
59
+ """Same two-line idiom used inline in coord/interactive.py and
60
+ coord/commands/sessions.py -- there is no shared public helper for it."""
61
+ return socket.gethostname().split(".")[0].lower()
62
+
63
+
64
+ def resolve_session_target(
65
+ session_id: str, board: Board, config: Config
66
+ ) -> tuple[str | None, str] | None:
67
+ """Resolve *session_id* (== assignment_id) to ``(ssh_host, tmux_name)``.
68
+
69
+ ``ssh_host`` is ``None`` when the session lives on this machine (attach
70
+ locally); otherwise it is the target machine's ``host`` for an ssh
71
+ attach, matching the fleet's named-tmux model (#486/#487).
72
+
73
+ This is an interim substitute for the not-yet-built ``GET /api/sessions``
74
+ endpoint (a sibling issue in the #1064 epic) -- it reads the board
75
+ directly instead of going through that API. Whoever builds
76
+ ``/api/sessions`` should consider routing this resolver through it so the
77
+ two paths can't drift.
78
+
79
+ Returns ``None`` when *session_id* doesn't match any active assignment.
80
+ """
81
+ assignment = next(
82
+ (a for a in board.active if a.assignment_id == session_id), None
83
+ )
84
+ if assignment is None:
85
+ return None
86
+
87
+ session_name = tmux_session_name(session_id)
88
+ machine_name = assignment.machine_name
89
+ if not machine_name:
90
+ return None, session_name
91
+
92
+ machine = next((m for m in config.machines if m.name == machine_name), None)
93
+ if machine is None:
94
+ return None, session_name
95
+
96
+ local_hn = _local_short_hostname()
97
+ is_local = (
98
+ machine.name.lower() == local_hn
99
+ or machine.host.split(".")[0].lower() == local_hn
100
+ )
101
+ return (None if is_local else machine.host), session_name
102
+
103
+
104
+ class AttachedPty(Protocol):
105
+ """One live PTY-backed attach -- the seam the WS handler drives.
106
+
107
+ Fakeable in tests so the acceptance test needs no real ssh/tmux (#1065).
108
+ """
109
+
110
+ async def read(self) -> bytes:
111
+ """Return the next chunk of PTY output, or ``b""`` on EOF/closed."""
112
+ ...
113
+
114
+ def write(self, data: bytes) -> None:
115
+ """Write browser keystrokes to the PTY (stdin of the attached process)."""
116
+ ...
117
+
118
+ def resize(self, cols: int, rows: int) -> None:
119
+ """Propagate a terminal resize to the PTY (``TIOCSWINSZ``)."""
120
+ ...
121
+
122
+ def detach(self) -> None:
123
+ """End *this* client's attach. MUST NOT kill the tmux session."""
124
+ ...
125
+
126
+ async def copy_mode(self, action: str) -> None:
127
+ """Drive tmux copy-mode server-side via ``tmux send-keys -X`` (#1299).
128
+
129
+ This is prefix- and mode-key-agnostic: ``send-keys -X`` targets the
130
+ tmux *server* directly, so the client does not need to know the user's
131
+ ``prefix`` binding or whether ``mode-keys`` is set to ``vi`` or
132
+ ``emacs``.
133
+
134
+ ToS §3.7 / #437: every action here is a *human tapping a button* in
135
+ their own attached session. No autonomous injection, scraping, or
136
+ decision-making -- same guarantee as the ``write`` relay path.
137
+
138
+ Supported *action* values::
139
+
140
+ "enter" -- enter copy-mode (``copy-mode -t <session>``)
141
+ "exit" -- leave copy-mode (``send-keys -X cancel``)
142
+ "page-up" -- scroll a page up
143
+ "page-down" -- scroll a page down
144
+ "top" -- jump to oldest history line
145
+ "bottom" -- jump to newest line (live pane)
146
+
147
+ Unknown or unmapped *action* values are silently ignored -- never
148
+ raise.
149
+ """
150
+ ...
151
+
152
+
153
+ class SessionAttacher(Protocol):
154
+ """Seam over the ssh/tmux attach spawn (#1065 acceptance: injectable so
155
+ tests need no real ssh)."""
156
+
157
+ async def attach(self, host: str | None, session_name: str) -> AttachedPty:
158
+ ...
159
+
160
+
161
+ class TmuxSessionAttacher:
162
+ """Real :class:`SessionAttacher`: spawns ``tmux attach-session`` (local)
163
+ or ``ssh <host> -tt tmux attach-session`` (remote) behind a PTY.
164
+
165
+ Ending the resulting :class:`AttachedPty` (:meth:`AttachedPty.detach`)
166
+ only terminates this *local* attach/ssh client process -- the tmux
167
+ session, and whatever is running inside its pane (typically ``claude``),
168
+ are unaffected. That is exactly the "detach, don't kill" guarantee
169
+ ``coord.interactive._launch_via_tmux`` already relies on: a tmux session
170
+ outlives any one attached client.
171
+ """
172
+
173
+ async def attach(self, host: str | None, session_name: str) -> AttachedPty:
174
+ import fcntl # stdlib, Unix-only -- deferred for platform safety
175
+ import pty # stdlib, Unix-only -- deferred for platform safety
176
+ import termios # stdlib, Unix-only -- deferred for platform safety
177
+
178
+ host_obj = TmuxHost(ssh_target=host)
179
+
180
+ # ── Resize-to-client hardening ────────────────────────────────────
181
+ # Without these two steps, attaching from a phone causes a visible
182
+ # resize glitch: the PTY defaults to 80×24, ``tmux attach-session``
183
+ # sees an 80×24 client and immediately shrinks the running window
184
+ # (and the claude process inside it) *before* the browser's first
185
+ # resize frame arrives.
186
+ #
187
+ # Fix 1: query the session's current window size and prime the PTY
188
+ # at that size so ``tmux attach-session`` registers us as a client
189
+ # that already matches the window — no downsize on connect.
190
+ # The browser's first WS resize frame (sent from onopen) will arrive
191
+ # within milliseconds and TIOCSWINSZ the PTY to the phone's real
192
+ # viewport, which tmux then propagates via SIGWINCH.
193
+ # Both calls below are blocking (each can be a real ssh round-trip
194
+ # for a remote host, worst case the full 5s timeout) and this
195
+ # `attach()` runs directly on the dashboard's single asyncio event
196
+ # loop (awaited from the WS route handler in server.py) -- calling
197
+ # `subprocess.run` here would stall every other connection the
198
+ # dashboard is serving (other attached terminals, other websockets,
199
+ # REST endpoints) for up to ~10s. Route them through the loop's
200
+ # executor, matching the existing pattern (server.py's
201
+ # `loop.run_in_executor(None, _fetch_agent_status, ...)`) instead of
202
+ # blocking inline.
203
+ loop = asyncio.get_running_loop()
204
+
205
+ initial_cols, initial_rows = 80, 24 # safe fallback
206
+ try:
207
+ result = await loop.run_in_executor(
208
+ None,
209
+ lambda: subprocess.run(
210
+ host_obj.cmd(
211
+ [
212
+ "display-message",
213
+ "-t",
214
+ session_name,
215
+ "-p",
216
+ "#{window_width} #{window_height}",
217
+ ]
218
+ ),
219
+ capture_output=True,
220
+ text=True,
221
+ timeout=5,
222
+ ),
223
+ )
224
+ parts = result.stdout.strip().split()
225
+ if len(parts) == 2:
226
+ w, h = int(parts[0]), int(parts[1])
227
+ if w > 0 and h > 0:
228
+ initial_cols, initial_rows = w, h
229
+ except Exception:
230
+ pass
231
+
232
+ # Fix 2: enable aggressive-resize for this window so that when
233
+ # TIOCSWINSZ fires with the phone's viewport, the tmux server adopts
234
+ # the new (sole) client's size rather than remaining pinned to a
235
+ # previously-attached smaller client's footprint.
236
+ try:
237
+ await loop.run_in_executor(
238
+ None,
239
+ lambda: subprocess.run(
240
+ host_obj.cmd(
241
+ [
242
+ "set-window-option",
243
+ "-t",
244
+ session_name,
245
+ "aggressive-resize",
246
+ "on",
247
+ ]
248
+ ),
249
+ capture_output=True,
250
+ timeout=5,
251
+ ),
252
+ )
253
+ except Exception:
254
+ pass
255
+
256
+ master_fd, slave_fd = pty.openpty()
257
+
258
+ # Prime the PTY at the session's current window size. tmux reads the
259
+ # client's PTY dimensions when the attach handshake completes; a
260
+ # correctly-sized PTY prevents the attach from triggering a downsize.
261
+ try:
262
+ fcntl.ioctl(
263
+ master_fd,
264
+ termios.TIOCSWINSZ,
265
+ struct.pack("HHHH", initial_rows, initial_cols, 0, 0),
266
+ )
267
+ except OSError:
268
+ pass
269
+
270
+ argv = host_obj.cmd(["attach-session", "-t", session_name], tty=True)
271
+ # ``TERM`` may be absent when ``coord web`` runs as a systemd user
272
+ # service (no controlling TTY, no ``Environment=TERM=...`` in the unit
273
+ # file). Without it, anything inside the attached pane that probes
274
+ # terminal capabilities (e.g. ``claude`` on start) fails with "terminal
275
+ # does not support clear" (#1229). Mirror the same guard already used
276
+ # in ``coord/agent.py`` for interactive-launch subprocesses.
277
+ env = dict(os.environ)
278
+ env.setdefault("TERM", "xterm-256color")
279
+ try:
280
+ proc = subprocess.Popen(
281
+ argv,
282
+ stdin=slave_fd,
283
+ stdout=slave_fd,
284
+ stderr=slave_fd,
285
+ preexec_fn=os.setsid,
286
+ close_fds=True,
287
+ env=env,
288
+ )
289
+ finally:
290
+ os.close(slave_fd)
291
+ return _RealAttachedPty(proc, master_fd, host_obj=host_obj, session_name=session_name)
292
+
293
+
294
+ # Maps the wire ``action`` value from a copy-mode control frame to the tmux
295
+ # argument list (the ``-t <session_name>`` target is inserted at call time so
296
+ # the mapping itself stays tidy and session-agnostic).
297
+ _COPY_MODE_TMUX_ARGS: dict[str, list[str]] = {
298
+ "enter": ["copy-mode"],
299
+ "exit": ["send-keys", "-X", "cancel"],
300
+ "page-up": ["send-keys", "-X", "page-up"],
301
+ "page-down": ["send-keys", "-X", "page-down"],
302
+ "top": ["send-keys", "-X", "history-top"],
303
+ "bottom": ["send-keys", "-X", "history-bottom"],
304
+ }
305
+
306
+
307
+ class _RealAttachedPty:
308
+ def __init__(
309
+ self,
310
+ proc: subprocess.Popen,
311
+ master_fd: int,
312
+ *,
313
+ host_obj: "TmuxHost",
314
+ session_name: str,
315
+ ) -> None:
316
+ self._proc = proc
317
+ self._master_fd = master_fd
318
+ self._host_obj = host_obj
319
+ self._session_name = session_name
320
+
321
+ async def read(self) -> bytes:
322
+ loop = asyncio.get_running_loop()
323
+ try:
324
+ return await loop.run_in_executor(None, os.read, self._master_fd, 65536)
325
+ except OSError:
326
+ return b""
327
+
328
+ def write(self, data: bytes) -> None:
329
+ try:
330
+ os.write(self._master_fd, data)
331
+ except OSError:
332
+ pass
333
+
334
+ def resize(self, cols: int, rows: int) -> None:
335
+ import fcntl # stdlib, Unix-only -- deferred for platform safety
336
+ import termios # stdlib, Unix-only -- deferred for platform safety
337
+
338
+ try:
339
+ fcntl.ioctl(
340
+ self._master_fd,
341
+ termios.TIOCSWINSZ,
342
+ struct.pack("HHHH", rows, cols, 0, 0),
343
+ )
344
+ except OSError:
345
+ pass
346
+
347
+ def detach(self) -> None:
348
+ # Kill only the local attach/ssh client -- see class docstring above.
349
+ try:
350
+ self._proc.terminate()
351
+ except OSError:
352
+ pass
353
+ try:
354
+ os.close(self._master_fd)
355
+ except OSError:
356
+ pass
357
+
358
+ async def copy_mode(self, action: str) -> None:
359
+ """See :meth:`AttachedPty.copy_mode`."""
360
+ tmux_fragment = _COPY_MODE_TMUX_ARGS.get(action)
361
+ if tmux_fragment is None:
362
+ return # unknown action -- no-op, never raise
363
+
364
+ # Insert the target flag immediately after the tmux sub-command so the
365
+ # command reads: ``copy-mode -t <session>`` or
366
+ # ``send-keys -t <session> -X <action>``.
367
+ sub_cmd = tmux_fragment[0]
368
+ rest = tmux_fragment[1:]
369
+ tmux_args = [sub_cmd, "-t", self._session_name, *rest]
370
+
371
+ loop = asyncio.get_running_loop()
372
+ try:
373
+ await loop.run_in_executor(
374
+ None,
375
+ lambda: subprocess.run(
376
+ self._host_obj.cmd(tmux_args),
377
+ capture_output=True,
378
+ timeout=5,
379
+ ),
380
+ )
381
+ except Exception:
382
+ pass # best-effort; a stale copy-mode state is not fatal
@@ -0,0 +1,9 @@
1
+ node_modules
2
+ dist
3
+ dist-ssr
4
+ *.local
5
+ .env
6
+ .env.local
7
+ # Playwright failure artifacts (screenshots, traces)
8
+ test-results/
9
+ playwright-report/
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "default",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "tailwind.config.js",
8
+ "css": "src/index.css",
9
+ "baseColor": "slate",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils"
16
+ }
17
+ }