Skip to content

Repository files navigation

pall8t

(pronounced "pallet" — the thing containers ship on)

Run AI coding agents inside apple/container sandboxes. pall8t is a headless CLI that does one job well: launch an agent in a macOS-native lightweight VM, with your current directory mounted as the workspace, an isolated container home, and automatic image rebuilds when the Containerfile changes. Multiplexing, session persistence, and workspace isolation belong to the tools that already own them (tmux, herdr, git worktrees) — pall8t is the well-behaved foreground process they spawn (see ADR-0006).

Why

  • Sandboxed by construction. Agents run in a per-container VM, never on the host. Files land as your UID, never root.
  • No Docker Desktop. apple/container is macOS-native and boots fast, but its CLI is docker-incompatible; pall8t abstracts it into exactly what running an agent needs. apple/container is the only supported runtime — there's no Docker, OrbStack, or podman backend.
  • Host non-pollution. ~/.pall8t/home is mounted as the container home — the agent's login and config live there, and your host ~/.claude is never touched.
  • Automatic rebuilds, no daemon. At run time the Containerfile's content hash picks the image tag; a change means a rebuild before launch. No watch process, no state file.
  • Worktree-aware. If your cwd is a git worktree, the main repository's .git is mounted too, so git inside the container works exactly as on the host.
  • Reference repos, protected by duplication. Repos listed in config are duplicated via git clone --local (hardlinked objects) and the copy is mounted at the original's path — the workaround for apple/container's missing read-only mounts (apple/container#990).

Requirements

Install & quickstart

brew install TakiTake/tap/pall8t

(or build from source with cargo install --path . — this needs the Rust toolchain.)

cd ~/src/my-project
pall8t init     # one-time: ~/.pall8t/home, .pall8t/config.toml skeleton, default Containerfile
pall8t run      # build if needed, then run the agent (default: claude) in the sandbox

The first pall8t run needs a one-time agent login inside the container; credentials persist in ~/.pall8t/home across runs and rebuilds.

The agent session is a plain foreground process: run it under tmux or herdr for persistence and multiplexing, Ctrl-C/signals reach the agent, and the exit code is the agent's own.

CLI

pall8t init              # generate ~/.pall8t/home, .pall8t/config.toml skeleton, default Containerfile
pall8t run [-- cmd...]   # hash check → build if needed → run (TTY passthrough)
pall8t build             # explicit (unconditional) build
pall8t ls [--json]       # list pall8t containers (--json for herdr etc.)
pall8t exec <id> -- cmd  # run a command inside a running container
pall8t stop <id>         # stop a container
pall8t herdr doctor [--json]  # check herdr env/socket/binary reachability

Config

Two layers, merged per field with the project winning: global ~/.pall8t/config.toml, per-project .pall8t/config.toml — the project-scope mirror of ~/.pall8t.

[container]
cpus = 4
memory = "8g"
# containerfile = "path/to/other/Containerfile"   # relative to the project dir; default: .pall8t/Containerfile
# watch = ["flake.nix", "flake.lock"]   # extra files whose content also decides whether to rebuild

[run]
command = ["claude"]     # --dangerously-skip-permissions is NOT in the default.
                         # Users who want it must set it explicitly.

[[repos]]                # reference repos: cloned with `git clone --local`,
source = "~/src/other-lib"   # the copy is mounted at this same path

Customizing the Containerfile

Resolution priority: explicit containerfile config (relative to the project dir) → .pall8t/Containerfile if present → the built-in default image (node + claude CLI + gh).

  • User-level default. The shipped default is materialized once at ~/.pall8t/Containerfile by pall8t init and never overwritten — edit it to customize the default shared by all projects; delete it to restore the shipped one.
  • Project-level. Create .pall8t/Containerfile to opt a project into its own image instead of the shared default; it always wins over the user-level default. Copying ~/.pall8t/Containerfile as a starting point is the easy path.

A few caveats apply either way: there is no fallback to a root ./Containerfile — that file usually belongs to the project's own app image, so pall8t never picks it up implicitly; point containerfile at it explicitly if you really want that. The build context is always the resolved Containerfile's own directory, so a .pall8t/Containerfile can only COPY files that live under .pall8t/. Custom toolchains must live outside /home/dev — the persistent home mount shadows it.

The image tag embeds the Containerfile's content hash, so any edit — no commit required — triggers a rebuild on the next run, and superseded images are pruned automatically after a successful build (images still used by a running container are kept). Only the Containerfile itself is hashed by default, not files it COPYs in — set container.watch to fold specific extra files into the same hash (e.g. a lockfile the Containerfile builds the toolchain from), so editing one of them also triggers a rebuild instead of silently reusing a stale image. Constraints: literal paths only (no globs), relative to the project dir, must already exist as regular files (a missing entry, or one that isn't a regular file, is a hard error, never silently skipped), capped at 100 files / 4 MiB combined, and only usable with a project Containerfile (containerfile or .pall8t/Containerfile) — the built-in default image builds from ~/.pall8t and can't depend on per-project files. pall8t build remains the escape hatch for what no hash can see (e.g. updated base image or packages).

A build streams container build's own output live to stderr — no -v flag, this is always on, since a silent multi-minute build looks hung. Deliberately kept off pall8t's own stdout, which pall8t build's final built <tag> line and pall8t ls --json need to stay machine-readable.

Working with git worktrees

Cutting worktrees is the caller's business — you or herdr — but pall8t makes them work inside the sandbox:

git -C ~/src/my-project worktree add ../my-project-task -b task
cd ~/src/my-project-task
pall8t run

pall8t detects that cwd's .git is a worktree pointer and identity-mounts the main repository's .git alongside, so status/commit/diff inside the container behave exactly as on the host.

herdr integration

Type pall8t run into a herdr pane and herdr recognizes the sandboxed agent as-is — no wrapper function or special launch command needed (the agent-state bullet below explains how, and when a name can't be derived). herdr injects HERDR_ENV/HERDR_PANE_ID/HERDR_SOCKET_PATH/HERDR_BIN_PATH into pall8t itself (the host process), not into the sandboxed claude, so pall8t acts on them before it execs into the container:

  • Agent state (idle/working/blocked). pall8t run execs the container client with argv[0] set to the sandboxed agent's name — the first name in the run command that herdr recognizes (claude, codex, gemini, …; launchers like env/npx/uv run and pkg@version specs are looked through), falling back to HERDR_AGENT when the command contains none. Only recognized names are ever derived: anything else (a wrapper script, a shell one-liner) yields no guess instead of a wrong one. HERDR_AGENT never overrides a name found in the command — the command is what actually runs, while an env var lingering in the environment is ambient and would mislabel e.g. pall8t run -- codex as claude under a stale HERDR_AGENT=claude; pall8t prints a note when it ignores one. Homebrew installs container as a bash exec wrapper whose inner exec would rewrite argv[0] to the Cellar path and destroy the hint (observed live), so pall8t looks through such a pass-through wrapper and execs its target binary directly, carrying over the wrapper's env assignments (CONTAINER_INSTALL_ROOT); it also sets HERDR_AGENT on the process so a future herdr macOS env hint could survive even argv0-rewriting setups. herdr assigns pane identity from the host process tree by argv0 basename (on macOS via sysctl(KERN_PROCARGS2)), and that identity is what unlocks its screen-content state detection — which then works on the sandboxed agent as well as a native one, because the agent's real UI streams through the pane's PTY unchanged. Without the hint, herdr only ever sees a process named container, never recognizes the pane, and tracks no state. (herdr's own HERDR_AGENT env-hint is Linux-only — it reads /proc/<pid>/environ — which is why pall8t honors the variable itself on macOS.) Side effect: ps shows the host-side client process under the agent's name; its executable is still container.
  • Sidebar identity. pall8t reports the pane's display name to herdr (herdr pane report-metadata … --display-agent "<agent> (pall8t)"), which takes priority over the plain agent name herdr derived for the pane — verified live end-to-end (agent pane shows "claude (pall8t)"). When no agent name could be determined, no report is sent at all — the pane keeps whatever name herdr already had for it rather than being labeled with a guess. Deliberately sent without --agent: herdr only surfaces display_agent when it matches effective_agent_label() (host-process-derived, per above) — a match that only holds once the argv0 hint has taken effect, and this report must not depend on it. Best-effort either way: a missing herdr binary or unreachable socket just prints a warning and the run continues.
  • No redundant tmux wrapper. If [run] command is the Claude Code agent-teams tmux wrapper below and pall8t detects it's running inside a herdr pane, it runs plain claude instead — herdr already supplies persistence/multiplexing, so the wrapper (and its status bar) is redundant chrome. An explicit pall8t run -- <cmd> override always wins over this.
  • pall8t herdr doctor checks whether pall8t can see and reach the herdr pane it's running under (env vars present, socket reachable, herdr binary resolvable). Read-only, diagnostic only; --json for scripting.

Native session resume/restore (pall8t resume, live session-id reporting via herdr pane report-agent-session) isn't implemented yet: it needs a change to pall8t's foreground/exec-replace process model, and, upstream, a way for herdr to let a custom source supply its own resume command instead of its current hardcoded claude --resume <id> table. Tracked in issue #18.

Claude Code agent teams (split panes)

Claude Code can show teammate agents as tmux split panes (teammateMode: "auto" / "tmux"), but only if it's already running inside a tmux session — the default image ships tmux for exactly this. In config:

[run]
command = ["tmux", "new", "-A", "-s", "claude", "claude"]

then, inside the container (one-time, persists in the container home), add "teammateMode": "auto" to ~/.claude/settings.json. Note that tmux here only multiplexes within one run: every pall8t run starts a fresh container (--rm), so there is no session to re-attach across runs — for persistence, run pall8t itself under tmux on the host. The image ships /etc/tmux.conf with status off; override in ~/.tmux.conf inside the container if you want the status bar back.

Known limitations (v1)

  • Shared home under parallel runs (default mode only). The default [home] mode = "shared" has all containers share ~/.pall8t/home rw; Claude Code has known ~/.claude.json corruption issues under concurrent sessions — the same conditions as parallel agents on the host. Set mode = "isolated" (experimental) to opt in to per-run home forks with automatic harvest and explicit promote/merge (pall8t home inbox|show|promote|drop|merge), plus revision history and lifecycle management (pall8t home log|diff|rollback|ls|rm|gc) — see docs/specs/home-compositor.md. Off by default and experimental in v1; only the pluggable local-Claude merge resolver and the non-APFS fork fallback remain on the roadmap.
  • No read-only mounts (apple/container limitation) — reference repos are protected by git clone --local duplication instead.
  • Workspace isolation is the caller's responsibility. Two agents in the same directory will step on each other; use worktrees.

Full requirements in docs/requirements.md; architecture decisions in docs/adr/; release process in docs/release.md.

About

Run AI coding agents inside apple/container sandboxes.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages