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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- 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,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
|
+
}
|