jackin-capsule

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

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

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-capsule at /jackin/runtime/jackin-capsule.

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.sock for 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. The opt-in host-owned attach path moves terminal ownership into the jackin host process instead; when the host cannot connect to the bind-mounted socket directly, jackin-capsule attach-proxy relays the same binary attach bytes over stdio inside the container.

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, refreshes per-agent home/auth handoff state, and repairs container-local agent status reporter config. For Codex, that includes ~/.codex/hooks.json plus [tui] notification settings in ~/.codex/config.toml so agent-turn-complete and approval-requested reach the capsule as OSC 9 notifications. 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:

OwnerLives whereOwnsDoes not own
jackin host CLIHost processDocker lifecycle, image build, socket mount path, final cleanup, operator promptsAgent PTYs, pane layout, terminal rendering
jackin-capsule daemonPID 1 inside role containerPTYs, tabs/panes, socket listener, render loop, session inventory, child reapingHost config, host git state, host credentials
jackin-capsule attach client / attach-proxyShort-lived docker exec processIn-container socket bridge for legacy attach or host stdio fallbackSession state after detach, host browser/clipboard/filesystem actions
jackin host attach clientHost processHost terminal raw mode, direct/proxy attach transport, explicit host-open/file-export destinationsAgent PTYs, daemon session state
/jackin/runtime/entrypoint.shAgent child process pathRuntime dispatch to Claude/Codex/Amp/Kimi/OpenCodeSocket protocol, PTY lifecycle
jackin-capsule runtime-setupCalled by entrypoint before each agent execIdempotent in-container setup and auth/home handoffHost-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

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, snapshot, agent-registry, and cached usage 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-protocol/src/attach.rs because both the host attach client and the in-container Capsule daemon need the same binary framing.

Current control messages:

RequestResponseUsed 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
{"type":"wait_session_status",...}{"type":"session_status_result",...}Scripts that block until a session reaches one of the requested public states
{"type":"session_read_visible",...}{"type":"session_visible",...}Diagnostic tooling that needs the current visible grid for one pane
{"type":"session_status_explain",...}{"type":"session_status_explain",...}jackin-capsule status explain and status capture
{"type":"report_runtime_event",...}one-way forwarded to the daemon loopAgent hook and plugin reporters
{"type":"report_child_agent_state",...}one-way forwarded to the daemon loopBridge reporters that roll descendant agent work into a parent pane
{"type":"events_subscribe",...}persistent stream of ServerMsg eventsHost daemons and tools that need live session, token, and workspace status changes
{"type":"agents"}{"type":"agent_registry","records":[...]}jackin-capsule agents, per-container agent inventory
{"type":"usage_focused"}{"type":"usage_focused","usage":{...}}Capsule prefix-u overlay and active-tab status signal
{"type":"usage_refresh_focused"}{"type":"usage_focused","usage":{...}}Capsule overlay refresh action; refresh runs inside the daemon cache
{"type":"usage_account_list"}{"type":"usage_accounts","accounts":[...]}jackin-capsule usage accounts

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, public state, and an AgentStatusReport when the daemon has evidence. The report exposes the raw detector state, source, confidence, detected agent, foreground pgid, visible-screen booleans, process_exited, foreground_returned_to_shell, stale_report, subagents_active, and revision fields. The public states are working, blocked, done, idle, and unknown; stuck is a diagnostic overlay exposed in status explain, not a replacement state. Raw detector states are unknown, working, blocked, and idle; done is derived when raw idle returns after work and the pane has not yet been acknowledged by focus or attach. The authority is implemented by crates/jackin-agent-status/src/lib.rs, with the evidence snapshot in crates/jackin-agent-status/src/evidence.rs, arbitration in crates/jackin-agent-status/src/arbitrate.rs, debounce/watchdog policy in crates/jackin-agent-status/src/policy.rs, and rule-pack engine in crates/jackin-agent-status/src/rules.rs. Capsule's crates/jackin-capsule/src/agent_status.rs re-exports the pure crate and keeps reporter installers. Rule packs load from embedded fallbacks, then /jackin/runtime/agent-status/packs/, then the operator override directory selected by JACKIN_STATUS_PACK_DIR or $HOME/.jackin/agent-status/packs/. The detection crate also models a signed bundle source for future org-published pack updates: a bundle must verify signer identity and signature before any pack TOML is parsed, and rejected or malformed bundles keep the embedded floor live.

EventsSubscribe keeps the control connection open after the initial request and sends length-prefixed ServerMsg frames. The live event stream includes AgentStateChanged for per-session effective-state transitions, SessionSpawned, SessionExited, TokenUsageChanged, and WorkspaceStatusChanged. WorkspaceStatusChanged is emitted when the daemon's aggregate status snapshot changes; it carries the rolled-up effective status plus session_count, blocked_count, done_count, and working_count. The roll-up precedence is blocked > done > working > idle > unknown, matching the status authority contract used by console badges and future host-daemon consumers.

Usage control replies are daemon-cache reads. Capsule renderers and one-shot clients do not call provider APIs directly; refresh cadence, timeouts, stale states, and provider-child cooldowns stay owned by the PID 1 daemon. The usage surface is account-quota-only: the obsolete workspace/session spend summary messages and CLI subcommands were removed with the Capsule usage overlay cleanup. Per-message token/cost attribution belongs to Token & Cost Telemetry, not this control surface. jackin-capsule usage claude-cli is intentionally outside the control channel: it is an explicit operator diagnostic that runs claude -p /usage on demand and prints the raw CLI stdout/stderr/status as JSON for debugging Claude OAuth snapshot failures.

Current attach frames:

MessageDirectionPurpose
Helloclient → daemonAnnounce 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
Inputclient → daemonForward raw keystrokes / paste / mouse bytes
Resizeclient → daemonTerminal window resized
Commandclient → daemonReserved for future structured client commands
Detachclient → daemonRequest client detach
FocusIn / FocusOutclient → daemonOuter terminal focus changed
ClipboardImageclient → daemonHost attach client sends a bounded small image payload for Capsule validation and staging under /jackin/run/clipboard/
ClipboardImageStart / ClipboardImageChunk / ClipboardImageEndclient → daemonHost attach client streams a larger clipboard image with chunk offsets and SHA-256 verification before Capsule stages and pastes the container path
ClipboardImageErrorclient → daemonHost attach client reports a visible failure for an explicit image-stage request it could not satisfy
Welcomedaemon → clientHandshake response
Outputdaemon → clientTerminal bytes to render
SessionListdaemon → clientTyped session inventory
Shutdowndaemon → clientAll sessions exited; client should exit; failed final sessions include a short reason payload
HostOpenUrldaemon → clientHost attach client opens an explicit HTTP(S) URL selected from jackin-owned UI, such as the GitHub context dialog
HostStageImageFromClipboardPathdaemon → clientHost attach client reads host clipboard text for the explicit image-path staging action and replies with image frames or ClipboardImageError
FileExportStart / FileExportChunk / FileExportEnddaemon → clientHost attach client writes one validated regular file to ~/Downloads/jackin/<instance>/ with chunk offsets and SHA-256 verification

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:

  1. Raw terminal bytes stay raw. Keystrokes, paste payloads, mouse sequences, ANSI output, and OSC passthrough use Input / Output frames without JSON, escaping, or base64.
  2. 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.
  3. 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.
  4. Host-affordance frames are explicit. Clipboard images, host URL opens, and file exports are typed attach frames triggered by operator actions. They do not expose a general host clipboard, browser, or writable host mount to agents.

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.toml before docker run starts 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-wide JACKIN_AGENT environment 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.sh for an agent pane. Shell panes get no JACKIN_AGENT.
  • Terminal input crosses through the attach channel as raw bytes in Input frames. The daemon either consumes bytes that target Capsule UI (palette, prefix commands, tab chrome, dialog controls) or writes them to the active PTY unchanged.
  • Host-affordance actions cross only through explicit attach frames while the host-owned attach client is active. Ctrl+V can become a ClipboardImage frame when the host clipboard contains supported image data; the command palette's Stage image from clipboard path action emits HostStageImageFromClipboardPath and expects image frames or ClipboardImageError in response; GitHub context rows can emit HostOpenUrl; and the command palette's Export file, Export file and reveal, and Export file and open dialogs ask Capsule to validate one workspace or /jackin/run/ regular file before streaming it to the host download directory. The reveal/open variants set explicit post-export flags on FileExportStart; the host attach client acts only after byte count and SHA-256 verification.
  • 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 the grid 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 DamageGrid, composed with Capsule chrome, and sent back to the attached client as raw ANSI bytes in Output frames.
  • 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 snapshot and 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-setup copies 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 PTY
entrypoint:
  -> jackin-capsule runtime-setup
  -> exec selected agent runtime
host: docker exec -it <container> /jackin/runtime/jackin-capsule
client:
  -> connect /jackin/run/jackin.sock
  -> send Hello(rows, cols, spawn=None)
daemon:
  -> send Welcome
  -> render current tab/pane tree as Output frames
operator:
  -> keys become Input frames
daemon:
  -> Capsule UI consumes palette/prefix/dialog keys
  -> all other bytes go to focused PTY stdin
PTY reader:
  -> reads agent output
  -> updates DamageGrid screen
  -> render loop emits Output frames

Starting 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 codex
client:
  -> 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 tab

Reading 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 connection
host:
  -> render console preview / pane picker

On 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 crates/jackin-runtime/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

The socket is powerful because the attach channel can send bytes to the focused PTY. The current security model is therefore filesystem ownership:

  • /jackin/run is created under the single /jackin/ container-owned tree and chmodded 0700.
  • /jackin/run/jackin.sock is chmodded 0600.
  • 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

The jackin CLI talks to the Capsule through a few deliberately narrow paths:

PathMechanismPurpose
Initial launchdocker 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 attachdocker exec -it <container> /jackin/runtime/jackin-capsuleStarts 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 shelldocker 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 inventoryjackin-capsule status, direct host UnixStream, or docker exec ... jackin-capsule snapshotSends 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-setupRuns container-local setup immediately before the selected agent process is exec'd.

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 → Shell

Virtual terminal emulator

Each pane owns one PTY and one DamageGrid (from the crates/jackin-term/src/grid.rs jackin-term crate). The grid feeds PTY bytes through a vte::Parser and maintains the cell grid, alternate screen, scrollback, cursor, and mode state, while a typed passthrough stream (drain_passthrough()) surfaces the OSC and extension sequences that need focused-pane forwarding. 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

jackin-capsule uses an internal dirty-row layer over the jackin-term DamageGrid. 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. Focused live pane-only updates encode borrowed GridPatch rows directly through crates/jackin-capsule/src/tui/socket_backend.rs; full redraws, scrollback views, dialogs, selection, and chrome still render through Ratatui widgets. 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 terminal engine is jackin-term's owned DamageGrid, which records dirty rows at mutation time (DirtyTracker) instead of diffing whole screens, emits typed OSC/CSI passthrough events directly, and keeps primary, alternate, and scrollback rows in a shared daemon RowArena. There is no alternate terminal model in jackin-capsule: no feature flag, fallback path, source-policy exception, or dependency remains for the retired external parser. The Capsule Terminal Model page records why the old vt100 fork was retired and how jackin-term differs.

The shipped layer is DamageGrid dirty tracking plus SocketBackend patch encoding. PTY output marks only that pane dirty. The render ticker coalesces bursts at roughly 30 fps; when the focused live pane is the only dirty surface, the compositor drains dirty_spans(), borrows a GridPatch, and emits cursor moves plus ANSI for changed rows only. The status bar, dialogs, selection overlay, layout, and full-frame lifecycle stay separate and use the Ratatui frame path.

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. At JACKIN_TELEMETRY_LEVEL=debug, 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

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 ☰Menu button. 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 to prefix… 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 separate copy/open actions when a pull request exists. Opening that modal schedules a fresh background gh lookup for the current branch and HEAD, bypassing a recent cached "no PR" answer and still trying the lookup if startup missed gh availability. The empty left side on the default branch is not a GitHub-context target. The GitHub URL row is the emphasized clickable copy target; the Open PR and Open CI rows request a host browser open through the host attach path. The right side opens Debug info with the full container name, role, active pane's agent, current workdir, invocation ID when available. Enter copies the first copyable value in the shared row order (Invocation ID whenever present), and clicking a copyable value copies that row. All click targets switch to a pointer cursor on supported terminals and lift their color while hovered. The daemon watches the container-side Git metadata directory for HEAD changes, so branch switches invalidate stale PR context immediately without a hot polling loop. A once-per-second poll remains as a fallback for missed filesystem events and HEAD movement that does not rewrite HEAD. Background gh PR/check lookups stay cached for the current branch plus HEAD and refresh on a conservative cadence; switching to main or master clears the branch/PR detail while keeping the always-on instance ID.

Most Capsule dialogs render through the same full-frame overlay path. That path paints an opaque solid-black backdrop over the content area and then draws the active dialog on top: pane bodies, pane borders, active-pane focus chrome, and scrollbars are hidden behind the fill, not dimmed. (Dimming the live content read as unstable, so the modal hides it outright.) 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.

Debug info is the status-preserving exception and follows the shared dialog contract in Dialogs & Modals: the top status/tab chrome and bottom context row remain visible, the dialog is centered in the content area between those reserved rows, and the copy/hover/scroll behavior comes from jackin_tui::operator_info::ContainerInfoState projected through TermRock DetailTable / scroll primitives.

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

Key press arrives

  ├─ Row 0 mouse click → tab switch
  ├─ Bottom context-row click → GitHub context on the left, Debug 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 stdin

The 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 the scrollback the pane's DamageGrid holds, 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.

The agent's terminal experience is the highest-priority constraint. No multiplexer feature may intercept, transform, or drop input that the agent should receive. See the Multiplexer Design Rules for the full passthrough contract.

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

Modern terminals interpret OSC escape sequences agents emit for window titles, clipboard writes, desktop notifications, and hyperlinks. jackin-capsule denies OSC 52 clipboard writes from the focused pane by default because they mutate the operator's host clipboard. Set JACKIN_OSC52=allow before jackin load only when a role is expected to copy from the agent pane into the host clipboard.

The lower-risk title, notification, and hyperlink families still default to passthrough so the agent's UI works the same as it would running directly on the host. Operators running an untrusted role can disable those families per-container with these environment variables (set on the host before jackin load so they reach the derived image's runtime):

Env varBehavior
JACKIN_OSC52=allowEnables OSC 52 clipboard writes (agent → host clipboard); all other values keep them denied
JACKIN_OSC_TITLE=deny / off / noDisables OSC 0 / 1 / 2 window-title and icon-name updates
JACKIN_OSC_NOTIFY=deny / off / noDisables OSC 9 desktop notifications
JACKIN_OSC_HYPERLINK=deny / off / noDisables OSC 8 clickable hyperlinks
JACKIN_OPEN_LINKSjackin host browser-open actions, including GitHub open rows, visible URL modified-click, and command-palette link open

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 same filtered GitHub branch / pull request context that feeds the bottom context bar, then re-emitted only when the value changes. This keeps Ghostty-style tab and window lists anchored to the workspace plus pull request title or non-default branch even when a focused pane briefly forwards its own shell title.

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:

  • pointer over clickable chrome owned by jackin, such as tabs, the top menu button, the bottom-row GitHub and Debug info entry points, and clickable dialog values such as Container ID, Invocation ID, and GitHub URL copy targets.
  • text over pane bodies where mouse selection owned by jackin is available because the focused program did not request mouse reporting.
  • ew-resize / ns-resize over split borders and grabbing during an active resize drag.
  • default everywhere 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 Debug 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

jackin-capsule is versioned identically to the jackin CLI — both use the same crates/jackin/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

ensure_available in crates/jackin-image/src/capsule_binary.rs resolves the binary in priority order:

  1. 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.

  2. Cache hit — if ~/.jackin/cache/jackin-capsule/<version>/linux-<arch>/jackin-capsule exists and is executable, it is used immediately.

  3. Packaged binary — Homebrew formulae install the matching Linux jackin-capsule resource under the formula's libexec/jackin-capsule/linux-<arch>/jackin-capsule path. If that binary exists and passes the same version check, jackin uses it directly.

  4. Download — on a cache and package miss, a jackin-capsule-*.tar.gz archive and its .sha256 companion are downloaded from GitHub Releases: the rolling preview tag for -dev and -preview.* versions, or the versioned v<version> tag for stable releases. The archive hash must match before jackin extracts the top-level jackin-capsule binary into the cache. On Linux the extracted binary is also verified by executing jackin-capsule --version and 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

Use the build-jackin-capsule binary (defined in crates/jackin/src/bin/build_jackin_capsule/main.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:

  • arm64aarch64-unknown-linux-gnu.2.17
  • amd64x86_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/binary
export JACKIN_CAPSULE_BIN=/path/to/cached/binary

The binary is written to the standard cache path and subsequent jackin load invocations in the same shell reuse it without rebuilding.

Flags:

FlagDescription
--arch arm64|amd64Target architecture (default: current host's container arch)
--profile debugBuild with the capsule-debug Cargo profile: symbols retained, line tables included. Backtraces from Backtrace::force_capture() resolve to function names, file paths, and line numbers instead of <unknown>. Output binary is named jackin-capsule-debug. The lean release binary is unchanged.
--debugConvenience alias for --profile debug.
--exportPrint 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.

Debug builds for crash triage. When a container crashes with an exit-101 panic and docker logs shows <unknown> frames, use the canonical Debugging Capsule Crashes flow. That page owns the symbolicated-build recipe, RUST_BACKTRACE=full launch, and explicit log-inspection steps.

Derived image build

crates/jackin-image/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

ComponentRelationship
crates/jackin-image/src/capsule_binary.rsResolves the binary path: JACKIN_CAPSULE_BIN override → cache hit → download from GitHub Releases
crates/jackin/src/bin/build_jackin_capsule/main.rsDeveloper tool: cargo run --bin build-jackin-capsule — builds via cargo-zigbuild and populates the cache
crates/jackin-image/src/derived_image.rsCopies binary into derived image build context
crates/jackin-capsule/src/runtime_setup.rsImplements jackin-capsule runtime-setup, the Rust replacement for deterministic entrypoint shell setup
crates/jackin-runtime/src/runtime/launch.rsAdds the per-instance socket directory mount to docker run; connects via docker exec jackin-capsule
crates/jackin-runtime/src/runtime/attach.rsQueries socket for session list; spawns new sessions via jackin-capsule new
crates/jackin-isolation/src/finalize.rsQueries socket to detect live sessions before cleanup
Agent runtime status authority roadmap itemDesign record and regression checklist for the shipped in-container state authority
jackin daemon roadmap itemThe daemon will subscribe to each container's socket and maintain a cross-instance session index
Multiplexer Design RulesPassthrough contract, input routing rules, verification checklist

See also

On this page