Getting Oriented

Architecture

How jackin❯ orchestrates containers, networks, and persisted state

This page is for contributors — anyone who wants to understand or work on jackin itself. Operators do not need to read it to use jackin; the Operator Guide and Concepts cover everything an operator needs.

High-level architecture

runtime topology
jackin CLIoperator interface
Docker Engine
jackin-agent-smith-netper-agent network

Agent Container

Claude Code · mounted dirs

DinD Sidecar

docker:dind · TLS certs volume


~/.jackin/data/runtime state per instance
~/.jackin/agents/cached agent repos
~/.config/jackin/operator config

jackin deliberately uses your existing Docker engine as the operator control plane. For host-side Docker API calls, jackin honors DOCKER_HOST when the operator sets it to a non-empty value (matching Docker CLI's own treatment of an empty DOCKER_HOST= as unset), including TLS endpoints — when DOCKER_HOST points at a tcp:// daemon and DOCKER_TLS_VERIFY / DOCKER_CERT_PATH are set, jackin performs the mutual-TLS handshake with those client certificates, so a remote daemon or a Docker-in-Docker socket on :2376 works out of the box. Otherwise it inspects the active Docker CLI context — including any per-shell DOCKER_CONTEXT override — and connects to that context's Docker host when the context is a local socket or plain HTTP/TCP endpoint, so API calls such as network creation land on the same daemon that later docker run commands use. SSH Docker contexts are rejected for now because the Bollard dependency build cannot reproduce the Docker CLI's SSH transport, and TLS contexts are rejected because jackin does not yet wire a context's TLS material into the connection (a directly-set DOCKER_HOST with explicit DOCKER_CERT_PATH is the supported path for TLS daemons).

That creates an important split:

  • build time happens on the host Docker engine
  • runtime orchestration creates the role container, DinD sidecar, certs volume, and per-instance network on that same selected host Docker engine
  • runtime Docker commands issued by the agent go to the per-instance DinD sidecar

So jackin gets host-side cache reuse for building agent images, while still keeping the agent away from your host Docker daemon during runtime.

Design constraints

jackin architecture is shaped by a few explicit decisions:

  1. Local development first. The first target is a developer machine that already knows how to run Docker.
  2. Environment as source code. Roles should be normal git repos reviewed like normal software.
  3. Workspace boundary separate from tool profile. File visibility and installed tooling are different control planes on purpose.
  4. Operator-owned infrastructure, not black-box runtime. The implementation should stay inspectable and patchable.
  5. Container isolation is acceptable for the first target. jackin does not claim a microVM boundary today.

Async architecture

The host runtime is intentionally a Tokio current_thread runtime. The operator CLI is an orchestrator, not a high-throughput server: keeping one async scheduler thread makes ordering easier to reason about, keeps render and progress state deterministic, and forces expensive work to declare itself instead of disappearing into incidental blocking calls.

The rule is simple: render and progress tasks never block on synchronous I/O or CPU-heavy work. If a call can be async, it runs through the async abstraction; if it cannot be made async, the blocking boundary is explicit.

Call shapeMechanismReason
Async subprocess / command captureShellRunnerCentralizes subprocess execution, diagnostics, cancellation shape, and test fakes instead of sprinkling Command calls through launch code.
Docker lifecycle API callsDockerApi / BollardDockerClientKeeps Docker inspect/list/create/remove operations async and mockable; interactive docker build, docker run -it, and docker exec -it stay on the Docker CLI because their terminal behavior is the compatibility contract.
Unavoidable synchronous filesystem, archive, or CPU worktokio::task::spawn_blockingMoves work that can stall the scheduler off the render/progress thread while keeping the boundary visible in review.
Independent launch stagestokio::try_join! or sibling futuresRuns unrelated setup concurrently while preserving fail-fast error propagation and one diagnostics timeline.
TUI rendering / progress updatesNon-blocking state updates onlyThe render task reads state and paints; it does not perform filesystem, network, Docker, or subprocess work.

This is a cross-cutting TUI rule too: background I/O reports into typed state, and the render path consumes that state. A review that finds std::thread::sleep, blocking std::fs, or direct synchronous command capture in a render-thread crate should treat it as an architecture bug unless the call is already behind an explicit blocking boundary.

Image layers

  1. Derived layer

    jackin-managed
    • UID/GID remapping
    • Claude Code installation
    • Pre-launch hook
    • Runtime entrypoint
    • Plugin bootstrap
  2. Agent layer

    agent repo
    • Language runtimes
    • Development tools
    • Custom configuration
  3. Construct base

    shared
    • Debian Trixie
    • Docker CLI + Compose
    • Git, GitHub CLI
    • mise, ripgrep, fd, fzf
    • zsh + Oh My Zsh

The role repo owns the environment layer. jackin owns the derived agent layer.

That means a role defines the tools and conventions, while jackin injects the agent-specific pieces such as Claude, Codex, Amp, Kimi, or OpenCode installation, the entrypoint, and UID/GID remapping.

Role repository tooling

Role repositories are normal source repositories, so jackin keeps role maintenance split between two surfaces. The main operator CLI owns desktop ergonomics: its role-authoring dispatch validates checked-out role repositories, applies manifest migrations to local copies, and writes small starter repositories for authors. That path lives in crates/jackin/src/role_authoring.rs and reuses the same manifest migration registry and repository contract checks used by runtime launch.

Automation stays on the small role-focused binary in crates/jackin/src/bin/role.rs. CI workflows and future Renovate-style migration jobs should not depend on the full operator CLI just to validate a role repo or restamp a manifest; they call the standalone validator, which shares crates/jackin-manifest/src/repo.rs and crates/jackin-manifest/src/migrations.rs with the desktop path.

Container lifecycle

Loading an agent

Every jackin ... invocation mints a cli.invocation.id before command-specific work starts. Launch paths retain bounded current-invocation progress for the operator surface and export governed telemetry directly to OTLP when configured. --debug controls operator troubleshooting output; it does not create a telemetry artifact.

  1. Resolve role — map the selector to a repo, clone or update it, and reject dirty cached checkouts
  2. Validate the repo contract — require jackin.role.toml and a valid Dockerfile path, validate manifest strictly
  3. Start independent background work — launch non-interactive operator-env resolution, optional workspace git-pull-on-entry, and per-instance sidecar/network setup as soon as their inputs are available
  4. Resolve foreground environment variables — await operator-env values, then prompt the user for interactive env vars declared in the manifest
  5. Generate a derived build context — copy the repo, inject agent assets (entrypoint + runtime hooks), and render a derived Dockerfile
  6. Build the image on the host Docker engine — reusing host-side Docker cache where possible while sidecar/network setup continues
  7. Prepare workspace state — join the deferred git pull before workspace materialization, then write per-instance manifest/index records, runtime directories, and mount lists
  8. Write Capsule launch config — serialize the role key, workspace workdir, ordered supported-agent list, and manifest model overrides to ~/.jackin/sockets/<container>/agent.toml, which is bind-mounted at /jackin/run/agent.toml.
  9. Start the agent container detached (docker run -d ... <image> <agent>) with jackin-capsule as PID 1. The trailing <agent> argv selects only the initial tab; it is not a container-level environment variable.
  10. Attach the operator terminal via docker exec -it <container> /jackin/runtime/jackin-capsule. The client connects to the PID 1 daemon over /jackin/run/jackin.sock and renders the in-container multiplexer.

Launch progress is emitted through crates/jackin-runtime/src/runtime/progress.rs. Rich terminals render the Ratatui launch cockpit; low-capability or non-interactive terminals receive compact deterministic stage lines. The public stages are stable (identity, role, credentials, construct, agent binaries, derived image, workspace, network, sidecar, capsule, hardline) even when implementation details change underneath. The rich cockpit may briefly dwell on stage changes so very fast work still produces a legible full-sequence rail before the terminal is handed to the Capsule client.

Stage labelTechnical work behind the label
identityResolve the launch target, workspace/path label, role selector, requested agent, instance name, image tag, invocation identity, and operator-visible loading title.
roleResolve the role source from config or command flags, acquire/update the role-repo cache, check out the requested ref/branch, and load the role manifest. Default-branch launch refreshes can reuse a fresh .git/FETCH_HEAD within role_repo_refresh_ttl_seconds (default 60; 0 always fetches), while rebuilds, branch overrides, and background image refreshes still fetch.
credentialsInspect host-side agent/GitHub/1Password credential availability read-only, materialize per-instance auth state under jackin-owned state, and prepare the credential mounts/env that the container receives. Non-interactive operator-env reads start before image decision and are awaited before manifest prompts.
constructVerify or pull the shared construct base image (projectjackin/construct:trixie) that the derived role image builds from.
agent binariesResolve and cache the declared agent runtime binaries plus the matching jackin-capsule binary under jackin-owned host cache state. A stale metadata cache no longer blocks a warm launch when an executable binary is already cached: jackin uses that binary immediately, refreshes release metadata in the background, and records labels for the binary actually installed. A cold cache still resolves/downloads normally, with Dockerfile fallback installers available for agents that support them.
derived imageDecide reuse from local image tags and recipe labels; on a miss, generate the role-specific DerivedDockerfile, assemble the temporary Docker build context, copy cached runtime assets into it, and run docker build. Build output remains operator output and is available in the in-memory launch overlay while the process is active. Reuse launches do not block on published-image pulls or agent release lookups; those staleness checks run after attach in a background sentinel, and a stale result rebuilds the image for the next launch.
workspacePrepare the mounted workspace/directory, optional workspace git-pull-on-entry work, per-instance manifest/index records, runtime directories, mount list, and Capsule launch config. Git-pull-on-entry starts before image work and is joined before materialization so mounted sources reflect the pulled state.
networkCreate or reconcile the per-instance Docker network and related host-side runtime names. This can run while runtime binaries and the derived image are prepared.
sidecarStart, recover, or adopt the DinD sidecar container and its certs volume for the role container. Console entry may pre-create one kept sidecar in the background; a later launch adopts it when compatible, then replenishes another kept sidecar after the interactive attach returns. Sidecar readiness is polled during image work, role-state preparation, and workspace materialization.
capsuleStart the detached role container with jackin-capsule as PID 1, the selected agent argv, resolved mounts, runtime config, labels, and environment.
hardlineAttach the foreground terminal to the running Capsule client with docker exec -it, then classify the post-session container state for cleanup, preservation, or recovery.

The cinematic launch animations are gated by jackin-managed role-container discovery. A hyperspace-warp intro (preceded by typed cyberpunk-style phrases) plays before entering the first container, and a decelerating warp outro plays on foreground exit — followed by a duration caption when no role containers remain, or a still-running summary when others do. Digital rain itself lives only in the loading cockpit, not at these boundaries. Discovery failure skips the animation and records the reason in diagnostics instead of blocking launch.

The detached-start + exec split decouples the container's lifetime from the foreground attach: closing the operator's terminal detaches from the client, while the PID 1 daemon and live PTYs keep running. jackin hardline reconnects to the daemon or asks it to spawn a new session. Cleanup distinguishes three post-session states:

  • running → terminal was closed; preserve the container for jackin hardline to reconnect
  • stopped with exit 0 → the user exited cleanly; tear down container, DinD, network, and certs volume
  • stopped with non-zero exit or OOM → crash; preserve the container and DinD so jackin hardline can restart in place

Every fresh launch gets a unique DNS-safe instance identity. Existing running or stopped instances are not fresh-launch blockers; they remain discoverable through hardline and the console. Missing recoverable state is the case that can prompt or block before a fresh load. The detailed contributor contract lives in Runtime Instance Model.

Reconnecting

jackin hardline inspects the target container. If it's running, it attaches with docker exec ... /jackin/runtime/jackin-capsule; hardline --new --agent <agent> uses jackin-capsule new <agent> so agent selection travels as argv. If it's stopped with a non-zero exit (crash / OOM), it restores the required DinD sidecar/network/certs when needed, restarts the role container, and attaches. If the role container itself is missing but indexed recoverable state exists, hardline can rebuild the runtime around jackin-managed local state. Clean exits (exit code 0) are treated as completed sessions — use jackin load to start a fresh instance.

Capsule supervisor and session model

Every role container runs the jackin Capsule (crates/jackin-capsule/src/main.rs) as PID 1. In daemon mode it reaps children, manages PTYs, renders the multiplexer, and serves the socket API. SIGTERM / SIGINT trigger orderly shutdown, and the daemon exits when the last live session ends so Docker reports a clean container exit. The full Capsule design is documented in jackin Capsule.

Sessions are daemon-owned PTYs inside the running container. All agent sessions are equal — there is no "primary" or "secondary" distinction.

Session typeIdentityCreated byReconnectable
Agent sessiondaemon session id plus agent slugjackin load, jackin hardline, or console a/AYes — jackin hardline attaches to the daemon or creates a new session
Shelldaemon session id with no agent slughardline --shell or console x/XNo — intended as an ephemeral zsh pane

When an agent session exits cleanly (the operator types /exit or the agent finishes), the daemon removes that pane. If no live sessions remain, PID 1 exits and the host cleanup path tears down the role container, DinD sidecar, certs volume, and network.

JACKIN_AGENT is set per agent PTY by crates/jackin-capsule/src/session.rs, immediately before /jackin/runtime/entrypoint.sh runs. The daemon's own process does not carry JACKIN_AGENT, so shell panes and future mixed-agent sessions do not inherit a misleading container-wide agent value.

Role identity, workspace workdir, supported-agent order, and model overrides are not duplicated into container-wide environment variables. The daemon reads those values from /jackin/run/agent.toml, keeping all jackin-owned in-container runtime state under /jackin/.

Session state is not yet reconciled against the live container on console refresh — Phase 4 of the Console agent session control roadmap covers this work. The sessions field in the instance manifest is written but not populated until reconciliation lands.

Implementation: crates/jackin-capsule/src/daemon.rs, crates/jackin-runtime/src/runtime/launch.rs (launch_role_runtime), crates/jackin-runtime/src/runtime/attach.rs (reconnect_or_create_session, spawn_agent_session, spawn_shell_session).

Stopping

jackin eject removes:

  1. the agent container
  2. the DinD sidecar
  3. the TLS certs volume ({container}-dind-certs)
  4. the per-agent network

Persisted state remains unless you also purge it.

Networking

Each agent gets an isolated Docker network:

  • network name is derived from the agent container name
  • the agent container and DinD sidecar share that network
  • the agent reaches the daemon through DOCKER_HOST=tcp://{dind}:2376 with TLS mutual authentication
  • the agent reaches published services launched via DinD through JACKIN_DIND_HOSTNAME={dind}
  • different agents do not share a network by default

The DinD sidecar auto-generates TLS certificates at startup into a shared Docker volume. The agent container mounts the client certificates read-only and sets DOCKER_TLS_VERIFY=1 so all Docker CLI commands are authenticated.

Terminal capability model

Pane PTYs run with a stable TERM=xterm-256color baseline and COLORTERM=truecolor. jackin does not copy the host terminal's $TERM into the long-lived container environment, because a preserved instance can be reattached later from a different terminal app. A pane that started while the operator used Ghostty must still be safe to reattach from Kitty, iTerm, Warp, SSH, or a plain xterm-compatible terminal. COLORTERM=truecolor is a deterministic capability hint for agent CLIs and TUIs, not a host-specific terminal identity.

Terminal-specific enhancements are attached-client state instead. Every foreground attach (jackin load, jackin hardline, and console attach) runs a short-lived Capsule client through docker exec; that client reports its current TERM, TERM_PROGRAM, and COLORTERM in the attach handshake. The PID 1 daemon stores that identity only for the active client and refreshes it on every takeover, so jackin-owned output enhancements follow the terminal the operator is using now.

Ghostty, Kitty, and iTerm get native enhancements through that active-client capability path. Features that are raw terminal protocols, such as extended-key sequences, OSC 8 hyperlinks, OSC 9 notifications, and focused-pane title updates, continue to travel over the attach stream when allowed. OSC 52 clipboard writes from pane output are default-deny and require JACKIN_OSC52=allow; jackin-owned copy actions still emit OSC 52 with visible feedback. Enhancements that require known outer-terminal support, such as OSC 22 pointer-shape feedback, are enabled only when the active client advertises a compatible identity.

Capsule terminal capability model

The container no longer runs tmux for agent sessions. The Capsule owns the PTYs directly, so terminal behavior that used to come from tmux options is implemented at the Capsule/client boundary:

Former tmux concernCapsule behavior
Modified keysClient-side input parsing preserves xterm/Kitty/CSI-u key sequences and forwards pane input unmodified unless it is a jackin-owned palette or prefix command.
Terminal feature advertisementPane PTYs run with TERM=xterm-256color and COLORTERM=truecolor; outer-terminal identity is reported per attach and used only for client-side enhancements.
Focus eventsThe attach client enables focus reporting on the outer terminal and forwards focus-in/focus-out only to panes that requested it.
OSC passthroughFocused-pane OSC 0/1/2 titles, OSC 8 hyperlinks, and OSC 9 notifications are forwarded with per-family opt-outs. OSC 52 clipboard writes are forwarded only with JACKIN_OSC52=allow. OSC 7 is captured for pane titles and not forwarded to the host.
Escape timingThe input parser treats Escape as data for the pane except when a jackin dialog is open; dialogs receive normalized legacy keys even after an embedded TUI enables progressive keyboard reporting.
MouseThe client keeps the outer terminal in SGR any-event mode. The daemon handles clicks on jackin chrome and forwards pane mouse events only when the pane's program opted into mouse reporting.

Implementation: crates/jackin-capsule/src/client.rs, crates/jackin-capsule/src/tui/input.rs, crates/jackin-capsule/src/session.rs, and crates/jackin-capsule/src/daemon.rs.

Operators running jackin console from inside their own tmux session need matching host-side configuration so that modified keys are forwarded through the host tmux before they reach the container. See the Running inside tmux operator guide.

Workspace materialization

Workspace mounts pass through three distinct shapes between operator intent and a Docker bind-mount. Keeping these separate is what lets per-mount isolation slot in cleanly:

ShapeOriginOwns
WorkspaceConfig~/.config/jackin/config.tomlPersisted operator intent — raw src/dst/readonly/isolation fields per mount.
ResolvedWorkspaceresolve_load_workspaceExpanded host paths plus the merged composition of workspace, ad-hoc, and global mounts. Still independent of the actual container instance.
MaterializedWorkspacepost-name-claim materializerFinal host paths Docker should bind-mount for this container. Isolated checkouts have been created on disk; isolated src values point at the per-container worktree or clone path.

Materialization happens after jackin claims the final unique container name and the per-container state directory exists. For each non-shared mount, materialization is idempotent: worktree mode creates a scratch branch from host HEAD, clone mode runs a local clone of the host repo's current branch, and both modes record base_commit. Later loads to the same container reuse the existing checkout as-is — no reset, no rebase, no fast-forward.

On first worktree creation for a host repo, jackin enables extensions.worktreeConfig (bumping core.repositoryformatversion to 1 if needed) so per-worktree config writes via git config --worktree don't leak into the shared host .git/config.

isolation.json

Materialized isolated mounts are recorded at:

<data_dir>/<container>/.jackin/isolation.json

The file lives next to the per-container agent state and .jackin/ metadata and is the source of truth for purge after the operator has changed (or removed) the workspace config. Each record contains at least:

FieldPurpose
workspaceWorkspace name at materialization time
mount_dstContainer destination path (the mount identity)
original_srcResolved host src recorded for source-drift detection
isolationMode (worktree or clone)
worktree_pathMaterialized worktree or clone path on the host
scratch_branchWorktree scratch branch name (jackin/scratch/<container>); empty for clone records
base_commitHost HEAD at first materialization
selector_keyAgent selector key
container_nameFinal container name
statusactive | preserved_dirty | preserved_unpushed

Successful cleanup removes the corresponding record rather than keeping a historical cleaned entry.

Foreground session finalization

Every command that opens or reopens an agent foreground session — jackin load, jackin hardline, the operator console — funnels through the same post-session finalizer. After the foreground docker exec ... jackin-capsule attach returns, jackin inspects the container and decides:

  • Container is still running → preserve everything for jackin hardline to reconnect later. No isolated-checkout action.
  • Stopped, non-zero exit or OOM → preserve the container, DinD, and isolated checkouts so jackin hardline can restart in place.
  • Stopped, exit code 0 (clean) → attempt safe isolated-checkout cleanup. A checkout is safe to delete only when it has no uncommitted changes and every local branch in the checkout is safe — agents routinely rename the worktree scratch branch to feature/* before opening a PR, and clone mode starts on the host branch directly, so the safety check enumerates git for-each-ref refs/heads/ rather than inspecting only the recorded scratch branch. A branch is safe when its tip is at base_commit, when its upstream is configured and git rev-list <upstream>..<branch> is empty, or when its upstream tracking is [gone] (the squash-merged-and-pruned heuristic — without it, the GitHub-default merge-and-delete-branch flow leaves a graveyard of preserved checkouts).
  • Clean exit with unsafe cleanup, interactive rich terminal → ask the operator in a rich dialog whether to return to the agent (restart + re-attach), preserve recovery state, or force-delete. The prompt wording branches on whether the checkout is dirty (uncommitted changes) or clean-but-unpushed (unpushed commits on a local branch). If the dialog cannot render, show an error dialog in the same rich surface and preserve the worktree.
  • Clean exit with unsafe cleanup, non-interactive → preserve the recovery state and print the checkout path plus the per-reason phrasing and how to return, inspect, or discard.

The same finalizer runs after hardline attaches, so the cleanup decision is independent of how the foreground session was opened.

State management

~/.jackin/
├── roles/                         # Cached role repos
│   ├── agent-smith/               # Flat role (no namespace)
│   └── chainargos/                # Namespace directory
│       └── agent-brown/           # Namespaced role
├── cache/                         # Shared caches (version checks and other rebuildable data)
├── data/
│   ├── instances.json             # Rebuildable index of all known instances
│   └── <container-base>/          # Persisted state per instance
│       ├── claude/                # Claude auth handoff (provisioned when Claude is a supported agent)
│       │   ├── credentials.json   # Forwarded OAuth credentials under auth_forward=sync
│       │   └── account.json       # Forwarded Claude Code account metadata under sync/token modes
│       ├── codex/                 # Codex auth handoff (provisioned when Codex is a supported agent)
│       │   └── auth.json          # ChatGPT OAuth tokens (synced from host ~/.codex/auth.json under auth_forward=sync)
│       ├── amp/                   # Amp auth handoff (provisioned when Amp is a supported agent)
│       │   └── secrets.json       # Amp API key or OAuth token (synced from host ~/.local/share/amp/secrets.json under auth_forward=sync)
│       ├── kimi-code/             # Kimi auth handoff (provisioned when Kimi is a supported agent)
│       │   ├── config.toml        # Kimi config (synced from host ~/.kimi-code/config.toml under auth_forward=sync)
│       │   ├── credentials/       # Kimi credentials (synced from host ~/.kimi-code/credentials under auth_forward=sync)
│       │   └── device_id          # Kimi device id (synced from host ~/.kimi-code/device_id under auth_forward=sync)
│       ├── opencode/              # OpenCode auth handoff (provisioned when OpenCode is a supported agent)
│       │   └── auth.json          # OpenCode provider credentials (synced from host ~/.local/share/opencode/auth.json under auth_forward=sync)
│       ├── home/                  # Durable agent home state mounted into the container
│       │   ├── .claude/           # Claude Code conversation history, runtime-local plugins, settings
│       │   ├── .claude.json       # Claude Code account metadata inside the durable home
│       │   ├── .codex/            # Codex conversation history, runtime-local config, login state
│       │   ├── .local/share/amp/  # Amp conversation history, runtime-local config, login state
│       │   ├── .kimi-code/        # Kimi runtime-local config and login state
│       │   └── .local/share/opencode/  # OpenCode provider credentials, model preferences
│       ├── state/                 # jackin runtime markers mounted at /jackin/state
│       ├── .config/gh/            # GitHub CLI authentication (agent-neutral)
│       └── .jackin/               # jackin runtime metadata (agent-neutral)
│           ├── instance.json      # Per-instance manifest (identity, role, agent, mounts, status)
│           └── isolation.json     # Per-mount isolation records (when applicable)

This is what persists across sessions:

  • Claude/Codex/Amp/Kimi/OpenCode history, runtime-local settings, plugins, and authentication state stored under the per-instance durable home
  • jackin plugin metadata
  • gh state acquired inside the agent container

This is what does not persist:

  • packages installed interactively into the agent container
  • ad-hoc root filesystem changes outside mounted paths
  • DinD images, containers, and cache

That means jackin restores only host-backed state: workspace mounts, isolated worktrees/clones, and the per-instance agent home under ~/.jackin/data/<container>/home/. Anything installed or edited only in the container writable layer is intentionally disposable and will not be restored after Docker deletes the container.

jackin is biased toward reproducible roles rather than mutable long-lived sandboxes.

If a tool should always exist, put it in the role repo Dockerfile instead of installing it ad hoc during a session.

Why Docker instead of microVMs?

Because jackin is solving a slightly different problem.

Using plain Docker lets jackin:

  • reuse a toolchain developers already have
  • keep the implementation smaller and easier to inspect
  • build agent images with normal Docker workflows
  • share host-side build cache across roles
  • stay local-first without introducing a separate VM product layer

The cost of that choice is straightforward:

  • weaker isolation than a microVM
  • shared host kernel
  • privileged DinD sidecars
  • no hypervisor boundary

If the main thing you need is the strongest local boundary for autonomous agents on a laptop, microVM-based products such as Docker Sandboxes are ahead.

Current technical debt and limitations

  • DinD transport uses auto-generated TLS certificates. The certificates are generated per-session and not pinned to a CA chain you control.
  • Claude installation is not fully pinned. The derived image installs Claude Code from the upstream installer.
  • Derived build context rejects symlinks. Role repos with symlink-heavy layouts are not supported yet.
  • Cached role repos must stay clean. jackin refuses to build from a dirty or origin-mismatched cache.
  • Container mutability is intentionally thin. That keeps sessions cleaner, but makes ad-hoc package installs non-persistent.
  • Isolation-mode edits are guarded only when preserved state already records the previous mode. Source drift is checked before workspace edits are saved; changing only mounts[].isolation can still take effect on the next launch without a pre-save warning when there is no conflicting preserved record.

Technology stack

jackin is built in Rust. The runtime dependencies, grouped by what they own, match Cargo.toml:

ConcernCrates
CLI parsing & manpagesclap, clap_mangen
Terminal UIratatui, crossterm, ratatui-textarea, tui-widget-list, dialoguer
Table outputtabled
Configuration (parse + edit)serde, serde_json, toml, toml_edit
XDG paths and process lockingdirectories, fs2
Dockerfile parsingdockerfile-parser-rs
Error handlinganyhow, thiserror
Colored outputowo-colors
Filesystem helperstempfile
Open external URLsopen
Test helpers (dev)assert_cmd, predicates

The project targets Rust edition 2024 with the newest pinned stable Rust, currently 1.97 (see rust-version in Cargo.toml and the exact channel in rust-toolchain.toml), and unsafe_code = "forbid" at the crate root. Older compilers are unsupported. Clippy lints are configured with correctness and suspicious set to deny and complexity/style/perf/pedantic/nursery set to warn — see Cargo.toml for the full lint table.

When this list drifts from Cargo.toml, Cargo.toml wins. Update both in the same PR.

Async architecture and the "no blocking on the render thread" rule

Both the host CLI (crates/jackin/src/main.rs) and the capsule daemon (crates/jackin-capsule/src/main.rs) run on a current_thread Tokio runtime. The render/event loop and all spawned tasks share a single OS thread. This is intentional:

  • The host CLI holds !Send terminal raw-mode state and signal handlers that must stay on the OS main thread.
  • The capsule daemon's multiplexer loop, PTY readers, and socket accept loop communicate via mpsc channels — the single-thread model prevents data races without locking.

Rule: no blocking syscall on the tokio runtime thread. Any call that can block for more than a few microseconds must be offloaded. Two mechanisms:

Call typeMechanism
Subprocess execution (git, gh, op) that is a one-shot output readtokio::task::spawn_blocking
Long-running subprocess with output streamingtokio::process::Command + lines()
CPU-bound work (hashing, compression)tokio::task::spawn_blocking
File I/O in a hot async pathtokio::fs or spawn_blocking
Sleep / poll waittokio::time::sleep — never std::thread::sleep

spawn_blocking works on the current_thread runtime — it uses Tokio's separate blocking thread pool, so the runtime thread keeps polling the render task while the blocking work runs elsewhere.

Known open sites (Defect 43). A small number of blocking calls remain in the provisioning path. The highest-priority one is RoleState::prepare in crates/jackin-instance/src/lib.rs, which runs gh CLI, macOS keychain (security), and filesystem copies directly on the async provisioning task — and is therefore the most likely rain-freeze cause when git auth or 1Password are involved. Wrap it in spawn_blocking and clone the necessary inputs for the closure. Subsequent conversions: op_cli.rs poll loops, and any remaining std::process::Command in async-reachable paths.

Parallelism: poll independent stages early, join at the first consumer. The launch pipeline in crates/jackin-runtime/src/runtime/launch/launch_pipeline.rs starts work as soon as its inputs are ready, then joins it where the result is first needed. Operator-env reads overlap image decision and build, workspace git pulls overlap image work and join before materialization, and sidecar/network readiness is polled while runtime binaries and the derived image are prepared. Each stage emits its own stage_started / stage_done events to the progress channel; the shared-state mutex in SharedView handles out-of-order updates already.

AgentRuntime and Provider registry

Adding a new agent runtime or provider must be one registration, not N call-site edits. This is enforced through two sealed adapter registries.

AgentRuntime trait (crates/jackin-core/src/agent/runtime.rs). Every agent variant (claude, codex, amp, kimi, opencode) is represented by a zero-sized adapter struct that implements AgentRuntime. Agent::runtime() returns &'static dyn AgentRuntime — a compile-time-known dispatch point. Adding a sixth built-in runtime means adding the enum variant, adapter file, registry entry, and the small number of centralized named-field accessor rows that remain because the public TOML schemas still expose named agent sections.

Key trait methods:

  • slug() / label() — wire identifier and display name.
  • install_block(source) — generates the Dockerfile COPY/RUN block for this agent.
  • required_env_var() — the env var whose presence signals that the agent is installed.
  • supported_modes() — which AuthForwardMode values are valid for this agent.
  • state_paths()AgentStatePaths { credential_dir, credential_file, folder_env_var } — the per-agent credential location on the host and the environment hint for the operator. Used by provisioning and by sync_source_dir resolution.
  • parse_version(output) — parses --version stdout to extract a semver string, replacing five per-agent branches.

ProviderAdapter trait (crates/jackin-protocol/src/provider_adapter.rs). Same sealed pattern for providers (Anthropic, Zai, MiniMax, Kimi). Provider::adapter() returns &'static dyn ProviderAdapter. Provider::available_for(agent_slug, has_key: impl Fn(Provider) -> bool) replaces the four-positional-bool form.

Key trait methods:

  • label() — display name.
  • needs_key_for_agent(slug) — whether an explicit API key is required (Anthropic + claude: no; everything else: yes).
  • supports_agent(slug) — whether this provider works with the given agent.
  • env_overrides(token) — the env vars to inject at spawn to redirect the agent to this provider.
  • key_env_var() — the env var name that holds the provider's API key (ZAI_API_KEY, MINIMAX_API_KEY, KIMI_CODE_API_KEY, or None for Anthropic).
  • opencode_model() — the provider/model string for OpenCode's -m flag.

Sealed trait pattern. Both traits are sealed via a pub(crate) mod private { pub trait Sealed {} } guard so external crates cannot add providers or runtimes. The built-in set is the intended complete set; adding one means changing this crate, which triggers a review.

On this page