Skip to content

Add web terminal page with split panes and spatial navigation - #36

Closed
lawrencecchen wants to merge 5 commits into
mainfrom
web-terminal-page
Closed

lawrencecchen wants to merge 5 commits into
mainfrom
web-terminal-page

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Feb 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Adds /terminal route with full split-pane terminal UI (per-pane tabs, drag-drop, workspace sidebar)
  • Restructures marketing pages into (main)/ route group to keep footer off /terminal
  • Implements Ghostty's spatial navigation algorithm for Cmd+Ctrl+HJKL directional focus
  • Includes PTY WebSocket server, dual renderer support (xterm.js + ghostty-web), and 67 Playwright e2e tests

Test plan

  • next build passes
  • 65/67 Playwright e2e tests pass (2 pre-existing drag tolerance failures)
  • 13 spatial navigation tests covering all directions in 2x2 grid, roundtrips, edge no-ops, full circuit
  • Manual verification of /terminal page in browser
  • Verify marketing pages still render correctly under (main)/ route group

Summary by CodeRabbit

  • New Features

    • Multi-workspace terminal support with drag-and-drop pane management
    • Multiplexed session handling with shared WebSocket connections
    • Ghostty and Xterm terminal renderers with configurable themes
    • Keyboard shortcuts for workspace navigation, splitting, and pane control
    • Session notifications and metadata tracking
    • NPM distribution for cross-platform cmuxd binary support
  • Documentation

    • Architecture and protocol documentation for cmuxd server implementation

…ation

- Add /terminal route with full split pane terminal UI
- Per-pane tab bars with drag-drop between groups and onto surfaces
- Workspace sidebar (Cmd+B toggle) matching native app styling
- Spatial navigation (Cmd+Ctrl+HJKL) using Ghostty's algorithm:
  build normalized spatial rectangles, filter by direction, pick
  nearest by Euclidean distance
- Route group restructure: move marketing pages into (main)/ to
  keep footer off /terminal
- Dark mode forced on terminal page, overscroll prevention
- Keyboard shortcuts: Cmd+D/Shift+D split, Ctrl+Shift+T new workspace,
  Cmd+[] switch workspaces, Ctrl+Tab cycle panes, Ctrl+D close
- Playwright e2e tests (67 tests) covering splits, tabs, drag-drop,
  divider resize, focus, and 2x2 grid spatial navigation
- PTY server (pty-server.mjs) for WebSocket terminal connections
- Dual renderer support (xterm.js + ghostty-web)
@vercel

vercel Bot commented Feb 12, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Error Error Mar 9, 2026 7:27am
cmuxterm-docs Ready Ready Preview, Comment Mar 9, 2026 7:27am

@cmux-agent

cmux-agent Bot commented Feb 12, 2026 •

Copy link
Copy Markdown
Contributor

Preview Videos and Screenshots

Open Workspace (1 hr expiry) · Open Dev Browser (1 hr expiry) · Open Diff Heatmap

Captured 4 screenshots and 1 video for commit 9f1cb4c (2026-02-12 10:52:23.289 UTC).

Clicking the Split Right button creates a new terminal pane to the right of the current pane (video)

split-pane-interaction.gif

Terminal Multi Split

terminal-multi-split-panes.png

Terminal Multiple Workspaces

terminal-multiple-workspaces.png

Terminal Page Default

terminal-page-default-state.png

Terminal Split Horizontal

terminal-split-horizontal.png


Generated by cmux preview system

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f1cb4c32a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread web/app/terminal/terminal-page.tsx Outdated
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
const groupId = bar.getAttribute("data-group-id")!
// Find insertion index based on x position
const tabs = bar.querySelectorAll("[data-testid^='tab-']")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict tab drop hit-testing to actual tab elements

The drop-index logic in detectTarget queries "[data-testid^='tab-']", which also matches close-button nodes like tab-close-* in addition to real tabs. When a group has multiple tabs, this inflates/offsets the computed insertion index, so dropping into a tab bar can place the tab in the wrong position (often at the end). Use a selector/attribute that targets only the tab containers.

Useful? React with 👍 / 👎.

if (tabIdx === -1 || tabIdx === action.toIndex) return state
const newTabs = [...group.tabs]
const [moved] = newTabs.splice(tabIdx, 1)
newTabs.splice(action.toIndex, 0, moved)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Adjust insertion index for rightward same-group tab moves

In REORDER_TAB, the code removes the dragged tab and reinserts it at action.toIndex without compensating when the source index is before the destination. For same-group drags to the right, this produces an off-by-one final position because array indices shift after removal, so tabs land one slot farther right than intended.

Useful? React with 👍 / 👎.

Comment thread web/app/terminal/lib/reducer.ts Outdated
Comment on lines +426 to +427
if (gid === ws.focusedGroupId) {
updatedWs.title = action.title

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update workspace title only from the active focused tab

The title update path sets updatedWs.title for any tab whose group is focused, even when that tab is not the group's active tab. If a background tab in the focused pane emits OSC title updates, the workspace title in the sidebar can switch away from the visible terminal, causing inconsistent UI state. This should be gated by group.activeTabId === action.tabId.

Useful? React with 👍 / 👎.

Add the Go rewrite of cmuxd (simpler build, no Zig toolchain needed)
and point Playwright at `go run .` instead of the Zig binary. Add CI
job to build and vet the Go code on every push/PR.
@coderabbitai

coderabbitai Bot commented Mar 9, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This pull request introduces a comprehensive terminal multiplexing system comprising dual backend implementations (Go and Zig), a complete web-based frontend with split-pane workspaces and tab management, CI/CD workflows for automated builds and releases, and extensive test coverage including E2E tests for the terminal UI and PTY interactions.

Changes

Cohort / File(s) Summary
CI/Build Workflows
.github/workflows/ci.yml, .github/workflows/cmuxd-release.yml
Added Go build and vet job for cmuxd-go; added multi-platform release workflow for cmuxd with Zig builds, artifact uploads, and npm package publishing.
Swift Bridge Utility
Sources/CmuxdBridge.swift
New bridge utility enum for managing cmuxd connections, including port availability checks, binary path resolution, and TCP connectivity verification with non-blocking connects.
cmuxd-go Implementation
cmuxd-go/*.go, cmuxd-go/*.sh
Complete Go-based cmuxd server with Ghostty config loading, session/PTY management, mux WebSocket protocol, OSC parsing for terminal events, notification system, multiplayer session support, and corresponding tests and build scripts.
cmuxd Zig Implementation
cmuxd/src/*.zig, cmuxd/build.zig*, cmuxd/dev.sh
Complete Zig-based cmuxd server with configuration loading, WebSocket protocol, session/PTY management, multi-client support, and npm package wrapper for cross-platform distribution.
npm Distribution
cmuxd/npm/*
Added npm package manifest and installation scripts for cmuxd, including postinstall binary download and CLI wrapper for platform-specific binaries.
Web Frontend - Terminal Components
web/app/terminal/components/*
New UI components for split-pane terminal layout: GroupTabBar for tab management, Sidebar with workspaces and notifications, SplitDivider for pane resizing, SplitTreeView for rendering split layouts, TerminalSurface for terminal rendering, Toolbar for pane controls, and placeholder surfaces.
Web Frontend - Page Layout
web/app/terminal/layout.tsx, web/app/terminal/page.tsx, web/app/terminal/client-terminal.tsx
New terminal page structure with full-viewport layout, client-side rendering wrapper, and metadata configuration.
Web Frontend - Terminal Adapters
web/app/terminal/lib/terminal-adapter.ts, web/app/terminal/lib/ghostty-adapter.ts, web/app/terminal/lib/xterm-adapter.ts, web/app/terminal/lib/ghostty-init.ts
Terminal adapter abstraction layer supporting both Ghostty and xterm.js with dynamic loading, configuration, mouse event handling, and focus management.
Web Frontend - State & Registry
web/app/terminal/lib/split-tree.ts, web/app/terminal/lib/reducer.ts, web/app/terminal/lib/surface-registry.ts
Core state management for workspace/pane/tab tree structures, reducer-based state updates with action dispatch, and surface registry managing terminal instances with mux connection support and fake shell fallback.
Web Frontend - Main Page
web/app/terminal/terminal-page.tsx
Main terminal UI component orchestrating workspaces, split panes, tabs, and keyboard shortcuts with drag-and-drop tab management.
Web Frontend - Layout Updates
web/app/(main)/layout.tsx, web/app/layout.tsx, web/app/(main)/page.tsx, web/app/(main)/*.tsx
New main layout wrapper, updated import paths for nested route structure, and main page import adjustments.
Web Frontend - Styling
web/app/components/callout.tsx, web/app/components/spacing-control.tsx, web/app/globals.css
Added CSS custom properties for dynamic theming (\-\-accent, \-\-divider, \-\-surface-focused), updated component styling to use inline styles for theme-driven colors instead of fixed Tailwind classes.
Web Frontend - Tests
web/e2e/terminal.spec.ts, web/e2e/pty.spec.ts, web/e2e/fixtures/mouse-test.py, web/playwright.config.ts
Comprehensive Playwright-based E2E tests for terminal UI (layout, splits, tabs, drag-and-drop, keyboard shortcuts), PTY lifecycle, multiplexed sessions, and mouse input; includes test fixtures and configuration.
Web Frontend - Dependencies & Config
web/package.json, web/.gitignore, web/pty-server.mjs
Added npm dependencies for xterm, Ghostty, Playwright, and ws; added postinstall script for WASM copying; new PTY server for WebSocket-based terminal connections.
Planning Documentation
plan.md
Architecture document outlining cmuxd design, protocol specifications, workspace schema, and phased implementation plan.
Test Utilities
tests/test_spatial_navigation.swift
Comprehensive test suite validating SplitTree spatial navigation across various layout configurations (grids, L-shapes, asymmetric splits).

Sequence Diagram(s)

sequenceDiagram
    participant Browser as Browser Client
    participant WS as WebSocket
    participant Server as cmuxd Server
    participant PTY as PTY Process
    
    Browser->>Server: WebSocket Connect (mode=mux)
    Server->>Server: Create initial Session
    Server->>Server: Register Client
    Server->>Browser: workspace_snapshot<br/>(sessionId, workspace JSON)
    
    Browser->>Server: create_session(cols, rows)
    Server->>PTY: forkpty + spawn shell
    Server->>Server: Initialize OSC parser
    Server->>Browser: session_created(sessionId)
    
    Browser->>Server: attach_session(sessionId)
    Server->>Server: Track client attachment
    Server->>Browser: session_attached(sessionId)
    
    Browser->>Server: Binary frame<br/>(sessionId, user input)
    Server->>PTY: WriteInput (stdin)
    
    PTY->>Server: Output data (stdout)
    Server->>Server: Parse OSC events<br/>(title, cwd)
    Server->>Server: Update session metadata
    Server->>Browser: Binary frame<br/>(sessionId, PTY output)
    Server->>Browser: session_metadata<br/>(title, cwd, git)
    
    Browser->>Server: update_metadata<br/>(status, log, ports)
    Server->>Server: Coalesce updates<br/>(50ms window)
    Server->>Browser: workspace_update<br/>(updated workspace JSON)
    
    Browser->>Server: detach_session(sessionId)
    Server->>Server: Untrack client
    
    Browser->>Server: destroy_session(sessionId)
    Server->>PTY: Kill process
    Server->>Browser: session_destroyed(sessionId)
    Server->>Browser: workspace_update
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


🐰 A multiplexed terminal springs to life,
With split panes dancing, tabs cutting through strife,
In Go and in Zig, the backends now sing,
While React spins workspaces with a graceful wing! ✨

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch web-terminal-page

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 14fab2ac65

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +291 to +297
if (e.altKey && !meta && !ctrl && !shift && e.code === "KeyW") {
e.preventDefault()
e.stopPropagation()
const group = ws.groups[ws.focusedGroupId]
if (group && group.activeTabId) {
dispatch({ type: "CLOSE_TAB", groupId: ws.focusedGroupId, tabId: group.activeTabId })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Destroy terminal surface when Alt+W closes the active tab

The Alt+W shortcut dispatches CLOSE_TAB directly instead of going through the close handler that calls surfaceRegistry.destroy(...), so the tab disappears from state but its terminal/session resources remain alive. In PTY or mux mode this leaves orphaned sockets/sessions running in the background each time users close tabs via keyboard, which can accumulate stale processes and memory.

Useful? React with 👍 / 👎.

case "SELECT_TAB": {
const ws = activeWs(state)
const group = ws.groups[action.groupId]
if (!group || group.activeTabId === action.tabId) return state

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow SELECT_TAB to focus panes with already-active tabs

This early return blocks SELECT_TAB whenever the clicked tab is already active, even if it is in a different pane than the current focus. As a result, clicking the active tab in an unfocused pane does not move focus to that pane, making tab-bar focus behavior inconsistent with the reducer's intent to update focusedGroupId.

Useful? React with 👍 / 👎.

Comment on lines +236 to +238
if (tab && action.groupId === ws.focusedGroupId) {
updatedWs.title = tab.title
updatedWs.subtitle = getWorkspaceSubtitle(updatedWs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute workspace title when SELECT_TAB changes pane focus

Workspace title/subtitle updates are conditioned on action.groupId === ws.focusedGroupId, which checks the old focus before SELECT_TAB moves focus to the new group. When users select a tab in another pane, focus changes but the sidebar title/subtitle stays stale until some later action updates it, causing visible mismatch with the newly focused terminal.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

40 issues found across 91 files

Note: This PR contains a large number of files. cubic only reviews up to 75 files per PR, so some files may not have been reviewed.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name=".github/workflows/ci.yml">

<violation number="1" location=".github/workflows/ci.yml:56">
P2: Pin `actions/setup-go` to an immutable commit SHA instead of the mutable `@v5` tag to avoid supply-chain drift in CI.</violation>
</file>

<file name="cmuxd-go/legacy.go">

<violation number="1" location="cmuxd-go/legacy.go:19">
P1: Do not disable WebSocket origin verification here; this allows cross-origin sites to open terminal sessions against this endpoint.</violation>
</file>

<file name="web/app/terminal/lib/ghostty-init.ts">

<violation number="1" location="web/app/terminal/lib/ghostty-init.ts:7">
P2: Avoid overriding `console.log` globally at module load; this introduces runtime-wide side effects outside terminal initialization.</violation>
</file>

<file name="cmuxd-go/notification.go">

<violation number="1" location="cmuxd-go/notification.go:56">
P1: Do not return internal `*Notification` pointers from this synchronized store; it leaks shared mutable state outside the mutex and can cause races.</violation>
</file>

<file name="cmuxd-go/session.go">

<violation number="1" location="cmuxd-go/session.go:186">
P2: Recompute the smallest-wins size when a client detaches; otherwise the session can remain at the previous minimum dimensions after the smallest client leaves.</violation>
</file>

<file name="cmuxd/src/main.zig">

<violation number="1" location="cmuxd/src/main.zig:237">
P1: Legacy mode performs unsynchronized writes to the same websocket stream from multiple threads, which can interleave and corrupt frames.</violation>

<violation number="2" location="cmuxd/src/main.zig:246">
P1: This fd is closed twice (here and again in deferred `sess.kill()`), which can close an unrelated descriptor after fd reuse.</violation>

<violation number="3" location="cmuxd/src/main.zig:375">
P1: Session pointers are dereferenced after releasing `server.mutex`, allowing concurrent `destroy_session` to free the session first (use-after-free race).</violation>

<violation number="4" location="cmuxd/src/main.zig:588">
P0: Bind this server to loopback instead of 0.0.0.0; the current listener exposes unauthenticated PTY access on all network interfaces.</violation>
</file>

<file name="cmuxd/src/config.zig">

<violation number="1" location="cmuxd/src/config.zig:344">
P2: JSON string values are emitted without escaping, so config values containing quotes/backslashes/control characters will produce invalid JSON in the terminalConfig payload.</violation>
</file>

<file name="web/app/terminal/components/surface-placeholder.tsx">

<violation number="1" location="web/app/terminal/components/surface-placeholder.tsx:66">
P3: `DropZoneOverlay` is duplicated from `terminal-surface.tsx`; extract a shared component/helper to avoid maintenance drift.</violation>
</file>

<file name="cmuxd/src/protocol.zig">

<violation number="1" location="cmuxd/src/protocol.zig:211">
P1: One-shot `stream.write` calls can short-write, producing truncated WebSocket frames/handshake responses.</violation>

<violation number="2" location="cmuxd/src/protocol.zig:215">
P1: `wsHandshake` returns a query-string slice backed by a stack buffer, so the returned data is invalid after return.</violation>

<violation number="3" location="cmuxd/src/protocol.zig:269">
P1: `writeMaskedWsFrame` truncates payloads >65536 bytes while still declaring the original payload length in the frame header.</violation>

<violation number="4" location="cmuxd/src/protocol.zig:315">
P1: `writeMaskedPtyFrame` can emit malformed frames by truncating large `data` while advertising a larger payload length.</violation>

<violation number="5" location="cmuxd/src/protocol.zig:345">
P2: `wsClientHandshake` should validate `Sec-WebSocket-Accept`; checking only `HTTP/1.1 101` can accept invalid handshakes.</violation>
</file>

<file name="web/app/terminal/lib/surface-registry.ts">

<violation number="1" location="web/app/terminal/lib/surface-registry.ts:419">
P2: Fake bash commands produce staircasing output due to missing carriage returns</violation>

<violation number="2" location="web/app/terminal/lib/surface-registry.ts:477">
P2: Fake shell input parser leaks characters from multi-character escape sequences</violation>

<violation number="3" location="web/app/terminal/lib/surface-registry.ts:611">
P1: Pending connection promises leak and hang indefinitely on WebSocket close</violation>

<violation number="4" location="web/app/terminal/lib/surface-registry.ts:775">
P1: Unsafe WebSocket send can throw InvalidStateError</violation>

<violation number="5" location="web/app/terminal/lib/surface-registry.ts:956">
P2: WebSocket connection promise hangs if socket closes before opening</violation>
</file>

<file name="cmuxd/src/session.zig">

<violation number="1" location="cmuxd/src/session.zig:101">
P2: Return an allocated empty buffer when VT is uninitialized; `&.{}` breaks this function’s "caller must free" contract.</violation>

<violation number="2" location="cmuxd/src/session.zig:242">
P1: Add error-path cleanup in `create`; failures after allocation can leak the session object and leave a spawned PTY process/fd running.</violation>
</file>

<file name="web/pty-server.mjs">

<violation number="1" location="web/pty-server.mjs:17">
P0: WebSocket PTY sessions are unauthenticated, allowing arbitrary remote shell access to any client that can connect.</violation>
</file>

<file name="cmuxd/npm/package.json">

<violation number="1" location="cmuxd/npm/package.json:5">
P2: The npm package metadata says MIT, but the repository is AGPL-3.0-or-later. Update the package license field so consumers don’t receive incorrect licensing information.</violation>
</file>

<file name="web/app/terminal/lib/ghostty-adapter.ts">

<violation number="1" location="web/app/terminal/lib/ghostty-adapter.ts:195">
P2: `viewportY` is already the absolute top-of-viewport line in the buffer (xterm-compatible API). Adding `baseY` double-counts the scrollback offset, so `getScreenText` will read the wrong lines once scrollback exists. Use `viewportY + y` like the xterm adapter.</violation>
</file>

<file name="web/app/terminal/terminal-page.tsx">

<violation number="1" location="web/app/terminal/terminal-page.tsx:190">
P1: `detectTarget` creates a new object on every mouse movement, causing `setDropTarget` to bypass React's referential equality check and re-render the entire terminal page on every pixel moved during a drag.</violation>

<violation number="2" location="web/app/terminal/terminal-page.tsx:370">
P1: Intercepting `Ctrl+D` globally in the capture phase prevents the terminal from receiving the `EOF` signal, breaking standard Unix behavior like exiting REPLs or programs like `cat`.</violation>
</file>

<file name="Sources/CmuxdBridge.swift">

<violation number="1" location="Sources/CmuxdBridge.swift:32">
P2: Avoid committing a machine-specific absolute fallback path for `cmux-bridge`; this makes bridge detection fail on other development environments.</violation>

<violation number="2" location="Sources/CmuxdBridge.swift:45">
P2: Shell-quote the bridge binary path when generating the command string; unescaped paths with spaces produce an invalid command.</violation>
</file>

<file name="cmuxd/src/bridge.zig">

<violation number="1" location="cmuxd/src/bridge.zig:164">
P1: Handle interrupted stdin reads explicitly; SIGWINCH can currently break the loop and exit the bridge on terminal resize.</violation>

<violation number="2" location="cmuxd/src/bridge.zig:181">
P2: Use a full-buffer write for PTY output; a single `posix.write` can short-write and lose terminal bytes.</violation>
</file>

<file name="cmuxd-go/config.go">

<violation number="1" location="cmuxd-go/config.go:253">
P2: `font-size` parsing only accepts integers, so valid fractional Ghostty sizes are dropped.</violation>
</file>

<file name="cmuxd-go/mux.go">

<violation number="1" location="cmuxd-go/mux.go:19">
P1: `InsecureSkipVerify: true` disables the library’s origin verification and exposes the mux endpoint to cross‑site WebSocket/CSRF access. Unless you explicitly need cross‑origin access, keep the default origin checks (or use `OriginPatterns` for a limited allowlist).</violation>

<violation number="2" location="cmuxd-go/mux.go:32">
P1: `srv.sessions.mu` is held while acquiring `srv.mu`, but other paths (e.g., `processOscEvents`) take `srv.mu` then `srv.sessions.mu`. This lock-order inversion can deadlock under concurrent PTY reads and control messages. Use a consistent lock order or release `srv.sessions.mu` before taking `srv.mu` for broadcasts.</violation>
</file>

<file name="web/app/terminal/lib/reducer.ts">

<violation number="1" location="web/app/terminal/lib/reducer.ts:79">
P1: Synchronize workspace titles in `updateActiveWs` to fix stale sidebar titles on tab/pane changes.</violation>

<violation number="2" location="web/app/terminal/lib/reducer.ts:107">
P2: Update workspace titles in `removeGroupFromWs` to reflect the newly focused pane after closure.</violation>

<violation number="3" location="web/app/terminal/lib/reducer.ts:236">
P2: Remove the stale focus check so workspace titles update when selecting tabs in other panes.</violation>
</file>

<file name="web/app/terminal/lib/xterm-adapter.ts">

<violation number="1" location="web/app/terminal/lib/xterm-adapter.ts:22">
P3: Theme background mismatch when config.theme omits background.</violation>
</file>

<file name="cmuxd/npm/postinstall.mjs">

<violation number="1" location="cmuxd/npm/postinstall.mjs:50">
P1: A failed download leaves a partial file that breaks future installs, and unhandled file stream errors can crash the process.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread cmuxd/src/main.zig
}
}

const addr = std.net.Address.initIp4(.{ 0, 0, 0, 0 }, port);

@cubic-dev-ai cubic-dev-ai Bot Mar 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Bind this server to loopback instead of 0.0.0.0; the current listener exposes unauthenticated PTY access on all network interfaces.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxd/src/main.zig, line 588:

<comment>Bind this server to loopback instead of 0.0.0.0; the current listener exposes unauthenticated PTY access on all network interfaces.</comment>

<file context>
@@ -0,0 +1,617 @@
+        }
+    }
+
+    const addr = std.net.Address.initIp4(.{ 0, 0, 0, 0 }, port);
+    var server = try addr.listen(.{ .reuse_address = true });
+    defer server.deinit();
</file context>
Suggested change
const addr = std.net.Address.initIp4(.{ 0, 0, 0, 0 }, port);
const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port);
Fix with Cubic

Comment thread web/pty-server.mjs
const wss = new WebSocketServer({ server, path: "/ws" })

wss.on("connection", (ws, req) => {
const url = new URL(req.url, `http://localhost:${PORT}`)

@cubic-dev-ai cubic-dev-ai Bot Mar 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: WebSocket PTY sessions are unauthenticated, allowing arbitrary remote shell access to any client that can connect.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/pty-server.mjs, line 17:

<comment>WebSocket PTY sessions are unauthenticated, allowing arbitrary remote shell access to any client that can connect.</comment>

<file context>
@@ -0,0 +1,65 @@
+const wss = new WebSocketServer({ server, path: "/ws" })
+
+wss.on("connection", (ws, req) => {
+  const url = new URL(req.url, `http://localhost:${PORT}`)
+  const cols = parseInt(url.searchParams.get("cols") || "80", 10)
+  const rows = parseInt(url.searchParams.get("rows") || "24", 10)
</file context>
Fix with Cubic

Comment thread cmuxd-go/legacy.go
rows := parseQueryUint16(r, "rows", 24)

conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true,

@cubic-dev-ai cubic-dev-ai Bot Mar 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Do not disable WebSocket origin verification here; this allows cross-origin sites to open terminal sessions against this endpoint.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxd-go/legacy.go, line 19:

<comment>Do not disable WebSocket origin verification here; this allows cross-origin sites to open terminal sessions against this endpoint.</comment>

<file context>
@@ -0,0 +1,98 @@
+	rows := parseQueryUint16(r, "rows", 24)
+
+	conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
+		InsecureSkipVerify: true,
+	})
+	if err != nil {
</file context>
Fix with Cubic

Comment thread cmuxd-go/notification.go
}

// MarkRead marks a notification as read. Returns the notification if found.
func (s *NotificationStore) MarkRead(id uint64) *Notification {

@cubic-dev-ai cubic-dev-ai Bot Mar 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Do not return internal *Notification pointers from this synchronized store; it leaks shared mutable state outside the mutex and can cause races.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxd-go/notification.go, line 56:

<comment>Do not return internal `*Notification` pointers from this synchronized store; it leaks shared mutable state outside the mutex and can cause races.</comment>

<file context>
@@ -0,0 +1,139 @@
+}
+
+// MarkRead marks a notification as read. Returns the notification if found.
+func (s *NotificationStore) MarkRead(id uint64) *Notification {
+	s.mu.Lock()
+	defer s.mu.Unlock()
</file context>
Fix with Cubic

Comment thread cmuxd/src/main.zig
// Cleanup
sess.alive.store(false, .release);
const fd: c_int = sess.pty_fd;
_ = c.close(fd);

@cubic-dev-ai cubic-dev-ai Bot Mar 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This fd is closed twice (here and again in deferred sess.kill()), which can close an unrelated descriptor after fd reuse.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxd/src/main.zig, line 246:

<comment>This fd is closed twice (here and again in deferred `sess.kill()`), which can close an unrelated descriptor after fd reuse.</comment>

<file context>
@@ -0,0 +1,617 @@
+    // Cleanup
+    sess.alive.store(false, .release);
+    const fd: c_int = sess.pty_fd;
+    _ = c.close(fd);
+    reader.join();
+}
</file context>
Fix with Cubic

Comment thread cmuxd-go/config.go
case "font-family":
cfg.FontFamily = value
case "font-size":
if v, err := strconv.ParseUint(value, 10, 16); err == nil {

@cubic-dev-ai cubic-dev-ai Bot Mar 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: font-size parsing only accepts integers, so valid fractional Ghostty sizes are dropped.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxd-go/config.go, line 253:

<comment>`font-size` parsing only accepts integers, so valid fractional Ghostty sizes are dropped.</comment>

<file context>
@@ -0,0 +1,562 @@
+		case "font-family":
+			cfg.FontFamily = value
+		case "font-size":
+			if v, err := strconv.ParseUint(value, 10, 16); err == nil {
+				u := uint16(v)
+				cfg.FontSize = &u
</file context>
Fix with Cubic

newFocus = adj && newLeaves.includes(adj) ? adj : newLeaves[0]
}

return { ...ws, root: newRoot, groups: remainingGroups, focusedGroupId: newFocus }

@cubic-dev-ai cubic-dev-ai Bot Mar 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Update workspace titles in removeGroupFromWs to reflect the newly focused pane after closure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/app/terminal/lib/reducer.ts, line 107:

<comment>Update workspace titles in `removeGroupFromWs` to reflect the newly focused pane after closure.</comment>

<file context>
@@ -0,0 +1,557 @@
+    newFocus = adj && newLeaves.includes(adj) ? adj : newLeaves[0]
+  }
+
+  return { ...ws, root: newRoot, groups: remainingGroups, focusedGroupId: newFocus }
+}
+
</file context>
Fix with Cubic

const updatedGroups = { ...ws.groups, [group.id]: { ...group, activeTabId: action.tabId } }
const updatedWs = { ...ws, groups: updatedGroups, focusedGroupId: action.groupId }
const tab = group.tabs.find((t) => t.id === action.tabId)
if (tab && action.groupId === ws.focusedGroupId) {

@cubic-dev-ai cubic-dev-ai Bot Mar 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Remove the stale focus check so workspace titles update when selecting tabs in other panes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/app/terminal/lib/reducer.ts, line 236:

<comment>Remove the stale focus check so workspace titles update when selecting tabs in other panes.</comment>

<file context>
@@ -0,0 +1,557 @@
+      const updatedGroups = { ...ws.groups, [group.id]: { ...group, activeTabId: action.tabId } }
+      const updatedWs = { ...ws, groups: updatedGroups, focusedGroupId: action.groupId }
+      const tab = group.tabs.find((t) => t.id === action.tabId)
+      if (tab && action.groupId === ws.focusedGroupId) {
+        updatedWs.title = tab.title
+        updatedWs.subtitle = getWorkspaceSubtitle(updatedWs)
</file context>
Fix with Cubic

)
}

function DropZoneOverlay({ direction }: { direction: DropDirection }) {

@cubic-dev-ai cubic-dev-ai Bot Mar 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: DropZoneOverlay is duplicated from terminal-surface.tsx; extract a shared component/helper to avoid maintenance drift.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/app/terminal/components/surface-placeholder.tsx, line 66:

<comment>`DropZoneOverlay` is duplicated from `terminal-surface.tsx`; extract a shared component/helper to avoid maintenance drift.</comment>

<file context>
@@ -0,0 +1,85 @@
+  )
+}
+
+function DropZoneOverlay({ direction }: { direction: DropDirection }) {
+  const pos: React.CSSProperties = {
+    position: "absolute",
</file context>
Fix with Cubic

// Map our theme format to xterm.js ITheme
const theme = config.theme ? {
foreground: config.theme.foreground,
background: config.theme.background,

@cubic-dev-ai cubic-dev-ai Bot Mar 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Theme background mismatch when config.theme omits background.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/app/terminal/lib/xterm-adapter.ts, line 22:

<comment>Theme background mismatch when config.theme omits background.</comment>

<file context>
@@ -0,0 +1,229 @@
+    // Map our theme format to xterm.js ITheme
+    const theme = config.theme ? {
+      foreground: config.theme.foreground,
+      background: config.theme.background,
+      cursor: config.theme.cursor,
+      cursorAccent: config.theme.cursorAccent,
</file context>
Fix with Cubic

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/app/(main)/docs/changelog/page.tsx (1)

220-240: ⚠️ Potential issue | 🟡 Minor

The Changed badge is under-contrast in light mode.

On the new tinted pill, color: "var(--accent)" leaves this 12px label at roughly 3:1 contrast in light theme, so it becomes noticeably harder to read. Please keep the accent background, but use a darker foreground here.

🎨 Suggested adjustment
   } else if (lower === "changed") {
-    color = "";
+    color = "text-blue-700 dark:text-blue-400";
     label = "Changed";
   }
@@
       style={
         lower === "changed"
           ? {
               background: "rgba(var(--accent-rgb), 0.1)",
-              color: "var(--accent)",
             }
           : undefined
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/`(main)/docs/changelog/page.tsx around lines 220 - 240, The "Changed"
pill currently sets label via lower === "changed" and relies on an inline style
that sets color: "var(--accent)", which yields poor contrast; update the lower
=== "changed" branch to assign a darker foreground in the color variable (e.g.,
include a text color utility/class that provides sufficient contrast) while
keeping the background (rgba(var(--accent-rgb), 0.1)), and remove or stop
overriding the text color in the inline style block inside the returned <span>
so the class-based darker text color is used instead.
🟠 Major comments (29)
tests/test_spatial_navigation.swift-435-455 (1)

435-455: ⚠️ Potential issue | 🟠 Major

Wire this spatial navigation test into CI/CD or document it as manual-only.

This is a custom @main binary that will not be executed by standard swift test or Xcode test discovery. The file contains compile and run instructions (lines 2–3), but a search of CI/CD automation, workflows, and build schemes found no integration. If these tests are not actually run by any pipeline, they won't catch regressions.

Add a build step to compile and run this test in CI, or if it is truly manual, clarify that in the file header.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_spatial_navigation.swift` around lines 435 - 455, This file
defines a standalone `@main` entrypoint (TestRunner.main) that isn't run by swift
test or Xcode—either add CI integration to build and run it or mark it explicit
manual-only: to integrate, add the executable target (or include the file in an
existing test target) so the package builds the TestRunner, then add a CI
workflow step to run the compiled binary (checking testsPassed/testsFailed and
failing CI on non‑zero), or if manual-only, add a clear file header comment and
update repository docs/README stating the tests in TestRunner (using
testsPassed/testsFailed) must be run manually and are not part of automated CI.
cmuxd/dev.sh-5-6 (1)

5-6: ⚠️ Potential issue | 🟠 Major

Tighten the pre-launch cleanup.

Line 5 kills any cmuxd process whose argv happens to contain --port, including unrelated local instances, and it still misses a prior launch that used the default port without that flag. Kill only the listener for the selected port, or track the PID this script started instead of using a broad pkill -f.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd/dev.sh` around lines 5 - 6, The current pre-launch cleanup uses a broad
pkill -f 'cmuxd.*--port' which can kill unrelated cmuxd instances and misses
instances started without --port; replace this with either tracking the PID of
the instance this script starts (capture $! after launching cmuxd and store it
to a .pid file, then kill that PID on cleanup) or resolve the PID by port (use
ss/lsof to find the process listening on the specific PORT variable and kill
only that PID). Update the cleanup to avoid using pkill -f and ensure any
created .pid file is removed after killing; also keep or adjust the existing
sleep 0.1 as needed.
web/pty-server.mjs-21-26 (1)

21-26: ⚠️ Potential issue | 🟠 Major

Don't pass the full server environment into the shell.

Line 26 forwards the entire Node process environment into each PTY. web/app/env.ts (Lines 3-17) shows that includes app secrets like RESEND_API_KEY and feedback credentials, so every terminal session can read values that were never meant for the shell. Build a small allowlist (PATH, HOME, TERM, etc.) instead of spreading process.env.

🧹 Safer environment setup
+const childEnv = {
+  PATH: env.PATH ?? "",
+  HOME: env.HOME ?? "",
+  SHELL,
+  TERM: "xterm-256color",
+  COLORTERM: "truecolor",
+}
+
   const proc = pty.spawn(SHELL, [], {
     name: "xterm-256color",
     cols,
     rows,
     cwd: env.PTY_CWD || process.cwd(),
-    env: { ...env },
+    env: childEnv,
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pty-server.mjs` around lines 21 - 26, The PTY is currently created with
env: { ...env } in the pty.spawn call which forwards the entire server
process.env (including secrets) into every shell; change this to build and pass
a minimal allowlist object instead (e.g., include PATH, HOME, TERM, LANG, SHELL,
and any explicit PTY_CWD/PTY_* variables you intentionally expose) and use that
object for the env option in pty.spawn (refer to the pty.spawn invocation, SHELL
constant, and env.PTY_CWD to locate where to replace the spread). Ensure you do
not copy process.env wholesale and only include the specific keys you want
exposed to each terminal session.
web/pty-server.mjs-17-19 (1)

17-19: ⚠️ Potential issue | 🟠 Major

Validate terminal dimensions before spawn/resize.

Lines 18-19 and Line 47 trust client-supplied cols/rows almost entirely. parseInt(...) can yield NaN, negatives, or absurdly large values, and the resize path only checks truthiness before forwarding them into the PTY calls. Clamp these to sane positive integers and ignore malformed resize frames instead of passing them through.

📏 Example validation helper
+function parseDimension(value, fallback) {
+  const parsed = Number.parseInt(String(value ?? ""), 10)
+  return Number.isInteger(parsed) && parsed >= 1 && parsed <= 1000
+    ? parsed
+    : fallback
+}
+
 wss.on("connection", (ws, req) => {
   const url = new URL(req.url, `http://localhost:${PORT}`)
-  const cols = parseInt(url.searchParams.get("cols") || "80", 10)
-  const rows = parseInt(url.searchParams.get("rows") || "24", 10)
+  const cols = parseDimension(url.searchParams.get("cols"), 80)
+  const rows = parseDimension(url.searchParams.get("rows"), 24)
@@
       try {
         const parsed = JSON.parse(str)
-        if (parsed.type === "resize" && parsed.cols && parsed.rows) {
-          proc.resize(parsed.cols, parsed.rows)
+        const nextCols = parseDimension(parsed.cols, NaN)
+        const nextRows = parseDimension(parsed.rows, NaN)
+        if (
+          parsed.type === "resize" &&
+          Number.isFinite(nextCols) &&
+          Number.isFinite(nextRows)
+        ) {
+          proc.resize(nextCols, nextRows)
           return
         }
       } catch {

Also applies to: 41-49

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pty-server.mjs` around lines 17 - 19, Client-supplied terminal dimensions
cols and rows parsed from URL/search params (variables cols and rows) can be
NaN, negative, zero or unreasonably large; clamp and validate these before using
them in the PTY spawn and resize calls (e.g., inside whatever PTY spawn and
resize handlers that call pty.spawn or ptyProcess.resize). Replace raw parseInt
usage with a small helper that parses to integer, returns a sane default if NaN,
and clamps to a min (1) and a reasonable max (e.g., 1000); on resize frames,
ignore the frame if the parsed values are invalid instead of forwarding them to
the PTY. Update all uses of cols/rows (initial spawn and the resize path) to use
the validated/clamped values.
web/app/terminal/lib/surface-registry.ts-817-818 (1)

817-818: ⚠️ Potential issue | 🟠 Major

Don't bake a localhost-only PTY topology into the client.

Hard-coding ws://localhost:3778 / http://localhost:3778 makes this route effectively dev-only and will fail on HTTPS pages because ws:// is mixed content. The PTY base URL should come from runtime config, with ws vs wss derived from the active scheme.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/terminal/lib/surface-registry.ts` around lines 817 - 818, The PTY
endpoints are hard-coded to ws://localhost:3778 and http://localhost:3778;
change PTY_WS_URL and PTY_CONFIG_URL usage to derive a base URL from runtime
config (e.g., a provided cfg.ptyBase or environment-injected value) and fallback
to the page origin when not provided, and compute the WS scheme by mapping
window.location.protocol ('https:' -> 'wss:', otherwise 'ws:') so you don't emit
mixed-content ws:// on HTTPS pages; update any code that references PTY_WS_URL
or PTY_CONFIG_URL to build URLs from this base (e.g., `${wsScheme}//${base}/ws`
and `${base}/terminal-config`) rather than the hard-coded strings.
web/app/terminal/lib/surface-registry.ts-651-654 (1)

651-654: ⚠️ Potential issue | 🟠 Major

Reuse or explicitly dispose the server's initial mux session.

The mux handshake already provides workspace_snapshot.initialSessionId, but ensureMuxConfig() opens the socket just to fetch config and connectMux() still unconditionally sends create_session. That leaves the server-created initial PTY unattached until the websocket closes. Use the initial session for the first tab, or destroy it before creating a replacement.

Also applies to: 832-865, 1033-1037

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/terminal/lib/surface-registry.ts` around lines 651 - 654, The
server-provided initial mux session (workspace_snapshot.initialSessionId) is
currently ignored causing an orphaned PTY when ensureMuxConfig() opens a socket
just to fetch config and connectMux() always sends a create_session; update the
logic in ensureMuxConfig(), connectMux(), and the startup handling around the
workspace_snapshot message (where _clientId, _terminalConfig, and _ready are
set) to reuse the provided initialSessionId for the first tab instead of
creating a new session, or explicitly send a destroy_session for that
initialSessionId before creating a replacement; specifically, detect
workspace_snapshot.initialSessionId, pass it into connectMux() (or have
connectMux() check for an existing initialSessionId) and avoid unconditional
create_session, or call destroy_session with that id prior to create_session to
prevent orphaned PTYs.
cmuxd-go/notification.go-36-53 (1)

36-53: ⚠️ Potential issue | 🟠 Major

Don't hand out live notification pointers after releasing the mutex.

These methods return the store's internal *Notification objects, so callers don't get a stable snapshot and can read/mutate the same instances outside mu while later writes are happening. Returning copied values would preserve the thread-safety this type is trying to provide.

Also applies to: 56-79, 106-120

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd-go/notification.go` around lines 36 - 53, The Add method and other
NotificationStore methods that return *Notification or []*Notification currently
hand out pointers to internal objects; instead, while holding s.mu copy the
notification data into new Notification instances (for Add, allocate a new
Notification copy to return; for methods that return slices, build a new slice
of copies) and release the mutex before returning so callers get independent
copies. Update NotificationStore.Add and the other methods referenced (the
methods in the 56-79 and 106-120 ranges that return notifications) to perform
deep/value copies under s.mu and return those copies rather than pointers into
s.notifications.
cmuxd/src/config.zig-341-345 (1)

341-345: ⚠️ Potential issue | 🟠 Major

Escape JSON string values before writing them.

writeJsonField injects raw strings into JSON. A font family or theme value containing ", \, or a newline will produce invalid JSON and break the web config consumer.

Proposed fix
 fn writeJsonField(w: anytype, first: *bool, key: []const u8, value: []const u8) !void {
     if (!first.*) try w.writeAll(",");
     first.* = false;
-    try w.print("\"{s}\":\"{s}\"", .{ key, value });
+    try w.print("\"{s}\":", .{key});
+    try std.json.stringify(value, .{}, w);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd/src/config.zig` around lines 341 - 345, writeJsonField currently
injects raw key/value strings into JSON, which breaks when values contain
quotes, backslashes, or control characters (e.g., newline); add a JSON string
escaping helper (e.g., escapeJsonString(value: []const u8) ![]u8) that returns a
properly escaped string (escaping " as \", \ as \\, and control chars like \n,
\r, \t, etc., using Unicode escapes for other non-printables), then call that
helper from writeJsonField (escape the value, and optionally the key) and print
the escaped result with try w.print("\"{s}\":\"{s}\"", .{ keyEscaped,
valueEscaped }); ensure to free any allocated buffer if using allocator.
Sources/CmuxdBridge.swift-43-49 (1)

43-49: ⚠️ Potential issue | 🟠 Major

Shell-escape the bridge path before returning a command string.

bridgeBinaryPath comes from bundle/development filesystem locations, so it can contain spaces or shell metacharacters. Returning \(binary) --port ... as a raw shell string will split the executable path and can launch the wrong command. Prefer a Process + arguments, or at least escape/quote the path before concatenating it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxdBridge.swift` around lines 43 - 49, The command() function
builds a raw shell string using bridgeBinaryPath which may contain spaces or
metacharacters; instead change command(port:sessionId:) to produce a
safely-escaped command or (preferably) return an array of executable + arguments
or construct a Process invocation: stop concatenating "\(binary) --port …",
instead either shell-quote/escape bridgeBinaryPath when building the single
string or refactor the API to return (or call) Process with executableURL set to
bridgeBinaryPath and arguments ["--port", "\(port)", "--session", "\(sid)"]
(refer to the command(port:sessionId:) function and bridgeBinaryPath symbol when
making the change).
cmuxd-go/launch.go-135-169 (1)

135-169: ⚠️ Potential issue | 🟠 Major

Honor ShellIntegration == "none" before rewriting the shell env.

This block still exports CMUX_SHELL_INTEGRATION=1 and points ZDOTDIR at the integration directory even when shellIntegrationEnabled(cfg) is false, so opting out still routes zsh startup through the cmux integration files.

Proposed fix
 	if paths.cmuxIntegrationDir == "" {
 		return
 	}
 
+	if !shellIntegrationEnabled(cfg) {
+		delete(env, "CMUX_SHELL_INTEGRATION")
+		delete(env, "CMUX_SHELL_INTEGRATION_DIR")
+		delete(env, "CMUX_ZSH_ZDOTDIR")
+		delete(env, "GHOSTTY_ZSH_ZDOTDIR")
+		return
+	}
+
 	env["CMUX_SHELL_INTEGRATION"] = "1"
 	env["CMUX_SHELL_INTEGRATION_DIR"] = paths.cmuxIntegrationDir
 
 	shellPath := env["SHELL"]
@@ -148,19 +156,12 @@ func configureShellLaunchEnv(env map[string]string, cfg *TerminalConfig, paths l
 	}
 
 	originalZdotdir := env["ZDOTDIR"]
-	if shellIntegrationEnabled(cfg) {
-		restoreZdotdir := originalZdotdir
-		if restoreZdotdir == "" {
-			restoreZdotdir = env["HOME"]
-		}
-		if restoreZdotdir != "" {
-			env["GHOSTTY_ZSH_ZDOTDIR"] = restoreZdotdir
-		}
-		delete(env, "CMUX_ZSH_ZDOTDIR")
-	} else if originalZdotdir != "" {
-		env["CMUX_ZSH_ZDOTDIR"] = originalZdotdir
-		delete(env, "GHOSTTY_ZSH_ZDOTDIR")
-	} else {
-		delete(env, "CMUX_ZSH_ZDOTDIR")
-		delete(env, "GHOSTTY_ZSH_ZDOTDIR")
+	restoreZdotdir := originalZdotdir
+	if restoreZdotdir == "" {
+		restoreZdotdir = env["HOME"]
 	}
+	if restoreZdotdir != "" {
+		env["GHOSTTY_ZSH_ZDOTDIR"] = restoreZdotdir
+	}
+	delete(env, "CMUX_ZSH_ZDOTDIR")
 
 	env["ZDOTDIR"] = paths.cmuxIntegrationDir
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd-go/launch.go` around lines 135 - 169, The code unconditionally sets
env["CMUX_SHELL_INTEGRATION"] and env["CMUX_SHELL_INTEGRATION_DIR"] and rewrites
env["ZDOTDIR"] even when shellIntegrationEnabled(cfg) is false; change the logic
to early-check shellIntegrationEnabled(cfg) (calling the same function used
later) and only export CMUX_SHELL_INTEGRATION, CMUX_SHELL_INTEGRATION_DIR and
set env["ZDOTDIR"]=paths.cmuxIntegrationDir when that function returns true,
otherwise leave env alone (except for the safe preservation/restoration of
original ZDOTDIR using CMUX_ZSH_ZDOTDIR/GHOSTTY_ZSH_ZDOTDIR as currently done).
Locate and update the block referencing paths.cmuxIntegrationDir, shellPath,
originalZdotdir, and the shellIntegrationEnabled(cfg) conditional to avoid
touching the SHELL/ZDOTDIR env when integration is opted out.
web/app/terminal/lib/surface-registry.ts-26-34 (1)

26-34: ⚠️ Potential issue | 🟠 Major

Use string IDs for notifications across the Go/TypeScript boundary.

cmuxd-go/notification.go exposes Notification.ID as uint64. The client currently stores and round-trips this as a JavaScript number (IEEE 754 double precision). For any ID exceeding 2^53 - 1 (9,007,199,254,740,991), JavaScript will round the value when decoding from JSON. When markNotificationRead() sends the rounded ID back to the server, it targets the wrong notification. Use string IDs on the wire format and in the NotificationData interface to safely preserve all uint64 values.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/terminal/lib/surface-registry.ts` around lines 26 - 34, Change the
notification ID from a numeric type to a string everywhere it crosses the
Go/TypeScript boundary: update the NotificationData interface so id: string
(instead of number), update any code that constructs/parses NotificationData
from JSON to treat ID as string, and update markNotificationRead (and any other
functions that send or compare notification IDs) to accept and transmit the
string ID so uint64 values from cmuxd-go/notification.go (Notification.ID) are
preserved exactly. Ensure any local numeric comparisons or conversions
explicitly parse the string to BigInt if needed, and remove reliance on JS
number rounding.
cmuxd/src/bridge.zig-100-108 (1)

100-108: ⚠️ Potential issue | 🟠 Major

--session still spins up a throwaway session first.

By the time this branch runs, the server has already created the connection's initial session/PTTY and sent its snapshot. Attaching here adds needless session startup work on every attach, and if the server does not reclaim that initial session immediately it also leaves an orphan behind.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd/src/bridge.zig` around lines 100 - 108, The code currently creates the
connection's initial session/PTTY before this attach_session branch, causing a
throwaway session to be started; change the control flow so that when
attach_session is present (the attach_session optional is set), you skip
creating the initial session/PTTY and skip sending its snapshot, and instead
immediately perform the attach flow here: build the attach message (using sid,
cols, rows), write it with proto.writeMaskedWsFrame(stream, msg, proto.ws_text),
and set session_id = sid; also ensure any earlier code that unconditionally
creates a session or sends a snapshot is made conditional (guarded by if
(!attach_session)) so no orphan session is created.
cmuxd-go/config.go-318-325 (1)

318-325: ⚠️ Potential issue | 🟠 Major

Search user theme directories before bundled resources.

resolveThemeFromPaths returns on the first hit, but themeSearchPaths checks bundled/system locations before XDG_CONFIG_HOME and ~/.config. A user theme with the same name as a built-in theme can never override it with the current order.

Also applies to: 429-483

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd-go/config.go` around lines 318 - 325, resolveThemeFromPaths currently
returns on the first file found, but themeSearchPaths assembles paths with
bundled/system locations before user locations so a user theme can never
override a built-in; reorder the search so user config directories are checked
first (ensure XDG_CONFIG_HOME and ~/.config entries precede bundled/system
resource paths) or alter resolveThemeFromPaths/applyThemeUserWins to prefer
later user files by continuing the search and applying user files last; update
themeSearchPaths and any similar lookup code referenced around lines 429-483 to
ensure user theme directories have priority over bundled resources.
web/app/terminal/components/sidebar.tsx-103-122 (1)

103-122: ⚠️ Potential issue | 🟠 Major

Use semantic controls for workspace actions.

The add control, workspace rows, and close affordances are clickable span/div elements with no keyboard behavior or control semantics. That blocks non-mouse users from adding, selecting, or closing workspaces from the sidebar.

Also applies to: 140-255

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/terminal/components/sidebar.tsx` around lines 103 - 122, The
clickable span for adding workspaces (onAddWorkspace) and other workspace
row/close affordances should be converted to semantic interactive controls or
enhanced with accessibility attributes: replace non-button elements with
<button> elements or ensure each has role="button", tabIndex=0, an accessible
label (aria-label or visible text), and keyboard handlers (handle Enter/Space to
invoke onAddWorkspace or the row/close actions). Update the workspace row
rendering and close affordance handlers mentioned in the component to use these
accessible controls or add corresponding onKeyDown handlers and aria attributes
so non-mouse users can focus and activate them.
cmuxd-go/config.go-155-158 (1)

155-158: ⚠️ Potential issue | 🟠 Major

Don't require HOME before reading config.

This early return skips XDG_CONFIG_HOME-based discovery entirely. In services or containers where HOME is unset but XDG variables are present, the terminal silently falls back to defaults even though config is still discoverable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd-go/config.go` around lines 155 - 158, The code currently returns early
when os.Getenv("HOME") is empty, which prevents XDG_CONFIG_HOME discovery;
change the logic in config.go so you do not return immediately if HOME is
empty—first check os.Getenv("XDG_CONFIG_HOME") and use that if set, otherwise if
HOME is set use filepath.Join(home, ".config"); only skip config discovery
(return cfg, nil) if both XDG_CONFIG_HOME and HOME are unset. Update the block
that defines the local variable home and the subsequent early return so
XDG-based discovery runs when HOME is missing.
cmuxd/src/bridge.zig-170-200 (1)

170-200: ⚠️ Potential issue | 🟠 Major

session_exited doesn't actually stop the bridge.

Breaking out of wsReaderThread only stops the read side. The main loop is still blocked on stdin, so raw mode is not restored and the process does not exit until the user types again or closes stdin.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd/src/bridge.zig` around lines 170 - 200, The wsReaderThread currently
breaks only its read loop on a "session_exited" message, leaving the main stdin
loop blocked and raw mode active; change the handler in wsReaderThread (the
proto.ws_text branch where parseMessageType yields "session_exited" and
parseJsonU32 finds the session id) to signal the rest of the program to
terminate (for example by setting an atomic/volatile shutdown flag, closing a
dedicated shutdown pipe/socket the main loop also selects on, or invoking the
centralized shutdown/cleanup routine) instead of merely breaking the reader
loop; ensure that whatever shutdown path you choose causes the main loop to stop
blocking on stdin and runs the code that restores terminal/raw mode and exits
the process cleanly.
cmuxd/src/bridge.zig-79-97 (1)

79-97: ⚠️ Potential issue | 🟠 Major

Buffer early PTY frames instead of dropping them.

This loop skips every binary frame before initialSessionId is known. That means shell output produced during startup is lost permanently, and those skipped frames also count against the arbitrary 100-frame limit, so a chatty startup can fail session discovery entirely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd/src/bridge.zig` around lines 79 - 97, The loop currently drops binary
PTY frames and counts them toward the 100-attempt limit; change it to buffer
early PTY frames instead: create an in-memory buffer (e.g., early_pty_frames)
and when proto.readWsFrame returns a frame with opcode != proto.ws_text push its
payload into that buffer and do NOT increment attempts for those binary frames
(only increment attempts for non-binary/parse attempts), then once
proto.parseJsonU32 yields the initialSessionId and assigns session_id, iterate
over the buffered frames and replay/process them as if they were just received
(same handling as later PTY frames); update references in the loop to use
early_pty_frames, session_id, proto.readWsFrame, proto.ws_text and
proto.parseJsonU32 accordingly.
cmuxd-go/config.go-289-301 (1)

289-301: ⚠️ Potential issue | 🟠 Major

Normalize palette colors to include # prefix before sending to browser.

The parser accepts palette entries in both N=#rrggbb and N=rrggbb formats, but neither the adapters nor the theme serialization normalize the colors. Values like ff0000 are sent to the browser unchanged, causing them to be invalid for CSS/JavaScript color parsing. Colors should be normalized to include the # prefix during parsing or serialization.

Also applies to: 532-545

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd-go/config.go` around lines 289 - 301, The palette parser accepts both
"N=#rrggbb" and "N=rrggbb" but currently forwards raw values to
theme.setPalette, causing missing-# colors (e.g., "ff0000") to be invalid in the
browser; update the parsing code where color is extracted (the block that calls
theme.setPalette) to normalize the color string by trimming whitespace and
ensuring it begins with a single '#' (if it already starts with '#' leave it,
otherwise prefix one) before calling theme.setPalette; apply the same
normalization to the other equivalent parsing block that also calls
theme.setPalette (the section around the second occurrence noted in the review).
cmuxd/src/bridge.zig-120-125 (1)

120-125: ⚠️ Potential issue | 🟠 Major

Add SA.RESTART flag to prevent signal interruption of the read loop.

Without SA.RESTART, SIGWINCH interrupts the blocking posix.read() at line 164 with EINTR. The catch break treats this error the same as EOF, exiting the main loop without ever checking the g_winch flag at line 155. This allows the bridge to terminate on the first window resize before the resize message is sent.

Set .flags = posix.SA.RESTART in the Sigaction struct (lines 120-125) and at lines 164-166 to automatically restart the read operation instead of failing with EINTR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd/src/bridge.zig` around lines 120 - 125, The SIGWINCH handler Sigaction
is installed without SA.RESTART which lets posix.read() be interrupted by EINTR
and causes the read loop (which checks g_winch) to exit; update the Sigaction
initialization for sigwinchHandler (the var sa: posix.Sigaction where .handler =
.{ .handler = sigwinchHandler } and the second Sigaction setup used before
posix.sigaction) to set .flags = posix.SA.RESTART so reads are automatically
restarted and the loop can observe g_winch rather than treating EINTR as EOF.
web/app/terminal/terminal-page.tsx-171-227 (1)

171-227: ⚠️ Potential issue | 🟠 Major

Don't use the effect-captured drop state on mouseup.

The final mousemove can schedule a new dropTarget, and mouseup can still run with the previous closure. Fast drags then fall back to click-selection or dispatch to the wrong destination. Recompute the target from the mouseup coordinates, or keep the live drag state in refs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/terminal/terminal-page.tsx` around lines 171 - 227, The mouseup
handler is using effect-captured dropTarget which can be stale; update
handleMouseUp to compute the drop target from the actual mouseup coordinates
(call detectTarget(e.clientX, e.clientY)) or maintain a live ref (e.g.,
latestDropTargetRef) updated in handleMouseMove and read in handleMouseUp
instead of relying on the closed-over dropTarget variable; modify the useEffect
to reference the ref (or remove dropTarget from the closure) and ensure
dragInfo, detectTarget, and ghostRef behavior remains unchanged.
cmuxd/src/main.zig-71-86 (1)

71-86: ⚠️ Potential issue | 🟠 Major

Don't hold Server.mutex during client socket writes.

broadcast and broadcastPtyOutput send on every client while the global mutex is held. One slow or wedged socket can stall PTY reads, session lifecycle operations, and disconnect cleanup for everyone.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd/src/main.zig` around lines 71 - 86, Server.broadcast and
Server.broadcastPtyOutput currently hold Server.mutex while iterating
self.clients.items and calling client.sendText / client.sendPtyData, which can
block the whole server on a slow socket; instead, while holding Server.mutex
briefly, snapshot the list of client references (e.g., copy pointers/indices
from self.clients.items into a temporary array), then release Server.mutex and
iterate that snapshot to call client.sendText / client.sendPtyData; ensure
lifetime of client references is valid (or mark disconnected clients and skip)
and do this for both broadcast and broadcastPtyOutput to avoid performing socket
writes under the mutex.
cmuxd-go/server.go-79-94 (1)

79-94: ⚠️ Potential issue | 🟠 Major

Move websocket writes out of the global server lock.

Broadcast and BroadcastPtyOutput perform network I/O while s.mu is held. A single blocked client write can back up PTY output and stall unrelated metadata/workspace updates across the whole server. Snapshot s.clients under the lock, then send outside it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd-go/server.go` around lines 79 - 94, Server.Broadcast and
Server.BroadcastPtyOutput currently perform websocket writes (via SendText and
SendPtyData) while holding the global mutex s.mu, which can block other work;
under the lock, copy/snapshot s.clients into a local slice (e.g. clients :=
make([]*Client, len(s.clients)); copy(...)) then release the lock and iterate
that local slice to call SendText or SendPtyData so network I/O happens outside
s.mu; update both Broadcast and BroadcastPtyOutput to follow this pattern
referencing s.mu, s.clients, Broadcast, BroadcastPtyOutput, SendText and
SendPtyData.
cmuxd-go/mux.go-42-75 (1)

42-75: ⚠️ Potential issue | 🟠 Major

Mirror destroy_session side effects in the disconnect cleanup path.

When this defer removes the last client from a session, it deletes and kills the session immediately but skips srv.notifications.RemoveForSession(...) and srv.BroadcastWorkspaceUpdate(...). Other connected clients can keep stale panes and notification badges for sessions that no longer exist.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd-go/mux.go` around lines 42 - 75, The defer teardown in mux.go currently
deletes and kills sessions but doesn't perform the same side effects as
destroy_session: call srv.notifications.RemoveForSession(...) for the removed
session and invoke srv.BroadcastWorkspaceUpdate(...) so other clients' UIs are
updated; modify the block where you detect sess.ClientCount() == 0 (the code
that deletes from srv.sessions.sessions and calls sess.Kill()) to also call
srv.notifications.RemoveForSession(sid) and then
srv.BroadcastWorkspaceUpdate(...) (passing the same context/identifiers used by
destroy_session), ensuring these calls happen while holding appropriate locks or
after unlocking to match destroy_session's ordering.
cmuxd/src/main.zig-291-328 (1)

291-328: ⚠️ Potential issue | 🟠 Major

Broadcast a workspace update when orphaned sessions are removed.

This disconnect cleanup deletes last-owner sessions but only emits client_left. Other connected clients keep rendering the removed panes until some unrelated mutation eventually triggers broadcastWorkspaceUpdate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd/src/main.zig` around lines 291 - 328, The cleanup currently removes
orphaned sessions (the removed variable after fetchRemove) but only emits a
global client_left message, leaving other clients with stale panes; modify the
defer block where removed is handled (inside the if (removed) |s| branch) to
call server.broadcastWorkspaceUpdate() after removing the session (and before or
after s.kill()/alloc.destroy(s) as appropriate for the implementation) so all
connected clients receive an immediate workspace update; ensure you use the
existing server.broadcastWorkspaceUpdate() symbol and respect the existing mutex
lock/unlock ordering around the removal and broadcast.
web/app/terminal/terminal-page.tsx-291-297 (1)

291-297: ⚠️ Potential issue | 🟠 Major

Close-tab hotkey bypasses terminal disposal.

This Alt+W path dispatches CLOSE_TAB directly, so the tab disappears without surfaceRegistry.destroy(...). The PTY/websocket for that tab keeps running in the background.

♻️ Minimal fix
-        const group = ws.groups[ws.focusedGroupId]
-        if (group && group.activeTabId) {
-          dispatch({ type: "CLOSE_TAB", groupId: ws.focusedGroupId, tabId: group.activeTabId })
-        }
+        const group = ws.groups[ws.focusedGroupId]
+        if (group?.activeTabId) {
+          handleCloseTab(ws.focusedGroupId, group.activeTabId)
+        }

Also add handleCloseTab to the effect dependency array.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/terminal/terminal-page.tsx` around lines 291 - 297, The Alt+W branch
is dispatching CLOSE_TAB directly and bypasses surface cleanup; instead call the
existing tab-close handler so surfaceRegistry.destroy(...) runs: replace the
direct dispatch in the Alt+W handler with a call to
handleCloseTab(ws.focusedGroupId, group.activeTabId) (or otherwise invoke the
same logic that calls surfaceRegistry.destroy for that tab), and ensure
handleCloseTab is added to the effect dependency array so the effect sees
updates to that function; reference ws.groups, ws.focusedGroupId, CLOSE_TAB,
surfaceRegistry.destroy, and handleCloseTab when making the change.
cmuxd-go/session.go-179-202 (1)

179-202: ⚠️ Potential issue | 🟠 Major

AttachClient, DetachClient, and UpdateClientSize are not thread-safe.

These methods modify s.ClientSizes and s.DriverID without synchronization. The comment says "protected by Server.mu", but this creates a hidden contract that's easy to violate. Consider adding a mutex to Session or documenting the requirement more prominently.

The current design relies on callers holding Server.mu, which is:

  1. Not enforced by the type system
  2. Easy to forget when adding new call sites
  3. Could lead to data races if any caller forgets

Consider either:

  • Adding a sync.Mutex to Session for these operations
  • Using sync.Map for ClientSizes
  • Adding explicit documentation with // REQUIRES: caller holds Server.mu
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd-go/session.go` around lines 179 - 202, The session methods
AttachClient, DetachClient, ClientCount and UpdateClientSize mutate/read
Session.ClientSizes and DriverID without synchronization; add a sync.Mutex
(e.g., mu sync.Mutex) to the Session struct and wrap all accesses/modifications
in AttachClient, DetachClient, ClientCount and UpdateClientSize with
mu.Lock()/mu.Unlock() (or use defer) so applySmallestWins() is called while
holding the lock, ensuring the same protection for any helper methods it uses;
alternatively, if you choose sync.Map, convert ClientSizes to a sync.Map and
update the methods to use its Load/Store/Delete/Range APIs and ensure DriverID
updates are similarly protected.
cmuxd/src/session.zig-179-194 (1)

179-194: ⚠️ Potential issue | 🟠 Major

resize lacks mutex protection for concurrent access.

The resize method updates self.cols and self.rows outside the mutex, then acquires the mutex only for VT resize. If multiple threads call resize concurrently, there's a race on the cols/rows fields and the ioctl call.

🔒 Proposed fix: extend mutex scope
 pub fn resize(self: *Session, cols_val: u16, rows_val: u16) void {
+    self.vt_mutex.lock();
+    defer self.vt_mutex.unlock();
+    
     self.cols = cols_val;
     self.rows = rows_val;
     var ws: c.winsize = .{
         .ws_col = cols_val,
         .ws_row = rows_val,
         .ws_xpixel = 0,
         .ws_ypixel = 0,
     };
     _ = c.ioctl(self.pty_fd, c.TIOCSWINSZ, &ws);
     if (self.vt_initialized) {
-        self.vt_mutex.lock();
-        defer self.vt_mutex.unlock();
         self.vt.resize(self.alloc, cols_val, rows_val) catch {};
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd/src/session.zig` around lines 179 - 194, The resize method updates
shared state without holding the session mutex, causing races; modify
Session.resize so it acquires self.vt_mutex before mutating self.cols and
self.rows and before calling c.ioctl, then perform vt.resize (self.vt.resize)
while still holding the mutex, using defer to unlock; preserve the ioctl
invocation and any error handling semantics (currently discarded) and ensure the
mutex is held for the entire sequence: update fields, call ioctl, and call
vt.resize.
cmuxd/src/protocol.zig-267-274 (1)

267-274: ⚠️ Potential issue | 🟠 Major

Large payloads silently truncated in masked frame writer.

If payload.len > 65536, only the first 65536 bytes are sent due to @min(payload.len, masked.len). This silent truncation could cause protocol errors or data loss.

🐛 Proposed fix: chunk large payloads or return error
 pub fn writeMaskedWsFrame(stream: std.net.Stream, payload: []const u8, opcode: u8) !void {
+    if (payload.len > 65536) {
+        return error.PayloadTooLarge;
+    }
     // ... rest of function

Alternatively, implement chunked writing for large payloads.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd/src/protocol.zig` around lines 267 - 274, The masked frame writer
currently allocates a fixed buffer "masked" of 65536 bytes and uses n =
`@min`(payload.len, masked.len) so any payload.len > 65536 is silently truncated;
fix by implementing chunked masking+write: iterate over payload in slices no
larger than masked.len, for each slice compute masked[i] = b ^ mask[i % 4]
(preserving mask position across chunks) and call stream.write for each masked
slice until the full payload is written (or alternatively return an explicit
error if you prefer not to support >65536 frames); update references in this
function to use the chunked loop instead of the single `@min` call and ensure
mask, payload, and stream.write are used per-chunk.
cmuxd/src/protocol.zig-258-275 (1)

258-275: ⚠️ Potential issue | 🟠 Major

Fixed masking key weakens security.

The writeMaskedWsFrame function uses a hardcoded mask [4]u8{ 0x37, 0xfa, 0x21, 0x3d }. Per RFC 6455 Section 5.3, the masking key "MUST be derived from a strong source of entropy" to prevent cache poisoning attacks.

🔒 Proposed fix: generate random mask
 pub fn writeMaskedWsFrame(stream: std.net.Stream, payload: []const u8, opcode: u8) !void {
     // ... header setup ...

-    // Mask key (simple incrementing bytes)
-    const mask = [4]u8{ 0x37, 0xfa, 0x21, 0x3d };
+    // Generate cryptographically random mask per RFC 6455
+    var mask: [4]u8 = undefined;
+    std.crypto.random.bytes(&mask);
     header[hlen] = mask[0];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd/src/protocol.zig` around lines 258 - 275, The mask is hardcoded in
writeMaskedWsFrame which violates RFC6455; replace the static const mask with a
4-byte mask generated from a cryptographically secure RNG (use Zig's crypto
secure random API, e.g. std.crypto.random or equivalent secure OS-backed
generator) and write that generated mask into header[hlen..hlen+4] and use the
same generated bytes when XOR-ing the payload (the masked buffer logic that uses
mask[i % 4]). Ensure the RNG call is checked for errors before writing and
preserve existing write order (header then masked payload).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4f51d2b2-b486-4c27-99ba-058db3d412b4

📥 Commits

Reviewing files that changed from the base of the PR and between 0f29735 and 14fab2a.

⛔ Files ignored due to path filters (3)
  • cmuxd-go/go.sum is excluded by !**/*.sum
  • web/bun.lock is excluded by !**/*.lock
  • web/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (88)
  • .github/workflows/ci.yml
  • .github/workflows/cmuxd-release.yml
  • Sources/CmuxdBridge.swift
  • cmuxd-go/.gitignore
  • cmuxd-go/config.go
  • cmuxd-go/config_test.go
  • cmuxd-go/dev.sh
  • cmuxd-go/go.mod
  • cmuxd-go/launch.go
  • cmuxd-go/launch_test.go
  • cmuxd-go/legacy.go
  • cmuxd-go/main.go
  • cmuxd-go/metadata_test.go
  • cmuxd-go/mux.go
  • cmuxd-go/notification.go
  • cmuxd-go/notification_test.go
  • cmuxd-go/osc.go
  • cmuxd-go/osc_test.go
  • cmuxd-go/protocol.go
  • cmuxd-go/ringbuffer.go
  • cmuxd-go/server.go
  • cmuxd-go/session.go
  • cmuxd-go/workspace.go
  • cmuxd/build.zig
  • cmuxd/build.zig.zon
  • cmuxd/dev.sh
  • cmuxd/npm/.gitignore
  • cmuxd/npm/bin/.gitkeep
  • cmuxd/npm/cli.mjs
  • cmuxd/npm/package.json
  • cmuxd/npm/postinstall.mjs
  • cmuxd/src/bridge.zig
  • cmuxd/src/config.zig
  • cmuxd/src/main.zig
  • cmuxd/src/protocol.zig
  • cmuxd/src/session.zig
  • plan.md
  • tests/test_spatial_navigation.swift
  • web/.gitignore
  • web/app/(main)/(legal)/eula/page.tsx
  • web/app/(main)/(legal)/layout.tsx
  • web/app/(main)/(legal)/privacy-policy/page.tsx
  • web/app/(main)/(legal)/terms-of-service/page.tsx
  • web/app/(main)/blog/introducing-cmux/page.tsx
  • web/app/(main)/blog/layout.tsx
  • web/app/(main)/blog/page.tsx
  • web/app/(main)/community/page.tsx
  • web/app/(main)/docs/api/page.tsx
  • web/app/(main)/docs/changelog/changelog-media.ts
  • web/app/(main)/docs/changelog/page.tsx
  • web/app/(main)/docs/concepts/page.tsx
  • web/app/(main)/docs/configuration/page.tsx
  • web/app/(main)/docs/docs-nav.tsx
  • web/app/(main)/docs/getting-started/page.tsx
  • web/app/(main)/docs/keyboard-shortcuts/page.tsx
  • web/app/(main)/docs/layout.tsx
  • web/app/(main)/docs/notifications/page.tsx
  • web/app/(main)/docs/page.tsx
  • web/app/(main)/layout.tsx
  • web/app/(main)/page.tsx
  • web/app/components/callout.tsx
  • web/app/components/spacing-control.tsx
  • web/app/globals.css
  • web/app/layout.tsx
  • web/app/terminal/client-terminal.tsx
  • web/app/terminal/components/group-tab-bar.tsx
  • web/app/terminal/components/sidebar.tsx
  • web/app/terminal/components/split-divider.tsx
  • web/app/terminal/components/split-tree-view.tsx
  • web/app/terminal/components/surface-placeholder.tsx
  • web/app/terminal/components/terminal-surface.tsx
  • web/app/terminal/components/toolbar.tsx
  • web/app/terminal/layout.tsx
  • web/app/terminal/lib/ghostty-adapter.ts
  • web/app/terminal/lib/ghostty-init.ts
  • web/app/terminal/lib/reducer.ts
  • web/app/terminal/lib/split-tree.ts
  • web/app/terminal/lib/surface-registry.ts
  • web/app/terminal/lib/terminal-adapter.ts
  • web/app/terminal/lib/xterm-adapter.ts
  • web/app/terminal/page.tsx
  • web/app/terminal/terminal-page.tsx
  • web/e2e/fixtures/mouse-test.py
  • web/e2e/pty.spec.ts
  • web/e2e/terminal.spec.ts
  • web/package.json
  • web/playwright.config.ts
  • web/pty-server.mjs

Comment thread cmuxd-go/mux.go
Comment on lines +19 to +21
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

❓ Verification inconclusive

In github.com/coder/websocket v1.8.14, what does websocket.AcceptOptions.InsecureSkipVerify do on the server, and what is the recommended way to allow only specific localhost origins instead?


What AcceptOptions.InsecureSkipVerify does (server side)

On the server, InsecureSkipVerify disables websocket.Accept’s Origin header verification. Concretely, when it’s true, the library skips calling its authenticateOrigin check, so cross-origin WebSocket handshakes aren’t rejected based on Origin/Host mismatch (i.e., it effectively allows any browser origin that can reach the endpoint). This is about CSRF / cross-site WebSocket hijacking protection, not TLS cert verification. [1][2]

Recommended way to allow only specific localhost origins

Use AcceptOptions.OriginPatterns (not InsecureSkipVerify) to whitelist only the localhost origins you want. The matcher compares patterns (case-insensitive) using path.Match against the parsed Origin host (or scheme://host if your pattern includes ://). [1][2]

Example (allow only specific localhost dev ports):

c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
  OriginPatterns: []string{
    "localhost:3000",
    "127.0.0.1:3000",
    "[::1]:3000",
  },
})

Or, if you truly want “any port but only localhost”:

OriginPatterns: []string{"localhost:*", "127.0.0.1:*", "[::1]:*"}

Sources:
[1] https://raw.githubusercontent.com/coder/websocket/v1.8.14/accept.go
[2] https://pkg.go.dev/github.com/coder/websocket#AcceptOptions


Use OriginPatterns to allow only specific localhost origins instead of disabling Origin checks.

InsecureSkipVerify: true disables Origin header verification on an endpoint that can spawn and control local PTYs. This allows cross-site WebSocket hijacking attacks from any arbitrary page that can reach the localhost service.

Replace with AcceptOptions.OriginPatterns to whitelist only the intended localhost origins:

Example fix
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
	OriginPatterns: []string{
		"localhost:*",
		"127.0.0.1:*",
		"[::1]:*",
	},
})

Adjust the port patterns to match your actual dev/deployment origins.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd-go/mux.go` around lines 19 - 21, The code currently calls
websocket.Accept with AcceptOptions setting InsecureSkipVerify: true which
disables Origin checks; change this to use AcceptOptions.OriginPatterns to
whitelist only intended localhost origins (e.g., patterns for localhost,
127.0.0.1, [::1] with appropriate port wildcards) so Origin header verification
remains enabled; update the call site where websocket.Accept is invoked and
remove InsecureSkipVerify while adding an OriginPatterns slice that matches your
expected dev/deployment origins.

Comment thread cmuxd-go/mux.go
Comment on lines +224 to +240
srv.sessions.mu.Lock()
sess := srv.sessions.sessions[msg.SessionID]
if sess != nil {
changed := sess.UpdateClientSize(client.id, ClientSize{Cols: msg.Cols, Rows: msg.Rows})
if changed {
reply, _ := json.Marshal(SessionResizedMsg{
Type: "session_resized",
SessionID: msg.SessionID,
Cols: sess.Cols,
Rows: sess.Rows,
})
srv.mu.Lock()
srv.Broadcast(ctx, reply)
srv.mu.Unlock()
}
}
srv.sessions.mu.Unlock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Use one lock order for srv.mu and srv.sessions.mu.

These branches take srv.sessions.mu and then srv.mu, but cmuxd-go/server.go already takes the opposite order in BuildWorkspaceJSON and processOscEvents. A concurrent resize or driver update can deadlock against metadata/workspace broadcasts. Build the reply while holding srv.sessions.mu, release it, and only then take srv.mu to broadcast.

Also applies to: 298-320, 325-338, 343-356

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd-go/mux.go` around lines 224 - 240, The code currently locks
srv.sessions.mu then acquires srv.mu (e.g., in the resize branch around
UpdateClientSize and Broadcast), which can deadlock against places that take
srv.mu first (like BuildWorkspaceJSON and processOscEvents); to fix, while
holding srv.sessions.mu build the reply payload (SessionResizedMsg JSON via
json.Marshal) and capture any needed fields from sess (Cols, Rows, SessionID),
then release srv.sessions.mu and only after that acquire srv.mu to call
srv.Broadcast with the prebuilt reply; apply the same pattern to the other
branches you mentioned (around the code at 298-320, 325-338, 343-356) so no code
path takes the locks in the opposite order.

Comment thread cmuxd/src/bridge.zig
Comment on lines +145 to +166
const reader = std.Thread.spawn(.{}, wsReaderThread, .{ stream, session_id }) catch {
std.debug.print("cmux-bridge: failed to spawn reader thread\n", .{});
std.process.exit(1);
};
_ = reader; // detached below

// Main loop: stdin → cmuxd
var buf: [4096]u8 = undefined;
while (true) {
// Check for SIGWINCH
if (g_winch.swap(false, .acq_rel)) {
var ws: c.winsize = undefined;
if (c.ioctl(c.STDOUT_FILENO, c.TIOCGWINSZ, &ws) == 0) {
var msg_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "{{\"type\":\"resize\",\"sessionId\":{d},\"cols\":{d},\"rows\":{d}}}", .{ session_id, ws.ws_col, ws.ws_row }) catch continue;
proto.writeMaskedWsFrame(stream, msg, proto.ws_text) catch break;
}
}

const n = posix.read(stdin_fd, &buf) catch break;
if (n == 0) break;
proto.writeMaskedPtyFrame(stream, session_id, buf[0..n]) catch break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Serialize all writes to the WebSocket stream.

Once the reader thread is running, Line 160 and Line 166 can race with Line 196. Concurrent writes on the same std.net.Stream can interleave frame bytes and corrupt the WebSocket connection under ping + input/resize traffic.

Also applies to: 170-197

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd/src/bridge.zig` around lines 145 - 166, Concurrent writes to the same
std.net.Stream from the reader thread and main loop (e.g.,
proto.writeMaskedWsFrame and proto.writeMaskedPtyFrame invoked around the
g_winch handling and stdin loop) can interleave and corrupt frames; serialize
all WebSocket writes by introducing a dedicated write mutex or single writer
task and acquiring it before any call to proto.writeMaskedWsFrame /
proto.writeMaskedPtyFrame (including calls from wsReaderThread), or route all
outgoing frames through a single channel/queue consumed by one writer coroutine;
update wsReaderThread and the main loop to use that shared serializer so every
write to the stream is atomic.

Comment thread cmuxd/src/main.zig
Comment on lines +588 to +589
const addr = std.net.Address.initIp4(.{ 0, 0, 0, 0 }, port);
var server = try addr.listen(.{ .reuse_address = true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Bind loopback by default.

Listening on 0.0.0.0 exposes an unauthenticated shell/mux service to the network. Default to 127.0.0.1 and make remote exposure an explicit opt-in.

🔒 Suggested default
-    const addr = std.net.Address.initIp4(.{ 0, 0, 0, 0 }, port);
+    const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const addr = std.net.Address.initIp4(.{ 0, 0, 0, 0 }, port);
var server = try addr.listen(.{ .reuse_address = true });
const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port);
var server = try addr.listen(.{ .reuse_address = true });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxd/src/main.zig` around lines 588 - 589, The code currently binds to
0.0.0.0 which exposes the service to the network; change the address used in
Address.initIp4 to the IPv4 loopback bytes (127,0,0,1) so the default bind is
local only (update the call site that creates addr via Address.initIp4 and keeps
using addr.listen and server as before); also add a short comment near
Address.initIp4/addr.listen explaining that remote exposure must be opted into
explicitly (e.g., via a flag) so reviewers know this was intentional.

Comment thread web/pty-server.mjs
Comment on lines +14 to +27
const wss = new WebSocketServer({ server, path: "/ws" })

wss.on("connection", (ws, req) => {
const url = new URL(req.url, `http://localhost:${PORT}`)
const cols = parseInt(url.searchParams.get("cols") || "80", 10)
const rows = parseInt(url.searchParams.get("rows") || "24", 10)

const proc = pty.spawn(SHELL, [], {
name: "xterm-256color",
cols,
rows,
cwd: env.PTY_CWD || process.cwd(),
env: { ...env },
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Authenticate /ws before creating a PTY.

Line 16 accepts any WebSocket connection, and Lines 21-27 immediately spawn a shell under the server account. If this port is reachable beyond localhost, this is unauthenticated remote command execution. Gate the handshake before pty.spawn(...), and bind to loopback by default if this server is only meant for local dev/test use.

🔐 Possible hardening direction
+const HOST = env.PTY_HOST || "127.0.0.1"
+const PTY_TOKEN = env.PTY_TOKEN
+
 const wss = new WebSocketServer({ server, path: "/ws" })
 
 wss.on("connection", (ws, req) => {
-  const url = new URL(req.url, `http://localhost:${PORT}`)
+  const url = new URL(req.url ?? "/", `http://${HOST}:${PORT}`)
+  if (!PTY_TOKEN || url.searchParams.get("token") !== PTY_TOKEN) {
+    ws.close(1008, "Unauthorized")
+    return
+  }
   const cols = parseInt(url.searchParams.get("cols") || "80", 10)
   const rows = parseInt(url.searchParams.get("rows") || "24", 10)
   const proc = pty.spawn(SHELL, [], {
     name: "xterm-256color",
     cols,
@@
-server.listen(PORT, () => {
+server.listen(PORT, HOST, () => {
   console.log(`PTY server listening on :${PORT}`)
 })

Also applies to: 63-65

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pty-server.mjs` around lines 14 - 27, The WebSocket connection handler
currently accepts any connection and immediately calls pty.spawn (see
wss.on("connection", ...), pty.spawn, SHELL), which allows unauthenticated
remote shells; before invoking pty.spawn (and before any shell-related logic
around env.PTY_CWD), validate/authenticate the handshake (e.g., check an auth
token/query param, verify cookies or an Authorization header from req, or
perform a sub-protocol authentication) and only call pty.spawn when
authentication succeeds; additionally, if this server is intended for local dev
only, bind the WebSocketServer to loopback by default (adjust WebSocketServer
server/options where it is created) so connections from remote hosts are
refused.

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026
lawrencecchen added a commit that referenced this pull request Oct 4, 2026
The tagged release commit: #33 (action replies without the 1 s guard
wait, explicit AX and screenshot failures), #34 (guard per agent
session), #35 (CmuxAgentCursor package) and the 0.8.0 version strings.
The lease v2 reducer (#36) landed after the tag and comes with 0.8.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Oct 4, 2026
…et hash

Written by scripts/bump-cmux-cua-pin.sh cmux-cua-v0.8.2:
- CMUX_CUA_PINNED_SHA 418f5841 (the commit the tag points to)
- CMUX_CUA_RELEASE_TAG cmux-cua-v0.8.2
- CMUX_CUA_DARWIN_UNIVERSAL_UNSIGNED_SHA256 bf62782d...a877, computed
  from cmux-cua-0.8.2-darwin-universal-unsigned.tar.gz and equal to the
  release checksums.txt (and to the CI lead's value)
- Packages/Shared/CmuxAgentCursor re-vendored at 418f5841 (tree
  183e5064); the package is self-contained now (cmux-cua #39).
Brings lease v2 (#36), the self-contained cursor package (#39) and the
Linux release fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Oct 4, 2026
…et hash

Written by scripts/bump-cmux-cua-pin.sh cmux-cua-v0.8.2:
- CMUX_CUA_PINNED_SHA 418f5841 (the commit the tag points to)
- CMUX_CUA_RELEASE_TAG cmux-cua-v0.8.2
- CMUX_CUA_DARWIN_UNIVERSAL_UNSIGNED_SHA256 bf62782d...a877, computed
  from cmux-cua-0.8.2-darwin-universal-unsigned.tar.gz and equal to the
  release checksums.txt (and to the CI lead's value)
- Packages/Shared/CmuxAgentCursor re-vendored at 418f5841 (tree
  183e5064); the package is self-contained now (cmux-cua #39).
Brings lease v2 (#36), the self-contained cursor package (#39) and the
Linux release fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Oct 4, 2026
…et hash

Written by scripts/bump-cmux-cua-pin.sh cmux-cua-v0.8.2:
- CMUX_CUA_PINNED_SHA 418f5841 (the commit the tag points to)
- CMUX_CUA_RELEASE_TAG cmux-cua-v0.8.2
- CMUX_CUA_DARWIN_UNIVERSAL_UNSIGNED_SHA256 bf62782d...a877, computed
  from cmux-cua-0.8.2-darwin-universal-unsigned.tar.gz and equal to the
  release checksums.txt (and to the CI lead's value)
- Packages/Shared/CmuxAgentCursor re-vendored at 418f5841 (tree
  183e5064); the package is self-contained now (cmux-cua #39).
Brings lease v2 (#36), the self-contained cursor package (#39) and the
Linux release fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch had an error being deployed

1 failed and 2 active (outdated) deployments
Preview — 14fab2ac Deployed Mar 9, 2026 by vercel[bot]
Preview – cmuxterm-docs — 9f1cb4c3 Deployed Feb 12, 2026 by vercel[bot]
Preview – cmux — 9f1cb4c3 Deployed Feb 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants