cyberuni/cyber-mux
Cross-multiplexer pane control: open, send, read, focus, and close panes over tmux, herdr, wezterm, and more.
cyber-mux
0.9.1
Patch Changes
- b0d9d4b: Fix the
muxskill's frontmatter, which failed to parse as YAML, and list the CLI commands in its argument hint. - d6f15d5: Report the package version in the agent plugin manifests and the
muxskill'snpxfallback; 0.9.0 shipped them at 0.8.0.
0.9.0
Minor Changes
- 901317d: Encode
--format jsonand--format agentwith@clibuilder/axi, the output package the clibuilder CLIs share.--format agentnow writes TOON instead of the human table, on success and on structured errors;--format jsonis now compact JSON (same payload, no indentation).textstays the default. - ed43ce2: Ship cyber-mux as an agent plugin for Claude Code, Cursor, Codex, and Copilot CLI, with a
muxskill (/cyber-mux:mux <command> | --help) that drives the CLI. - 8cb77a5:
cyber-mux/worktreenow re-exports the leased worktree pool from@cyberuni/agent-harness/worktrees(acquire,release,explain,primaryRoot,listWorktrees,slotPath, and the rest) and addsmuxWorktreeCreator, the library's injectable creator that binds a new worktree to a herdr workspace, plustoHarnessExecto drive the library with cyber-mux'sExec. The synchronous helpers the library replaces (resolvePrimaryRoot,listWorktreesFromGit,isWorktreeRemovable,resolveWorktreePath,pruneWorktrees,provisionWorktree,ghForgeMergedProbe,normalizeWorktreePath) are deprecated; they keep working unchanged.
0.8.0
Minor Changes
-
e8c4b28: The CLI now runs on clibuilder instead of commander, and ships as a clibuilder plugin: a host CLI that lists
cyber-mux/pluginin itspluginsconfig gets every verb undermux(<host> mux list).Usage errors behave differently in a few places:
- A malformed value (
--at bogus,--lines abc,--env NOEQUALS) is now a codedinvalid-valueerror on stdout with exit 2. It used to exit 1 with commander's own text on stderr. - A missing
--branchonworktree addorworktree provisionis now a codedmissing-argumenterror with exit 2. --helpexits 0 as before. The help layout is clibuilder's.--versionreports the package version instead of0.0.0.
- A malformed value (
Patch Changes
- 31dea02:
mode's own--helpline said it reports "tmux / herdr / none", three backends behind the seven it actually resolves. It now names them all, and says it reports the drivable backend — which is why a recognized-but-undrivable mux (GNU screen) answersnonehere whiledoctorstill names it. - 80c6687: Ship a readme with the package — the repository readme now lives in
packages/cyber-mux/(symlinked from the repo root), so npm shows the badges, backend list, and command table instead of nothing.
0.7.0
Minor Changes
-
cd4db8e: Close the two focus gaps on the
MuxAdapterseam (#152):isPaneFocusedcan now say "cannot determine" on every backend, andopensWithoutStealingFocusis replaced by the three-valuedfocusOnOpen.isPaneFocusedreaches its ownundefined. The member already returnedboolean | undefined, but six of the seven adapters could not actually produce theundefinedfor the case it exists for — an unanswerable query came back as a confidentfalse, which a caller cannot tell from a real negative.- WezTerm gains a real probe. It used to answer a blanket
undefinedon the claim that the backend has no focus primitive. It has one:cli list-clients --format jsoncarriesfocused_pane_id, measured on20240203-110809-5046fc22with a GUI client attached to a headless mux server, moving withcli activate-panein both directions. It deliberately does not readcli list --format json'sis_active, which is per-tab — measured, three rows reported ittrueat once across two tabs and two windows. - Zellij stops believing
is_focused. That flag is true on more than one record at a time (a floating plugin pane and the tiled pane beneath it), so the probe could reporttruefor a pane the client was not on. It now readslist-clients, the same authority the adapter's focus restore already used. - tmux and rmux presence-check every
-Ffield before comparing any: a short line left the active flagsundefined, andundefined === '1'isfalse. - cmux and otty presence-check the focus field, not just the row. Both row shapes are source/docs reads, so an absent field is the likeliest way they are wrong.
focusOnOpenreplacesopensWithoutStealingFocus. BREAKING for anyone reading that member. The boolean could not express a backend that moves focus and puts it back, so it called that "no theft" — which is how Zellij came to declare the same value as tmux while doing something visibly different. The new member is'preserved' | 'restored' | 'stolen':backend value why tmux, rmux 'preserved'-don every route, and-ttargets the split directlyherdr 'preserved'--no-focuson every route, andpane split --panenames the anchorZellij 'restored'its frompath focuses the anchor, opens, and focuses backWezTerm, cmux, otty 'restored'new — every open now reads the focused pane first and restores it No backend declares
'stolen'today, which is #152's acceptance criterion: an open on any backend either leaves focus alone or deterministically restores it. The restore is spelled once (restoringFocus, exported from the package root alongside theFocusOnOpentype), reads before anything moves, runs in afinallyso a throwing open cannot leave the caller's view relocated, and is skipped rather than guessed when nothing reports being focused.cmux's
--focus false(#167) and otty'spane split --no-focus(#172) are deliberately still not passed: both are unverified without a Mac, and the restore is correct whether or not those documented defaults are what ships. Everything stated about cmux and otty here remains a source/docs read — neither can be driven on Linux CI (#128). - WezTerm gains a real probe. It used to answer a blanket
-
829e44f: herdr:
worktree.releaseWorkspacecloses the whole worktree group againherdr 0.9.0 put the cascade behind
workspace close --group: a bare close of a PRIMARY workspace that still has worktree workspaces open no longer errors, it just releases the primary and leaves the group up. The adapter sent the bare verb, so the group silently stayed open.releaseWorkspacenow takesopts?: { group?: boolean }, defaulting totrue— cascading is what the verb has always done, and defaulting it off would narrow it for callers who never asked for a change. Pass{ group: false }to release only the workspace named.Pre-0.9.0 herdr rejects
--groupclient-side, at argument parsing, without contacting the server, so the adapter retries the bare verb there — which already cascades on those releases. The retry keys onExec'snullfailure sentinel, so it works against every runner rather than only ones that report a diagnostic reason. -
e79f649: Implement
AgentLifecycleon the otty backend, bound tootty pane wait --pane <id>— the second backend with a native per-pane agent-state wait, after herdr.cyber-mux agent waitnow drives otty instead of refusing it, andagentApi(env).supported()istruethere.otty's wait ends on
idleand nothing else, so an--untilnaming any other state is refused by name with the newAgentWaitStatesUnsupportedError(exported fromcyber-mux/agent) rather than narrowed to what the backend happens to support.timeoutMsrounds up to otty's whole-second--timeout-secsand never to0, which is otty's spelling for an unbounded wait.LivePane.agentStatuson otty is unchanged and stillundefined: otty exposes no documented CLI read of per-pane agent state, sosupported()andstatus()now answer differently on the same backend.Everything here is read off otty's published CLI documentation; otty is a GUI-only app with no binary in CI, so nothing in this change was verified against a live otty.
-
18e3269: otty can now size a split, and names a new tab at birth.
otty pane splitdocuments a--sizeflag, so the otty adapter declarescanSizeSplits: trueand rendersMuxOpenOptions.ratioonto it.--sizeis the new pane's share, whileratiois the fraction kept by the original, so the value is inverted — the same inversion the cmux and WezTerm adapters already make, and the opposite of herdr's pass-through--ratio. otty's unit is a whole percent over a documented 10–90 range:ratio: 0.7becomes--size 30, and a ratio that lands outside the range (0.95, a 5% new pane) is clamped into it with a warning on stderr rather than sent as a size otty rejects, which would fail the split outright.The previous
canSizeSplits: falsedid not merely undercount otty — it gave a reason that was wrong, implying otty has no sizing verb. The verb it does have that cyber-mux still cannot use ispane resize, which counts cells;resizePanestays refused on otty for exactly that reason, and the adapter now says which is which.otty tab newdocuments--title, soopen({ at: 'tab', label })names the tab in the creating call instead of following up with arename— one round trip fewer, and no window in which the tab carries otty's default name. Same fix the workspace tier already got withotty open --title.Read off otty's published CLI reference, not verified against a live binary — otty is a macOS-only GUI app with no public source, so the coverage is mocked-
Execargv assertions. -
1a5f898: Add pane relocation to the
MuxAdapterseam —movePane(exec, target, destination, side)andbreakPane(exec, target, at). Until now every pane cyber-mux opened stayed where it was born: the seam could create, read, name, resize, focus, zoom and close a pane, and never move one.movePaneputs a live pane beside another one — the destination is a PANE plus a side ('right' | 'down'), never a tab or a workspace, because that is the only destination the capable backends can all name exactly.breakPanepromotes a pane into its own tab or its own workspace. Both keep the pane's process and scrollback; neither opens or tears anything down.Both return
OpenedPanerather thanvoid, and that is load-bearing: herdr pane ids are workspace-scoped, so a relocation across that boundary rewrites the id and the old one drops out ofpane list. The return is the pane's new identity plus the tab and workspace it now lives in.Real on tmux, rmux, herdr and wezterm, each driven live. Refused BY NAME elsewhere:
PaneMoveUnsupportedError/PaneBreakUnsupportedError, never an emulation. zellij and otty refuse both; cmux refuses the move and declares the break-out.canMovePanesandcanBreakPanesare how a caller asks before relocating — two flags rather than one, because cmux has exactly one of the two capabilities.New exports on the
.entry:PaneMoveUnsupportedError,PaneBreakUnsupportedError,refusePaneMove,refusePaneBreak,canMovePanes,canBreakPanes, plus theMuxMoveSideandMuxBreakTiertypes.MuxAdaptergains two required members, so an out-of-tree adapter must implement them. -
237ae29: Add pane zoom to the
MuxAdapterseam —setPaneZoom(exec, target, zoomed)andisPaneZoomed(exec, target). A zoomed pane fills its region while its siblings stay open behind it, which every backend but one can do and none of them could be asked to do before.This is not
focus(). Focus moves the client; zoom makes the pane big. A caller that has just opened a worker pane and wants a human to actually read it needs the second, and only the first was expressible.- Absolute, never a toggle, for
resizePane's reason: a blind toggle can be asked for "big" and deliver "small", which is unusable by an agent. A caller that wants the toggle writessetPaneZoom(e, t, !isPaneZoomed(e, t)). - Asking for the state a pane is already in touches the backend at all. That is contract, not an
optimization: two backends spell zoom at the tab tier, where an unguarded write acts on a pane the
caller never named —
herdr pane zoom <p3> --offunzooms whichever sibling was zoomed, and tmux'sresize-pane -Zon a window zoomed elsewhere just unzooms it. - Zooming moves focus to the pane on every backend that has the verb. Declared rather than compensated: undoing it would be a second visible focus move. Unzooming moves nothing.
- Real on five of seven backends, each driven against a live binary: tmux 3.7c and rmux 0.10.0
(
resize-pane -Z), herdr 0.8.0/0.9.0 (pane zoom --on|--off), zellij 0.45.0 (action toggle-fullscreen), and wezterm 20240203 (cli zoom-pane --zoom|--unzoom, the only natively absolute one). - cmux and otty refuse BY NAME with
PaneZoomUnsupportedErrorrather than emulating: cmux has no pane-zoom verb at any programmable layer (its tmux-compatresize-pane -Zparses, does nothing and exits 0), and otty's documentedpane zoomnames no flags and reports no state, so an absolute set cannot be rendered.canZoomPanesis the pre-flight declaration,canFloatPanes's exact shape. isPaneZoomedanswersundefined— neverfalse— on those two: cmux binds pane zoom to a GUI keystroke, so afalsewould be a lie about a pane the user zoomed by hand.
New exports on the
.entry:PaneZoomUnsupportedError,refusePaneZoom,canZoomPanes.MuxAdaptergains two required members, so an out-of-tree adapter must implement them. - Absolute, never a toggle, for
-
dd95e70: Detect squash-merged branches when deciding whether a worktree is disposable.
git branch --mergedis structurally blind to a squash merge — the squash commit on the target is a rewritten tip with no ancestry link back to the branch — so a squash-merging repo under-reported every reusable worktree, and a pool built onprovisionnever recycled: it only ever created.The landed signal is now LAYERED, in cost order, with the ancestry answer kept exactly where it was:
ancestor—git branch --merged, git's own proof, one call for the whole repo.upstream-gone— the branch's remote-tracking ref has disappeared ([gone]), i.e. the forge merged the pull request and deleted its head branch. One call for the whole repo, offline, and correct for squash, rebase, and merge-commit strategies alike. Read, never refreshed: nothing here runsgit fetch --prunefor you, so the signal is as fresh as your last fetch.squash-patch— the branch collapsed to one synthetic commit is already applied on the target (commit-tree+git cherry). Offline, and matches a clean squash or rebase merge.forge— the forge's own word on whether a merged PR exists for the head branch. OPT-IN: pass asignals.forgeprobe (ghForgeMergedProbeships as one). Nothing reaches the network without it.
Entries carry a new
mergedSignalfield naming which layer establishedmerged: true, absent for a negative or undeterminable verdict — the layers do not carry equal authority, and a caller auditing a reclaim needs to see which one spoke.Every layer after the first is POSITIVE-ONLY, so the composite is a monotone OR: a squash that was conflict-resolved or hand-edited does not match and degrades to "not reusable" rather than to a false positive, and a landed signal never outranks a guard — a dirty, occupied, stale, or primary checkout is refused however it was cleared.
isWorktreeRemovable,removeWorktreeSafely, and the primary/dirty/occupied gates are unchanged, as is behavior on merge-commit and fast-forward repos.
Patch Changes
-
94cc2d3: Drive cmux's real verbs for the six
MuxAdaptermembers that could not work on it — fabricated flags, a JSON shape that is never emitted, a route that never emits JSON at all, and two verbs that do not exist.open()atpane:right/pane:downsent--cwdand--sizetonew-pane, which accepts neither and validates no unknown flag, so both were silently ignored: the split opened in the wrong directory and the ratio did nothing. Both flags are gone.canSizeSplitsis no longer declared — cmux has no split-size flag andresize-paneis cell-based, so arationow degrades to cmux's own even split, zellij's answer exactly. The directory is carried instead as acdon the command line (a shell-level cd, so it lands in that surface's shell history), the same last-resort shape env already takes.open()atworkspacethrew on every call:new-workspacehardcodeshonorJSONOutput: false, socmux --json new-workspaceprintsOK workspace:3and never JSON. It now runs the namespacedcmux --json workspace create, which does emit JSON — and takes--name, so a workspace is named at birth rather than renamed afterwards.listPanes,paneExistsandisPaneFocusedwere all dead. They readlist-panes, which reports PANES as{"panes":[…]}, while the parse required a top-level array of panes each holding surface objects — a shape cmux never emits, so the listing was empty for every real response and the three members answered "no panes", "does not exist" and "cannot tell" always. They now readlist-panels(the surface tier) with its real keys:reffor the id,focusedfor focus.LivePane.cwdis now absent on cmux: the listing carries the directory a surface was created with and nothing that tracks where its shell is, so reporting it would go stale at the firstcd.rename()ranrename-surface/rename-pane, neither of which exists in cmux at all. Both seam tiers now runrename-tab --surface <id> --title <name>; thepanetier retargets the surface the pane is showing, because cmux has no pane rename at any layer.teardown()sentclose-surface --surface <id>with no workspace. It now names the workspace whenever the adapter is bound to one — cmux resolves an explicit--surfacewithin a workspace, falling back to$CMUX_WORKSPACE_ID, so the old form worked from inside cmux, refused for a library caller with no cmux env, and resolved against the wrong workspace for a surface outside the caller's.
Also fixes a grouping bug this read uncovered:
workspace.createreports nopane_ref, so a workspace open's tab id is a SURFACE ref, and thegrouplookup — which sent onlypane_idagainst a handle registry keyed by ref string rather than by kind — silently resolved it to the caller's own workspace and would have grouped the wrong one. Each attempt is now verified against its own result.Read off cmux's own Swift source (
manaflow-ai/cmuxat71eb616d) — the CLI dispatch and parsers, the socket payload builders, anddocs/cli-contract.md— and not verified against a live binary: cmux is macOS-GUI-only, and issue #128 tracks the missing real-boundary suite.identify-backed focus, acapabilitiespre-flight, and cmux's native workspace--envall replace working behavior on source-only evidence and are deliberately left for someone with a Mac. -
08b9b39: Group cmux workspaces through its real
workspace-groupfamily instead of dropping the caller'sworkspaceGroupsilently.group()on the cmux adapter was a no-op, justified in the header by "cmux has a real workspace tier that already groups every surface in it, so there is nothing for this to add". That reasoning is sound for Pane → Surface and does not reach the tier the flag targets: cmux ships a first-classworkspace-groupfamily that groups multiple top-level workspaces into a named, collapsible sidebar section. A caller passingworkspaceGroupon a cmux{ at: 'workspace' }open got nothing grouped and no error saying so.It now routes to
workspace-group createthenworkspace-group add. cmux mints its own group ids, so the seam's opaque caller-chosen id cannot be the group id — it rides--idempotency-key(and--name, so the sidebar section carries it too), which makes a repeat call find the existing group rather than mint a second one.addis idempotent, so re-grouping needs no membership pre-check. Only theworkspaceroute groups: ataborpane:*open lands in the workspace the caller is already in, and grouping that would group a space the caller never opened.Behavior change worth knowing: cmux's
workspace-group createalways mints a brand-new anchor workspace, so the first grouping call for a given id adds a visible workspace to the sidebar — a deviation from the seam's "groupopens nothing", accepted because the alternative was the silent drop above and cmux offers no membership-only create. It does not steal focus, and later calls for the same id open nothing.No seam member changed —
group()/workspaceGroupwas already the right shape; only the adapter was wrong. Read off cmux's own Swift source (manaflow-ai/cmuxat71eb616d), not verified against a live binary: cmux is macOS-GUI-only, and issue #128 tracks the missing real-boundary suite. -
b1ce2d3:
probeMultiplexernow reports the pane for an otty session discovered through the process-ancestry route.paneForcarried a hand-written list of the muxes with a per-pane env var andottywas missing from it, so an ancestry-discovered otty session answered "no pane" while$OTTY_PANE_IDwas set and readable —currentPaneself-identity silently came back empty. The$CYBER_MUXfast-path was unaffected.The guard is now a
PANE_ENVlookup rather than a second list, andPaneMuxis derived fromMuxby subtracting the two muxes that genuinely carry no pane (screen,none) instead of re-listing the ones that do — so a new backend cannot be added without either giving it a pane var or excluding it on purpose.screenandnonestill report no pane. -
6a57d2f: Fix two otty argv shapes that otty's CLI does not accept, and refuse pane-tier
renameon otty.otty openalways opens a new window and documents only--commandand--title; the adapter was passing a--new-windowthat belongs to the differentotty view/otty editfamily, so everyopen({ at: 'workspace' })died at otty's argument parser. It now issues a bareotty openand names the new window at birth with the documented--titleinstead of renaming a tab afterward.otty pane splittakes--direction <right|left|up|down>, not a bare directional flag. The adapter was passing--right/--bottom— andbottomis not a direction otty names at all — so everypane:rightandpane:downsplit was broken too. It now passes--direction right/--direction down.otty scopes
renameto windows and tabs, sorename(…, 'pane', …)now throws a named error rather than issuing aotty pane renamethat otty rejects and this adapter discarded, and alabelon a--at pane:*open degrades to a stderr warning — the same trade the WezTerm adapter makes for the same missing primitive.Read off otty's published CLI reference, NOT verified against a live binary — otty is GUI-only and absent from the machine this was written on.
-
bdf7744: Stop sending
--cwdonotty tab new, and keep it onotty pane split— where otty's own docs show it.Issue #163 read otty's CLI reference, found no
--cwdanywhere on it, and concluded the adapter was sending a fabricated flag on both routes. Only half of that is right. otty's orchestration guide runsotty pane split --direction right --cwd "$PWD" --no-focus --jsonas a command you can type by hand, so the split route's--cwdis documented by otty itself and is unchanged. The reference'swindow / tab / panesection carries no flag table at all — measured across all 141 URLs indocs.otty.sh/sitemap.xml,--cwdand--no-focuseach appear on exactly one page, and it is not the reference.tab newhas no such backing: nothing otty publishes puts a working directory on it, and otty ships no source to settle it either way. The costs of the two readings are not symmetric — if the flag exists, acdworks too; if it does not,--cwdfails every--at tabopen at otty's argument parser. So the tab route drops the flag and carries the directory as acdon the command line, the same shape cmux'spane:*route takes, with the env prefix inside the&&. It is a shell-level cd, so it lands in that tab's shell history and means nothing in a non-shell pane. Thecdneeds no launch command to ride, so a tab opened with acwdand nolaunchstill lands in the right directory. The workspace tier was never affected —otty open [path]takes the directory as a documented positional.Read off otty's published docs, NOT verified against a live binary — otty is a macOS/Windows GUI app absent from the machine this was written on, and #128 tracks the missing real-boundary suite.
-
c973613: Fix the tmux adapter returning plausible-looking wrong answers when the calling process has no locale. Every tmux invocation now leads with
-u.Without a
LANG/LC_ALL/LC_CTYPEcontaining "UTF-8", tmux does not consider its command client UTF-8 and sanitizes what it prints: every byte outside printable ASCII becomes a literal_. The adapter separates the fields of its-Fformats with a TAB, so the split found no separator and a listing of N panes collapsed into ONE record whose id was the whole line —%0_zsh_/home/u_host_0rather than a pane id. It did not throw and did not return empty, so anything culling or reconciling on that listing acted on a pane that does not exist.Reached by a caller that has neither a locale nor
$TMUX: a systemd unit, a cron job, a container entrypoint, or a non-interactive ssh session driving tmux throughCYBER_MUX=tmux. A caller running inside a pane was never affected —$TMUXgives the command client the containing client's UTF-8 state.-uon every invocation, not only the ones that parse a tab. The mangling is a whole byte class, measured on tmux 3.7c: every control byte,0x7f, and every non-ASCII byte comes back as_. So#{pane_title},#{pane_current_path}and#{session_name}lost their non-ASCII content in the space-separated formats too, which re-picking the separator would not have fixed.- rmux is unaffected and unchanged. Measured on a live rmux 0.10.0 under
env -i: it emits a real tab. The earlier note that it shared the defect was an inference from the shared#{…}vocabulary, not a measurement. - The regression test drives the real tmux with the environment stripped —
PATHandHOMEand nothing else, on its own socket — because the integration suite inherits a locale, which is why this survived every release.
No API change.
-
a30d58c: zellij: complete a
zellij actionthat its own pre-flight dropped, and confirm a focus landed before anything is built on itzellij actionresolves the session list before it honors--session, probing every socket and requiring aConnStatusreply; against a busy server that probe loses a live session, and the command exits 1 withThere is no active session!— or exits 0 printing nothing — having done nothing at all. Measured on a live 0.45.0 under CPU contention: 2 bad answers in 600list-clientscalls, both correct on an immediate re-ask.Three real consequences are fixed.
open({ from })no longer skips its focus restore when thelist-clientsread behind it came back empty — a lost answer used to read as "no client is attached", which left the caller's client on the pane the open had just created.setPaneZoomnow confirms the client actually reached the pane before toggling fullscreen, becausetoggle-fullscreen -p <id>on a pane the client is not on, in a tab that already has a fullscreen pane, leaves fullscreen and reports success. And arename,focus,teardown,sendorreadthat never reached the server is re-issued rather than silently lost.new-paneandnew-tabare deliberately unchanged: they create, so they keep failing loudly by name rather than risking a stray pane.
0.6.0
Minor Changes
-
8469598: Declare
MuxAdapter.opensWithoutStealingFocus, and make it true on tmux, rmux, herdr, and Zellij.Opening a pane is not supposed to drag the user somewhere. Nothing on the seam said so, and four routes did not honor it.
open()now leaves the caller's focus where it found it on tmux, rmux, herdr, and Zellij 0.45+, and every adapter declares which answer it gives.Behavior changes
- tmux passes
-donsplit-windowandnew-paneas well asnew-window. Previously apane:right,pane:down, orpane:floatopen moved the attached client onto the new pane;new-window -dwas the only route that did not. Measured on 3.7c. - rmux passes
-donsplit-windowas well asnew-window, the same hole tmux had and for the same reason — rmux reimplements tmux's command language. Verified live on 0.10.0, not inferred from the resemblance. It has nonew-pane, so there is no float route to cover. - herdr passes
--no-focusonpane splitas well asworkspace createandtab create. On 0.8.2 the split already left focus alone, so this changes nothing observable — it is passed so the guarantee does not rest on a backend default. - Zellij requires 0.45.0 (up from 0.44.1), the release that added
--no-focus. Ataborworkspaceopen, and apane:*open naming nofrom, pass the flag. Apane:*open that names afromcannot: under--no-focusZellij anchors the split on the pane the command was issued from rather than the focused one, so passing it would split the wrong pane. That path focuses the target, splits it, then focuses back to the pane that had focus. On an older Zellij the open fails loudly with Zellij's own unknown-argument error instead of silently stealing focus.
New seam member
opensWithoutStealingFocus: booleanis required onMuxAdapter, so a third-party adapter must add it. It followscanSizeSplits— a declaration the caller reads, not aMuxOpenOptionsflag — but is required rather than optional becauseundefinedhere cannot be distinguished from an author who never considered focus.false(WezTerm, cmux, otty) is a degrade, not a refusal: the open still returns the pane you asked for, it just moves the user to it.Zellij users on 0.44.x must upgrade. The adapter declared
≥ 0.44.1and now declares≥ 0.45.0. A--no-focusopen on an older binary is an unknown-argument error, so it fails loudly and creates nothing rather than mis-targeting a pane — the same way an old tmux answersnew-panewithunknown command. The adapter version-probes nothing, by design.Zellij's
--no-focuswas written from the v0.45.0 source tree rather than a live binary, and is now driven in CI against a real Zellij 0.45.0. - tmux passes
-
dc2d3f0: Add
RegionInspector.resizePane— a pane's size can now be changed after birth, not only chosen at it.resizePane(exec, target, ratio)takes the same fraction-of-the-split-regionMuxOpenOptions.ratiotakes, so a caller that opened a split at a ratio restores it with the same number.Implemented on tmux, rmux and herdr, and verified against live binaries (tmux 3.7c, rmux 0.10.0, herdr 0.8.2). It rides the existing optional
regionscapability rather than a newcanResizePanesdeclaration, because a ratio is not a primitive any backend has: every one of them needs the region's current geometry to turn it into its own resize argument, which is exactly whatregionsreports — so a backend that reports rects answers the member for free. wezterm, zellij, cmux and otty do not report geometry and so refuse by name with the newPaneResizeUnsupportedError, raised by the newderivePaneResizeorchestrator. -
c498513: Add an rmux backend
cyber-muxnow drives rmux, an async Rust reimplementation of the tmux command language, alongside tmux, herdr, WezTerm, Zellij, cmux and otty. rmux runs natively on Linux, macOS and Windows — it is the first backend that runs natively on Windows.The adapter realizes the existing
MuxAdaptercontract plusRegionInspector, with no seam change: placements, naming, workspace grouping, reads, waits, focus and region geometry all work, and splits can be sized. Detection recognizes rmux through$RMUXand$RMUX_PANE, through anrmux-daemonprocess ancestor, and through the explicitCYBER_MUX=rmuxoverride.One capability is genuinely absent: rmux has no floating panes.
new-pane— the tmux 3.7 command--at pane:floatdrives — is not part of rmux's command set, so a float is refused by name (FloatingPanesUnsupportedError) rather than quietly substituted with a tiled split, exactly as WezTerm, herdr, cmux and otty refuse it. Every other placement is available.If you use rmux and tmux on the same machine, detection order matters and is now handled. An rmux pane exports
$TMUXand$TMUX_PANEas well as its own$RMUX/$RMUX_PANE, for tmux compatibility, and rmux puts atmuxshim on$PATH.cyber-muxtherefore asks the rmux question first: a pane carrying both resolves to rmux. Nothing about tmux detection changes.Verified against a live rmux 0.10.0 binary on Linux, including a real-boundary suite (
pnpm --filter cyber-mux test:integration) that drives the actual binary on a throwaway socket. Not exercised on Windows or macOS — the native-Windows claim is about rmux's own portability, not about acyber-muxrun anyone has observed there.
0.5.0
Minor Changes
-
3fd1c3c: Add cmux backend adapter
cmux is a Ghostty-based macOS terminal built for AI coding agents. This adds detection (
$CMUX_WORKSPACE_ID) and a fullMuxAdapterimplementation using cmux's CLI (cmux new-pane,cmux send, etc.).- Detection via
$CMUX_WORKSPACE_IDenv variable - Pane identity via
$CMUX_SURFACE_ID(cmux's "surface" is the terminal unit) - Supports workspace, tab (surface), and pane:right/pane:down placements
- Supports split sizing via
--sizeflag - No
--envflag support (env compensation via command prefix) - No geometry/regions support (cmux CLI doesn't report positions)
- macOS-only (cmux is a native Swift/AppKit app)
- Detection via
-
2c7de82: Add a
pane:floatplacement — a pane that sits above the tiled layout instead of taking a share of it, so nothing else is resized.MuxPlacementgains'pane:float', and--at pane:floatgains the matching CLI choice. A floating pane is an ordinaryOpenedPanewith a real id, soread/sendText/submit/waitForOutput/teardowndrive it unchanged.- tmux (≥ 3.7,
new-pane) and zellij (new-pane --floating) open a real one. - wezterm and herdr have no floating-pane concept and refuse by name — a new
FloatingPanesUnsupportedErrorfromopen, surfaced by the CLI asbackend-unsupported(exit 1) — rather than quietly substituting a tiled split, which would resize the region's other panes.MuxAdapter.canFloatPanes(with thecanFloatPanes(adapter)helper) is how a caller asks before opening; the refusal is raised before any backend command, so a refused float opens nothing. ratiois dropped on a float on every backend, tmux included: a float takes no share of the region, so there is no original pane whose fraction it could be.
-
2eefb02:
LivePanenow reports whether a pane FLOATS, so a caller can tell a float from a tiled pane after the open that made it — the read side of thepane:floatplacement.LivePane.floatingis required, the only required field on the type beyondid/mux. Every backend can answer, so absence is not one of its states: an optional field would leave a caller guessing whether a missing value meant not floating or cannot tell.- tmux reads
#{pane_floating_flag}and zellij readsis_floating, each appended to the pane listing the adapter already asks for — no extra command on either. - herdr, wezterm, cmux and otty answer
falseby construction: they have no floating-pane concept, so every pane they can report really is tiled. Deliberately not a named refusal — the create side has no truthful pane to hand back and refuses by name, while the read side has one, which is why this ridesLivePanerather than a capability object. cyber-mux list --format jsoncarries the new field per pane; the human table is unchanged.
-
fd13d41: Add otty backend adapter
otty is a native terminal-centric workspace app with integrated multiplexing, built for AI coding agents. This adds detection and a full
MuxAdapterimplementation using otty's CLI.- Detection via
$OTTY_PANE_IDenv variable - Supports workspace (window), tab, and pane:right/pane:down placements
- Send-keys supports text and key tokens in one atomic call
- No split sizing support (
canSizeSplits: false) - No
--envflag support (env compensation via command prefix) - No geometry/regions support (otty CLI doesn't report positions)
- macOS/Windows desktop app
- Detection via
-
1535fab:
readreports whether the capture dropped older rows, and--fulltakes the restMuxAdapter.read(and the boundMuxSession.read) now answers with{ text, truncated? }instead of a bare string, so a caller matching against a snapshot can tell a short pane from a capture that hit its bound. PassMuxReadOptions.truncationto have the backend determine it; leave it off andtruncatedis absent — neverfalse, because afalsethat means "I did not check" is indistinguishable from "you have everything" (the conflation herdr itself shipped a fix for in 0.8.0, herdrdev/herdr#1717).MuxReadOptions.linesgains'all'— the same window knob at its limit, for the whole scrollback. One option rather than a secondfull?: boolean, so no caller can spell a contradiction the seam would need a precedence rule for. It also makes the truncation answer free at that end: an unbounded window omitted nothing by construction, so no adapter spends a probe on it.Real on every backend, by one rule: ask for one row more than the captured window and compare row counts (
isReadTruncated,read-window.ts). tmux takes-S -(N+1)and spells'all'as-S -, WezTerm--start-line -(N+1), Zellij compares against the full dump itslinesread already holds (no extra query) and takes--fullfor'all', and herdr probes--source recent— its CLI prints the read's text alone and never surfaces thetruncatedits socket API computes.Opt-in at the seam because the probe costs one extra backend query and
readis the hottest verb there —pollForOutputruns it once per poll tick. Omitted, the argv is byte-identical to the read that has always been issued.CLI:
cyber-mux readnow carries one read window and one escape hatch —--lines <n>bounds it,--fulltakes the whole scrollback, and passing both is a usage error (exit 2). It always reports truncation, no flag needed: a truncated capture is followed by atruncatedfield and ahelp:entry naming--fullas the fix (AXI #3's shape), while a complete capture stays the pane's raw bytes alone soread | grepis unchanged.--format jsonspellstruncatedeither way. The answer is never on stderr, which agents do not read.Callers reading text from
readnow take.text(nudgeandwaitForOutputalready do internally).
Patch Changes
-
f66590d: Refuse a
pane:floatopen on cmux and otty instead of silently substituting a right split.Both adapters declared they cannot float (no
canFloatPanes) but never enforced it:open()resolved placement as down-or-right, soat: 'pane:float'fell through to a tiled split and was reported as success — a pane with a real id and none of the property the caller asked for. They now throwFloatingPanesUnsupportedErrornaming the backend, before any command is issued, the same as wezterm and herdr.The CLI was already correct (it checks
canFloatPanesand refuses before reaching an adapter); the hole was on the library surface, where a caller holding an adapter callsopen()directly. -
303a0e9: Fix two zellij
list-panes --jsonfield names, verified against a live 0.44.3 binaryThe zellij adapter was built from a documentation probe, and two of the field names it read do not exist in zellij's actual output. Driving the adapter against a real binary surfaced both.
pane_commandis reallyterminal_command. The adapter drops a pane's title when that title is just the running command zellij gave an unnamed pane — a guard that stops every shell pane from reporting the same manufacturedlabel. Reading the wrong name meant the guard compared againstundefinedand never fired, so unnamed panes exported their own command line as an authored label. It now fires.pane_cwddoes not exist; zellij's pane records carry no cwd field at all.LivePane.cwdwas therefore never populated for zellij and the code that read it was dead. It is gone, so a zellij pane is honestly cwd-less rather than appearing to sometimes report one.
-
586f9a9: Stop a lost or misdelivered zellij CLI reply from becoming a wrong answer
Zellij 0.44.3 can deliver a
zellij actionreply to the wrong command. Under CPU contention a command exits 0 having printed nothing, and the payload it should have received arrives on the stdout of the command issued after it. Reproduced by alternatingnew-tabandlist-panes --jsonforty times on a loaded two-core box: twice in forty, thenew-tabprinted an empty string and thelist-panesthat followed it printed27. Two hundred back-to-backlist-panes --jsoncalls with no mutating verb between them lost nothing, so it is the mutating verbs that open the window.Two of the adapter's answers were wrong in the face of that. An empty reply to
list-panes --jsonwas read as an empty session — but a live zellij session always has at least one pane, and reading zero made every id the adapter resolves fail at once. And the idnew-pane/new-tabprinted was taken on trust, so a stale one could name a pane that had been standing all along and hand the caller somebody else's pane.The listing is now re-asked when a read does not come back as a pane array, so
[]means zellij answered with no panes rather than that zellij did not answer. And anopen()reads the listing before the command as well as after: a reported id is believed only where it names a pane that was not already there, which closes the phantom guard thatnew-pane --directionneeds — the split prints a plausible id and exits 0 having created nothing when the attached client sits on a plugin pane. Where the id cannot be believed, a single pane that appeared over the open answers instead, so an open that genuinely happened is no longer failed for a reply zellij dropped.An
open()also no longer resolves to a PLUGIN pane. Zellij loads plugin panes on its own schedule, so a tab opened bynew-tabcan be carrying one in the same listing as its own initial pane, and that record can sort first. Caught at the real boundary: anopen()attabreturnedplugin_15, an id that exists — so nothing downstream refused it — and therename()after it renamed a pane the caller never opened. Bothnew-tabandnew-panecreate a terminal pane, so that is what an open now resolves to. -
58061b3: Report a zellij pane's working directory, verified against a live 0.44.3 binary
LivePane.cwdwas never populated on zellij, so callers filtering a listing by directory got nothing back from this backend. The adapter carried the claim thatlist-panes --jsonhas no cwd field at all on 0.44.3 — a probe conclusion drawn from a record that genuinely has none.pane_cwdis real, and so ispane_commandbeside it. Both are present on a terminal pane's record and omitted entirely on a plugin pane's, which is how a probe that sampled a plugin pane read them as absent from the schema. A driven 0.44.3 session reportspane_cwdon every terminal pane, andlistPanesnow fillsLivePane.cwdfrom it — free, in thelist-panes --jsoncall the listing already makes. A record with no directory to report still carries nocwd, rather than an empty or invented one.The label guard is unchanged: it reads
terminal_command, which is null for a plain shell, and that is the field it wants. -
d1da9a6: Give zellij's plugin and terminal panes distinct ids in the live listing
A zellij pane number is unique only within a kind: zellij numbers plugin panes and terminal panes in separate spaces, so a live 0.44.3 session reports
id: 0for both its suppressedzellij:linkplugin pane and its first terminal pane. The adapter stringified that number as-is, so two genuinely different panes were reported under oneLivePane.id— an identity hazard for everything that resolves by id, including the guard that catches anopen()reporting a pane it never created.The listing now qualifies a bare number with the kind
is_pluginnames, so those two panes are reported asplugin_0andterminal_0. Both prefixed forms are whatzellij action --pane-iditself accepts, andterminal_Nis already whatnew-paneprints, so a qualified id is directly drivable. An id zellij already spelled out is passed through untouched.A zellij
LivePane.idtherefore readsterminal_3where it used to read3. A bare number still resolves — it addresses the terminal pane, on the backend and in this adapter alike — so ids already held by a caller keep working.
0.4.0
Minor Changes
-
59865fd: Add the agent-lifecycle capability, normalized by truthful refusal rather than emulation.
cyber-mux/agentsubpath — the newAgentLifecyclecapability seam, itsderiveAgentWaitorchestrator, and theAgentLifecycleUnsupportedErrorrefusal. TheAgentStatustype (idle | working | blocked | done | unknown) rides out on the core.barrel viaLivePane.LivePane.agentStatus— herdr 0.7.5's per-paneagent_statusfeed, reported on the live pane listing exactly like the herdr-onlyharnessfield: filled where the backend can answer, OMITTED (never a falseunknown) where it cannot.agent status <pane>— a snapshot that degrades truthfully: it prints the resolved pane'sagentStatuson herdr and, on a backend with no agent-state feed, still prints the pane with no status and exits 0 rather than refusing.agent wait <pane> [--until <s...>] [--timeout <ms>]— a blocking drive of herdr's nativeagent wait, reporting the reached state. On tmux, wezterm and zellij — which have no native per-pane agent-state primitive — it is refused withbackend-unsupported(exit 1) naming the herdr-only constraint, the exact mirror of howtemplate saverefuses a geometry-incapable backend.agentApi(env, deps?)— the exec-boundcyber-mux/agentfacade parallelingworktreeApi/templateApi: it resolves the backend fromenvonce and exposessupported()/status(target)/wait(target, opts?)with the seams bound. It adds no logic of its own —supportedreads the same capability presence,statusthe sameLivePane.agentStatus, andwaitroutes throughderiveAgentWait, so the refusal stays specified and enforced once.
The refuse-not-emulate normalization is deliberate: a lookalike wait built from output polling would silently disagree with herdr's own state derivation, so a backend without the primitive is refused rather than guessed.
-
fa91591: A missing
<pane>on a pane verb now lists the live panes as candidates. It stays a usage error (exit 2), but instead of merely naming the missing argument it queries the backend and reports each live pane's id, label, and cwd under the newmissing-panecode — the same rendering and code family asambiguous-pane— so the caller's next move (cyber-mux list) is folded into the error. This coversread,focus,close,submit,send text,send keys,exists, andagent status. With no multiplexer the deeperno-muxfailure (exit 1) still surfaces first, andagent waiton a backend without the agent-lifecycle capability is still refused withbackend-unsupported(exit 1) ahead of any missing-pane check.cyber-mux listalso gains a herdr-only agent-status column, shown only when the backend feeds a per-pane agent state (omitted entirely on tmux, wezterm, and zellij). -
1914eb9: Add a portable
waitForOutputto the seam, and awaitverb to the CLI: block until a pane's output matches a literal (--match) or a regex (--regex), or until a timeout elapses. Real support on every backend rather than a one-backend capability — herdr drives its nativepane wait-output(0.7.5), while tmux, WezTerm and Zellij poll their existing read through one shared loop, so every backend searches exactly the snapshot itsreadreturns and existing output counts as a match. The CLI puts the verdict in the exit code (0 matched, 1 timed out) and prints the pane's own output on a timeout, so a caller that guessed the wrong pattern keeps the evidence; a pane that is GONE fails withpane-not-foundinstead of quietly waiting out the deadline, and so does a wait that never ran (a herdr older than 0.7.5, which has nopane wait-output, fails loudly rather than reporting an instant false timeout). Resolves #97. -
2b04288: Add the
cyber-mux worktree provisionCLI verb — the command-line surface over theprovisionWorktreeseam. It reuses a free worktree (the setworktree listmarks(removable)andpruneremoves) or creates a fresh checkout at the sibling path, and reports whether itreusedorcreated, the worktree, and on reuse the recycled entry. Flags:--branch(required),--base,--path,--format.The verb uses the default availability gate only and offers no flag to inject a host predicate — that is the deliberate surface divergence from the library seam, which takes an injectable one. A host that must exclude, say, a live-session worktree calls
WorktreeApi.provisiondirectly.The worktree spec is now split by public surface to make that divergence first-class: the
cyber-mux worktree <verb>surface (verbs, flag defaults, table rendering, and this new verb) is specified undercli/worktree/, while the surface-independent library contract (the seam, git-owns- facts, removal ordering, and the injectable predicate) stays inmux/worktree/. -
7df7b93: Add
worktree provision— reuse a free worktree instead of always creating a fresh one. The twin ofprune: prune removes disposable worktrees,provisionWorktree/WorktreeApi.provisionrecycles one through the same default gate (isWorktreeRemovable), else creates. Availability is an injected predicate so a host can add its own "no live session bound" check without leaking that concept into the worktree seam. A reused worktree is reset to a pristine tree on a fresh branch (switch -c→reset --hard→clean -fdx). The result reports whether it reused or created, and carries the recycled worktree in full.
Patch Changes
- bad1d3f: Validate
MuxOpenOptions.ratioat the seam: a sizing adapter (tmux,herdr,wezterm) now rejects a ratio outside0 < ratio < 1with a named error instead of rendering it into a silently broken split (above 1 produced a negative length; 0 or 1 gave a whole-region split). The check lives with the size render (assertRatioInRange), so a backend that cannot size a split (zellij, which drops the ratio) is unaffected, andtemplate's schema still refuses a degenerate ratio earlier per node. The range was already documented as a contract precondition; it is now enforced. Resolves #18.
0.3.0
Minor Changes
-
cd74775: Expose a library API.
cyber-muxnow publishes real entry points beside the CLI:cyber-mux— the multiplexer core:resolveMux, which returns aMuxSessionwithExecbound (mux.open(opts), no runner threaded per call), over the raw exec-injectedMuxAdaptercontract and its types reached viaresolveMuxAdapter; the mux probe (probeMultiplexer,currentPane);callerPane; the tmux/herdr/wezterm adapters;nudge; and theExec/NewIdseams (each a type plus its real implementation).cyber-mux/worktree— the git-worktree adapter (resolvePrimaryRoot,assertDistinctFromPrimary,gitWorktreeAdapter,listWorktreesFromGit,removeWorktreeSafely, and theWorktreeFsseam), plusworktreeApi(deps?)— the same helpers withExec/WorktreeFsbound.cyber-mux/template— template resolution and theTemplateStoreseam, plustemplateApi(env, deps?)— resolution withenv/Exec/TemplateStorebound.
Every entry ships type declarations, and the core is pure: it takes its effects (
Exec,NewId,WorktreeFs,TemplateStore) as parameters, with the real implementations exported as separate named values, so a host binds them once and tests drive fakes.probeMultiplexergains anenvPrefixoption so a host embedding cyber-mux under its own namespace adopts the env fast-path without forking detection. The CLI bin is unchanged.The package also ships its TypeScript source (tests excluded) alongside declaration maps, so go-to-definition on any exported symbol lands in real source rather than a generated
.d.ts.Pre-1.0, depend on this with a caret range (
^0.2.0); a 0.x minor may still carry breaking changes. -
90daa48:
cyber-mux template edit [<name>]shows a template's panes and fills them in — the other half oftemplate save, which captures geometry but lands with nocommandon any pane.The bare form lists and mutates nothing: a table of every pane with its position, label, dir and current value, plus
help[N]suggestions for what to do next. Itspanecolumn is verbatim what--settakes, so acting on the listing is a paste rather than a derivation. Panes are addressed by ordinal (3, or2.3for tab 2 pane 3) and never by label, since two panes may share a label by design. Aposition(top-left,right) is shown because apply order is a tree walk rather than a reading order — pane 2 of a 2x2 is the pane below pane 1, not the one beside it.--set <pane>=<value>writes without a terminal, is repeatable, splits on the first=only so a value may contain one, and clears the field when the value is empty. Re-running the same--setis a no-op that exits 0 and leaves the file's mtime alone, so a checked-in template is never dirtied by an edit that changes nothing. A batch naming one pane that does not exist writes none of them, and the error lists every identifier that would have worked.--interactiveasks one question per pane instead, in apply order, with the current value pre-filled into the editable line: Enter keeps,-clears,'-'is a literal dash, Ctrl-D abandons the edit and leaves the file untouched. It refuses when stdin is not a tty or when--format json|agentwas asked for, and points at--setinstead.--field command|labelpicks what both modes write;--dry-runprints the result instead of writing it. A template's spelling survives either way — one written with the flatpanes/arrangesugar comes back out flat rather than re-spelled as a tree. -
ff91915:
worktree listnow answers whether a worktree is still needed, not only whether it is occupied.Entries carry two new booleans —
merged(the branch's tip is an ancestor of the repo's default branch) anddirty(the checkout has uncommitted changes) — read from git on every backend, exactly aslinkedandprunableare. The default branch is resolved fromorigin/HEAD, falling back to the primary checkout's branch;mainis never hardcoded.The table compresses those two, plus the workspace binding, into a single
(removable)marker onBRANCH— merged and clean and unoccupied, i.e. safe to remove. It rides onBRANCHbecause the branch is what carries the work that landed, and it is mutually exclusive with(*), so no row ever shows two markers.--format jsonis unmarked as always: consumers read the rawmergedanddirtybooleans and compose their own policy.A squash or rebase merge rewrites the commits, so such a branch reads
merged: falseand goes unmarked — the signal errs toward "still needed" deliberately. Any signal git cannot determine (a detached HEAD, aprunableentry, no default branch) is an absent field and an unmarked row, never a guess and never a failure.This reports only. Removal gating and pruning are unchanged: nothing consults
(removable)before deleting anything. -
6a36ad6: Add
worktree pruneto remove every disposable worktree in one call — the same gateworktree listmarks(removable)with. The bare form previews the candidates; pass--forceto actually remove them. -
20da54f: Add a Zellij backend — the fourth multiplexer cyber-mux drives, after tmux, herdr, and WezTerm.
Detected via
$ZELLIJ(fast-path overrideCYBER_MUX=zellij), with self-identity from$ZELLIJ_PANE_ID. Driven throughzellij action …and gated on Zellij ≥ 0.44.1, the release that added per-pane CLI addressing (--pane-idacross the action verbs,focus-pane-id,list-panes --json, and ids returned fromnew-pane/new-tab) — the stable per-pane handle the seam requires.Capability shape: it names panes (
new-pane --name/rename-pane) and reports the focused pane (is_focused), unlike WezTerm. Aworkspaceplacement opens a new tab in the ambient session — Zellij pane ids are session-scoped and the seam's pane target carries no session — but the occupied workspace is still reported as the session name, unlike tmux. Tiled splits are always even (noratio), env rides in as a command prefix (no--envflag), and pane-geometry introspection (template save) is not yet supported.
Patch Changes
-
c4f2293:
template savenow explains the command limit in terms of portability rather than availability, and its help text changes accordingly.The old wording — "no multiplexer can report the command a pane was launched with" — was true as literally phrased and misleading in effect. Probed against live binaries: herdr 0.7.4's
pane process-inforeturns full argv for a pane's whole foreground tree, and/procreaches the same from a pid on any backend, so "there is nothing to be had here" was false.The real reason a capture writes no
commandis that what a backend reports is the resolved command line, not the one that was typed:nr web devcomes back asnode /run/user/1000/fnm_multishells/4223_1784479278417/bin/nr web dev, a path carrying a uid, a pid and a timestamp that is dead on the next machine. A template is meant to be checked in and run elsewhere, and applying one submits whatevercommandsays — so a wrong one fails by executing something. Absent beats wrong.Behavior is unchanged: a capture still records no
commandon any pane.template save --helpnow also names the twotemplate editcalls that fill them in. -
1fa102d: Place every tab of a multi-tab template in the workspace the apply opened. Previously only the first tab landed there — each later tab was created beside the pane the command was run from, because a
tabplacement with no anchor is resolved against the workspace the user is looking at.SessionOpenOptionsgainswithin, the workspace atabplacement opens inside, honored by the herdr and WezTerm backends and ignored by tmux, which has no workspace tier. -
9af5af2:
CYBER_MUX=screenis now rejected with a named error instead of the generic "run inside a multiplexer" throw. GNU Screen is detected — an override pinning it, or a realscreenancestor, is reported truthfully — but it is not a drivable backend, and pinning it now says so plainly.The
CYBER_MUXcontract used to namescreenas an accepted override value alongsidetmux/herdr/wezterm, but no adapter ever stood behind it, so settingCYBER_MUX=screenproducedcyber-mux requires a session backend — run inside tmux, herdr, or wezterm— a lie, since the caller had declared a real multiplexer. The value looked supported and was not.Probed live (GNU Screen 5.0.2): the blocker is identity, which is load-bearing across the whole contract (
SessionTarget.id,currentPane,LivePane.id). Screen addresses its split regions positionally — no per-region id to send to or read from — and leaves$WINDOWunset in windows opened viascreen -X, exactly the panes a driver creates, so a pane cannot even self-identify. Every supported backend ships a stable per-pane id ($TMUX_PANE/$HERDR_PANE_ID/$WEZTERM_PANE); screen has no equivalent for driven panes.Rather than ship a half-faithful adapter with unstable pane identity,
cyber-muxkeepsscreenrecognized-but-rejected: the value is still honored as an override (so it is never silently ignored and fallen through to discovery) and now fails with the reason. Detection of a realscreensession is unchanged; only the drive step rejects it. Full probe and decision: the45-screen-adapterADR. -
9c06f45:
worktree listdrops the LINKED column from the table. The primary checkout is marked(*)after its branch instead, so the one bit that column carried costs no width.--format jsonis unchanged: every entry still carries thelinkedboolean. -
68c28a1:
worktree listmarks a prunable worktree — one whose checkout no longer exists on disk — with(gone)after itsrootin the table. It rides onrootbecause the path is the thing that vanished, and(gone)is git's own word for it (branch -vvprints the same).--format jsonis unchanged: entries still carry theprunableboolean. -
cc3c5d8:
worktree listshortens arootunder your home directory to~/…in the table. The match is on a path boundary, so/home/annexis untouched by a home of/home/ann.--format jsonis unchanged: consumers still get the absolute path.
0.1.0
Minor Changes
-
0ae7980: Add
cyber-mux worktree addandcyber-mux worktree remove— plaingit worktreehelpers ported from cyberlegion.adddefaults the checkout path to a sibling of the primary checkout (<parent>/<repo>.worktrees/<branch>), never nested inside the primary's own working tree;--pathoverrides it.removerefuses the primary checkout (even with--force), tolerates a worktree already gone from disk, and refuses to discard uncommitted changes unless--forceis passed. -
9665402: Address a pane by name or by id. Every pane-taking verb —
read,submit,exists,focus,close,send text,send keys, andtemplate save --from— now accepts a pane's label wherever it took an id, so a caller holding a template manifest's(label, pane)pairs can address "theworkerpane" without doing the lookup itself.An id still wins. A string is taken as an id when a live pane carries that id, and resolved as a name only otherwise — so every existing id-based call keeps working and cannot be made to mean something else by someone renaming an unrelated pane. An id is recognized by matching a live pane rather than by the shape of the string, so a pane labeled
%9is still reachable by that name.A label is a human name, not a key, and nothing requires one to be unique. So a name matching two or more live panes fails rather than guessing, reporting each candidate's id, label and working directory — every id directly usable as the retry — as a structured
ambiguous-paneerror on stdout, honoring--format. A name matching nothing is the existing not-found path.cyber-mux existsgains a third exit code.0still means one match and1still means none, but2now means the locator matched two or more panes — there is no single pane the question is about. Exit2means ambiguous on every pane verb. Nothing that exits0or1today changes: a name could not be passed at all before this release, so exit2is only reachable through the new capability.cyber-mux listreplaces itsmuxcolumn withlabel. The label is what you now type instead of an id, so it is the fact that row exists to carry;muxwas constant on every row by construction — one adapter is selected per session — so the column discriminated nothing.cyber-mux doctoris where the backend is a live question, and it still reports it. -
9d027b3: The CLI's error surface now follows AXI on every command, not just the one path that already did.
- Errors go to stdout, not stderr. AXI reserves stdout for everything the agent consumes — data,
errors and suggestions alike — and defines stderr as debug the agent does not read. An error on
stderr is a report its own reader never sees, so every structured error now writes to stdout.
Diagnostics (warnings, progress) stay on stderr. This diverges from
cyberplace, which still puts errors on stderr; correcting that shared node is tracked separately. - Every error carries a stable
codeand an actionablehelp:line, honoring--format json. A caller matches on the code instead of parsing prose, and the help names thecyber-muxcommand that fixes the problem — neversee --help, and never the underlying multiplexer's raw diagnostic. - Usage errors exit
2; operation failures exit1. An unrecognized flag, a missing required argument, a malformed template name, a mutually exclusive flag pair, and a barecyber-mux sendare usage errors — the invocation is wrong and the fix is a different one — so they exit2. A genuine operation failure (no multiplexer, a pane that resolves to nothing, a backend that cannot answer) exits1. An unknown flag also lists the command's valid flags so the agent self-corrects in one turn, validated against the subcommand actually invoked.
cyber-mux existskeeps1forgone— a predicate answering its question, not an error — as a deliberate, documented divergence from AXI's1 = error. - Errors go to stdout, not stderr. AXI reserves stdout for everything the agent consumes — data,
errors and suggestions alike — and defines stderr as debug the agent does not read. An error on
stderr is a report its own reader never sees, so every structured error now writes to stdout.
Diagnostics (warnings, progress) stay on stderr. This diverges from
-
5d7b7b4: Add
--env KEY=VALUE(repeatable) to every verb that opens a pane —open,worktree add, andworktree open. The seam and both adapters already set env natively at every tier; this gives it a CLI door, so a caller no longer has to reach for a template to set an environment variable in the pane they open.--envsplits on the first=, so a value may contain=(URL=k=v); a trailing=sets an empty value (ROLE=); a pair with no=is rejected before anything opens, so a typo never leaves a half-created worktree behind.- On
worktree add,--envimplies--at workspacefor the same reason--launchdoes — asking for something in a pane is asking for the pane. It conflicts with--template, whose template owns its own panes' env. - On the one route that cannot set env at birth — herdr's
worktree create/worktree open, which take no env parameter — env rides in as anenv KEY=VALUEprefix on the launch command, and when there is no command to carry it the drop is reported on stderr rather than passing in silence.
-
96dbe39: A failed backend command now says why it failed, in the backend's own words:
Execgained an optionallastError, and every adapter throw site that runs a command carries it through.The gap.
realExecran withstdio: ['ignore', 'pipe', 'ignore']and mapped any failure tonull, so a backend's stderr was discarded by the seam itself. Asking for a pane pool too large for the terminal got you:tmux split-window failedwhile tmux was saying
no space for new panethe whole time, to a stream nobody read. The failure was correct — the walk stops, reports the panes it built, exits 1, kills nothing — but gave the caller nothing to act on.The change.
Execis now a callable interface rather than a bare function type, carrying an optionallastError?: string— the reason the most recent call returnednull. A plain arrow function still satisfies it, so every existing call site and every test fake is unchanged; a runner that never sets it degrades to no reason at all.realExeccaptures stderr and records it, clearing it on every success so a reason can never outlive the command that produced it.withReason(exec, message)(new, fromexec.ts) appends the reason when there is one. The eight adapter throw sites that run a command use it.
tmux split-window failed — no space for new paneWhy a mutable field and not a result object. Widening the return to
{ ok, stdout, stderr }is the tidier seam, and it rewrites 45 production call sites and 40 test fakes for a diagnostic.Execis synchronous by construction (execFileSync), so "the most recent call" is unambiguous and a throw site reads it on the line after the call that set it. Forwarding stderr to the terminal instead was rejected for a concrete reason:existsand the multiplexer probe run commands that fail routinely, so it would spam every normal run.Deliberately not everywhere.
lastErroris a diagnostic, never a control-flow signal —nullremains the only failure sentinel. Sites that do not run a command do not use it: resolving a pane id out oflist-panesoutput is a parse failure, not a command failure, so attributing the runner's most recent reason there would be a confident lie.send keysstill does not read it, so an unknown key still exits 0 on both backends.No behavior changes for a command that succeeds, and no error message changes for a runner that reports no reason.
-
95ec62b: Add
cyber-mux template save <name>— capture the live pane region around a pane into a named template, so a pool built by hand once can be named rather than transcribed. This closes the schema's one real authoring cost: a 4+ pane grid needs nestedsplitnodes nobody wants to type.It captures the region around the calling pane by default, or
--from <pane>'s; writes to the repo's templates directory (--to userfor your own), refusing to overwrite without--force; and prints the written path alone on stdout, so$(cyber-mux template save pool-4)composes. Absolute paths never reach the template — a pane under the captured root becomes a relativedir, and one outside it loses its directory with a warning.A capture recovers geometry, labels and dirs — never commands, and that limit is structural rather than a gap: no multiplexer reports the command a pane was launched with, because cyber-mux types commands with
submitrather than passing them to the split. So a saved template is a draft, and it says so in its owndescription. Fill the commands in before applying it.This adds an optional
describeRegion?member to theSessionAdapterseam — "report this region's geometry", answered as one rectangle per pane, with the split tree derived from those rectangles rather than from any backend's own encoding. Both tmux and herdr implement it; a backend that cannot describe its region refusessaverather than degrading. -
c456ef9: The two contextual-disclosure suggestions (AXI #9) now ride on stdout inside the structured payload as a
help[N]:block, not on stderr.What moved. Two "here is your next move" notes were written to stderr, the stream AXI defines as the one an agent does not read — so scope information an agent must act on was landing where it never sees it:
template savein a multi-tab workspace, when a bare save captured only the caller's own region, noted the tabs it left out.worktree add/open, when the chosen placement cost the workspace grouping, named the flag (--at workspace) that would have grouped it.
Both now ride in the command's own stdout payload as a
help[N]:block — a message line and the concrete command that acts on it ({ message, command }) — emitted only when there is a next move (AXI #9's omit-when-self-contained rule).Breaking:
template save's stdout is now a structured payload, not a bare path. Its text output is apathfield (plus the help block when a bare save left tabs behind), andtemplate savegained--format json, which emits{ "path": ..., "help": [...] }. Programmatic composition that read the bare path from$(cyber-mux template save x)must move to:cyber-mux template save x --format json | jq -r .pathworktree add/opengain ahelpfield on their--format jsonobject only when a grouping was lost; the bare, non-degraded shape is unchanged. -
a530024: Add named workspace templates — a reusable template applied against a target directory supplied at invocation time. A template names a pool once (geometry, a startup command, and an environment per pane) and re-targets it on every apply; nothing about the target directory is ever written into the template, and a template carrying a
cwdfails validation rather than being silently ignored.Templates are JSON, resolved by name from
<primaryRoot>/.cyber-mux/templates/<name>.jsonand then${XDG_CONFIG_HOME:-~/.config}/cyber-mux/templates/<name>.json, with the repo winning andtemplate listmarking a user template a repo template shadows. Resolving through the primary checkout means every worktree of a project sees the same templates, including a worktree whose branch predates one.The schema is a binary split tree (
split/panenodes,direction: right|down,ratio,first/second; panes carrylabel/command/env/dir), plus a flatpanes+arrange(tiled/even-horizontal/even-vertical) sugar that cyber-mux desugars itself — so one template yields one geometry on every backend rather than deferring to each multiplexer's own grid algorithm.New verbs:
cyber-mux template list | show [--desugar] | validate, which take a file as their subject and answer with no multiplexer at all. Applying a template is not its own verb — it is--templateon the commands that already open a space (open,worktree add), the exact sibling of--launchand mutually exclusive with it.--format jsonemits the apply manifest: every pane created, as(label, pane, dir, command).Also adds
ratioandenvtoSessionOpenOptions, native on both backends (herdr--ratio/--env, tmux-l/-e) at both the split and region tiers. -
ad1d15f:
opennow reports the workspace the new pane landed in — both beside the pane it opened and in the--templatemanifest, which stops emitting aworkspacethat is alwaysnulleven on a backend that had just opened a real workspace.The manifest is framed as the complete machine-readable answer to "which panes exist and what are they for", but a consumer grouping panes by workspace had nothing to group on:
SessionAdapter.openreturned only a pane id, so nothing downstream had a workspace to report. Only the worktree capability surfaced one, which is whyworktree add --templategot it right andopen --templatedid not.opennow returns anOpenedPane— the pane handle widened with an optionalworkspace. This is additive: the field is optional, so an implementor returning only a pane id still satisfies the seam. On herdr the answer costs no extra call, since every route (workspace create,tab create,pane split) already emits the pane's ownworkspace_idin the output the pane id is read from. A backend with no workspace tier reports it absent rather than a false "none" — the same conventionisPaneFocused'sundefinedfollows — which is why it staysnullon tmux, whereworkspaceandtabboth collapse to a Window.openreports it too, not just--template:cyber-mux open --format jsonnow carries{ pane, workspace }. Nothing is looked up to answer that — the backend said so when the pane was born and the seam already held it, so the previous report was discarding a fact it had.nullon a backend with no workspace tier; the text report omits the line entirely rather than printing a bare null.The reported workspace is occupancy, never a worktree binding. It says which workspace a pane lives in; it does not say a worktree was grouped there. A worktree opened at a
pane:rightplacement lives in the caller's workspace while bound to none, and the worktree report keeps answering that question separately and unchanged. -
5271bfa:
open --launchis now optional. Runningcyber-mux openwith no--launchopens a blank pane instead of requiring a command to launch. -
1d6c744: Split the turn-driving verbs so that typing text and pressing keys are separate intents, and only
submitsupplies an Enter.Breaking.
cyber-mux send <pane> <text>andSessionAdapter.send()are gone, replaced by:cyber-mux send text <pane> <text>/sendText()— type literal characters, press no Enter. Text that happens to name a key (Enter,Up) is typed, never interpreted as that key.cyber-mux send keys <pane> <keys...>/sendKeys()— press named keys in order, typing nothing. Keys use a portable core vocabulary (UpDownLeftRightEnterEscapeTabSpaceBackspaceC-cF1–F12) normalized per backend; anything outside it is forwarded to the backend as-is.cyber-mux submit <pane> [text]/submit(exec, target, text?)— gains the optional textsendused to have: types it, then always presses Enter. With no text (or empty text) it keeps its existing bare-Enter flush, which retypes nothing.
This fixes a real fault.
send/submitpreviously passed text straight totmux send-keys, which resolves each argument as a key name before falling back to characters — so submitting text that named a key pressed that key instead of typing it. SubmittingUppressed the arrow, recalling the pane's previous command from shell history, and the trailing Enter then re-ran it. Typing now goes throughsend-keys -l, which disables key-name lookup.Migration:
send(exec, t, text)→submit(exec, t, text)for taking a turn;submit(exec, t)is unchanged. On the CLI,cyber-mux send <pane> <text>→cyber-mux submit <pane> <text>. Barecyber-mux sendis now a command group: with no subcommand it prints help to stderr and exits 1. -
e0dc5fa:
--at pane:*now splits the calling pane on both backends, andSessionOpenOptionsgainedfromto name the pane a split targets.The bug.
SessionPlacementis documented as placement "relative to the caller's current one", but neither backend's default delivers that, and they fail in opposite directions:- tmux ignores
$TMUX_PANEentirely and splits the session's active pane. Verified on tmux 3.6b: asplit-windowrun inside pane%1, with$TMUX_PANEcorrectly reading%1, split the active%0instead. - herdr resolves
--currentfrom$HERDR_PANE_ID, then silently falls back to the UI-focused pane when that is unset. Verified on herdr 0.7.4.
Both defaults track the pane the user is looking at. That coincides with the caller whenever a human is typing, and diverges exactly when a program is driving — so
cyber-mux open --at pane:rightcould split whatever pane happened to be focused, with no error. The same command also meant different things on different backends, in the one seam this package exists to make uniform.$CYBER_MUX_PANE— the documented pane-id fast-path a spawn propagates — was also unreachable from a split, since a backend's own default cannot see it.The fix. Callers now resolve their own pane and name it, rather than trusting either backend's default:
herdr pane split <id>instead of--current,tmux split-window -t <id>instead of no-t.SessionOpenOptions.from?: SessionTarget— the pane apane:*placement splits. Ignored bytab/workspace, which split nothing.callerPane(adapter, env)(new, frombackend.ts) — this session's own pane as a target, resolved through the same$CYBER_MUX_PANE→$TMUX_PANE/$HERDR_PANE_IDchain ascurrentPane, so the documented override reaches a split.undefinedwhen the pane belongs to a different multiplexer than the adapter drives, rather than handing one backend the other's pane id.addAndOpenWorktree/openExistingWorktreeaccept and forwardfrom.
Behavior change. On tmux,
--at pane:*from a pane that is not the active one now splits the caller instead of the active pane. That is the documented contract being honored rather than a new intent.Omitting
fromis unchanged: it falls back to the backend's own default, so a caller that cannot identify itself (a cron job, a shell outside any pane) still opens a pane rather than failing. - tmux ignores
-
4ecd471: No label has to be unique any more — not a pane's, not a tab's. A duplicate
labelwas a validation error, andtemplate savedropped a shared label from both panes to keep the template valid. That was backwards: a label reaches a live pane because a person renamed it by hand, andsaveexists to capture exactly that — so the rule discarded the very fact the capture was there to preserve. Neither backend asks for uniqueness (herdr labels every new workspace's root tab1, so it manufactures duplicates by default), and nothing keys on a name: the manifest's unique handle is the pane id, and it reports a pane's tab by index. A pool of three panes all namedworkeris now a legal template, and a capture keeps every label the user set. Ambiguity belongs to whoever looks a pane up, not to the author.A template can describe a workspace of tabs, not just one pane tree.
tabs: [...]is the two-level form — each tab carries its own pane tree in the very same shaperoot/panesalready accept, sugar included. A template declares exactly one ofroot,panes, ortabs; the first two are the one-tab spelling and are unchanged. Pane labels stay unique across the whole template (the manifest is one flat list, so its keys are global); tab labels are a separate namespace.open --templateandworktree add --templateboth build every tab, and the manifest reports thetabeach pane landed in.template save --workspacecaptures a whole workspace back — one tab per live tab, each with its own derived tree. A baretemplate saveis unchanged: it still captures only the caller's own region, and now notes on stderr when the workspace held more tabs than it took.On a backend with a real workspace tier (herdr) a workspace of N tabs maps directly. On one without (tmux, where
workspaceandtabboth collapse to a Window) the grouping is carried two ways, because one carrier cannot serve both readers: a human reading the status bar gets it in the tab label (<workspace> - <tab>, never shortened), while capture reads an opaque window option. The label is never parsed back —acme - beta - mainis ambiguous under every split rule, so parsing would silently mis-group a legal label.SessionAdaptergainsrename(name a space after its birth — the one case--labelcannot serve, since herdr labels a new workspace's root tab1with no flag) andgroup(group a space that is already open).OpenedPanenow carries thetabthe pane landed in, reported by every backend because every multiplexer has the Tab level, and required to address a rename at that tier: herdr refuses a pane id there while tmux resolves one, so a caller reaching for the pane id would be green on one backend and silently broken on the other. -
a71c120: Add a WezTerm
SessionAdapter, driven throughwezterm cli— cyber-mux now runs inside WezTerm's built-in multiplexer alongside tmux and herdr. Detection extends the existing env fast-path via$WEZTERM_PANE, the same way$TMUX_PANE/$HERDR_PANE_IDalready work.Several real capability gaps fell out of building this adapter against the WezTerm CLI's own reference (no live WezTerm GUI was available to verify against, so this is probed from
wezterm cli --help/the CLI docs rather than a live binary the way the tmux/herdr adapters are):- No
--envonspawn/split-paneat all. Unlike herdr, which is native everywhere except one worktree route, WezTerm's CLI has no env flag on any space-creating command — every open takes the command-prefix-or-warn fallback, not just one. - No way to title a pane, at birth or after.
set-tab-title/set-window-titleexist; there is no pane equivalent. Renaming a pane throws rather than silently doing nothing;open's pane-tier--labeldegrades to a stderr warning instead of failing the whole open. - No focus-query primitive.
wezterm cli list --format jsoncarries no active/focused field for a pane, tab, or window, soisPaneFocusedalways answersunknownfor this backend — the seam's own honest answer for "no primitive to ask", not a per-query fallback the way it is on tmux/herdr. - No per-key press primitive. There is no
send-keys-shaped verb, onlysend-text— the portable core vocabulary is instead realized by encoding each key as its own raw terminal byte sequence and typing it viasend-text --no-paste. - No pane geometry.
list --format jsonreports a pane's size but never its position, so there is nothing to build a rect from —describeRegion/describeWorkspaceare omitted, same as any backend that cannot describe its own region. - No git-worktree concept in the CLI at all — like tmux, this backend never binds a worktree to a workspace; callers fall back to plain git plus a placement-appropriate
open().
--percentonsplit-panesizes the new pane, the same inversion direction tmux's-lneeds — not herdr's pass-through of the original pane's fraction.spawn/split-panereport only the new pane's bare id on stdout, unlike tmux/herdr, so the tab (and, on a tab or pane:* placement, the workspace) cost a follow-upwezterm cli list --format jsonlookup rather than a free read of output already held; theworkspaceplacement is the one exception, since the workspace name is chosen byopen()itself. - No
-
dff6276: Route worktree creation through the multiplexer that binds worktrees to workspaces, so a worktree is grouped with its repo where the backend supports it. herdr binds a worktree to a workspace as a first-class record — the binding its UI groups a repo's primary checkout and its worktrees by — and only its own
worktree create/worktree openproduce one:git worktree addfollowed byworkspace create --cwd <checkout>yields a workspace herdr does not know is a worktree at all. tmux has no workspace tier and binds nothing, so callers fall back to plain git plus a normalopen— same command, both backends.cyber-mux worktree addtakes--at,--launch, and--base. With neither--atnor--launchit is unchanged: plain git, no backend resolved, works outside any multiplexer (nothing is opened, so nothing can be grouped).--launchimplies--at workspace, the only placement a binding can attach to. A placement that cannot carry a binding degrades rather than failing — a worktree in a split pane is a complete outcome — reported asworkspace: nullplus a note on stderr, so--format jsonstays clean.New
cyber-mux worktree open <path>groups a checkout that plain git created earlier, making "add now, group later" a real story. Newcyber-mux worktree listreports every worktree of the repo and the workspace each is open in; its path/branch/linked/prunable always come from git on every backend, so two backends can never disagree about the same worktree.worktree listandworktree removenow answer outside a multiplexer.cyber-mux open,worktree add, andworktree opentake--label, naming whatever--atopened at whatever tier it opened it — a workspace, tab, or pane label on herdr; a window name or pane title on tmux (workspaceandtabboth collapse to a Window there). Each backend takes it at birth where its own CLI allows (workspace create --label,tmux new-window -n— which also disables tmux'sautomatic-rename, so the name survives what the pane runs) and names the space immediately after where it does not. Note what you get without it: sinceworktree addalways passes--pathto hold the sibling convention across backends, herdr labels the workspace after the checkout path's basename —--branch feat/deep/nameyields a workspace namednameunless you pass--label.cyber-mux worktree removereleases a bound workspace instead of orphaning it, with the gates unchanged and identical on every backend: the checks run before the workspace is released (a refused removal has no side effect) and the release runs before git removes the checkout (no workspace left on a dead directory).
Patch Changes
- 76ee25b: Fix
liston the herdr backend to report every live pane, including one with no agent/harness running in it. Previously such panes (a plain tab, an extra split, or a blank pane fromopenwith no--launch) were silently dropped, contradictinglist's own "enumerate every live pane" contract. - 5de47c3:
cyber-mux worktree add/open/list/removeno longer leak the multiplexer's raw diagnostic on theworktree-failederror. Previously, when opening or binding a worktree's pane failed on the backend (tmux or herdr), the generic catch-all forwarded that failure's message verbatim — including the backend's own name and its raw stderr — the one path AXI's "never leak a dependency's name or text" rule didn't yet reach. This CLI's own worktree refusals (a dirty-checkout guard, a primary-checkout guard) are unaffected and still report their own text as before; a genuine backend failure now reports a generic, codedworktree-failedmessage, with the raw diagnostic written to stderr as a non-load-bearing detail instead.