Agent Container
Claude Code · mounted dirs
Claude Code · mounted dirs
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). 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 and TLS Docker contexts are rejected for now because the current Bollard dependency build cannot reproduce the Docker CLI’s SSH transport or context TLS material.
That creates an important split:
So jackin’ gets host-side cache reuse for building agent images, while still keeping the agent away from your host Docker daemon during runtime.
jackin’ architecture is shaped by a few explicit decisions:
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 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 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 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 src/repo.rs and src/manifest/migrations.rs with the desktop path.
jackin.role.toml and a valid Dockerfile path, validate manifest strictlydocker:dind sidecar with TLS enabled and a shared certificate volume~/.jackin/sockets/<container>/agent.toml, which is bind-mounted at /jackin/run/agent.toml.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.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.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:
jackin hardline to reconnectjackin hardline can restart in placeEvery 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.
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.
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 type | Identity | Created by | Reconnectable |
|---|---|---|---|
| Agent session | daemon session id plus agent slug | jackin load, jackin hardline, or console a/A | Yes — jackin hardline attaches to the daemon or creates a new session |
| Shell | daemon session id with no agent slug | hardline --shell or console x/X | No — 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, src/runtime/launch.rs (launch_role_runtime), src/runtime/attach.rs (reconnect_or_create_session, spawn_agent_session, spawn_shell_session).
jackin eject removes:
{container}-dind-certs)Persisted state remains unless you also purge it.
Each agent gets an isolated Docker network:
DOCKER_HOST=tcp://{dind}:2376 with TLS mutual authenticationJACKIN_DIND_HOSTNAME={dind}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.
Pane PTYs run with a stable TERM=xterm-256color baseline. 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.
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 52 clipboard writes, OSC 8 hyperlinks, OSC 9 notifications, and focused-pane title updates, continue to travel over the attach stream when allowed. 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.
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 concern | Capsule behavior |
|---|---|
| Modified keys | Client-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 advertisement | Pane PTYs run with TERM=xterm-256color; outer-terminal identity is reported per attach and used only for client-side enhancements. |
| Focus events | The attach client enables focus reporting on the outer terminal and forwards focus-in/focus-out only to panes that requested it. |
| OSC passthrough | Focused-pane OSC 0/1/2 titles, OSC 8 hyperlinks, OSC 9 notifications, and OSC 52 clipboard writes are forwarded with per-family opt-outs. OSC 7 is captured for pane titles and not forwarded to the host. |
| Escape timing | The 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. |
| Mouse | The 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/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 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:
| Shape | Origin | Owns |
|---|---|---|
WorkspaceConfig | ~/.config/jackin/config.toml | Persisted operator intent — raw src/dst/readonly/isolation fields per mount. |
ResolvedWorkspace | resolve_load_workspace | Expanded host paths plus the merged composition of workspace, ad-hoc, and global mounts. Still independent of the actual container instance. |
MaterializedWorkspace | post-name-claim materializer | Final 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.jsonMaterialized isolated mounts are recorded at:
<data_dir>/<container>/.jackin/isolation.jsonThe 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:
| Field | Purpose |
|---|---|
workspace | Workspace name at materialization time |
mount_dst | Container destination path (the mount identity) |
original_src | Resolved host src recorded for source-drift detection |
isolation | Mode (worktree or clone) |
worktree_path | Materialized worktree or clone path on the host |
scratch_branch | Worktree scratch branch name (jackin/scratch/<container>); empty for clone records |
base_commit | Host HEAD at first materialization |
selector_key | Agent selector key |
container_name | Final container name |
status | active | preserved_dirty | preserved_unpushed |
Successful cleanup removes the corresponding record rather than keeping a historical cleaned entry.
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:
jackin hardline to reconnect later. No isolated-checkout action.jackin hardline can restart in place.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).uncommitted changes) or clean-but-unpushed (unpushed commits on a local branch).The same finalizer runs after hardline attaches, so the cleanup decision is independent of how the foreground session was opened.
~/.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/ # Kimi auth handoff (provisioned when Kimi is a supported agent)│ │ ├── config.toml # Kimi config (synced from host ~/.kimi/config.toml under auth_forward=sync)│ │ ├── credentials/ # Kimi credentials (synced from host ~/.kimi/credentials under auth_forward=sync)│ │ └── device_id # Kimi device id (synced from host ~/.kimi/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/ # 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:
gh state acquired inside the agent containerThis is what does not persist:
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.
Because jackin’ is solving a slightly different problem.
Using plain Docker lets jackin’:
The cost of that choice is straightforward:
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.
mounts[].isolation can still take effect on the next launch without a pre-save warning when there is no conflicting preserved record.jackin’ is built in Rust. The runtime dependencies, grouped by what they own, match Cargo.toml:
| Concern | Crates |
|---|---|
| CLI parsing & manpages | clap, clap_mangen |
| Terminal UI | ratatui, crossterm, ratatui-textarea, tui-widget-list, dialoguer |
| Table output | tabled |
| Configuration (parse + edit) | serde, serde_json, toml, toml_edit |
| XDG paths and process locking | directories, fs2 |
| Dockerfile parsing | dockerfile-parser-rs |
| Error handling | anyhow, thiserror |
| Colored output | owo-colors |
| Filesystem helpers | tempfile |
| Open external URLs | open |
| Test helpers (dev) | assert_cmd, predicates |
The project targets Rust edition 2024 with a current MSRV of 1.94 (see rust-version in Cargo.toml) and unsafe_code = "forbid" at the crate root. 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.