Skip to content

feat: first-class WSL session support (supersedes #65) - #121

Merged
AThraen merged 46 commits into
mainfrom
feat/wsl-sessions-v2
Sep 7, 2026
Merged

feat: first-class WSL session support (supersedes #65)#121
AThraen merged 46 commits into
mainfrom
feat/wsl-sessions-v2

Conversation

@AThraen

@AThraen AThraen commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Lands first-class WSL sessions (#65 by @Bitblade — thank you!) on top of current main, with every finding from the review of #65 fixed and the newer "Edit session" feature (#98) taught about WSL. Supersedes #65; the contributor's 24 commits are preserved verbatim underneath the hardening commits.

WSL becomes a third SessionKind alongside Local and SSH: the New Session dialog gains a WSL radio with distro auto-detection, a Linux working folder (browse rooted at \\wsl$\<distro>\~), and an optional -u <user>; the session runs wsl.exe -d <distro> [-u <user>] --cd <folder> -- bash -lc "<cmd>". Git status, repo-root detection, worktrees, run commands, sleep/wake, recently-closed and relaunch-from-search all work for WSL sessions.

What the review of #65 found, and what changed

# Finding Fix Test
1 Promote-only IsRemote setter ignored false, so editing an SSH session to Local kept Kind=Ssh; the serialised FullCommandLine then threw on every state.json save Migration moved into the loader: LegacyIsRemote JSON slot + MigrateLegacyFields() called from StateService.Normalize; IsRemote is a [JsonIgnore]d two-way convenience; FullCommandLine never throws. "IsRemote": true is still written for SSH sessions so a rollback to a pre-Kind release keeps them SSH ShellSessionMigrationTests
2 Edit-session dialog, SessionConfigDraft, SessionConfigEditor knew nothing about WSL Draft carries Kind + WslDistro/WslUser/WslWorkingFolder; Apply re-derives the \\wsl$ UNC WorkingFolder; dialog pre-fills WSL mode, keeps an uninstalled distro as (not installed) in edit mode only SessionConfigEditorTests
3 Relaunch from a search hit recreated a WSL session as Local at the UNC session_history.snapshot_json stores a RecentlyClosedEntry; relaunch goes through ReopenClosedSessionAsync; existing DBs upgraded idempotently SearchServiceTests
4 Synchronous Directory.Exists on \\wsl$ on the UI thread boots a stopped distro and froze the app Probe runs on the thread pool; continuation guarded against a disposed VM build + suite
5 Win32 quoting never doubled backslashes before quotes / at the end MSVCRT-correct QuoteForCmd, one BuildWslArgs shared by sessions and run commands Win32CommandLineTests round-trips through the real CommandLineToArgvW
6 Start_Click re-entrancy during the 3 s WSL home probe Submit guard + closed guard build + suite
7 Ubuntu matched Ubuntu-22.04 in the UNC→Linux arg translator Lookahead boundary; one shared WslDiscoveryService.TryParseUncPath replaces two parsers GitServiceWslRoutingTests, WslDiscoveryServiceTests
8 Git poller spawned wsl.exe up to 3× every 10 s forever 30 s cadence for WSL, negative repo-root cache SessionViewModelGitPollingTests
9 Blank distro threw from BuildWslArgs after the pane existed, leaking it ShellSession.LaunchValidationError checked before any UI; RunInstance.Start fails as a run chip instead of throwing ShellSessionTests, SessionRunnerTests
10 CLAUDE.md untouched and now false New "WSL Sessions" section; Kind replaces IsRemote as the documented branch point

Whole-branch review

A final review of the merged branch raised two more items, both fixed here: RunInstance was copying its full 1 MB output buffer on every 4 KB PTY chunk for a property nothing reads (hot-path regression introduced while extracting a helper), and the launch-refusal dialog rendered Cannot start ''. for the default blank session name — the one message on a path that deletes a restored session. Also folded in: a single shared helper that re-derives the \wsl$ UNC so ReopenClosedSessionAsync, SessionConfigEditor.Apply and the creation paths cannot drift; a null-distro guard in the UNC parser; NUL-safe FolderShort; and the New Session dialog no longer serializes the wsl -l -v spawn ahead of the worktree probe.

Test plan

  • dotnet build -c Release — 0 warnings
  • dotnet test tests/CodeShellManager.Tests/457 passed, 0 failed (400 on main)
  • Every hardening commit reviewed by an independent reviewer (spec + quality) before the next task started; whole-branch review at the end
  • Manual: new WSL session → prompt lands in the distro; sidebar branch/dirty; Edit session… on a WSL session changes distro/folder and relaunches; Ctrl+Shift+T and search relaunch restore WSL; state.json from v0.7.0 with IsRemote: true loads as SSH

Known gaps (documented in CLAUDE.md)

  • WSL Claude sessions do not auto-resume on restore (--resume id lookup reads the Windows ~/.claude).
  • A distro name beginning with - is not defended against in wsl.exe option parsing.

🤖 Generated with Claude Code

https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu

Found in manual testing (after the whole-branch review)

Testing on a machine whose only WSL distro was docker-desktop surfaced four more, all fixed here:

  • wsl.exe was double-expanding every payload. wsl <cmd> runs the trailing command through the distro's default login shell, so -- bash -lc "…" got expanded twice, in the wrong environment. wsl -d Ubuntu -- bash -lc 'for t in a b; do echo "L=$t"; done' printed L= twice; with -e it prints L=a / L=b. Any command containing a variable, subshell, glob or tilde was silently wrong — on every distro, not just unusual ones.
  • bash was hardcoded. Distros without it (Alpine, BusyBox images, docker-desktop) could not start a session or a run command at all, whatever the user typed. The login shell is now probed once per distro and falls back to sh.
  • Docker's internal distros were offered in the picker. They are BusyBox plumbing, root-only and rebuilt on Docker updates. Filtered out, so a machine with only those falls through to the existing "No WSL distros found" hint instead of a dead end.
  • A blank Linux Working Folder ignored its own "(optional)" label. No --cd was emitted, so the session inherited the launching Windows process's directory and landed in /mnt/c/Users/<you> on the 9p mount. It now uses --cd ~, a typed ~/proj is expanded first, and the $HOME probe retries at launch because its 3s cap expires on a cold distro.
  • The New Session dialog clipped its own buttons once WSL mode's extra rows pushed the content past the fixed window height. Buttons are docked and the content scrolls.

Bitblade and others added 30 commits May 17, 2026 14:06
Replaces the binary IsRemote flag with a three-valued SessionKind enum so a
WSL session can live alongside Local and SSH ones — picking a distro from a
combobox (auto-detected via `wsl -l -v`), pointing at a Linux working folder,
and optionally pinning a -u user. wsl.exe is the launch process; PTY plumbing
is unchanged.

Legacy state.json that only carried IsRemote still deserializes cleanly: the
IsRemote setter promotes Kind from Local to Ssh, so older files migrate on
first load without bespoke conversion code.

WSL sessions store their WorkingFolder as a `\\wsl$\<distro>\...` UNC view of
the Linux path so Git for Windows, the "Open in Explorer" menu, and the
sidebar's git-branch poll all keep working unmodified — git status, dirty
state, and repo-root-based accent colors light up the same as for Local
sessions.

Run-commands (the F5 / chips strip) now also dispatch correctly inside WSL by
wrapping the command in `wsl.exe -d ... -- bash -lc '<escaped>'`.

The three IsRemote display-label / accent-key ternaries that were drifting
across MainWindow + SessionViewModel are consolidated onto three small
helpers on ShellSession (DefaultDisplayName / FolderShort / AccentKey) so
adding a fourth session kind in future doesn't require chasing call sites.
Mirrors the local Browse… button on the WSL panel. Opens FolderBrowserDialog
rooted at \\wsl$\<selected-distro> (the WSL filesystem appears there as a
native UNC share); on result, parses the picked path back to a Linux path
and, if the user drilled into a different distro than the combo had, updates
the combo too.

ParseWslUncPath is extracted as an internal static so it can be covered
headlessly via InternalsVisibleTo — accepts both the \\wsl$\ and the newer
\\wsl.localhost\ prefixes, plus forward-slash variants.
Symmetry with IsRemote (which remains the SSH predicate). Keeps the
"remote = ssh" convention intact — just gives WSL its own one-liner so
call sites don't have to spell out `Kind == SessionKind.Wsl`.
Git for Windows can't reliably operate on \\wsl$\<distro>\... paths —
the dubious-ownership check refuses to run, and "not a git repo" pops
on perfectly valid repos. Detecting the WSL UNC in the GitService funnel
and dispatching to `wsl.exe -d <distro> -- git -C <linuxPath> <args>`
sidesteps both. Two small translators make the seam invisible:

  TranslateUncArgsToLinux: rewrites \\wsl$\<distro>\foo tokens in the
    arg string to /foo before invocation, so callers can keep passing
    Windows-shaped paths (e.g. `worktree add <unc-target>`).

  TranslateLinuxPathsToUnc: walks git stdout for absolute Linux paths
    (rev-parse --git-common-dir, worktree list --porcelain) and rewrites
    them back to UNC, so the rest of the app sees uniform paths.

Both translators are conservative about what they touch — `refs/heads/foo`
and `M README.md` are passed through untouched per tests.
Three improvements bundled because they're all about the picker landing
somewhere useful:

- Browse seed = the user's home inside the distro, resolved via
  `wsl -d <distro> [-u <user>] -- sh -c "cd ~ && pwd"` and cached
  per (distro, user) so repeated clicks don't re-shell.
- Also set FolderBrowserDialog.InitialDirectory in addition to
  SelectedPath. SelectedPath alone left the COM dialog rooted at the
  user's last folder (typically Documents) and only pre-typed the UNC
  in the entry field — clicking Browse felt broken.
- The WSL panel now has a visible "User (optional)" column header.
  Previously the user textbox was identifiable only by tooltip, which
  read as an empty unlabeled box.
The early-return guard "if NameBox not empty, leave alone" was too greedy:
once we auto-filled the name from the first context, it preserved that
stale value forever — switching distros wouldn't update the suggestion.

Track our own last auto-fill so the guard fires only on truly-user-edited
content. Empty box and "still shows our previous suggestion" are both
treated as free-to-overwrite; anything else is preserved as user input.
…sion

Right-click → "New session here" on a WSL session used to open the dialog
in Local mode with the parent's `\\wsl$\…` UNC pre-filled into the
working-folder textbox — a layer-cake of subtle wrongness.

Now the dialog auto-selects the WSL radio, pre-fills user and Linux
working folder from the parent in the constructor, and remembers the
parent's distro so PopulateWslDistrosAsync can mark the right combo
entry as selected once the async distro list lands.
PowerShell-here on a WSL parent still opens a Windows shell (PS handles
the UNC path well enough as cwd, so it works), but the natural shell to
ask for from a WSL session is bash inside the same distro. Add a sibling
menu item that creates a fresh WSL session pointed at the parent's
distro / user / Linux folder. Only shown when the parent IsWsl.
A WSL session whose user picked "New worktree from this branch" got a
fresh session at the UNC path of the new worktree — but as a Local kind
running PowerShell, so the parent's command (claude, codex, etc.) failed
with "not recognized" inside a PS prompt at \\wsl$\<distro>\....

Same fix applies to "New session in sibling worktree" and the sibling-
worktree checkbox in the New Session dialog: all three created child
sessions without copying Kind/SSH/WSL fields, so a WSL parent quietly
demoted its children to Local.

Centralized in InheritSessionKindFrom, which also derives the child's
WslWorkingFolder from its WorkingFolder UNC (the worktree path the
caller built). DuplicateSessionAsync refactored to use the same helper
— its inline copy logic was about to drift.
…starts clean

Passing the seed UNC to both InitialDirectory and SelectedPath made the
COM file dialog show the raw UNC in the bottom "Folder:" textbox — which
Windows then renders as a truncated, slash-flipped tail (e.g.
"bu/home/bitblade" for \\wsl$\Ubuntu\home\bitblade). InitialDirectory
alone navigates the tree to the right place; SelectedPath was only
making the visible textbox look broken.
…dlock

GetDistrosAsync correctly drains both streams; GetDistroHomeAsync didn't.
If wsl.exe writes enough to stderr (transient init notices, a stopped
distro error) the child would block on its stderr buffer and the stdout
await would never complete — silently falling through to the 3s timeout
on every Browse click for WSL sessions.

Per Copilot review.
The comment claimed Git for Windows handles WSL UNCs natively — but the
preceding GitService routing through wsl.exe exists precisely because
it doesn't. Comment now reflects the actual dispatch.

Per Copilot review.
The previous guard "Kind != Local → return" was too conservative —
RunCommandTemplatesService.SeedFor calls Directory.EnumerateFiles, which
works fine on `\\wsl$\<distro>\…` UNCs, and RunInstance already runs WSL
sessions' commands via `wsl.exe -- bash -lc`. So a WSL project with a
package.json or Cargo.toml at its root now gets its templates the same
way a Local one does. Only SSH stays opted out.

Per Copilot review.
… WSL

RecentlyClosedEntry only captured IsRemote + SSH fields. Closing a WSL
session and reopening it via Ctrl+Shift+T (or the Recently Closed list
in the New Session dialog) resurrected it as Local at the `\\wsl$\…`
UNC path — same failure mode Copilot called out for the worktree path,
just on a different code route.

- Add Kind / WslDistro / WslUser / WslWorkingFolder to the entry record;
  FromSession copies them.
- Mirror the IsRemote→Ssh migration shim on RecentlyClosedEntry too, so
  state.json files written before this commit (no Kind key) still render
  the right subtitle and reopen as SSH.
- ReopenClosedSessionAsync copies Kind first (so a WSL entry doesn't get
  demoted by the IsRemote shim) and the WSL fields second.
- Subtitle now keys on Kind so a WSL entry shows `<distro>: <path>`.

Per Copilot review.
The unquoted regex stops at whitespace, so a quoted UNC like
"\\wsl\$\Ubuntu\home\alice\my repo" (the shape `worktree add <target>`
would produce for a Linux path with a space) used to be half-translated
and yield a broken git command.

Add a two-pass approach: first replace quoted runs (consuming the
content up to the closing quote and re-quoting the Linux output),
then fall back to the existing unquoted pass for arguments that
never needed quoting in the first place.

Per Copilot review.
ShellSession.BuildWslArgs, RunInstance.BuildWslArgs, and GitService's
RunGitInWslAsync concatenated WslDistro/WslUser/WslWorkingFolder
straight into a single argument string. Most distro names are
space-free in practice, but Linux working folders genuinely can have
spaces (`/home/alice/my proj`) and `wsl --cd` then sees two arguments.

Add a conservative QuoteForCmd helper on ShellSession (internal, so
tests reach it via InternalsVisibleTo): leaves space-free values
alone so existing call sites and tests don't churn, and double-quotes
anything that needs it (with embedded `"` escaped as `\"`).

Not migrating to ProcessStartInfo.ArgumentList — PseudoTerminal's API
takes a single command-line string, and reshaping it is out of scope
for this PR. The quoting helper is the surgical fix.

Per Copilot review.
If the user navigated out of `\\wsl$\` (e.g. into `C:\Users\…`) and
picked there, we silently stuffed the Windows path into
WslWorkingFolderBox — `wsl.exe --cd C:\…` then failed at session start
with a confusing message. Show a clear MessageBox naming the picked
path and explaining which folders are valid, and leave the textbox
unchanged so the user can try again.

Per Copilot review.
If the user picked a WSL distro but left the Linux Working Folder
empty, the launcher omitted `--cd` (so the shell correctly landed in
\$HOME) but ToUncPath produced `\\wsl\$\<distro>` — the distro root.
GitService then keyed on that UNC, asked git for status at "/", and
came back empty even when \$HOME was a real repo. Sidebar branch info
silently disappeared.

Make Start_Click async and call GetDistroHomeAsync (the cached lookup
we already use for the Browse picker) when WslWorkingFolder is empty,
so the session's WorkingFolder UNC and its Linux path stay aligned.
Best-effort: if WSL is unreachable, fall through to the existing
behavior (land in \$HOME, no git info).

Per Copilot review.
GetDistrosAsync and GetDistroHomeAsync caught Win32Exception and
FileNotFoundException only, but Process.Start can throw
InvalidOperationException / PlatformNotSupportedException and the
read pipeline can throw IOException — so the doc claim "Never throws —
every failure mode collapses to an empty list" wasn't quite accurate.
A stray exception here crashes the New Session dialog's Loaded
handler, which is a worse outcome than "no WSL distros listed."

Broaden the outer catch to Exception and document why.

Per Copilot review (round 2).
The token splitter took tokens[idx] as the name, so a `wsl --import "My
Distro" …` row was tokenized as name="My", state="Distro", version=0
(the actual "Running"/"Stopped" text isn't a digit, so int.TryParse
silently failed). The picker would then list a phantom "My" distro and
`wsl -d My` would error at session start.

Switch to consuming from the trailing end of the line: VERSION is
always the last token, STATE the second-to-last, and everything in
between (after an optional leading `*` for the default-distro marker)
is the name joined by spaces.

Per Copilot review (round 2, suppressed comment).
Last unquoted wsl.exe-arg interpolation we missed in the earlier sweep.
With Parse now accepting space-containing distro names, the launcher
side has to be ready to receive one — without quoting, a "My Distro"
distro would arrive as `-d My Distro` (two args to wsl.exe) and the
home-resolution would silently fail.

Per Copilot review (round 2, suppressed comment).
RunInstance.BuildWslArgs wrapped the command in POSIX single quotes via
SingleQuoteEscape. But wsl.exe is started directly by CreateProcess (no
outer shell), so Windows command-line tokenization runs first and only
treats "..." as grouping. With single quotes, `bash -lc 'cargo test'`
reached bash split at the space into the two args `'cargo` and `test'`
— bash choked on the unbalanced quote and the run-command failed.

Mirror the double-quote shape ShellSession.BuildWslArgs already uses:
wrap the commandLine in `"..."` and escape embedded `"` as `\"`. The
SSH variant keeps SingleQuoteEscape because the WHOLE bash command
there is itself inside outer SSH double quotes — the structure is
different, so the same trick would actually break it.

Updates the existing tests to expect the double-quote shape and adds
coverage for the embedded-double-quote case.

Per Copilot review.
Same class of regex-stops-at-whitespace bug Copilot already flagged for
TranslateUncArgsToLinux, on the return trip. A worktree path like
`/home/alice/My Projects/repo` came back as
`\\wsl$\Ubuntu\home\alice\My` with the rest left as forward-slashed
garbage attached.

All of our current callers (rev-parse --git-common-dir, worktree list
--porcelain) emit the path as the full remainder of the line, so
widening the tail to `[^\r\n'"<>|]+` (anything but newline / shell-meta)
is safe and recovers space-containing paths. Comment documents the
caller-coupled contract.

Per Copilot review.
Two near-identical manual leaf-extraction loops were doing what
System.IO.Path.GetFileName already does: trim trailing separators,
return the segment after the last one, handle empty input. Path on
Windows recognizes both `/` and `\` so it works fine for the Linux-
style paths these call sites deal with.

- ShellSession.DefaultDisplayName + BuildWslFolderShort drop the local
  LeafName helper.
- NewSessionDialog.AutoFillName drops its inline copy.

No behavior change; the two functions returned identical results for
every input these call sites would ever see.
…a setter

The promote-only setter from #65 ignored false, so SessionConfigEditor.Apply
could never flip an SSH session back to Local, and the still-serialised
FullCommandLine then threw on every save. Kind is the only persisted field;
IsRemote is a [JsonIgnore]d two-way convenience; the legacy JSON key lands in
LegacyIsRemote and StateService.Normalize folds it into Kind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
Diff treats distro/user/Linux-folder like the SSH target fields; Apply sets
Kind directly and re-derives the UNC WorkingFolder so it can't drift from
WslWorkingFolder. Adds the SSH->Local regression test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
ForEdit pre-selects the distro and fills user/Linux folder, the distro list is
populated in edit mode, and the appearance panel is available for WSL sessions
(only SSH hides it). Boot label names the distro.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
AThraen and others added 13 commits September 6, 2026 22:39
QuoteForCmd now doubles backslashes before quotes and at the end of a quoted
value; verified by round-tripping through CommandLineToArgvW. RunInstance
delegates to ShellSession.BuildWslArgs and reports a start failure in the run
output instead of throwing from the toolbar click.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
…est case

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
A WSL session with a blank distro (or SSH with a blank host) used to throw from
BuildWslArgs after the WebView2 and wrapper existed and outside the failure
handler, leaking both. Validate first via ShellSession.LaunchValidationError.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
… for WSL

Directory.Exists on a wsl share boots a stopped distro and was running on the
dispatcher at startup and every tick. WSL sessions now poll every 30s and
remember a not-a-repo answer instead of spawning wsl.exe for it forever.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
…lator

Ubuntu no longer matches Ubuntu-22.04 when translating wsl UNC args for git;
GitService and NewSessionDialog share WslDiscoveryService.TryParseUncPath.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
… kind

session_history gains a snapshot_json column holding the RecentlyClosedEntry;
relaunch goes through ReopenClosedSessionAsync so WSL/SSH sessions come back
as themselves. Existing databases are upgraded in InitializeSchemaAsync.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
Start_Click disables the primary button for the duration and bails out if the
window closed during the await, so a second Enter or a Cancel can't set
DialogResult on a closed window.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
…SSH rollback safety

Final whole-branch review fixes (Important + hardening items):

- RunInstance.OnPtyData no longer routes through AppendText on every PTY
  chunk. AppendText did a full ToString() snapshot of the (up to 1MB)
  ANSI-stripped buffer per 4KB chunk plus an OutputBuffer PropertyChanged -
  LOH churn on a hot path with no consumers in the app (only
  SnapshotOutput() is read). AppendText is kept for the Start() failure
  path only.
- MainWindow.LaunchSessionAsync's refusal dialog no longer shows
  "Cannot start ''." for a session with a blank Name (the designed
  default) - falls back to DefaultDisplayName. This is the only
  user-visible output on a path that deletes a restored session.
- WslDiscoveryService.ResyncWslWorkingFolder(session) is the one shared
  helper that re-derives WorkingFolder from WslDistro + WslWorkingFolder
  for a WSL session (the UNC mirror invariant). Wired into
  ReopenClosedSessionAsync (a hand-edited/stale RecentlyClosed entry no
  longer trusts a mismatched WorkingFolder) and SessionConfigEditor.Apply
  (replacing the inline ternary; WslDistro is now trimmed like
  WslWorkingFolder already was). LaunchWslConsoleFromSession's hand-copied
  Kind/WslDistro/WslUser/WslWorkingFolder assignments are replaced with a
  call to InheritSessionKindFrom - a behavior-identical subset since the
  new session's WorkingFolder is already the parent's known-good UNC.
- ShellSession/RecentlyClosedEntry.LegacyIsRemote is now a computed
  getter (true for Ssh, null - omitted from JSON - otherwise) backed by a
  private field for the incoming legacy value on deserialize.
  origin/main persists only "IsRemote" and has no Kind; without this, an
  older build reading a state.json this one wrote turned every SSH
  session Local on rollback. MigrateLegacyFields still folds a legacy
  true into Kind and never demotes an already-Ssh/Wsl Kind.
- WslDiscoveryService.TryParseUncPath now returns (null, "") rather than
  (empty-string, "") for a UNC with an empty distro segment
  (\wsl$\home\alice) - GitService tested `wslDistro != null` and would
  have spawned `wsl.exe -d "" -- git ...`.
- ShellSession.FolderShort's Local branch uses Path.GetFileName instead
  of DirectoryInfo(...).Name, which throws ArgumentException on a
  WorkingFolder containing an embedded NUL (reachable from state.json on
  the restore path). Matches what DefaultDisplayName already does.
- NewSessionDialog's Loaded handler starts the WSL distro probe without
  awaiting it before the sibling-worktree probe, then awaits both - a
  create-mode dialog no longer waits on a `wsl -l -v` spawn the worktree
  checkbox list doesn't need. Edit-mode still skips the worktree probe
  but still populates the distro list.

Tests: SessionRunnerTests (OutputBuffer no longer touched by OnPtyData,
Cannot-start assertion moved to SnapshotOutput), SessionConfigEditorTests
(mirror of the stale-WSL-fields-on-Local test, for stale SSH fields on a
WSL session), ShellSessionMigrationTests (updated Serialize/Normalize
assertions for the computed LegacyIsRemote, new SSH round-trip test),
ShellSessionTests (FolderShort with an embedded NUL), WslDiscoveryServiceTests
(empty-distro UNC shape, ResyncWslWorkingFolder happy path + non-WSL no-op).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
The try body was left at method-body depth when Task 9 wrapped it in
try/finally. Indent one level so it reads as nested. Whitespace only —
git diff -w against the parent commit is empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
@AThraen AThraen mentioned this pull request Sep 7, 2026
10 tasks
AThraen and others added 3 commits September 7, 2026 20:46
Three real bugs from manual WSL testing:

1. BuildWslArgs used `-- bash -lc` as the wsl.exe separator, which runs the
   payload through the distro's *default* login shell before our own
   `bash -lc` ever sees it — a second, unwanted expansion pass that mangles
   any command using $vars, `backticks`, globs or ~. Switched to `-e <shell>`
   (--exec), which runs the shell directly. Verified empirically: a loop with
   `-- bash -lc` printed empty loop vars; the same command with `-e bash`
   worked correctly.

2. BuildWslArgs hardcoded "bash" as the login shell, so every session and run
   command failed on distros with no bash (Alpine, BusyBox images, Docker
   Desktop's own docker-desktop distro) — even an empty command box, since the
   payload became `bash -lc "bash"`. Added WslDiscoveryService.GetLoginShellAsync
   (cached bash-or-sh probe, mirrors GetDistroHomeAsync, defaults to "bash" on
   any failure) and a runtime-only ShellSession.ResolvedWslShell that
   MainWindow.LaunchSessionAsync resolves before building args; run commands
   inherit it via the shared ShellSession instance. NewSessionDialog now
   leaves an empty WSL command box empty instead of persisting a literal
   "bash" that fails on sh-only distros.

3. WslDiscoveryService.Parse offered docker-desktop / docker-desktop-data in
   the distro picker — Docker's own BusyBox plumbing, not a user environment,
   and often the only entry on a dev machine. Filtered both out (exact,
   case-insensitive match only, so a name that merely contains the phrase is
   kept).

Updated CLAUDE.md's WSL Sessions section and the WslDiscoveryService Services
row accordingly.

10 new tests (ShellSessionTests, RunInstanceTests, Win32CommandLineTests,
WslDiscoveryServiceTests); 457 -> 467 passing, 0 failures. Build clean, 0
warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
Three fixes from manual testing.

A blank Linux Working Folder is labelled "(optional)" but emitted no --cd at
all, so wsl inherited the launching Windows process's cwd and the session
landed in /mnt/c/Users/<you> on the 9p mount instead of $HOME. Emit `--cd ~`
(unquoted) instead, and retry the $HOME probe at launch — the dialog's probe
is capped at 3s and a cold distro blows through it, which left WorkingFolder
at the distro root while the shell sat in $HOME.

The New Session dialog is fixed-height with no scroll container, and WSL mode
adds three rows the other modes lack; with the recently-closed list and custom
args also showing, the content outgrew the window and the button row was
pushed off the bottom edge. Dock the buttons and scroll the content.

The shutdown board labelled rows with Command, which is now legitimately blank
for a WSL session whose shell box was left empty. Use DisplayName.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
Review follow-ups.

The login-shell and $HOME probes each carry a 3s timeout and are independent,
so running them in sequence doubled the worst case on a cold launch, once per
session during a restore. Start the shell probe first and await it last.

wsl.exe special-cases only the bare `~` token: a folder typed as "~/proj" is
treated as an absolute Windows path and fails the launch outright. Expand it
against $HOME on the same path that already resolves a blank folder.

Also drops two Grid rows left unused when the button row moved into the
DockPanel, and corrects two doubled apostrophes in CLAUDE.md.

The docs now record that a bare `~` cannot be tested from PowerShell, which
expands it to the Windows profile path before wsl.exe sees it. Verified from
cmd: `--cd ~` gives /home/<user>, and with `-u root` gives /root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
@AThraen
AThraen merged commit 48b2e81 into main Sep 7, 2026
1 check passed
@AThraen
AThraen deleted the feat/wsl-sessions-v2 branch September 7, 2026 19:17
AThraen added a commit that referenced this pull request Sep 8, 2026
…ently

origin/main landed #121, and 55609f0 within it independently addressed the same
root cause from the other end. Neither side is redundant, so both are kept.

**Their half** wrapped the two call sites in SessionViewModel with Task.Run,
which moves GitService's synchronous prefix — Directory.Exists, and on a \wsl$
share that boots a stopped distro — off the dispatcher. Plus a WSL-specific 30s
cadence, a negative repo-root cache so a not-a-repo answer stops costing a
wsl.exe spawn per tick, and a cancellation guard so a probe finishing after
Dispose can't touch a dead VM.

**My half** hardens GitService itself: RunGitFullAsync is Task.Run-wrapped and
every await is ConfigureAwait(false), so *every* caller is safe, not just the
two that were patched. ListWorktreesAsync, ListBranchesAsync and
CreateWorktreeAsync are all reachable from UI code and all still had
Process.Start on the caller's thread.

Reconciliations that needed a decision rather than a pick:

- **Dropped my Task.Run around the constructor's poll start.** It existed to
  prevent the UI SynchronizationContext being captured, but with GitService
  hardened nothing dangerous runs on that thread any more, and dropping the
  capture would have moved every property set off the UI thread — losing the
  invariant their comment is explicit about. Capturing the context is correct
  again now that the thing which made it harmful is gone.

- **GitRepoWatcher fires on a threadpool thread**, which would have broken that
  same invariant from the other direction. It now posts to the
  SynchronizationContext captured at construction, so the watcher path sets
  properties on the same thread the poll path does.

- **Merged the two cadence policies into one function.** GitPollIntervalFor now
  takes (kind, isForeground): background 120s regardless of kind, foreground
  10s local / 30s WSL. Their kind axis and my foreground axis are answering
  different questions and compose cleanly.

- **The watcher is Local-only.** A WSL session's folder is a \wsl$ UNC, and
  watching that keeps the distro's 9p server busy — defeating exactly the
  idle-VM shutdown their 30s cadence protects.

- ApplyGitInfo now sits behind their cancellation guard, so the coalesced
  notification inherits the disposed-VM protection.

495/495 tests, 0 warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants