-
Notifications
You must be signed in to change notification settings - Fork 3
Domains Mux
The mux domain is the pane multiplexing layer that Clio uses to talk to a herdr terminal pane-host server over a Unix domain socket. It owns three concerns: a newline-delimited-JSON socket client that is the only module in the tree that reads herdr wire shapes, a dock controller that manages the geometry of Clio's own docked panes (the workers-view and the files pane), and a Yazi file-manager integration that opens and tracks a Yazi instance in a herdr pane. The domain enforces a strict ownership rule: every mutating method in the contract checks the pane registry before touching a pane, so Clio never closes, renames, focuses, reports state on, or sends input to a pane it did not create.
The domain entry point is createMuxDomainModule in
src/domains/mux/index.ts, which returns a DomainModule whose createExtension calls
createMuxBundle in src/domains/mux/extension.ts. The extension runs the detection ladder
once at boot, builds a MuxClient from the socket that answers a ping, and hands both to
createMuxRuntime in src/domains/mux/contract.ts. The runtime exposes a MuxContract
(the cross-domain surface) plus start/stop lifecycle handles that the domain extension
drives.
The runtime is composed only when src/entry/panes-activation.ts resolves the activation
ladder to an active rung. The orchestrator dynamically imports src/entry/with-panes.ts
(which statically re-exports createMuxDomainModule and the interactive glue) only then; a
plain clio-coder boot never loads any mux code. A built-graph contract test
(tests/contracts/instant-shell-import-graph.test.ts) pins that the default boot chunk
carries no mux domain code.
createPaneRegistry in src/domains/mux/pane-registry.ts holds an insertion-ordered Map
of MuxPaneRecord entries keyed by pane id. Every mutating path in contract.ts asks the
registry first:
-
closePanecallsregistry.owns(paneId)and returnsfalseif the registry does not hold the pane. -
focusPane,zoomPane, andclosePaneall checkregistry.ownsbefore touching the wire. -
reportSelfwrites to Clio's own hosting pane without a registry check because that pane is Clio's own, not one it created for the user.
The registry is fed by openUtilityPane (which calls registry.record after a successful
split), by adoptPane (which records a pane carried over from a previous session), and by
forget/reconcile on pane.closed/pane.exited events and after a reconnect. The
pane.moved event is handled specially: herdr rewrites the pane id on a move, so the
contract's onEvent handler forgets the old id and re-records the new one, preserving
ownership across the user's reorganization.
detectMux in src/domains/mux/detect.ts implements the three-rung capability ladder:
-
off:HERDR_ENVis not set or the user choseoff. No file descriptor is opened. -
embedded: refused asnonewithrefused: truebecause embedded pane hosting is not implemented. The detection carries the reason "embedded pane hosting is not implemented; use auto or guest". -
guest: requiresHERDR_ENV=1, a connectable socket, and apinganswered inside one second. Socket candidates are resolved in the orderHERDR_SOCKET_PATH, then$XDG_CONFIG_HOME/herdr/sessions/$HERDR_SESSION/herdr.sock, then$XDG_CONFIG_HOME/herdr/herdr.sock.
The MuxDetection result carries self (Clio's own pane location from
HERDR_WORKSPACE_ID/HERDR_TAB_ID/HERDR_PANE_ID) and the live MuxClient from the
ping, so the caller inherits a warm socket rather than reconnecting.
createMuxClient in src/domains/mux/socket-client.ts is the only module that reads herdr
wire shapes. It exposes a MuxClient interface covering discovery (ping, snapshot,
paneCurrent, paneList), pane control (paneSplit, paneClose, paneRename,
paneLayout, paneFocus, paneZoom, paneSendText, paneReportAgent,
paneReportMetadata), layouts (layoutExport, layoutSetSplitRatio), worktrees
(worktreeList, worktreeCreate, worktreeOpen, worktreeRemove), and notifications
(notificationShow).
The client uses two connection kinds:
-
One connection per request. herdr's
handle_connectionreads exactly one request line, writes the response, and closes. Each call gets a monotonic id and checks the echoed id. A call with no response inside its budget rejects withMuxRequestTimeout. Connect failures back off with a capped exponential delay (DEFAULT_BACKOFF: 250ms initial, 5000ms max, factor 2). -
One dedicated connection per
events.subscribestream. herdr acknowledges once and then pushes event lines forever. After a subscription reconnect the client refetchessession.snapshotand hands it to the resync handler.
All wire shapes are mapped into the Clio types from types.ts by reader functions
(readPane, readTab, readSnapshot, readTabGeometry, readLayoutTree, etc.) that
throw MuxError("protocol", …) on missing required fields. Unknown fields are ignored,
which is herdr's stated forward-compatibility rule.
protocol.ts defines MUX_METHOD_MIN_PROTOCOL: a record mapping gated wire methods to
the minimum herdr protocol version that supports them. Notification, pane-control, layout,
and worktree methods require protocol 17; worktree methods require protocol 10.
muxSupportsMethod returns false for a null server (detection never completed a
handshake), which is the none rung. The contract checks floors before calling gated
methods: focusPane requires pane.focus, notify requires notification.show,
worktreeCreate requires worktree.create, etc. Below the floor the method degrades to
the documented fallback (e.g. null for worktreeCreate, false for worktreeRemove).
types.ts defines MuxErrorKind and muxErrorKind to classify server error codes into
Clio failure kinds. The mapping handles agent_blocked, agent_prompt_stalled,
feature_disabled, invalid_params (including invalid_request), and not_found, with
suffix and prefix fallbacks. Anything unmatched stays unknown, carrying the server's raw
code through untouched.
createDockController in src/domains/mux/dock-controller.ts owns the geometry and
lifecycle of Clio's two managed dock slots: workers (to the right of the anchor) and
files (below it). The controller owns geometry only; ownership stays in the pane
registry and error swallowing stays in the contract's attempt wrapper.
DOCK_SPECS is a fixed record:
| Slot | Direction | Default share | Min cells |
|---|---|---|---|
workers |
right |
0.34 | 48 |
files |
down |
0.3 | 12 |
DOCK_MAX_SHARE is 0.5: a dock may never take more than half the axis, whatever the share
asks. SHARE_EPSILON is 0.02: observed-vs-applied share differences below this are
rounding, not a user drag.
Three rules run through the reconciliation:
-
A user action is a decision. A resize observed via
layout.updatedthat does not match what Clio last applied becomes the new target. A closed dock stays closed. A moved dock is followed to its new id. -
Clio's own corrections must not read as user actions. Every applied share is remembered (
lastAppliedShare) and an observation matching it (or the target) withinSHARE_EPSILONis a no-op. -
Opening never steals focus.
pane.splitis always called withfocus: false. Focus and zoom live on the contract and only ever run on explicit request.
open first checks if the slot already has a pane (idempotence). If not, it reads the
anchor's rect from paneLayout, calls planDockOpen to decide whether the dock fits and
with what split ratio, and then calls paneSplit with targetPaneId set to the anchor.
After the split, one converge pass re-reads the layout and applies a corrected ratio if
the dock landed below its cell floor. Refusal happens in planDockOpen before any split
reaches the wire, so a too-small terminal never flashes a sliver pane.
The contract's onEvent handler feeds the dock controller:
-
layout.updated:docks.noteLayoutUpdated(geometry)updates target shares. -
pane.moved:docks.notePaneMoved(previousPaneId, paneId, tabId)follows the dock to its new id. -
pane.closed/pane.exited:docks.notePaneGone(paneId)removes the dock state.
The Yazi file manager runs in a herdr pane as a "companion" (persistent, follows Clio's
cwd) or a "chooser" (one-shot selection). The integration lives in
src/domains/mux/yazi/:
-
session.ts:createYaziSessionopens a Yazi process in a mux pane. It resolves theyaziandyabinaries through the toolchain ladder, generates a managed profile if requested, creates transport files (.stream,.chooser,.cwd) under<cache>/yazi/sessions/, and callsmux.openUtilityPanewith the Yazi argv. In companion mode it starts aYaziEventStreamthat polls the stream file forcdandclio-coder-pickDDS events. In chooser mode it polls the chooser file for paths. Transport files are removed on close and swept if stale (older than 24 hours). -
profile.ts:ensureYaziProfilematerializes a deterministic Yazi profile (yazi.toml, keymap.toml, theme.toml, init.lua, and a vendoredgit.yaziplugin) under<cache>/yazi/profile/. The profile is stamped with yazi version, Clio version, ya path, and SHA-256 hashes of the asset tree and theme. Regeneration happens only when the stamp changes. A scratch HOME is used to validate the profile withyazi --debugbefore promoting it. -
event-stream.ts:createYaziEventStreamis a poll-tail reader for a bounded DDS stdout file. It parsescdevents (which carry the new cwd and the Yazi instance id) andclio-coder-pickevents (which carry a list of paths). The stream stops onpane-gone,file-missing, orsize-cap(default 1 MiB). -
theme.ts:renderYaziThemeandrenderHerdrThemeBlockgenerate TOML theme blocks from Clio's color tokens.
The createYaziSession function uses dock: { slot: "files" } when calling
mux.openUtilityPane, which routes through the dock controller. Below the layout tier
(protocol < 17) the dock degrades to a plain split.
The end-to-end flow for /panes open shell:
sequenceDiagram
participant Operator
participant SlashCmd as slash-commands.ts
participant PanesRT as panes-runtime.ts
participant Mux as MuxContract (contract.ts)
participant Registry as MuxPaneRegistry
participant Docks as DockController
participant Client as MuxClient (socket-client.ts)
Operator->>SlashCmd: /panes open shell
SlashCmd->>PanesRT: open({ preset: "shell" })
PanesRT->>PanesRT: resolveBinaryPath("bash") → /bin/bash
PanesRT->>Mux: openUtilityPane({ argv: ["/bin/bash", "-l"], cwd, label: "shell" })
Mux->>Mux: attempt("openUtilityPane", ...)
Mux->>Client: paneSplit({ direction: "right", targetPaneId: anchor, cwd, focus: false })
Client-->>Mux: MuxPane { paneId: "p1", ... }
Mux->>Registry: record(paneRecord({ paneId: "p1", ... }, { purpose: "utility", label: "shell" }))
Mux->>Client: paneReportMetadata({ paneId: "p1", tokens: { clio_coder_owner: "clio-coder:mux", role: "utility" } })
Mux->>Client: paneSendText({ paneId: "p1", text: "exec '/bin/bash' '-l'\n" })
Mux-->>PanesRT: { paneId: "p1", tabId: "t1", workspaceId: "w1" }
PanesRT-->>SlashCmd: { status: "opened", label: "shell", paneId: "p1" }
SlashCmd-->>Operator: opened the shell pane (p1).
Key points in the flow:
-
Preset probing before split:
panes-runtime.tsprobes the binary through the toolchain ladder before callingopenUtilityPane. A missing binary returns amissing-binaryresult without ever splitting a pane. -
Shell-quoting:
contract.tsshell-quotes each argv element with POSIX single-quoting ('…'with'escaped as'\'') and sends the command viapaneSendTextasexec <argv>\n. Theexecreplaces the shell so the pane exits with the program and emitspane.exitedfor reconciliation. -
Owner token: every Clio-created pane carries
clio_coder_owner: clio-coder:muxandrole: <purpose>metadata tokens, soadoptPanecan find surviving panes after a restart andclio-coder doctorcan find orphans. -
Best-effort contract: the
attemptwrapper catches all errors and returns the fallback value. A mux failure never fails a dispatch.
Every mutating method in contract.ts begins with an ownership check:
// closePane
if (!registry.owns(paneId)) return false;
// focusPane
if (!registry.owns(paneId)) return false;
// zoomPane
if (!registry.owns(paneId)) return false;The only documented exception is reportSelf, which writes to Clio's own hosting pane
(detection.self.paneId) without a registry check. This is the pane Clio runs in, not
one Clio created for the user.
-
Detection (
detectMux) runs once at boot, before any panes module loads. -
Runtime creation (
createMuxRuntime) builds the registry, dock controller (if protocol ≥ 17 and an anchor pane exists), and the contract object. -
Subscription (
runtime.start) subscribes topane.closed,pane.exited, and—if docks are active—pane.movedandlayout.updated. TheonResynchandler reconciles the registry against a fresh snapshot after any reconnect. -
Shutdown (
runtime.stop) closes the subscription, clears handlers, closes all dock panes, and closes the client. Docks close with the session; unmanaged utility panes stay open (policy #272).
The attempt wrapper tracks health: a transport or timeout error sets healthy = false
and a cooldown (DEGRADE_COOLDOWN_MS = 5000ms) before probing again. The usable()
predicate checks stopped, client !== null, detection.mode !== "none", and the
health/cooldown state. Below the cooldown, all methods return their fallback without
touching the socket.
Presets are defined in PANES_PRESETS in src/domains/mux/operations.ts. To add one:
- Append an entry to
PANES_PRESETSwithid,binary,summary, andinstallHint. - The
PanesPresetIdtype andPANES_PRESET_IDSderive automatically. -
panes-runtime.tspresetArgvneeds a branch for the new preset (forshellit returns[binaryPath, "-l"]; forlogsit returns[binaryPath, "-n", "200", "-F", journalPath]; the new preset returns its argv). -
panes-tool-surface.tsautomatically picks up the new id in thepresetenum.
To gate a new herdr method on a protocol floor:
- Add the method name to
MuxGatedMethodinsrc/domains/mux/protocol.ts. - Add the floor to
MUX_METHOD_MIN_PROTOCOL. - Check
muxSupportsMethod(detection.server, "<method>")in the contract before calling.
To add a third dock slot:
- Add an entry to
DOCK_SPECSindock-controller.tswithslot,direction,defaultShare, andminCells. - The
DockSlottype andbySlotmap derive from the record key. -
contract.tsopenUtilityPaneroutesrequest.dock.slottodocks.open.
The Yazi profile is regenerated only when the stamp changes. To customize the profile:
- Modify the assets in
src/domains/mux/yazi/assets/(yazi.toml, init.lua, plugins/). - The
assetSha256in the stamp changes, triggering regeneration on next open. -
theme.tomlandkeymap.tomlare rendered from Clio's tokens at generation time.
This test drives the panes tool through the session registry over a fake mux that
records every request. It demonstrates:
-
One pane per preset: a second
/panes open shellfocuses the existing pane instead of splitting again. The test assertsf.callsequals["open:shell:/bin/bash -l:/workspace", "focus:p1", "close:p1"]. -
Refusal of argv:
{ action: "open", preset: "shell", argv: ["rm", "-rf", "/"] }returns an error before the fake mux sees it.f.callsremains empty. -
Missing binary: with
bash: null, the open returns amissing-binaryerror with the install hint, andf.callsremains empty. -
Watch pane: attaching a
PanesWatchControllerand callingshowwith a matching agent id routes through the controller, returningwatchingwith the run id.
This test drives the watch pane through the real socket client (a live createMuxClient
talking to a node:net server) and the real mux runtime. It demonstrates:
-
Structured error preservation: when the fake herdr server returns a structured
error (
{ code: "layout_capacity", message: "tab limit reached: 8 panes" }), the watch result preserves the kind, code, method, and message. The test verifies five error shapes: structured, empty, code-only, literal unknown message, and literal unknown code and message. -
Refusal isolation: a structured refusal (e.g.
feature_disabled) is kept separate from a later independent successful watch. The test assertsf.callsis empty after the refusal, proving no wire request was sent.
This test drives the files pane surface through the real Yazi session and bridge. It demonstrates:
-
Preset aliasing:
resolvePanesPresetId("yazi")returns"files"(the alias from 0.4.0/0.4.1). -
Dock geometry: opening the files pane with
dock: { slot: "files", share: 0.3 }routes through the dock controller. The test verifies the dock'stargetShareis clamped and thepaneSplitcall carries the correct ratio. -
Transport file lifecycle:
sweepStaleTransportFilesremoves.stream,.chooser, and.cwdfiles older than 24 hours.
This test verifies the ownership token:
- A new pane carries only
clio_coder_owner: clio-coder:mux(the canonical token), neverclio_owner: clio:mux(the released token). -
adoptPanestill finds and adopts a pane carrying the releasedclio_ownertoken, so a pane an older 0.4 session left open is found and cleaned up.
This test verifies detection remedies:
-
Embedded refusal:
detectMux({ enabled: "embedded", env: {} })returnsmode: "none",refused: true, and the reason "embedded pane hosting is not implemented; use auto or guest". -
Files pane disabled: with
interface.panes.files.enabled: false,panes.open({ preset: "yazi" })returns a refusal naming the canonical key. -
Slash command inactive: running
/paneswithout the panes extension prints the notice "panes are inactive: this session started without them. Restart withclio-coder --with-panes".
-
The ownership check is the first line of every mutating method. If you add a new mutating method to
MuxContract, the first thing it must do isif (!registry.owns(paneId)) return false;(or equivalent). The testtests/contracts/legacy-naming-retired.test.tspins that the released token is never written, only read. -
pane.splitalways usesfocus: false. The dock controller and the contract'sopenUtilityPaneboth passfocus: false. If you change this, the dock will steal focus from the operator's shell on every open, violating rule 3 of the reconciliation logic. -
The
attemptwrapper is the only error-swallowing layer. Every method incontract.tsroutes throughattempt. If you add a new method that talks to the socket, wrap it inattemptwith the appropriate fallback. Thedegradefunction insideattemptsetshealthy = falseand a cooldown on transport/timeout errors. -
Protocol floors are checked against
detection.server, not the wire response. ThemuxSupportsMethodfunction takes the server info from the handshake, not the current response. If a server updates its protocol mid-session, the floor check will not see it until the next detection. -
The Yazi profile is validated with
yazi --debugbefore promotion.ensureYaziProfilespawnsyazi --debugin a scratch HOME and checks the output for config error markers. If you change the profile assets, test with the actual Yazi binary, not just the TOML parser. The testtests/contracts/doctor-yazi-repair.test.tspins that a generation failure is retained until a profile successfully regenerates. -
exactOptionalPropertyTypesis on. When building optional request objects (e.g.MuxSplitRequest,MuxOpenUtilityPaneRequest), use the spread pattern...(x !== undefined ? { x } : {})rather thanx: undefined. This is enforced by the TypeScript compiler and the boundary checker. -
The dock controller never caches the split path.
applySharere-derives the split path from a freshlayoutExporton every resize. If you add caching, the path may go stale after a user move, and the share will be applied to the wrong split. -
Transport files are per-session and worthless once the session ends.
createYaziSessionremoves.stream,.chooser, and.cwdfiles on close. If you add new transport files, add them to theremoveTransportFilescleanup and thesweepStaleTransportFilesregex, or<cache>/yazi/sessionswill grow without bound. -
The panes tool has no
argvfield and never will. The tool's schema inpanes-surface.tshas noargvparameter.panes-tool.tsexplicitly refuses any request that contains anargvfield. If you need to run a command in a pane, use the/panes open <command>slash command, not the tool. -
The
roletoken is whatadoptPanefinds after a restart.openUtilityPanewritestokens: { role: token(purpose) }alongside the owner token.adoptPanescans a fresh snapshot for panes carrying Clio's owner token and a matchingroletoken. If you change the purpose system, update the adoption path or surviving panes will not be re-adopted.
Source and generation metadata
title: "Domains mux"
summary: "The pane multiplexing layer: the herdr socket client, dock controller for managed panes, Yazi file-manager integration, protocol floors, and the ownership rule that confines every mutating call to panes Clio created."
sources:
- "src/domains/mux/index.ts"
- "src/domains/mux/contract.ts"
- "src/domains/mux/socket-client.ts"
- "src/domains/mux/dock-controller.ts"
- "src/domains/mux/operations.ts"
- "src/domains/mux/pane-registry.ts"
- "src/domains/mux/detect.ts"
- "src/domains/mux/protocol.ts"
- "src/domains/mux/yazi/session.ts"
symbols:
- "createMuxRuntime"
- "createMuxClient"
- "createDockController"
- "createPaneRegistry"
- "detectMux"
- "muxSupportsMethod"
- "createYaziSession"
- "createPanesRuntime"
- "createPanesTool"
tests:
- "tests/contracts/panes-tool.test.ts"
- "tests/extended/panes-watch.test.ts"
- "tests/extended/panes-files.test.ts"
- "tests/contracts/legacy-naming-retired.test.ts"
invariants:
- "Every mutating contract method checks the pane registry first; a pane Clio did not create is never closed, renamed, focused, or reported on."
- "A mux failure never throws at a caller; every method degrades to a fallback value and the caller sees `available() === false`."
- "The dock controller never steals focus; `pane.split` is always called with `focus: false`."
- "Preset panes are the only utility panes the model tool may open; arbitrary argv stays operator-only through `/panes open`."
validate:
- "pnpm test -- --grep panes"Clio Coder · Repository · Website · Documentation
Wiki v0.1 · Developing implementation reference · Source snapshot: 657dce13d. Authored architecture documents define the product contracts.
- Clio Coder GUI Client
- apps / clio-coder-gui
- Apps clio coder gui server
- Apps clio coder gui tests
- apps
- Architecture
- Command-line surfaces
- Core
- Domains agents
- Config Domain
- Context Domain
- Dispatch domain
- Domains evidence
- Domains extensions
- Domains gateway
- domains
- Domains interop
- Domains lifecycle
- Domains memory
- Middleware Domain
- Domains mux
- Domains observability
- Domains plugins
- Prompt Compiler
- Domains providers
- Domains quota
- Domains resources
- Domains safety
- Domains scheduling
- Domains session
- Vendored Tool Registry and Resolution
- Engine
- Engine acp
- Engine apis
- engine
- Entry point
- Interactive
- interactive
- Interactive overlays
- Interactive renderers
- clio-coder wiki
- Scripts
- Contract tests
- Tests extended
- tests
- Tools
- Tools data
- tools
- Tools verify
- Worker runtime