Lands the active enforcement layer for the Phase 2.1 MCP plugin substrate (PR #71) alongside a wave of lifecycle, identity, and UX hardening (PR #72/#74/#75). Plugins now have their declared capabilities verified on every RPC; the daemon self-shuts when idle and recovers from AV-blocked PID verification; a frozen WMUX_WORKSPACE_ID env can no longer leave in-pane MCP servers permanently stuck on a stale identity; xterm light themes are now WCAG-AA legible for true-color RGB white output; and keyboard pane/surface navigation finally moves DOM focus along with the visual marker.
Minor version bump (v2.11.0 → v2.12.0) because Phase 2.2 adds the RpcRejection discriminated union to RpcResponse's failure arm, the daemon.idleShutdownMinutes config, and the mcp.mode config flag. All additive; existing v2.11.x callers keep working.
Daemon lifecycle hardening (#72)
Closes four gaps in the wmux daemon lifecycle: an orphan daemon that survives forever in RAM after a forced wmux quit, a boot-block when anti-virus prevents PID verification, an opaque "daemon could not start" error after the respawn budget exhausts, and a transient first-ping race during cold-boot. Combined effect: the "1 wmux ≙ 1 daemon" invariant is now self-healing instead of relying on the next clean shutdown.
Added
- Daemon idle self-shutdown — the daemon now terminates itself after 5 minutes with zero RPC clients and zero live PTY sessions (configurable via
daemon.idleShutdownMinutes in ~/.wmux/config.json; set to 0 to keep the legacy "alive forever" behavior). Routes through the same shutdown() body used by SIGTERM / SIGINT / daemon.shutdown RPC, so the existing phase instrumentation and re-entry guard apply. Logs [shutdown.phase] idle.timeout idleMs=… cfgMs=….
DaemonPipeServer.getConnectionCount() / getLastDisconnectAt() — public accessors for the Watchdog idle predicate. The disconnect anchor is stamped only on the 0-edge (last socket closing), so a flapping reconnect cycle resets the idle deadline forward instead of accumulating stale idle time.
Watchdog idle-check hook — opt-in callbacks onIdleCheck / onIdleShutdown evaluated on every health tick. Decision logic exposed as evaluateIdle() so unit tests drive it without timers. Single state machine: idleMs = now − (lastDisconnectAt ?? startTime). Grace window and idle window are independently configurable.
scripts/daemon-idle-shutdown-dynamic.mjs — end-to-end verification that spawns the bundled daemon in an isolated tmp WMUX_DIR with WMUX_IDLE_SHUTDOWN_MS / WMUX_IDLE_GRACE_MS / WMUX_WATCHDOG_TICK_MS env overrides, connects, disconnects, and asserts the daemon exits cleanly with the idle.timeout breadcrumb. Runs in ~5s.
Fixed
- Launcher ping retry —
ensureDaemon now retries the first daemon.ping once with a 250ms delay before declaring the existing daemon unresponsive. Absorbs the cold-boot race where Defender realtime scan, ConPTY cold-init, or a large recovery loop makes the daemon miss the first 3-second ping window. Total worst case 6.25s, still well under the 15s spawn budget.
- Unverified-live PID is now recoverable — when anti-virus blocks
tasklist.exe / Get-CimInstance and the launcher cannot confirm what owns daemon.pid, it now prompts the user with an Electron dialog offering "Clean up and start fresh" instead of refusing to boot. Cancel re-throws the legacy error, now annotated with the exact elevated-PowerShell taskkill /F /PID … command for manual recovery.
- Respawn-exhausted is no longer silent —
DaemonRespawnController now captures the latest error message from the bootstrap or respawn loop and ships it on the respawn-exhausted event. main surfaces it via a native dialog.showErrorBox plus the existing renderer IPC channel, with concrete recovery steps. lastError is cleared on successful install so future exhaustions don't echo stale diagnostics.
- SIGKILL-failure throw now embeds the recovery command — when the OS refuses to terminate a verified-stale daemon (typically EPERM under AV / different-user scenarios), the thrown error now includes the exact
taskkill /F /PID … invocation the user needs in an elevated PowerShell. No silent taskkill fallback because process.kill('SIGKILL') already walks the same TerminateProcess path with the same user token; embedding the hint is more honest than retrying.
Changed
DaemonRespawnController.RespawnEvent — the respawn-exhausted variant now carries an optional lastError field. Additive change; existing consumers that ignore the field still type-check.
- Suppression env var
WMUX_NO_DIALOG=1 bypasses both the launcher recovery dialog and the respawn-exhausted dialog for automated runs.
Test
- New
idleShutdown.test.ts (source-level invariants for the daemon main wiring), new idle-flow test cases in Watchdog.test.ts, new getConnectionCount / getLastDisconnectAt lifecycle test in DaemonPipeServer.test.ts, new lastError propagation test in DaemonRespawnController.test.ts, and scripts/daemon-idle-shutdown-dynamic.mjs for the end-to-end path.
Workspace identity drift fix (#72)
Fixes a serious multi-agent bug: an in-pane MCP server (e.g. Claude Code) could get permanently stuck reporting a workspace id that no longer exists — a2a.whoami returning no workspace found for ws-… and terminal_send rejecting with not owned by workspace … (actual owner: …). Every identity-gated MCP call (A2A, terminal_*, browser routing) failed until the MCP server was restarted. Triggered when a workspace id is re-minted (daemon respawn / session restore) while the shell process — and its frozen WMUX_WORKSPACE_ID env — lives on.
Fixed
- Workspace-identity is now anchored to the immutable
ptyId, not a frozen workspace id. The on-disk PID map (~/.wmux/pid-map/<pid>) stores the ptyId; a2a.resolve.identity resolves the current owning workspace live from the renderer (input.findOwnerWorkspace) on every call. A re-minted workspace id can no longer produce a stale identity. The map is also re-anchored on pty.reconnect, so a surviving shell re-adopted after a respawn resolves correctly without a restart.
- MCP resolvers (
src/mcp, src/company/mcp) no longer permanently trust the env hint. WMUX_WORKSPACE_ID is demoted to a last-resort fallback behind the live PID-walk; an RPC that reports a stale identity (no workspace found / not owned by workspace) invalidates the in-process cache so the next call self-heals.
Changed
a2a.resolve.identity returns PID → current workspaceId (resolved live), legacy ws--prefixed pid-map entries pass through for one cycle, and ptyIds with no live owner are omitted (no phantom mappings).
docs/PROTOCOL.md §6.1 reordered: path B (live PID-walk) is now preferred over path A (stale-prone env hint); added the ptyId-anchor and self-heal notes.
Phase 2.2 MCP plugin permission enforcement (#71)
Lands the active enforcement layer on top of the Phase 2.1 record-only identity + grammar substrate (PR #48) and the spec-side default rules (PR #68). Plugins that declare a capability set via mcp.declarePermissions now have those declarations verified against every RPC they issue; mismatches return a structured RpcRejection describing the per-path failure, and unconfirmed declarations surface a user-approval prompt before the call can proceed.
Added
PermissionEnforcer substrate (src/main/mcp/PermissionEnforcer.ts) — pure-function permission gate. Given a method, params, request context, and trust record, returns allow, reject, or partial. Same function runs in both shadow and enforce modes; only the dispatcher's reaction changes.
- Single declarative
methodCapabilityMap — Record<RpcMethod, RequiredCapability> covering the full 96-method RPC surface. tsc --noEmit enforces totality so a new method without a gate entry fails the build. Identity bootstrap (mcp.identify, mcp.declarePermissions, system.identify, system.capabilities) is capability: null. Internal surfaces (daemon, company, surface, hooks) map to the reserved wmux.internal capability that no plugin can declare.
- Structured
RpcRejection discriminated union on RpcResponse's failure arm — capability-not-declared, path-not-allowed, paths-partially-allowed (with {allowed, rejected[]}), and identity-status (with optional pendingApproval.promptId). Additive on the existing {ok:false; error} arm; every switch (r.ok) site keeps narrowing.
ShadowRejectionLogger + JSONL audit log at ~/.wmux/shadow-rejections.log — discriminated entries (rejection / legacy-traffic). 1 MiB cap with single-generation rotation. Sync writes wrapped in try/catch — telemetry must never affect RPC throughput.
LegacyTrafficCounter — per-method milestones (1st / 10th / 100th / 1000th / 10000th call) for envelope-less RPCs, flushed to the shadow log. Replaces the previous process-once trust-DB write for accurate v3.1 surfacing data.
ApprovalQueue — (clientName, hash(declaredCapabilities)) dedupe key, synchronous promptId minting + async resolution. On approve/deny, writes through PluginTrustStore.setUserDecision. Multiple inflight RPCs from the same plugin during a prompt coalesce onto one modal.
PermissionApprovalDialog — risk-class-grouped capability list with asymmetric wording. Terminal-content (terminal.read, pane.search) and terminal-input (terminal.send) get critical-severity copy that names the concrete privilege ("can read what's on your screen, including secrets"); metadata / events / pane-lifecycle / workspace get neutral copy. Browser and A2A get caution.
mcp.mode config flag in ~/.wmux/config.json — shadow or enforce. Production wmux defaults to enforce; dev (electron-forge start / NODE_ENV=test) defaults to shadow for dogfood rollback safety.
PluginTrustStore.setUserDecision(name, 'trusted' | 'denied') — explicit user-decision write path. Seeds a fresh record when a prompt fires before mcp.identify lands.
- Spec §4.4 "Enforcement contract" — documents the wire shape, retry idiom, mode flag, and worked glob example (
meta.write:custom.foo ≠ custom.foo.bar without trailing * or **).
inventory.md Phase 2.2 capability map — per-method capability + path-source + risk-class column.
Changed
RpcRouter.dispatch now calls the enforcer before invoking the handler. In shadow mode, the would-be rejection is logged and the handler still runs (no behavior change for v2.x callers). In enforce mode, a non-allow outcome returns the RpcResponse failure WITHOUT calling the handler. legacy callers (no clientName envelope) and identity-bootstrap RPCs are always allowed.
ApprovalQueue.requestApproval returns { promptId, resolution } — the promptId is available synchronously so the dispatcher can thread it into the rejection without awaiting the user's decision.
Fixed
- Keyboard pane/surface navigation now moves keyboard focus, not just the active border (
src/renderer/hooks/useActivePaneFocus.ts). Switching panes with the tmux prefix arrows, Alt+Ctrl+Arrow, Ctrl+Tab, the RPC pane.focus bridge, or keyboard tab-switching moved the red active border (driven by ws.activePaneId) but left DOM focus on the previously focused pane's xterm — so keystrokes still landed in the old pane. xterm routes input from whichever textarea holds DOM focus, and no navigation path ever called terminal.focus(). A central useActivePaneFocus hook now pulls DOM focus onto the resolved active terminal whenever the target workspace/pane/surface changes, covering every state-only switch path in one place. Mouse clicks were unaffected (the click focuses the target xterm for free) and remain so.
Notes for plugin authors
- Plugins SHOULD retry on
rejection.pendingApproval.promptId with 1–5 s backoff. The substrate doesn't pin a socket waiting for the user (50-connection cap; OAuth authorization_pending precedent).
meta.write:custom.foo matches the EXACT path custom.foo. Declare meta.write:custom.foo.* or meta.write:custom.foo.** to cover the subtree.
events.poll is partial-mode multi-path: subscribing to mixed-allowed topics returns the allowed subset with a paths-partially-allowed rejection on the failure arm carrying both allowed and rejected lists. pane.setMetadata and pane.clearMetadata are all-or-nothing.
Light xterm theme contrast (#74)
Claude Code (and several other TUI apps) emit foreground text as true-color RGB white (#FFFFFF). Those escape sequences bypass our sandstone-light / paper-light xterm palettes, so the literal white rendered directly on hinomaru's cream background (#FAF8F5) and read as invisible — users could not see Claude Code's output at all on hinomaru/taegeuk.
Fixed
- xterm
minimumContrastRatio set to 4.5 (WCAG AA) on light themes. Detected via isLight(background) on the resolved palette; covers built-in light themes and any custom palette a user configures to a light tone. Dark themes keep the default ratio of 1 so intentionally subtle dimmed foregrounds (e.g. catppuccin-mocha's text-muted) remain unmodified.
- Applied at both the initial
new Terminal({...}) site and the runtime theme-switch effect, so toggling between themes inside a live session takes effect without remounting the terminal.
Keyboard pane navigation DOM focus (#75)
Switching panes with the keyboard moved the red active border but typing still landed in the previously focused pane. xterm routes keystrokes from whichever <textarea> currently holds DOM focus; navigation paths (focusPaneDirection, cyclePane, surface-tab switches, RPC pane.focus) only updated state, never called terminal.focus(). Mouse clicks were unaffected because the click focuses the target xterm DOM for free — so only keyboard paths were broken.
Fixed
useActivePaneFocus central hook (src/renderer/hooks/useActivePaneFocus.ts) — subscribes to the resolved active terminal (workspace + pane + surface) and pulls DOM focus onto that xterm whenever the target changes, closing every state-only switch path in one place rather than patching four call sites. Retries across a few animation frames so a freshly split pane's xterm still gets focus once useTerminal registers it. Declines non-terminal surfaces (browser/editor).
Test
- New
src/renderer/hooks/__tests__/useActivePaneFocus.test.ts — 11 cases on the pure resolution logic (resolveActivePanePtyId), including pane-switch and same-pane tab-switch coverage that directly pins this bug, plus browser/editor/empty-ptyId rejection. The DOM-focus application half (terminal.focus() + rAF retry) needs a browser harness the node-env vitest lacks and is verified by dogfood.