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,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)
|