ResearchAgent Orchestration

jackin❯ Capsule: Multiplexer Rewrite Design History

Status: Historical design record. The rewrite this page documents has shipped; the canonical description of Capsule's current architecture lives in jackin Capsule and Multiplexer design rules. This page exists so the why behind the shipped design — the defects that forced the rewrite, the prior-art borrows, and the compatibility rationale — is not lost once the jackin-capsule roadmap item is trimmed to current/remaining work only.

Why the first attempt was rewritten

The first jackin-capsule attempt produced a working PID 1 binary that opened PTYs, drew a status bar, managed a binary pane tree, and framed messages over a Unix socket. The shape was right; the execution was not. Five categories of defect made the result unusable for modern TUI agents and forced a ground-up rewrite rather than incremental fixes:

  1. Hand-rolled VT emulator without alt-screen support. The original crates/jackin-capsule/src/terminal.rs implemented the vte::Perform trait directly, but its csi_dispatch ignored the h/l actions and never inspected the ? intermediate, so every DEC private mode was silently dropped — alternate screen (\x1b[?1049h), cursor visibility (\x1b[?25h/l), application cursor keys (\x1b[?1), bracketed paste (\x1b[?2004), and mouse modes (\x1b[?1000-1006). Modern TUIs enter alt-screen and expect these modes; without them the visible pane was garbled or blank. This module is long gone — the Capsule Terminal Model page records the two later stages (an intermediate vt100 fork, then the current jackin-term DamageGrid) that replaced it.
  2. Ctrl+J = 0x0A = line feed as the palette-open key. That byte is identical to a literal newline; any LF in pasted text or from a TUI emitting \n opened the command palette and consumed it, so operators effectively could not send Enter to the agent on terminals that don't strictly send \r for the return key. The fix was a proper prefix-key state machine, not a single hardcoded byte — see the shipped input model in jackin Capsule.
  3. Daemon exits when all sessions die. The original daemon called std::process::exit(0) once every tracked session was no longer alive. Because the daemon is PID 1, that call brought down the whole container. An agent that crashed during its first second of launch — common while iterating on roles — looked to the operator like "jackin keeps exiting on me." The daemon now persists until SIGTERM.
  4. Mouse passthrough was a literal no-op. The MousePress branch outside row 0 discarded the event (let _ = session; None) instead of forwarding it. The current daemon (crates/jackin-capsule/src/daemon/mouse_input.rs) implements full mouse routing: pane-relative re-encoding, hover/pointer-shape feedback, scrollbar drag, and text selection.
  5. Hot-path framing was full-screen redraw, base64, and JSON. Every PTY chunk rebuilt the entire cell grid as ANSI, base64-encoded it, wrapped it in a JSON ServerMsg::Output, and length-prefixed the result — megabytes per second of pointless re-encoding between two processes sharing a kernel for an 80×24 pane redrawing at 60 Hz. The shipped attach channel sends raw PTY bytes through a tag-plus-length binary frame instead (see the wire protocol in jackin Capsule).

Smaller defects rode along with these five: single-tab borders always drawn, status-bar tabs appending their state glyph after click-region width was computed (so click targets drifted), the reattach path dropping the outbound channel sender, and the client never propagating SIGWINCH. All are fixed in the shipped implementation.

Architectural reference: Zellij

Zellij was the closest architectural reference for the rewrite. It is Apache-2.0 (freely studied and adapted) and solves the same class of problem at larger scope. The pieces of its design jackin adopted:

  • Client-server split over a Unix domain socket. The server owns all PTYs, all VT state, all tabs/panes; the client owns user input and rendering. Detach and reattach leave PTYs running.
  • Typed instruction bus. Zellij's server threads talk over MPSC channels carrying typed enums. jackin's server is single-threaded enough that a full bus was overkill, but the typed-enum-over-channel shape is what the daemon's tokio::select! loop converged on.
  • Per-pane VT state, replayed on switch. The server keeps a grid for every pane, including non-visible ones; switching tabs replays the target pane from its saved screen instead of asking the program to redraw.
  • Binary protocol on the hot path. Zellij uses protobuf; jackin uses simpler tag-plus-length-prefix binary framing. The point of the borrow was never protobuf specifically — it was "don't put base64-inside-JSON on the hot path."

Zellij carries scope jackin explicitly does not need — multi-client collaboration, plugins via WASM, scrollback search, copy-mode regex. The rewrite cherry-picked the structural pieces and left the rest.

Prior art: Herdr

Herdr is the closest public reference for the multiplexer-server concept: a single Rust binary managing multiple AI coding agents with per-project workspace grouping, four-state status tracking, a Unix socket API, and session persistence across client detach. The comparative table against other tools (CCManager, Agent Session Manager, WezTerm Agent Deck, etc.) lives in the Agent Orchestration Program research page; this section records the specific design borrows Capsule took from Herdr's UI shape (not its code — Herdr is AGPL-3.0, so any reimplementation had to be written from scratch).

Herdr wraps bare host processes; jackin wraps Docker containers. When Herdr sees docker attach rather than the agent itself, its foreground-process and screen heuristics degrade — it is watching the wrong process. jackin-capsule runs inside the container and reads the agent's PTY output directly, which is why the same heuristic approach is reliable for jackin in a way it structurally cannot be for Herdr wrapping a container.

Structural UI borrows (from Herdr's UI shape):

  • Top-of-screen chrome — a brand pill on the left followed by one tab per active session, active tab visually distinct, tab labels carrying the rolled-up state glyph.
  • Empty initial state when no agent is preselected — brand header, zero tabs, a centred hint listing configured agents plus Shell, matching jackin console's no-preselection launch path.
  • Per-tab "most urgent" state roll-up — a tab containing any blocked pane is blocked; otherwise done; otherwise working; otherwise idle. This priority order (blocked > done > working > idle > unknown) drives the in-container tab-strip glyph (tab_label in crates/jackin-capsule/src/tui/components/status_bar.rs, computed inline). crates/jackin-agent-status/src/arbitrate.rs also exposes a reusable roll_up_states helper with the same priority order and unit tests, but it has no production call site yet — it is the natural building block for the still-open host-side per-instance roll-up (see the jackin-capsule roadmap item's remaining work).

Concepts implemented independently (validated by Herdr's live usage, reimplemented from scratch because of the license):

  • Two-stage done state. done (work finished, not yet reviewed) vs. idle (reviewed or empty) so the autonomous task queue does not refill a slot before the operator acknowledges it. Shipped as SessionStatus::acknowledge() in crates/jackin-agent-status/src/lib.rs.
  • Notification suppression when already looking and sound escalation as opt-in — validated design inputs for agent attention prompts, which is still an open roadmap item.
  • Blocking wait semantics on the socket — Herdr's herdr wait agent-status 1-1 --status done is a better interface than polling for automation scripts. This is validated design input, not yet shipped: the control channel in crates/jackin-protocol/src/control.rs is request/reply only today (Status, Snapshot, Agents, ReportRuntimeEvent, StatusCapture, the Usage* family, TokenUsage), with no blocking-wait or streaming-subscribe method. See the jackin-capsule roadmap item's remaining work.
  • Layered state authority — foreground process state, visible-screen signals, and semantic integration reports combined into one arbitration result. Fully specified and shipped as the agent runtime status authority.

What Capsule deliberately did not borrow: Herdr as the session substrate (bare-host PTY management is the wrong shape for a container-internal, statically-linked binary), Herdr's theme system (jackin TUI uses a fixed palette), Herdr's SSH remote tunneling (different threat model than the explicit operator-controlled tunneling planned for jackin-remote), and Herdr's wire protocol verbatim (the socket vocabulary was designed for jackin's own data model).

Terminal compatibility rationale: tmux setting parity

Before the rewrite, docker/runtime/entrypoint.sh carried five non-default tmux option choices the operator had pinned over time to keep agent TUIs working. The rewrite had to reproduce the same observable behaviour without tmux, or real workflows would regress. Each option is now a canonical rule in Multiplexer design rules; the rationale for each substitution:

tmux optionWhy it existedjackin-capsule's substitute
extended-keys alwaysAgent TUIs don't emit the per-app activation escape that on waits forThe attach channel forwards raw bytes both directions, so there is nothing to negotiate — kitty keyboard and CSI-u sequences round-trip unchanged
focus-events onClaude Code / Codex pause animations and polling on focus-outThe daemon tracks a per-pane "outer-terminal-focused" flag and synthesises focus events only for the focused pane in the active tab
allow-passthrough onDesktop notifications, progress, clipboard, and titles were silently dropped without itOSC sequences forward from the focused pane only. OSC 52 clipboard writes are default-deny and require JACKIN_OSC52=allow; other families have per-family operator opt-outs (JACKIN_OSC_NOTIFY, etc.)
escape-time 0tmux's default ESC disambiguation delay misfired vi-mode navigation in agent TUIsA configurable JACKIN_ESCAPE_TIME deadline for a bare ESC, while preserving complete sequences split across chunk boundaries
mouse onMouse clicks/drags/scroll needed to reach agent TUIs that opt into reportingAny-event SGR mouse tracking on the outer terminal, re-encoded per pane's negotiated protocol/encoding

Ghostty compatibility notes

The operator runs jackin inside Ghostty, whose feature set is wider than xterm's. Most of the features the rewrite targeted are shipped and now live in Multiplexer design rules: kitty keyboard protocol pass-through, true-color/OSC 10-11 answered from the pane grid's stored palette, bracketed paste, OSC 52 clipboard, OSC 8 hyperlinks (scheme-filtered), and synchronized output (?2026) — absorbed by the grid rather than forwarded, because the capsule's own ClientWriter brackets every composed frame and forwarding the agent's markers verbatim could let a dropped ESU freeze the outer terminal.

Two Ghostty features from the original design remain unimplemented rather than shipped: the kitty graphics protocol (inline images via APC \x1b_G…\x1b\\) and Sixel graphics. Both were designed to forward unchanged for the focused pane and stay silent for backgrounded panes, matching the OSC passthrough model, but no PassthroughEvent variant or session-level handling for APC/Sixel exists in jackin-term today — unhandled APC sequences fall under the grid's default-deny-unless-allowlisted CSI/APC policy and are dropped. An agent that emits inline images or Sixel output today loses that output rather than having it rendered. This is tracked as remaining work on the jackin-capsule roadmap item.

Historical implementation arc

Phase 1 — cleanup gap closed. Before Capsule, containers used tmux as the session layer, wrapped by a bash wait loop (docker/runtime/supervisor.sh, since deleted) that polled tmux list-sessions every second rather than watching the socket file disappear — tmux list-sessions was robust to stale socket files left behind by a crashed tmux server, where a plain file-existence check would loop forever. A 60-second startup grace period waited for the socket file to appear before entering the monitor loop. Two companion host-side fixes made the cleanup path actually fire: finalize_foreground_session (crates/jackin-isolation/src/finalize.rs) started calling has_tmux_sessions when the container was still Running after docker exec returned, and crates/jackin-runtime/src/runtime/launch.rs started tearing down the DinD sidecar and Docker network in every branch where the container had exited, including crashes.

Phase 2 — Rust binary skeleton. Created the crates/jackin-capsule/ workspace member; implemented PID 1 bootstrap (zombie reaping, signal handling), a tmux-socket inotify watch (replacing the bash polling loop), and a Unix socket listener with a status command; added the cross-compile CI job and the socket mount to docker run.

Phase 3 — the rewrite. Landed as one cohesive change because the five defects above were interlocked — fixing one without the others left the binary unusable for testing — but the work was reviewed in sub-phases, each required to pass cargo nextest run and cargo clippy independently: replacing the VT emulator (later superseded again by the vt100jackin-term transition documented in Capsule Terminal Model); the prefix-key input state machine; the persistent server and binary attach channel; and resize/mouse/status-bar polish. All four sub-phases are shipped. The one piece of that plan explicitly not carried forward: an intermediate design considered keeping exited tabs around for replay until SIGTERM — that was rejected because it weakened the "daemon exits when the last session ends" cleanup contract the host relies on to tear down the container, DinD sidecar, cert volume, and network.

Original (superseded) control-channel API sketch

The pre-rewrite target API sketched a phased vocabulary of status (Phase 2), session.create / session.kill / session.title / session.attach (Phase 3), and events (Phase 3, streaming). The shipped control channel took a different shape: request/reply methods (Status, Snapshot, Agents, ReportRuntimeEvent, StatusCapture, the Usage* family, TokenUsage, ExecCommand) defined in crates/jackin-protocol/src/control.rs, with session creation folded into the attach channel's Hello { spawn } frame rather than a standalone control message, and session kill/title handled by daemon-side operator actions (palette close, tab-strip glyph) rather than exposed as host-callable control methods. Structured session.kill / session.title control methods and a streaming event channel remain open — see the jackin-capsule roadmap item.

On this page