jackin' Capsule: In-Container Control Plane
The jackin’ Capsule is the in-container runtime control plane for every Capsule-managed role container. Its executable is jackin-capsule, installed at /jackin/runtime/jackin-capsule in the derived image and run as PID 1 when the container starts.
The Capsule is not the Docker container, the role image, or an agent runtime. It is the process inside the container that owns PTYs, supervises child processes, renders the in-container multiplexer, exposes the Unix socket API, and performs deterministic runtime setup before each agent starts. The jackin host CLI uses that process as the boundary between host orchestration and in-container session state.
The problem it solves
Section titled “The problem it solves”When jackin’ used tmux as the session layer, three structural limitations became apparent:
No structured session inventory. Querying which agents were running required docker exec <container> tmux list-sessions from the host — a subprocess round-trip that returns a string jackin’ then had to parse. There was no typed interface; every session management operation went through shell command construction and string output parsing.
No agent-state awareness. tmux knows about terminal sessions and windows. It has no concept of whether the process inside a pane is working, waiting for input, or finished. jackin’ could not ask “is this agent blocked?” without reading raw PTY output from outside the container.
No event stream. The host could not subscribe to “a session ended” without polling. The only reliable signal was the container stopping, which was too coarse — a container could be running with zero active sessions.
These three gaps are why observability, attention prompts, and future desktop integration were hard to build on top of tmux. Every feature that needed to know what agents were doing required another docker exec tmux ... round-trip with output parsing.
Why build it in-house
Section titled “Why build it in-house”The closest public reference is Herdr — a Rust multiplexer purpose-built for AI coding agents with four-state status tracking, a Unix socket API, and workspace-level roll-ups. Herdr cannot be embedded in jackin’: it is AGPL-3.0, which conflicts with the Apache-2.0 license used by jackin’.
Beyond the license, Herdr wraps bare host processes. jackin’ wraps Docker containers. When Herdr sees docker attach rather than the agent process, its PTY heuristics degrade — it is watching the wrong process. jackin-capsule runs inside the container and reads the agent’s PTY output directly, which is exactly where the heuristics need to run.
Building it in-house gives jackin’ a control plane that is:
- Typed and structured. The socket has a one-shot JSON control channel for inventory queries and a binary attach channel for interactive sessions. The host asks for a session list and gets a typed response, not a string to parse.
- Agent-aware. The multiplexer owns every PTY session from spawn to exit. It sees the output directly and can infer state (working / blocked / done / idle) without guessing.
- Socket-backed. The daemon is the source of truth for session status and snapshots. The host can query typed state through the control channel today, and the same endpoint is the planned source for host-daemon and desktop event streams.
- Dependency-free in the image. tmux is no longer installed in the construct image. The derived image only needs
jackin-capsuleat/jackin/runtime/jackin-capsule.
Process model
Section titled “Process model”jackin-capsule operates in three modes:
Daemon mode (PID 1). The binary is the container’s init process. It:
- Reaps zombie children (mandatory for PID 1 — processes re-parented to PID 1 when their parent exits must be waited on or they accumulate as zombies in the process table)
- Spawns PTY sessions for each agent (one session per pane leaf in the layout tree)
- Renders the status bar and compositor output
- Listens on
/jackin/run/jackin.sockfor client connections and host CLI queries
The host launcher passes the initial agent slug as the container command
argument after the image name, for example docker run ... jk_the-architect codex. The daemon uses that argv value only to choose the first tab.
JACKIN_AGENT is set later on each agent PTY before
/jackin/runtime/entrypoint.sh runs, so the container’s ambient environment
does not imply that every pane is the same agent.
Attach client mode (PID ≠ 1). When the operator runs docker exec -it <container> jackin-capsule, the binary connects to the daemon socket, bridges the operator’s terminal to the active session, and forwards attach frames between the terminal and daemon.
Runtime setup subcommand (PID ≠ 1). /jackin/runtime/entrypoint.sh calls jackin-capsule runtime-setup before each agent exec. The subcommand performs idempotent container setup (git identity, GitHub HTTPS rewrites, and the gh credential helper), installs the shared git trailer hook on demand, and refreshes per-agent home/auth handoff state. The hook at /jackin/state/git-hooks/prepare-commit-msg is a symlink back to /jackin/runtime/jackin-capsule, so Git invokes Rust code directly for Co-authored-by and Signed-off-by normalization instead of a shell script. The container init marker lives under /jackin/state, so global git/GitHub setup runs once per running container; per-session auth handoff still refreshes before every agent launch.
Those modes deliberately keep ownership narrow:
| Owner | Lives where | Owns | Does not own |
|---|---|---|---|
jackin host CLI | Host process | Docker lifecycle, image build, socket mount path, final cleanup, operator prompts | Agent PTYs, pane layout, terminal rendering |
jackin-capsule daemon | PID 1 inside role container | PTYs, tabs/panes, socket listener, render loop, session inventory, child reaping | Host config, host git state, host credentials |
jackin-capsule attach client | Short-lived docker exec process | Host terminal raw mode, attach protocol bridge | Session state after detach |
/jackin/runtime/entrypoint.sh | Agent child process path | Runtime dispatch to Claude/Codex/Amp/Kimi/OpenCode | Socket protocol, PTY lifecycle |
jackin-capsule runtime-setup | Called by entrypoint before each agent exec | Idempotent in-container setup and auth/home handoff | Host-side mutations |
The important invariant is that the host starts and stops containers, but the Capsule owns what happens inside a running container after PID 1 starts. Contributors should avoid adding host-side code that scrapes terminal output or shells into the container to infer session state; the socket is the supported boundary.
Unix Socket and Wire Protocol
Section titled “Unix Socket and Wire Protocol”The daemon listens at /jackin/run/jackin.sock inside the container. jackin’ bind-mounts a host directory for the instance to /jackin/run; the socket file appears inside that directory after the daemon starts:
docker run ... -v ~/.jackin/sockets/<container-name>:/jackin/run ...That makes the host-side path ~/.jackin/sockets/<container-name>/jackin.sock. One socket directory exists per running instance. The socket is a Unix domain socket, not TCP. Its parent directory is locked to 0700 and the socket file is locked to 0600; the attach channel has no separate authentication layer beyond filesystem ownership, so widening those permissions would allow an in-container peer to inject input into the focused PTY.
The same bind-mounted /jackin/run directory also carries agent.toml. The host writes ~/.jackin/sockets/<container-name>/agent.toml before docker run; Capsule reads it as /jackin/run/agent.toml during startup. This keeps jackin-owned in-container runtime state under the single /jackin/ root.
Two protocols share the same socket. The first byte sent by a client selects which one the daemon dispatches:
- Control channel — first byte
0x00, interpreted as the first byte of a four-byte big-endian length prefix. The rest of the frame is a UTF-8 JSON request. The daemon reads one request, writes one length-prefixed JSON response, then closes the connection. This is used for one-shot status and snapshot queries. - Attach channel — any non-zero first byte, interpreted as an attach-frame tag. Every frame is
[tag: u8][payload_len: u32 big-endian][payload bytes]. Interactive terminal bytes stay raw on this hot path; they are not JSON-wrapped or base64-encoded.
Both channels cap a single payload at 4 MiB. The control channel rejects malformed JSON and unknown structural shapes. Unknown tagged message variants decode as Unknown so a newer peer can fail fast instead of hanging on a read. The attach channel rejects unknown tags, truncated payloads, and oversized lengths.
The authoritative control-channel wire types live in crates/jackin-protocol/src/control.rs so the host CLI and the in-container binary share the same serde schema without making jackin depend on jackin-capsule’s PTY and terminal stack. The attach-channel frame codec lives in crates/jackin-capsule/src/protocol/attach.rs because it is only used by interactive Capsule clients.
Current control messages:
| Request | Response | Used by |
|---|---|---|
{"type":"status"} | {"type":"session_list","sessions":[...]} | jackin-capsule status, host session inventory, hardline --inspect |
{"type":"snapshot"} | {"type":"snapshot","tabs":[...],"active_tab":N} | jackin console live preview and pane picker |
SessionInfo carries id, label, optional agent, public state, and active. TabSnapshot carries render-order tabs, each tab’s focused pane id, and one PaneSnapshot per pane leaf. PaneSnapshot carries session_id, label, optional agent slug, and state. The public states are working, blocked, done, and idle; today they are derived inside crates/jackin-capsule/src/session.rs, with the fuller state authority tracked in Agent runtime status authority.
Current attach frames:
| Message | Direction | Purpose |
|---|---|---|
Hello | client → daemon | Announce terminal size; optionally request a new shell or agent session; forwards per-session env overrides from docker exec; optionally asks focus to move to a specific session id |
Input | client → daemon | Forward raw keystrokes / paste / mouse bytes |
Resize | client → daemon | Terminal window resized |
Command | client → daemon | Reserved for future structured client commands |
Detach | client → daemon | Request client detach |
FocusIn / FocusOut | client → daemon | Outer terminal focus changed |
Welcome | daemon → client | Handshake response |
Output | daemon → client | Terminal bytes to render |
SessionList | daemon → client | Typed session inventory |
Shutdown | daemon → client | All sessions exited; client should exit |
The interactive attach path is long-lived. The short-lived docker exec client connects, sends Hello, then forwards operator terminal input as Input frames and terminal resizes as Resize frames. The daemon replies with Welcome, then streams rendered terminal output as Output frames. When the operator detaches, the client sends Detach and exits; the daemon keeps every PTY running. When the last live session ends, the daemon sends Shutdown, drains the final frame, exits as PID 1, and Docker reports the role container stopped.
The attach protocol is intentionally not a general RPC surface. It is optimized for terminal traffic and has three constraints:
- Raw terminal bytes stay raw. Keystrokes, paste payloads, mouse sequences, ANSI output, and OSC passthrough use
Input/Outputframes without JSON, escaping, or base64. - Structured control stays out of the hot path. Inventory and snapshots use the one-shot control channel. Future high-level actions should prefer the control channel unless they are part of an active terminal attach.
- The daemon is authoritative. A client can request a new shell or agent in
Hello, but it cannot directly mutate the session table. The daemon validates the request, spawns the PTY, and then reports the resulting state.
How State Moves Across the Boundary
Section titled “How State Moves Across the Boundary”The host and Capsule intentionally exchange only a small set of facts:
- Launch metadata is written by the host into
/jackin/run/agent.tomlbeforedocker runstarts the role container. The file contains the role key, workspace workdir, ordered supported-agent list, and per-agent model overrides. Capsule reads it at startup and treats it as the source of truth for session spawning. - Initial agent selection crosses from host to container as argv on
docker run, not as a container-wideJACKIN_AGENTenvironment variable. The daemon uses it only to spawn the first tab. - Per-session agent identity is set later by the daemon immediately before it spawns
/jackin/runtime/entrypoint.shfor an agent pane. Shell panes get noJACKIN_AGENT. - Terminal input crosses through the attach channel as raw bytes in
Inputframes. The daemon either consumes bytes that target Capsule UI (palette, prefix commands, tab chrome, dialog controls) or writes them to the active PTY unchanged. - Wheel input is routed by focused-pane state. Mouse-enabled programs receive re-encoded pane-local SGR or xterm mouse events. Normal-screen panes that did not request mouse reporting use jackin’ scrollback only when vt100 or inline-scroll capture has retained history, including rows preserved before normal-screen clear/redraw and top-anchored scroll-region movement; that same retained history is the only source for pane scroll chrome. When retained history is empty, wheel input stays local to jackin’ so it cannot mutate the prompt as cursor keys, whether the pane was spawned as an agent or shell. Alternate-screen panes without mouse reporting receive cursor-key fallback, matching terminal emulator alternate-screen scroll behavior.
- Terminal output is read by a per-session PTY reader, parsed into that pane’s
vt100::Screen, composed with Capsule chrome, and sent back to the attached client as raw ANSI bytes inOutputframes. - Session inventory is read from the daemon’s in-memory session table and returned through the control channel as typed JSON. The host does not scrape rendered terminal text to learn which sessions exist.
- Console previews are read from the daemon’s tab/pane snapshot. On same-kernel Docker hosts the host opens the bind-mounted Unix socket directly; on Docker Desktop, where the macOS host cannot connect to a Linux VM Unix socket, jackin’ falls back to
docker exec ... /jackin/runtime/jackin-capsule snapshotand reads the same JSON response through stdout. - Runtime setup inputs are already present in mounted
/jackin/state and environment selected by the host launcher.jackin-capsule runtime-setupcopies or applies that state inside the container before the agent process replaces the entrypoint.
The full lifecycle for a normal attach looks like this:
host: jackin load -> docker run -d ... <image> <initial-agent>container: jackin-capsule PID 1 -> bind /jackin/run/jackin.sock -> spawn initial PTY -> exec /jackin/runtime/entrypoint.sh inside that PTYentrypoint: -> jackin-capsule runtime-setup -> exec selected agent runtimehost: docker exec -it <container> /jackin/runtime/jackin-capsuleclient: -> connect /jackin/run/jackin.sock -> send Hello(rows, cols, spawn=None)daemon: -> send Welcome -> render current tab/pane tree as Output framesoperator: -> keys become Input framesdaemon: -> Capsule UI consumes palette/prefix/dialog keys -> all other bytes go to focused PTY stdinPTY reader: -> reads agent output -> updates vt100 screen -> render loop emits Output framesStarting another agent session is the same attach flow with a spawn request:
host: jackin hardline --new --agent codex -> docker exec -it <container> /jackin/runtime/jackin-capsule new codexclient: -> send Hello(rows, cols, spawn=Agent("codex"))daemon: -> create new tab/pane -> set JACKIN_AGENT=codex only for that PTY -> run /jackin/runtime/entrypoint.sh -> focus the new tabReading state without attaching uses the control channel instead:
host: -> open ~/.jackin/sockets/<container-name>/jackin.sock -> write len_be32 + {"type":"snapshot"}daemon: -> read one request -> serialize ServerMsg::Snapshot -> write len_be32 + JSON -> close connectionhost: -> render console preview / pane pickerOn Docker Desktop, the host cannot open the Linux VM’s Unix socket path directly even though the parent directory is bind-mounted. In that case src/runtime/snapshot.rs falls back to docker exec ... /jackin/runtime/jackin-capsule snapshot; the in-container client still speaks the same control protocol to the same daemon socket, then prints the JSON response for the host to read.
Failure and Security Boundaries
Section titled “Failure and Security Boundaries”The socket is powerful because the attach channel can send bytes to the focused PTY. The current security model is therefore filesystem ownership:
/jackin/runis created under the single/jackin/container-owned tree and chmodded0700./jackin/run/jackin.sockis chmodded0600.- The listener caps concurrent clients at 16 and drops excess connections so an in-container process sharing the agent uid cannot exhaust all attach slots.
- Malformed control requests are rejected and logged; the daemon does not block waiting forever for a reply it will never send.
- Oversized control and attach payloads are rejected before allocation beyond the 4 MiB cap.
- The host does not write into the socket path except by mounting the per-instance directory. Runtime communication is host-to-container or in-container; it does not mutate host config, host repos, or host credentials.
If the socket listener fails repeatedly, the accept loop backs off and then stops after repeated errors instead of spinning and flooding logs. Because the Capsule is PID 1, unrecoverable daemon failure naturally brings the role container down and lets the host path report a stopped or crashed instance rather than leaving an invisible half-attached terminal.
Communication paths
Section titled “Communication paths”The jackin CLI talks to the Capsule through a few deliberately narrow paths:
| Path | Mechanism | Purpose |
|---|---|---|
| Initial launch | docker run -d ... <image> <agent> | Starts jackin-capsule as PID 1. The trailing <agent> argv selects only the first tab; it is not exported as global container state. |
| Interactive attach | docker exec -it <container> /jackin/runtime/jackin-capsule | Starts a second jackin-capsule process in attach client mode. That client connects to the PID 1 daemon over /jackin/run/jackin.sock and bridges the operator terminal. |
| New agent or shell | docker exec -it <container> /jackin/runtime/jackin-capsule new [agent] | The attach client sends a Hello frame with a spawn request. The daemon creates the new PTY and sets JACKIN_AGENT only for agent sessions. |
| Session inventory | jackin-capsule status, direct host UnixStream, or docker exec ... jackin-capsule snapshot | Sends a typed control request and receives typed state. The CLI command prints a human-readable line format for older host paths; console snapshot code reads JSON directly, using docker exec when the host cannot connect to the bind-mounted Unix socket. |
| Runtime setup | /jackin/runtime/entrypoint.sh calls jackin-capsule runtime-setup | Runs container-local setup immediately before the selected agent process is exec’d. |
Layout model
Section titled “Layout model”Each tab owns a binary tree of panes. Any leaf can be split into two children (HSplit or VSplit) with a configurable ratio. The operator can build an arbitrary N×M grid by successive splits. The active pane receives input; all other panes continue running.
Tab└── HSplit (ratio: 0.5) ├── Leaf → Claude session └── VSplit (ratio: 0.5) ├── Leaf → Codex session └── Leaf → ShellVirtual terminal emulator
Section titled “Virtual terminal emulator”Each pane owns one PTY and one vt100::Parser<OscCapture>. The parser maintains a vt100::Screen cell grid from the PTY output stream, while OscCapture records OSC and extension sequences that need focused-pane passthrough. When the operator switches tabs, splits, or reattaches, the compositor re-renders the saved grid to the host terminal. Wide glyph continuation cells are skipped during replay so CJK and emoji output keeps the same column layout the agent drew.
Dirty-output renderer
Section titled “Dirty-output renderer”jackin-capsule uses an internal dirty-row layer in crates/jackin-capsule/src/render.rs rather than replacing vt100 with a larger terminal engine. The decision follows the Zellij model: keep per-pane terminal state, track changed visible lines, and serialize only the changed pane body rows when the rest of the frame is still valid. Zellij’s OutputBuffer / changed_lines design was the primary reference; tmux’s grid/screen/redraw split was the baseline for separating pane content from status bar and pane chrome.
The crate options were evaluated as follows:
vt100stays as the parser and grid source.Screen::contents_diffis useful for whole-screen origins, but it does not know jackin’ pane offsets, borders, status rows, dialog overlays, or the two per-pane dimming strengths, so jackin’ snapshots visible rows itself. Inactive split panes use a light ANSI-dim cue; modal backdrops use stronger darkened colors. jackin’ temporarily pins a forkedvt100revision forScreen::clear_scrollback()andCSI 3Jsupport; those belong invt100because scrollback is owned by the in-memory terminal grid, not by the jackin’ pane compositor.vt100-cttis a newer fork ofvt100, but it has far lower ecosystem adoption and its default feature set pulls intui-term,ratatui, andratatui-core. Without a fork-only bug fix that jackin’ needs, the canonicalvt100crate remains the lower-risk choice.termwiz::Surfacehas a mature change-log and diff API, but adopting it would mean translating everyvt100cell into a second surface or replacing the parser. That is more architecture churn than the current problem needs.alacritty_terminalis a terminal-emulator core, not a small compositor helper. It would couple jackin’ to a heavier internal grid API without replacing the chrome rules specific to jackin’.
| Approach | Performance model | Correctness risk | Integration / maintenance | License / reuse |
|---|---|---|---|---|
Internal Zellij-style row cache over vt100 | Scan visible rows for dirty panes; emit only changed rows plus that pane’s chrome | Low: keeps the existing parser and compositor coordinate rules | Small local API; coupled to jackin’ pane geometry by design | Apache-compatible dependencies; reusable inside jackin-capsule, not a separate crate boundary |
vt100::Screen::contents_diff | Emits a diff between two whole-screen origins | Medium: pane offsets, dimming, borders, overlays, and selection would need wrapping around every byte stream | Low dependency cost but awkward at the call sites | Apache-compatible; reusable only for full-origin surfaces |
vt100-ctt fork | Same basic screen/diff model as vt100 | Low technically, higher supply-chain risk from lower adoption | Low code churn but unnecessary dependency churn | MIT-compatible; rejected until a fork-only fix is needed |
termwiz::Surface | Mature sequence-numbered surface diff and compositing | Low at the surface layer, higher while translating from vt100 | High: duplicate/replace the current screen model | MIT-compatible; reusable but too broad for this PR |
alacritty_terminal / emulator cores | High-performance terminal grid primitives | Medium: more internal emulator semantics to own | High: larger API surface and version churn | Apache/MIT-compatible options; tightly tied to emulator internals |
The shipped layer is PaneBodyCache: each visible pane body keeps a snapshot of rendered cells (contents, style, width). PTY output marks only that pane dirty. The render ticker coalesces bursts at roughly 30 fps, compares the current visible rows against the cached rows, emits cursor moves plus ANSI for changed rows only, then repaints that pane’s border and scrollbar. The status bar, dialogs, selection overlay, layout, and full-frame lifecycle stay separate.
Full-frame redraws are still intentional and named in crates/jackin-capsule/src/daemon.rs: first attach, resize, tab switch, layout change, split/close, zoom/unzoom, scrollback browsing, pane clear, dialog/palette/confirm overlays, selection repaint, focus/style changes, session exit, cache miss, and unsafe partial cases all take the full path. With JACKIN_DEBUG=1, each render logs its kind (full or partial), reason, dirty pane count, rows emitted, bytes emitted, and duration in microseconds so regressions can be compared against the old full-pane path from logs.
Status bar
Section titled “Status bar”The top two rows of the host terminal are owned by jackin-capsule and never yielded to agent sessions. They render:
jackin'brand pill — bright-green background, near-black text. Always visible. When an operator shares their screen, others can immediately see the container is running inside jackin’.- Session tabs — one tab per session. Active tab is bold white text on a lifted graphite background, separate from the brand green, with the white underline as the primary active cue. Inactive tabs are grey. Mouse-clickable tabs lift their background on hover. Tabs show a state glyph for the session roll-up. The blocked attention glyph is latched: incidental PTY output does not clear it, and explicit operator keyboard input to that pane does.
- Menu button — the right side of row 0 keeps a padded, clickable
☰Menubutton. It uses blue chrome rather than the green brand/focus color, lifts on hover, and switches the pointer cursor where supported. When prefix mode is enabled and awaiting the next key, the button changes toprefix…so the operator can see that Capsule has consumed the prefix byte and is waiting for the command key.
Capsule always reserves the bottom terminal row for the white session context bar. The right side shows the short jackin instance ID from JACKIN_INSTANCE_ID, such as spamcw91 for jk-spamcw91-jackin-thearchitect. The launcher injects both JACKIN_CONTAINER_NAME and JACKIN_INSTANCE_ID before the capsule daemon starts, so the first frame can render the instance ID without waiting for GitHub, branch polling, shell startup, or a later hostname update. The left side is reserved for branch or pull request context only: when the workspace workdir’s current branch has an open GitHub pull request, it shows PR #<number> · <title>. While gh is still resolving an open pull request for a branch, it shows Resolving PR · <branch> instead of pretending there is no pull request. On a non-main / non-master branch without an open PR, the same row shows Branch · <branch> on the left. On main, master, or a directory without a recognizable branch, the left side stays empty and only the instance ID remains visible. The PR or branch portion opens a GitHub context modal with branch, URL, title, best-effort gh pr checks status, and an Enter hint for copying the PR URL when a pull request exists. The empty left side on the default branch is not a GitHub-context target. The GitHub URL value is the emphasized clickable copy target. The right side opens Container info with the full container name, role, active pane’s agent, current workdir, and an Enter hint for copying the container name. The Container ID value is the emphasized clickable copy target. All click targets switch to a pointer cursor on supported terminals and lift their color while hovered. The daemon polls local Git branch state several times per second in a background task, so switching branches updates the bar before GitHub metadata is refreshed. gh PR/check lookups are cached per branch and refreshed no more than once per minute; switching to main or master clears the branch/PR detail while keeping the always-on instance ID.
Every Capsule dialog renders through the same full-frame overlay path. That path redraws the full multiplexer background in backdrop colors: pane bodies, pane borders, active-pane focus chrome, the top status bar, and the bottom session context bar are all strongly dimmed before the active dialog is painted. This is intentionally stronger than the light inactive-pane cue used in split layouts. No focused-pane green border or scrollbar remains visible behind a modal; focus returns to pane chrome only after the dialog closes. The dialog footer hint renders above the bottom status row with one blank spacer row between them, so modal key hints stay in the bottom chrome area instead of inside or next to the dialog.
The status bar is drawn after hiding the cursor, and the cursor is restored after the draw. It never causes the pane content to repaint unnecessarily.
Input routing
Section titled “Input routing”Key press arrives │ ├─ Row 0 mouse click → tab switch ├─ Bottom context-row click → GitHub context on the left, container info on the right ├─ Direct palette key (default `Ctrl+\`, override via JACKIN_PALETTE_KEY) → command palette ├─ Prefix key (Ctrl+B by default when JACKIN_PREFIX is set) → tmux-style prefix-command dispatcher (Space/`:` palette, `c` new tab, `d` detach, `Ctrl+L` clear pane, `"` / `%` splits, `n` / `p` tabs, etc.) ├─ Prefix focus/resize commands and Alt+Shift+Arrow resize commands → multiplexer action ├─ Command palette open → palette key handler └─ Everything else → active pane PTY stdinThe palette key (and, when enabled, the prefix-key state machine) are the only keystrokes the multiplexer intercepts from the input stream in normal mode. The default Ctrl+\ (0x1C) is picked because raw-mode terminals never emit it as content and no agent uses it as an editing key; the earlier Ctrl+J default collided with the literal LF byte that multi-line editors use as a line continuation, so Ctrl+J is only available as an opt-in via JACKIN_PALETTE_KEY=C-j. Plain Ctrl+L is forwarded unmodified; the clear path owned by jackin’ is explicit through the palette’s Clear pane action or prefix then Ctrl+L. That command clears scrollback saved by jackin’ via vt100, sends form-feed to the pane so the shell or TUI redraws its own visible screen, and never uses RIS (ESC c) because a reset would disrupt pane modes. The close path is scoped by the active tab’s pane count: a single-pane tab renders Close tab and routes straight to confirmation, while split tabs render Close and push the pane-vs-tab target picker. The top-level Exit command opens an Exit? confirmation; confirming terminates every live pane, lets PID 1 exit after the session set is empty, and hands control back to the host finalizer so the normal cleanup/eject path removes the role container, DinD sidecar, certs volume, and network. Detach remains explicit through prefix d or the attach protocol’s Detach frame and keeps sessions running. Everything else — including all agent-specific shortcuts, bracketed paste, mouse events, and extended key sequences — is forwarded unmodified to the active PTY.
Dialog dispatch receives normalized keys, not raw terminal protocol details. When a focused agent enables kitty / CSI-u keyboard reporting, the input parser converts unmodified arrow keys and Escape back to the legacy byte forms the dialog handlers match, and it suppresses release events. That keeps Esc as “pop one dialog frame” even after an embedded TUI has enabled progressive keyboard reporting.
Agent discovery
Section titled “Agent discovery”Available runtimes are declared in /jackin/run/agent.toml, which is generated from the validated role manifest at launch time. The daemon reads the ordered agents list at startup to populate the agent picker in the command palette and to validate jackin-capsule new <agent> requests; model overrides live in the same file’s [models] table.
OSC passthrough opt-outs
Section titled “OSC passthrough opt-outs”Modern terminals interpret OSC escape sequences agents emit for window titles, clipboard writes, desktop notifications, and hyperlinks. By default jackin-capsule forwards each family from the focused pane to the operator’s outer terminal so the agent’s UI works the same as it would running directly on the host. Operators running an untrusted role can disable any family per-container with these environment variables (set on the host before jackin load so they reach the derived image’s runtime):
| Env var | Disables when set to deny / off / no |
|---|---|
JACKIN_OSC52 | OSC 52 clipboard writes (agent → host clipboard) |
JACKIN_OSC_TITLE | OSC 0 / 1 / 2 window-title and icon-name updates |
JACKIN_OSC_NOTIFY | OSC 9 desktop notifications |
JACKIN_OSC_HYPERLINK | OSC 8 clickable hyperlinks |
OSC 7 (current working directory) is never forwarded — it would tell the operator’s host terminal to track the container’s cwd, which has no meaning outside the container; jackin-capsule captures it locally for the pane title and stops. OSC 8 forwarding additionally rejects any URI whose scheme is not http, https, or mailto (so a compromised agent cannot ship a javascript: or file:// hyperlink that would script the operator’s host terminal on click).
The multiplexer also emits its own OSC 2 title from the compositor. That title is not pane content: it is derived from /jackin/run/agent.toml’s workdir and the cached GitHub branch / pull request context, then re-emitted only when the value changes. This keeps Ghostty-style tab and window lists anchored to the workspace plus pull request title even when a focused pane briefly forwards its own shell title.
Pointer shape feedback
Section titled “Pointer shape feedback”The attach client enables SGR any-event mouse tracking on the outer terminal. When the daemon sees passive pointer motion, it emits OSC 22 pointer-shape hints for interactive chrome when the client reports a real terminal identity through TERM_PROGRAM or a known OSC 22-capable TERM family. Clearly non-interactive terminals (TERM=dumb, TERM=linux), generic PTYs that only report xterm-256color, and known terminals that mishandle the sequence stay on the default pointer:
pointerover clickable chrome owned by jackin’, such as tabs, the top menu button, the bottom-row GitHub and container-info entry points, and clickable dialog values such as the Container ID and GitHub URL copy targets.textover pane bodies where mouse selection owned by jackin’ is available because the focused program did not request mouse reporting.ew-resize/ns-resizeover split borders andgrabbingduring an active resize drag.defaulteverywhere else, and again during attach cleanup.
Terminals that do not implement OSC 22 should ignore the sequence, while terminals that do implement it get the same cursor affordance the web UI uses for clickable elements. The visual behavior is still an enhancement rather than a dependency for mouse input or copy/select behavior.
Clickable Capsule chrome also repaints on hover when passive mouse motion identifies a known target. Tabs, the top menu button, the bottom GitHub context target, the bottom container-info target, and dialog copy values use a slightly brighter background or link color so clickability is visible even when pointer-shape escapes are unsupported.
The policy is read once per session at spawn time; an agent that exports the env var after start does not flip the gate for that session. Set the values on the host, before jackin load, to ensure the derived image carries them through to the multiplexer’s environment.
Distribution and versioning
Section titled “Distribution and versioning”jackin-capsule is versioned identically to the jackin CLI — both use the same build.rs logic that produces {cargo_pkg_version}+{7-char-git-sha}. The host CLI’s version string and the binary’s --version output always match.
How jackin’ acquires the binary
Section titled “How jackin’ acquires the binary”ensure_available in src/capsule_binary.rs resolves the binary in priority order:
-
JACKIN_CAPSULE_BIN=/path/to/binary(env var set) — jackin’ uses that path directly. No cache lookup, no download. The binary can come from anywhere: a local build, a CI artifact, a closed-source build. jackin’ validates only that the file exists and is executable. -
Cache hit — if
~/.jackin/cache/jackin-capsule/<version>/linux-<arch>/jackin-capsuleexists and is executable, it is used immediately. -
Packaged binary — Homebrew formulae install the matching Linux
jackin-capsuleresource under the formula’slibexec/jackin-capsule/linux-<arch>/jackin-capsulepath. If that binary exists and passes the same version check, jackin’ uses it directly. -
Download — on a cache and package miss, a
jackin-capsule-*.tar.gzarchive and its.sha256companion are downloaded from GitHub Releases: the rollingpreviewtag for-devand-preview.*versions, or the versionedv<version>tag for stable releases. The archive hash must match before jackin’ extracts the top-leveljackin-capsulebinary into the cache. On Linux the extracted binary is also verified by executingjackin-capsule --versionand asserting the output matches the expected version string.
jackin’ itself has no knowledge of how to build jackin-capsule from source. Build logic lives exclusively in the developer tool described below.
Building for local development
Section titled “Building for local development”Use the build-jackin-capsule binary (defined in src/bin/build_jackin_capsule.rs) to compile jackin-capsule for Linux and populate the cache. It uses cargo-zigbuild with Zig as the linker — no Docker required — and targets a glibc 2.17 floor to match CI:
arm64→aarch64-unknown-linux-gnu.2.17amd64→x86_64-unknown-linux-gnu.2.17
Option A — one shot (recommended for PR verification):
eval "$(cargo run --bin build-jackin-capsule -- --export)"This builds the binary, caches it under ~/.jackin/cache/jackin-capsule/, and sets JACKIN_CAPSULE_BIN in the current shell so jackin load uses it directly — bypassing the cache lookup entirely.
Option B — two steps (useful when running multiple jackin load invocations):
cargo run --bin build-jackin-capsule# prints: [build] to use: export JACKIN_CAPSULE_BIN=/path/to/cached/binaryexport JACKIN_CAPSULE_BIN=/path/to/cached/binaryThe binary is written to the standard cache path and subsequent jackin load invocations in the same shell reuse it without rebuilding.
Flags:
| Flag | Description |
|---|---|
--arch arm64|amd64 | Target architecture (default: current host’s container arch) |
--export | Print only export JACKIN_CAPSULE_BIN=<path> for eval |
Prerequisites: zig and cargo-zigbuild must be installed. Both are declared in mise.toml; run mise install zig cargo:cargo-zigbuild if missing.
First build takes 2–3 minutes; subsequent builds are incremental via cargo’s dependency cache.
Derived image build
Section titled “Derived image build”src/derived_image.rs copies the resolved binary into the Docker build context so docker build does not need network access at image-build time. The COPY .jackin-runtime/jackin-capsule /jackin/runtime/jackin-capsule instruction is emitted only when the binary path is available; when it is not (e.g. in unit tests), the instruction is omitted and the image falls back to whatever /jackin/runtime/jackin-capsule the base image provides.
Relationship to the rest of jackin’
Section titled “Relationship to the rest of jackin’”| Component | Relationship |
|---|---|
src/capsule_binary.rs | Resolves the binary path: JACKIN_CAPSULE_BIN override → cache hit → download from GitHub Releases |
src/bin/build_jackin_capsule.rs | Developer tool: cargo run --bin build-jackin-capsule — builds via cargo-zigbuild and populates the cache |
src/derived_image.rs | Copies binary into derived image build context |
crates/jackin-capsule/src/runtime_setup.rs | Implements jackin-capsule runtime-setup, the Rust replacement for deterministic entrypoint shell setup |
src/runtime/launch.rs | Adds the per-instance socket directory mount to docker run; connects via docker exec jackin-capsule |
src/runtime/attach.rs | Queries socket for session list; spawns new sessions via jackin-capsule new |
src/isolation/finalize.rs | Queries socket to detect live sessions before cleanup |
| Agent runtime status authority roadmap item | Defines the in-container state authority and consumes agent-state events from the socket once the event stream is implemented |
| jackin’ daemon roadmap item | The daemon will subscribe to each container’s socket and maintain a cross-instance session index |
| Multiplexer Design Rules | Passthrough contract, input routing rules, verification checklist |
See also
Section titled “See also”- Multiplexer Design Rules — the non-negotiable passthrough contract and per-feature verification checklist
- jackin-capsule roadmap item — original problem statement, Herdr evaluation, phased implementation plan
- Agent Orchestrator Research — comparative analysis of Herdr and other tools that informed the design