Skip to content

ts sdk: typed protocol + transports for building cmux-tui frontends - #7886

Merged
lawrencecchen merged 2 commits into
mainfrom
feat-ts-frontend-sdk
Jul 11, 2026
Merged

lawrencecchen merged 2 commits into
mainfrom
feat-ts-frontend-sdk

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 11, 2026 •

Copy link
Copy Markdown
Contributor

Turns the TypeScript binding into the client library for third-party cmux-tui frontends (the xterm.js React reference app and eventually the Swift app's web layer consume this): complete typed protocol (all 50 commands + events, discriminated unions mirroring spec/), a Transport abstraction with the wire-identical unix client and a browser+node WebSocketTransport (injected ctor, no hard ws dep), typed attach streaming with Uint8Array VT payloads, browser-safe package exports (cmux / cmux/node / cmux/browser). Conformance TS e2e unchanged and must stay green in CI (local server build blocked by the known host zig issue). 8/8 tests, npm pack clean.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Large client refactor and expanded protocol surface; behavior changes mainly around streaming/multiplexing and attach decoding, though Node zero-arg usage is preserved via a thin wrapper.

Overview
Refactors the cmux TypeScript binding from a Node-only Unix-socket client into a frontend SDK: injectable Transport, a shared MessageRouter, and CmuxStream for subscribe/attach instead of socket-specific JSON-line I/O.

Adds a protocol/ module with discriminated CmuxRequest / event unions (full v6 command set), a generic request() that infers response types, and many new typed command helpers (layout, agents, run, copy, etc.). attachSurface() now decodes vt-state, output, and resized payloads to Uint8Array via portable base64 helpers (no Buffer in shared code).

Ships UnixSocketTransport and WebSocketTransport (browser global or injected ctor), conditional package exports (cmux, cmux/browser, cmux/node), ESM NodeNext build, npm test, and updated bindings.md TypeScript contract. Node new CmuxClient() / env socket defaults remain on the cmux/node (and root Node) entry via node-client.

Reviewed by Cursor Bugbot for commit 45c299f. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Turns the TypeScript bindings into a typed frontend SDK for cmux with a full protocol, pluggable transports, and browser-safe exports. Keeps the Node API compatible while adding typed streaming attach and a generic, type‑inferred request method that enforces required params.

  • New Features

    • Full typed protocol: 50 commands + discriminated event unions.
    • Pluggable Transport with UnixSocketTransport (JSON-lines) and WebSocketTransport (browser + Node via injected WebSocket).
    • CmuxClient.request(cmd, params) enforces required params; request({ cmd, ... }) infers result types. Existing command methods remain.
    • attachSurface() yields a stream; vt-state, output, and resized payloads decode to Uint8Array.
    • Browser-safe entries: cmux (browser condition), cmux/browser, and cmux/node; no runtime deps.
  • Migration

    • No breaking changes for Node users; import { CmuxClient } from "cmux" still works. cmux/node is available explicitly. Requires Node 20+.
    • In browsers, use cmux or cmux/browser with WebSocketTransport.
    • In Node with WebSocket, inject a constructor (e.g., from ws) into WebSocketTransport.
    • For dedicated subscribe/attach connections, provide streamTransportFactory (default for Unix sockets).

Written for commit 45c299f. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added a cross-platform TypeScript client supporting browser WebSockets and Node.js Unix sockets.
    • Added typed protocol commands, responses, events, streaming subscriptions, and surface attachment.
    • Added browser and Node package entry points with ESM support.
    • Added portable Base64 encoding and decoding helpers.
    • Added async streaming, typed raw requests, and forward-compatible event handling.
  • Documentation

    • Expanded setup, runtime, frontend integration, transport, and verification guidance.
  • Tests

    • Added coverage for transports, streaming, Base64 utilities, protocol typing, and Unix-socket communication.

…ends

Typed request/response/event definitions for all 50 implemented commands
(discriminated unions on cmd/event), Transport abstraction with the existing
unix JSON-lines client (wire-identical) and a WebSocketTransport working in
browser and node (injectable WebSocket ctor, no ws dependency), typed
attachSurface streaming with base64->Uint8Array decode (Buffer-free shared
paths). Dual entries: cmux (browser condition safe), cmux/node, cmux/browser.
README frontend example. 8 tests incl. compile-checked unions + fake-WS
transport contract.
@vercel

vercel Bot commented Jul 11, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jul 11, 2026 11:39am
cmux-staging Building Building Preview, Comment Jul 11, 2026 11:39am

@coderabbitai

coderabbitai Bot commented Jul 11, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The TypeScript binding is refactored around typed protocol contracts and a shared transport interface. It adds browser WebSocket and Node Unix-socket transports, routed requests and async streams, conditional package exports, runtime-safe Base64 helpers, documentation, and tests.

Changes

TypeScript client library

Layer / File(s) Summary
Protocol contracts
cmux-tui/bindings/typescript/src/protocol/*, cmux-tui/bindings/typescript/src/types.ts
Adds typed command, response, tree, and event models with decoded attach payloads and compatibility re-exports.
Cross-runtime transports
cmux-tui/bindings/typescript/src/transport.ts, src/websocket-transport.ts, src/node-transport.ts, src/node-client.ts, src/base64.ts, src/*barrel*.ts
Introduces shared transport abstractions, browser WebSocket support, Node Unix-socket JSON-lines framing, runtime-safe Base64 helpers, and environment-specific entrypoints.
Client routing and streams
cmux-tui/bindings/typescript/src/client.ts
Adds correlated typed requests, transport lifecycle handling, closeable async streams, attach protocol checks, and Base64 event decoding.
Publication and usage contract
cmux-tui/bindings/typescript/package.json, tsconfig.json, README.md, cmux-tui/spec/bindings.md
Publishes ESM conditional exports, compiles tests with NodeNext, and documents browser, Node, transport, and typed-request usage.
Runtime and type validation
cmux-tui/bindings/typescript/test/*
Tests Base64 fallback behavior, typed requests, attach decoding, protocol typing, Unix framing, and WebSocket lifecycle handling.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CmuxClient
  participant MessageRouter
  participant Transport
  CmuxClient->>MessageRouter: Send typed request
  MessageRouter->>Transport: Send JSON payload
  Transport-->>MessageRouter: Return response or event
  MessageRouter-->>CmuxClient: Resolve request or yield stream event
Loading

Possibly related PRs

  • manaflow-ai/cmux#7584: Adds server-side protocol commands represented by the new TypeScript request and response typings.
  • manaflow-ai/cmux#7588: Adds control-socket commands and layout events covered by the new protocol surfaces.
  • manaflow-ai/cmux#7604: Adds protocol commands and events mirrored by the TypeScript bindings.

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors, 2 warnings)

Check name Status Explanation Resolution
Cmux Algorithmic Complexity ❌ Error FAIL: client.ts and both transports use .shift() on backlog queues in hot paths, making drains O(n²) as buffered events/messages grow. Use a head-index queue/deque for buffered, waiters, and pending; if sizes are truly bounded, document the bound and add a benchmark.
Cmux User-Facing Error Privacy ❌ Error client.ts exposes raw server errors in CmuxCommandError and parser messages in CmuxProtocolError, which the rule forbids. Sanitize user-facing errors: replace raw response.error/error.message text with generic product terms and move details to internal logs.
Description check ⚠️ Warning The description covers the main change and testing, but it does not follow the required template sections or include the demo, review trigger, or checklist. Add the required Summary, Testing, Demo Video, Review Trigger, and Checklist sections, and include the missing demo link and checklist details.
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (21 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly names the typed TypeScript SDK, protocol work, and transport additions for cmux-tui frontends.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed PASS: The diff only changes cmux-tui/bindings/typescript/src/client.ts; no Swift files are touched, so there are no Swift actor-isolation regressions to assess.
Cmux Swift Blocking Runtime ✅ Passed Diff changes only cmux-tui/bindings/typescript/src/client.ts; no Swift production code or blocking sync patterns were introduced.
Cmux Browser Automation Off-Main ✅ Passed PR only changes TypeScript bindings/docs; the rule targets Swift browser-command routing files not touched here.
Cmux Expensive Synchronous Load ✅ Passed The PR diff touches only TypeScript/docs; no Swift files or Swift expensive-sync-load call sites are changed, so the check is not applicable.
Cmux Cache Substitution Correctness ✅ Passed No persistence/history/snapshot path replaces a fresh read with a cache; attachSurface still reads live snapshots, and cached protocol gating falls back to identify() on cold cache.
Cmux No Hacky Sleeps ✅ Passed No fixed sleeps or race-covering delays were added; timers are bounded request/stream timeouts and microtask callbacks for lifecycle events, with tests.
Cmux Swift Concurrency ✅ Passed The diff only changes a TypeScript file; no Swift-owned code or concurrency patterns are introduced, so the Swift modernization check is not applicable.
Cmux Swift @Concurrent ✅ Passed No Swift files are in the PR diff; it only changes TypeScript bindings/docs/tests, so the Swift concurrency rule is not applicable.
Cmux Swift File And Package Boundaries ✅ Passed No Swift sources or Swift package files were changed; the Swift boundary rule is not applicable here.
Cmux Swiftpm Lockfiles ✅ Passed Diff only updates vendored submodule vendor/bonsplit; no cmux-owned Package.swift/.resolved/Xcode/workflow changes, and the vendor .gitignore still ignores Package.resolved.
Cmux Swift Logging ✅ Passed No Swift production files changed; the diff is TypeScript/docs only, so the Swift logging rules aren’t implicated.
Cmux Full Internationalization ✅ Passed PASS: The only real patch is a vendored Swift config tweak for divider hit-testing; it adds no user-facing copy or locale-specific assets.
Cmux Swiftui State Layout ✅ Passed PASS: The diff only changes cmux-tui/bindings/typescript/src/client.ts; no SwiftUI views, layout, or observation state changes are present, so the rule is not applicable.
Cmux Architecture Rethink ✅ Passed The Swift submodule change is a local divider-hit expansion fix with one clear owner/invariant, and it adds no timing, observer, lock, or split-lifecycle workaround patterns.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PASS: the diff only touches TypeScript bindings/docs/tests; no .swift files changed, so the Swift auxiliary-window close-shortcut rule is not applicable.
Cmux Source Artifacts ✅ Passed PASS: The only changed path is cmux-tui/bindings/typescript/src/client.ts, and the patch only adjusts source typings/comments—no logs, caches, build output, or temp/artifact paths were added.
Cmux No Test Or Debug Seam In Production Source ✅ Passed The PR only changes a TypeScript file; no Swift files under any production Sources path are touched, so the seam rule is not applicable.
Cmux No Ambient Global State ✅ Passed PR changes are TypeScript/docs only; no Swift production files or ambient globals were added.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ts-frontend-sdk

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 11, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR turns the TypeScript binding into a typed SDK for cmux frontends. The main changes are:

  • Typed commands, responses, events, layouts, and attach payloads.
  • Pluggable Unix-socket and WebSocket transports.
  • Browser-safe and Node-specific package entry points.
  • Transport-independent request routing and streaming APIs.
  • Expanded SDK tests and usage documentation.

Confidence Score: 5/5

This looks safe to merge.

  • No additional blocking issue was found in the updated code.
  • The remaining candidate cases either duplicate existing findings or do not require a separate fix.

Important Files Changed

Filename Overview
cmux-tui/bindings/typescript/src/client.ts Adds transport-independent request routing, typed command wrappers, decoded attach streams, and push-based event handling.
cmux-tui/bindings/typescript/src/protocol/commands.ts Defines the typed request and response contracts for the command surface.
cmux-tui/bindings/typescript/src/node-transport.ts Adds the Node Unix-socket JSON-lines transport.
cmux-tui/bindings/typescript/src/websocket-transport.ts Adds an injectable WebSocket transport for browsers and Node.
cmux-tui/bindings/typescript/package.json Adds ESM conditional exports for browser, Node, and root package entry points.

Reviews (2): Last reviewed commit: "ts sdk: require params in the convenienc..." | Re-trigger Greptile

Comment on lines +475 to +478
case "resized": {
if (typeof event.replay !== "string") throw new Error("resized replay is not base64 text");
return { ...event, replay: decodeBase64(event.replay) } as DecodedAttachEvent;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Resize Event Uses Wrong Field

The server sends resized attach payloads in data, but this branch reads replay. A normal surface resize therefore throws resized replay is not base64 text, fails the attach stream, and drops later terminal output.

Suggested change
case "resized": {
if (typeof event.replay !== "string") throw new Error("resized replay is not base64 text");
return { ...event, replay: decodeBase64(event.replay) } as DecodedAttachEvent;
}
case "resized": {
if (typeof event.data !== "string") throw new Error("resized data is not base64 text");
return { ...event, data: decodeBase64(event.data) } as DecodedAttachEvent;
}

Comment on lines +208 to +213
export interface WaitForRequest extends CmuxRequestBase {
cmd: "wait-for";
surface: IdRef;
pattern: string;
/** `0` performs one immediate check. */
timeout_ms: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 String Surface IDs Are Rejected

This type accepts a string IdRef, but the current server deserializes wait-for.surface as a numeric SurfaceId. Calls such as waitFor("surface:1", ...) compile and then fail as bad requests; the same unsupported string shape is exposed for run.pane, notify.surface, list-agents.surface, and report-agent.surface.

return;
}

const key = object.id === undefined ? this.pending.keys().next().value : this.idKey(object.id as Json);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Idless Responses Cross Request Boundaries

When two requests are pending and a response omits id, this fallback assigns it to the oldest map entry rather than its originating command. An out-of-order response can give one caller the wrong result while the intended caller times out; idless responses should only be accepted when exactly one request is pending.

Comment on lines 192 to +200
async next(timeoutMs = this.timeoutMs): Promise<T> {
if (this.closed) throw new CmuxConnectionError("stream is closed");
if (this.buffered.length > 0) return this.buffered.shift()!;
for (;;) {
const value = await this.conn.recv(timeoutMs);
if (typeof value.event !== "string") continue;
const event = value as T;
if (event.event === "detached") this.close();
if (this.buffered.length > 0) {
const event = this.buffered.shift()!;
if (this.endsAfterDrain && this.buffered.length === 0) this.finish();
return event;
}
if (this.closed) throw new CmuxConnectionError("stream is closed");

const waiter: StreamWaiter<T> = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Terminal Stream Can Wait Again

If a terminal event is delivered directly to an existing waiter, endsAfterDrain becomes true with an empty buffer, but the stream is not finished. The next next() call registers another waiter and eventually reports a timeout even though the server already ended the stream.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8e4135a. Configure here.

this.waiters.push(waiter);
});
if (this.endsAfterDrain && this.buffered.length === 0) this.finish();
return event;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Events after detached attach

Medium Severity

attachSurface marks detached as terminal but only closes the stream after the internal buffer drains. If another attach event is pushed in the same transport read (or was already queued behind detached), next() can still yield it after detached, unlike the prior client which stopped the attach connection on detached.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8e4135a. Configure here.

@lawrencecchen
lawrencecchen merged commit 272aee3 into main Jul 11, 2026
29 of 31 checks passed

@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: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmux-tui/bindings/typescript/src/client.ts`:
- Around line 192-222: Decouple async iteration from the request timeout: update
next() so an omitted timeout skips timer creation rather than defaulting to
this.timeoutMs, while preserving bounded waits when next(ms) is called
explicitly. Update [Symbol.asyncIterator] to call next() without a timeout so
idle subscribe() and attachSurface() streams remain open indefinitely, and
ensure waiter resolution/rejection still clears any timer when one exists.
- Around line 181-266: Replace the O(n) buffered-array dequeue in
CmuxStream.next with a head-index-based queue or small ring buffer, eliminating
buffered.shift() while preserving FIFO ordering and existing drain/finish
behavior. Update push and any empty-queue checks to remain correct as events are
consumed and buffered.
- Around line 286-288: Update the client close lifecycle to track and clean up
streams using dedicated transports. Add an open-stream collection, register each
stream created by openStream(), and remove it from that collection inside the
stream cleanup callback; have close() clean up all tracked streams before
closing the main transport.
- Around line 66-73: The SendOptions declaration manually duplicates protocol
fields; replace it with a type derived from CmuxRequestParams for the "send"
command, matching the existing NewTabOptions and SplitOptions aliases and
preserving the send protocol’s current field behavior.
- Around line 138-151: Update the pending-response handling in the client
message-processing method so messages without an id use the implicit
pending-request path only when exactly one request is in flight. If zero or
multiple entries exist, fail closed by rejecting the message as a protocol error
instead of selecting the first pending key; keep explicit-id responses
unchanged.

In `@cmux-tui/bindings/typescript/src/node-transport.ts`:
- Around line 66-68: Update close() to invoke finish() synchronously before or
as part of destroying the socket, ensuring this.closed is set immediately and
pending sends are handled consistently. Preserve the existing guard against
repeated closes and socket cleanup behavior.

In `@cmux-tui/bindings/typescript/src/websocket-transport.ts`:
- Around line 105-108: Update the private flush() method to handle exceptions
from socket.send() by catching the failure and routing it through fail(),
ensuring errorHandlers are notified. Avoid removing a queued message until its
send succeeds, so messages remain available if sending fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 100f525a-b78d-4c4e-9393-7a543733fae0

📥 Commits

Reviewing files that changed from the base of the PR and between 90b462a and 8e4135a.

📒 Files selected for processing (23)
  • cmux-tui/bindings/typescript/README.md
  • cmux-tui/bindings/typescript/package.json
  • cmux-tui/bindings/typescript/src/base64.ts
  • cmux-tui/bindings/typescript/src/browser.ts
  • cmux-tui/bindings/typescript/src/client.ts
  • cmux-tui/bindings/typescript/src/index.ts
  • cmux-tui/bindings/typescript/src/node-client.ts
  • cmux-tui/bindings/typescript/src/node-transport.ts
  • cmux-tui/bindings/typescript/src/protocol/commands.ts
  • cmux-tui/bindings/typescript/src/protocol/common.ts
  • cmux-tui/bindings/typescript/src/protocol/events.ts
  • cmux-tui/bindings/typescript/src/protocol/index.ts
  • cmux-tui/bindings/typescript/src/protocol/tree.ts
  • cmux-tui/bindings/typescript/src/transport.ts
  • cmux-tui/bindings/typescript/src/types.ts
  • cmux-tui/bindings/typescript/src/websocket-transport.ts
  • cmux-tui/bindings/typescript/test/base64.test.ts
  • cmux-tui/bindings/typescript/test/client.test.ts
  • cmux-tui/bindings/typescript/test/protocol-types.ts
  • cmux-tui/bindings/typescript/test/unix-transport.test.ts
  • cmux-tui/bindings/typescript/test/websocket-transport.test.ts
  • cmux-tui/bindings/typescript/tsconfig.json
  • cmux-tui/spec/bindings.md

Comment on lines +66 to +73
export type NewTabOptions = CmuxRequestParams<"new-tab">;
export type NewBrowserTabOptions = Omit<CmuxRequestParams<"new-browser-tab">, "url">;
export type NewWorkspaceOptions = CmuxRequestParams<"new-workspace">;
export type NewScreenOptions = CmuxRequestParams<"new-screen">;
export type SplitOptions = Omit<CmuxRequestParams<"split">, "pane" | "dir">;
export type SelectOptions = CmuxRequestParams<"select-screen">;
export type SelectTabOptions = CmuxRequestParams<"select-tab">;
export interface SendOptions { text?: string | null; bytes?: string | Uint8Array | null }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

SendOptions hand-duplicates fields instead of deriving from the protocol type.

Every sibling options alias (NewTabOptions, SplitOptions, etc.) derives from CmuxRequestParams<C>, keeping a single source of truth with the generated protocol. SendOptions is hand-written and can silently drift if the send command's params change.

♻️ Proposed fix
-export interface SendOptions { text?: string | null; bytes?: string | Uint8Array | null }
+export type SendOptions = Omit<CmuxRequestParams<"send">, "surface" | "bytes"> & {
+  bytes?: string | Uint8Array | null;
+};
📝 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
export type NewTabOptions = CmuxRequestParams<"new-tab">;
export type NewBrowserTabOptions = Omit<CmuxRequestParams<"new-browser-tab">, "url">;
export type NewWorkspaceOptions = CmuxRequestParams<"new-workspace">;
export type NewScreenOptions = CmuxRequestParams<"new-screen">;
export type SplitOptions = Omit<CmuxRequestParams<"split">, "pane" | "dir">;
export type SelectOptions = CmuxRequestParams<"select-screen">;
export type SelectTabOptions = CmuxRequestParams<"select-tab">;
export interface SendOptions { text?: string | null; bytes?: string | Uint8Array | null }
export type NewTabOptions = CmuxRequestParams<"new-tab">;
export type NewBrowserTabOptions = Omit<CmuxRequestParams<"new-browser-tab">, "url">;
export type NewWorkspaceOptions = CmuxRequestParams<"new-workspace">;
export type NewScreenOptions = CmuxRequestParams<"new-screen">;
export type SplitOptions = Omit<CmuxRequestParams<"split">, "pane" | "dir">;
export type SelectOptions = CmuxRequestParams<"select-screen">;
export type SelectTabOptions = CmuxRequestParams<"select-tab">;
export type SendOptions = Omit<CmuxRequestParams<"send">, "surface" | "bytes"> & {
bytes?: string | Uint8Array | null;
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmux-tui/bindings/typescript/src/client.ts` around lines 66 - 73, The
SendOptions declaration manually duplicates protocol fields; replace it with a
type derived from CmuxRequestParams for the "send" command, matching the
existing NewTabOptions and SplitOptions aliases and preserving the send
protocol’s current field behavior.

Comment on lines +138 to 151
const object = value as Record<string, unknown>;
if (typeof object.event === "string") {
for (const handler of this.eventHandlers) handler(object as UnknownEvent);
return;
}

const key = object.id === undefined ? this.pending.keys().next().value : this.idKey(object.id as Json);
if (key === undefined) return;
const pending = this.pending.get(key);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(key);
pending.resolve(object as unknown as CmuxResponse<unknown>);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd bindings.md cmux-tui/spec
rg -n -i -A3 -B3 '\bid\b' cmux-tui/spec/bindings.md | head -100

Repository: manaflow-ai/cmux

Length of output: 3741


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== client.ts around receive =="
sed -n '120,165p' cmux-tui/bindings/typescript/src/client.ts

echo
echo "== protocol spec around commands/events =="
sed -n '1,220p' cmux-tui/spec/bindings.md

echo
echo "== searches for id-less / optional id / response shape =="
rg -n -i 'optional command id|command id|response|event discriminator|id\\s*:' cmux-tui/spec/bindings.md cmux-tui/bindings/typescript/src/client.ts

Repository: manaflow-ai/cmux

Length of output: 15791


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== spec tail around binding suite requirements =="
sed -n '260,300p' cmux-tui/spec/bindings.md

echo
echo "== response and command id shapes in TypeScript bindings =="
rg -n -A2 -B2 '"id"|id\?:|id:' cmux-tui/bindings/typescript/src cmux-tui/spec -g '!**/node_modules/**'

Repository: manaflow-ai/cmux

Length of output: 27296


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate TypeScript protocol types =="
fd -a 'protocol*.ts' cmux-tui/bindings/typescript/src

echo
echo "== search for optional response id fields =="
rg -n -A3 -B3 'interface .*Response|type .*Response|id\?:|id:' cmux-tui/bindings/typescript/src

Repository: manaflow-ai/cmux

Length of output: 10174


Fail closed on messages without id.
cmux-tui/bindings/typescript/src/client.ts:138-151 routes them to this.pending.keys().next().value, which can resolve the wrong in-flight request when more than one call is pending. Only accept this path when there is exactly one pending request; otherwise reject it as a protocol error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmux-tui/bindings/typescript/src/client.ts` around lines 138 - 151, Update
the pending-response handling in the client message-processing method so
messages without an id use the implicit pending-request path only when exactly
one request is in flight. If zero or multiple entries exist, fail closed by
rejecting the message as a protocol error instead of selecting the first pending
key; keep explicit-id responses unchanged.

Comment on lines +181 to 266
export class CmuxStream<T extends { event: string }> implements AsyncIterable<T> {
private readonly buffered: T[] = [];
private readonly waiters: StreamWaiter<T>[] = [];
private closed = false;
private endsAfterDrain = false;

private constructor(
private readonly conn: JsonLineConnection,
constructor(
private readonly timeoutMs: number,
buffered: T[],
) {
this.buffered = buffered;
}

static async open<T extends EventObject>(
socketPath: string,
timeoutMs: number,
request: JsonObject,
): Promise<CmuxStream<T>> {
const conn = await JsonLineConnection.connect(socketPath);
await conn.send(request);
const requestId = request.id;
const buffered: T[] = [];
for (;;) {
const value = await conn.recv(timeoutMs);
if (typeof value.event === "string") {
buffered.push(value as T);
continue;
}
if (value.id !== requestId) continue;
const response = value as ResponseEnvelope;
if (response.ok === true) return new CmuxStream(conn, timeoutMs, buffered);
throw new CmuxCommandError(response.error || "unknown error", response.id, response);
}
}
private readonly cleanup: () => void,
) {}

async next(timeoutMs = this.timeoutMs): Promise<T> {
if (this.closed) throw new CmuxConnectionError("stream is closed");
if (this.buffered.length > 0) return this.buffered.shift()!;
for (;;) {
const value = await this.conn.recv(timeoutMs);
if (typeof value.event !== "string") continue;
const event = value as T;
if (event.event === "detached") this.close();
if (this.buffered.length > 0) {
const event = this.buffered.shift()!;
if (this.endsAfterDrain && this.buffered.length === 0) this.finish();
return event;
}
if (this.closed) throw new CmuxConnectionError("stream is closed");

const waiter: StreamWaiter<T> = {
active: true,
resolve: () => undefined,
reject: () => undefined,
};
const event = await new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
waiter.active = false;
reject(new CmuxTimeoutError("stream did not produce an event"));
}, timeoutMs);
waiter.resolve = (value) => {
clearTimeout(timer);
resolve(value);
};
waiter.reject = (error) => {
clearTimeout(timer);
reject(error);
};
this.waiters.push(waiter);
});
if (this.endsAfterDrain && this.buffered.length === 0) this.finish();
return event;
}

close(): void {
if (!this.closed) {
this.closed = true;
this.conn.close();
if (this.closed) return;
this.finish();
this.rejectWaiters(new CmuxConnectionError("stream is closed"));
}

push(event: T, terminal = false): void {
if (this.closed) return;
let delivered = false;
while (this.waiters.length > 0) {
const waiter = this.waiters.shift()!;
if (!waiter.active) continue;
waiter.resolve(event);
delivered = true;
break;
}
if (!delivered) this.buffered.push(event);
if (terminal) this.endsAfterDrain = true;
}

fail(error: Error): void {
if (this.closed) return;
this.finish();
this.rejectWaiters(error);
}

async *[Symbol.asyncIterator](): AsyncIterator<T> {
while (!this.closed) {
yield await this.next();
while (!this.closed) yield await this.next();
}

private finish(): void {
if (this.closed) return;
this.closed = true;
this.cleanup();
}

private rejectWaiters(error: Error): void {
while (this.waiters.length > 0) {
const waiter = this.waiters.shift()!;
if (waiter.active) waiter.reject(error);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Array-based FIFO queue in CmuxStream uses O(n) .shift() per dequeue.

buffered is a plain array drained with .shift(). For a high-throughput attachSurface() stream (rapid output events) where the consumer lags the producer, this becomes an O(n²)-style drain cost as the guideline on avoiding unbenchmarked slower algorithms in hot paths warns against.

Consider a head-index-based dequeue (or a small ring buffer) instead of Array.shift() for buffered.
As per path instructions, "flag nested full-collection scans, per-target rescans for batch actions, repeated sort/filter/map work in hot paths... and unbenchmarked algorithm choices for paths expected to handle roughly 1000 workspaces or similar records."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmux-tui/bindings/typescript/src/client.ts` around lines 181 - 266, Replace
the O(n) buffered-array dequeue in CmuxStream.next with a head-index-based queue
or small ring buffer, eliminating buffered.shift() while preserving FIFO
ordering and existing drain/finish behavior. Update push and any empty-queue
checks to remain correct as events are consumed and buffered.

Source: Path instructions

Comment on lines 192 to 222
async next(timeoutMs = this.timeoutMs): Promise<T> {
if (this.closed) throw new CmuxConnectionError("stream is closed");
if (this.buffered.length > 0) return this.buffered.shift()!;
for (;;) {
const value = await this.conn.recv(timeoutMs);
if (typeof value.event !== "string") continue;
const event = value as T;
if (event.event === "detached") this.close();
if (this.buffered.length > 0) {
const event = this.buffered.shift()!;
if (this.endsAfterDrain && this.buffered.length === 0) this.finish();
return event;
}
if (this.closed) throw new CmuxConnectionError("stream is closed");

const waiter: StreamWaiter<T> = {
active: true,
resolve: () => undefined,
reject: () => undefined,
};
const event = await new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
waiter.active = false;
reject(new CmuxTimeoutError("stream did not produce an event"));
}, timeoutMs);
waiter.resolve = (value) => {
clearTimeout(timer);
resolve(value);
};
waiter.reject = (error) => {
clearTimeout(timer);
reject(error);
};
this.waiters.push(waiter);
});
if (this.endsAfterDrain && this.buffered.length === 0) this.finish();
return event;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

for await on subscribe()/attachSurface() throws after any idle gap.

[Symbol.asyncIterator] calls this.next() with no argument, so it always uses the request timeoutMs (default 10s) as the per-read timeout. Any legitimate idle period (sparse subscribe events, quiet terminal) longer than that throws CmuxTimeoutError and ends the iteration — this breaks the primary intended usage of the new typed streaming API. Note a naive fix of passing Infinity/Number.POSITIVE_INFINITY into next() would backfire: Node's setTimeout clamps out-of-range/non-finite delays to ~1ms, so the timer would fire almost immediately instead of never.

Recommend decoupling the iterator's idle-read timeout from the request timeoutMs (e.g. skip creating the timer entirely in next() when no explicit timeout is requested, and have [Symbol.asyncIterator] opt into "no timeout" by default), leaving next(ms) available for callers who want a bounded wait.

Also applies to: 250-252

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmux-tui/bindings/typescript/src/client.ts` around lines 192 - 222, Decouple
async iteration from the request timeout: update next() so an omitted timeout
skips timer creation rather than defaulting to this.timeoutMs, while preserving
bounded waits when next(ms) is called explicitly. Update [Symbol.asyncIterator]
to call next() without a timeout so idle subscribe() and attachSurface() streams
remain open indefinitely, and ensure waiter resolution/rejection still clears
any timer when one exists.

Comment on lines 286 to 288
async close(): Promise<void> {
const conn = await this.connPromise;
conn.close();
this.transport.close();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

close() doesn't close dedicated stream transports, leaking open sockets.

streamTransportFactory-created transports (used by subscribe()/attachSurface() when configured) are only closed via each CmuxStream's own cleanup() — triggered by the stream's own close()/fail()/drain. If a caller calls client.close() while a stream created through a dedicated transport is still open, that transport is never closed.

🛠️ Proposed fix: track open streams for cleanup on client close
   private readonly streamTransportFactory?: () => Transport;
+  private readonly openStreams = new Set<{ close(): void }>();
   private nextRequestId = 1;
   ...
   async close(): Promise<void> {
+    for (const stream of this.openStreams) stream.close();
     this.transport.close();
   }

And in openStream(), add/remove stream from this.openStreams on creation and inside the cleanup callback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmux-tui/bindings/typescript/src/client.ts` around lines 286 - 288, Update
the client close lifecycle to track and clean up streams using dedicated
transports. Add an open-stream collection, register each stream created by
openStream(), and remove it from that collection inside the stream cleanup
callback; have close() clean up all tracked streams before closing the main
transport.

Comment on lines +66 to +68
close(): void {
if (!this.closed) this.socket.destroy();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

close() doesn't set closed synchronously, allowing send() to succeed after close.

close() calls socket.destroy() but relies on the socket's asynchronous close event to set this.closed = true via finish(). Between close() and that event, send() checks this.closed (still false) and either writes to a destroyed socket or silently queues to pending — which finish() later clears without error. Calling this.finish() from close() sets closed synchronously so send() throws immediately.

🔧 Proposed fix
 close(): void {
-  if (!this.closed) this.socket.destroy();
+  if (this.closed) return;
+  this.socket.destroy();
+  this.finish();
 }
📝 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
close(): void {
if (!this.closed) this.socket.destroy();
}
close(): void {
if (this.closed) return;
this.socket.destroy();
this.finish();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmux-tui/bindings/typescript/src/node-transport.ts` around lines 66 - 68,
Update close() to invoke finish() synchronously before or as part of destroying
the socket, ensuring this.closed is set immediately and pending sends are
handled consistently. Preserve the existing guard against repeated closes and
socket cleanup behavior.

Comment on lines +105 to +108
private flush(): void {
if (this.closed) return;
while (this.pending.length > 0) this.socket.send(this.pending.shift()!);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unhandled exception in flush() can silently lose queued messages.

flush() is called from the "open" event handler (line 51). If this.socket.send() throws — e.g., the socket closes between the open event and the send call — the exception propagates unhandled through the event listener, errorHandlers are never notified, and messages already shifted from this.pending are permanently lost.

🔒 Proposed fix: wrap flush in try-catch and route to fail()
 private flush(): void {
   if (this.closed) return;
-  while (this.pending.length > 0) this.socket.send(this.pending.shift()!);
+  while (this.pending.length > 0) {
+    try {
+      this.socket.send(this.pending.shift()!);
+    } catch (error) {
+      this.fail(error instanceof Error ? error : new Error(String(error)));
+      return;
+    }
+  }
 }
📝 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
private flush(): void {
if (this.closed) return;
while (this.pending.length > 0) this.socket.send(this.pending.shift()!);
}
private flush(): void {
if (this.closed) return;
while (this.pending.length > 0) {
try {
this.socket.send(this.pending.shift()!);
} catch (error) {
this.fail(error instanceof Error ? error : new Error(String(error)));
return;
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmux-tui/bindings/typescript/src/websocket-transport.ts` around lines 105 -
108, Update the private flush() method to handle exceptions from socket.send()
by catching the failure and routing it through fail(), ensuring errorHandlers
are notified. Avoid removing a queued message until its send succeeds, so
messages remain available if sending fails.

This branch was successfully deployed

1 active deployment
Preview – cmux — 45c299f0 Deployed Jul 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant