feat(terminal): attach to foreign tmux sessions + plan direction fix
The maintainer flagged two divergences between the external review's prioritization and the actual product direction (2026-04-25): 1. #29 diff-centric review/apply was abandoned when the agent UI pivoted from chat-with-diff to tmux session passthrough — agents run in a tmux pane, edit remote files directly, and Sessions's job is multi-session lifecycle (spawn/switch/kill), not diff orchestration. The diff primitives that survived the pivot — `agent_proposal_watcher` (290 LOC), `agent_change_badge` (248 LOC) — are dead code from the abandoned design. 2. The persistent-terminal flow always opens `sessions-term-<host>`, so a user with their own `tmux new-session -A -s work` on the remote can't reach it via the palette. The "single Sessions-owned tmux session per host" model is too narrow. Plus environment constraints: cross-platform CI runners aren't available; code-signing budget (~$600/yr) is out of scope. Code: Sessions: Attach to Tmux Session - New `list_all_remote_tmux_sessions` in `terminal_tmux_session.py`, sibling of `list_terminal_sessions` but without the SESSION_NAME_PREFIX filter — returns Sessions-owned sessions alongside foreign ones. - New `SessionsAttachRemoteTmuxCommand` in `commands.py` + companion `_attach_remote_tmux_session` helper that opens a Terminus pane via `ssh -tt <alias> tmux attach-session -t <name>`. Read-only attach semantics: foreign sessions never enter the Sessions-owned per-host / per-session view caches (so kill / new-pane flows can never reach into a foreign session by accident). - Quick panel rows distinguish `Sessions-owned` vs `foreign` tmux sessions in the description column so the user knows what they're attaching to. - Existing `Sessions: Open Remote Terminal` / `New Remote Terminal Pane` / `Kill Remote Terminal` stay scoped to `sessions-term-*` unchanged. - Wired through `plugin.py` import + `__all__`; entrypoint smoke + runtime-import smoke updated; new palette entry in `Sessions.sublime-commands`. Tests - 6 new tests for `list_all_remote_tmux_sessions` (no-filter, empty-server, missing-tmux, timeout, oserror, blank-line stripping). - 5 new tests for the attach command (palette listing including foreign sessions, terminus_open argv shape, foreign-session does NOT register in the Sessions-owned caches, empty-list status hint, tmux-missing status hint). - Entrypoint + runtime-import smoke tests updated for the new export. - 1488 sublime tests pass (was 1477). Plan: planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md - New "Direction correction (post-review, 2026-04-25)" section documents the chat→tmux pivot + the dead diff modules + the blocked environment constraints. - §2.1 was "#29 diff-centric MVP, highest priority" → replaced with "Tmux session discovery + attach to foreign sessions" (this commit's feature). #29 moves to "Items DEPRIORITIZED / dropped". - §1.4 cross-platform smoke + signing → `[blocked-by-environment]` with documented "feasible without runners/certs" guidance. - §1.1 updated to reflect Linux-only first iteration is feasible now; full matrix waits on §1.4. - New "Code to consider retiring" section catalogues `agent_proposal_watcher.py` + `agent_change_badge.py` + their tests as removal candidates pending maintainer signoff. - "Open questions" surfaces three decision points: retire-or-archive the dead diff modules, ship Linux-only sublime-package now or hold, and Windows W1 priority given the macOS-primary workflow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,53 @@ without re-reading the full review.
|
||||
> features; ownership migration (not helper migration) is the
|
||||
> correct architectural next step.
|
||||
|
||||
## Direction correction (post-review, 2026-04-25)
|
||||
|
||||
The review took repo state as ground truth and ranked features
|
||||
accordingly. After re-reading, the maintainer flagged two divergences
|
||||
between the review's prioritization and the actual product direction:
|
||||
|
||||
1. **#29 diff-centric review/apply is abandoned, not "the most
|
||||
important open feature".** The pivot from chat-style agent UI
|
||||
("show the proposed diff inline, user approves hunks") to
|
||||
tmux-session-passthrough ("agent runs in a tmux pane, edits the
|
||||
remote files directly, Sessions just owns layout + lifecycle")
|
||||
was an explicit product decision: drop diff review, keep
|
||||
multi-session. The diff-centric primitives that survived the
|
||||
pivot — `agent_proposal_watcher` (290 LOC, unified diff parser),
|
||||
`agent_change_badge` (248 LOC, post-apply phantom renderer) —
|
||||
are dead code from the abandoned design. Issue #29 stays open
|
||||
on the tracker but is **not** the product's next feature.
|
||||
|
||||
2. **The persistent-terminal flow always opens
|
||||
`sessions-term-<host>`**, which means a user with their own
|
||||
long-lived `tmux new-session -A -s work` on the remote can't
|
||||
reach it via the palette. The current "single Sessions-owned
|
||||
tmux session per host" model is too narrow — discovery + attach
|
||||
for foreign tmux sessions is missing.
|
||||
|
||||
Environment constraints the maintainer has flagged as binding:
|
||||
|
||||
- **No cross-platform CI runners.** §1.4 macOS / Windows smoke CI
|
||||
is not feasible in the current Gitea Actions environment.
|
||||
- **No code-signing budget.** Apple Developer Program + Windows EV
|
||||
cert (~$600/yr combined) is out of scope right now.
|
||||
|
||||
Effects on this plan:
|
||||
|
||||
- §2.1 (was #29 diff-centric MVP) → moved to "Items
|
||||
DEPRIORITIZED / dropped" with the rationale above.
|
||||
- New §2.1 → tmux session discovery + attach (the actual gap the
|
||||
maintainer is feeling).
|
||||
- `agent_proposal_watcher.py` and `agent_change_badge.py` →
|
||||
marked as removal candidates in a new "code to consider
|
||||
retiring" section. Don't delete unilaterally; needs maintainer
|
||||
signoff.
|
||||
- §1.4 cross-platform smoke CI + signing → marked
|
||||
`[blocked-by-environment]` until runners + cert budget exist.
|
||||
- §1.1 per-platform sublime-package → same: Linux-only is
|
||||
feasible now if desired, full matrix is blocked.
|
||||
|
||||
## Status legend
|
||||
|
||||
- `[done @ <commit>]` — landed, commit ref noted.
|
||||
@@ -27,35 +74,38 @@ without re-reading the full review.
|
||||
|
||||
## Layer 1 — Five must-haves before broad distribution
|
||||
|
||||
### 1.1 `Sessions.sublime-package` + per-platform binary bundles `[plan]`
|
||||
### 1.1 `Sessions.sublime-package` install bundle `[plan]` (Linux only feasible now)
|
||||
|
||||
**Review:** "지금 상태는 설치 경험이 너무 개발자 중심입니다." A normal
|
||||
user shouldn't have to `git clone + cargo build`. Ship a single-file
|
||||
install (`Sessions-<platform>.sublime-package`) per platform, drop into
|
||||
`Packages/` and done.
|
||||
install (`Sessions-<platform>.sublime-package`) per platform.
|
||||
|
||||
**State today:** `scripts/build_sublime_package.py` already builds the
|
||||
package and can bundle the platform-tagged Rust binaries
|
||||
(`--bundle-built-rust-binaries --rust-platform-tag <tag>`). Two pieces
|
||||
missing:
|
||||
package + can bundle platform-tagged Rust binaries
|
||||
(`--bundle-built-rust-binaries --rust-platform-tag <tag>`). Three
|
||||
pieces missing:
|
||||
1. CI wiring — workflow doesn't call `build_sublime_package.py` yet.
|
||||
2. Signing alignment — `sign_release_artifacts.py` only knows about
|
||||
binaries (`ARTIFACT_CANDIDATES`); it needs to include the
|
||||
`.sublime-package` so SHA256SUMS covers it and users can verify
|
||||
the install bundle through the same `gpg --verify` flow.
|
||||
3. Cross-platform matrix — only `ubuntu-latest` runs CI today; macOS
|
||||
arm64 + Windows x86_64 packages depend on §1.4.
|
||||
binaries (`ARTIFACT_CANDIDATES`); needs an `--extra-asset` so the
|
||||
`.sublime-package` joins SHA256SUMS and users can verify the
|
||||
install bundle through the same `gpg --verify` flow.
|
||||
3. Cross-platform matrix → blocked by §1.4.
|
||||
|
||||
**Acceptance:**
|
||||
- `[plan]` near-term: extend `sign_release_artifacts.py` to accept
|
||||
additional inputs (`--extra-asset`) so `Sessions-linux-x86_64.sublime-package`
|
||||
joins SHA256SUMS, then add a CI step that runs
|
||||
`build_sublime_package.py --bundle-built-rust-binaries
|
||||
**Acceptance (Linux-only first iteration, feasible now):**
|
||||
- Extend `sign_release_artifacts.py` to accept additional inputs
|
||||
(`--extra-asset`), include them in SHA256SUMS.
|
||||
- Add CI steps between the release-workspace build and the sign
|
||||
step: `build_sublime_package.py --bundle-built-rust-binaries
|
||||
--rust-platform-tag linux-x86_64 --output
|
||||
dist/v$VER/Sessions-linux-x86_64.sublime-package` between the
|
||||
release-workspace build and the sign step.
|
||||
- `[plan]` longer-term: macOS arm64 + Windows x86_64 packages once
|
||||
§1.4 lands the cross-platform build matrix.
|
||||
dist/v$VER/Sessions-linux-x86_64.sublime-package` →
|
||||
`sign_release_artifacts.py --extra-asset Sessions-linux-x86_64.sublime-package`
|
||||
→ existing `create_gitea_release.py` picks it up automatically
|
||||
from `dist/v$VER/`.
|
||||
- README "install" section gains a short "Linux: drop the
|
||||
`.sublime-package` into `~/.config/sublime-text/Installed Packages/`"
|
||||
path alongside the existing dev-checkout path.
|
||||
|
||||
**macOS / Windows packages:** wait on §1.4 unblock.
|
||||
|
||||
### 1.2 Safe-by-default profile (defaults flip) `[plan]`
|
||||
|
||||
@@ -103,7 +153,7 @@ visibility for each known palette caption under the four
|
||||
setting-combination matrices. `SECURITY.md` gains an appendix listing
|
||||
every remote install command + what it touches.
|
||||
|
||||
### 1.4 macOS / Windows smoke CI + platform code signing `[plan]` + `[needs-input]`
|
||||
### 1.4 macOS / Windows smoke CI + platform code signing `[blocked-by-environment]`
|
||||
|
||||
**Review:** "README는 Linux/macOS/Windows 지원으로 적지만, CI는 모든
|
||||
핵심 job이 ubuntu-latest에서 돌고... broad distribution 전에는 최소한
|
||||
@@ -111,21 +161,29 @@ macOS + Windows smoke CI가 필요합니다." Plus "binaries are currently
|
||||
unsigned" — broad release needs Apple Developer ID + Windows
|
||||
Authenticode.
|
||||
|
||||
**Proposal:**
|
||||
- **Smoke CI** `[plan]`: add `macos-latest` + `windows-latest` jobs
|
||||
that run cargo build + python compileall + import smoke. Skip the
|
||||
full pytest suite there (Ubuntu carries it).
|
||||
- **Code signing** `[needs-input]`: macOS notarization requires Apple
|
||||
Developer Program ($99/yr) + `xcrun notarytool` access; Windows EV
|
||||
cert ~$500/yr. Both pipelines lift credentials from CI secrets
|
||||
(master never on contributor workstations).
|
||||
**Status (2026-04-25 maintainer note):** Both blocked by environment,
|
||||
not by design or code:
|
||||
- The Gitea Actions setup running this repo doesn't have macOS or
|
||||
Windows runners. Adding them is an infrastructure decision outside
|
||||
this plan's reach.
|
||||
- Code-signing certs (~$600/yr) are not in budget.
|
||||
|
||||
**Acceptance:**
|
||||
**What is feasible without runners / certs:**
|
||||
- Local build verification: maintainers running on macOS / Windows
|
||||
can `cargo build` + `python -m compileall` + import smoke locally
|
||||
before tagging. Document this as a release-time manual gate in
|
||||
`planning/V0_6_5_REPRO.md` (or successor).
|
||||
- Document the "currently Linux-only signed bundle" reality in
|
||||
README + SECURITY.md so external users aren't surprised by the
|
||||
asset list.
|
||||
|
||||
**When unblocked:**
|
||||
- New `.gitea/workflows/cross-platform-smoke.yml` with matrix
|
||||
`[ubuntu-latest, macos-latest, windows-latest] × [build + smoke]`.
|
||||
Failures block merge.
|
||||
- Per-platform `Sessions-<platform>.sublime-package` published when
|
||||
signing pipelines exist.
|
||||
- Per-platform `Sessions-<platform>.sublime-package` published.
|
||||
- macOS notarization + Windows Authenticode signing pipelines lift
|
||||
credentials from CI secrets (master never on contributor
|
||||
workstations).
|
||||
|
||||
### 1.5 Stabilize release discipline `[plan]`
|
||||
|
||||
@@ -155,27 +213,37 @@ prominently. CI green before promotion is enforced by
|
||||
> Review: "breadth보다 완결성. #29와 #32가 그대로 열려 있는데 주변
|
||||
> 기능을 더 늘리면 제품 중심이 흐려질 가능성이 큽니다."
|
||||
|
||||
### 2.1 #29 diff-centric review/apply MVP `[plan]` — **highest priority**
|
||||
### 2.1 Tmux session discovery + attach to foreign sessions `[plan]` — **highest priority**
|
||||
|
||||
**State today:** Primitives exist — `agent_proposal_watcher.py`
|
||||
parses unified diff (pure Python, no Sublime), `agent_change_badge.py`
|
||||
renders post-apply phantoms, agent tmux/switcher land changes via
|
||||
`tmux pipe-pane`. None of it is wired into a coherent review surface.
|
||||
**Maintainer-flagged gap (2026-04-25):** "영구 terminal 구현을 위해서
|
||||
아예 default로 tmux를 열다보니까 기존 다른 tmux에 연결할 수 없는건
|
||||
단점." Today every terminal flow opens / attaches the
|
||||
Sessions-owned `sessions-term-<host>` (or numbered children); a user
|
||||
running their own `tmux new-session -A -s work` on the remote can't
|
||||
reach it via the palette.
|
||||
|
||||
**MVP scope (review-suggested):** `Agent Proposals` panel that lets
|
||||
the user step through hunks, with three actions only — `Apply` /
|
||||
`Reject` / `Open target file`. Then layer on:
|
||||
1. Stale base hash check (refuse Apply if remote bytes drifted).
|
||||
2. Path confinement (refuse if path escapes workspace root).
|
||||
3. Optional Claude-style hook callbacks for advanced approval flows.
|
||||
**Proposal:**
|
||||
- New palette command `Sessions: Attach to Tmux Session`. Lists ALL
|
||||
remote tmux sessions (no `sessions-term-*` filter — Sessions-owned
|
||||
show alongside foreign), opens a Terminus pane attached to the
|
||||
picked one via `ssh -tt <alias> tmux attach -t <session_name>`.
|
||||
Read-only attach; Sessions never tries to kill / write-back to a
|
||||
foreign session.
|
||||
- The existing "Sessions: Open Remote Terminal" / "New Remote
|
||||
Terminal Pane" / "Kill Remote Terminal" stay scoped to the
|
||||
`sessions-term-*` namespace (so users can still kill Sessions's
|
||||
own sessions without risk of clobbering a foreign one).
|
||||
- Optional follow-up: setting `sessions_terminal_default_tmux_session`
|
||||
to override the default `sessions-term-<alias>` → user names. If
|
||||
set, "Open Remote Terminal" attaches there instead. Out of scope
|
||||
for the first cut; revisit after the attach command lands.
|
||||
|
||||
**Out of MVP:** drag-to-reorder, per-row inline menus, multi-agent
|
||||
proposal merging.
|
||||
|
||||
**Acceptance:** New `agent_proposal_review.py` module + palette
|
||||
command `Sessions: Review Agent Proposals`. Tests cover hunk apply,
|
||||
hunk reject, stale-base-hash refusal, path-confinement refusal.
|
||||
Closes #29.
|
||||
**Acceptance:** New `SessionsAttachRemoteTmuxCommand` registered via
|
||||
`plugin.py`. New palette entry. New supporting function
|
||||
`list_all_remote_tmux_sessions(host_alias)` in `terminal_tmux_session.py`
|
||||
(parallel to the existing `list_remote_terminal_sessions` which
|
||||
filters to Sessions-owned). Tests cover: empty list, mixed
|
||||
foreign + Sessions-owned, error path, attach command argv shape.
|
||||
|
||||
### 2.2 #32 large-file streaming + cancel + finalize `[plan]`
|
||||
|
||||
@@ -471,9 +539,48 @@ Per review: "지금은 덜 급한 것":
|
||||
- Wrapper-level Rust migration that doesn't move ownership (§3.1 —
|
||||
the "ctypes 종합상가" anti-pattern).
|
||||
|
||||
Plus, dropped explicitly by maintainer (2026-04-25):
|
||||
|
||||
- **#29 diff-centric review/apply** — the chat→tmux pivot abandoned
|
||||
this design direction. Agents run in tmux panes and edit remote
|
||||
files directly; Sessions's job is multi-session lifecycle
|
||||
(spawn / switch / kill), not diff orchestration. The diff
|
||||
primitives that survived the pivot are dead code (see "Code to
|
||||
consider retiring" below). Issue #29 stays open on the tracker
|
||||
but is not the product's next feature.
|
||||
|
||||
If you find yourself drafting any of the above, pause and check
|
||||
this list first.
|
||||
|
||||
## Code to consider retiring (post-direction-correction)
|
||||
|
||||
Modules that supported the abandoned chat-with-diff agent design.
|
||||
Not auto-deleted — needs maintainer signoff. Listed here so the
|
||||
next time someone touches them they ask "is this still load-bearing?"
|
||||
before extending.
|
||||
|
||||
- `sublime/sessions/agent_proposal_watcher.py` (290 LOC) — pure
|
||||
Python unified-diff parser. Designed to tail `tmux pipe-pane`
|
||||
output and surface diff hunks for the never-shipped review
|
||||
panel. No live caller in the agent flow.
|
||||
- `sublime/sessions/agent_change_badge.py` (248 LOC,
|
||||
`AgentChangeBadgeRenderer`) — post-apply phantom badge for
|
||||
hunks the user accepted. Imported nowhere outside its own
|
||||
module + tests.
|
||||
- Their tests + adversarial tests under
|
||||
`sublime/tests/test_agent_proposal_watcher*.py` and
|
||||
`sublime/tests/test_agent_change_badge*.py`.
|
||||
|
||||
If retired, also drop the dangling reference comment in
|
||||
`agent_tmux.py:10` ("The companion `agent_proposal_watcher`
|
||||
module parses diff output tailed").
|
||||
|
||||
Decision needed: delete entirely (cleanest) vs. keep as a
|
||||
documented "experiment archive" under `planning/archive/` (preserves
|
||||
the work in case the diff direction comes back). My read: delete —
|
||||
git history preserves the work; carrying dead code increases
|
||||
cognitive overhead and test-suite weight.
|
||||
|
||||
---
|
||||
|
||||
## Already shipped from this batch
|
||||
@@ -494,11 +601,17 @@ this list first.
|
||||
|
||||
## Open questions
|
||||
|
||||
- §1.1 partial (Linux-only `Sessions.sublime-package` shipped now,
|
||||
macOS / Windows wait on §1.4) — is that acceptable as a partial
|
||||
improvement, or hold until full per-platform matrix exists?
|
||||
- §1.4 code-signing budget approval — Apple Developer Program +
|
||||
Windows EV cert combined ~$600/yr. `[needs-input]`.
|
||||
- §2.1 #29 MVP — does the user want the basic three-action
|
||||
(Apply/Reject/Open) review surface first, or invest directly in
|
||||
the stale-hash + path-confinement scope?
|
||||
- **Code to retire (`agent_proposal_watcher.py` +
|
||||
`agent_change_badge.py`)** — delete now, or move to
|
||||
`planning/archive/` as an experiment archive? My read: delete;
|
||||
git history preserves the work, carrying dead code costs
|
||||
cognitive overhead + test-suite weight. `[needs-input]`.
|
||||
- **§1.1 Linux-only sublime-package** — ship as a release asset on
|
||||
the next batch (extending `sign_release_artifacts.py` +
|
||||
CI wiring), or hold until macOS / Windows are unblocked? My read:
|
||||
ship Linux-only — concrete improvement for at-minimum the Linux
|
||||
user fraction, no rework cost when the matrix expands.
|
||||
- **Windows W1 PersistentBroker (§2.6)** — given the maintainer's
|
||||
current macOS-primary workflow, is Windows parity still on the
|
||||
roadmap? If not, drop §2.6 from this plan and update README's
|
||||
"platform support" claim. If yes, keep but with no date attached.
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
"caption": "Sessions: Kill Remote Terminal",
|
||||
"command": "sessions_kill_remote_terminal"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Attach to Tmux Session",
|
||||
"command": "sessions_attach_remote_tmux"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Preview Remote Agent Payload",
|
||||
"command": "sessions_preview_remote_agent_payload"
|
||||
|
||||
@@ -9,6 +9,7 @@ from .sessions.agent_window_layout import (
|
||||
SessionsAgentLayoutCommand,
|
||||
)
|
||||
from .sessions.commands import (
|
||||
SessionsAttachRemoteTmuxCommand,
|
||||
SessionsBridgeLifecycleListener,
|
||||
SessionsClearPythonInterpreterCommand,
|
||||
SessionsConnectRemoteWorkspaceCommand,
|
||||
@@ -55,6 +56,7 @@ __all__ = [
|
||||
"SessionsAgentLayoutCollapseSwitcherCommand",
|
||||
"SessionsAgentLayoutCommand",
|
||||
"SessionsAgentSwitcherClickListener",
|
||||
"SessionsAttachRemoteTmuxCommand",
|
||||
"SessionsBridgeLifecycleListener",
|
||||
"SessionsClearPythonInterpreterCommand",
|
||||
"SessionsConnectRemoteWorkspaceCommand",
|
||||
|
||||
@@ -182,9 +182,11 @@ from .ssh_runner import (
|
||||
)
|
||||
from .ssh_tool_runtime import execute_remote_tool_request
|
||||
from .terminal_tmux_session import (
|
||||
SESSION_NAME_PREFIX,
|
||||
TerminalTmuxSessionError,
|
||||
build_remote_tmux_invocation,
|
||||
kill_terminal_session,
|
||||
list_all_remote_tmux_sessions,
|
||||
list_terminal_sessions,
|
||||
next_terminal_session_name,
|
||||
probe_tmux_available,
|
||||
@@ -3900,6 +3902,134 @@ class SessionsKillRemoteTerminalCommand(sublime_plugin.WindowCommand):
|
||||
quick(items, on_select)
|
||||
|
||||
|
||||
class SessionsAttachRemoteTmuxCommand(sublime_plugin.WindowCommand):
|
||||
"""Attach a Terminus pane to **any** existing remote tmux session.
|
||||
|
||||
Sibling of :class:`SessionsOpenRemoteTerminalCommand`, but reaches
|
||||
across the ``sessions-term-*`` namespace boundary: lists every
|
||||
tmux session running on the workspace's host (foreign sessions
|
||||
the user started outside Sessions, plus Sessions-owned ones), and
|
||||
attaches a Terminus pane to whichever the user picks. Read-only
|
||||
attach — Sessions never tries to kill or write-back to a foreign
|
||||
session, so this command is safe to point at long-lived shells
|
||||
spun up by other tools.
|
||||
|
||||
Plugged in to address the maintainer-flagged gap (2026-04-25):
|
||||
"영구 terminal 구현을 위해서 아예 default로 tmux를 열다보니까 기존
|
||||
다른 tmux에 연결할 수 없는건 단점." The other terminal commands
|
||||
stay scoped to ``sessions-term-*`` so kill / new-pane semantics
|
||||
can never reach into a foreign session by accident.
|
||||
"""
|
||||
|
||||
def run(self) -> None:
|
||||
"""List remote tmux sessions and attach a Terminus pane to the pick."""
|
||||
settings = SessionsSettings()
|
||||
context = _workspace_context(self.window, settings)
|
||||
if context is None:
|
||||
return
|
||||
host_alias = context.recent_entry.host_alias
|
||||
if not _terminal_tmux_enabled_for_host(host_alias):
|
||||
_status_message(
|
||||
"Sessions: tmux is not available on {} — no remote tmux "
|
||||
"sessions to attach.".format(host_alias)
|
||||
)
|
||||
return
|
||||
|
||||
sessions = list_all_remote_tmux_sessions(host_alias)
|
||||
if not sessions:
|
||||
_status_message(
|
||||
"Sessions: no remote tmux sessions running on {}.".format(host_alias)
|
||||
)
|
||||
return
|
||||
|
||||
items = [
|
||||
[
|
||||
name,
|
||||
(
|
||||
"Sessions-owned tmux session on {}".format(host_alias)
|
||||
if name.startswith(SESSION_NAME_PREFIX)
|
||||
else "foreign tmux session on {}".format(host_alias)
|
||||
),
|
||||
]
|
||||
for name in sessions
|
||||
]
|
||||
window = self.window
|
||||
remote_root = context.recent_entry.remote_root
|
||||
|
||||
def on_select(choice: int) -> None:
|
||||
if choice < 0 or choice >= len(sessions):
|
||||
return
|
||||
target = sessions[choice]
|
||||
_attach_remote_tmux_session(
|
||||
window=window,
|
||||
host_alias=host_alias,
|
||||
remote_root=remote_root,
|
||||
session_name=target,
|
||||
)
|
||||
|
||||
quick = getattr(self.window, "show_quick_panel", None)
|
||||
if callable(quick):
|
||||
quick(items, on_select)
|
||||
|
||||
|
||||
def _attach_remote_tmux_session(
|
||||
*,
|
||||
window: object,
|
||||
host_alias: str,
|
||||
remote_root: str,
|
||||
session_name: str,
|
||||
) -> None:
|
||||
"""Open a Terminus pane attached to ``session_name`` via ``tmux attach``.
|
||||
|
||||
Uses ``ssh -tt`` so tmux gets a real PTY (it's an interactive
|
||||
shell on the user's side, unlike the spawn-time agent flow which
|
||||
is non-interactive). When Terminus isn't installed, falls back to
|
||||
Sublime's ``new_terminal`` command — same fallback pattern as
|
||||
:class:`SessionsOpenRemoteTerminalCommand`. The pane is not
|
||||
registered in the Sessions per-host view cache because foreign
|
||||
sessions are not part of the Sessions kill / lifecycle namespace.
|
||||
"""
|
||||
run_command = getattr(window, "run_command", None)
|
||||
if not callable(run_command):
|
||||
_status_message("No terminal command is available in this Sublime build.")
|
||||
return
|
||||
|
||||
has_terminus = False
|
||||
find_resources = getattr(sublime, "find_resources", None)
|
||||
if callable(find_resources):
|
||||
try:
|
||||
has_terminus = bool(find_resources("Terminus.sublime-settings"))
|
||||
except (TypeError, ValueError, RuntimeError):
|
||||
has_terminus = False
|
||||
|
||||
remote_invocation = "tmux attach-session -t {}".format(shlex.quote(session_name))
|
||||
if has_terminus:
|
||||
run_command(
|
||||
"terminus_open",
|
||||
{
|
||||
"cmd": ["ssh", "-tt", host_alias, remote_invocation],
|
||||
"cwd": str(remote_root),
|
||||
"show_in_panel": True,
|
||||
"panel_name": "Terminus",
|
||||
"auto_close": False,
|
||||
"title": "tmux: {} @ {}".format(session_name, host_alias),
|
||||
"focus": True,
|
||||
},
|
||||
)
|
||||
else:
|
||||
ssh_shell_cmd = "ssh -tt {} {}".format(
|
||||
shlex.quote(host_alias),
|
||||
shlex.quote(remote_invocation),
|
||||
)
|
||||
run_command(
|
||||
"new_terminal",
|
||||
{
|
||||
"cmd": ["bash", "-lc", ssh_shell_cmd],
|
||||
"title": "tmux: {} @ {}".format(session_name, host_alias),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _spawn_remote_terminal_pane(
|
||||
*,
|
||||
window: object,
|
||||
|
||||
@@ -156,6 +156,68 @@ def next_terminal_session_name(host_alias: str, existing_names: Iterable[str]) -
|
||||
return "{}-{}".format(base, candidate)
|
||||
|
||||
|
||||
def list_all_remote_tmux_sessions(
|
||||
host_alias: str,
|
||||
*,
|
||||
ssh_command_builder: Optional[Callable[[str], Sequence[str]]] = None,
|
||||
run: Optional[RunFn] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> List[str]:
|
||||
"""Return EVERY tmux session name on the remote, including foreign ones.
|
||||
|
||||
Sibling of :func:`list_terminal_sessions`, but does **not** filter
|
||||
by :data:`SESSION_NAME_PREFIX`. Used by
|
||||
``Sessions: Attach to Tmux Session`` so the user can attach a
|
||||
Terminus pane to a tmux session they started outside Sessions
|
||||
(typed ``tmux new -s work`` directly, or attached to a session
|
||||
created by another tool).
|
||||
|
||||
Same forgiving error semantics as ``list_terminal_sessions`` — the
|
||||
three "no sessions to report" cases (no server running / tmux
|
||||
missing / SSH probe error) all collapse to ``[]`` so the caller
|
||||
doesn't have to special-case them when populating a quick panel.
|
||||
|
||||
Args:
|
||||
host_alias: SSH alias to enumerate against.
|
||||
ssh_command_builder: Maps ``alias`` to an argv prefix; defaults
|
||||
to ``["ssh", alias]``.
|
||||
run: Override for ``subprocess.run``.
|
||||
timeout: Seconds before giving up.
|
||||
|
||||
Returns:
|
||||
Every session name reported by ``tmux list-sessions``, in
|
||||
the order tmux returned them. Empty when the server isn't
|
||||
running, tmux isn't installed, or the SSH probe failed.
|
||||
"""
|
||||
builder = ssh_command_builder or _default_ssh_command_builder
|
||||
run_fn = run or subprocess.run
|
||||
argv: List[str] = list(builder(host_alias)) + [
|
||||
"tmux",
|
||||
"list-sessions",
|
||||
"-F",
|
||||
"#{session_name}",
|
||||
]
|
||||
try:
|
||||
completed = run_fn(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return []
|
||||
except OSError:
|
||||
return []
|
||||
if completed.returncode != 0:
|
||||
return []
|
||||
return [
|
||||
line.strip() for line in (completed.stdout or "").splitlines() if line.strip()
|
||||
]
|
||||
|
||||
|
||||
def list_terminal_sessions(
|
||||
host_alias: str,
|
||||
*,
|
||||
@@ -407,6 +469,7 @@ __all__ = (
|
||||
"TmuxProbeResult",
|
||||
"build_remote_tmux_invocation",
|
||||
"kill_terminal_session",
|
||||
"list_all_remote_tmux_sessions",
|
||||
"list_terminal_sessions",
|
||||
"next_terminal_session_name",
|
||||
"probe_tmux_available",
|
||||
|
||||
@@ -364,3 +364,144 @@ def test_kill_command_handles_already_gone_session_message(
|
||||
on_select(0)
|
||||
|
||||
assert any("was already gone" in m for m in status)
|
||||
|
||||
|
||||
# --- SessionsAttachRemoteTmuxCommand ----------------------------------------
|
||||
|
||||
|
||||
def test_attach_command_lists_every_remote_tmux_session_in_quick_panel(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Quick panel must include foreign tmux sessions, not just sessions-term-*.
|
||||
|
||||
The whole point of the attach command vs. kill is that it reaches
|
||||
across the SESSION_NAME_PREFIX boundary so the user can attach to
|
||||
whatever they spun up outside Sessions.
|
||||
"""
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_all_remote_tmux_sessions",
|
||||
lambda host_alias, **_: [
|
||||
"sessions-term-prod",
|
||||
"work", # foreign — must appear.
|
||||
"sessions-agent-abc-claude", # also foreign-style for attach purposes.
|
||||
"0", # default tmux numeric session — must appear.
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsAttachRemoteTmuxCommand(window).run()
|
||||
|
||||
assert len(window.quick_panels) == 1
|
||||
items = window.quick_panels[0]
|
||||
captions = [row[0] for row in items]
|
||||
descriptions = [row[1] for row in items]
|
||||
assert captions == [
|
||||
"sessions-term-prod",
|
||||
"work",
|
||||
"sessions-agent-abc-claude",
|
||||
"0",
|
||||
]
|
||||
# First entry is Sessions-owned; the rest are flagged "foreign" so the user
|
||||
# knows what they're attaching to.
|
||||
assert descriptions[0].startswith("Sessions-owned")
|
||||
assert all(desc.startswith("foreign") for desc in descriptions[1:])
|
||||
|
||||
|
||||
def test_attach_command_runs_terminus_open_with_attach_argv_on_select(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_all_remote_tmux_sessions",
|
||||
lambda host_alias, **_: ["work", "sessions-term-prod"],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsAttachRemoteTmuxCommand(window).run()
|
||||
on_select = window.quick_panel_callbacks[0]
|
||||
# Pick the foreign "work" session.
|
||||
on_select(0)
|
||||
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
args = terminus_calls[0][1]
|
||||
# cmd is ["ssh", "-tt", "prod", "tmux attach-session -t work"]
|
||||
assert args["cmd"][:3] == ["ssh", "-tt", "prod"]
|
||||
assert "tmux attach-session -t" in args["cmd"][3]
|
||||
assert "work" in args["cmd"][3]
|
||||
# Title surfaces both pieces of context the user needs to identify the pane.
|
||||
assert "work" in args["title"]
|
||||
assert "prod" in args["title"]
|
||||
|
||||
|
||||
def test_attach_command_does_not_register_view_in_sessions_owned_caches(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Foreign attach must not appear in the Sessions-owned per-host /
|
||||
per-session view caches — those caches drive Sessions's own kill /
|
||||
reattach flows, and a foreign session must stay out of that scope.
|
||||
"""
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_all_remote_tmux_sessions",
|
||||
lambda host_alias, **_: ["work"],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsAttachRemoteTmuxCommand(window).run()
|
||||
on_select = window.quick_panel_callbacks[0]
|
||||
on_select(0)
|
||||
|
||||
assert "prod" not in commands._TERMINUS_VIEW_BY_HOST
|
||||
assert "work" not in commands._TERMINUS_VIEW_BY_SESSION_NAME
|
||||
|
||||
|
||||
def test_attach_command_emits_status_when_no_remote_sessions(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands, "list_all_remote_tmux_sessions", lambda host_alias, **_: []
|
||||
)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsAttachRemoteTmuxCommand(window).run()
|
||||
|
||||
assert window.quick_panels == []
|
||||
assert any("no remote tmux sessions running on prod" in m for m in status)
|
||||
|
||||
|
||||
def test_attach_command_warns_when_tmux_missing(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", False)
|
||||
list_calls: List[str] = []
|
||||
|
||||
def fail_list(host_alias: str, **_: Any) -> List[str]:
|
||||
list_calls.append(host_alias)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(commands, "list_all_remote_tmux_sessions", fail_list)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsAttachRemoteTmuxCommand(window).run()
|
||||
|
||||
assert list_calls == [] # short-circuits before listing.
|
||||
assert window.quick_panels == []
|
||||
assert any("tmux is not available on prod" in m for m in status)
|
||||
|
||||
@@ -52,6 +52,7 @@ def test_plugin_entrypoint_exports_sessions_commands() -> None:
|
||||
"SessionsAgentLayoutCollapseSwitcherCommand",
|
||||
"SessionsAgentLayoutCommand",
|
||||
"SessionsAgentSwitcherClickListener",
|
||||
"SessionsAttachRemoteTmuxCommand",
|
||||
"SessionsBridgeLifecycleListener",
|
||||
"SessionsClearPythonInterpreterCommand",
|
||||
"SessionsConnectRemoteWorkspaceCommand",
|
||||
@@ -114,6 +115,9 @@ def test_plugin_entrypoint_exports_sessions_commands() -> None:
|
||||
assert plugin_module.SessionsKillRemoteTerminalCommand.__name__ == (
|
||||
"SessionsKillRemoteTerminalCommand"
|
||||
)
|
||||
assert plugin_module.SessionsAttachRemoteTmuxCommand.__name__ == (
|
||||
"SessionsAttachRemoteTmuxCommand"
|
||||
)
|
||||
assert plugin_module.SessionsOpenRemoteFileCommand.__name__ == (
|
||||
"SessionsOpenRemoteFileCommand"
|
||||
)
|
||||
|
||||
@@ -43,6 +43,7 @@ def test_sessions_plugin_imports_under_sublime_style_package_layout() -> None:
|
||||
"SessionsAgentLayoutCollapseSwitcherCommand",
|
||||
"SessionsAgentLayoutCommand",
|
||||
"SessionsAgentSwitcherClickListener",
|
||||
"SessionsAttachRemoteTmuxCommand",
|
||||
"SessionsBridgeLifecycleListener",
|
||||
"SessionsClearPythonInterpreterCommand",
|
||||
"SessionsConnectRemoteWorkspaceCommand",
|
||||
|
||||
@@ -17,6 +17,7 @@ from sessions.terminal_tmux_session import (
|
||||
TmuxProbeResult,
|
||||
build_remote_tmux_invocation,
|
||||
kill_terminal_session,
|
||||
list_all_remote_tmux_sessions,
|
||||
list_terminal_sessions,
|
||||
next_terminal_session_name,
|
||||
probe_tmux_available,
|
||||
@@ -296,6 +297,72 @@ def test_list_terminal_sessions_returns_empty_on_oserror() -> None:
|
||||
assert list_terminal_sessions("prod", run=run) == []
|
||||
|
||||
|
||||
# --- list_all_remote_tmux_sessions -------------------------------------------
|
||||
|
||||
|
||||
def test_list_all_remote_tmux_sessions_returns_every_name() -> None:
|
||||
"""No SESSION_NAME_PREFIX filter — Sessions-owned, agent, foreign all kept."""
|
||||
stdout = (
|
||||
"sessions-term-prod\n"
|
||||
"sessions-term-prod-2\n"
|
||||
"sessions-agent-abc12345-claude\n" # agent — kept here, foreign-style.
|
||||
"work\n" # user-named — kept.
|
||||
"0\n" # default tmux numeric session — kept.
|
||||
)
|
||||
run = _RecordingRun(returncode=0, stdout=stdout)
|
||||
result = list_all_remote_tmux_sessions("prod", run=run)
|
||||
assert result == [
|
||||
"sessions-term-prod",
|
||||
"sessions-term-prod-2",
|
||||
"sessions-agent-abc12345-claude",
|
||||
"work",
|
||||
"0",
|
||||
]
|
||||
# Same argv shape as the filtered sibling — only the post-processing differs.
|
||||
assert run.calls == [
|
||||
[
|
||||
"ssh",
|
||||
"prod",
|
||||
"tmux",
|
||||
"list-sessions",
|
||||
"-F",
|
||||
"#{session_name}",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def test_list_all_remote_tmux_sessions_returns_empty_when_no_server_running() -> None:
|
||||
run = _RecordingRun(returncode=1, stdout="", stderr="no server running")
|
||||
assert list_all_remote_tmux_sessions("prod", run=run) == []
|
||||
|
||||
|
||||
def test_list_all_remote_tmux_sessions_returns_empty_when_tmux_missing() -> None:
|
||||
run = _RecordingRun(returncode=127, stdout="", stderr="tmux: command not found")
|
||||
assert list_all_remote_tmux_sessions("prod", run=run) == []
|
||||
|
||||
|
||||
def test_list_all_remote_tmux_sessions_returns_empty_on_timeout() -> None:
|
||||
run = _RecordingRun(
|
||||
raises=subprocess.TimeoutExpired(cmd=["ssh"], timeout=5.0),
|
||||
)
|
||||
assert list_all_remote_tmux_sessions("prod", run=run, timeout=5.0) == []
|
||||
|
||||
|
||||
def test_list_all_remote_tmux_sessions_returns_empty_on_oserror() -> None:
|
||||
run = _RecordingRun(raises=OSError("ssh: not found"))
|
||||
assert list_all_remote_tmux_sessions("prod", run=run) == []
|
||||
|
||||
|
||||
def test_list_all_remote_tmux_sessions_skips_blank_lines() -> None:
|
||||
"""Blank stdout lines (trailing newline, doubles) collapse out."""
|
||||
stdout = "work\n\n\nsessions-term-prod\n"
|
||||
run = _RecordingRun(returncode=0, stdout=stdout)
|
||||
assert list_all_remote_tmux_sessions("prod", run=run) == [
|
||||
"work",
|
||||
"sessions-term-prod",
|
||||
]
|
||||
|
||||
|
||||
# --- kill_terminal_session ---------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user