Borrowing it
Nothing to install: this file belongs to amirlehmam/wmux. Take a copy, put it at the same path in your own repository, and replace the rules that are about this project with yours.
curl -O https://raw.githubusercontent.com/amirlehmam/wmux/master/CLAUDE.mdgit clone --depth 1 https://github.com/amirlehmam/wmuxWrote this? Show the measurements
A badge with what this costs and how it scanned, read live from this page, so it follows the numbers instead of freezing them. Markdown for a README, HTML for a documentation site or a project page.
[](https://agentmods.dev/instructions/amirlehmam/wmux/claude-md)<a href="https://agentmods.dev/instructions/amirlehmam/wmux/claude-md"><img src="https://agentmods.dev/badge/instructions/amirlehmam/wmux/claude-md/github.svg" alt="Measured on agentmods" height="20"></a>Or the 80×15 button, for a site that already has a row of RSS and ATOM ones. Only the verdict fits; the numbers stay here.
<a href="https://agentmods.dev/instructions/amirlehmam/wmux/claude-md"><img src="https://agentmods.dev/badge/instructions/amirlehmam/wmux/claude-md.svg" alt="Reviewed on agentmods" width="80" height="20"></a>What it costs to keep this loaded
Counted locally with the o200k_base tokenizer, which is exact for GPT models; Claude uses its own tokenizer and its counts differ. Treat this as one consistent yardstick across the catalogue rather than a bill. Prices are per million input tokens.
| Model | Per session | Once invoked |
|---|---|---|
| Fable 5.1 | $0.37124 | $0.37124 |
| Opus 5.5 | $0.14850 | $0.14850 |
| Sonnet 5.5 | $0.07425 | $0.07425 |
| Haiku 4.5 | $0.03712 | $0.03712 |
Grade F, and why
wmux CLAUDE.md scanned grade F with 4 findings against 26 rules in 11 categories — prompt injection, anti-refusal, data exfiltration, privilege escalation, supply chain, agent snooping, system-prompt leakage, SSRF and excessive agency — measured today.
A static scan of the body, not an audit. Every finding is printed with the line that produced it so you can judge whether it matters here. A mod is markdown that instructs an agent; that is exactly why what it instructs is worth reading.
Reaches for credential fileshighPrivilege escalation
SSH keys, cloud credentials, git-credentials, .npmrc, /etc/shadow: reading these is how a config file becomes a credential leak.
| `code-file.ts` | Which files the code viewer will read **and write**, and how. Sibling to `markdown-file.ts`, and it deliberately neither imports from it nor changes it: markdown's extension whitelist stays exactly as Reads agent configuration directoriesmediumAgent snooping
.claude/, .codex/, .gemini/ hold keys, settings and other credentials a mod has no legitimate need for.
| `claude-context.ts` | Injects wmux instructions into `~/.claude/CLAUDE.md`, configures hooks, points chrome-devtools-mcp at the browser panel — **and the inverse of each**, since 0.40.0. Two things it no longer does, b Recursive force deletehighDestructive command
rm -rf with a variable or a broad path is one typo away from removing the wrong tree.
rm -rf .asar-staging build-out Makes network callslowCapability
Not a fault in itself. Listed so you know the mod talks to something, and to what.
| `cdp-proxy.ts` | CDP WebSocket proxy, plus the HTTP endpoints a CDP client probes before it opens one. The rule the route table has to be read against is that this proxy **impersonates Chrome**, so its contract is not How it starts
The opening of the file, as written. The whole thing — 969 lines — stays where its author put it; the contents beside it link to each section on GitHub.
wmux — Development Guide
Electron-based Windows terminal multiplexer for AI agents. TypeScript, React 19, Zustand, xterm.js, node-pty.
Owner: amirlehmam (GitHub) — speaks French, prefers fast pragmatic solutions, tests live.
Repo: github.com/amirlehmam/wmux | Site: wmux.org (Netlify, static from site/)
Version: 2.42.0
Build & Dev
npm run dev # Vite (port 5199) + Electron hot-reload
npm run build:main # tsc main/preload/cli only (fast iteration)
npm run build:renderer # Vite production build: BOTH entries (desktop index.html + remote/index.html), writes dist/renderer/remote-manifest.json (the console server refuses to serve without it)
npm run build # Full: tsc + vite + electron-builder
npm test # Vitest unit tests
npm run test:watch # Vitest watch mode
npm run lint # ESLint src/
Known Build Gotcha
Project lives in OneDrive - Pulsa (path with spaces). This breaks:
npm link/node-gyp(can't build node-pty)electron-builderwinCodeSign (symlink errors)
Workaround: Don't use electron-builder for the final package. Use ASAR-based manual packaging (see Release Process below).
Architecture
src/
main/ Electron main process
renderer/ React UI (Vite)
preload/ contextBridge (window.wmux)
cli/ CLI → named pipe (\\.\pipe\wmux)
shared/ Shared types (IPC channels, branded IDs)
shell-integration/ Shell hooks (bash/zsh/PowerShell/cmd)
resources/ Runtime assets (icons, themes, sounds, shell-integration, CLI)
wmux-orchestrator/ Claude Code plugin — DEPRECATED, no longer installed (#239)
site/ Landing page (static HTML, Netlify)
tests/ Unit + e2e (Vitest)
docs/ Planning docs
Main Process (src/main/)
| File | Role |
|---|---|
index.ts |
Entry point, AppUserModelId, auto-save (30s), pipe server startup, V2 pipe handlers (workspace/pane/surface/markdown/sidebar/notification) |
pty-manager.ts |
PTY lifecycle (create with surfaceId, write, resize, kill). Shell resolution treats an App Execution Alias (%LOCALAPPDATA%MicrosoftWindowsAppspwsh.exe) as installed (#299): existsSync/statSync answer EACCES on an AppExecLink, so a Store-only PowerShell — which is exactly what where pwsh.exe and therefore the Settings shell detector return — fell back to 5.1 on every pane. fs.readlinkSync decodes the alias to the versioned exe (appExecAliasTarget), which a normal user can run but not LIST (%ProgramFiles%WindowsApps is ACL-denied, so findStorePwsh misses on most machines). The spec keeps the alias spelling; only the spawn uses the target, and the memoised hit re-validates so a Store update re-resolves. isWslBashLauncher matches the exe behind the WSL alias too, so the Git Bash rule (#252) still holds |
pty-crash-guard.ts |
Keeps a PTY-side error from killing the whole app (issue #150). Every pane's PTY lives in main, so a throw there takes the window down: wraps node-pty's _$onProcessExit (a throw there becomes an unhandled C++ Napi::Error in conpty.node → abort()) and claims each terminal's error event (node-pty re-throws socket errors when fewer than 2 listeners exist, and registers none). Installed at module load — after pty.spawn is too late |
pipe-server.ts |
Named pipe \\.\pipe\wmux — V1 text (shell hooks), V2 JSON-RPC (CLI/agents). Every reply is socket.write(text, () => socket.destroy()), and the clients (wmux.ts, wmux-hook.ts) destroy() on the first reply chunk — never end(). libuv on Windows arms a 50 ms eof_timeout after a half-close on a named pipe (src/win/pipe.c) and only releases the handle on the peer's EOF or on expiry; the server never closed its side, so every CLI verb and every hook process paid the full timer plus slop: wmux ping 94 → 33 ms, a hook process 94 → 32 ms, against a raw pipe round trip of 1 ms. A server-side end() does NOT fix it (both sides arm the same timer, measured 93 ms unchanged), which is why the comment there says destroy and means it. Safe only because every client sends one request per connection — the framing loop still gates the destroy on "no further complete line buffered", pinned by a test |
cdp-bridge.ts |
Browser webview control via Chrome DevTools Protocol |
cdp-proxy.ts |
CDP WebSocket proxy, plus the HTTP endpoints a CDP client probes before it opens one. The rule the route table has to be read against is that this proxy impersonates Chrome, so its contract is not "what wmux documented" but "what real Chrome accepts" — anything that attaches to Chrome must attach here. Exact-match on req.url quietly broke that (#233): Chrome serves /json/version and /json/version/ alike, Playwright's connectOverCDP probes the TRAILING-SLASH form, and the 404 it got read as a Playwright bug because curl /json/version returned 200 right beside it. cdpRoutePath folds the spellings (and drops a query string, which was the same bug unreported) and is pure so the accepted table is testable without binding a port, like isAllowedCdpHost/isAllowedCdpOrigin above it. Normalization accepts only what Chrome accepts — an unknown route still 404s in both spellings, and neither guard is on this path. Since 2.12.0 it is also a BROWSER rather than a pipe (#237) — see cdp-browser-domain.ts |
cdp-browser-domain.ts |
The browser-level protocol, answered by wmux instead of forwarded (#237). The proxy used to hand every message to webContents.debugger, which is a PAGE-level debugger, so the first thing puppeteer.connect({browserURL}) sends — Target.getBrowserContexts — came back "Not allowed" and no real client ever got a session; chrome-devtools-mcp listed its 29 tools and failed on the first one that touched the page. wmux has one page to offer, so being a browser is bookkeeping: one target, one flattened session, the default browser context, and a translation down to Electron's single unsessioned debugger. Pure like agent-browser-verbs.ts, for the same reason. Two rules, both found by pointing real puppeteer at it rather than by reading it. (1) A reply to a session-scoped command MUST echo its sessionId — puppeteer dispatches on sessionId before it looks at id, so dropping it resolves nothing and the caller hangs to its own timeout, which reads as a slow page. (2) Target.* on the PAGE session must emit NO events. When setAutoAttach there answered the way the browser connection does — with an attachedToTarget for the page — puppeteer read it as a newly discovered child, attached, sent setAutoAttach down the new session, and got another attachment for the same target: 4 GB heap and Ineffective mark-compacts near heap limit inside browser.pages(). A unit test asserting the return value passes. Single-pane concessions: createTarget navigates the pane and answers with the target already there, closeTarget is success:false rather than a lie, createBrowserContext is refused rather than silently sharing cookies, Browser.close is swallowed — that "browser" is the user's window |
agent-browser-cli.ts |
Where agent-browser is on this machine, and how to run it. Two traps, both found by running the real binary rather than by reading its docs. (1) execFile never returns. Its callback fires on stdio CLOSE, not on process exit, and every command that starts the daemon — the first open of a session, dashboard start — leaves that daemon holding the inherited stdout pipe open for as long as it lives. Measured against 0.35.0: an identical cold open had the child exit 0 at 787 ms while execFile's callback had still not fired at 3 min, so the verb burned its whole timeout and was reported as a FAILURE despite having succeeded. Hence spawn, resolving on 'exit' (the event that actually means "done"), with a 50 ms drain window so output is not truncated — 'close' lands right behind 'exit' when nothing holds the pipe, so the wait is only ever paid by the daemon-spawning commands. Anyone "simplifying" this back to execFile breaks agent mode completely and silently. (2) Never a .cmd. Node refuses to spawn .bat/.cmd without shell: true (the CVE-2024-27980 mitigation) and throws a synchronous EINVAL — in main that is an uncaught throw, not a failed command. shell: true is not available as a fix: argv carries agent-controlled URLs and eval snippets, and routing them through cmd.exe's parser is the exact trap powershell-shim.ts documents (#154). The npm package ships real per-platform binaries under its own node_modules/agent-browser/bin/, so resolution targets those first and the shim is never a candidate. Memoised like node-runtime.ts (#187) because it is read on the pane path (#176), and keyed on the configured path so a Settings change invalidates without every caller remembering to ask |
agent-browser-verbs.ts |
The pure wmux-verb → argv table. I/O-free on purpose: it is the piece most likely to drift as agent-browser's CLI evolves, so it must be exhaustively testable with no daemon, no Chrome, no Electron. Returns an ARRAY, never a joined string — params arrive from a pipe command an agent controls. A missing ref on click/type/fill throws -32602 rather than putting undefined on a command line, and an unknown verb throws the same -32601 the web engine does so a caller cannot tell the engines apart by their error. Also where the one engine divergence is recorded in code: wait has no per-call timeout in agent-browser, so a caller ms sent alongside a ref is unrepresentable in argv and deliberately dropped — see CLI Reference |
agent-browser-session.ts |
surfaceId → session name + wmux-allocated stream port. Sessions are ephemeral — nothing persisted, no profile dir, a session's process lifetime equals its surface's — and that is what makes orphan handling correct by construction rather than by heuristic: no wmux-owned session can legitimately survive, so any prefixed session with no live surface is garbage. That is the property the #139 post-mortem wanted and did not have. But the registry is in-memory and starts EMPTY, so it is not ground truth after a crash — the sessions that actually survived are precisely the ones it cannot see, and reconciliation asks agent-browser session list instead. wmux allocates the stream port ITSELF (9300+, above the CDP proxy's 9222-9230) because the dashboard deep-links by ?port=, and discovering an OS-assigned port after the fact races the webview load; ensureBindable verifies each candidate with a real listen first, since the registry tracks only what it handed out and never what the OS holds — an orphan from a previous run may still be bound. isWmuxSessionName is a security boundary, not tidiness: names come out of a machine-global namespace anyone can write into and go straight back onto a command line as --session <name> close, so both the instance prefix AND the full surf-<uuid> shape are required. WMUX_INSTANCE gets its own prefix — a side-by-side wmux is exactly as much "not us" as a human's hand-made session |
agent-browser-daemon.ts |
The observability dashboard, refcounted by live agent-mode surfaces: first acquire starts it, last release stops it. Adopt, never fight — if :4848 already answers, a human or another wmux started it, so wmux uses it, records adopted, and NEVER stops it: not on the last release, not on shutdown. adopted is a getter with deliberately no setter, since anything that could write it could make teardown kill a dashboard wmux does not own. A failed acquire() rolls back its own increment (clamped — refs must never go negative) so a phantom ref cannot strand a dashboard no later release can reach zero on, and release()/shutdown() wait out an in-flight start before deciding there is nothing to stop — otherwise a start that settles just after shutdown leaks a child process that outlives wmux entirely |
agent-browser-runtime.ts |
The single home for the singletons — exactly one SessionRegistry, exactly one DashboardDaemon — plus the impure halves the pure modules take by injection. A second copy silently corrupts both: two registries hand 9300 to two surfaces, two daemons each believe they own the dashboard and the first release() to hit zero stops it out from under the other. It also owns the per-surface dashboard reference, because two paths take one for the SAME surface (the renderer enabling agent mode, and a wmux browser verb arriving for that pane) and when each kept its own Set the pane took two references and gave back one, so the dashboard outlived every agent pane until quit. Readiness is the PORT, never the exit: dashboard start exits at 58 ms having daemonised while :4848 does not accept a connection for ~500 ms, and in some configurations it does not exit at all. reconcileOrphanSessions runs at startup off session list because a crash reaches none of the other teardown paths and Windows reparents rather than kills (#139); a close that hangs (observed after a daemon version-mismatch restart) is deadlined and left for the next launch rather than resolved by killing a PID — agent-browser exposes no per-session PID and its daemon fronts several sessions, so leaking one Chrome beats killing somebody else's browser |
dashboard-content-type.ts |
agent-browser's dashboard answers its own deep link (/?port=N) as application/octet-stream because its file server derives the type from the raw request target (fixed upstream in vercel-labs/agent-browser#2046), and Chromium turns that into a native Save dialog on every launch restoring an agent-mode pane (#269). index.ts relabels exactly that response — 127.0.0.1:4848, top-level frame, extensionless path, octet-stream — to text/html via onHeadersReceived on the default session (webviews have no partition). NOT a will-download handler that re-issues the navigation: the server answers the same URL with the same wrong type, so that is a download loop. Web-mode downloads are untouched. The filter pattern is portless on purpose; the port is checked in the pure function |
agent-spawn-target.ts |
Which (pane, workspace) pair a spawn actually means (#242). They used to be resolved INDEPENDENTLY — workspaceId off the active workspace, paneId taken verbatim — so nothing ever checked that the pane belonged to the workspace it was filed under. A pane from a non-active workspace therefore produced a live, running agent recorded against the wrong workspace, after which every lookup through that record failed: read-screen answered "no terminal for surface … (markdown/browser pane, another window, or closed)", three causes none of which was the real one, and the reporter restarted three orchestration waves chasing it. The rule is that the pane is authoritative whenever it is given — a pane belongs to exactly one workspace and the split tree says which, so --pane alone reads as fully specified because it is. Both named and disagreeing is -32602, not a silently mismatched record; an unknown pane is -32602 rather than an agent nobody can address. With a pane named the active workspace is never consulted, so a fully-specified spawn now works when there is no active workspace at all. The MIRRORED half the report only suspected is here too: pane loads were always read off the ACTIVE workspace, so spawn_batch --workspace <other> honoured the flag for the record and ignored it for the panes. With neither pane nor workspace named, the CALLER's workspace (caller: $WMUX_SURFACE_ID, resolved through __wmux_locateSurface in every window) comes before the active one (#246) — the focused workspace is only the answer when there is no caller, or it no longer resolves. Pure, with the renderer lookups injected — the split tree lives in the Zustand store and main has no copy, so every question is a round trip, which is exactly what makes the decision worth separating from the asking. spawnTargetLookups in index.ts is the impure half and asks EVERY window, first real answer wins (#143) |
agent-manager.ts |
Agent PTY spawning, round-robin distribution across panes. The agent command travels as a startupCommands entry that the PowerShell integration runs during init (see pty-manager.ts), not as keystrokes typed after a sniffed prompt: the prompt regex stopped matching the moment #207 wrapped the prompt in OSC 133 marks, so every wmux agent spawn silently fell into the 1500 ms debounce (first output 1.71 s → 0.56 s). It no longer types anything itself: since #251 PtyManager delivers startup commands for every caller (see startup-commands.ts); looksLikePrompt is re-exported from there |
startup-commands.ts |
When a startup command may be TYPED into a shell with no init seam (cmd, bash, WSL, PowerShell without the integration) — #251. It used to be two rules in two places: the renderer typed restore's claude --resume on a blind 600 ms timer after pty.create ("pty created" is not "shell reading" — cmd measured 372 ms, pwsh 1.5 s to ready on the reporter's machine), and agent-manager kept its own prompt sniff. PtyManager now owns delivery and startupCommandsConsumed is simply "were there any", so no caller may type them again. Readiness, strongest first: an OSC 133 A/B mark (deterministic — the cmd and bash integrations emit them), a prompt-shaped last line, output quiet for 1.5 s, and a 5 s ceiling on a SEPARATE timer (if output re-armed it, a shell that never stops printing would never get its command). A weaker signal never pushes back a stronger armed one. PowerShell with the integration is untouched: WMUX_STARTUP_COMMANDS runs them during init with no keystrokes at all (measured: argv arrives intact through the npm claude.ps1 shim). Pure startupGateStep + a thin driver. This gate is not the fix for #251, though 2.14.0 shipped it as one: that symptom (esume in Claude's session picker) is Claude receiving -resume with ONE dash — its parser reads it as -r with the value esume, and the picker's search box is seeded from argv, never from keystrokes. ~1,900 measured deliveries afterwards (cmd, pwsh 7, 5.1, both ConPTYs, the old blind timer at every delay) never lost a character: the console input buffer holds type-ahead, and a restored pwsh pane was never on the typed path at all. No wmux path drops that dash, so the open question is what claude resolves to on the reporter's machine — ask for the CommandLine of the running claude.exe (from Get-CimInstance Win32_Process) before touching delivery again. What that hunt did find: the integration cleared WMUX_STARTUP_COMMANDS AFTER its loop, so the command it launched inherited the variable (fixed in 2.16.0, pinned by startup-commands-env.test.ts — whose first version asserted on an echo %VAR% and passed with the bug present, because the leaked value was the probe's own text) |
git-bash.ts |
What a bare bash.exe / git-bash.exe spec means (#252). Settings' "Git Bash" option stores bash.exe, resolved with where against wmux's OWN PATH — and Git for Windows puts only <Git>\cmd there, so it either missed (silent fallback to pwsh: "the setting has no effect") or hit System32\bash.exe, the WSL launcher. An honest PATH bash (MSYS2, Cygwin) still wins; the WSL launcher does not count as one; Git's bin\bash.exe is found through the git.exe on PATH or the default install roots, and gets --login -i like every other Git Bash entry point (only for the bare alias — a path the user wrote gets no flags). A spec that still fails now says so in the pane (shellFallbackFrom) instead of only in main's log |
remote-console/* |
The phone Remote Console (#254): watch and answer agent panes from a phone browser. Off by default, loopback by default. index.ts is the FACADE and the only module the rest of main imports: it loads ./runtime with a bare lazy require (the cli-paths.ts idiom) so the server/ws/crypto work stays off startup and main compiles whether or not the runtime is present — which also makes a packaging miss SILENT (remote-console-missing in main.log, null everywhere), hence the release-checklist grep. contract.ts's ConsoleOps is the COMPLETE list of what the console can do to the app, built in main's index.ts; taps.ts holds the no-op hooks both PTY forwarders call (always through the object, never destructured). Seven invariants, each a reason something is shaped the way it is: I1 separate dispatcher — the console never touches the pipe token or the V2 switch, its message table is deny-by-default and reaches only ConsoleOps; I2 no credential mint over the pipe — the pipe gets exactly one read-only method, remote.status (NOT in PUBLIC_V2_METHODS), and pairing/enable/revoke are Settings clicks, because anything a pipe command can do a prompt-injected agent holding the token can do; I3 off and loopback by default, a LAN bind is viewer-only unless allowInsecureControl is acknowledged twice; I4 remote input obeys every back-channel rule — answers only through deliverAnswer (the pane.answer_agent path: blocked-only, declared payload, never clears blocked), noteHumanInput immediately before each write with the identical bytes, blocked snapshotted before any write, the phone never resizes a PTY, never an implicit surface; I5 the pairing secret rides in the URL FRAGMENT and is stripped by the page's first statement; I6 persistence (remote-console.json, remote-devices.json with token HASHES only) stays under getAppDataDir(), so the #132 consent gate is not engaged; I7 no existing boundary moves, and command lines, report_command, session ids, cwd, notification text and choice key/text never go on the wire — main.log records byte counts, never typed content. will-quit is synchronous, so teardown is stopNow() (timers, sockets, server.close(), taps reset — no await), called right after the will-quit log line and before the PTY drain. The taps are installed only while the server listens, and a forwarder binds once, at PTY creation — so ipc-handlers.ts keeps its OWN surfaceId → webContents map and replayRemoteBindings() re-announces it on every not-running → running edge, after start() and after setConfig; without it every pane open before the user flipped the switch answers no-terminal on the phone |
pty-keys.ts |
Named key → raw PTY bytes: PTY_KEY_MAP / translateKeyName (moved byte-identical out of index.ts for #254, since surface.send_key, the pane.answer_agent writer and the phone's key bar must type the SAME bytes for the same name) plus REMOTE_KEY_BYTES, the phone's closed subset keyed by the protocol's RemoteKey so a key the protocol accepts cannot lack bytes |
window-manager.ts |
Electron BrowserWindow creation/management |
ipc-handlers.ts |
All IPC channel handlers |
claude-context.ts |
Injects wmux instructions into ~/.claude/CLAUDE.md, configures hooks, points chrome-devtools-mcp at the browser panel — and the inverse of each, since 0.40.0. Two things it no longer does, both because they were writing into files on terms Claude Code does not use. The orchestrator plugin is DEPRECATED and uninstall-only (#239): wmux hand-wrote installed_plugins.json, whose v2 shape is {version:2, plugins:{"<plugin>@<market>":[…]}}, as a bare object at the TOP level, so Claude Code listed nothing, loaded nothing and marked the copied tree .orphaned_at while wmux logged a success — and native parallel orchestration makes repairing it pointless. removeOrchestratorPlugin therefore runs in BOTH branches of the toggle, and must never remove plugins[key] or clear an enabledPlugins flag with a real install behind it (orchestratorFlagIsStale): that is where the hand-installed replacement lands. And the chrome-devtools MCP entry goes to ~/.claude.json, NOT ~/.claude/settings.json (#237) — settings.json has no mcpServers key and its schema strips unknown keys, so six releases' worth of writes landed, changed nothing, and logged success; the stale entry is swept on both paths. The per-tool-call hooks (PreToolUse, PostToolUse, UserPromptSubmit) are written with async: true: they are pure observers, and Claude Code waited a measured 125-145 ms on each of them on the critical path of every tool call. Ordering is already handled — every report carries the wall-clock it fired at and agent-state.ts drops anything older than what it accepted (#151). SessionStart, Stop and SessionEnd stay sync. applyWmuxHooks rewrites its own entries on every launch, so an install that predates the flag picks it up without a migration |
agent-integration.ts |
Consent gate for every write outside %APPDATA%\wmux (issue #132). Asks on first launch, stores unset/granted/declined in wmux's own settings.json, and reconciles ~/.claude + ~/.config/opencode + ~/.kiro + ~/.omp + ~/.pi + ~/.grok to match. Nothing in claude-context.ts, opencode-context.ts, kiro-context.ts, omp-context.ts, pi-context.ts or grok-context.ts may be called directly from startup any more — route it through here. Which FEATURE a new integration hangs off is a real decision, not a slot: the pi extension is under hooks because the panel's own label for that toggle is "Status hooks" and that is exactly what it is, while the OpenCode plugin does the same job from under orchestrator — a pre-existing mismatch left alone deliberately, since moving it would silently re-enable a status bridge for everyone who switched that toggle off |
kiro-context.ts |
Kiro CLI support (issue #148). Writes ~/.kiro/steering/wmux.md — a dedicated global steering file, since Kiro loads every .md in that dir, so there is no shared file to splice into. No hooks: Kiro's are per-project (.kiro/hooks/), and writing into every repo the user opens is the #132 mistake. State comes from wmux report-agent instead |
grok-context.ts |
Grok Build (xAI's grok CLI). Grok's Claude compatibility (on by default) already loads ~/.claude/CLAUDE.md and runs the ~/.claude/settings.json hooks, so this only covers the gaps. Hooks: $GROK_HOME/hooks/wmux.json carries the Claude entries BYTE-IDENTICAL (built by applyWmuxHooks + wmuxEventHookCommand) plus Grok-only StopCancelled/StopFailure — Grok dedupes handlers on (event, command, matcher), so with compat on each hook runs once and with compat.claude.hooks = false this file alone is the full set; any drift in the command bytes runs every hook twice. wmux-hook.js reports both extra events as Stop (SubagentStop when the payload has subagentType), which is what ends an interrupted turn. It also reads Grok's camelCase envelope (notificationType, toolName, toolInput; grok 1.0.44 sends the notification type ONLY in camelCase) and never forwards a session id when GROK_HOOK_EVENT is set — Grok sends a snake_case session_id alias too, and it would reach a claude --resume line on restore. Instructions: $GROK_HOME/rules/wmux.md only when Grok will NOT read the CLAUDE.md block (GROK_CLAUDE_AGENTS_ENABLED / compat.claude.agents = false, via a line scan of config.toml), else ~5 KB loads twice; runs after ensureClaudeContext and removes a stale copy. Nothing is written unless Grok's home dir exists. Three things the PR's live test could not reach (the account had no usage, so no successful turn, no subagent, no background task) and that Grok's own hook docs settle. (1) A Grok subagent is its OWN session firing the same hooks on the parent's pane, marked subagentType: its SessionEnd released the pane's record mid-turn and its UserPromptSubmit — the task brief — cleared a "Needs you" nobody answered and entered the prompt log as something the user typed, so reportedEvent in the helper reports neither (its tool events and Notifications still pass). (2) task_complete fires when a background shell exits; under the unknown-type default of claude-notification.ts that was "Needs you" plus the bell, so it is info there. (3) Ownership of hooks/wmux.json is STRUCTURAL (isWmuxHooksFile: only a hooks key, every handler a node "…wmux-hook.js" … command), not "contains wmux-hook" — that is the name a user picks for a hand-written file, and one hook of their own in it was overwritten on launch and deleted on toggle-off. GROK_HOME counts only when absolute, and removal sweeps the default home as well as the current one. Known gap, not fixed: Grok documents that a StopCancelled report may arrive AFTER the next turn's UserPromptSubmit (it is dispatched off the command loop; the fix is tracking promptId), so a cancel followed at once by a queued prompt can read idle until that turn's first tool call |
opencode-context.ts |
Installs resources/opencode-plugin/wmux.js into ~/.config/opencode/plugin/, gated on the // wmux-plugin-version: marker (pluginNeedsUpdate compares it verbatim, so any change to the plugin needs a bump or it reaches nobody — every broken install already has the old file on disk). That plugin file must export WmuxPlugin and the default definition, nothing else (#191, #271): OpenCode v1's legacy loader calls EVERY export as a plugin factory and then invokes a config hook on the result, so an exported helper returning a plain value crashes OpenCode at startup. Helpers hang off WmuxPlugin.__wmuxInternals for the tests; a test pins the export set. **OpenCode v2 rejects any file without `export default { id, effect |
pi-context.ts |
pi (@earendil-works/pi-coding-agent) support (#231). Two halves. The context block does NOT always go to AGENTS.md: pi loads exactly ONE user-scope context file from ~/.pi/agent, taking the first that exists of AGENTS.override.md, AGENTS.md, AGENTS.MD, CLAUDE.md, CLAUDE.MD, and never looking at the rest — so the obvious implementation creates a file, reports success and reaches nobody on a machine that already has an override. getPiContextFilePath splices into the file pi will actually read; removal sweeps EVERY candidate, because the target can change between launches and an orphaned block in a file nothing reads is an uninstall that cannot uninstall. The extension is what omp-context.ts said was "the right shape for a follow-up": pi auto-discovers ~/.pi/agent/extensions/ GLOBALLY, so unlike omp's per-project .omp/hooks/ there is no repo to write into and #132 is not engaged. PI_CODING_AGENT_DIR is honoured when it is in wmux's own env; a value exported per-shell after launch is unknowable here and falls back to the default — a miss, never a write to the wrong place |
claude-observer.ts |
Monitors Claude Code activity for sidebar display |
claude-resume.ts |
claude --resume on workspace restore (#186), behind workspacePrefs.restoreClaudeSessions (default off — every such pane starts an agent at once). Stamps each terminal's live session id into the PERSISTED tree only, the way freezeSurfaceCwds stamps cwd; a live surface never carries one. Main-side rather than renderer-side because the id lives in agent-state.ts's record map. The id reaches a command line, so CLAUDE_SESSION_ID_RE is a security boundary, enforced at reportAgentSession AND again in claude-resume-command.ts (session.json is user-editable). handleHookEvent must skip SessionEnd: it carries a session_id like every hook, but releaseAgent() has just run for it, and recording there would resume a Claude the user deliberately quit |
codex-launcher.ts / codex-resume.ts |
Codex conversation restore (#261, contributed), behind workspacePrefs.restoreCodexSessions (default off). No hooks: a private PATH shim (src/codex-bin, src/codex-bin-ps) runs cli/wmux-codex.js, which starts codex app-server behind an authenticated 127.0.0.1 relay (codex-relay.ts, the one CLI file that needs an npm package — ws, shipped in cli/node_modules) and reads the thread id off its responses. Three rules from review. (1) With the pref OFF a pane gets no shim and pays no where codex.exe — codex must be exactly what the shell would run, and pane create is the #176 hot path. (2) PowerShell gets the .ps1 shim or NOTHING, never the .cmd (#154: cmd.exe re-parses the argv). (3) The relay is required lazily and a failure falls back to plain codex, so a packaging miss costs the feature and never every codex launch. Only a clean exit (code 0) forgets the id; a crash or wmux quit keeps it |
opencode-resume.ts |
OpenCode session restore (#290), behind workspacePrefs.restoreOpencodeSessions (default off), replayed as opencode -s <id>. The handle comes from wmux’s OWN OpenCode plugin (report-session --provider opencode), validated as ses_<base62> — strictly, because Claude’s looser validator accepts an OpenCode id too, which is why agent-session-persistence.ts stamps Claude only for a Claude (or provider-less) record. The plugin never reports a subagent session (one born with a parent — info.parentID on v1, data.parentID on v2; parentID on message.updated is a MESSAGE id) and never reports from OpenCode 2’s shared serve --service server, whose WMUX_SURFACE_ID is only the pane that started it — v2 is captured under --standalone only. Quitting OpenCode releases via pane.release_opencode_session, refused while wmux is quitting (the Codex rule) |
agy-resume.ts / agy-context.ts |
Antigravity CLI conversation restore, behind workspacePrefs.restoreAgySessions (default off), replayed as agy --conversation <id>. The handle comes from wmux’s OWN agy plugin, a directory ~/.gemini/config/plugins/wmux/ (plugin.json + hooks.json + wmux-agy-hook.cjs) installed under the Status hooks consent only where ~/.gemini/antigravity-cli exists. Its hooks are SessionStart (new conversation) and PreInvocation (first model call of a turn — the only sign of a --conversation resume), and they send pane.report_agent_session with provider: 'agy', validated as a UUID. Commands are bare node wmux-agy-hook.cjs <Event>: agy runs hooks from the hooks.json folder and passes a quoted argument to node with its quotes on. agy has no session-end hook, so its exit is the shell returning to its prompt (report_shell_state): releaseAgyAtPrompt drops an agy handle, or — when the command that ended was agy but it never reported — the restored id (known only from a preexec report_command, which the PowerShell integration sends for ssh alone, so in pwsh such an agy keeps its id); refused for a stale (out-of-sequence) prompt and while wmux is quitting |
qwen-context.ts / qwen-resume.ts |
Qwen Code (qwen, 0.14.0+) at Claude parity. Qwen does NOT read ~/.claude, so the Claude integration is written again into $QWEN_HOME (absolute only, default ~/.qwen), and only when that dir exists: the wmux block in QWEN.md (instructions), the Claude hook set plus StopFailure in settings.json hooks (hooks), and mcpServers.chrome-devtools there too (browserMcp). A settings.json with comments is left alone, never rewritten without them. No quotes in a hook command (2.42.1): started from pwsh/cmd (no MSYSTEM), Qwen runs hooks as spawn(ComSpec, ['/d','/s','/c', cmd]) without windowsVerbatimArguments, so Node's \" reaches node literally and a quoted path with a space (OneDrive - Org) dies on MODULE_NOT_FOUND — every pwsh-started Qwen pane reported nothing. Hooks therefore run $QWEN_HOME/wmux-qwen-hook.cjs, a one-line require of the real helper, named BARE (qwenHookTarget); a probe from Git Bash passes either way because Qwen picks bash there, so verify from pwsh. Every hook command ends --provider qwen, which is how wmux-hook.js knows the payload is Qwen's: it maps runtime tool ids to Claude names (run_shell_command→Bash), logs only submitted_prompt (Qwen also fires UserPromptSubmit for tool-result continuations), forwards only a strict UUID session id (a subagent's <uuid>-agent-<name> would overwrite the pane's handle), and sends provider: 'qwen' so handleHookEvent files the id as Qwen's — without the tag a restore types claude --resume <qwen-id>. Restore behind workspacePrefs.restoreQwenSessions (default off), replayed as qwen --resume <id>; dead ids are pruned at load from <QWEN_RUNTIME_DIR|QWEN_HOME|~/.qwen>/projects/*/chats/*.jsonl. Verified live against qwen 0.25.3 + Ollama: every event reports, --resume reopens the conversation |
agent-state.ts |
Declared agent run state — blocked/working/idle, run refcount, seq dedupe, metadata TTL (issue #128). Also the back-channel: declared choices + answerAgent. Answering never clears blocked — the agent must confirm, or a mis-declared key silently stops a stuck pane asking for help |
agent-state-rpc.ts |
pane.report_agent & friends, routed off the main V2 switch |
agent-hook-bridge.ts |
Claude Code hooks → declared state, so it works with no plugin to install |
quit-sequence.ts |
When quit may let the process leave (#214). The 0xc0000409 aborts reported there are shutdown crashes: all six land on a will-quit line in the reporter's own main.log, to the second, and logDiagnostic('will-quit', …) is that handler's first statement. Six across thirteen quits at PTY counts 2–7 — a race, not the file explorer it was filed against. The mechanism is the one the session-end comment in index.ts already describes: killAll() fires N node-pty ConPTY exit callbacks and the process then walks into Node's environment teardown, where a napi call that loses throws off the top of the stack into __fastfail(7). pty-crash-guard.ts closes the route where the JS callback throws and cannot close this one, because JS is never reached. Two moves: drain (hold the quit ~250 ms so the callbacks land while the environment is healthy — the strategy session-end argues for, applied to the ordinary quit), then leave hard (app.exit(), so whatever did not land never sees the teardown). A quit with no PTYs defers nothing and exits normally: the hard exit skips Chromium's own shutdown, so it is a treatment for a condition and not a new way of quitting. Pure, because will-quit needs a real Electron app to reach and the part worth pinning is the decision |
close-guard.ts |
Opt-in "ask before the window closes" (#227, 2.10.2), pref confirmAppClose. Pure decision — allow / ask / swallow — applied by window-manager.ts on the BrowserWindow close event with a NATIVE dialog.showMessageBox, Cancel as the default button. Native and in main on purpose: by the time a close can be intercepted it is already in main, and the reporter's renderer was the part not responding. The pref is read off settings.json at close time (the renderer persists it there synchronously), so no IPC. bypass is `isQuitting |
gpu-watchdog.ts |
The self-heal for a wedged GPU process (#229, 2.10.4). After ~2.5 days the reporter's window kept its last frame while every PTY lived on, Responding=True, 0% CPU everywhere; killing ONLY the GPU process restored rendering with the same renderer PID and nothing lost. No repro (hybrid NVIDIA/Intel + two DisplayLink adapters, a 3456×1408 clear window). The detector is a rAF race in renderer/utils/gpu-watchdog.ts: requestAnimationFrame runs on BeginFrames from the display compositor in the GPU process, so when it wedges rAF stops while setTimeout keeps going — 3 unanswered requests 5 s apart, while document.visibilityState is visible, is a report. The renderer never acts: main gates on the window being FOCUSED and powerMonitor.getSystemIdleTime() ≤ 30 s, because a hidden document is not the only window nobody is looking at (screen off, an always-on-top overlay) and Chromium counts each killed GPU process as a crash — enough of them and it drops to software rendering for the session, hence also 5 min between restarts. process.kill(pid) on the GPU entry of app.getAppMetrics(); Chromium relaunches it on demand and terminal-renderer.ts's context-loss path (#218) repaints every pane. Logs gpu-stall (skips, deduped per run), gpu-restart and gpu-gone to main.log. Off switch: gpuWatchdog: false in wmux-workspace-prefs |
icon-cache.ts |
The stale-taskbar-icon story (#137, #226 — same reporter, two rebrands). Explorer's iconcache_*.db is memory-mapped by the running explorer.exe and flushed only on exit, so NO installer-side call refreshes a pinned button — 1.5.1 measured ie4uinit doing nothing and SHChangeNotify not reaching the taskband store. What works is an Explorer restart with the files deleted in between, which an installer must not do silently and a user may ask for: Settings → General → Refresh taskbar icons, behind a native confirm in ipc-handlers.ts that says File Explorer windows will close. PowerShell by absolute System32 path, detached, never cmd.exe (#154). noteIconRevision fingerprints the shipped .ico into %APPDATA%\wmux\icon-rev so the renderer can post ONE bell notification on the first launch after an update that changed it; with no record it fires only on an UPGRADE (savedVersion() read before handleVersionChange overwrites it), never on a fresh install |
changelog.ts |
Release notes in the app (#211). GETs /releases and caches every success under %APPDATA%\wmux\cache — inside wmux's own dir, so no part of the #132 consent gate. Every failure falls back to that cache, including a manual Refresh: a refresh that fails must not blank the list being read. No Authorization header ever; the moment this could carry a token it becomes a thing that can leak one. toChangelogEntries is pure and is where everything that can be wrong lives — drafts dropped, a body that is null rather than absent, a name that is '' on a bare-tag release (so ?? keeps the wrong thing), and ORDER: the API returns releases by CREATION date, which stops being version order the moment a patch for an older line ships after a newer minor |
chromium-profile.ts |
Where Electron's userData lives (#282, 2.22.0): %APPDATA%\wmux\chromium, NOT %APPDATA%\wmux. That root was also the default of openwong2kim/wmux, a different Electron app with the same name, and Electron keys requestSingleInstanceLock on userData — so with both installed the second to start got gotLock false and quit, handing its argv to the wrong app as second-instance (reproduced in a standalone Electron 43 harness: CONTROL false, after the move both true). They also shared one Chromium profile: one Local Storage (most renderer prefs), one cookie jar for both browser panels. wmux's OWN files (settings.json, sessions, pipe-token, logs, agent-detection…) stay in getAppDataDir() — the CLI, hooks and crash-report read them there, and so does a downgraded wmux — so never reach them through app.getPath('userData') (user-manifests.ts did, and was moved). The move COPIES Local Storage, IndexedDB, Network + Local State (cookies and their key — useless apart), Preferences, WebStorage once, skipping caches and LevelDB LOCK/lockfile (held exclusively by a running profile, carry no data), and writes .migrated last. If Local Storage cannot be copied whole the partial copy is removed and THIS launch keeps the old userData — never a profile without the user's settings; the next launch retries. Other collisions with that app, handled elsewhere: wmux-hook.js exits when WMUX_SURFACE_ID comes with none of our markers (WMUX=1, WMUX_PIPE, WMUX_REMOTE) — theirs exports the same name; and the agent-context block no longer reads a non-pong bare wmux as absence, since theirs puts its own wmux.cmd on the user PATH |
workspace-manifest.ts + workspace-open.ts / -scheduler.ts / -trust.ts / -manifest-v2.ts, wait-probes.ts, surface-env.ts |
Project-local workspace manifests (#284, 2.23.0): wmux.workspace.json → panes (cwd, run, runOnce, env, after, waitFor) + browser tabs. User docs: docs/workspace-manifest.md. A manifest arrives with git clone, so it is UNTRUSTED input and the design is the guardrails. Trust is a native dialog in main (workspace-manifest-v2.ts), keyed on the file's sha256 in %APPDATA%\wmux\workspace-trust.json — never beside the manifest, or a repo could ship its own "trusted" flag — and there is deliberately no --trust: an agent holding the pipe token could pass it. The dialog text is formatPlan of the SAME plan the scheduler runs (file read once, hashed, planned), so what was shown is what runs; any byte changed re-prompts with an added/removed diff. trustLines are computed WITHOUT runOnce history, or a runOnce step that simply ran reads as "removed" in the next diff (the first version did that; a test caught it). Pure validation (workspace-manifest.ts): cwd relative and contained (plus a realpath check in workspace-open.ts so a junction cannot walk out), no line break in a command, waitFor loopback-only (it must not be a port scanner for somebody else's file), WMUX_* env keys refused, a literal value under a secret-looking key warns toward ${env:NAME}. ${env:NAME} is resolved in surface-env.ts at PTY spawn (IPC pty:create only — agent-manager passes its own WMUX_AGENT_ID straight to create() and must not be filtered), so SurfaceRef.env and session.json carry the reference, never the secret. Manifest commands never enter the store or session.json: a restart would replay them without asking again. Every command goes through workspace-scheduler.ts → PtyManager.deliverCommands (the #251 gated typing path, now also for an already-running shell); after waits on DISPATCH, waitFor is bounded, a timed-out pane prints a [wmux] … notice and runs nothing while its dependents still go. Notices are written to xterm (showPaneNotice, parked until the pane mounts), never the PTY. Re-opening an open manifest focuses it (live-only map). wmux workspace save never makes main write a path: workspace.export RETURNS the manifest, the CLI writes it. Per-pane startup commands are editable on a live tab (right-click → Startup command…, StartupCommandDialog.tsx) |
session-persistence.ts |
Auto-save/restore window state. saveSession is atomic in the sense the comment always claimed and the code did not (#214): it used to unlink the live session.json before renaming the temp over it, a window with no session file at all — die there and the next launch has nothing to restore, comes up as a fresh Session 1, and re-mints every pane and surface id, which is that issue's "surfaces come back with new ids and lose their customTitle". The unlink was never needed: libuv's uv_fs_rename uses MoveFileExW with MOVEFILE_REPLACE_EXISTING, so a plain renameSync over an existing file is legal. It survives only as a fallback for a real sharing violation (antivirus, a sync client), where not saving at all is worse than a brief window. Also the scheduled snapshots (#238): session.json holds only the CURRENT layout, so a mistake has nothing behind it — the reporter lost 28 browser panes to a cleanup script and recovered from an older file by luck. writeSessionSnapshot keeps a three-slot Auto-save … ring on the existing 30s auto-save tick (no second timer — a second one would have a second idea of what the layout is). A ring rather than the single entry that was asked for, because one slot bounds recovery to the state AFTER the loss. shouldSnapshot gates on the clock AND on layoutFingerprint, which deliberately excludes cwd/bounds/sidebarWidth: those change on every prompt, so including them makes the gate pass every tick, and without the gate an idle machine rotates three identical copies in and deletes its own history |
session-flush.ts |
The LAST save before a window goes away (#285). session.json was written only by the 30 s auto-save tick and a fire-and-forget session:request in before-quit — and closing the last window with × reaches before-quit after that window is gone, so up to 30 s of edits (the reporter's startup command, saved 9 s before quitting) never reached disk. Now WindowManager.beforeClose holds the LAST window's close and before-quit holds the quit (once, quitFlushDone) until each window's session:save lands, bounded by SESSION_FLUSH_TIMEOUT_MS (1 s) because a hung renderer is exactly the window the user is closing. Skipped on session-end (OS deadline). The startup-command dialog also writes at once via requestSessionSaveNow() (renderer/utils/session-save-now.ts) |
webview-prefs.ts |
The prefs every browser-panel <webview> attaches with. will-attach-webview fires on the HOST window, never on the guest — it was registered on type === 'webview' only, so the hardening there was dead code until #287 moved it. Also sets autoplayPolicy: 'document-user-activation-required' (#287): Electron's default lets a page autoplay WITH sound on load; Chrome/Edge make it wait for a user gesture. Muted autoplay is unaffected |
browser-history-restore.ts |
Back/Forward across a restart and a split-tree remount (#315). Every mount is a fresh WebContents, and Electron 43's navigationHistory.restore refuses any guest that has loaded a page — about:blank included — while a <webview> with no src never attaches. So the pane mounts on a sentinel src (about:blank#wmux-history=<json>, shared/browser-history.ts), will-attach-webview decodes it and BLANKS params.src (left in place, the guest's own load raced the restore and truncated Forward), web-contents-created claims it by guest id (it fires synchronously inside createGuest, right after will-attach — measured with three panes at once), and did-attach-webview restores. Persisted as SurfaceRef.browserHistory / workspace.browserHistory beside the URL; url + title only, never pageState (it carries form values); http(s) only; 50 entries keeping Back over Forward. The history is used only when its active entry IS the URL the #232 rules chose, so it can add Back/Forward but never pick the page. browser:get-history answers only for a guest the asking window hosts |
exit-watchdog.ts |
A quit that never finishes (#289: exit via=app.exit logged, process alive 16 h with its GPU/network/audio children, next instance beside it). Once teardown wedges in native code no JS runs again — not a timer, not a worker — so will-quit arms ANOTHER process: wmux's own exe under ELECTRON_RUN_AS_NODE, detached, with an inline script that polls the parent pid every 250 ms, leaves the moment it is gone, and only past QUIT_TEARDOWN_BUDGET_MS + 15 s terminates it and logs exit-watchdog killed= to main.log. Never a .cmd/PowerShell helper (the dropper shape zip-updater.ts avoids) |
renderer-recovery.ts |
Stuck window, live terminals (#289). PTYs live in main and a reloaded window gets its own session:load-auto slot back (same surface ids → pty.has re-attach), so a reload IS the recovery. WindowManager.watchRendererHealth offers it on unresponsive (Wait is the default; responsive aborts the dialog via signal) and on a non-clean render-process-gone. A hung renderer is forcefullyCrashRenderer()d before reload(), and the reloading flag stops that kill from asking twice. Never automatic: a slow renderer is not a dead one |
git-view.ts |
The project Git view (#362) — main half of the git surface: worktrees, working changes, branch changes and the commit graph over ONE IPC channel (git:view, a tagged GitViewRequest from shared/git-view.ts). Read-only by construction: it runs only reading sub-commands (worktree list, status, log, diff, show, for-each-ref, merge-base, rev-parse, symbolic-ref --quiet). Three runner rules, each against a concrete failure: (1) no shell, and no renderer string in an option slot — isSafeRev refuses a leading -, a range and an object path, paths only follow -- under --literal-pathspecs, and git itself is an ABSOLUTE path (gitCandidates: PATH dirs, then Git for Windows' install roots), never a bare git resolved per spawn; (2) GIT_OPTIONAL_LOCKS=0, because a polled git status refreshing the index takes .git/index.lock and makes the git commit of an agent working in that worktree fail; (3) --no-ext-diff --no-textconv, since a diff driver named by .gitattributes is a program. Worktree-scoped requests name the worktree by path, and it must be one git worktree list reports for the repository — the view can only point git at directories git vouched for. Branch changes are THREE-dot (merge-base(base, HEAD)..HEAD) so commits that landed on the base after the fork never read as the agent's work, and uncommitted changes stay out of them. The graph names every worktree HEAD explicitly beside --all, because a detached worktree's commits sit on no ref and are exactly what an agent made. Never throws across IPC ({ok:false, error, code}); identical in-flight requests are coalesced (#141) |
port-scanner.ts |
Active port detection for running dev servers |
powershell-shim.ts |
The wmux.ps1 gate (issue #154). PowerShell resolves a .ps1 ahead of every PATHEXT entry, which is how cmd.exe's argument parser is kept out of the PowerShell path — but a .ps1 PowerShell refuses (Restricted policy, or Mark of the Web under RemoteSigned) is a hard error with NO fallback to the .cmd beside it. So the shim dir goes on PATH only after a probe script in that same dir has actually run in every installed host |
node-runtime.ts |
Which binary on this machine can run a .js file (#187). Everything wmux hands an agent is "a script plus something to run it", and every consumer had been assuming node was on PATH or that the host process was itself a JS runtime — false under OpenCode, whose process.execPath is a compiled opencode.exe. Resolved once (memoised: it is read on the synchronous pane-create path, see #176) and declared as WMUX_NODE. The last resort is wmux's own Electron binary, which is Node under ELECTRON_RUN_AS_NODE=1, so the chain never dead-ends — but that flag is what makes it a runtime instead of a second wmux window, hence the separate WMUX_NODE_ELECTRON signal |
ssh-argv.ts |
Parses an ssh command line into the facts scp needs to reach the same host (#195). Pure, and the single funnel for all three detection sources so they cannot disagree about what a command line means. Returning null is the safe outcome — a mis-parse does not fail loudly, it uploads a file to the wrong host — so port forwards (-N/-W), one-shot remote commands and RequestTTY=no all abandon the parse rather than guess |
ssh-detect.ts |
Is this surface sitting inside ssh, and where? Three layers: managed (wmux ssh put the command line in the shell spec), reported (shell-integration preexec hook), probed (Win32_Process ancestry sweep). The precedence rule is a security boundary, not a preference: Windows has no tty foreground process group, so a descendant ssh.exe may be a background process — the probe may only corroborate an authoritative layer, never establish one. refresh() short-circuits with no sweep when neither authoritative layer has an entry, because it runs on the paste path |
remote-upload.ts |
scp/ssh argv construction and the transfer itself. BatchMode=yes throughout: these run with no TTY, so a passphrase prompt would hang to the timeout. All-or-nothing — a failed batch deletes its private /tmp/wmux-drop-<uuid>/ before returning, since half a batch gives the user remote paths and silence with no way to tell which is which |
remote-insert.ts |
What a paste or drop types into a terminal. Lives in main because every input to the decision does (clipboard, detector, filesystem, scp, config). Quoting is per-side: Windows-conditional locally, always single-quoted for a remote sh |
win32-process.ts |
One Get-CimInstance Win32_Process invocation for both the orphan reaper and the ssh probe. Shared so the security-relevant part — resolving powershell.exe by ABSOLUTE path, so a writeable PATH dir cannot shadow it — is stated once |
system32.ts |
Absolute paths to Windows-owned tools. opensshPath() prefers Program Files OpenSSH over System32 (#193): Git for Windows puts an MSYS2 ssh ahead of System32 on PATH, and it cannot talk to the Windows named-pipe ssh-agent — so a bare ssh spec died on "Permission denied (publickey)" wherever keys live only in an agent |
shell-context-menu.ts |
"Open in wmux" Explorer verb — HKCU shell keys for Directory/Directory\Background/Drive, plus directoryFromArgv for the launch path. Win11 places it under "Show more options"; the modern menu needs a signed MSIX, which unsigned wmux cannot ship |
theme-loader.ts |
Theme loading |
explorer-fs.ts |
The file explorer's jailed directory enumeration, and the path policy every explorer and code read goes through. resolveInRoot is the security boundary: the renderer sends a surfaceId and a RELATIVE path and NEVER an absolute one, because a renderer-supplied root would not be a jail — a compromised renderer would simply pass C:\. Containment is path.relative, plus a Windows-spelling prefix check beside it (the root is realpath'd on the way in, so 8.3 short names and junction aliases are already gone). Two traps live here: rel.startsWith('..') also rejects a legitimately-named ..foo, which the user sees as a folder that silently refuses to open; and a drive root ALREADY ends in a separator, so appending one unconditionally spells c:\\ and locks a pane at C:\ out of every folder below it. The walk lstats EVERY segment rather than only the leaf — root\link\sub follows link before a leaf-only lstat ever runs, and libuv maps reparse points onto symlinks so mklink /J junctions are covered by the same check |
explorer-roots.ts |
surfaceId → the explorer root for that pane. The root is the pane's TERMINAL cwd, not its active surface's — a pane showing a markdown tab still belongs to the folder its shell is in — and it is normalized through trustedWindowsCwd, so a Git Bash /c/Users/... report and a PowerShell junction alias both land on the same spelling main can jail against |
code-file.ts |
Which files the code viewer will read and write, and how. Sibling to markdown-file.ts, and it deliberately neither imports from it nor changes it: markdown's extension whitelist stays exactly as narrow as it is, for exactly the callers it already has. The threat model is INVERTED — markdown names its whitelist as the thing stopping a renderer bug from reading ~/.ssh/id_rsa into a visible pane, and this module has no whitelist at all because the whole point is to read the files that whitelist rejects. What replaces it is explorer-fs.ts's path jail, applied by the code:read-file handler before anything here runs. BINARY_EXT and the content sniff are a UX filter — they keep .png out of the tree and mojibake out of the pane — and a future reader who treats them as the boundary will draw the wrong conclusion about what may be relaxed. writeCodeFile adds two things a naive save gets wrong and that both show up as a whole-file rewrite in the next commit: a <textarea> normalizes its value to LF, so a CRLF file's endings are restored from what is on disk, and the file's encoding (UTF-8, UTF-8+BOM, UTF-16LE) is probed from the preamble of the file being overwritten rather than round-tripped through the renderer. The size cap is measured on the ENCODED buffer — UTF-16 doubles most source text, so a string check lets a file through at twice the limit the read side then refuses to reopen |
file-grants.ts |
Which paths a renderer may write to — the single gate for every renderer→disk write, checked by both markdown:save-file and code:write-file. Was markdown-grants.ts; renamed when the code surface became editable, because it was always a generic Map<webContentsId, Set<path>> and two grant sets would be two answers to "may this be written", whose drift shows up as a silent write to a file one of them would have refused. Records a partial reversal of #210, deliberately: that PR refused a jailed read that mints a grant on the grounds that "jailed to a pane root" is not consent, which was right while the pane was read-only. The editor's transaction is different — the user clicks a row, types, presses Ctrl+S — so the rule is now "a write lands only on a path opened into a live pane in THIS window, in this session, through the jail, and only if it has not changed on disk since". Still not a grant source: markdown:read-file, which takes a renderer-supplied ABSOLUTE path; a renderer that can mint its own grants makes the set meaningless. That is why the jailed markdown read had to be a separate channel rather than a flag on the existing one |
config-loader.ts |
WT/Ghostty config import. Reachable from Settings → Terminal → Import. WT spells opacity two ways — modern opacity (0-100, independent of useAcrylic) and pre-1.12 acrylicOpacity (0-1, only with useAcrylic) |
shell-detector.ts |
Available shells detection |
updater.ts |
Auto-update. Routes by install layout: NSIS → electron-updater, portable zip → zip-updater.ts (#184). initAutoUpdater() returns before registering NsisUpdater on a zip extract, so a portable install can never enter the #96 "update ready" loop. A Scoop install (#321) matches the zip shape too, so it is checked FIRST (isScoopManagedInstall: Scoop's manifest.json + install.json beside the exe) and never updates in place — the badge answers with a scoop update wmux dialog. The repo is its own Scoop bucket (bucket/wmux.json), bumped by the last step of release.yml via scripts/update-scoop-manifest.mjs |
zip-updater.ts |
In-place update for portable zip extracts (#184). Detection is the whole contract: wmux.exe present, Uninstall wmux.exe absent — that name is electron-builder's Uninstall ${productFilename}.exe, so it moves if productName/executableName ever change. Download via net.request, extract via System32 tar.exe (PowerShell Expand-Archive fallback), then a detached cmd helper waits on the old PID and robocopies over the install root. The helper's relaunch is unconditional — wmux has already quit, so bailing out on a copy failure is the one outcome the user can't recover from. For the same reason wmux quits only once it knows the helper can run (#3): a missing payload rejects with PAYLOAD_MISSING (staged update forgotten, next click downloads), a helper that cannot be written or whose cmd.exe emits 'error' rejects with the payload kept (next click shows the install dialog again — never a straight quit from a badge reading "Click to try again" — and after two failures of the same payload answers install_failed with the release page URL), and quit waits for 'spawn'. The retry keeps the phase at error and starts the dialog synchronously: flipping to ready first let a second quick click install behind the dialog. buildHelperArgs spawns cmd /d /s /c ""<helper>" "<arg>"…" verbatim, because Node's per-argument quoting plus cmd /c's strip-first-and-last-quote rule ran C:\Users\First on any %TEMP% with a space; it also refuses a %…% pair, which cmd expands even inside quotes. The helper does not strip MOTW and not delete itself — hidden Unblock-File and a self-erasing script are the two dropper signals behavioural AV reacts to, and neither did anything (the payload has no :Zone.Identifier; robocopy replaces the destination's streams; stripMotw() covers the rest on launch). The leftovers are removed instead by sweepUpdateLeftovers, 60 s after a packaged Windows start whatever the install type, and every rule keeps on doubt: fresh (≤ 1 h, future or unreadable mtime), a zip/extract dir whose PID has a fresh helper beside it (after Later the payload is days old while a helper copies from it), a PID process.kill(pid, 0) does not answer ESRCH for, a symlink/junction, a type that does not match the name. Names are built and parsed in one place (update*Name / classifyUpdateLeftover). The helper's console is visible on purpose and says so in English: windowsHide cannot hide it (DETACHED_PROCESS makes Windows ignore CREATE_NO_WINDOW — measured, a detached spawn adds a conhost.exe and a non-detached one does not), and the two ways to hide it are both worse — dropping detached puts the helper's life back in the hands of a wmux that has already quit, and a hidden detached script overwriting an unsigned exe is the dropper shape the rest of this row is avoiding. It was reported as an empty window titled findstr.exe, since Windows renames a console after its current child, which is why title is re-set inside the loop. The pause is waitfor /t N, never timeout (refuses the ignored stdin: /t 3 returns in 66 ms, so the loop spun tasklist at 0.31 s CPU per 8 s wait) and never ping -n (the dropper batch-sleep idiom); where waitfor.exe is absent it degrades to that old spin. The liveness probe goes through a FILE (tasklist > %LIST%, then findstr on the file), never `tasklist |
What this file has done since we first saw it
Hashed on every crawl. A supply-chain change to an agent config is a question of when, not whether, so the history is kept rather than the latest state alone.
- today Changed · +19 lines · +1,554 tokens per session 94fd0cd31f34
- yesterday Changed · +18 lines · +462 tokens per session 6e809e6f4aaf
- 2d ago Changed · +6 lines · +84 tokens per session 7482f02b5b3c
- 3d ago Changed · +1 lines · +239 tokens per session f0728b3565d2
- 4d ago Changed · +85 tokens per session 6ef09cbe0893
- 5d ago Changed · +1 lines · +321 tokens per session 2d4e4bd87187
- 6d ago Changed · +4 lines · +1,279 tokens per session 93603c0cbe31
- 7d ago Changed · +342 tokens per session a22c594f5efb
- 8d ago Changed · +1 lines · +52 tokens per session d6fd2569df57
- 9d ago First seen · 919 lines · 32,706 tokens per session scan F 96038078c204
wmux CLAUDE.md is an instructions file published in the GitHub repository amirlehmam/wmux (431 stars, last pushed today), licensed MIT. It adds 37,124 tokens to every session, about $0.1485 per session on Opus 5.5. A static security scan graded it F with 4 findings (reaches for credential files, reads agent configuration directories, recursive force delete). No closer match exists in the catalogue, so it is treated as the original; first seen 2026-08-30.
Other instructions, from other repositories
vscode buildNext.instructions.md
Working notes and architecture documentation for the new esbuild-based build system in build/next. Use when making changes to the new build pipeline (transpile/bundle commands, NLS plugin, source-map handling, resource copying, or self-hosting watch tasks).
next.js AGENTS.md
AGENTS.md instructions for vercel/next.js, covering next.js development guide, codebase structure, monorepo overview, core package: packages/next and other important packages.
vscode oss-third-party-notices.instructions.md
Instructions for microsoft/vscode, covering vs code oss third-party-notices pipeline, architecture, pipeline flow in ci, cache eligibility and rollout and applying the notice (cutover).
langchain AGENTS.md
AGENTS.md instructions for langchain-ai/langchain, covering global development guidelines for the langchain monorepo, corridor security analysis, project architecture and context, monorepo structure and development tools & commands.
vscode copilot-instructions.md
Copilot instructions for microsoft/vscode, covering vs code copilot instructions, project overview, root folders, core architecture (src/ folder) and built-in extensions (extensions/ folder).
deno copilot-instructions.md
Copilot instructions for denoland/deno, covering deno development guide for github copilot, network access, high level overview, key directories and key files to understand first.