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:
2026-04-25 17:15:15 +09:00
parent 57523033a0
commit 358d674f3d
9 changed files with 584 additions and 59 deletions

View File

@@ -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.

View File

@@ -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"

View File

@@ -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",

View File

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

View File

@@ -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",

View File

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

View File

@@ -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"
)

View File

@@ -43,6 +43,7 @@ def test_sessions_plugin_imports_under_sublime_style_package_layout() -> None:
"SessionsAgentLayoutCollapseSwitcherCommand",
"SessionsAgentLayoutCommand",
"SessionsAgentSwitcherClickListener",
"SessionsAttachRemoteTmuxCommand",
"SessionsBridgeLifecycleListener",
"SessionsClearPythonInterpreterCommand",
"SessionsConnectRemoteWorkspaceCommand",

View File

@@ -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 ---------------------------------------------------