Compare commits

...

8 Commits

Author SHA1 Message Date
e61e56c21d chore(release): v0.7.24 — sync_mode + terminal pane survival + connect-preempt fix
All checks were successful
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 18s
ci / rust release (push) Successful in 2m50s
ci / rust debug (push) Successful in 2m55s
ci / python (push) Successful in 1m26s
Release Publish (Gitea session_helper) / publish-linux-x86_64 (push) Successful in 4m4s
Three user-visible improvements landed since v0.7.23.

1. ``sessions_sync_mode`` (safe / balanced / full) is the new
   product-level knob for EDR-managed and shared machines. ``safe``
   forces ``sessions_mirror_auto_refresh``,
   ``sessions_mirror_include_files``, and
   ``sessions_connect_auto_open_remote_folder`` to ``false``
   regardless of their per-key value, giving a quiet first connect
   without per-key clamping. ``balanced`` keeps the historical
   default. ``SECURITY.md`` ships a one-paragraph rationale so EDR
   admins can drop ``"sessions_sync_mode": "safe"`` into
   ``Packages/User/Sessions.sublime-settings`` and be done.

2. ``SessionsOpenRemoteTerminalCommand`` no longer flash-closes the
   Terminus pane on shells that lose the stdin handshake. Two
   changes: prefix the remote invocation with
   ``exec </dev/tty >/dev/tty 2>/dev/tty`` so the shell's three
   standard fds are pinned to the SSH-allocated pty before
   anything else (defeats the Terminus pty handshake race that
   killed zsh/bash before the prompt rendered, even with ``-i``);
   and switch ``auto_close`` from ``True`` to ``False`` so any
   unexpected exit (dotfile breakage, vanished remote root, SSH
   drop) leaves the pane visible with the exit message instead of
   hiding it behind a flash-close.

3. Fix ``set.disciscard()`` typo in
   ``_preempt_connect_session_for_new_remote_request`` — the
   AttributeError aborted the queue prune mid-iteration, leaving
   stale ``task_key`` entries that blocked the next equivalent
   connect from being scheduled.

Also planning-side bookkeeping landed in this cycle: BACKLOG opens
Track H (Rust ownership migration; ``open_remote_file_into_local_cache``
to a Rust runtime API, ``commands.py`` service split, queue/watch/
auto-reconnect to the broker), and the Track G v1 bidirectional-sync
plan is now a tracked planning document.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:55:02 +09:00
60a8ad1f0b docs(planning): land Track G v1 bidirectional-sync plan
Bring the post-v0.7.23 audit + redesign of Track G's `.git` sync into
the planning tree as a tracked document. The plan was authored as a
working draft from a code audit + external-tool methodology survey
(Git refspecs, VS Code/Zed remote-dev, Jujutsu's op log, Syncthing
conflict copies); committing it makes the rationale and the phased
delivery (A0 verification → A1 op log → A2 `git bundle` → A3
conflict UI) reviewable alongside the code that will eventually
implement it.

The originally co-authored Track T (Terminus pane survival) section
has been removed from this plan; that fix already shipped in commit
0e2fdd9 (`fix(sublime/terminal): pin stdio to /dev/tty +
auto_close=False`).

Wire it in:

- README planning index links the new file alongside the existing
  PYTHON_RUST_BOUNDARY / VSCODE_REMOTE_TRANSPORT_MODEL / DEEP-RESEARCH
  documents.
- BACKLOG Track G section's v1 scope paragraph points to the plan,
  so contributors landing v1 work see the architecture before
  touching the wipe-and-replace path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:50:51 +09:00
0e2fdd959e fix(sublime/terminal): pin stdio to /dev/tty + auto_close=False
Two changes to ``SessionsOpenRemoteTerminalCommand`` so the Terminus
pane no longer flash-closes when an interactive shell exits
unexpectedly.

1. Prefix the remote invocation with ``exec </dev/tty >/dev/tty
   2>/dev/tty`` so the shell's three standard fds are pinned to the
   SSH-allocated pty before anything else runs. The v0.7.22 ``-il``
   fix targeted bash's non-interactive-on-EOF semantics, but a
   Terminus pty handshake race can still leave the shell with an fd
   that signals EOF on its first read — killing zsh/bash before the
   prompt renders even with ``-i`` set. ``</dev/tty`` bypasses
   whatever stdio Terminus connected and goes straight to the
   controlling terminal.

2. Switch ``auto_close`` from ``True`` to ``False``. With auto-close
   on, any unexpected shell exit (dotfile breakage, vanished remote
   root, SSH disconnect) flash-closes the pane and hides whatever
   error the shell printed on its way out — the only signal the user
   has to diagnose what went wrong. Costs one Ctrl+W on a normal
   ``exit`` — worth it for the broken-path UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:38:31 +09:00
3eaa697419 docs(BACKLOG): open Track H — Rust ownership migration plan
The 2026-04 distribution review flagged that the codebase shape is
closer to "Python calls Rust a lot" than "Rust owns the hot paths":
``commands.py`` (7379 LOC), ``ssh_file_transport.py`` (2240),
``_rust_ffi.py`` (1337) still carry runtime ownership Python should
not. Track H captures the ownership-migration plan as three concrete
sub-tracks driven from the existing PYTHON_RUST_BOUNDARY waves:

- H1: ``open_remote_file_into_local_cache()`` → Rust runtime API
       (single biggest single-file ROI; ssh_file_transport target < 1500 LOC).
- H2: ``commands.py`` service split + module-global state reduction
       (commands target < 4000 LOC; first PR extracts the save service).
- H3: background queue / mirror queue / open-file watch / auto-reconnect
       → Rust broker (auto-reconnect thread first; queues in follow-up PRs).

Each sub-track lists its first-PR scope, conflict surface,
done-when, regression test set, and risk + mitigation. Recommended
PR order H1 → H2-save → H3-reconnect → H2-connect → H3-queue → … is
in the dependency graph.

This is plan-only; no implementation lives in this commit. The
implementation PRs come later, off this BACKLOG entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:32:35 +09:00
007e53628d test(sessions_native): cover ABI truncation contract for output-buffer fns
Python's ctypes caller relies on the "ask, resize, ask" handshake:
when the out buffer is too small, every output-buffer ABI must
return a positive rc equal to the required size (including NUL). A
regression that returns 0 with a silently truncated buffer, or
collapses the contract to a negative error code, would corrupt every
Python helper that does the size dance.

Add the missing buffer-too-small case to five ABI fns that previously
only covered happy-path / null-input. ``normalize_remote_root``
already had this coverage; the new tests extend the same contract to
``bridge_payload_method_label``, ``bridge_error_message``,
``bridge_extract_handshake``, ``bridge_parse_response_packet``, and
``workspace_cache_key`` — the five fns the bridge / persistent broker
hits most.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:32:05 +09:00
0b4fdb0abd feat(settings): introduce sessions_sync_mode (safe / balanced / full)
Sessions already shipped EDR-friendly bandwidth caps and several
auto-on switches (mirror_auto_refresh, mirror_include_files,
connect_auto_open_remote_folder), but security-sensitive users had
to clamp each one by hand. This was the friction the 2026-04
distribution review flagged.

Add a single product-level knob, ``sessions_sync_mode``:

- ``safe``     — quiet first connect for EDR-managed or shared
                 machines; forces the three keys above to ``False``
                 regardless of their per-key value.
- ``balanced`` — historical default; per-key settings unchanged.
- ``full``     — same as balanced today; reserved for future
                 opt-in "more aggressive" defaults.

Implementation: one helper in ``settings_model`` (``sync_mode_bool``)
is consulted from the three ``commands.py`` reader sites. SECURITY.md
gets a ``Sync mode`` section so EDR admins can read one paragraph and
ship ``Packages/User/Sessions.sublime-settings`` with
``"sessions_sync_mode": "safe"`` to neutralise the auto-on paths.
13 new tests cover the helper directly (unit) and the three readers
(integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:31:15 +09:00
5194d34180 docs: align product direction (#29 deprioritised, agent surface frozen)
The 2026-04-25 distribution review reframed `#29` (diff-centric review)
as no longer the next feature, and Track D (in-Sublime agent
integration via tmux) was dropped 2026-04-27. README still listed
`#29` as an open milestone and "multi-session agent window" as an
in-flight evolution; align it with planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md
and planning/BACKLOG.md so a new contributor reads one consistent
direction.

Also tag the still-installable Track D leftovers (kind="agent" rows
tmux / claude-code / codex-cli) as frozen-experimental in the
catalog module docstring and the `sessions_remote_extensions`
settings comment, so users and contributors know not to extend
them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:26:16 +09:00
e52239629e fix(commands): correct disciscard typo in connect-preempt cleanup
`_preempt_connect_session_for_new_remote_request` called
`set.disciscard()` (typo) on the pending-key set; the resulting
AttributeError aborted the queue prune mid-iteration, leaving the
stale task_key behind so the next equivalent connect could not be
re-scheduled. Add a regression test that asserts the key is gone
after preempt without an exception.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:25:23 +09:00
16 changed files with 841 additions and 44 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), [#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29) diff-centric review, [#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32) large-file streaming).
- **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 → **[#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29)** diff-centric product. 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).
- **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).
- **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 (after the MVP above)
- ~~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.
## Repository layout
@@ -30,6 +30,7 @@ Current focus:
| [`planning/VSCODE_REMOTE_TRANSPORT_MODEL.md`](planning/VSCODE_REMOTE_TRANSPORT_MODEL.md) | Envelope + logical channels (VS Codealigned) |
| [`planning/REMOTE_DEV_MVP_LSP.md`](planning/REMOTE_DEV_MVP_LSP.md) | Phase 6.2 LSP / tool transport choices |
| [`planning/DEEP-RESEARCH-REPORT.md`](planning/DEEP-RESEARCH-REPORT.md) | External audit + **priority reconciliation** (end) |
| [`planning/TRACK_G_V1_BIDIRECTIONAL_SYNC.md`](planning/TRACK_G_V1_BIDIRECTIONAL_SYNC.md) | Track G v1 plan: bidirectional `.git` sync redesign (op-log + ref snapshot + `git bundle`, replaces tar-wipe) |
## Installing In Sublime Text

View File

@@ -40,6 +40,34 @@ These are benign — the plugin is simply caching remote files locally and
forwarding ports — but the binaries are unsigned local builds, so they have no
reputation credit to offset the heuristic.
## Sync mode (`sessions_sync_mode`)
The plugin exposes a single product-level knob, `sessions_sync_mode`, that
collapses the "first-connect noise" knobs an EDR administrator most often wants
to clamp into one named tier:
- `safe` — quiet first connect for EDR-managed or shared machines. Forces
`sessions_mirror_auto_refresh`, `sessions_mirror_include_files`, and
`sessions_connect_auto_open_remote_folder` to `false` regardless of their
per-key value. The plugin still works, but no periodic background refresh
runs, the cache contains directory placeholders only (files materialise on
open), and connect does not auto-open the remote folder picker.
- `balanced` — historical default. Per-key settings (auto-refresh interval,
EDR caps, etc.) take effect unchanged. Recommended for most desktop use.
- `full` — same as `balanced` today; reserved for future opt-in "more
aggressive" defaults.
The bandwidth caps that exist independently of the sync mode
(`sessions_mirror_max_entries`, `sessions_mirror_max_dir_fanout`,
`sessions_mirror_writes_per_second_cap`,
`sessions_mirror_auto_prune_stale_cache: false`) still apply in every mode.
Picking `safe` is a strict superset of those caps for the periodic and
auto-open paths.
For policy distribution: shipping `Packages/User/Sessions.sublime-settings`
with `"sessions_sync_mode": "safe"` is enough to neutralise the three
auto-on behaviours without touching individual per-key settings.
## What the binaries do NOT do
- Do NOT modify, encrypt, or delete files outside the plugin's own cache root

View File

@@ -201,6 +201,14 @@ v1 scope: nested repos, submodules, multi-repo workspaces, LFS,
packed-`.git` reconcile fast-path, untracked-not-ignored lazy fetch
polishing, automatic reconcile loop replacing the manual command.
**v1 architecture plan**: see
[`TRACK_G_V1_BIDIRECTIONAL_SYNC.md`](TRACK_G_V1_BIDIRECTIONAL_SYNC.md) for
the full audit + redesign — op log + ref snapshot at every refresh,
`git bundle` over the existing bridge replacing the tar-wipe, and
conflict-copy semantics for diverged refs/files. Replaces the
wipe-and-replace `.git` sync with a CAS-guarded refspec model so
local-only branches and unpushed commits survive every refresh.
### Out of scope
- GitLens-style inline blame / hover annotations. Sublime Text UI
@@ -238,6 +246,133 @@ Final integration agent wires Sublime Merge launch + the manual
---
## Track H — Rust ownership migration (Python monolith reduction) — **[opened 2026-04-29]**
The 2026-04 distribution review (external) flagged that the current
shape is closer to "Python calls Rust a lot" than "Rust owns the hot
paths" — `commands.py` (7379 LOC), `ssh_file_transport.py` (2240),
`_rust_ffi.py` (1337) still carry runtime ownership Python should not.
Track H stops the helper-migration cadence and shifts to *ownership*
migration. It implements the concrete sub-tracks behind
[`PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md) Wave 23 and the
[REVIEW_v0_6_4_DISTRIBUTION_PLAN](REVIEW_v0_6_4_DISTRIBUTION_PLAN.md)
"Stage 4 ownership" line.
User-visible behaviour does not change inside Track H. Anything that
adds new wire format or new commands belongs to a different track.
### H1. `open_remote_file_into_local_cache()` → Rust runtime API
**[file]** `sublime/sessions/ssh_file_transport.py`,
`sublime/sessions/_rust_ffi.py`, `rust/crates/local_bridge/src/`,
`rust/crates/sessions_native/src/`
**[conflict with]** Track G v1 (working-tree materialiser shares the
read path), M3 (auto-format race lives in the same save flow).
**[done-when]** Python `open_remote_file_into_local_cache()` shrinks
to a thin wrapper around one Rust call; remote read → open guardrail
→ local cache write happens inside one Rust transaction. Target:
`ssh_file_transport.py` < 1500 LOC. Pairs with Gitea #24 / #27.
First-PR scope:
1. New Rust module (`local_bridge::file_open` or
`sessions_native::runtime::file_open`) that bundles the existing
`sessions_file_open_guard_reason`, the bridge `file/read`, and the
cache write into a single function returning a structured outcome.
2. Python wrapper in `_rust_ffi.py` that calls the new ABI; the
pre-existing Python implementation is **deleted in the same PR**
(single-source-of-truth rule from `PYTHON_RUST_BOUNDARY.md`).
3. Save / reload / hydrate / stale-refresh call sites become thin
wrappers — the transaction is owned by Rust.
4. Regression coverage: `test_remote_file_metadata`,
`test_eager_hydrate`, `test_cmd_save`, `test_file_pipeline` pass
against the new path.
Risk: save-conflict UI and the save barrier currently live in Python
(Sublime UI thread). Pulling the *decision* into Rust would force a
new sync surface; the first PR keeps the decision (warning popup) in
Python and only moves guardrail + read + write.
### H2. `commands.py` service split + module-global state reduction
**[file]** `sublime/sessions/commands.py` (7379 LOC today), new
`sublime/sessions/commands_*.py` modules (the
`commands_file_actions.py` / `commands_python_pipeline.py` pattern is
already established).
**[conflict with]** H1 (the save / reload / hydrate sites are touched
by H1 too — bundle them in the same PR or H1 will land first), Track
G (commands.py hosts much of the git track wiring).
**[done-when]** `commands.py` < 4000 LOC; six service modules
(connect / sync / git / lsp / save / terminal) each own their state;
at least half of the module-globals (`_BACKGROUND_PENDING_KEYS`,
`_HYDRATE_IN_FLIGHT`, `_MIRROR_AUTO_REFRESH_*`,
`_OPEN_FILE_WATCH_WINDOWS`, …) become service-local.
First-PR scope: extract the **save** service into
`commands_save.py` (save / barrier / conflict UI + the related state
keys: `_OPEN_REQUEST_SERIAL_BY_WORKSPACE`, `_HYDRATE_REVERT_COOLDOWN`,
…). Regression coverage from `test_cmd_save`, `test_cmd_auto_reload`,
`test_save_*`. Connect/mirror/git/lsp services follow in their own
PRs.
Risk: naive file split easily creates import cycles. Mitigation:
move state and helpers **into the service module** rather than
re-export from `commands.py`; allow only `service module → commands`
direction in imports, never the reverse.
### H3. Background queue / mirror queue / open-file watch / auto-reconnect → Rust broker
**[file]** `sublime/sessions/commands.py` (queue/worker/watch
functions), `sublime/sessions/_rust_ffi.py` (broker FFI),
`rust/crates/sessions_native/src/broker*.rs`,
`rust/crates/local_bridge/`.
**[conflict with]** H1 (open-file watch shares the read path), H2
(landing the commands split first makes this PR much smaller).
**[done-when]** `_BACKGROUND_TASK_QUEUE`, `_MIRROR_TASK_QUEUE`,
`_OPEN_FILE_WATCH_*`, and the auto-reconnect thread no longer exist
in Python or are reduced to a status-callback hook on Rust broker
events. The boundary doc's "multiplexed stdio / channel supervisor"
responsibility is owned by Rust.
First-PR scope: auto-reconnect thread → Rust broker. The
`sessions_broker_*` FFI (open_session, reset, handshake, is_active)
already exists; broker drives health probing, Python only receives
the status callback. Regression coverage:
`test_bridge_lifecycle`, `test_connect_workflow`, the
reconnect-specific test cases.
Risk: moving the queue (later PRs) changes the meaning of the
generation token / connect-preempt rule (the `disciscard` typo from
2026-04-29 lived in this exact area). Mitigation: the first PR moves
*the thread*, not the queue. Queue semantics stay identical until a
follow-up PR explicitly re-derives them on the Rust side.
### Dependency graph (Track H)
```
H1 ──▶ H2-save (save service is a thin wrapper after H1)
H1 ──▶ H3 (open-file watch sits on H1's ownership boundary)
H2-save ──▶ H3-reconnect (status callbacks land cleanly into a service)
```
Recommended PR order: H1 → H2-save → H3-reconnect → H2-connect →
H3-queue → H2-mirror → H3-mirror-queue.
### Out of scope (Track H)
- New features, new ABI / protocol / wire format. Track H is
**ownership only**; user-visible behaviour must not change inside
the track.
- Cosmetic clean-up of Python wrappers. That belongs to a separate
PR after Track H lands.
---
## Track W — Windows parity (surfaced by the v0.6.0/v0.6.1 test pass)
*Several features rely on POSIX assumptions; v0.6.1 patched the

View File

@@ -0,0 +1,271 @@
# Track G v1 — Bidirectional `.git` Sync
**Status:** Draft plan, post-v0.7.23. Authored from a code audit + external-tool methodology survey.
**Symptom triggering this plan** (verbatim from `test.log`):
> Sublime Merge에서 만든 로컬 `test` 브랜치는 살아 있는데 **remote에는 전파되지 않음**.
The user's framing: 단순 양방향 sync로는 race condition을 못 풀고 한쪽이 다른쪽을 덮어쓰니, 협업 에디터들의 방법론을 차용하자.
> Note: the Terminus pane-survival diagnosis that originally accompanied this
> audit was landed separately as commit `0e2fdd9`
> (`fix(sublime/terminal): pin stdio to /dev/tty + auto_close=False`). This
> document is now scoped to bidirectional `.git` sync only.
---
## 1. What the audit found (concrete)
Current Track G v0 architecture (commands.py:7115+):
```
on every mirror sync.done:
for each discovered repo:
1. read post-checkout marker → git checkout <new_head> on remote
2. probe remote ref fingerprint; skip if unchanged
3. tar -czf .git | base64 → WIPE local .git → untar
4. re-install post-checkout hook
5. materialise dirty working-tree files
```
Three classes of problem with this model:
### A. Hook-based op capture is unreliable in our environment
`windows.log` evidence: every `git.checkout_proxy` event in the trace shows `proxied: false`**the post-checkout hook never fired during the user's `test`-branch reproduction**. There are three plausible explanations and they're not mutually exclusive:
- **A1.** Sublime Merge uses libgit2 internally, and **libgit2 does not invoke client-side hooks by default.** This means a fundamental class of user actions performed via Sublime Merge — `git checkout`, `git checkout -b`, branch deletes — never hit our hook. **If true, the entire marker-based mechanism is dead-on-arrival for our primary user.**
- **A2.** `post-checkout` only fires on checkout; `git branch -d`, `git branch -m`, and `git commit` never trigger it regardless of front-end. Failure modes #2 (delete), #4 (commit) are uncovered by design.
- **A3.** Multiple checkouts in quick succession overwrite the single per-repo marker file (`git_branch_proxy.py` keeps one marker per repo); intermediate states are lost.
**A1 is the load-bearing finding.** Before any plan that depends on hooks, we have to verify it. But we should plan as if it's true, because the alternative — building a working hook around libgit2 — is harder than removing the dependency on hooks entirely.
### B. Wipe-and-replace is structurally hostile to local writes
`git_dot_git_sync.py:194` — every fetch tick removes the entire local `.git` (preserving only `SESSIONS_PENDING_CHECKOUT`) and replaces it with the remote tarball. Anything the local user wrote into `.git` between fetches that isn't on the preserved-files list is destroyed:
- A branch ref the user created locally that doesn't exist on remote yet (failure mode #1).
- A commit object Sublime Merge wrote locally that hasn't been pushed (failure mode #4).
- Stash entries, reflog entries, refs/notes entries.
The v0.7.23 mirror-boundary fix prevents the *outer* mirror from pruning `.git`, but the *inner* tar replace still does the same damage. This is the single biggest correctness hole.
### C. No three-way diff over ref state
Track G has no memory of "what the local refs looked like at the end of the last successful refresh." Without that, it can't tell:
- Did the user create `refs/heads/test` locally? Or did remote have it last time and we just lost it?
- Did the user delete `refs/heads/feature/old`? Or is it just absent from the remote and we should let it be?
Every failure mode reduces to "we couldn't tell who changed what since the last sync."
## 2. What we steal from the methodology survey
The survey (full report in research notes) covered Git refspecs, VS Code/Zed/Gateway remote-dev, CRDTs, OT, file-sync conflict copies, and Jujutsu. The honest landings:
- **CRDTs**: wrong tool. Ref state is a CAS-on-pointers problem under structural constraints, not a free-form text merge. Adopting Automerge here multiplies storage and replaces a tractable problem (Git already solved it) with an intractable one (semantic merge of pointer values).
- **Headless backends (VS Code Server, Zed Headless, JetBrains Backend)**: foreclosed. Sublime Merge is a separate native app that wants a real on-disk `.git`; the whole reason we have a local mirror is to feed it. The headless answer would invalidate the project.
- **OT**: the algorithm doesn't apply (refs aren't a stream of insert/delete ops), but the **central-arbitrator pattern does** — and we already have one (the remote box's `.git`).
- **Git's own model**: directly applicable. Two clones of the same repo never silently overwrite each other because of refspec namespacing + fast-forward checks + `--force-with-lease`. We are reinventing this badly.
- **File-sync conflict copies (Syncthing/Dropbox)**: directly applicable for the working-tree edge cases.
- **Jujutsu's operation log**: directly applicable as the foundation we're missing.
## 3. The redesign — three changes, in dependency order
### Change #1 — Op log + ref snapshot at every refresh boundary *(foundation)*
Promoted from "safety net" to foundation because of finding A1: without reliable hooks, **we have to detect ref-state changes by polling**, and polling needs a baseline.
Add a sessions-owned sidecar under each repo: `.git/sessions/op-log.jsonl` and `.git/sessions/last-snapshot.json`. The snapshot stores `{ref_name → sha}` and the symbolic `HEAD` target for both local and remote at the end of the last successful refresh.
```text
each refresh tick (per repo):
before = read_snapshot() # {local: {refs}, remote: {refs}}
local_now = read_local_refs() # cheap: walk refs/heads/*
remote_now = exec(host, "git for-each-ref ... ; HEAD") # cheap: one exec/once
diff = three_way(before, local_now, remote_now)
apply(diff) # ← Changes #2 + #3
write_snapshot({local: local_now, remote: remote_now})
append op_log({ts, diff, actions, errors})
```
The diff classifies every ref into one of:
- `unchanged` — both sides match the snapshot. Skip.
- `local_only_new` — local has it, remote doesn't, snapshot didn't have it on either. **User created.** Action in Change #2.
- `local_only_deleted` — snapshot had it on both, neither has it now. (Edge case — only happens if user deleted on both sides between ticks.)
- `local_deleted` — snapshot had it on local, local doesn't. **User deleted.** Action in Change #2.
- `remote_only_new` — remote has it, local doesn't, snapshot didn't have it. **Remote teammate created.** Mirror into local.
- `remote_deleted` — snapshot had it on remote, remote doesn't. Mirror local prune.
- `local_advanced` — local SHA is descendant of snapshot SHA, remote SHA == snapshot SHA. **User committed.** Action in Change #2.
- `remote_advanced` — same on remote side. Fast-forward local.
- `diverged` — both sides moved differently. Surface to user; do nothing automatic. Action in Change #3.
Op log is append-only JSONL, rotated at N=1000 lines or 30 days. Gives us a "Sessions: Undo Last Sync" command that walks the most recent entry and restores ref state via `git update-ref`. Critically: it gives us **debuggability** — when refs vanish, we know which tick wiped them.
**Invariants:**
- Every ref-mutating action writes to the log *before* the action (write-ahead).
- The log lives under `.git/sessions/` so git itself ignores it.
- Snapshots are atomic: write to `last-snapshot.json.tmp`, fsync, rename.
- **The whole `read snapshot → diff → apply → write snapshot` sequence runs under a per-repo flock on `.git/sessions/refresh.lock`.** Sessions stacks overlapping refresh ticks (the `mirror_queue` evidence in `windows.log` shows multiple `dequeue` events for the same workspace within the same second); without the lock, two ticks read the same baseline, both compute "local_only_new" for the same ref, both call `update-ref` with the same `expected_old`, the second's CAS fails, and the diff classifier treats it as divergence — false-positive UI noise that trains users to dismiss real divergence. The lock is `fcntl.flock(LOCK_EX | LOCK_NB)`; on contention skip the tick (the next one picks up the new state). This is *not* deferred to v1+; it's part of Change #1 itself.
**On "undo".** The op log enables a forensic command — `Sessions: Show Last Sync` — that displays the previous tick's diff and resulting ref state side-by-side, lets the user copy SHAs, and offers a *local-only* "restore local refs from snapshot" action. It does **not** undo remote-side changes that have already been pushed (those may have been built on by other consumers; rolling them back via `--force-with-lease` is a separate user-driven decision, not a button in the editor). The naming reflects this: forensic + local-restore, not "undo." If users need remote rollback they run `git push --force-with-lease` themselves with the SHA the readout gave them.
### Change #2 — Replace tar wipe with `git bundle` over the existing bridge *(eliminates the wipe)*
Borrow Git's own model. After Change #1's diff classifies what happened, perform the actual sync via Git primitives instead of tar-replace.
**Transport choice.** The Rust bridge today is `exec/once` only — single round-trip `argv → {exit_code, stdout, stderr}`. There is no streaming/duplex endpoint. That rules out `git fetch ssh://host/path` *through the bridge* (pack-protocol needs a duplex pipe), and it rules out `git fetch ssh://...` running its own SSH child too — that path would respawn `ssh` outside the bridge's ControlMaster on every refresh, regressing the v0.7.21 askpass-flash fix and racing the bridge's auth state.
The right primitive is **`git bundle`**:
- `git bundle create - <refspec>` packs refs + objects into a single self-contained file written to stdout. Fits the existing `exec/once` shape (one argv, one stdout payload, one timeout) — exactly what we already use for the `tar -czf .git | base64` path, just with a vastly smaller payload because bundles only contain the *requested* refs plus reachable objects.
- Bundles support **incremental ranges**: `git bundle create - <new_sha> ^<last_seen_sha>` writes only objects new since the snapshot. Steady-state bandwidth drops from "26 MB tar" to "kilobytes of new commits."
- Local apply: `git bundle unbundle <file>` reads the bundle and writes new objects + advances the named refs. No streaming required either way.
```text
on remote (one exec/once per refresh):
set sessions-scoped config (idempotent, one-time per repo):
git config receive.denyCurrentBranch updateInstead
for each ref in diff.local_only_new diff.local_advanced:
# Send local commits + ref to remote. Reuse `git bundle` in the
# other direction: build bundle locally, ship to remote, unbundle.
local: git bundle create - <local_sha> ^<snapshot_sha_or_empty>
| base64 -w0 → tx
remote (via exec/once):
printf %s "<bundle_b64>" | base64 -d | git -C <root> bundle unbundle /dev/stdin <ref>
git update-ref -m "sessions sync" refs/heads/<name> <local_sha> <snapshot_sha> # CAS
for each ref in diff.local_deleted:
remote: git update-ref -d refs/heads/<name> <snapshot_sha> # CAS
for the active HEAD checkout (the post-checkout case):
if user moved HEAD locally: git -C <root> checkout <new_head> # current behaviour, kept
on local (replaces the tar pull):
remote (one exec/once):
git -C <root> bundle create - --branches \
$(for r in <changed_refs>; do printf '^%s ' "<snapshot_sha_for_$r>"; done)
| base64 -w0
local:
base64 -d | git -C <local-mirror> bundle unbundle /dev/stdin
# bundle wrote into refs/heads/* directly per the bundle's ref names — undesirable.
# Use --map-refs or rewrite: bundle creates with the source ref name; we want
# them under refs/sessions/<host>/heads/*. Fix: bundle uses fully-qualified
# ref names, so on the remote side rewrite the bundle's ref list to
# refs/sessions/<host>/heads/* before piping. (`git bundle` accepts
# "refs/heads/foo" or any other refname; emit them as
# "refs/sessions/<host>/heads/foo" by passing explicit names.)
for each ref in diff.remote_only_new diff.remote_advanced:
git update-ref refs/heads/<name> refs/sessions/<host>/heads/<name> # only if local is ancestor (FF)
for each ref in diff.remote_deleted:
git update-ref -d refs/heads/<name> <snapshot_sha> # CAS
```
Notes:
- The `refs/sessions/<host>/heads/*` namespace gives Sublime Merge an explicit, separate view of the remote tracking refs. It also means we never write into `refs/heads/*` except through fast-forward/CAS, so user-created branches survive every refresh by construction.
- `refs/heads/*` becomes user-territory; the sync layer only **proposes** changes there via the diff classifier. Fast-forwards apply automatically; divergence surfaces to UI (Change #3).
- `--force-with-lease`-equivalent for ref updates: `git update-ref -m "sessions sync" <ref> <new_sha> <expected_old_sha>`. Atomic CAS primitive. If the expected-old check fails (someone moved the ref between our snapshot and our update), abort and treat as `diverged`.
- **Initial seed.** First sync after migration: snapshot is empty, bundles are full ref histories. Same one-shot cost as the v0 tar pull, never repeated. Backfill `refs/sessions/<host>/heads/*` from this first bundle.
**`updateInstead` is not a free pass.** It updates the working tree only when the index and worktree match the new commit's tree on the paths being updated; on dirty conflict the push is rejected. So even with the config flipped, a remote with edits in flight on the active branch refuses our update. Explicit handling:
- The CAS-guarded ref update writes the proposed `new_sha` into the remote's ref store regardless of working-tree state — `update-ref` doesn't touch the worktree.
- The *separate* working-tree update (the "make the worktree reflect the new HEAD" step, equivalent to `git checkout`) is the part that fails on dirty trees. That's the existing G6 path.
- Therefore: split the proxy into ref-mutation (always proceeds via CAS) and worktree-mutation (subject to dirty-tree rejection, retried on next tick). When the worktree update is deferred, the ref already advanced — `for-each-ref` reports the new tip, the local mirror sees it on the next refresh, but the remote *worktree* still shows the old contents until the user resolves dirty state. Surface this state explicitly: status bar `"Branch advanced; remote worktree out of sync (dirty): <files>"`.
**Failure modes addressed.** This Change kills failure modes #1 (local-only branches survive — they live in `refs/heads/*` which is never wiped), #2 (deletion is detected via the diff and propagated via CAS-guarded `update-ref -d`), #3 (CAS via `expected_old_sha` rejects concurrent moves), and #4 (commit objects are bundled and unbundled before any clobber risk). Failure mode #5 stays for Change #3.
### Change #3 — Conflict-copy semantics + divergence UI *(closes the working-tree edge case)*
Two narrow additions for the cases Change #2 surfaces but doesn't auto-resolve:
```text
during materialise(file):
if local.mtime > last_fetch.mtime
and hash(local) != hash(remote)
and hash(local) != hash(last_fetched_remote_for_this_path):
write remote bytes to <file>.sessions-conflict-<ts>
leave <file> alone
enqueue notification
during reconcile_ref where diff == "diverged":
status bar:
"Branch <name> diverged: local=<short_sha> remote=<short_sha>.
Run `Sessions: Resolve Diverged Refs` to choose."
command-palette resolution prompt: [Keep local | Take remote | Open Sublime Merge]
```
`<file>.sessions-conflict-<ts>` is added to `.gitignore` automatically by Sessions (one-time append on first conflict). Resolution is always user-driven; the sync layer never auto-resolves a divergence.
## 4. What we explicitly do *not* do
- **No CRDT for refs.** Wrong tool, wrong constraints.
- **No CRDT for working-tree text.** Sublime doesn't expose buffer state as a manipulable structure; we'd be shipping a parallel editor. Conflict-copy is the right depth.
- **No headless backend.** Foreclosed by Sublime Merge's local-`.git` requirement.
- **No live ref polling between refresh ticks.** The existing refresh cadence is good enough; adding an inotify or filesystem watcher is scope-creep until we have a concrete user complaint about latency.
- **No replacement of the post-checkout hook proxy.** Keep it as a *latency optimisation* — when it does fire (real `git` binary, e.g., user runs `git checkout` in a terminal against the local mirror), the marker gives us sub-second response. When it doesn't fire (libgit2 inside Sublime Merge), the polling diff in Change #1 catches it on the next tick. Belt + suspenders.
---
## 5. Phased delivery
| Phase | Scope | Ships fixes for |
|------|------|---|
| **A0** | Verify finding A1: does Sublime Merge fire client-side hooks? See §5.1 protocol below | (decides A1+ rationale) |
| **A1** | Change #1 — op log + snapshot. Pure addition; no behaviour change. Lets us see what's happening. | Debuggability, not user-visible |
| **A2** | Change #2 — refspec sync replaces tar wipe. Largest single change. | Failure modes #1, #2, #3, #4 |
| **A3** | Change #3 — conflict copies + divergence UI | Failure mode #5, makes A2's diverged-branch case actionable |
A0 must complete before A2 design is finalised (it changes the rationale, not the design). A1 ships first because it's pure addition with no risk. A2 + A3 ship together because A3 closes the UX hole A2 opens.
### 5.1 A0 verification protocol
Sublime Merge has multiple branch-mutation entry points and may use different code paths for each (libgit2 vs shell-out can vary by operation, by platform, and by Sublime Merge version). A one-bit "did we see a marker" answer doesn't generalise. Run the matrix:
- **Sublime Merge build to test against:** the latest stable on the user's primary platform. Record the build number in the report.
- **Setup per repo:** `install_post_checkout_hook` writes the v0 hook; tail `<.git>/SESSIONS_PENDING_CHECKOUT` and the hook's stderr (redirect via `exec 2>>/tmp/sessions-hook-trace.log` in the hook).
- **Operations to exercise** (in order, fresh marker between each):
1. Branch checkout — sidebar double-click on an existing branch.
2. Branch checkout — command palette `Switch Branch`.
3. Branch checkout — context menu on a commit, "Checkout Commit."
4. Branch create — sidebar "New Branch" dialog.
5. Branch create — `git checkout -b` from the embedded terminal (control: this *must* fire the hook; if it doesn't, the hook itself is broken, not Sublime Merge).
6. Branch delete — sidebar right-click "Delete."
7. Commit — stage + commit a small change.
8. Push — push that commit.
- **Per-operation record:** marker file present (Y/N), marker contents (paste verbatim if Y), hook stderr (paste).
Outcomes that change the plan:
- Hook fires for ops 14: A1 is *partially* false; we have a real ops-capture channel for the user's primary path. Plan rationale shifts but Change #1 (polling diff) is still valuable as backstop for delete/commit/push.
- Hook fires only for op 5 (the control): A1 is true for Sublime Merge entirely; Change #1 becomes the sole capture mechanism, hook stays for terminal users only.
- Hook fires for none, including op 5: the hook installation itself is broken; investigate that *first* before any A1 conclusion.
---
## 6. Risks & open questions
1. **A0 outcome.** If Sublime Merge *does* fire hooks (we were wrong about libgit2), Change #1's polling diff is still a strict improvement, but the urgency drops. Plan stays the same; rationale shifts.
2. **`receive.denyCurrentBranch=updateInstead` surprise.** Mutates the user's remote git config. Mitigation: scope per-repo, surface a one-time notification, document in release notes, support opt-out (fall back to current `git checkout` proxy).
3. **Object-pack push size.** First sync after adopting Change #2 will push any local-only commits the user accumulated under v0. Could be tens of MB. Mitigation: gate behind a dry-run + confirm.
4. **Migration from existing wiped-and-restored `.git` directories.** Some installs will have `refs/sessions/<host>/*` empty until the first Change #2 fetch. Backfill on first run; idempotent.
5. **Worktree (`.git` file) repos** — still v1+, deferred. Track G v0 already filters these out (`commands.py:7167`). No regression.
6. **Op-log size on busy repos** — refs/heads/* with thousands of entries × N refresh ticks. Mitigation: log only the *diff* (typical: 03 entries per tick), rotate at 1000 lines.
7. **Concurrent Sessions instances on the same workspace** — two editors open against one host. Today: undefined. Post-A2: each instance's per-repo flock (Change #1 invariant) serialises refresh ticks within an editor; cross-editor contention is also covered because flock is at the OS level on the same `.git/sessions/refresh.lock` file. The losing instance skips its tick and picks up state on the next one.
8. **Critic adjudication notes (post-review).** This plan was reviewed adversarially before sign-off. The top issue raised — "Change #2's transport story is incoherent" — is addressed by switching from `git fetch ssh://...` to `git bundle` over the existing `exec/once` bridge (§3 Change #2 transport choice). Other significant issues addressed inline: `denyCurrentBranch=updateInstead` on dirty trees (§3 "`updateInstead` is not a free pass"), concurrent refresh atomicity promoted from v1+ to v1 invariant (§3 Change #1 invariants, risk #7), A0 verification protocol made explicit (§5.1), "Undo Last Sync" renamed to forensic "Show Last Sync" (§3 Change #1, "On undo"). Outstanding from the review: bandwidth estimate for `for-each-ref` polling (low priority — order-of-magnitude analysis can land with the A1 implementation; if a thousand-ref repo crosses 100 KB/tick we'll add response compression).
---
## 7. Why this is shippable
- A1 is a pure addition (no behaviour change). Ships behind a feature flag, dark-launches the diff classifier.
- A2's footprint replaces `git_dot_git_sync.py:_replace_local_dot_git` (one ~100-line function) with a `git fetch` invocation + a small reconciler. The total spec is **smaller** than what we have.
- A3 is two narrow additions, both cheap.
- Every change is independently reversible: feature flag at the workspace-state level, fall back to v0 tar-wipe for the duration of a release if A2 ships broken.
The single most important sentence in this plan: **stop wiping `.git`.** Every other recommendation flows from that, and from the realisation that hooks are a latency optimisation, not the primary ops capture.

View File

@@ -1,6 +1,6 @@
[project]
name = "sessions-sublime"
version = "0.7.23"
version = "0.7.24"
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.23"
version = "0.7.24"
dependencies = [
"base64",
"glob",
@@ -432,7 +432,7 @@ dependencies = [
[[package]]
name = "session_helper"
version = "0.7.23"
version = "0.7.24"
dependencies = [
"base64",
"notify",
@@ -443,7 +443,7 @@ dependencies = [
[[package]]
name = "session_protocol"
version = "0.7.23"
version = "0.7.24"
dependencies = [
"base64",
"serde",
@@ -452,14 +452,14 @@ dependencies = [
[[package]]
name = "sessions_askpass"
version = "0.7.23"
version = "0.7.24"
dependencies = [
"tempfile",
]
[[package]]
name = "sessions_native"
version = "0.7.23"
version = "0.7.24"
dependencies = [
"serde_json",
"session_protocol",
@@ -770,7 +770,7 @@ dependencies = [
[[package]]
name = "workspace_identity"
version = "0.7.23"
version = "0.7.24"
[[package]]
name = "zmij"

View File

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

View File

@@ -693,6 +693,82 @@ fn broker_open_session_rejects_malformed_extra_env_json() {
assert_eq!(rc, -20, "expected BrokerInvalidJson (-20), got {rc}");
}
// ------------------- truncation contract (output-buffer ABI) -------------------
//
// Python's ctypes caller relies on the "ask, resize, ask" handshake: when the
// out buffer is too small, the function must return a *positive* rc equal to
// the required size (including NUL). A regression that returns 0 with a
// silently truncated buffer, or a negative error code, would corrupt every
// Python helper that does the size dance. Each test below feeds an
// intentionally undersized buffer to one ABI function and asserts the
// positive-required-size invariant.
#[test]
fn bridge_payload_method_label_returns_required_size_when_buffer_too_small() {
let payload = CString::new(r#"{"method":"file/read"}"#).unwrap();
let mut tiny = [0i8; 1];
let rc = unsafe {
sessions_bridge_payload_method_label(payload.as_ptr(), tiny.as_mut_ptr(), tiny.len())
};
assert!(rc > 0, "expected positive required size, got {rc}");
}
#[test]
fn bridge_error_message_returns_required_size_when_buffer_too_small() {
let payload = CString::new(r#"{"error":{"message":"a much longer message"}}"#).unwrap();
let fallback = CString::new("fallback").unwrap();
let mut tiny = [0i8; 1];
let rc = unsafe {
sessions_bridge_error_message(
payload.as_ptr(),
fallback.as_ptr(),
tiny.as_mut_ptr(),
tiny.len(),
)
};
assert!(rc > 0, "expected positive required size, got {rc}");
}
#[test]
fn bridge_extract_handshake_returns_required_size_when_buffer_too_small() {
let payload =
CString::new(r#"{"ok":true,"result":{"handshake":{"remote_home":"/r","arch":"x86"}}}"#)
.unwrap();
let mut tiny = [0i8; 1];
let rc = unsafe {
sessions_bridge_extract_handshake(payload.as_ptr(), tiny.as_mut_ptr(), tiny.len())
};
assert!(rc > 0, "expected positive required size, got {rc}");
}
#[test]
fn bridge_parse_response_packet_returns_required_size_when_buffer_too_small() {
let payload = CString::new(r#"{"id":"req-a","ok":true,"result":{"entries":[1,2,3]}}"#).unwrap();
let mut tiny = [0i8; 1];
let rc = unsafe {
sessions_bridge_parse_response_packet(payload.as_ptr(), tiny.as_mut_ptr(), tiny.len())
};
assert!(rc > 0, "expected positive required size, got {rc}");
}
#[test]
fn workspace_cache_key_returns_required_size_when_buffer_too_small() {
let host = CString::new("prod").unwrap();
let root = CString::new("/srv/app").unwrap();
let profile = CString::new("python").unwrap();
let mut tiny = [0i8; 1];
let rc = unsafe {
sessions_workspace_cache_key(
host.as_ptr(),
root.as_ptr(),
profile.as_ptr(),
tiny.as_mut_ptr(),
tiny.len(),
)
};
assert!(rc > 0, "expected positive required size, got {rc}");
}
#[test]
fn broker_open_session_null_host_returns_null_pointer_code() {
let bridge = CString::new("/bin/true").unwrap();

View File

@@ -87,6 +87,23 @@
// mass-file-write rules.
"sessions_shared_cache_root": null,
// Product-level sync mode. One knob that maps to safe / balanced / full
// defaults for the most user-visible bandwidth and write-volume controls.
//
// "safe" — quiet first connect for EDR-managed or shared machines:
// forces ``sessions_mirror_auto_refresh``,
// ``sessions_mirror_include_files``, and
// ``sessions_connect_auto_open_remote_folder`` to ``false``
// regardless of their per-key value.
// "balanced" — the historical default; per-key settings below take effect
// unchanged. Recommended for most desktop use.
// "full" — same as ``balanced`` today; reserved for future "more
// aggressive" defaults (extra hydrate, eager prune, etc).
//
// Per-key settings below remain authoritative under balanced/full.
// See ``SECURITY.md`` § "Sync mode" for the rationale.
"sessions_sync_mode": "balanced",
// Run periodic background mirror refresh once a workspace is opened.
"sessions_mirror_auto_refresh": true,
@@ -215,5 +232,12 @@
// 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

@@ -111,6 +111,7 @@ from .settings_model import (
RemoteExtensionSpec,
SessionsSettings,
load_sessions_settings_from_sublime,
sync_mode_bool,
)
from .sidebar_project_folders import (
merge_sessions_sidebar_folder,
@@ -385,7 +386,7 @@ def _preempt_connect_session_for_new_remote_request() -> int:
target, args, label, task_key = entry
if target is _connect_selected_host_async:
if task_key:
_BACKGROUND_PENDING_KEYS.disciscard(task_key)
_BACKGROUND_PENDING_KEYS.discard(task_key)
if len(args) >= 3:
prior = args[2]
if isinstance(prior, str) and prior.strip():
@@ -3335,7 +3336,7 @@ def _mirror_options_from_sublime_settings(
return RemoteCacheMirrorOptions(
max_traversal_depth=max_depth,
max_entries=int(getter("sessions_mirror_max_entries", 1000)),
include_files=bool(getter("sessions_mirror_include_files", True)),
include_files=sync_mode_bool(getter, "sessions_mirror_include_files", True),
ignore_patterns=ignore_patterns,
prune_missing=prune_missing,
max_dir_fanout=fanout_cap,
@@ -3388,7 +3389,7 @@ def _mirror_auto_refresh_enabled() -> bool:
getter = getattr(stored, "get", None)
if not callable(getter):
return True
return bool(getter("sessions_mirror_auto_refresh", True))
return sync_mode_bool(getter, "sessions_mirror_auto_refresh", True)
def _mirror_auto_refresh_interval_ms() -> int:
@@ -6379,7 +6380,7 @@ def _connect_auto_open_remote_folder() -> bool:
getter = getattr(stored, "get", None)
if not callable(getter):
return True
return bool(getter("sessions_connect_auto_open_remote_folder", True))
return sync_mode_bool(getter, "sessions_connect_auto_open_remote_folder", True)
def _sessions_reset_workspace_ui_state_on_open() -> bool:
@@ -6955,14 +6956,16 @@ def _write_connected_host_state(mapping: Dict[str, str]) -> None:
class SessionsOpenRemoteTerminalCommand(sublime_plugin.WindowCommand):
"""Open a transient Terminus pane SSH'd into the workspace's remote root.
"""Open a Terminus pane SSH'd into the workspace's remote root.
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, no persistence across pane closes — when
the shell exits the pane closes (``auto_close=True``). For long-running
or tmux-heavy workflows the user is expected to open their own external
terminal.
no tmux 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
their own external terminal.
"""
def run(self) -> None:
@@ -6991,24 +6994,29 @@ class SessionsOpenRemoteTerminalCommand(sublime_plugin.WindowCommand):
)
return
# ``-il`` not ``-l``: ``-l`` alone leaves the shell in
# non-interactive mode when stdin's tty handshake is racy, and
# the shell exits immediately at first read — with
# ``auto_close=True`` that flashes the pane closed before the
# user can do anything. ``-i`` forces interactive mode so the
# shell stays attached to the Terminus pty even when the
# initial fd state is ambiguous. Same fix the pre-Terminus
# external-terminal path used (``bash -il``) — got dropped in
# the d21600f Terminus refactor.
# ``exec </dev/tty >/dev/tty 2>/dev/tty`` first: pin the shell's
# three standard fds to the SSH-allocated pty (``ssh -t``)
# before anything else. Without this pin, a brief Terminus pty
# handshake race can leave the shell with an fd that's already
# signalling EOF on its first read, which kills an interactive
# zsh/bash before the prompt even renders. ``</dev/tty``
# bypasses whatever stdio Terminus connected and goes straight
# to the controlling terminal.
#
# ``-il`` not ``-l``: ``-i`` forces interactive mode so the
# shell doesn't fall back to "non-interactive, exit at first
# EOF" semantics. Combined with the explicit ``</dev/tty``
# pin, this covers both the fd race and the interactivity
# detection path.
#
# ``;`` not ``&&``: if ``cd`` fails (remote_root vanished,
# perm change, mount unavailable) the shell still spawns
# instead of exiting on cd's non-zero exit. ``cd``'s stderr
# stays visible inside the pane and the user lands in
# ``$HOME``.
remote_invocation = "cd {}; exec ${{SHELL:-/bin/sh}} -il".format(
shlex.quote(remote_root),
)
remote_invocation = (
"exec </dev/tty >/dev/tty 2>/dev/tty; cd {}; exec ${{SHELL:-/bin/sh}} -il"
).format(shlex.quote(remote_root))
# ``panel_name`` makes Terminus open the shell as a panel
# docked at the bottom of the active window. Without it
# Terminus defaults to a new tab in the editor pane group,
@@ -7017,13 +7025,20 @@ class SessionsOpenRemoteTerminalCommand(sublime_plugin.WindowCommand):
# had with the pre-Terminus external-terminal path. Single
# well-known panel name keeps successive invocations reusing
# one slot instead of stacking new panels.
#
# ``auto_close=False``: if the remote shell dies for any
# reason (dotfile breakage, ``cd`` to a vanished mount,
# SSH disconnect), keep the pane visible so the user can
# read the exit message instead of watching the panel
# flash-close with no diagnostic. Costs one Ctrl+W on
# normal ``exit`` — worth it for the broken-path UX.
run_command(
"terminus_open",
{
"cmd": ["ssh", "-t", host_alias, remote_invocation],
"cwd": str(context.local_cache_root),
"title": "ssh {}:{}".format(host_alias, remote_root),
"auto_close": True,
"auto_close": False,
"panel_name": "Sessions Terminus",
},
)

View File

@@ -8,6 +8,16 @@ 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

View File

@@ -419,6 +419,59 @@ def load_sessions_settings_from_sublime() -> SessionsSettings:
)
# --------------------------------------------------------------------------
# Sessions sync-mode (product-level "safe / balanced / full" knob).
#
# Sessions ships with a handful of EDR-friendly bandwidth caps (max_entries,
# max_dir_fanout, writes_per_second_cap) and several auto-on switches
# (mirror_auto_refresh, mirror_include_files, connect_auto_open_remote_folder).
# Asking security-sensitive users to clamp each switch by hand was the friction
# point flagged in the 2026-04 distribution review. ``sessions_sync_mode`` is
# the single product-level knob users see; per-key settings still work and act
# as explicit overrides under ``balanced`` / ``full``. Under ``safe`` the keys
# in ``_SAFE_MODE_FORCED_OFF`` are forced to ``False`` regardless of their
# per-key default, so picking ``safe`` once is enough to get a quiet first
# connect on EDR-managed machines.
# --------------------------------------------------------------------------
SESSIONS_SYNC_MODE_KEY = "sessions_sync_mode"
SESSIONS_SYNC_MODE_DEFAULT = "balanced"
SESSIONS_SYNC_MODE_VALUES: Tuple[str, ...] = ("safe", "balanced", "full")
_SAFE_MODE_FORCED_OFF: Tuple[str, ...] = (
"sessions_mirror_auto_refresh",
"sessions_mirror_include_files",
"sessions_connect_auto_open_remote_folder",
)
def resolve_sessions_sync_mode(getter) -> str:
"""Return the validated sync mode (``safe`` / ``balanced`` / ``full``).
``getter`` matches the ``Settings.get(key, default)`` shape used at the
Sublime API boundary; unknown values fall back to ``balanced`` so a typo in
user settings cannot silently change product behavior.
"""
raw = getter(SESSIONS_SYNC_MODE_KEY, SESSIONS_SYNC_MODE_DEFAULT)
if isinstance(raw, str) and raw in SESSIONS_SYNC_MODE_VALUES:
return raw
return SESSIONS_SYNC_MODE_DEFAULT
def sync_mode_bool(getter, key: str, fallback: bool) -> bool:
"""Return effective bool for ``key`` after applying sync-mode overrides.
Under ``safe`` the small list of bandwidth / auto-open keys collapses to
``False`` regardless of what the per-key default or user setting says — that
is the point of safe mode. Under ``balanced`` and ``full`` the per-key
value (or its fallback) is returned unchanged, so existing user settings
keep working without modification.
"""
if key in _SAFE_MODE_FORCED_OFF and resolve_sessions_sync_mode(getter) == "safe":
return False
return bool(getter(key, fallback))
def gitea_registry_http_headers(settings: SessionsSettings) -> Dict[str, str]:
"""Return headers for Gitea generic package GET/PUT (Cloudflare-safe defaults)."""
ua = (settings.gitea_http_user_agent or "").strip() or os.environ.get(

View File

@@ -409,9 +409,11 @@ def test_open_remote_terminal_opens_transient_terminus_pane(
) -> None:
"""Resolves workspace alias + remote root and dispatches to ``terminus_open``.
The terminal is intentionally transient: ``auto_close=True`` so the pane
closes when the shell exits, no view-reuse cache, no tmux. For long-lived
or tmux-heavy workflows the user runs an external terminal themselves.
``auto_close=False`` so an unexpected shell exit (dotfile breakage,
vanished remote root, SSH disconnect) leaves the pane visible with the
exit message instead of flash-closing. No view-reuse cache, no tmux.
For long-lived or tmux-heavy workflows the user runs an external
terminal themselves.
"""
ssh_config_path = tmp_path / "config"
_write_ssh_config(ssh_config_path, "Host prod\n HostName prod.example.com\n")
@@ -450,18 +452,23 @@ def test_open_remote_terminal_opens_transient_terminus_pane(
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
assert len(terminus_calls) == 1
args = terminus_calls[0][1]
# ``-il`` not ``-l``: bash without ``-i`` exits at EOF if stdin's
# tty state is ambiguous on pane spawn, which combined with
# ``auto_close=True`` flashes the pane closed before the user
# sees anything. ``;`` not ``&&`` so a failed ``cd`` doesn't
# take the shell down with it.
# ``exec </dev/tty ...`` pins the shell's stdio to the SSH-allocated
# pty before anything else, defeating Terminus pty handshake races
# that would otherwise leave the shell reading EOF on first read.
# ``-il`` then forces interactive + login mode so neither bash nor
# zsh falls back to non-interactive "exit at EOF" semantics. ``;``
# not ``&&`` so a failed ``cd`` doesn't take the shell down with it.
assert args["cmd"] == [
"ssh",
"-t",
"prod",
"cd /srv/app; exec ${SHELL:-/bin/sh} -il",
"exec </dev/tty >/dev/tty 2>/dev/tty; cd /srv/app; exec ${SHELL:-/bin/sh} -il",
]
assert args["auto_close"] is True
# ``auto_close=False`` so an unexpected shell exit (dotfile error,
# missing remote root, SSH drop) keeps the pane visible long enough
# for the user to read the exit message. Costs one Ctrl+W on a
# normal ``exit`` — worth it for the broken-path UX.
assert args["auto_close"] is False
assert args["title"] == "ssh prod:/srv/app"
assert "cwd" in args
# ``panel_name`` makes Terminus dock the shell as a bottom panel.
@@ -1014,3 +1021,36 @@ def test_connect_command_reloads_ssh_config_each_run(
assert window.quick_panels[0] == [["prod", "prod.example.com"]]
assert window.quick_panels[1] == [["stage", "stage.example.com"]]
def test_preempt_connect_clears_pending_task_keys() -> None:
"""Regression: preempt drains queued connect entries and clears their pending key.
Earlier code called `set.disciscard()` (typo) on the pending-key set; the
resulting AttributeError aborted the queue prune mid-iteration, so a stale
key stayed in `_BACKGROUND_PENDING_KEYS` and blocked the next equivalent
task from being scheduled.
"""
task_key = "connect:test-host"
inner_args = ("dummy_input_id", 0, "test-host")
entry = (commands._connect_selected_host_async, inner_args, "label", task_key)
with commands._BACKGROUND_TASK_LOCK:
commands._BACKGROUND_TASK_QUEUE.append(entry)
commands._BACKGROUND_PENDING_KEYS.add(task_key)
try:
commands._preempt_connect_session_for_new_remote_request()
assert task_key not in commands._BACKGROUND_PENDING_KEYS
with commands._BACKGROUND_TASK_LOCK:
remaining = [
e
for e in commands._BACKGROUND_TASK_QUEUE
if e[0] is commands._connect_selected_host_async
]
assert remaining == []
finally:
with commands._BACKGROUND_TASK_LOCK:
commands._BACKGROUND_TASK_QUEUE.clear()
commands._BACKGROUND_PENDING_KEYS.discard(task_key)

View File

@@ -285,6 +285,48 @@ def test_mirror_auto_refresh_with_settings(sublime_settings) -> None:
assert commands._mirror_auto_refresh_enabled() is False
def test_mirror_auto_refresh_safe_mode_forces_off(sublime_settings) -> None:
# safe sync mode overrides any per-key True; quiet first connect.
sublime_settings(
{
"sessions_sync_mode": "safe",
"sessions_mirror_auto_refresh": True,
}
)
assert commands._mirror_auto_refresh_enabled() is False
def test_connect_auto_open_safe_mode_forces_off(sublime_settings) -> None:
sublime_settings(
{
"sessions_sync_mode": "safe",
"sessions_connect_auto_open_remote_folder": True,
}
)
assert commands._connect_auto_open_remote_folder() is False
def test_mirror_options_safe_mode_clears_include_files(sublime_settings) -> None:
sublime_settings(
{
"sessions_sync_mode": "safe",
"sessions_mirror_include_files": True,
}
)
opts = commands._mirror_options_from_sublime_settings()
assert opts.include_files is False
def test_mirror_auto_refresh_balanced_mode_passes_through(sublime_settings) -> None:
sublime_settings(
{
"sessions_sync_mode": "balanced",
"sessions_mirror_auto_refresh": True,
}
)
assert commands._mirror_auto_refresh_enabled() is True
def test_mirror_auto_refresh_interval_with_settings(sublime_settings) -> None:
sublime_settings({"sessions_mirror_auto_refresh_interval_seconds": 30})
assert commands._mirror_auto_refresh_interval_ms() == 30_000

View File

@@ -4,6 +4,8 @@ from pathlib import Path
import pytest
from sessions.settings_model import (
DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS,
SESSIONS_SYNC_MODE_DEFAULT,
SESSIONS_SYNC_MODE_KEY,
CodeServerSpec,
RemoteExtensionSpec,
SessionsSettings,
@@ -14,6 +16,8 @@ from sessions.settings_model import (
normalize_code_server_specs,
normalize_remote_extension_specs,
normalize_remote_python_tool_pipeline,
resolve_sessions_sync_mode,
sync_mode_bool,
)
@@ -447,3 +451,101 @@ def test_load_settings_bad_base_url_uses_default(monkeypatch) -> None:
monkeypatch.setitem(sys.modules, "sublime", fake_sublime)
settings = load_sessions_settings_from_sublime()
assert settings.gitea_base_url == "https://git.teahaven.kr"
# --------------------------------------------------------------------------
# Sessions sync-mode (safe / balanced / full) — see SECURITY.md § "Sync mode"
# --------------------------------------------------------------------------
class _DictGetter:
"""Minimal stand-in for ``Sublime.Settings.get`` used by the sync-mode helpers."""
def __init__(self, store):
self._store = dict(store)
def __call__(self, key, default=None):
return self._store.get(key, default)
def test_resolve_sessions_sync_mode_defaults_to_balanced() -> None:
assert resolve_sessions_sync_mode(_DictGetter({})) == SESSIONS_SYNC_MODE_DEFAULT
assert SESSIONS_SYNC_MODE_DEFAULT == "balanced"
def test_resolve_sessions_sync_mode_falls_back_on_unknown_value() -> None:
getter = _DictGetter({SESSIONS_SYNC_MODE_KEY: "ultra"})
assert resolve_sessions_sync_mode(getter) == SESSIONS_SYNC_MODE_DEFAULT
def test_resolve_sessions_sync_mode_accepts_known_values() -> None:
for value in ("safe", "balanced", "full"):
getter = _DictGetter({SESSIONS_SYNC_MODE_KEY: value})
assert resolve_sessions_sync_mode(getter) == value
def test_sync_mode_bool_safe_forces_off_three_keys() -> None:
getter = _DictGetter(
{
SESSIONS_SYNC_MODE_KEY: "safe",
"sessions_mirror_auto_refresh": True,
"sessions_mirror_include_files": True,
"sessions_connect_auto_open_remote_folder": True,
}
)
for forced_key in (
"sessions_mirror_auto_refresh",
"sessions_mirror_include_files",
"sessions_connect_auto_open_remote_folder",
):
assert sync_mode_bool(getter, forced_key, True) is False
def test_sync_mode_bool_safe_does_not_touch_unrelated_keys() -> None:
# User-set value for a key NOT in the safe-mode forced-off list passes
# through unchanged.
getter_user_set = _DictGetter(
{
SESSIONS_SYNC_MODE_KEY: "safe",
"sessions_mirror_show_sidebar_after_sync": True,
}
)
assert (
sync_mode_bool(
getter_user_set, "sessions_mirror_show_sidebar_after_sync", False
)
is True
)
# When the key is not set, the caller's fallback is honoured.
getter_no_set = _DictGetter({SESSIONS_SYNC_MODE_KEY: "safe"})
assert (
sync_mode_bool(getter_no_set, "sessions_mirror_show_sidebar_after_sync", True)
is True
)
assert (
sync_mode_bool(getter_no_set, "sessions_mirror_show_sidebar_after_sync", False)
is False
)
def test_sync_mode_bool_balanced_passes_per_key_setting_through() -> None:
getter = _DictGetter(
{
SESSIONS_SYNC_MODE_KEY: "balanced",
"sessions_mirror_auto_refresh": False,
}
)
assert sync_mode_bool(getter, "sessions_mirror_auto_refresh", True) is False
def test_sync_mode_bool_full_passes_per_key_setting_through() -> None:
getter = _DictGetter({SESSIONS_SYNC_MODE_KEY: "full"})
assert sync_mode_bool(getter, "sessions_mirror_auto_refresh", True) is True
assert sync_mode_bool(getter, "sessions_mirror_include_files", False) is False
def test_sync_mode_bool_uses_fallback_when_key_missing() -> None:
getter = _DictGetter({SESSIONS_SYNC_MODE_KEY: "balanced"})
assert sync_mode_bool(getter, "sessions_mirror_auto_refresh", True) is True
assert sync_mode_bool(getter, "sessions_mirror_auto_refresh", False) is False

2
uv.lock generated
View File

@@ -854,7 +854,7 @@ wheels = [
[[package]]
name = "sessions-sublime"
version = "0.7.23"
version = "0.7.24"
source = { virtual = "." }
[package.dev-dependencies]