Compare commits

...

2 Commits

Author SHA1 Message Date
f70999a9d7 chore(release): v0.7.25 — Track D residue cleanup + LSP-style project override
All checks were successful
Release Publish (Gitea session_helper) / publish-linux-x86_64 (push) Successful in 3m19s
ci / mutation test (broker) (push) Has been skipped
ci / test-health gate (push) Successful in 17s
Release Publish (Gitea session_helper) / verify-release-tag (push) Successful in 17s
ci / rust debug (push) Successful in 2m56s
ci / rust release (push) Successful in 2m59s
ci / python (push) Successful in 1m27s
User-visible:
- Remote extension catalog drops the four ``kind="agent"`` /
  ``kind="jupyter"`` rows (``tmux``, ``claude-code``, ``codex-cli``,
  ``jupyterlab``) — install/remove/status palette no longer shows
  Track D / Jupyter Lab entries.
- ``.sublime-project`` ``"settings"`` block now overrides
  ``sessions_remote_python_auto_diagnostics_on_save`` /
  ``_on_open`` / ``sessions_remote_python_tool_pipeline``
  per-workspace, matching Sublime LSP precedence
  (package → user → project).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:46:21 +09:00
7b43de90ad refactor(catalog)+feat(settings): excise Track D residue + add LSP-style project-level override
Two threads landing together because they share the
``Sessions.sublime-settings`` header comment edits.

Track D residue cleanup
-----------------------
v0.6.7 dropped the in-Sublime agent integration (Track D, 2026-04-27)
but left install-flow leftovers behind — now removed:

* ``BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG`` drops the three
  ``kind="agent"`` rows (``tmux``, ``claude-code``, ``codex-cli``) and
  the ``kind="jupyter"`` row (``jupyterlab``, superseded by
  ``marimo_hosting``). Twelve ``_BUILTIN_BASH_*`` install/remove/probe
  blocks deleted. ``managed_remote_extension_catalog.py`` shrinks
  358 → 182 lines.
* ``rust/crates/local_bridge/src/agent_remote_payload.rs`` (279 lines)
  + ``parse-agent-editor-envelope`` CLI subcommand removed — used
  only by the deleted ``agent_proposal_watcher``; verified zero live
  callers.
* Tests: drop ``test_catalog_contains_jupyter_extension_entry`` and
  ``test_catalog_contains_agent_extension_entries``; ``debugpy``
  ``kind="debugger"`` test stays. ``test_settings_model.py`` builtin
  id assertions trimmed to four entries.
* Comments: ``frozen-experimental`` docstring + matching
  ``Sessions.sublime-settings`` block deleted; ``commands.py``
  ``_managed_extension_project_client_keys_for_spec`` example
  jupyter → debugger; Open-Remote-Terminal docstring drops the "no
  tmux session multiplexing" framing; ``marimo_hosting.py`` drops
  dead ``tmux``-children + ``jupyter_hosting.py`` postmortem
  references.
* Planning: ``AGENT_TMUX_LAYOUT.md`` and ``V0_6_5_REPRO.md`` deleted
  (both reference deleted features); ``BACKLOG.md`` Track D entry,
  ``REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`` Stage 4 obsolete +
  follow-up cleanup section, ``PYTHON_RUST_BOUNDARY.md``
  agent_remote_payload row, ``README.md`` Track D bullet — all
  updated to reflect the 2026-04-30 residue removal.

No backward-compat shim. ``debugpy`` ``kind="debugger"`` row
untouched.

LSP-style project-level override for the on-save pipeline
---------------------------------------------------------
The original Sessions design wired toolchain settings with the same
package → user → ``.sublime-project`` precedence Sublime LSP uses,
and ``merge_sessions_lsp_into_project_data`` already follows that
for the ``settings.LSP`` row writer. The on-save toggle path
(``_effective_sessions_settings_for_remote_python`` →
``load_sessions_settings_from_sublime``) skipped the project layer,
so per-workspace toggling required editing global user settings.

Fix: ``_effective_sessions_settings_for_remote_python`` accepts an
optional ``window`` and overlays
``window.project_data().get("settings", {})`` on top of the user
merge for ``sessions_remote_python_auto_diagnostics_on_save``,
``sessions_remote_python_auto_diagnostics_on_open``, and
``sessions_remote_python_tool_pipeline``. New
``_project_settings_block_for_window`` helper tolerates missing
``project_data`` callable / ``None`` payloads / non-mapping values.
Bool keys reject non-bool values silently (fall through to user);
pipeline runs through ``normalize_remote_python_tool_pipeline``.

All five callers in ``commands_python_pipeline.py`` now pass
``window``; the two listeners (``on_post_save``,
``on_activated_async``) reorder window-resolution before the toggle
check so the project block is consultable when the listener fires.

Six new regression tests in ``test_commands.py`` pin
project-overrides-user / user-wins-when-absent / pipeline-override /
wrong-type-rejected / null-project_data-safe / no-window-legacy.

``Sessions.sublime-settings`` header comment now documents the
precedence chain inline so users discover the
``.sublime-project`` ``"settings"`` block path without code-diving.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:45:21 +09:00
21 changed files with 243 additions and 1178 deletions

View File

@@ -5,14 +5,14 @@
Current focus:
- **Completed milestones:** Phase 06.2 (all closed), Phase 7 - Stability Hardening (closed), Phase 8 - Rust Transport Expansion (closed), Remote LSP integration track ([#34](https://git.teahaven.kr/sublime-rs/sessions/issues/34), [#35](https://git.teahaven.kr/sublime-rs/sessions/issues/35), [#36](https://git.teahaven.kr/sublime-rs/sessions/issues/36), [#37](https://git.teahaven.kr/sublime-rs/sessions/issues/37) — all closed; `local_bridge lsp-stdio`, persistent broker attach IPC, `session_helper lsp_stdio` supervision, URI rewrite + save barrier, host-scoped install with workspace-scoped env/config). See [`planning/GITEA_ISSUES.md`](planning/GITEA_ISSUES.md).
- **Open milestones:** Phase 9 - Quality Gates & Scale ([#10](https://git.teahaven.kr/sublime-rs/sessions/issues/10), [#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32) large-file streaming). [#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29) (diff-centric review) was reframed in the 2026-04-25 distribution review and is **no longer the next feature** — see [`planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`](planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md) and [`planning/SHIPPED.md`](planning/SHIPPED.md). Track D (in-Sublime agent integration) was dropped 2026-04-27 — see [`planning/BACKLOG.md`](planning/BACKLOG.md).
- **Open milestones:** Phase 9 - Quality Gates & Scale ([#10](https://git.teahaven.kr/sublime-rs/sessions/issues/10), [#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32) large-file streaming). [#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29) (diff-centric review) was reframed in the 2026-04-25 distribution review and is **no longer the next feature** — see [`planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`](planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md) and [`planning/SHIPPED.md`](planning/SHIPPED.md). Track D (in-Sublime agent integration) was dropped 2026-04-27 and the residual `tmux`/`claude-code`/`codex-cli`/`jupyterlab` catalog entries were excised on 2026-04-30 — see [`planning/BACKLOG.md`](planning/BACKLOG.md) and [`planning/SHIPPED.md`](planning/SHIPPED.md).
- **Execution order (2026-04, Rust-first):** P0.5 stabilization → crate consolidation → artifact publish + manifest/checksum → **[#24](https://git.teahaven.kr/sublime-rs/sessions/issues/24)** Rust runtime ownership → **[#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32)** large-file → Track G v1 (multi-repo, refs/ fast-path, line-staging polish). #29 diff-centric review/apply is **deprioritized**, not on this order. Normative detail: [`planning/GITEA_ISSUES.md`](planning/GITEA_ISSUES.md) (execution priority and schedule), migration waves: [`planning/PYTHON_RUST_BOUNDARY.md`](planning/PYTHON_RUST_BOUNDARY.md). Distribution-readiness + ownership-migration plan: [`planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`](planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md).
- **P0.5 stabilization (2026-04, closed):** persistent bridge, download-only helper, reconnect, mirror ignore patterns, save conflict UI, wire contract test coverage (bridge stdout fixtures, binary smoke test, ABI smoke test), stability hardening (prune symlink/permission edges, multi-window dedup, refresh race prevention), remote file auto-reload via periodic stat → revert, LSP-ready on-demand fetch via external path mapper + `on_window_command` interceptor.
- SSH config driven workspace selection
- session-bound helper over SSH stdio
- local cache with local-host-independent workspace identity
- formatter and linter execution in the remote environment (baseline + #30 pipeline on save)
- ~~long-term evolution toward a multi-session agent window~~ — **frozen 2026-04-27**: the v0.6.0v0.6.7 in-Sublime agent code (`agent_tmux`, `agent_window_layout`, `agent_switcher_view`, agent palette commands, `tmux`/`claude-code`/`codex-cli` catalog entries) stays in the tree but has no follow-up work; agents now run in an external terminal that the user manages outside Sublime. See [`planning/BACKLOG.md`](planning/BACKLOG.md) Track D.
- ~~long-term evolution toward a multi-session agent window~~ — **dropped 2026-04-27, residue removed 2026-04-30**: the v0.6.0v0.6.7 in-Sublime agent code (`agent_tmux`, `agent_window_layout`, `agent_switcher_view`, agent palette commands) was deleted in v0.6.7; the `tmux`/`claude-code`/`codex-cli` catalog entries and the parallel `jupyterlab` (`kind="jupyter"`) entry were excised on 2026-04-30. Agents now run in an external terminal that the user manages outside Sublime; `marimo` replaces in-tree Jupyter hosting. See [`planning/BACKLOG.md`](planning/BACKLOG.md) Track D and [`planning/SHIPPED.md`](planning/SHIPPED.md).
## Repository layout

View File

@@ -1,387 +0,0 @@
# AGENT_TMUX_LAYOUT — remote agents via tmux, three-group Sublime window
**Status**: design only. Supersedes the earlier agent-chat / diff-viewer
design (which has been dropped — we don't build a chat UI).
**Depends on**: `PYTHON_RUST_BOUNDARY.md` (no protocol changes here —
agents run as plain CLIs over SSH; the bridge stays for file / LSP
channels). Interacts with the managed-extension catalog (`kind="agent"`).
## Why tmux instead of a custom chat UI
The previous plan was to reimplement a chat widget in Sublime using
phantoms, panels, and a custom NDJSON protocol to codex / claude
daemons. That is a lot of UI code that reinvents a terminal. It also
fragments when the agent CLI updates its protocol.
Observation: **every serious remote agent already ships a working
terminal UI** (codex, claude code, anthropic CLI). Running that UI
inside a Terminus pane that is attached to a tmux session on the
remote host gives us:
- the real UX the agent vendor ships, including their slash commands,
markdown, syntax, keybindings;
- persistence across Sublime restarts (tmux keeps the session and the
scrollback);
- trivial switching between agents (just attach to a different tmux
session) without any protocol layer;
- the ability to run multiple agents in parallel, one tmux session
each.
The Sublime side only needs to:
1. spawn / attach tmux sessions,
2. lay out the window into three groups,
3. persist and expose the `(workspace, agent)` pairs.
## Window layout
```
┌──────────────┬──────────────────────────┬─────────────────┐
│ │ │ │
│ file │ Terminus │ Agent │
│ sidebar │ (ssh host │ Session │
│ + │ tmux attach -t ...) │ Switcher │
│ editor │ │ │
│ (group 0) │ (group 1) │ (group 2) │
│ │ │ │
└──────────────┴──────────────────────────┴─────────────────┘
```
- Sublime's built-in left sidebar (workspace file tree) is still there;
our layout only affects the editor area to its right.
- Group 0: the normal editor pane. File tabs open here.
- Group 1: Terminus view attached to the agent's tmux session. This
group is **single-view** — switching agents replaces the view.
- Group 2: a read-only Sublime view rendering the switcher. Clicks on
a pair entry dispatch `sessions_switch_agent_session`.
Proposed column widths: `[0.40, 0.40, 0.20]`. The switcher column
can collapse to 0.0 via a toggle command when the user wants more
editor room.
## Session naming convention
```
sessions-agent-<workspace_cache_key[:8]>-<agent_id>
```
Example: `sessions-agent-07c4844b-claude`. Agent ids come from the
catalog entry (see below).
`tmux new-session -A -s <name> -- <agent_cmd>` is idempotent: attaches
if the session exists, spawns with `<agent_cmd>` if it doesn't. We
never `kill-session` implicitly — detach only. Explicit
`Sessions: Kill Agent Session` command drives cleanup.
## Extension catalog entries
Agents are installed via the existing managed-extension flow. New
`kind="agent"` variant:
```python
ManagedRemoteExtensionCatalogEntry(
install_catalog_id="claude-cli",
install_label="Claude Code CLI (remote)",
install_argv=("bash", "-lc", _CLAUDE_INSTALL_SCRIPT),
remove_argv=("bash", "-lc", _CLAUDE_REMOVE_SCRIPT),
probe_argv=("bash", "-lc", "command -v claude && claude --version"),
install_cwd=None,
kind="agent",
)
```
The install scripts are the vendor's official install lines (npm /
curl-to-bash / etc.). Probes check `command -v <bin>` + `--version`.
We do **not** maintain our own agent binaries.
## Sub-tracks (parallelisable)
### D1. Tmux session broker — pure Python, unit-testable
New module `sublime/sessions/agent_tmux.py`. No Sublime imports at
module top.
```python
@dataclass(frozen=True)
class TmuxAgentSession:
host_alias: str
workspace_cache_key: str
agent_id: str
session_name: str # "sessions-agent-<ws>-<agent>"
attach_argv: list[str] # ["ssh", "<host>", "tmux", "attach", "-t", name]
spawn_argv: list[str] # ["ssh", "<host>", "tmux", "new-session", "-A", "-s", name, "--", <agent_cmd>]
class AgentTmuxBroker:
def __init__(
self,
*,
ssh_command_builder: Callable[[str], list[str]] = ...,
run: Callable[..., subprocess.CompletedProcess] = subprocess.run,
): ...
def plan(self, host_alias, workspace_cache_key, agent_id, agent_cmd) -> TmuxAgentSession: ...
def is_running(self, host_alias, session_name) -> bool:
# ssh host tmux has-session -t <name>
...
def attach_or_spawn(self, session: TmuxAgentSession) -> None:
# has-session → attach_argv, else new-session command
# Called by the Terminus launcher (D3).
...
def list_sessions(self, host_alias) -> list[str]:
# ssh host tmux list-sessions -F '#{session_name}'
# Filtered to "sessions-agent-*".
...
def kill(self, host_alias, session_name) -> None:
# ssh host tmux kill-session -t <name>
...
```
Injectable `run` makes everything unit-testable.
**[files]** `agent_tmux.py` (new), `test_agent_tmux.py` (new).
### D2. Three-group window layout
New module `sublime/sessions/agent_window_layout.py`. Provides one
command:
```python
class SessionsAgentLayoutCommand(sublime_plugin.WindowCommand):
def run(self, cols=(0.40, 0.80, 1.00)) -> None:
self.window.set_layout({
"cols": [0.0, cols[0], cols[1], cols[2]],
"rows": [0.0, 1.0],
"cells": [[0, 0, 1, 1], [1, 0, 2, 1], [2, 0, 3, 1]],
})
```
Plus `SessionsAgentLayoutCollapseSwitcherCommand` that widens to
`[0.5, 1.0, 1.0]` (hides group 2). Toggleable via keybind in
`Default.sublime-keymap`.
Persists the active layout in workspace state so reload restores it.
**[files]** `agent_window_layout.py` (new), `workspace_state.py`
(extend with a `layout` field), Default keymap.
### D3. Terminus launcher
`sessions_open_agent_terminus`, driven by D1 + D2:
```python
def run(self, host_alias, workspace_cache_key, agent_id, agent_cmd):
session = broker.plan(host_alias, workspace_cache_key, agent_id, agent_cmd)
broker.attach_or_spawn(session) # ensures tmux session exists
# Terminus docs: terminus_open accepts {"cmd": [...], "cwd": str}.
self.window.focus_group(1)
self.window.run_command("terminus_open", {
"shell_cmd": " ".join(shlex.quote(a) for a in session.attach_argv),
"cwd": None,
"title": f"Agent · {agent_id} · {host_alias}",
"pre_window_hooks": [["move_to_group", {"group": 1}]],
})
```
Handles the tmux-not-installed case: probe via `ssh host command -v
tmux`; if missing, show `Sessions: Install Remote Extension` hint with
a one-shot install (tmux goes in the extension catalog too, as
`kind="agent"` prerequisite).
**[files]** `commands.py` (add class), `Sessions.sublime-commands`
(palette entry).
### D4. Switcher view (group 2)
Group 2 holds a named view (`settings().get("sessions_agent_switcher")
== True`). Renders a list like:
```
○ 07c4844b · claude [attached]
● a75c7f0f · codex (active)
○ a75c7f0f · claude
─────────────
+ New agent session…
```
Clicks resolved via `on_text_command drag_select` → if the cursor
line maps to a pair row, fire `sessions_switch_agent_session
{"pair_id": "<cache_key>:<agent_id>"}`.
Live updates: re-render on D5's pair-change callbacks.
**[files]** `agent_switcher_view.py` (new), integration hook in
`commands.py`.
### D5. Pair persistence + switch orchestration
Workspace-level store in `workspace_state.py`:
```python
@dataclass(frozen=True)
class AgentPair:
workspace_cache_key: str
agent_id: str
created_at: float
last_activated_at: float
def register_agent_pair(pair: AgentPair) -> None: ...
def active_agent_pair(workspace_cache_key: str) -> Optional[AgentPair]: ...
def list_agent_pairs() -> list[AgentPair]: ...
```
New command `SessionsSwitchAgentSessionCommand`:
1. Find the target pair.
2. If the workspace behind `pair.workspace_cache_key` is not the
current active workspace, call existing workspace-switch machinery
first (project data swap). File sidebar + editor re-targets follow.
3. Attach Terminus in group 1 to the pair's tmux session (D3).
4. Refresh switcher view (D4).
**[files]** `commands.py`, `workspace_state.py`, `agent_switcher_view.py`.
### D6. Lifecycle + teardown
- `plugin_unloaded`: detach (Terminus `terminus_keypress ctrl-b d`
equivalent) — do **not** kill. tmux keeps the agent alive.
- `Sessions: Kill Agent Session` command (palette) — explicit kill of
the active pair's tmux session; user confirmation prompt.
- `Sessions: Kill All Agent Sessions` — explicit sweep on the
currently connected host.
**[files]** `commands.py`, `agent_tmux.py`.
### D7. Edit-proposal surfacing in the editor
**Goal**: when the agent proposes an edit (i.e. calls its edit / write /
patch tool), show the proposed change as a diff in the Sublime editor,
not only inside the Terminus pane. Apply-on-click is a nice-to-have but
not required for the first cut; **just making the proposal visible in
the editor surface is the MVP**.
Three phases, ordered by both effort and fidelity:
#### Phase 1 — pipe-pane scrollback tail (agent-agnostic, visibility-only)
Mirror the Terminus/tmux pane to a local file via `tmux pipe-pane`, tail
it with a Python watcher, and parse out unified-diff blocks. Render them
in a dedicated output panel `Sessions: Agent Proposals` with the file
path + hunk text. Clicking a path opens the relevant file (via the
existing on-demand fetch listener).
- Works for any agent that prints a unified diff to the terminal
(claude, codex, aider, etc.).
- **No apply** — the agent still drives its own confirmation step in
the terminal. Our panel is purely informational.
- **Brittle**: ANSI colour codes, pager truncation, non-standard diff
formats can corrupt the parse. We handle the common case and drop
silently on weird input.
- **[files]** `agent_proposal_watcher.py` (new) — tail + parse + emit to
an output panel; `commands.py` — palette entries for `Sessions: Open
Agent Proposals`, `Sessions: Clear Agent Proposals`.
- **[testability]** `_parse_unified_diff_stream` is pure string→list;
unit-testable with fixture blobs. Tail loop mocked with an in-memory
file-like.
#### Phase 2 — post-apply phantom badge (agent-agnostic, already-applied)
When the agent writes a file on the remote and our existing `file/watch`
fires a change event for an already-open local cache file:
- Snapshot the buffer before re-fetching.
- Compute a line-level diff between the snapshot and the new content.
- Decorate the modified hunks with a Sublime phantom / region of the
form `🤖 claude · <time>` in a distinct colour scope
(`region.bluish markup.agent.changed`).
- Fades after 30 seconds or on next edit.
No user action required; purely a visual cue that "the file just
under your cursor changed because of the agent, not you". Works for
every agent that writes files on the remote, regardless of how the
user approved the change.
- **[files]** `agent_change_badge.py` (new), hooked into the existing
`file/watch` handling in `ssh_file_transport`/`commands`.
- **Accepts**: that by the time we render the badge the change is
already applied. This is the easiest-to-ship "editor sees the diff"
path — the user sees what changed, still in the normal file flow.
#### Phase 3 — pre-apply preview via Claude Code hooks (claude-specific)
Claude Code ships first-class support for `PreToolUse` and `PostToolUse`
hooks (configured in `.claude/settings.json` on the remote). We install
a small shell hook that:
- On `PreToolUse` for `edit_file` / `write_file` / `str_replace`: write
the tool-call JSON to a local Unix socket (forwarded via `ssh -L`
control-master) and **wait** for an `approve` / `reject` reply from
Sublime before letting the hook return.
- Sublime receives the JSON, renders a rich diff preview in the
relevant editor view (using Sublime's built-in `diff` syntax or a
phantom overlay), and shows floating `Apply` / `Reject` buttons.
- User's click sends `approve` / `reject` back through the socket; the
hook returns; claude proceeds or aborts the tool call.
This is the most ambitious variant: editor-native preview, user clicks
in Sublime (not in the terminal), claude respects the decision. It is
**only claude-specific** — codex / aider / others do not expose
equivalent hooks at the time of writing.
- **[files]** `agent_claude_hook.sh` (shipped hook script, `bash -lc`
compatible), `claude_hook_server.py` (new: Unix-socket server inside
the Sublime plugin process), `agent_proposal_preview.py` (new:
phantom/diff rendering).
- **[risks]** hook timeout: if Sublime isn't running or the socket isn't
listening, claude waits indefinitely. The hook must have a 10 s
default-deny fallback.
- **[installer]** add the hook script to the managed-extension catalog
under `kind="agent"` alongside the claude CLI install. Sessions drops
`.claude/settings.json` on first use if missing.
**Phase adoption plan**:
- Phase 1 ships with v0.6.0 alongside the tmux layout (it's
agent-agnostic and cheap).
- Phase 2 ships in a follow-up (v0.6.1) — needs thoughtful diff
colouring that doesn't clash with Sublime's save-status markers.
- Phase 3 is gated on demand (v0.7.0 candidate). Users who want
apply-from-editor get it; others stay on Phase 1/2.
## Parallel work plan
Two agents + one integrator:
### Agent α (pure-Python, no Sublime)
Owns D1 (broker) and the tmux / SSH CLI details. Output: tested
`agent_tmux.py` + comprehensive unit tests.
### Agent β (Sublime-facing UI)
Owns D2 (layout) and D4 (switcher view skeleton with fake pair data).
Output: clickable layout + list, no integration yet.
### Integrator (manual)
Lands D3 + D5 + D6 + extension catalog entries on top of α+β, wires
everything, runs full pytest + manual macOS smoke test.
Total scope ≈ 600900 Python LoC + 400 test LoC. No Rust changes. No
protocol changes. Release as **v0.6.0** (minor bump — new user-visible
feature).
## Out of scope (do not do here)
- Agent-specific parsing of output beyond unified diffs (markdown
rendering, thinking blocks, etc.). Terminus renders the raw agent
UI verbatim. If users want richer output, the agent CLI should
provide it. Diff surfacing is the one exception — see D7.
- In-Sublime chat widgets / side panels. Explicitly dropped.
- Replacing the agent's own in-terminal confirmation flow except via
the claude-hook path (D7 Phase 3).
- Any change to the local_bridge / session_helper protocol.

View File

@@ -73,16 +73,21 @@ investment in Terminus-side polish is out of scope.
---
## ~~Track D — Agent integration via tmux~~ — **[dropped 2026-04-27]**
## ~~Track D — Agent integration via tmux~~ — **[dropped 2026-04-27, residue removed 2026-04-30]**
Whole-track drop. The new direction: agents (codex / claude / etc.)
run in an external terminal that the user manages outside Sublime —
no in-Sublime layout / switcher / proposal-surfacing work. The
v0.6.0v0.6.7 work that already shipped (`agent_tmux`,
`agent_window_layout`, `agent_switcher_view`, workspace/agent pair
registry, three palette commands) stays in the codebase but is
considered frozen; D1D7 sub-tracks no longer have follow-up work.
`AGENT_TMUX_LAYOUT.md` retained for historical reference only.
v0.6.0v0.6.7 in-tree code (`agent_tmux`, `agent_window_layout`,
`agent_switcher_view`, workspace/agent pair registry, three palette
commands) was deleted in v0.6.7. The residual catalog entries
(`tmux` / `claude-code` / `codex-cli` `kind="agent"` rows plus their
install/remove/probe bash blocks) and the parallel `jupyterlab`
`kind="jupyter"` row were excised on 2026-04-30 along with the
matching tests and the `planning/AGENT_TMUX_LAYOUT.md` design
document; D1D7 sub-tracks have no follow-up work. Anything still
needed about the historical layout lives in git history at
`v0.6.6..v0.6.7`.
---
@@ -492,7 +497,7 @@ continuous `bridge.request_timeout` on `mirror-sync` (45s),
`file/watch` (35s), `file/read` (30s). Subsequent "Sessions
disconnected" → reconnect loop.
**Diagnosed** via debug-trace capture (see V0_6_5_REPRO §B1): the
**Diagnosed** via debug-trace capture: the
deep mirror-sync at `max_traversal_depth=12` over slow tunnels
(AWS SSM) genuinely runs 45-50 s end-to-end, just exceeding the
generic 45 s request timeout. helper is alive and streaming the

View File

@@ -106,7 +106,6 @@ MVP code may live in Python for velocity; **new non-trivial logic should default
| `workspace_state.py` (identity) | Cache key, paths | `workspace_identity` | `normalize_remote_root` is **Rust-only** via `sessions_native` cdylib; Python `cache_key` hashing remains until a later slice. |
| `ssh_runner.py`, `ssh_file_transport.py` | SSH subprocess, file I/O | `local_bridge`, `session_helper` | Python glue only; **no remote-Python transport fallback** for tree/file (bridge required or structured failure). |
| `file_state.py` | Open/save policy, conflict rules | *future* `sessions_file_policy` or similar | Pure functions → good Rust candidate. |
| `agent_remote_payload.py` | Sublime-side envelope **glue only** | `local_bridge::agent_remote_payload` + `local_bridge parse-agent-editor-envelope` | **Rust only** for parsing/validation; Python subprocesses `local_bridge` (no second implementation). |
| `connect_preflight.py` | remote-root validation | `workspace_identity` + `sessions_native` | Uses ``normalize_remote_root`` (Rust); host-alias resolution stays Python (SSH config objects). |
| `settings_model.py` | typed settings | *future* | Optional codegen from JSON schema. |

View File

@@ -172,7 +172,8 @@ not by design or code:
- 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).
the next per-version repro checklist (the v0.6.5-specific one
was retired with the Track D residue cleanup; replace as needed).
- Document the "currently Linux-only signed bundle" reality in
README + SECURITY.md so external users aren't surprised by the
asset list.
@@ -517,19 +518,18 @@ Python.
envelope, invokes the broker, returns the typed result. `_rust_ffi`
hosts ~7 functions, not 30+.
### 3.5 Stage 4 — agent / diff / runtime state ownership `[plan]`
### 3.5 Stage 4 — agent / diff / runtime state ownership `[obsolete — Track D dropped 2026-04-27]`
**Move out of Python:**
- `agent_proposal_watcher` diff parser (already pure Python, no
Sublime — explicitly tagged as a Rust candidate).
- Agent pair registry in `workspace_state.py` (module-global
mutable).
- `agent_tmux::AgentTmuxBroker` orchestration.
- Deferred-dir registry in `workspace_state.py`.
**Python keeps:** layout (`agent_window_layout.py`), switcher view
(`agent_switcher_view.py`), output panel rendering, palette wiring
for agent commands.
This stage is no longer applicable. Track D was dropped on
2026-04-27 and the v0.6.7 commit deleted `agent_proposal_watcher`,
`agent_change_badge`, `agent_tmux`, `agent_window_layout`,
`agent_switcher_view`, and the workspace/agent pair registry.
The 2026-04-30 cleanup excised the residual `tmux`/`claude-code`/
`codex-cli` `kind="agent"` catalog entries, the parallel
`jupyterlab` `kind="jupyter"` row, and the `AGENT_TMUX_LAYOUT.md`
design doc. There is nothing left to migrate to Rust under this
stage; the remaining `workspace_state.py` deferred-dir registry can
be carried by Stage 1 if it ever needs to move.
### 3.6 Success metrics — not LOC `[plan]`
@@ -555,23 +555,19 @@ after each stage.
Concrete order, lowest risk first:
1. **Pure-Python no-Sublime modules first** (review-recommended):
`agent_proposal_watcher` diff parser is the explicit example —
no `sublime` import, easy port surface. Use it as the warm-up
for the migration tooling.
2. **Stage 1 (broker ownership)** — biggest effect, central
1. **Stage 1 (broker ownership)** — biggest effect, central
choke point. Land before stages 2/3 because they depend on the
broker for cancellation + lifecycle.
3. **Stage 2 (materialization)** — paired with §2.2 large-file
2. **Stage 2 (materialization)** — paired with §2.2 large-file
streaming work; the new chunked `file/read` is implemented
inside the new Rust materialization pipeline rather than in
Python.
4. **Stage 3 (envelope ownership)** — naturally falls out of
3. **Stage 3 (envelope ownership)** — naturally falls out of
stages 1+2; remaining method-builder code in Python is
replaced by `runtime_*` calls.
5. **Stage 4 (agent / diff / state)** — paired with §2.1
diff-centric review work; agent state moves with the new
review module.
4. ~~**Stage 4 (agent / diff / state)**~~obsolete; see §3.5
above. Track D was dropped 2026-04-27 and the agent modules
plus catalog residue were deleted in v0.6.7 / 2026-04-30.
---
@@ -579,7 +575,7 @@ Concrete order, lowest risk first:
Per review: "지금은 덜 급한 것":
- More agent types (catalog already covers Claude Code + Codex CLI).
- ~~More agent types (catalog already covers Claude Code + Codex CLI).~~ — moot; Track D dropped 2026-04-27 and the agent catalog rows were excised on 2026-04-30.
- More palette commands (palette is already too wide — see §1.3).
- Big new LSP redesign (Remote LSP track #34/#35/#36/#37 closed; no
unmet need).
@@ -621,6 +617,21 @@ Test-health gate stays green after the deletion: adversarial 190
(floor 27), mock-only:high-value 0.95 (cap 0.98). No floor
adjustment needed.
**Follow-up cleanup (2026-04-30, v0.7.25)** — the v0.6.7 cut left
the catalog install/remove rows behind. Now also deleted:
- `BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG` rows for `tmux`,
`claude-code`, `codex-cli` (`kind="agent"`) and `jupyterlab`
(`kind="jupyter"`), plus the twelve `_BUILTIN_BASH_*` install/
remove/probe blocks that backed them.
- `sublime/tests/test_managed_remote_extension_catalog.py` —
`test_catalog_contains_jupyter_extension_entry` and
`test_catalog_contains_agent_extension_entries`.
- `planning/AGENT_TMUX_LAYOUT.md` (Track D layout design doc).
- The frozen-experimental docstring in
`managed_remote_extension_catalog.py` and the matching
`Sessions.sublime-settings` comment block.
---
## Already shipped from this batch

View File

@@ -9,6 +9,12 @@ Evergreen architecture contracts:
- `PYTHON_RUST_BOUNDARY.md` — what lives where, lifecycle invariants.
- `VSCODE_REMOTE_TRANSPORT_MODEL.md` — single-session + channel envelopes.
## v0.7.x — Track G git/SCM, sync mode, Rust ownership
| ver | landed | module(s) |
|---|---|---|
| 0.7.25 | **Cleanup: excise Track D residue and the parallel Jupyter catalog row.** Track D (in-Sublime agent integration via tmux) was dropped 2026-04-27; the v0.6.7 commit deleted the live agent code (`agent_tmux`, `agent_window_layout`, `agent_switcher_view`, palette commands) but left three `kind="agent"` catalog rows (`tmux`, `claude-code`, `codex-cli`), nine bash install/remove/probe blocks, the `kind="jupyter"` row for `jupyterlab` (now superseded by `marimo_hosting`), and the matching frozen-experimental docstring + `Sessions.sublime-settings` comment block as install-flow leftovers. All of that residue is now removed: `BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG` drops to four entries (`pyright-langserver`, `ruff`, `rust-analyzer`, `debugpy`), the catalog file shrinks from 358 → 182 lines, and the matching tests (`test_catalog_contains_jupyter_extension_entry`, `test_catalog_contains_agent_extension_entries`) are deleted. `_managed_extension_project_client_keys_for_spec` docstring updated (jupyter → debugger as the non-LSP example), `marimo_hosting.py` comment cleanup (drop dead `tmux`-children + `jupyter_hosting.py` postmortem references — the latter file no longer exists), `commands.py` Open-Remote-Terminal docstring drops "no tmux session multiplexing" framing. README.md + `planning/BACKLOG.md` Track D entry note the 2026-04-30 residue removal date. **No backward compatibility shim** — existing installs that ran the old install flow keep their remote-side `tmux`/`claude-code`/`codex-cli`/`jupyterlab` binaries; users can uninstall manually with the same commands the catalog used to run (apt/dnf/brew for tmux, `rm -rf ~/.claude/bin` for claude-code, `npm uninstall -g @openai/codex` for codex-cli, `pip uninstall jupyterlab jupyter_server jupyterlab_server` for jupyterlab). `debugpy` `kind="debugger"` row stays untouched. **LSP-style project-level override** for the on-save/on-open pipeline: the original Sessions design was that toolchain wiring follows Sublime LSP precedence (package → user → `.sublime-project` `"settings"`), but only the `settings.LSP` row writer (`merge_sessions_lsp_into_project_data`) honored project scope — the on-save toggle path (`_effective_sessions_settings_for_remote_python``load_sessions_settings_from_sublime`) read user settings only, so per-workspace toggling required editing global user settings. Now `_effective_sessions_settings_for_remote_python` accepts an optional `window` argument and overlays `window.project_data().get("settings", {})` on top of the user merge for `sessions_remote_python_auto_diagnostics_on_save`, `sessions_remote_python_auto_diagnostics_on_open`, and `sessions_remote_python_tool_pipeline`. All five callers in `commands_python_pipeline.py` now pass `window`; the two listeners (`on_post_save`, `on_activated_async`) reorder window-resolution before the toggle check. Type-safety: bool keys reject non-bool values silently (fall through to user); pipeline runs through `normalize_remote_python_tool_pipeline`. Six new regression tests in `test_commands.py` pin project-overrides-user / user-wins-when-absent / pipeline-override / wrong-type-rejected / null-project_data-safe / no-window-legacy-path. `Sessions.sublime-settings` header comment documents the precedence chain inline. | `sublime/sessions/managed_remote_extension_catalog.py`, `sublime/sessions/commands.py`, `sublime/sessions/commands_python_pipeline.py`, `sublime/sessions/marimo_hosting.py`, `sublime/Sessions.sublime-settings`, `sublime/tests/test_managed_remote_extension_catalog.py`, `sublime/tests/test_commands.py`, `README.md`, `planning/BACKLOG.md` |
## v0.6.x — tmux-backed remote agent sessions
| ver | landed | module(s) |

View File

@@ -1,192 +0,0 @@
# V0_6_5_REPRO — focused repro for current macOS test pass issues
Short, narrow checklist. **Not** a full feature test (`TEST_CHECKLIST.md`
is for that). Goal here: confirm the v0.6.5 batch-3 fixes work on the
real macOS host that hit them, and capture diagnostic data for the
remaining unresolved issues so the next debug round has signal to work
with.
Run the steps **in order**. Paste the requested log fragments / observed
behavior under each step. If a step fails unexpectedly, stop, capture
the bundle from §10 of the full TEST_CHECKLIST, and ping back here.
## 0. Setup
```sh
cd <Sessions checkout>
git fetch origin && git checkout v0.6.5 # or main once v0.6.5 lands
cargo build --manifest-path rust/Cargo.toml --release --workspace
```
In `Packages/User/Sessions.sublime-settings`, add:
```json
{
"sessions_debug_trace_enabled": true
}
```
(Set `SESSIONS_BRIDGE_DIAG_VERBOSE=1` in the Sublime launch env only if
asked below — it's noisy.)
Restart Sublime, reopen the test workspace.
---
## A. Verify just-fixed items (should now PASS)
### A1. Agent tmux spawn — no `not a terminal`
Palette → `Sessions: New Agent Session` → pick `Claude Code CLI (remote)`.
- [ ] **No** "Sessions warning: Agent session start failed ... open
terminal failed: not a terminal". Terminus pane opens; tmux
session `sessions-agent-<ws>-claude-code` runs.
- [ ] On the remote: `tmux list-sessions | grep sessions-agent` shows it.
If this still errors, paste the full warning string + grep
`bridge.rust.helper_stdout_message` lines around the failure timestamp
from `<Sublime cache>/Sessions/logs/debug-trace.log`.
### A2. New Remote Terminal Pane / Kill Remote Terminal in palette
- [ ] Palette → type "Sessions: New Remote Terminal Pane" — entry now
appears. Select it; numbered tmux session
(`sessions-term-<host>-2`) opens.
- [ ] Palette → "Sessions: Kill Remote Terminal" — entry now appears.
Select it; quick panel lists live terminals; pick one to kill.
### A3. localhost:PORT canonical URL
In any Terminus pane: `python3 -m http.server 8080`.
- [ ] Hover the `0.0.0.0:8080` line — underlined.
- [ ] Cmd+click → browser opens **`http://localhost:8080/`** (canonical
form with `localhost` host + trailing slash). Should NOT be
`about:blank-` or `about:blank` anymore.
- [ ] Repeat with `127.0.0.1:8080` line — opens
`http://127.0.0.1:8080/`.
### A4. `Sessions: Preview Remote Agent Payload` hidden
- [ ] Palette → type "Sessions: Preview" — should **not** show
"Preview Remote Agent Payload" by default.
- [ ] In `Packages/User/Sessions.sublime-settings`, add
`"sessions_show_dev_commands": true`. Reload settings (or
restart). Re-type — entry now appears.
- [ ] Revert the setting back to `false` (or remove the line).
---
## B. Still-broken — capture diagnostic data
### B1. mirror-sync deep traversal hang at `awaiting_response_dispatch` — **[diagnosed + fixed v0.7.5]**
Symptom from previous capture: every ~60s a deep
`mirror-sync force_full_sync=true max_traversal_depth=12` request hangs
at `bridge.request_timeout` after 45s with
`stall_phase=awaiting_response_dispatch`. Shallow sync (depth 2) returns
in <300ms. Workspace effectively never finishes hydrating, so
"No deferred directory to expand" fires (deferred state never recorded)
and `sync.done` never lands (eager-hydrate retry never fires).
**Root cause confirmed (2026-04-27 capture against `aws-celery`)**:
helper is alive and streaming responses continuously throughout the
45 s window (verbose `bridge.rust.helper_stdout_message` events every
~200 ms; line_bytes 200-120 KB; no `helper_stdout_eof`). Adjacent
`file/stat` / `file/watch` requests complete normally during the same
window (bridge plumbing healthy). The cycle immediately before the
captured timeout — `mirror-sync 300` — completed at `elapsed_ms=44952`,
literally 48 ms inside the 45 s budget. The next cycle (`mirror-sync
308`) didn't make it. So the hang is **not** OOM, **not** a dispatcher
stall — the deep walk legitimately runs ~45-50 s on this remote, just
exceeding the timeout. `stall_phase=awaiting_response_dispatch` is only
the Python-side label for `RequestOutcomeKind.TIMEOUT`, not an actual
"stall" state. Both prior gut hypotheses (helper death, channel buffer
overflow) ruled out by the trace.
**Fix shipped in v0.7.5** (see BACKLOG M5 for the full release notes):
- `sessions_mirror_max_traversal_depth` default 12 → 5 so auto-deepen
stays well under budget on slow tunnels.
- Mirror-sync timeout split from the generic 45 s into a separate 90 s
default, configurable via `sessions_mirror_sync_timeout_s`.
- Auto-refresh exponential backoff (1×, 2×, 4×, 8×, 16× capped) on
consecutive failures + reset on first success — stops the every-
minute pile-on while a helper is still working.
This entry is left as the diagnosis record; the original capture
recipe below stays useful for any future timeout-shaped repro.
Capture:
1. Set `SESSIONS_BRIDGE_DIAG_VERBOSE=1` in the Sublime launch env (so
`bridge.rust.helper_stdout_message` lines also land in the trace).
2. Connect to the host that reproduces this (the aws-celery host).
3. Wait 5 minutes — long enough for at least 2 timeout cycles.
4. From `<Sublime cache>/Sessions/logs/debug-trace.log`, paste the
block of lines between two consecutive `mirror_queue.enqueue
task=work` events that wrap a `bridge.request_timeout`. Should
include all `bridge.rust.*` lines in between.
What to look for in the paste:
- Any `bridge.rust.helper_stdout_eof` — helper closed stdout before
responding (suggests session_helper died on the remote, possibly
out-of-memory on the deep walk).
- Any `bridge.rust.helper_stdout_message` with abnormally long line
payloads — large response chunks that may exceed channel buffer
and stall the dispatcher.
- The final `bridge.request_done` (if any) for the `mirror-sync` id
before the timeout fires.
Also: on the remote, while a deep sync is in flight, run
`ps -ef | grep session_helper` and paste output. We want to see if
the helper is actually busy (CPU > 0) or idle (already responded but
the response is stuck somewhere local).
### B2. Hover absolute remote path → does not open in Sublime
`ls -la /etc` (or any deep path) in a Terminus pane.
- [ ] Hover an absolute path line. Underline appears? **yes / no**
- [ ] Cmd+click. Sublime opens the file? **yes / no — what happens
instead** (about:blank? nothing at all? error in console?)
If nothing opens: paste any line from `debug-trace.log` matching
`terminal_link.click` or `bridge.request` that lands within ~3 seconds
of the click.
### B3. `Sessions: Open Remote Jupyter` — silent
Palette → "Sessions: Open Remote Jupyter".
- [ ] Browser tab opens? **yes / no**.
- [ ] `Sessions: Stop Remote Jupyter` available afterward?
- [ ] Paste lines from `debug-trace.log` matching `jupyter` and
`queue.dequeue task=jupyter_open`. The previous capture showed
`queue.done elapsed_ms=27748` but no visible browser tab —
need to know whether the launch URL is being constructed at
all, or constructed but not opened.
### B4. `Sessions: New Agent Session` quick panel
If A1 succeeds, this is likely also fine. If A1 still errors, A1 is
the upstream cause of "역시 아무 것도 뜨지 않음".
Confirm: after A1 succeeds, can you run `Sessions: New Agent Session`
again and pick `OpenAI Codex CLI (remote)` to start a second pair?
---
## What to send back
For each step under §A, mark **PASS / FAIL** + behavior summary.
For each step under §B, paste:
- the requested log fragments (B1, B3 especially)
- a short observation note ("nothing opens", "browser opens to wrong
URL X", etc.)
Optional: attach the full debug-trace.log slice from the start of the
session to the end of the test pass — useful for cross-correlation
when individual steps look fine but downstream behavior breaks.

View File

@@ -1,6 +1,6 @@
[project]
name = "sessions-sublime"
version = "0.7.24"
version = "0.7.25"
description = "Sublime-facing Python code for Sessions."
requires-python = ">=3.8"
license = {text = "MIT"}

12
rust/Cargo.lock generated
View File

@@ -221,7 +221,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "local_bridge"
version = "0.7.24"
version = "0.7.25"
dependencies = [
"base64",
"glob",
@@ -432,7 +432,7 @@ dependencies = [
[[package]]
name = "session_helper"
version = "0.7.24"
version = "0.7.25"
dependencies = [
"base64",
"notify",
@@ -443,7 +443,7 @@ dependencies = [
[[package]]
name = "session_protocol"
version = "0.7.24"
version = "0.7.25"
dependencies = [
"base64",
"serde",
@@ -452,14 +452,14 @@ dependencies = [
[[package]]
name = "sessions_askpass"
version = "0.7.24"
version = "0.7.25"
dependencies = [
"tempfile",
]
[[package]]
name = "sessions_native"
version = "0.7.24"
version = "0.7.25"
dependencies = [
"serde_json",
"session_protocol",
@@ -770,7 +770,7 @@ dependencies = [
[[package]]
name = "workspace_identity"
version = "0.7.24"
version = "0.7.25"
[[package]]
name = "zmij"

View File

@@ -12,7 +12,7 @@ resolver = "2"
[workspace.package]
edition = "2024"
license = "MIT"
version = "0.7.24"
version = "0.7.25"
authors = ["Myeongseon Choi <key262yek@gmail.com>"]
repository = "https://git.teahaven.kr/sublime-rs/sessions"
homepage = "https://git.teahaven.kr/sublime-rs/sessions"

View File

@@ -1,279 +0,0 @@
//! Validated JSON payloads from a remote agent for client-side display (v0).
//!
//! Schema v1: whitespace-only `title` / `unified_diff` rejected; `schema_version`
//! must be a JSON **integer** (not bool/float). The Sublime package calls this
//! logic only via `local_bridge parse-agent-editor-envelope`—see
//! `planning/PYTHON_RUST_BOUNDARY.md` (single source of truth).
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// `sessions.agent_editor_preview`
pub const AGENT_EDITOR_PREVIEW_KIND: &str = "sessions.agent_editor_preview";
/// Supported envelope schema version.
pub const SUPPORTED_SCHEMA_VERSION: i64 = 1;
/// Pre-rendered text for editor-side preview (diff computed remotely).
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct AgentEditorPayload {
pub kind: String,
pub schema_version: i32,
pub title: String,
pub unified_diff: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_remote_path: Option<String>,
}
/// Parse a JSON object into [`AgentEditorPayload`] or return `None` if invalid.
pub fn parse_agent_editor_payload(raw: &Value) -> Option<AgentEditorPayload> {
let map = raw.as_object()?;
let kind = map.get("kind")?.as_str()?;
if kind != AGENT_EDITOR_PREVIEW_KIND {
return None;
}
let version = map.get("schema_version")?;
let schema_version = match version {
Value::Number(n) => {
if !n.is_i64() {
return None;
}
let i = n.as_i64()?;
if i != SUPPORTED_SCHEMA_VERSION {
return None;
}
i32::try_from(i).ok()?
}
_ => return None,
};
let title = map.get("title")?.as_str()?;
let unified_diff = map.get("unified_diff")?.as_str()?;
if title.trim().is_empty() || unified_diff.trim().is_empty() {
return None;
}
let path = match map.get("target_remote_path") {
None | Some(Value::Null) => None,
Some(Value::String(s)) => Some(s.clone()),
Some(_) => return None,
};
Some(AgentEditorPayload {
kind: kind.to_string(),
schema_version,
title: title.to_string(),
unified_diff: unified_diff.to_string(),
target_remote_path: path,
})
}
/// Parse remote command stdout into a payload, or a short error reason.
///
/// Accepts either a single JSON object or extra lines where the **last** non-empty
/// line is the object (prefix log lines).
pub fn parse_agent_editor_envelope_from_stdout(
text: &str,
) -> (Option<AgentEditorPayload>, Option<String>) {
let stripped = text.trim();
if stripped.is_empty() {
return (
None,
Some("Remote agent stdout was empty (expected one JSON object).".to_string()),
);
}
let mut first_decode_error: Option<String> = None;
let first: Value = match serde_json::from_str(stripped) {
Ok(v) => v,
Err(e) => {
first_decode_error = Some(e.to_string());
Value::Null
}
};
if let Value::Object(_) = &first
&& let Some(parsed) = parse_agent_editor_payload(&first)
{
return (Some(parsed), None);
}
let lines: Vec<&str> = stripped
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
if lines.is_empty() {
let msg = first_decode_error
.map(|e| format!("JSON decode failed: {e}"))
.unwrap_or_else(|| "JSON decode failed: unknown".to_string());
return (None, Some(msg));
}
let last: Value = match serde_json::from_str(lines[lines.len() - 1]) {
Ok(v) => v,
Err(e) => return (None, Some(format!("JSON decode failed: {e}"))),
};
if let Some(parsed) = parse_agent_editor_payload(&last) {
return (Some(parsed), None);
}
if !last.is_object() {
return (
None,
Some("JSON root must be an object (mapping).".to_string()),
);
}
(
None,
Some(format!(
"Schema validation failed: expected kind {AGENT_EDITOR_PREVIEW_KIND:?}, schema_version \
{SUPPORTED_SCHEMA_VERSION}, non-empty strings title and unified_diff, optional string \
target_remote_path."
)),
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn round_trip() {
let raw = json!({
"kind": AGENT_EDITOR_PREVIEW_KIND,
"schema_version": SUPPORTED_SCHEMA_VERSION,
"title": "Preview",
"unified_diff": "--- a/x\n+++ b/x\n",
"target_remote_path": "/srv/app/readme.md",
});
let parsed = parse_agent_editor_payload(&raw);
assert_eq!(
parsed,
Some(AgentEditorPayload {
kind: AGENT_EDITOR_PREVIEW_KIND.to_string(),
schema_version: 1,
title: "Preview".to_string(),
unified_diff: "--- a/x\n+++ b/x\n".to_string(),
target_remote_path: Some("/srv/app/readme.md".to_string()),
})
);
}
#[test]
fn optional_path_omitted() {
let raw = json!({
"kind": AGENT_EDITOR_PREVIEW_KIND,
"schema_version": SUPPORTED_SCHEMA_VERSION,
"title": "t",
"unified_diff": "d",
});
let parsed = parse_agent_editor_payload(&raw);
assert!(
matches!(&parsed, Some(p) if p.target_remote_path.is_none()),
"expected Some(payload) without target_remote_path, got {:?}",
parsed
);
}
#[test]
fn rejects_wrong_kind() {
let raw = json!({
"kind": "other",
"schema_version": SUPPORTED_SCHEMA_VERSION,
"title": "t",
"unified_diff": "d",
});
assert!(parse_agent_editor_payload(&raw).is_none());
}
#[test]
fn rejects_bad_schema() {
let raw = json!({
"kind": AGENT_EDITOR_PREVIEW_KIND,
"schema_version": 99,
"title": "t",
"unified_diff": "d",
});
assert!(parse_agent_editor_payload(&raw).is_none());
}
#[test]
fn rejects_non_object() {
assert!(parse_agent_editor_payload(&json!([])).is_none());
assert!(parse_agent_editor_payload(&json!("x")).is_none());
}
#[test]
fn rejects_bool_schema() {
let raw = json!({
"kind": AGENT_EDITOR_PREVIEW_KIND,
"schema_version": true,
"title": "t",
"unified_diff": "d",
});
assert!(parse_agent_editor_payload(&raw).is_none());
}
#[test]
fn rejects_whitespace_title_or_diff() {
let raw = json!({
"kind": AGENT_EDITOR_PREVIEW_KIND,
"schema_version": SUPPORTED_SCHEMA_VERSION,
"title": " ",
"unified_diff": "x",
});
assert!(parse_agent_editor_payload(&raw).is_none());
let raw = json!({
"kind": AGENT_EDITOR_PREVIEW_KIND,
"schema_version": SUPPORTED_SCHEMA_VERSION,
"title": "ok",
"unified_diff": "\n\t\n",
});
assert!(parse_agent_editor_payload(&raw).is_none());
}
#[test]
fn envelope_not_json() {
let (p, e) = parse_agent_editor_envelope_from_stdout("not json");
assert!(p.is_none());
assert!(e.is_some(), "expected err Some");
if let Some(err) = e {
assert!(err.contains("JSON decode failed"), "{err}");
}
}
#[test]
fn envelope_schema_failed_message() {
let raw = json!({
"kind": AGENT_EDITOR_PREVIEW_KIND,
"schema_version": 99,
"title": "t",
"unified_diff": "d",
});
let (p, e) = parse_agent_editor_envelope_from_stdout(&raw.to_string());
assert!(p.is_none());
assert!(e.is_some(), "expected err Some");
if let Some(err) = e {
assert!(err.contains("Schema validation failed"), "{err}");
}
}
#[test]
fn envelope_last_line_wins_with_prefix_logs() {
let body = json!({
"kind": AGENT_EDITOR_PREVIEW_KIND,
"schema_version": SUPPORTED_SCHEMA_VERSION,
"title": "ok",
"unified_diff": "diff",
});
let text = format!("noise line\n{}", body);
let (p, e) = parse_agent_editor_envelope_from_stdout(&text);
assert!(e.is_none());
assert!(p.is_some(), "expected payload Some");
if let Some(payload) = p {
assert_eq!(payload.title, "ok");
}
}
}

View File

@@ -7,7 +7,6 @@
//! - validate the helper handshake
//! - forward requests and return responses/errors
//! - mirror remote directory trees into a local cache ([`remote_cache_mirror`])
//! - parse agent→editor JSON envelopes ([`agent_remote_payload`])
//!
//! # Examples
//!
@@ -18,7 +17,6 @@
//! assert!(default_remote_helper_path().contains("session_helper"));
//! ```
pub mod agent_remote_payload;
pub mod diag_log;
pub mod helper_command;
pub mod lsp_uri_rewrite;

View File

@@ -11,8 +11,7 @@
//!
//! ``main`` only handles version-banner short-circuiting and the top-level
//! mode switch (``lsp-stdio`` subcommand vs. forwarder); ``run`` then dispatches
//! between ``parse-agent-editor-envelope``, persistent mode, and one-shot
//! request mode.
//! between persistent mode and one-shot request mode.
mod cli;
mod lsp_stdio;
@@ -23,7 +22,6 @@ use crate::cli::BridgeCliArgs;
use crate::lsp_stdio::run_lsp_stdio;
use crate::persistent::run_persistent;
use local_bridge::{BridgeCliOutput, BridgeRunError, run_request_over_ssh};
use serde_json::json;
use session_protocol::RequestEnvelope;
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
@@ -79,9 +77,6 @@ fn main() {
}
fn run(args: &[String]) -> Result<BridgeCliOutput, BridgeRunError> {
if args.first().map(String::as_str) == Some("parse-agent-editor-envelope") {
return run_parse_agent_editor_envelope();
}
if args.iter().any(|arg| arg == "--persistent") {
run_persistent(args)?;
return Ok(BridgeCliOutput {
@@ -137,27 +132,6 @@ pub(crate) fn write_bridge_output(
Ok(())
}
fn run_parse_agent_editor_envelope() -> Result<BridgeCliOutput, BridgeRunError> {
let mut buffer = String::new();
std::io::stdin().read_to_string(&mut buffer)?;
let (payload, error) =
local_bridge::agent_remote_payload::parse_agent_editor_envelope_from_stdout(&buffer);
let payload_json = payload
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(BridgeRunError::Json)?;
Ok(BridgeCliOutput {
ok: true,
id: None,
result: Some(json!({
"agent_editor_payload": payload_json,
"agent_editor_error": error,
})),
error: None,
})
}
fn read_stdin() -> Result<String, BridgeRunError> {
let mut buffer = String::new();
std::io::stdin().read_to_string(&mut buffer)?;

View File

@@ -167,14 +167,26 @@
"sessions_remote_terminal_shell": "bash -il",
// After saving a mirrored workspace .py file, run the remote diagnostics pipeline
// (ruff + pyright by default). See planning/REMOTE_DEV_MVP_LSP.md.
// (ruff + pyright by default).
//
// Three keys in this group — ``sessions_remote_python_auto_diagnostics_on_save``,
// ``sessions_remote_python_auto_diagnostics_on_open``, and
// ``sessions_remote_python_tool_pipeline`` — follow LSP-style precedence:
// package default → ``Packages/User/Sessions.sublime-settings`` → the
// ``.sublime-project`` ``"settings"`` block (per-workspace override). Drop
// ``"sessions_remote_python_auto_diagnostics_on_save": true`` into a
// workspace's ``.sublime-project`` to enable on-save lint/typecheck just for
// that project without flipping the global default.
"sessions_remote_python_auto_diagnostics_on_save": false,
// When true, run the same pipeline when a .py buffer under the cache is focused
// (debounced ~1.5s per view).
// (debounced ~1.5s per view). Same project-level override semantics as
// ``sessions_remote_python_auto_diagnostics_on_save``.
"sessions_remote_python_auto_diagnostics_on_open": false,
// Ordered steps: "ruff_lint", "pyright_check" (each runs on the remote host).
// Per-project override allowed via the ``.sublime-project`` ``"settings"``
// block (LSP-style precedence).
"sessions_remote_python_tool_pipeline": ["ruff_lint", "pyright_check"],
// Phase 6.3 channel-based code-server registry. New servers should be added here
@@ -232,12 +244,5 @@
// Each entry runs through bridge exec/once:
// install_argv -> probe_argv -> (status)
// remove_argv -> probe_argv -> (status)
//
// frozen-experimental: the bundled ``kind="agent"`` entries (``tmux``,
// ``claude-code``, ``codex-cli``) are leftovers from the dropped Track D
// (in-Sublime agent integration via tmux, dropped 2026-04-27). They stay
// installable so existing setups keep working, but the recommended path
// is to run agents in an external terminal. Use the LSP entries
// (Pyright, Ruff, rust-analyzer) as the supported surface.
"sessions_remote_extensions": []
}

View File

@@ -2704,7 +2704,7 @@ def _managed_extension_project_client_keys_for_spec(
) -> Tuple[str, ...]:
"""Return managed + legacy project LSP client keys for one catalog spec id.
Non-LSP kinds (``jupyter``) have no Sublime-LSP client rows to manage, so
Non-LSP kinds (``debugger``) have no Sublime-LSP client rows to manage, so
we return an empty tuple for them; only LSP-kind catalog matches contribute
client keys.
"""
@@ -6960,11 +6960,11 @@ class SessionsOpenRemoteTerminalCommand(sublime_plugin.WindowCommand):
Scope is intentionally narrow: a fresh ad-hoc shell for short, simple
commands (``ls``, ``git status``, running a script). No view-reuse cache,
no tmux session multiplexing. ``auto_close=False`` so the pane survives
no session multiplexing. ``auto_close=False`` so the pane survives
an unexpected shell exit — without it a flash-close hides whatever
error the shell printed on its way out, which is the only signal the
user has to diagnose dotfile breakage or remote-root vanish. For
long-running or tmux-heavy workflows the user is expected to open
long-running workflows the user is expected to open
their own external terminal.
"""

View File

@@ -25,7 +25,7 @@ import time
import webbrowser
from dataclasses import replace
from pathlib import Path, PurePosixPath
from typing import Dict, List, Optional, Sequence, Tuple
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from . import commands as _root
from .connect_preflight import ConnectStatus
@@ -69,7 +69,11 @@ from .remote_tool_wiring import (
build_python_lsp_source_action_tool_execution_request,
build_requests_for_python_tool_pipeline,
)
from .settings_model import SessionsSettings, load_sessions_settings_from_sublime
from .settings_model import (
SessionsSettings,
load_sessions_settings_from_sublime,
normalize_remote_python_tool_pipeline,
)
_LOG = logging.getLogger("sessions.commands_python_pipeline")
@@ -114,15 +118,65 @@ _DEBUG_PANEL_NAME = "sessions_debug_setup"
# ---------------------------------------------------------------------------
def _effective_sessions_settings_for_remote_python() -> SessionsSettings:
"""Merge default settings with ``Sessions.sublime-settings`` pipeline keys."""
def _project_settings_block_for_window(window: Optional[object]) -> Mapping[str, Any]:
"""Return ``window.project_data()['settings']`` if structurally valid, else ``{}``.
Mirrors the safety pattern already used elsewhere in this module: tolerate
a missing ``project_data`` callable, ``None`` payloads, and any non-mapping
value at either level instead of raising.
"""
if window is None:
return {}
project_data_fn = getattr(window, "project_data", None)
if not callable(project_data_fn):
return {}
project_data = project_data_fn()
if not isinstance(project_data, Mapping):
return {}
settings = project_data.get("settings")
if not isinstance(settings, Mapping):
return {}
return settings
def _effective_sessions_settings_for_remote_python(
window: Optional[object] = None,
) -> SessionsSettings:
"""Merge default → user → project settings for the on-save/on-open pipeline.
Reads ``Packages/Sessions/Sessions.sublime-settings`` (default) merged
with ``Packages/User/Sessions.sublime-settings`` (user) via
``load_sessions_settings_from_sublime``, then overlays the active
``.sublime-project`` ``"settings"`` block when ``window`` is given.
Mirrors how Sublime LSP layers per-project overrides on top of user
settings — see ``planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`` for the
LSP-style precedence rationale.
"""
base = SessionsSettings()
plug = load_sessions_settings_from_sublime()
on_save = plug.remote_python_auto_diagnostics_on_save
on_open = plug.remote_python_auto_diagnostics_on_open
pipeline = plug.remote_python_tool_pipeline
project_settings = _project_settings_block_for_window(window)
project_on_save = project_settings.get(
"sessions_remote_python_auto_diagnostics_on_save"
)
if isinstance(project_on_save, bool):
on_save = project_on_save
project_on_open = project_settings.get(
"sessions_remote_python_auto_diagnostics_on_open"
)
if isinstance(project_on_open, bool):
on_open = project_on_open
if "sessions_remote_python_tool_pipeline" in project_settings:
pipeline = normalize_remote_python_tool_pipeline(
project_settings.get("sessions_remote_python_tool_pipeline")
)
return replace(
base,
remote_python_auto_diagnostics_on_save=plug.remote_python_auto_diagnostics_on_save,
remote_python_auto_diagnostics_on_open=plug.remote_python_auto_diagnostics_on_open,
remote_python_tool_pipeline=plug.remote_python_tool_pipeline,
remote_python_auto_diagnostics_on_save=on_save,
remote_python_auto_diagnostics_on_open=on_open,
remote_python_tool_pipeline=pipeline,
)
@@ -166,7 +220,7 @@ def _collect_remote_python_pipeline_results(
"""
if not remote_path.endswith(".py"):
return ()
merged = _effective_sessions_settings_for_remote_python()
merged = _effective_sessions_settings_for_remote_python(window)
if not merged.remote_python_auto_diagnostics_on_save:
return ()
if post_save_view is not None:
@@ -301,7 +355,7 @@ def _schedule_remote_python_pipeline(
trigger: RunTrigger,
) -> None:
"""Kick off the remote diagnostics pipeline when targets are valid."""
merged = _effective_sessions_settings_for_remote_python()
merged = _effective_sessions_settings_for_remote_python(window)
targets = _remote_python_pipeline_targets(view, window, merged)
if targets is None:
return
@@ -329,7 +383,7 @@ def _maybe_schedule_remote_python_pipeline_after_cache_push(
"""
if not remote_path.endswith(".py"):
return
merged = _effective_sessions_settings_for_remote_python()
merged = _effective_sessions_settings_for_remote_python(window)
if not merged.remote_python_auto_diagnostics_on_save:
return
if post_save_view is not None:
@@ -550,26 +604,26 @@ class SessionsRemotePythonPipelineListener(sublime_plugin.EventListener):
def on_post_save(self, view) -> None:
"""Lint/typecheck after save when enabled in ``Sessions.sublime-settings``."""
merged = _effective_sessions_settings_for_remote_python()
if not merged.remote_python_auto_diagnostics_on_save:
return
window_fn = getattr(view, "window", None)
window = window_fn() if callable(window_fn) else None
if window is None:
return
merged = _effective_sessions_settings_for_remote_python(window)
if not merged.remote_python_auto_diagnostics_on_save:
return
if _root._remote_save_target_after_local_save(view, window) is not None:
return
_schedule_remote_python_pipeline(view, window, RunTrigger.ON_SAVE)
def on_activated_async(self, view) -> None:
"""Optionally run the pipeline when a ``.py`` cache buffer is focused."""
merged = _effective_sessions_settings_for_remote_python()
if not merged.remote_python_auto_diagnostics_on_open:
return
window_fn = getattr(view, "window", None)
window = window_fn() if callable(window_fn) else None
if window is None:
return
merged = _effective_sessions_settings_for_remote_python(window)
if not merged.remote_python_auto_diagnostics_on_open:
return
# Same rationale as the version-probe listener: don't spawn the
# bridge on a restored project window before the user has
# explicitly reconnected.

View File

@@ -8,16 +8,6 @@ Each :class:`ManagedRemoteExtensionCatalogEntry` bundles:
Add a new built-in extension by appending one frozen row to
:data:`BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG` and wiring any host-specific hints in
``sessions.commands`` if needed.
frozen-experimental — agent surface
-----------------------------------
``kind == "agent"`` rows (``tmux``, ``claude-code``, ``codex-cli``) are leftovers
from the v0.6.0v0.6.7 in-Sublime agent direction. Track D (in-Sublime agent
integration via tmux) was dropped 2026-04-27 — agents now run in an external
terminal that the user manages outside Sublime. The catalog entries stay in the
tree so existing installs keep working, but **do not extend or polish them**;
add new agent entries only after coordinating with the maintainers. See
``planning/BACKLOG.md`` § "Track D" and ``planning/SHIPPED.md`` v0.6.7.
"""
from __future__ import annotations
@@ -90,48 +80,6 @@ export PATH="$HOME/.cargo/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
rustup component remove rust-analyzer 2>/dev/null || true
exit 0
"""
_BUILTIN_BASH_JUPYTER_INSTALL = """\
export PATH="$HOME/.local/bin:$PATH"
GET_PIP_URL=https://bootstrap.pypa.io/get-pip.py
set -e
PKGS="jupyterlab ipykernel"
if ! command -v python3 >/dev/null 2>&1; then
echo "Sessions: python3 required to install Jupyter Lab." >&2
exit 127
fi
if python3 -m pip install --user $PKGS; then exit 0; fi
if command -v pip3 >/dev/null 2>&1 && pip3 install --user $PKGS; then
exit 0
fi
if command -v pip >/dev/null 2>&1 && pip install --user $PKGS; then
exit 0
fi
if python3 -m ensurepip --user --default-pip >/dev/null 2>&1 \\
&& python3 -m pip install --user $PKGS; then exit 0; fi
if command -v curl >/dev/null 2>&1 && curl -fsSL "$GET_PIP_URL" \\
| python3 - --user >/dev/null 2>&1 \\
&& python3 -m pip install --user $PKGS; then exit 0; fi
echo "Sessions: could not install Jupyter Lab." >&2
exit 1
"""
_BUILTIN_BASH_JUPYTER_REMOVE = """\
export PATH="$HOME/.local/bin:$PATH"
PKGS="jupyterlab jupyter_server jupyterlab_server"
python3 -m pip uninstall -y $PKGS 2>/dev/null || true
if command -v pip3 >/dev/null 2>&1; then
pip3 uninstall -y $PKGS 2>/dev/null || true
fi
if command -v pip >/dev/null 2>&1; then
pip uninstall -y $PKGS 2>/dev/null || true
fi
exit 0
"""
_BUILTIN_BASH_JUPYTER_PROBE = """\
export PATH="$HOME/.local/bin:$PATH"
set -e
command -v jupyter >/dev/null 2>&1 || { echo "jupyter not on PATH" >&2; exit 127; }
jupyter lab --version
"""
_BUILTIN_BASH_DEBUGPY_INSTALL = """\
export PATH="$HOME/.local/bin:$PATH"
set -e
@@ -154,94 +102,6 @@ if [ -z "{ACTIVE_PYTHON}" ]; then
fi
"{ACTIVE_PYTHON}" -c "import debugpy, sys; print(debugpy.__version__)"
"""
_BUILTIN_BASH_TMUX_INSTALL = """\
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
if command -v tmux >/dev/null 2>&1; then
tmux -V
exit 0
fi
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update && sudo apt-get install -y tmux
elif command -v dnf >/dev/null 2>&1; then
sudo dnf install -y tmux
elif command -v yum >/dev/null 2>&1; then
sudo yum install -y tmux
elif command -v pacman >/dev/null 2>&1; then
sudo pacman -S --noconfirm tmux
elif command -v brew >/dev/null 2>&1; then
brew install tmux
else
echo "Sessions: no supported package manager found (apt/dnf/yum/pacman/brew)." >&2
echo "Install tmux manually; see https://github.com/tmux/tmux/wiki/Installing" >&2
exit 127
fi
"""
_BUILTIN_BASH_TMUX_REMOVE = """\
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get remove -y tmux 2>/dev/null || true
elif command -v dnf >/dev/null 2>&1; then
sudo dnf remove -y tmux 2>/dev/null || true
elif command -v yum >/dev/null 2>&1; then
sudo yum remove -y tmux 2>/dev/null || true
elif command -v pacman >/dev/null 2>&1; then
sudo pacman -R --noconfirm tmux 2>/dev/null || true
elif command -v brew >/dev/null 2>&1; then
brew uninstall tmux 2>/dev/null || true
fi
exit 0
"""
_BUILTIN_BASH_TMUX_PROBE = """\
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
command -v tmux >/dev/null 2>&1 || { echo "tmux not on PATH" >&2; exit 127; }
tmux -V
"""
_BUILTIN_BASH_CLAUDE_INSTALL = """\
export PATH="$HOME/.claude/bin:$HOME/.local/bin:$PATH"
set -e
if ! command -v curl >/dev/null 2>&1; then
echo "Sessions: curl is required to install Claude Code CLI." >&2
echo "See https://docs.claude.com/en/docs/claude-code/setup for manual install." >&2
exit 127
fi
if ! curl -fsSL https://claude.ai/install.sh | bash; then
echo "Sessions: Claude Code install script failed (URL unreachable?)." >&2
echo "See https://docs.claude.com/en/docs/claude-code/setup for manual install." >&2
exit 1
fi
export PATH="$HOME/.claude/bin:$PATH"
command -v claude >/dev/null 2>&1 && claude --version
"""
_BUILTIN_BASH_CLAUDE_REMOVE = """\
rm -rf "$HOME/.claude/bin"
exit 0
"""
_BUILTIN_BASH_CLAUDE_PROBE = """\
export PATH="$HOME/.claude/bin:$HOME/.local/bin:$PATH"
command -v claude >/dev/null 2>&1 || { echo "claude not on PATH" >&2; exit 127; }
claude --version
"""
_BUILTIN_BASH_CODEX_INSTALL = """\
export PATH="$HOME/.local/bin:$PATH"
set -e
if ! command -v npm >/dev/null 2>&1; then
echo "Sessions: npm is required to install the OpenAI Codex CLI." >&2
echo "Install Node.js / npm first (see https://nodejs.org/)." >&2
exit 127
fi
npm install -g @openai/codex
command -v codex >/dev/null 2>&1 && codex --version
"""
_BUILTIN_BASH_CODEX_REMOVE = """\
export PATH="$HOME/.local/bin:$PATH"
command -v npm >/dev/null 2>&1 && npm uninstall -g @openai/codex 2>/dev/null || true
exit 0
"""
_BUILTIN_BASH_CODEX_PROBE = """\
export PATH="$HOME/.local/bin:$PATH"
command -v codex >/dev/null 2>&1 || { echo "codex not on PATH" >&2; exit 127; }
codex --version
"""
@dataclass(frozen=True)
@@ -307,15 +167,6 @@ BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG: Tuple[
remote_spawn_argv=("rust-analyzer",),
sublime_selector="source.rust",
),
ManagedRemoteExtensionCatalogEntry(
install_catalog_id="jupyterlab",
install_label="Jupyter Lab (remote)",
install_argv=("bash", "-lc", _BUILTIN_BASH_JUPYTER_INSTALL),
remove_argv=("bash", "-lc", _BUILTIN_BASH_JUPYTER_REMOVE),
probe_argv=("bash", "-lc", _BUILTIN_BASH_JUPYTER_PROBE),
install_cwd=None,
kind="jupyter",
),
ManagedRemoteExtensionCatalogEntry(
install_catalog_id="debugpy",
install_label="debugpy (remote Python debugger)",
@@ -328,31 +179,4 @@ BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG: Tuple[
install_cwd=None,
kind="debugger",
),
ManagedRemoteExtensionCatalogEntry(
install_catalog_id="tmux",
install_label="tmux (agent session prerequisite)",
install_argv=("bash", "-lc", _BUILTIN_BASH_TMUX_INSTALL),
remove_argv=("bash", "-lc", _BUILTIN_BASH_TMUX_REMOVE),
probe_argv=("bash", "-lc", _BUILTIN_BASH_TMUX_PROBE),
install_cwd=None,
kind="agent",
),
ManagedRemoteExtensionCatalogEntry(
install_catalog_id="claude-code",
install_label="Claude Code CLI (remote)",
install_argv=("bash", "-lc", _BUILTIN_BASH_CLAUDE_INSTALL),
remove_argv=("bash", "-lc", _BUILTIN_BASH_CLAUDE_REMOVE),
probe_argv=("bash", "-lc", _BUILTIN_BASH_CLAUDE_PROBE),
install_cwd=None,
kind="agent",
),
ManagedRemoteExtensionCatalogEntry(
install_catalog_id="codex-cli",
install_label="OpenAI Codex CLI (remote)",
install_argv=("bash", "-lc", _BUILTIN_BASH_CODEX_INSTALL),
remove_argv=("bash", "-lc", _BUILTIN_BASH_CODEX_REMOVE),
probe_argv=("bash", "-lc", _BUILTIN_BASH_CODEX_PROBE),
install_cwd=None,
kind="agent",
),
)

View File

@@ -262,7 +262,7 @@ class MarimoSessionManager:
"""
self._ssh = ssh_command_builder or _default_ssh_command_builder
# Default run/popen wrap subprocess with CREATE_NO_WINDOW on Windows
# so the underlying ssh / tmux children don't pop a console window
# so the underlying ssh children don't pop a console window
# every time the plugin talks to the remote. Injected overrides
# (unit tests) retain their exact behaviour — the helper returns an
# empty kwargs dict on non-Windows, so the wrapper is a no-op there.
@@ -447,8 +447,7 @@ class MarimoSessionManager:
)
# Pass ``bash -lc <script>`` as a single SSH-side argument so the
# remote login shell doesn't tokenise the script and pass only the
# leading word to ``bash -lc``. (See jupyter_hosting.py for the full
# postmortem of the prior tokenisation bug.)
# leading word to ``bash -lc``.
argv = list(self._ssh(host_alias)) + [
"bash -lc " + shlex.quote(remote_script),
]

View File

@@ -430,6 +430,96 @@ def test_effective_sessions_settings_for_remote_python(
assert isinstance(settings, SessionsSettings)
def test_effective_settings_project_overrides_user_for_on_save(
sublime_settings,
) -> None:
"""``.sublime-project`` ``settings`` block beats user setting (LSP-style)."""
sublime_settings({"sessions_remote_python_auto_diagnostics_on_save": False})
window = FakeWindow(
project_data={
"settings": {
"sessions_remote_python_auto_diagnostics_on_save": True,
},
},
)
settings = commands._effective_sessions_settings_for_remote_python(window)
assert settings.remote_python_auto_diagnostics_on_save is True
def test_effective_settings_user_wins_when_project_lacks_key(
sublime_settings,
) -> None:
"""Missing project key falls through to user/default precedence."""
sublime_settings({"sessions_remote_python_auto_diagnostics_on_save": True})
window = FakeWindow(project_data={"settings": {"unrelated": "x"}})
settings = commands._effective_sessions_settings_for_remote_python(window)
assert settings.remote_python_auto_diagnostics_on_save is True
def test_effective_settings_project_pipeline_overrides_user(
sublime_settings,
) -> None:
"""Project ``sessions_remote_python_tool_pipeline`` replaces user value."""
sublime_settings(
{"sessions_remote_python_tool_pipeline": ["ruff_lint", "pyright_check"]},
)
window = FakeWindow(
project_data={
"settings": {
"sessions_remote_python_tool_pipeline": ["ruff_lint"],
},
},
)
settings = commands._effective_sessions_settings_for_remote_python(window)
assert settings.remote_python_tool_pipeline == ("ruff_lint",)
def test_effective_settings_project_invalid_type_ignored(
sublime_settings,
) -> None:
"""Non-bool project value for a bool key falls through to user setting."""
sublime_settings({"sessions_remote_python_auto_diagnostics_on_save": True})
window = FakeWindow(
project_data={
"settings": {
"sessions_remote_python_auto_diagnostics_on_save": "yes",
},
},
)
settings = commands._effective_sessions_settings_for_remote_python(window)
# Wrong type is rejected → user setting wins.
assert settings.remote_python_auto_diagnostics_on_save is True
def test_effective_settings_no_project_data_safe(sublime_settings) -> None:
"""Window with ``project_data() is None`` must not raise."""
sublime_settings({})
window = FakeWindow(project_data=None)
settings = commands._effective_sessions_settings_for_remote_python(window)
assert isinstance(settings, SessionsSettings)
def test_effective_settings_no_window_skips_project_merge(
sublime_settings,
) -> None:
"""Calling without ``window`` is the legacy global-only path."""
sublime_settings({"sessions_remote_python_auto_diagnostics_on_save": True})
settings = commands._effective_sessions_settings_for_remote_python(None)
assert settings.remote_python_auto_diagnostics_on_save is True
def test_interactive_ssh_lane_basic() -> None:
commands._begin_interactive_ssh_lane("test-host-lane")
commands._end_interactive_ssh_lane("test-host-lane")

View File

@@ -39,20 +39,6 @@ def test_catalog_project_keys_match_managed_client_snapshot() -> None:
assert SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY in lsp_keys
def test_catalog_contains_jupyter_extension_entry() -> None:
entries = [
e for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG if e.kind == "jupyter"
]
assert len(entries) == 1
entry = entries[0]
assert entry.install_catalog_id == "jupyterlab"
# LSP-specific fields are cleared for non-LSP kinds.
assert entry.project_client_key is None
assert entry.bridge_server_id is None
assert entry.remote_spawn_argv is None
assert entry.sublime_selector is None
def test_catalog_contains_debugger_extension_entry() -> None:
entries = [
e for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG if e.kind == "debugger"
@@ -70,19 +56,3 @@ def test_catalog_contains_debugger_extension_entry() -> None:
assert entry.bridge_server_id is None
assert entry.remote_spawn_argv is None
assert entry.sublime_selector is None
def test_catalog_contains_agent_extension_entries() -> None:
entries = [e for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG if e.kind == "agent"]
assert [e.install_catalog_id for e in entries] == [
"tmux",
"claude-code",
"codex-cli",
]
for entry in entries:
# LSP-specific fields are cleared for non-LSP kinds.
assert entry.project_client_key is None
assert entry.bridge_server_id is None
assert entry.remote_spawn_argv is None
assert entry.sublime_selector is None
assert entry.legacy_project_client_keys == ()

View File

@@ -162,11 +162,7 @@ def test_merge_remote_extension_catalog_user_overrides_builtin_by_id() -> None:
"pyright-langserver",
"ruff",
"rust-analyzer",
"jupyterlab",
"debugpy",
"tmux",
"claude-code",
"codex-cli",
]
assert merged[0].label == "Custom Pyright"
assert merged[0].probe_argv == ("pyright-langserver", "--help")
@@ -188,11 +184,7 @@ def test_merge_remote_extension_catalog_appends_user_only_ids() -> None:
"pyright-langserver",
"ruff",
"rust-analyzer",
"jupyterlab",
"debugpy",
"tmux",
"claude-code",
"codex-cli",
"my-lsp",
]
@@ -350,11 +342,7 @@ def test_load_settings_from_sublime_with_full_mock(monkeypatch) -> None:
"pyright-langserver",
"ruff",
"rust-analyzer",
"jupyterlab",
"debugpy",
"tmux",
"claude-code",
"codex-cli",
}