Skip to content

feat(devcontainer): drive wmux from a container over the TCP bridge - #166

Closed
stlindenau wants to merge 7 commits into
amirlehmam:masterfrom
stlindenau:feat/devcontainer-remote-transport
Closed

feat(devcontainer): drive wmux from a container over the TCP bridge#166
stlindenau wants to merge 7 commits into
amirlehmam:masterfrom
stlindenau:feat/devcontainer-remote-transport

Conversation

@stlindenau

@stlindenau stlindenau commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Seven commits on top of master (v0.50.0). The headline one lets the wmux CLI, the Claude Code hooks and the shell integration reach \\.\pipe\wmux from inside a Linux devcontainer; the rest are standalone fixes found along the way.

Each commit stands alone and explains its own reasoning in the message — reviewing commit by commit will be a lot easier than reading the combined diff. Happy to split this into separate PRs if you'd prefer; opened as a draft so you can say so before I do.

What's in it

  • feat(devcontainer) (9fd08ed) — wmux bridge --wsl runs the byte relay inside WSL2 and reaches the Windows pipe via npiperelay.exe over interop. 0.0.0.0 there is the WSL2 network namespace: reachable from containers through the Docker host gateway, not from the LAN, and no firewall rule needed. Builds on the remote transport from 是否可以添加远程WMUX的管理 #78. Adds wmux raw-v1 so the bash integration can send V1 lines through the CLI's transport instead of a Windows temp file it cannot write, plus report_startup_command so a restored pane re-enters its container instead of coming up as a bare WSL prompt. AF_VSOCK, a Windows-side listener and cross-boundary Unix sockets were all tried first and are documented as rejected in the code. Setup guide: docs/DEVCONTAINER.md.
  • fix(cwd) (f5f782c) — a workspace tracks posixCwd separately from cwd, so a pwsh pane no longer rewrites the directory a neighbouring WSL pane falls back to. Extends what another issue with save session? #134 did per-terminal to the workspace, and is additive: sessions saved before the field existed recover via an isPosixPath(cwd) seed.
  • feat(config) (0fa84ab) — warn when ~/.wmux/config.toml fails to read or parse. The whole file is discarded on failure, so one stray character presents as "my setting has no effect" with nothing pointing at the file. Deduped, because loadUserConfig runs on every WSL pane spawn.
  • fix(cli) (16b0d4c) — wmux config path built the path from os.homedir() instead of asking the instance, so from WSL or a container it printed /home/vscode\.wmux\config.toml — not the file wmux reads, and not a well-formed path on either OS.
  • fix(shell-integration) (d1c1b58) — resources/shell-integration/wmux-bash-integration.sh had not moved since v0.5.2 while the copy under src/ had. Consumers that read it out of a repo checkout got the pre-bridge _wmux_report and an error on every prompt. Plain copy of the src/ file, no hand-edits. The .cmd and .ps1 copies have drifted the same way and are deliberately left for a separate change.
  • fix(cli) (c693c06) — three places in the CLI that assumed one OS's path conventions. findNpiperelay() split $PATH on a hard-coded :, so on native Windows the whole search collapsed to one nonsense entry; it now splits on path.delimiter, drops empties, and reads Path as well as PATH. The config path and locales path fallbacks — the branch taken when no instance answers — chose their separator from the host rather than from the home directory actually in hand, producing /home/vscode\.wmux\config.toml; they now pick path.posix.join for a POSIX home and path.join otherwise.
  • test(bash-path) (64fb8d8) — repairs the test fallout of the commit above. c693c06 also made the bashPathCandidates() test helper platform-conditional (Git Bash's /c first on Windows, WSL's /mnt/c elsewhere) so the orchestrator suites can resolve a drive path under a Linux bash, but tests/unit/bash-path.test.ts still asserted the fixed Git-Bash-first order it was written against in test: let the orchestrator status-vocab suite run on native Windows #130 — green on Windows, four failures on Linux and macOS. The set of spellings offered is invariant and is now asserted order-independently; the order is not, and is asserted once against the same process.platform condition the helper uses. No behaviour change.

resources/cli/wmux.js and resources/cli/wmux-hook.js are the tsc -p tsconfig.node.json output of their sources — re-checked at this head, and byte-identical to a fresh npm run build:main, so there are no hand-edits in the artifacts.

Testing

Measured on Linux (a devcontainer), which is not where most of this code lives, so the honest form is a comparison against master on the same machine. Baseline: 8122b69 checked out in a detached worktree, same node_modules, same commands.

master 8122b69 this branch 64fb8d8
npm run typecheck clean clean
npm run lint 32 problems (17 errors, 15 warnings) identical — 32 (17 / 15)
npx vitest run 6 failed, 865 passed, 2 skipped (873) 6 failed, 909 passed, 6 skipped (921)

The failing set is identical to master, one for one — same files, same test names:

tests/unit/orchestration-watcher.test.ts          (suite-level)
tests/unit/pty-ledger.test.ts        × 2          (live Win32_Process probe)
tests/unit/pty-manager.test.ts       × 1          (resolveSpawnCwd)
tests/unit/shell-context-menu.test.ts × 3         (directoryFromArgv)

All of them are Windows-path or live-Win32-probe cases that cannot pass off Windows; none of them are new. Nothing this branch adds breaks anything that worked before it.

The four new suites pass: workspace-posix-cwd 21/21, wmux-hook-remote-transport 5/5, cli-config-path 3/3, and wmux-bridge-transport 1/1 — including the case that relays TCP to a Unix-socket upstream, which is the transport shape the container path depends on. That suite's other four cases (npiperelay stdio, warm relay pool) are gated to WSL2 and skip here, which is where the +4 skipped comes from.

A run on Windows is still worth having before merge, and matters a little more now: c693c06 and 64fb8d8 both branch on process.platform, so each has a side no Linux run exercises.

The devcontainer path itself is in daily use in the setup docs/DEVCONTAINER.md describes (Windows 11 + WSL2 + Docker devcontainer, Claude Code inside the container).

Notes

  • wmux locales path still cannot be resolved the way wmux config path now is — config.get carries no locales directory, so there is nothing to ask the instance for. c693c06 only makes its local fallback produce a well-formed path for the OS it runs on; it can still be the wrong path when the CLI and wmux are on opposite sides of the WSL boundary.
  • Deadlines gained a floor on the slow transports: the 5s default sat below the container→WSL2→pipe round-trip, so calls that had already succeeded were reported as timeouts.

🤖 This PR and the code in it were written with the help of Claude Code; every commit but c693c06, where the trailer was dropped by accident, carries a Co-Authored-By: Claude trailer.

stlindenau and others added 5 commits August 13, 2026 22:21
…32 one

A workspace can hold a pwsh pane and a WSL pane at the same time, and both
report their directory over the same `report_pwd`. `workspace.cwd` was one
last-writer-wins field for both filesystems, so a single pwsh pane rewrote it
to C:\Users\<user> — and since a plain WSL pane never reports a cwd of its own
(wmux exports WMUX_INTEGRATION=1 into the distro but installs no rc hook
there), the next WSL pane fell back to that Win32 path, failed isPosixPath,
and got `--cd ~`. Every new WSL pane opened in the home directory instead of
the project, and a restore brought the whole workspace back there.

`posixCwd` is what issue amirlehmam#134 already did per-terminal, applied to the
workspace: stop making one field answer for two namespaces. It is additive and
only ever written by a POSIX report, so a Win32 pane cannot erase the fallback
its neighbours depend on. Sessions saved before the field existed recover via
the isPosixPath(cwd) seed in replaceAllWorkspaces.

The second half is the shell, not the path. resolveSpawnCwd's answer for a
POSIX cwd is %USERPROFILE% — honest, because CreateProcess cannot open a POSIX
directory and rejects a \\wsl.localhost\... UNC one too — but it means a new
tab or split in a WSL workspace lands in the Windows home folder. wsl.exe is
the one shell that can reach the path, so resolveShellForCwd substitutes it.
Only pwsh/cmd are replaced: an unclassifiable spec may be a deliberate remote
command line such as `ssh user@host` (issue amirlehmam#78), and sending that somewhere
else entirely is worse than the wrong directory.

The predicates live in src/shared/paths.ts so the renderer applies the same
rule the PTY layer does rather than re-deriving it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A read or parse failure discards the WHOLE file: every [terminal], [keys],
[browser] and [appearance] section reverts to its default. The errors were
always returned to the caller and printed by `wmux config show`, but nothing
ever told you to go and look — so one stray character presented as "my setting
has no effect", indistinguishable from a mis-set value or a wmux bug.

Warn on the way past, naming the file and pointing at `wmux config show`.
Per-key mapping errors get the same treatment: they are survivable, the rest
of the file still applies, but a silently skipped key reads as a wmux bug.

Deduped, because loadUserConfig runs on every WSL pane spawn as well as at
startup — undeduped, a single bad file would warn once per pane for the life
of the app. `wmux reload-config` clears the record: the user is iterating on
the file and needs to see whether the edit fixed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude Code increasingly runs in a Linux devcontainer while wmux runs on
Windows. Nothing in that container can open \\.\pipe\wmux, so the CLI, the
hooks and the shell integration all failed silently: no cwd, no git branch,
and a sidebar stuck on "Running" forever.

The bridge from issue amirlehmam#78 is most of the answer already — it just could not
reach the pipe from where it needed to run. Three pieces close the gap:

* connectTransport() learns npiperelay.exe, so `wmux bridge` can run INSIDE
  WSL2 and still reach the Windows pipe over interop. That placement is the
  security property: 0.0.0.0 there is the WSL2 namespace, reachable from
  containers on the host and not from the LAN, with no firewall rule. AF_VSOCK,
  a Windows-side listener and cross-boundary Unix sockets were tried first and
  are documented as rejected in the code.
* The bridge keeps relays warm and tears down half-close-aware. Spawning
  npiperelay measures ~7s on an AV-scanned corporate host, and destroying both
  sides on either 'close' killed the frame of any client that writes and hangs
  up — which is every Claude Code hook. Deadlines gain a floor on the slow
  transports for the same reason; the 5s default sat below the round-trip, so
  calls that had already succeeded were reported as timeouts.
* `wmux raw-v1` gives the bash integration a way to send its V1 lines through
  the CLI's transport instead of a temp file it cannot write to, and
  wmux-hook.js gets the same TCP branch.

report_startup_command is new and rides the same path: a shell declares how to
bring its own surface back, stored per surface, so a restored pane re-enters
its container instead of coming up as a bare WSL prompt.

docs/DEVCONTAINER.md is the standalone setup — one binary, one command, two
environment variables, no vendor tooling.

Co-Authored-By: Claude <noreply@anthropic.com>
`wmux config path` built the path from the caller's own home directory:

    console.log(`${os.homedir()}\.wmux\config.toml`)

That only holds when the CLI runs on the same Windows box as wmux. Run it from
WSL, or from a devcontainer over the bridge, and it prints
`/home/vscode\.wmux\config.toml` — not the file wmux reads, and not a
well-formed path on either OS. Editing "the config file" there edits nothing,
which is a long way to walk before finding out why a setting had no effect.

loadUserConfig() already records the real path and `config.get` returns it, so
ask. The local guess stays as the answer when no instance is reachable, but
built with path.join so it is at least self-consistent with the filesystem the
CLI is actually on.

`wmux locales path` has the same defect and is deliberately left alone:
config.get carries no locales directory, so it cannot be fixed the same way and
does not belong in this change.

Co-Authored-By: Claude <noreply@anthropic.com>
`resources/shell-integration/wmux-bash-integration.sh` had not moved since
395156c (v0.5.2). Both a45500e (the `wmux` shell shim) and 9fd08ed (the
devcontainer transport) only touched the copy under `src/`, so the two have
been silently diverging for a while.

Nothing in the app noticed: electron-builder copies `src/shell-integration`
into the package (electron-builder.json), and in dev pty-manager resolves the
same directory. `resources/` only matters to consumers that read it out of a
repo checkout — which is exactly what a devcontainer does when the wmux tree is
bind-mounted at /opt/wmux and the container copies the integration out of it.

Those shells got the pre-bridge `_wmux_report`: no `wmux` shim, no WMUX_REMOTE
branch, straight to the Windows-Temp file drop. `mkdir -p` there is silenced
but the append is not, so every single prompt in the container printed

    bash: /mnt/c/Users/<user>/AppData/Local/Temp/wmux/messages: No such file or directory

and, error aside, the pane still reported nothing to the sidebar.

Plain copy of the src file, no hand-edits, so the two cannot disagree about
anything else either. The .cmd and .ps1 copies have drifted the same way and
are deliberately left for a separate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stlindenau
stlindenau marked this pull request as ready for review August 14, 2026 07:07
@stlindenau
stlindenau marked this pull request as draft August 14, 2026 07:26
stlindenau and others added 2 commits August 14, 2026 10:54
…ally gets

c693c06 made `bashPathCandidates()` platform-conditional — Git Bash's `/c`
first on Windows, WSL's `/mnt/c` first everywhere else — so that the
orchestrator suites can resolve a Windows drive path when the bash on PATH is
a Linux one. `tests/unit/bash-path.test.ts` was not moved with it and still
asserted the fixed Git-Bash-first order it was written against in amirlehmam#130:

    expected '/mnt/c/dev/wmux' to be '/c/dev/wmux'

On Windows the suite passes, which is why the change looked clean. On Linux
and macOS four assertions fail, so `npm test` is red for anyone who is not on
the platform wmux ships for — including the container this branch exists to
support.

Pinning them back to `/c` would only move the failure to the other half of the
contributors, so the assertions are split by what is actually invariant and
what is not. The set of spellings offered is invariant: all three flavours,
always, checked order-independently with `arrayContaining` plus a length. The
order is not, so it is asserted once against the same `process.platform`
condition the helper uses, named `likeliestMount` with the reasoning attached.
`toBashPath`'s two preference cases follow from that constant rather than
repeating the literal.

The probe-driven case gains a Git Bash counterpart to the existing WSL one, so
the "probe decides, not the ordering" property is covered from both sides
rather than only the direction that happens to match this platform's default.

The doc comment on `toBashPath` still said Git Bash is preferred, full stop;
qualified with the platform, since that is now only half the story.

No behaviour change — `tests/helpers/bash-path.ts` keeps the ordering from
c693c06 untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stlindenau
stlindenau marked this pull request as ready for review August 14, 2026 09:31
@amirlehmam

Copy link
Copy Markdown
Owner

You asked whether to split before you did the work, so: yes please, and I've taken two of the seven commits already so you have less to rebase.

Cherry-picked into 1.0.0 (with -x, so the original SHAs are recorded):

commit why it went in on its own
f5f782c fix(cwd) Standalone, additive, and 21 tests. A pwsh pane rewriting the directory a neighbouring WSL pane falls back to is a bug on its own terms — nobody needs a devcontainer to hit it. The isPosixPath(cwd) seed for sessions saved before the field existed is the right call; persistence types being a superset of what every writer emits is a rule this repo learned the hard way in #145.
0fa84ab feat(config) Small, self-contained, and fixes a genuinely bad failure mode: the whole file is discarded on a parse error, so one stray character presents as "my setting has no effect" with nothing pointing at the file. Deduping it matters because loadUserConfig runs on every WSL pane spawn, and you handled that.

Both verified on Windows, which is the side your run couldn't exercise: npm run typecheck clean, full suite green (1004 passing with the rest of 1.0.0 on top).

Why the rest waits, and what it needs

Not a rejection of the feature — wmux bridge --wsl is a good idea and the rejected-alternatives notes (AF_VSOCK, a Windows-side listener, cross-boundary Unix sockets) are the kind of thing I'd otherwise have to rediscover. Three concrete reasons it doesn't ride into a 1.0.0:

1. resources/cli/wmux.js is +1412/-445 of build output. You state it's byte-identical to a fresh npm run build:main, and I believe you — but it's also 60% of the diff by volume, and it makes the PR unreviewable as a diff and unmergeable without conflicts the moment anything else touches the CLI. Which it did: #153's timeout work and my #158 changes both landed there. Please regenerate on top of current master rather than resolving those by hand.

2. c693c06 and 64fb8d8 each branch on process.platform, so neither side is fully exercised by a single-OS run. Your table is honest about this and you flagged it yourself. findNpiperelay() splitting $PATH on a hard-coded : is a real bug on Windows and worth having regardless — but it and the config path / locales path fallback fixes belong in a small Windows-verifiable PR, not behind the bridge feature.

3. d1c1b58 is not the standalone commit it looks like. "Plain copy of the src/ file, no hand-edits" is true, but it sits after 9fd08ed — so the copy carries the bridge's raw-v1 plumbing with it. The stale-since-v0.5.2 packaged bash integration is a real bug (#149's shape: works in dev, silently absent in installs) and I want it fixed, but as a copy of a src/ file that does not yet contain the bridge.

What I'd take next, in order

  1. fix(cli) path handling — the path.delimiter / Path-vs-PATH / POSIX-home-separator fixes, minus the npiperelay-specific parts if they're separable, plus 64fb8d8's test repair. Small, cross-platform, verifiable on Windows.
  2. fix(shell-integration) — the packaged bash integration resynced from a pre-bridge src/. And if you're willing, the .cmd and .ps1 copies you noted have drifted the same way; you're right that they're a separate change, but they're the same class of change and I'd rather fix all three once.
  3. feat(devcontainer) — the bridge itself, on its own, with regenerated artifacts and docs/DEVCONTAINER.md.

Split that way I can review and ship 1 and 2 quickly, and give 3 the attention it actually deserves.

Two things I want to flag on the bridge itself, for when it comes back

  • wmux bridge --wsl binding 0.0.0.0. Your reasoning is right that in the WSL2 network namespace this is not the LAN — but it's a line that will be read out of context by the next person to touch it, and the blast radius if that assumption ever changes (a WSL networking mode change, mirrored networking) is "wmux's control pipe is on the network". Please keep that comment as load-bearing as it is now, and if there's a way to assert the namespace rather than assume it, I'd take the extra check.
  • wmux raw-v1. A CLI verb that sends arbitrary V1 lines is a wider surface than the bash integration needs. If it can be narrowed to the specific reports the integration actually emits, I'd prefer that to a general escape hatch — the V1 path predates the auth gate and I'd rather not grow it.

One correction to your notes

Deadlines gained a floor on the slow transports: the 5s default sat below the container→WSL2→pipe round-trip

Good find, and it's the second time that default has been wrong for a transport it wasn't designed for (#153 was the first, for browser commands). When the bridge PR comes back, I'd rather the floor be derived from the transport than added per-call site — three places choosing their own timeout is how the first one got missed.

Thanks for opening it as a draft and asking first. That saved both of us a merge conflict and got two of your fixes shipped a week earlier than they would have been.

@stlindenau

Copy link
Copy Markdown
Contributor Author

Thanks for the two cherry-picks and for the review. Split as asked, in your order. Two of them are up here; the third is in my fork for now.

f5f782c and 0fa84ab were patch-identical to your addad6f and d24aed0, so the rebase dropped both on its own. Five commits left, which is what the new branch is built from.

Two corrections

findNpiperelay() can't come out of the bridge. You asked for PR 1 minus the npiperelay-specific parts "if they're separable" — they aren't, because the function doesn't exist on master. git grep npiperelay upstream/master -- src/cli/wmux.ts returns nothing; it arrives with the bridge. So the path.delimiter / Path-vs-PATH fix rides with #2 rather than #168. #168 is the config/locales fallbacks and the test repair only.

The resources/cli/wmux.js conflict you predicted isn't there. You expected #153's timeout work and your #158 changes to collide there. #153 (18940c0) is below the PR base, and #158 (1dd0d9e) touches src/main/claude-context.ts and friends, not the CLI — nothing in 8122b69..master touches src/cli/ or resources/cli/ at all. So no conflict to resolve.

But the artifacts are stale on master anyway

Different problem, same root as #169. On 7baf230, a clean npm run build:main against the checked-in copies:

file drift
resources/cli/wmux.js 1519 changed lines
resources/cli/wmux-hook.js 64 changed lines

Last touched at 34ab219. I've deliberately left this alone#168 and #169 stay src/-only rather than carrying a 1519-line regenerated blob that would swamp the review of a 12-line fix. Reporting it rather than fixing it silently.

Worth saying that it doesn't currently break anything: electron-builder.json ships dist/cli/*.js, and the manual release process in CLAUDE.md copies from dist/ too, so the checked-in resources/cli/ copies are never what ships. Same for resources/shell-integration/ in #169extraResources reads src/shell-integration, and pty-manager.ts loads src/ in dev and process.resourcesPath when packaged. Neither directory is on any path a released build takes.

Which raises the real question: who are those directories for? As far as I can tell the only consumers are tools reading the repo checkout — mine is one of them, which is how I noticed. If that's the intent they need to be correct; if it isn't, deleting them is cleaner than syncing them. Your call, and #169 works either way.

The CI check

This class of bug recurs because nothing enforces the copy — that's three instances now (the three shell-integration files, and both CLI artifacts). Want a CI check that resources/ matches a fresh build? Happy to open it as a follow-up; I didn't want to bundle an unasked-for workflow change into a fix.

On the 0.0.0.0 question

You asked about the blast radius of --wsl binding 0.0.0.0, and the honest answer turned out to be "it depends on a WSL setting the code never looked at":

  • NAT mode (the default, and what the old comment described) — the distro has its own namespace behind a Hyper-V vSwitch, so 0.0.0.0 is a private 172.x plus loopback. Reachable from containers, genuinely not from the LAN. The comment was correct and is kept.
  • Mirrored mode (networkingMode=mirrored) — no namespace of its own; the distro shares the Windows host's adapters, LAN and VPN included. Inbound is filtered by the Hyper-V firewall rather than the normal Windows Firewall profile, and the recipe everyone copies for "make WSL reachable" is Set-NetFirewallHyperVVMSetting … -DefaultInboundAction Allow. That combination puts wmux's control pipe on the corporate network. The token still authenticates every request, so it's exposure rather than an open door — but it's a real configuration, not a hypothetical.

So #2 reads wslinfo --networking-mode and refuses to pick 0.0.0.0 for you under mirrored or unknown; --host still works if you want it, and warns with the mode named. The decision is a pure function with a table test, so it doesn't need WSL to run. I haven't been able to exercise the mirrored branch on real hardware — that's stated in the PR rather than glossed.

I'll close this one as superseded. Review whichever of #168/#169 you like; #2 comes up here once they've landed and it's rebased onto them.

@stlindenau

Copy link
Copy Markdown
Contributor Author

Superseded by #168, #169 and stlindenau#2 — see the comment above for the split and the two corrections.

@stlindenau stlindenau closed this Aug 14, 2026
amirlehmam pushed a commit that referenced this pull request Aug 14, 2026
`wmux config path` built the path from the caller's own home directory:

    console.log(`${os.homedir()}\.wmux\config.toml`)

That only holds when the CLI runs on the same Windows box as wmux. Run it from
WSL, or from a devcontainer over the bridge, and it prints
`/home/vscode\.wmux\config.toml` — not the file wmux reads, and not a
well-formed path on either OS. Editing "the config file" there edits nothing,
which is a long way to walk before finding out why a setting had no effect.

loadUserConfig() already records the real path and `config.get` returns it, so
ask. The local guess stays as the answer when no instance is reachable, but
built with path.join so it is at least self-consistent with the filesystem the
CLI is actually on.

`wmux locales path` cannot be fixed the same way — `config.get` carries no
locales directory, so there is nothing to ask the instance for. The commit
after this one gives both fallbacks a spelling that is at least well-formed for
the OS in hand; the locales one can still name the wrong file when the CLI and
wmux are on opposite sides of the WSL boundary.

The test spawns dist/cli/wmux.js, and compiles it first. It originally spawned
resources/cli/wmux.js on the grounds that that is what a released wmux runs,
which turns out not to be so: package.json's `bin`, electron-builder's
extraResources and step 7 of the release process all name dist/cli/wmux.js.
resources/cli/wmux.js is a checked-in copy of that build with nothing enforcing
the copy — it is 1519 diff lines behind a build of master's own src/ as of
1.0.0 — so a test spawning it asserts against whatever was committed last
rather than against the branch under test. Building in beforeAll costs ~3s and
is needed either way: dist/ is gitignored and `npm test` runs ahead of
`npm run build:main`.

Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 16b0d4c, opened as part of #166)
amirlehmam pushed a commit that referenced this pull request Aug 14, 2026
`bashPathCandidates()` offered `/c`, `/mnt/c`, `/cygdrive/c` in that fixed
order, and `toBashPath()` documented Git Bash's `/c` as preferred outright.
That is right on a Windows box with Git for Windows and wrong everywhere else:
in a Linux checkout — a container, WSL, a CI runner — the bash on PATH mounts a
Windows drive at `/mnt/c` if it can see one at all, and leading with a spelling
that never resolves puts the fallback branch in charge.

So the order is now conditional on `process.platform` while the set stays
fixed: Git Bash first on Windows, the WSL mount first elsewhere, all three
always offered, and the probe still decides. `toBashPath()`'s comment is
qualified to match — off Windows there is no Git Bash to prefer.

`bashExists()` probes through a login shell (`-lc`) so it sees the environment
the orchestrator scripts are run under rather than a bare non-interactive one,
and names $0 `_`, which is what it is — a placeholder, not the shell.

`orchestration-status-vocab.test.ts` gains a `bashExists(hookPath)` term in its
skip condition. `hasBash()` alone only says a bash exists; it does not say that
bash can see the hook script, which is the thing the suite needs and the case
this change exists to handle. Unreachable now skips loudly instead of failing
on a path the shell cannot open.

`bash-path.test.ts` moves in the same commit because it cannot survive the
helper change on its own — it pinned the Git-Bash-first order literally, and
four of its assertions go red on Linux and macOS the moment the order becomes
conditional. Splitting the two would leave a commit that fails `npm test` for
anyone not on Windows. Rewritten so the invariant and the variant are asserted
separately: the spelling set order-independently with `arrayContaining` plus a
length, the order once against the same `process.platform` condition the helper
uses. The probe-driven case gains a Git Bash counterpart to the WSL one, so
"the probe decides, not the ordering" is covered from both directions.

On a Linux run neither suite changes result — both are green before and after,
and the status-vocab suite still executes rather than skipping. The change is
for the hosts where the reachable bash is not the one the ordering assumed;
verifying that half needs a Windows box with WSL bash but no Git Bash.

This is stlindenau/wmux@c693c06's test-helper hunks plus 64fb8d8, from the
branch in #166.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
amirlehmam added a commit that referenced this pull request Aug 14, 2026
First of the three splits of #166.

`wmux config path` rebuilt the path from the caller's own home directory,
which only holds when the CLI runs on the same Windows box as wmux. From
WSL or a container over the bridge it printed `/home/vscode\.wmux\config.toml`
— not the file wmux reads, and not well-formed on either OS. It now asks
the instance, which already records the real path, and keeps the local
guess only for when nothing answers.

Both no-instance fallbacks now take their separator from the home
directory in hand rather than from process.platform, because the case
that matters is exactly where those two disagree.

Also repairs the bash-path helper's test: the candidate ORDER is now
conditional on the platform (a Linux bash mounts a Windows drive at
/mnt/c, not /c), so the test that pinned Git-Bash-first literally had to
move with it or go red off Windows.

Co-Authored-By: stlindenau <stlindenau@users.noreply.github.com>
amirlehmam added a commit that referenced this pull request Aug 14, 2026
#166)

Second of the three splits of #166. Generated from master's own src/.

resources/shell-integration/ is a checked-in copy of src/shell-integration/
with nothing keeping it in step, and all three files had drifted. The
PowerShell one is the serious case: its copy predates #72, so
Send-WmuxMessage sends V1 lines with no auth token prefix — and the pipe
server rejects every V1 command but ping without one. Anything running
that copy reports no cwd, no git branch, no shell state and no PR, and has
no way to notice, because the pipe answers `unauthorized` into a void.

No release path reads these files (electron-builder maps src/, and the
dev fallback in getShellIntegrationPath resolves to src/ too), so this
never reached a released install. What reads them is anything consuming
wmux out of a repo checkout, which is how it surfaced.

Co-Authored-By: stlindenau <stlindenau@users.noreply.github.com>
amirlehmam pushed a commit that referenced this pull request Aug 14, 2026
Claude Code increasingly runs in a Linux devcontainer while wmux runs on
Windows. Nothing in that container can open \\.\pipe\wmux, so the CLI, the
hooks and the shell integration all failed silently: no cwd, no git branch,
and a sidebar stuck on "Running" forever.

The bridge from issue #78 is most of the answer already — it just could not
reach the pipe from where it needed to run. Three pieces close the gap:

* connectTransport() learns npiperelay.exe, so `wmux bridge` can run INSIDE
  WSL2 and still reach the Windows pipe over interop. That placement is the
  security property: 0.0.0.0 there is the WSL2 namespace, reachable from
  containers on the host and not from the LAN, with no firewall rule. AF_VSOCK,
  a Windows-side listener and cross-boundary Unix sockets were tried first and
  are documented as rejected in the code.
* The bridge keeps relays warm and tears down half-close-aware. Spawning
  npiperelay measures ~7s on an AV-scanned corporate host, and destroying both
  sides on either 'close' killed the frame of any client that writes and hangs
  up — which is every Claude Code hook. Deadlines gain a floor on the slow
  transports for the same reason; the 5s default sat below the round-trip, so
  calls that had already succeeded were reported as timeouts.
* `wmux raw-v1` gives the bash integration a way to send its V1 lines through
  the CLI's transport instead of a temp file it cannot write to, and
  wmux-hook.js gets the same TCP branch.

report_startup_command is new and rides the same path: a shell declares how to
bring its own surface back, stored per surface, so a restored pane re-enters
its container instead of coming up as a bare WSL prompt.

docs/DEVCONTAINER.md is the standalone setup — one binary, one command, two
environment variables, no vendor tooling.

Two things ride along that were separate commits in #166, because on current
master they have nothing to stand on their own for. The npiperelay lookup
splits PATH on path.delimiter and falls back to process.env.Path — the
Windows spelling — which only matters for the finder this commit introduces;
#168 landed the rest of that change and deliberately left this half behind.
And resources/shell-integration/wmux-bash-integration.sh is updated in step
with its source, which #169 plus the check in 42e3cca now require: the
packaged copy was resynced from a pre-bridge src/, so it has the `wmux` shim
but not the WMUX_REMOTE branch, and leaving it that way fails npm test.

Co-Authored-By: Claude <noreply@anthropic.com>
amirlehmam pushed a commit that referenced this pull request Aug 14, 2026
resources/cli/*.js is a copy of the tsc output in dist/cli/, and 42e3cca made
that an invariant rather than a hope: `npm run verify:resources` compares the
two byte-for-byte and CI runs it after build:main. So this is no longer
bookkeeping that can slip — the branch does not pass without it.

Two files are new. transport-deadline.js and wsl-network.js are shared modules
this branch introduces, and verify-resources compares directories, not a list
of names, so a build output with no copy fails the same way a stale copy does.
electron-builder.json names them too (earlier commit), for a different reason:
the CLI is packaged file-by-file, and a missing sibling is MODULE_NOT_FOUND on
the first line of every hook rather than a degraded feature.

The reason this is readable at all is 42e3cca. In #166 the same regeneration
read +1412/-445, which is what made that PR unreviewable: the checked-in copy
had drifted ~1700 diff lines behind its own source, so the bridge's output was
buried in years of unrelated rot. Regenerating from today's master, the diff is
just the bridge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
amirlehmam added a commit that referenced this pull request Aug 14, 2026
Third and last split of #166. `wmux bridge` exposes the local pipe on TCP
so a container can drive the wmux running on the Windows host: the
container has no \.\pipe\wmux, so every CLI call and every Claude Code
hook failed silently, and the sidebar sat on "Running" while the agent
idled.

Four things changed since the #166 review, all of them things I asked
for and one of them better than what I asked for:

- The 0.0.0.0 bind is checked rather than assumed. `--wsl` now reads
  `wslinfo --networking-mode` and only picks 0.0.0.0 once it has
  confirmed NAT. Under mirrored networking the distro shares the Windows
  host's real interfaces — LAN and VPN adapters included, gated by the
  Hyper-V firewall rather than the ordinary profile — so it refuses and
  names `--host <addr>`. Unknown is treated as unknown, not folded into
  the NAT default. An explicit --host is always honoured.
- `raw-v1` is an allowlist of the six verbs the shell integration emits,
  not a generic passthrough that would have made every future V1 command
  container-reachable the day it landed. The test derives the expected
  set by scanning the integration's own call sites.
- One transport-derived deadline instead of two spellings. The hook's
  copy did not know about npiperelay, so a hook firing from a WSL shell
  armed the 5s local-pipe timer for a ~7s hop and killed calls that were
  going to succeed.
- Artifacts regenerated, and now readable: 42e3cca turned that commit
  from ~1700 lines of accumulated rot into +443/-36 of actual bridge.

It also found a packaging gap on the way: extraResources ships the CLI
entry points file-by-file, not the directory, so a shared sibling module
is MODULE_NOT_FOUND on the first line of `wmux ping` and of every hook in
an installed build — invisible to `npm run dev`, where the module is
sitting right there in dist/. packaging.test.ts now walks the relative
imports out of each shipped entry point and requires each to be packaged
too. It caught wsl-network.js before review did.

Co-Authored-By: stlindenau <stlindenau@users.noreply.github.com>
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