fix(0.6.5): macOS batch-3 — agent tmux, palette, hover URL, status doc

Second-pass macOS test of v0.6.4 surfaced four user-visible regressions
plus one doc/impl mismatch. Fix all four; reconcile the doc.

agent_tmux: lock down no-TTY contract (B)
- v0.6.2 added ``tmux new-session -d``, but the spawn still failed on
  aws-celery with ``open terminal failed: not a terminal``. Two further
  holes: OpenSSH may inherit a controlling-tty via a stray
  ``RequestTTY=yes`` in the user's ssh config, and tmux 3.x still calls
  ``isatty(0)`` to snapshot terminal capabilities even with ``-d``. Fix:
  ``_default_ssh_command_builder`` returns ``["ssh", "-T", alias]`` so
  PTY allocation is explicitly suppressed; spawn command appends
  ``</dev/null`` so ``isatty(0)`` is unambiguously false. The persistent
  Terminal flow (``terminal_tmux_session``) still uses ``ssh -tt`` —
  Terminus does allocate a TTY, that path is unaffected.

palette: register the v0.6.2 terminal pane / kill commands (C)
- ``SessionsNewRemoteTerminalPaneCommand`` and
  ``SessionsKillRemoteTerminalCommand`` had ``Sessions.sublime-commands``
  rows but were never imported by ``sublime/plugin.py``. Sublime only
  auto-registers ``WindowCommand`` subclasses exposed at the plugin
  entrypoint module's top level, so the palette never saw them — symptom:
  "그런 command 없음." Add to ``plugin.py`` import + ``__all__`` and
  update entrypoint smoke / runtime-import tests so this regresses loudly
  next time a new command lands.

terminal_link_click: canonical localhost URL (E)
- Hovering ``0.0.0.0:8080`` Cmd+clicked to ``about:blank-`` on macOS.
  ``0.0.0.0`` isn't routable from Safari/Chrome and macOS
  ``open location`` treats a no-path URL as under-specified.
  ``classify_terminal_token`` now canonicalizes ``0.0.0.0`` →
  ``localhost`` and forces a trailing ``/`` when the matched token has
  no path. Adversarial tokens like ``localhost:8080-extra`` refuse the
  match outright rather than emit a malformed URL.

doc reconcile: TEST_CHECKLIST §4.2 (I)
- The previous TEST_CHECKLIST refresh promised "Clear Python Interpreter
  drops the Python: slot entirely". The shipped v0.6.2 behavior keeps
  the slot and shows ``Python: (not set)``; the slot drop is only the
  syntax-gate path (non-Python view). Update the step to match shipped
  behavior so the next test pass doesn't log a false regression.

1474 sublime tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:26:07 +09:00
parent 80d18754e2
commit 779387938c
10 changed files with 212 additions and 19 deletions

View File

@@ -331,9 +331,11 @@ plugin_loaded sweep.
in parens. The `<X.Y.Z>` value comes from a one-shot
`python -V` probe that is cached per interpreter path; first
activation may briefly show `(…)` while the probe runs.
- [ ] `Sessions: Clear Python Interpreter` → bar drops the `Python:`
slot entirely (set vs not-set distinction is now "slot present
vs absent", not glyph).
- [ ] `Sessions: Clear Python Interpreter` → on a Python view, bar
reads `Python: (not set)` (slot retained, text-only signal — no
glyph, no path). The slot is only dropped entirely by the
syntax gate (see next step) or when the view leaves a Sessions
workspace.
- [ ] (v0.6.2) Open a **non-Python view** (e.g. a `.md` or `.json`
file inside the Sessions workspace) → bar **drops the
`Python:` slot** for that view. Switching back to a `.py` view

View File

@@ -16,8 +16,10 @@ from .sessions.commands import (
SessionsExpandDeferredDirectoryCommand,
SessionsInstallRemoteExtensionCommand,
SessionsKillAgentSessionCommand,
SessionsKillRemoteTerminalCommand,
SessionsLspNavigationListener,
SessionsNewAgentSessionCommand,
SessionsNewRemoteTerminalPaneCommand,
SessionsOnDemandFetchListener,
SessionsOpenLocalSshConfigCommand,
SessionsOpenRecentRemoteWorkspaceCommand,
@@ -60,8 +62,10 @@ __all__ = [
"SessionsExpandDeferredDirectoryCommand",
"SessionsInstallRemoteExtensionCommand",
"SessionsKillAgentSessionCommand",
"SessionsKillRemoteTerminalCommand",
"SessionsLspNavigationListener",
"SessionsNewAgentSessionCommand",
"SessionsNewRemoteTerminalPaneCommand",
"SessionsOnDemandFetchListener",
"SessionsOpenRemoteFileCommand",
"SessionsOpenRemoteFolderCommand",

View File

@@ -67,8 +67,18 @@ SshCommandBuilder = Callable[[str], List[str]]
def _default_ssh_command_builder(alias: str) -> List[str]:
"""Return the default ``ssh <alias>`` argv prefix for remote commands."""
return ["ssh", alias]
"""Return the default ``ssh -T <alias>`` argv prefix for remote commands.
``-T`` explicitly disables PTY allocation. OpenSSH already defaults to
no-TTY when a remote command is supplied, but Sublime's plugin host
runs without a controlling terminal in some launch contexts (Finder /
Dock launches on macOS, Windows GUI), and a stray ``RequestTTY=yes``
in ``~/.ssh/config`` would otherwise cause the spawn to allocate a
pseudo-tty. ``-T`` makes the no-TTY contract explicit so the remote
``tmux new-session -d`` is guaranteed not to inherit a half-initialised
terminal — the trigger for ``open terminal failed: not a terminal``.
"""
return ["ssh", "-T", alias]
def _shell_quote_with_tilde_expansion(arg: str) -> str:
@@ -223,14 +233,30 @@ class AgentTmuxBroker:
attach_argv = tuple(ssh_prefix + ["tmux", "attach", "-t", session_name])
# ``-d`` (detached) is critical: the spawn is invoked through a
# non-interactive ``ssh <alias> bash -lc ...`` pipeline with no
# non-interactive ``ssh -T <alias> bash -lc ...`` pipeline with no
# allocated TTY. Without ``-d``, tmux tries to attach to the new
# session immediately and fails with
# ``open terminal failed: not a terminal``. The actual attach
# happens later from Terminus, which does allocate a TTY.
spawn_remote_cmd = "tmux new-session -A -d -s {name} -- {cmd}".format(
name=shlex.quote(session_name),
cmd=_quote_remote_command(agent_cmd_tuple),
#
# ``</dev/null`` belt-and-suspenders: even with ``-d``, tmux 3.x
# initialises a terminal-capability snapshot for the new session
# by probing whatever fd 0 is connected to. When ``ssh`` is
# launched from a Sublime plugin host on macOS the inherited
# stdin can be a closed/odd handle that tmux misclassifies as a
# broken terminal — the error string regressed in v0.6.2 testing
# on aws-celery despite ``-d`` being present. Explicitly hooking
# tmux's stdin to ``/dev/null`` makes ``isatty(0)`` definitively
# false and keeps tmux on the "no terminal needed" code path.
# ``-A`` semantics still apply: when the session already exists
# the broker short-circuits via ``is_running`` before this
# command runs (see :meth:`attach_or_spawn`), so this command
# only ever fires for the create-fresh case.
spawn_remote_cmd = (
"tmux new-session -A -d -s {name} -- {cmd} </dev/null".format(
name=shlex.quote(session_name),
cmd=_quote_remote_command(agent_cmd_tuple),
)
)
spawn_argv = tuple(ssh_prefix + ["bash", "-lc", spawn_remote_cmd])

View File

@@ -129,7 +129,26 @@ def classify_terminal_token(token: str) -> Optional[Tuple[str, str]]:
except ValueError:
port_value = -1
if 0 < port_value <= 65535:
return ("url", "http://" + token)
host = host_port.group("host")
rest = host_port.group("rest") or ""
# ``0.0.0.0`` is the wildcard bind address servers print to
# signal "listening on every interface" — macOS browsers
# refuse to route to it (Safari/Chrome land on
# ``about:blank`` with a stray suffix). Canonicalize to
# ``localhost`` so the click reaches the loopback listener
# the user actually wants. ``127.0.0.1`` already resolves on
# every platform so we leave it alone.
if host == "0.0.0.0":
host = "localhost"
# macOS ``open location`` (driving Safari/Chrome through
# AppleScript) treats a host:port URL with no path as
# under-specified and falls back to ``about:blank`` plus a
# leftover token. Always emit a canonical trailing slash
# when no path was present so every platform sees a
# well-formed ``http://host:port/`` URL.
if not rest:
rest = "/"
return ("url", "http://" + host + ":" + port_str + rest)
match = _ABSPATH_WITH_POS_PATTERN.match(token)
if match:
path = match.group("path")

View File

@@ -114,6 +114,28 @@ def test_plan_builds_spawn_argv_as_bash_lc_new_session_command() -> None:
"tmux new-session -A -d -s sessions-agent-07c4844b-claude -- "
)
assert "claude --verbose" in remote_cmd
# ``</dev/null`` is required so tmux 3.x doesn't probe the inherited
# stdin and emit ``open terminal failed: not a terminal`` even with
# ``-d``. See agent_tmux.plan() commentary.
assert remote_cmd.endswith(" </dev/null")
def test_plan_default_ssh_builder_passes_dash_T_to_disable_pty() -> None:
"""The shipped broker must explicitly disable PTY allocation.
OpenSSH's default of "no TTY when a remote command is given" is fine
on the happy path, but a stray ``RequestTTY=yes`` in the user's
``~/.ssh/config`` (or ``Host *`` block) would otherwise force a PTY
and recreate the original ``not a terminal`` failure even with
``-d``. The broker pins ``-T`` to make the no-TTY contract explicit.
"""
from sessions.agent_tmux import _default_ssh_command_builder
assert _default_ssh_command_builder("aws-celery") == [
"ssh",
"-T",
"aws-celery",
]
def test_plan_expands_tilde_paths_in_agent_cmd() -> None:
@@ -182,7 +204,12 @@ def test_attach_or_spawn_spawns_when_missing() -> None:
assert len(run.calls) == 2
spawn_argv = run.calls[1]
assert spawn_argv[:6] == ["ssh", "-F", "/fake/config", "dev", "bash", "-lc"]
assert "tmux new-session -A -d -s sessions-agent-07c4844b-claude" in spawn_argv[6]
remote_cmd = spawn_argv[6]
# Both the detached-create flag AND the stdin redirect must be in
# the spawned command; missing either causes the "open terminal
# failed: not a terminal" regression on no-TTY hosts.
assert "tmux new-session -A -d -s sessions-agent-07c4844b-claude" in remote_cmd
assert remote_cmd.endswith(" </dev/null")
def test_attach_or_spawn_raises_on_spawn_failure() -> None:

View File

@@ -62,6 +62,18 @@ def _write_fake_ssh(
script.write_text(
"#!/bin/sh\n"
"# test shim — subprocess.Popen-equivalent routing\n"
"# Drop any leading ssh option flags (e.g. ``-T`` to disable PTY\n"
"# allocation) before consuming the alias positional.\n"
"while [ $# -gt 0 ]; do\n"
' case "$1" in\n'
" -[A-Za-z])\n"
" shift\n"
" ;;\n"
" *)\n"
" break\n"
" ;;\n"
" esac\n"
"done\n"
"shift # drop alias\n"
'sub=""\n'
'if [ $# -ge 2 ]; then sub="$2"; fi\n'

View File

@@ -59,8 +59,10 @@ def test_plugin_entrypoint_exports_sessions_commands() -> None:
"SessionsExpandDeferredDirectoryCommand",
"SessionsInstallRemoteExtensionCommand",
"SessionsKillAgentSessionCommand",
"SessionsKillRemoteTerminalCommand",
"SessionsLspNavigationListener",
"SessionsNewAgentSessionCommand",
"SessionsNewRemoteTerminalPaneCommand",
"SessionsOnDemandFetchListener",
"SessionsOpenRemoteFileCommand",
"SessionsOpenRemoteFolderCommand",
@@ -106,6 +108,12 @@ def test_plugin_entrypoint_exports_sessions_commands() -> None:
assert plugin_module.SessionsOpenRemoteTerminalCommand.__name__ == (
"SessionsOpenRemoteTerminalCommand"
)
assert plugin_module.SessionsNewRemoteTerminalPaneCommand.__name__ == (
"SessionsNewRemoteTerminalPaneCommand"
)
assert plugin_module.SessionsKillRemoteTerminalCommand.__name__ == (
"SessionsKillRemoteTerminalCommand"
)
assert plugin_module.SessionsOpenRemoteFileCommand.__name__ == (
"SessionsOpenRemoteFileCommand"
)

View File

@@ -50,8 +50,10 @@ def test_sessions_plugin_imports_under_sublime_style_package_layout() -> None:
"SessionsExpandDeferredDirectoryCommand",
"SessionsInstallRemoteExtensionCommand",
"SessionsKillAgentSessionCommand",
"SessionsKillRemoteTerminalCommand",
"SessionsLspNavigationListener",
"SessionsNewAgentSessionCommand",
"SessionsNewRemoteTerminalPaneCommand",
"SessionsOnDemandFetchListener",
"SessionsOpenRemoteFileCommand",
"SessionsOpenRemoteFolderCommand",

View File

@@ -309,8 +309,10 @@ def test_click_on_localhost_url_opens_browser(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Cluster B fix: scheme-less ``localhost:PORT`` should round-trip
# through ``_handle_url`` as ``http://localhost:PORT`` so the browser
# picks it up like any other URL.
# through ``_handle_url`` as ``http://localhost:PORT/`` so the browser
# picks it up like any other URL. The trailing slash is load-bearing
# on macOS — without it ``open location`` falls back to
# ``about:blank`` (the v0.6.4 regression).
view = _FakeClickView("Server up at localhost:8080 now")
view.window_value = _FakeClickWindow()
opened: List[str] = []
@@ -323,7 +325,7 @@ def test_click_on_localhost_url_opens_browser(
# Point 14 lands inside the ``localhost:8080`` token (offset of ``l``).
result = listener.on_text_command(view, "drag_select", {"event": _click_event(14)})
assert result == ("noop", {})
assert opened == ["http://localhost:8080"]
assert opened == ["http://localhost:8080/"]
def test_click_on_non_link_token_falls_through_to_drag_select() -> None:

View File

@@ -92,16 +92,27 @@ def test_classify_token_trims_trailing_punctuation() -> None:
@pytest.mark.parametrize(
"token, expected_url",
[
# Bare localhost:port — the canonical dev-server case.
("localhost:8080", "http://localhost:8080"),
# Bare localhost:port — the canonical dev-server case. We always
# emit a trailing slash on the path because macOS' ``open
# location`` (driving Safari/Chrome via AppleScript) treats a
# bare host:port URL as under-specified and falls back to
# ``about:blank``.
("localhost:8080", "http://localhost:8080/"),
("localhost:8888/notebooks/a.ipynb", "http://localhost:8888/notebooks/a.ipynb"),
# 127.0.0.1 is what Jupyter's startup banner prints.
("127.0.0.1:8888", "http://127.0.0.1:8888"),
("127.0.0.1:8888", "http://127.0.0.1:8888/"),
("127.0.0.1:5173/", "http://127.0.0.1:5173/"),
# Arbitrary IPv4 that shows up in ML / Ray / dashboard links.
("10.0.0.4:9000", "http://10.0.0.4:9000"),
("10.0.0.4:9000", "http://10.0.0.4:9000/"),
# Trailing punctuation from prose strips before matching.
("localhost:3000.", "http://localhost:3000"),
("localhost:3000.", "http://localhost:3000/"),
# ``0.0.0.0`` is a wildcard bind address — servers print it to
# mean "listening on every interface" but browsers can't route
# to it. Canonicalize to ``localhost`` so the click lands on
# the loopback listener the user actually wants.
("0.0.0.0:8080", "http://localhost:8080/"),
("0.0.0.0:8080/", "http://localhost:8080/"),
("0.0.0.0:8080/dashboard", "http://localhost:8080/dashboard"),
],
)
def test_classify_token_handles_localhost_and_host_port(
@@ -148,6 +159,86 @@ def test_classify_token_localhost_does_not_collide_with_abspath() -> None:
assert value == "/srv/etc/localhost"
# --- adversarial: the v0.6.4 ``about:blank-`` regression --------------------
def test_classify_token_zero_host_canonicalizes_to_localhost() -> None:
# Repro for v0.6.4 macOS bug: ``python3 -m http.server 8080`` prints
# ``Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...``.
# macOS browsers can't route to ``0.0.0.0`` and fall back to
# ``about:blank``; canonicalize to loopback so Cmd+click reaches
# the listener the user actually wants.
assert classify_terminal_token("0.0.0.0:8080") == (
"url",
"http://localhost:8080/",
)
def test_classify_token_bare_host_port_emits_trailing_slash() -> None:
# Without a trailing slash the macOS ``open location`` AppleScript
# treats the URL as under-specified and the browser shows a stray
# leftover suffix (the v0.6.4 ``about:blank-`` symptom). The
# promotion path always emits a canonical ``/`` when no path
# component is present so every platform sees a well-formed URL.
assert classify_terminal_token("localhost:8080") == (
"url",
"http://localhost:8080/",
)
assert classify_terminal_token("127.0.0.1:8080") == (
"url",
"http://127.0.0.1:8080/",
)
@pytest.mark.parametrize(
"token",
[
# A trailing dash glued to the port has no canonical
# interpretation as a URL — better to refuse than guess. The
# v0.6.4 ``about:blank-`` symptom on macOS came from passing a
# malformed token straight to the browser; rejecting here means
# the click falls through to plain text selection.
"localhost:8080-extra",
"localhost:8080-",
"127.0.0.1:8080-",
"0.0.0.0:8080-",
],
)
def test_classify_token_rejects_dash_glued_to_port(token: str) -> None:
assert classify_terminal_token(token) is None
def test_http_server_banner_line_classifies_only_clean_url_tokens() -> None:
# The whole-line scenario for ``python3 -m http.server 8080``:
# ``Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...``.
# We feed each whitespace-delimited token to the classifier. The
# bare ``0.0.0.0`` (no port) and bare ``8080`` (no host) are noise;
# the parens-wrapped URL is rejected because we deliberately don't
# strip leading brackets (policy: hover precision over greediness).
# Nothing should classify as a URL — the user clicks on a clean
# ``localhost:8080`` token elsewhere in their pane (e.g. an explicit
# echo, a Vite/Jupyter banner) and gets the canonical
# ``http://localhost:8080/`` form below.
line = "Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ..."
hits = []
for token in line.split():
result = classify_terminal_token(token)
if result is not None:
hits.append(result)
# No token in *this* line promotes — the parens-wrapped URL is the
# one the user would visually click on but our policy is to require
# hover/click on the URL itself.
assert hits == []
# Sanity: the *bare* ``0.0.0.0:8080`` token (the load-bearing case
# the v0.6.4 bug report covers) does promote, and it canonicalizes
# to localhost with a trailing slash so macOS Safari/Chrome can
# actually route it.
assert classify_terminal_token("0.0.0.0:8080") == (
"url",
"http://localhost:8080/",
)
# --- extract_token_at --------------------------------------------------------