Electron-based Windows terminal multiplexer for AI agents. TypeScript, React 19, Zustand, xterm.js, node-p CC7B ty.
Owner: amirlehmam (GitHub) — speaks French, prefers fast pragmatic solutions, tests live.
Repo: github.com/amirlehmam/wmux | Site: wmux.org (Netlify, static from site/)
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
npx vitest run tests/unit/pty-manager.test.ts # Single file
npm run lint # ESLint src/Project lives in OneDrive - Pulsa (path with spaces). This breaks:
npm link/node-gyp(can't build node-pty)electron-builderwinCodeSign (symlink errors)
Releases are built by CI on a clean path, so this only affects local builds.
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
By area: the rules code in other files must follow. ls src/main lists the modules, and each one's design notes are in its own comments, starting with its header. Read them before changing it.
- App:
index.tsis the entry point, auto-save and the V2 pipe handlers;ipc-handlers.tsholds every IPC handler.awake-manager.tsowns Keep Windows awake, and renderers read it viaawake.getState, never a copy.gpu-watchdog.ts: main decides, the renderer only reports a stall.renderer-recovery.tsoffers a reload, never automatically. wmux's own files go ingetAppDataDir(), neverapp.getPath('userData')(chromium-profile.ts). - Closing and quitting: quits, relaunches and programmatic closes never prompt (
close-guard.ts).quit-sequence.tsdrains ConPTY exit callbacks beforeapp.exit().session-flush.tsholds the last close or the quit for each window'ssession:save, up to 1 s.exit-watchdog.tsis never a .cmd or PowerShell helper. - Terminals:
pty-crash-guard.tsmust be installed at module load, before the firstpty.spawn.PtyManagerdelivers startup commands (startup-commands.ts), and no caller may type them itself.pty-keys.tsis the one named-key table, shared by every writer.powershell-shim.ts: thewmux.ps1dir goes on PATH only after a probe has run in every installed PowerShell host. WithWMUX_NODE_ELECTRONset,WMUX_NODEmust run underELECTRON_RUN_AS_NODE=1(node-runtime.ts). - Pipe:
pipe-server.tsserves V1 text (shell hooks) and V2 JSON-RPC (CLI and agents) on\\.\pipe\wmux: one request per connection, closed withdestroy(), neverend(). - Browser:
cdp-proxy.tsimpersonates Chrome, so its routes accept what real Chrome accepts. Hidden panes must usevisibility, neverdisplay: none(browser-keep-rendering.ts). Every browser<webview>gets its hardened prefs from the HOST window'swill-attach-webview(webview-prefs.ts). - Agent browser (
agent-browser-*.ts): run the binary withspawnresolved on exit, neverexecFileor a.cmdshim.agent-browser-runtime.tsholds the singleSessionRegistryandDashboardDaemon: never construct another, and take dashboard refs only there. OnlyisWmuxSessionNamedecides what wmux may close, and a dashboard it adopted is never stopped. - Agents:
agent-integration.tsis the consent gate for every write outside%APPDATA%\wmux(#132), and the*-context.tswriters are called only through it. Before changing*-context.ts,*-resume.tsorcodex-launcher.ts, load thewmux-agent-integrationskill (.agents/skills/wmux-agent-integration/SKILL.md).agent-state.tsis declared run state (#128): answering never clearsblocked, and a PTY exit keeps session handles.agent-spawn-target.ts: a named pane is authoritative, and pane and surface lookups ask every window. - File access:
resolveInRootinexplorer-fs.tsis the jail every explorer and code read goes through; the renderer sends only relative paths.file-grants.tsis the single gate for every renderer→disk write: only a path opened into a live pane in this window and session through the jail, unchanged on disk since. The unjailedmarkdown:read-filenever grants. - Remote:
remote-console/*is the phone Remote Console (#254), off by default, loopback by default. Before changing anything there, readsrc/main/remote-console/AGENTS.md: the seven invariants (I1–I7), the lazy-loaded runtime, teardown onwill-quit, and the binding replay.ssh-argv.tsis the single funnel for every ssh detection source, and inssh-detect.tsthe process probe may only corroborate a session, never establish one. - Workspaces: only the close dialog may remove a
git-worktree.tscheckout, never any other close path. Manifests (workspace-*.ts, #284) have no--trustflag, and their commands never enter the store or session.json. - Windows:
win32-process.tsandsystem32.tsresolve Windows tools by absolute path, so a writable PATH entry cannot shadow them.zip-updater.tsdetects a portable install by electron-builder'sUninstall wmux.exe, so renamingproductNameorexecutableNamebreaks it.
As above: the rules other files must follow, with the design notes in each module's comments.
Components (in components/):
SplitPane/:surface-label.tsis the one tab-caption chain, andcustomTitleis only for names a human chose.use-osc-title.tsis the single reader of a program's window title (#221), so its pref gate cannot differ between callersTerminal/: anything floating over the xterm container isposition: absolute, never a flow sibling, or the PTY keeps its old row count and the bottom rows hide underneath (#82).PromptsPaneis a surface rather than an overlay, so that rule does not apply to itBrowser/:utils/browser-start-page.tsis the one rule for what a surface opens on when nobody said (#232): blank, never a page on the internetSidebar/:workspace-status.tsholds the three chains that must agree about one row (status words, status colour, state dot), so a priority added to one belongs in all three. Every UI place that names a workspace reads it throughuse-osc-title.ts(useWorkspaceLabel(s)/getStoredWorkspaceLabel), neverws.title; it is display only, and the CLI still answers the stored titleSettings/:ChangelogSettings(#211) shows notes from the network, so they only go throughrenderMarkdown, where DOMPurify runsMarkdown/:markdown-document.tsandmarkdown-hydrate.tsadd math, mermaid and local images (#380) as inert placeholders filled in after sanitizing.renderMarkdownstays without them because it also renders the changelogGit/: its review context lives on the surface, never on focus, so clicking into another agent's pane never moves it (#362)Diff/: the explorer opens it throughopenDiffView(open-diff.ts, #325), which pins the tab to the explorer's root.Diff/diff-render.tsxis shared withGit/
Remote Console phone UI (renderer/remote/, #254): a SECOND Vite entry (remote/index.html), served by main's console server and never loaded by the desktop window. Before changing it, read src/renderer/remote/AGENTS.md: the import-graph rule, the chunk it shares with the desktop window, npm run dev not serving it, and the mirror's clipping and key-bar rules.
Keystrokes: utils/shortcut-binding.ts's claimsKeyEvent is the one answer to "does wmux take this keystroke?" (#225): useKeyboardShortcuts and useTerminal's key handler must both use it, or a key reaches nobody.
Pipe Bridge (pipe-bridge.ts): exposes store operations as window.__wmux_* globals, which main calls through executeJavaScript for V2 pipe commands. The split tree lives only in the store, so main asks here for a surface's browser engine before routing a browser.* verb.
Store (Zustand, in store/):
osc-title-slice.ts: a program's window title is never aSurfaceReffield, which would persist it intosession.jsonprompt-slice.ts:line: nullmeans "not jumpable"; a view must never read it as line 0prompt-actions.ts: the shortcut table and the command palette both go through it, because the palette hands an action it does not recognise to aconsole.log
src/preload/index.ts is the list of bridges, with the reason for each one's shape in its comments. A jailed bridge takes a surfaceId and a relative path, never an absolute one: main derives the root, or it is not a jail.
Do NOT build MCP servers. Use the wmux CLI (wmux <command>) via Bash instead.
The CLI talks to the named pipe, which is simpler and more reliable.
For new Claude Code integrations, add CLI commands in src/cli/wmux.ts.
WorkspaceId, PaneId, SurfaceId, WindowId — branded string types in src/shared/types.ts.
Pattern: surf-{uuid}, pane-{uuid}, ws-{uuid}, win-{uuid}.
Terminal tabs in a pane are ALL rendered simultaneously (hidden with visibility: hidden).
When switching tabs, only CSS changes — the xterm instance stays alive, no PTY reconnection needed.
The surfaceId is passed to pty.create() so PTY ID = Surface ID (enables reliable re-attachment).
A split-tree change remounts the panes it moves, but not their terminals. TerminalHost (mounted once in App.tsx) portals one TerminalPane per terminal surface into an element owned by components/Terminal/terminal-keepalive.ts, and a pane renders only a KeptAliveTerminal slot that the element moves into. Two consequences for code outside it: React events from inside a terminal bubble through the host, not the pane, so the leaf's click-to-focus is a native listener on the slot (onActivate); and a same-commit move changes no prop, so attachGeneration re-runs the show path. utils/remount-snapshot.ts remains only as the fallback for a terminal that really is rebuilt. The design notes are in those files.
Pane layouts use an immutable binary tree (SplitNode). Each leaf = one pane with N surfaces (tabs).
Mutations go through splitNode(), removeLeaf(), findLeaf(), getAllPaneIds() in split-utils.ts.
Highlighting a prompt, pinning it, anchoring the view at the start of an answer and listing prompts as jump marks all depend on one fact: where a user prompt starts. utils/prompt-log.ts is the one producer, and the four views are separate toggles. Rules for code outside it:
- Two sources, ranked and never merged.
agentis a hook and carries the text.shellis OSC 133 and carries only a boundary. There is no heuristic third source: with neither present the log stays empty and every view is inert. - A shell's command text never crosses the pipe. The renderer reads it out of the buffer, because a command line is where a credential reliably shows up.
- One OSC 133 parse.
utils/alt-screen-owner.ts(#357) gets its marks fromhandlePromptMark, never from a second handler. - Changing an existing prompt default needs a
promptDefaultRevbump and aPROMPT_PROMOTIONSentry, or it reaches nobody.
The design notes are in utils/prompt-marks.ts (the tint and its content check), utils/prompt-anchor.ts (armed versus engaged), store/prompt-slice.ts and PromptPrefs in store/settings-slice.ts (the defaults). Read them before changing the feature.
CI builds and publishes every release when a v<version> tag is pushed (.github/workflows/release.yml): the NSIS installer, the portable zip and latest.yml. Never build or upload one by hand, and never dispatch the workflow to test: every run publishes. For anything else about a release, load the wmux-release skill (.agents/skills/wmux-release/SKILL.md).
pipeServer.on('v2') in src/main/index.ts answers V2 JSON-RPC, with routeSpecialV2 and the v2-bridge.ts table in front of its switch. Most methods ask the renderer through the window.__wmux_* globals in pipe-bridge.ts. Before adding or changing a method or CLI verb, load the wmux-pipe-method skill (.agents/skills/wmux-pipe-method/SKILL.md).
Deprecated in 2.12.0 (#239). wmux no longer installs this Claude Code plugin, and removes it if a previous version did. removeOrchestratorPlugin in claude-context.ts records why it never loaded.
The source still ships in resources/wmux-orchestrator/, with its layout in that README. scripts/launch-agent.js is the shared agent-command registry that the omp-context and pi-context tests read. The sidebar orchestration panel (orchestration-watcher.ts), wmux agent spawn and the OpenCode plugin, which the orchestrator consent feature still controls, aren't deprecated.
wmux help lists every command, and wmux <command> --help (or wmux help <command>) prints the usage text kept in COMMAND_SPECS in src/cli/wmux.ts. Read those rather than a copy here. The one verb that behaves differently on the two browser engines (wmux browser wait <ref> [ms] drops the timeout in agent mode) is documented at KNOWN ENGINE DIVERGENCE in src/main/agent-browser-verbs.ts.
The scripts live in src/shell-integration/. resources/shell-integration/ is a byte-identical mirror that tests/unit/resources-sync.test.ts enforces, so every edit is made in both. Before changing either, load the wmux-shell-integration skill (.agents/skills/wmux-shell-integration/SKILL.md).
Env vars set by wmux in spawned shells: WMUX=1, WMUX_SURFACE_ID, WMUX_PIPE, WMUX_CLI,
WMUX_NODE (+ WMUX_NODE_ELECTRON when it is wmux's own binary — issue #187).
Static site in site/, deployed to Netlify with npx netlify deploy --prod --dir site. site/i18n.js switches language by URL hash (#<code>).
Task procedures live in repo skills, loaded only when a task matches. .agents/skills/<name>/SKILL.md is the source of truth, and Codex reads it there. .claude/skills/ is a generated copy for Claude Code: edit .agents/skills/, then run npm run sync:skills. npm test fails while the two differ, and when a SKILL.md uses frontmatter outside the Agent Skills spec, since the same file has to load in both agents.
- State: Zustand slices in
src/renderer/store/, composed inindex.ts - IPC: Channels defined in
IPC_CHANNELSinsrc/shared/types.ts, never use magic strings - CSS:
src/renderer/styles/, class prefix per component (.pane-wrapper__*,.surface-tab__*) - Immutable trees: Split tree mutations always produce new objects via
patchLeaf() - French comms: User communicates in French, code/docs in English
- This file: a module's design notes and post-mortems go in its own comments or the PR description, not here. Add text only for a rule that crosses modules