pi-tin
v0.9.0
Published
Let AI coding agents run free (permissions bypassed) — in lightweight, VM-isolated sandboxes on macOS, via Apple's container CLI
Maintainers
Readme
pi-tin
Pie tins hold pies. pi-tin holds your agent dev environments.
Let your AI coding agents run free — local, contained workspaces.
cd ~/dev/my-app && pt # opens a VM-isolated workspace, agent readyA simple and convenient way to run your agents in yolo/skip-permissions mode, without exposing your host machine (Mac) or your other projects.
Works fantastically with herdr and has built-in support for some common coding agents: Pi, Amp, Claude Code, Antigravity, Codex, Copilot, Crush, and OpenCode. Other agents can be added with a simple agent definition.
Fast, lightweight, and simple to configure — the CLI exposes agent-friendly commands to create, open, and manage workspaces, container profiles, and agent profiles.
pi-tin gives each workspace its own micro-VM — a real Linux container with a full VM boundary, via Apple's efficient, lightweight container CLI (no Docker, no shared VM) — so agents can run with permission prompts bypassed, free to move fast inside the box, isolated from your Mac, your keys, and your other projects.
Running free is the default. Pi and Amp already work this way; the rest launch in bypass mode. (Prefer prompts? Set agent.skipPermissions: false.)
Multiplex agents with herdr. One config line (attach: herdr) and pt opens the workspace in herdr: agent panes rendered locally, drag-and-drop images straight into contained agents, detach freely. pi-tin adds an agent-aware lifecycle — after you detach, the workspace keeps running until the agents finish too, then stops; the next pt restores your layout and resumes the agents. → herdr attach
Runs on your Mac. No cloud VMs, no remote dev environment to rent or trust — the sandboxes are local, backed by Apple's native virtualization.
Open source top to bottom. pi-tin (GPLv3), Apple's container runtime (Apache-2.0), and the Linux kernel — all open — running on the virtualization built into macOS.
OCI-compatible. Use standard images from Docker Hub, GHCR, and any OCI registry — existing container images just work.
Daily-driven on Pi and Claude Code; other agents are supported but lightly tested. Further agents can be added with an agent definition.
The concept is simple: pi-tin makes a workspace (linux vm, agent profile(s), your selected project repos), and 'entering' the workspace via pt teleports you from your Mac into the workspace where agents can be unleashed.
Status: The core is solid and daily-driven; expect rough edges on less-common agents and setups. Issue reports are welcome.
[!IMPORTANT] What you put in the sandbox is what the agent can access. Everything outside the sandbox is off-limits. The agent cannot see your host machine or your files, unless you explicitly make them available.
Concepts
pi-tin has four pieces. You set them up once, then live in workspaces day to day.
- Project — a directory on your Mac (your repo). Mounted into a workspace; the same project can live in more than one workspace, and a workspace can have more than one project.
- Container profile — the image recipe: base image, packages, global tools, CPU/memory. One container profile backs many workspaces. pi-tin ships defaults (
node-dev,python-dev, …). - Agent profile — an AI agent's identity: isolated, persistent auth + config (e.g. a
personalvsworklogin). Independent of the container profile and shareable across workspaces, so you log in once. - Workspace — what you open. It binds one container profile, your project(s), and any agent profile(s) into a running, shareable container shell. This is your everyday unit.
container profile ──┐
├──▶ workspace ◀── project(s)
agent profile(s) ───┘An agent pi-tin doesn't know out of the box is added with an agent definition: it registers the agent kind — how to install and launch it. An agent profile then holds a login for it, as for any built-in. → Custom agents
Everyday flow: cd into a project and run pt — it opens the matching workspace (and offers to create or pick one if needed).
Prerequisites
- Apple Silicon Mac (M1 or later)
- macOS 26 or later with Apple's
containerCLI 1.0.0 or later installed - Node.js 18+
If the container CLI isn't installed, pi-tin offers to install it (via Homebrew or direct download). Prompts are interactive-only: without a TTY, missing prerequisites exit with a structured error instead (see Machine-Readable Output).
Installation
npm install -g pi-tin[!TIP]
ptis a built-in alias forpi-tin— use whichever you prefer, they're interchangeable (e.g.pt open myproject).
Quick Start
Set up with an agent
Already have an AI coding agent in your project? Paste the prompt below to get started:
Set up pi-tin for this project, acting as my natural-language UI for it.
1. Read https://github.com/dave-tn/pi-tin to learn what pi-tin is and how it fits
THIS project (infer the stack from the repo); tell me in 2-3 sentences, then ask
before doing anything.
2. Run `pi-tin agent-guide` — it tells you pi-tin's command surface, the JSON
contract and exit codes, AND how to act as my natural-language UI. Follow it.
3. Drive pi-tin through that agent CLI — never the interactive `create` wizard or
hand-edited YAML — to set up a workspace for this project.Set up manually
Day to day there's really one command: pt. cd into a project and run it —
cd ~/dev/my-app && pt— and pt does the right thing for that directory:
- One workspace includes it → opens that workspace (starting it, or joining it if it's already running).
- Several include it → you pick which to open, or create a new one.
- None include it →
ptoffers to create a workspace for it, launching the interactive setup; if you already have other workspaces, it also offers to add this directory to one of them instead.
On first run, pt will offer to install the container CLI if it's missing; the default container profiles (node-dev, python-dev, …) are installed automatically and kept up to date (see Container profiles). It also offers to create a default agent profile.
[!NOTE] First run can involve downloading container images and package installs (potentially a few GB), so it may take a few minutes. Subsequent runs are fast; entering a workspace is sub-second.
Workspace names must be lowercase alphanumeric, and may contain ., -, or _ (e.g. my-project, app_v2). They are also capped at 56 characters, because the name becomes the workspace's container name (pi-tin-<workspace>) and its network hostname, which the container runtime limits to 63.
Need to be explicit? pi-tin create <name> builds a workspace up front, pi-tin open <name> opens one by name from anywhere, and pi-tin --build forces an image rebuild on the matched workspace's next start.
Why Apple Containers?
Most container tools — Docker Desktop, Colima, Podman, OrbStack — run every container inside one shared Linux VM. Apple's container CLI runs each container in its own lightweight VM via the Virtualization framework. Apple's container system is lightweight, efficient, and optimised for macOS. For pi-tin that means:
- Stronger per-workspace isolation. Each workspace gets a full VM boundary — a better fit for autonomous coding agents than a shared VM.
- Selective host access. You mount only the host paths a workspace needs, rather than exposing broader paths to one shared VM.
- Sub-second start times. An optimised kernel config, minimal root filesystem, and lightweight init keep startup fast.
Configuration
Configs live at ~/.config/pi-tin/ (or $XDG_CONFIG_HOME/pi-tin/):
~/.config/pi-tin/
profiles/
node-dev.yaml # Container image recipe (one of several bundled defaults)
workspaces/
myproject.yaml # Workspace definition
agent-profiles/
personal/ # Agent identity (auth + config)
agents/
oh-my-pi.yaml # Custom agent definition (optional)Config is created automatically on first use.
Container profiles
A container profile defines the container image: base image, packages, global tools, and setup commands. pi-tin ships these opinionated defaults, each with the same shell tooling baseline (zsh + Oh My Zsh, zoxide, tmux, GitHub CLI, plus modern search/navigation CLIs — ripgrep, fd, bat, fzf, tree) and a current Node.js/npm (pi-tin installs all agent and global tooling with npm; container profiles not built on a Node base image install the current Node.js via NodeSource):
| Container profile | Base image | For |
|---------|-----------|-----|
| node-dev | node:trixie-slim | Node.js, with @playwright/cli + Chromium |
| bun-dev | oven/bun:slim | Bun (project work) alongside Node.js/npm, with @playwright/cli + Chromium |
| python-dev | python:3.13-slim | Python 3.13 with uv, pip/venv and build tools |
| rust-dev | debian:trixie-slim | Rust via rustup, with build tools |
| dotnet-dev | mcr.microsoft.com/dotnet/sdk:10.0 | .NET SDK 10 (LTS) |
Defaults ship with pi-tin and are updated automatically when pi-tin is upgraded. To customise one, remove the # This profile is managed by pi-tin... comment at the top — pi-tin will then leave the file untouched on future updates — or copy it to a new name and modify the copy.
The managed node-dev container profile uses node:trixie-slim (Debian 13), installs @playwright/cli plus Chromium, and sets PLAYWRIGHT_MCP_BROWSER=chromium so playwright-cli defaults to Chromium rather than the Chrome channel. All managed profiles set LANG/LC_ALL (C.UTF-8) and NODE_EXTRA_CA_CERTS; dotnet-dev also sets DOTNET_CLI_TELEMETRY_OPTOUT and DOTNET_NOLOGO.
Container profile schema
| Field | Required | Description |
| ----------------- | -------- | ----------- |
| description | yes | Human-readable label. |
| base_image | yes | OCI image ref (e.g. node:trixie-slim, debian:trixie-slim). The package manager is auto-detected from the name — see package_manager. |
| package_manager | no | Override auto-detection (apt / apk / dnf). Detection: name prefixes debian/ubuntu/node/python/oven/bun/buildpack-deps → apt; alpine anywhere in the name (e.g. python:3.12-alpine) → apk; fedora/rockylinux/almalinux prefixes or a /rhel//ubi path segment (e.g. redhat/ubi9 — bare rhel:9 is not recognised) → dnf. Required when the base image name isn't recognised (e.g. mcr.microsoft.com/...); generation throws otherwise. |
| user | yes | Non-root username for the container. Must match ^[a-z_][a-z0-9_-]*$. pi-tin sets HOME to /home/<user> (/root for root) and anchors the mounts it manages there; this wins over any home a base image already assigns to a pre-existing user of that name. |
| packages | no | System packages installed via the package manager. Defaults to []. pi-tin enters a workspace via the container user's login shell (falling back to /bin/sh); to use a specific shell, install it here and set it as the login shell in post_install (e.g. chsh -s "$(command -v zsh)" "$USERNAME", as the managed profiles do for zsh). |
| extra_packages | no | Concatenated with packages into the same install step — no behavioural or layering difference; the split is purely organisational. Defaults to []. |
| global_tools | no | Packages installed globally with npm (always npm, regardless of base image), before workspace tools. Defaults to []. |
| post_install | no | Root shell commands, run after system packages and before the user switch. Defaults to []. |
| post_setup | no | User-level shell commands, run as user after global tool installs and before workspace packages. Defaults to []. Use this for anything that installs into the user's $HOME (e.g. rustup). |
| env | no | Environment variables. Keys must match ^[A-Za-z_][A-Za-z0-9_]*$; values must be strings (quote numbers, e.g. "1") and are auto-quoted/escaped for the Dockerfile. Defaults to {}. |
| cpus | no | CPU limit, positive integer. Default: host cores − 2 (min 2). |
| memory | no | Memory limit string, e.g. "16g". K/M/G/T/P suffix (optional trailing b). Default: 8g. |
| workspace_state | no | Home-relative paths carried across container restarts — see Workspace state. Each path must be home-relative (no leading /, no ./.. segments). Defaults to []. |
Workspace state
A workspace container is semi-ephemeral: your project code and a few live host mounts survive because they're bind-mounted from the host, but the rest of the container's home is rebuilt from the image whenever the container is recreated (a restart, or the first open after an auto-stop). That normally discards small, useful container-internal state like the zoxide jump database and shell history.
workspace_state lists home-relative paths pi-tin snapshots across those recreations: copied in when a fresh container starts, and out when a session closes while the container is still running. Two teardown paths snapshot first as well: the restart that applies config changes or --build, and — herdr workspaces only, since their agents keep writing after the last session closes — the final auto-stop. A manual pi-tin stop takes no snapshot. State is stored per workspace under ~/.config/pi-tin/workspace-state/<workspace>/, so two workspaces on the same profile keep independent copies. Sync is best-effort with a per-command timeout rather than blocking open/exit indefinitely: a path whose copy exceeds the timeout is skipped with a warning (it is almost certainly too large to snapshot — see below) and the remaining paths still sync; only when the container runtime itself stops responding is the rest of the sync abandoned.
[!NOTE]
workspace_statepaths are a snapshot, not a live mount. They are copied in at start and out at close — never synced live. If two sessions run against the same workspace, the last to close wins. It is not a substitute for host mounts. (The same state tree also backs the managed agent install mounts below, which are live mounts and do use shared-directory slots.)
The state tree belongs to the workspace: pi-tin delete <workspace> removes ~/.config/pi-tin/workspace-state/<workspace>/ along with the container and image. It is frequently the largest thing a workspace leaves on the host, so delete --dry-run and the confirmation prompt both report the directory and its size before anything is removed.
A workspace_state path that overlaps a live mount — a managed agent install mount, a host.mounts entry, an agent profile, the tmux or gh mounts — is skipped, with a warning at start. The path already persists via the mount, and snapshotting it would be destructive as well as redundant: the copy-in clears its destination first, which on a mount means the host side of it. The check keys off the mounts the running container actually has, so a config edit that only takes effect on the next restart cannot mislead it. A container left running across a pi-tin upgrade may carry no record of its mounts: joining one after such an edit leaves nothing trustworthy to check against, so the copy-out at session close is skipped whole, with a warning, until the workspace is restarted.
Keep workspace_state deliberately small and tightly coupled to the container's own tooling — inert, tool-owned data such as databases and history, not shell rc files or anything executed. File modes are not preserved round-trip (a 0600 file can come back 0644), so never persist permission-sensitive files such as keys or credentials. A path too large to copy within the timeout (a package cache like .nuget/packages, for example) is skipped on every sync — persist those with a host.mounts entry instead. It exists only to smooth over the container's ephemerality for a few specific dev tools; it is not a general state-sync or backup mechanism. Everything else should stay ephemeral, with your code and working files living on host mounts instead. Agent sessions are not covered here; they persist via agent profiles instead.
Agent install mounts
The same per-workspace state tree also backs a small set of live host mounts pi-tin manages itself, so the things that update themselves inside the container simply persist, exactly as they would on a plain host:
| Mounted at | For |
| ---------- | --- |
| ~/.local/share/claude, ~/.local/bin | Claude Code's install and launcher |
| ~/.opencode/bin | OpenCode's binary |
| ~/.local/bin, ~/.config/herdr, ~/.local/state/herdr | the herdr server binary, its session state, and its state dir — downloaded agent-detection manifests, announcements, plugin state (with attach: herdr) |
Native agents (Claude Code, OpenCode) are not installed at image build. Every open checks in-container whether each is present and runs its official installer if not, showing elapsed time while it works — so an install that failed, timed out, or was interrupted simply retries on the next open, no restart needed. Once an agent is there the check costs one test -x and the agent's own auto-updater keeps it current, every update landing on the mount. A failed install is never fatal: the open continues without that agent. Ctrl-C abandons the whole install step (not just the agent being installed) and drops you into the shell. If another pi-tin open is already installing the same agent, this one says so and continues without waiting. pi-tin never prunes old versions: whatever the agent's updater does about them on a host, it does here.
An explicit host.mounts entry (or an agent profile) at any of these container paths takes precedence, and pi-tin says so at start. Like every live mount, they are skipped by the workspace_state snapshot — see Workspace state.
If an install ever ends up corrupt — or was interrupted partway through, which can leave a truncated binary that still looks installed — delete the agent's directory under ~/.config/pi-tin/workspace-state/<workspace>/ and reopen; the installer runs fresh.
Mounting ~/.local/bin would otherwise hide whatever the image baked there — the managed profiles put zoxide and its cd integration in exactly that directory. So the image's own ~/.local/bin is copied aside at build time and stays on PATH behind the mount. Baked tools keep working under the mount, including in user: root profiles; anything installed into ~/.local/bin at runtime persists and takes precedence over a baked copy of the same name.
[!NOTE] A baked tool resolves through
PATH, not from~/.local/bin/<tool>, so a profile that hard-codes that absolute path won't find it there — call it by name, install it elsewhere, or mount it yourself withhost.mounts. For the same reason, deleting a baked tool from the mount doesn't remove it; shadow it with your own binary of that name instead.
Persisting a package cache
Where a cache is genuinely worth keeping warm across container recreations — a cold dotnet restore re-downloading gigabytes, say — mount it live, backed by a directory under the same per-workspace state tree so the data stays with the workspace rather than becoming a stray host directory:
# ~/.config/pi-tin/workspaces/<workspace>.yaml
host:
mounts:
- host: ~/.config/pi-tin/workspace-state/<workspace>/.nuget/packages
container: /home/dev/.nuget/packages
readonly: falseCreate the host directory first (mkdir -p) — a missing path is skipped with a warning — and match the container path to the profile's user home. Reserve this for caches that measurably earn their keep: each entry consumes one of the 22 mount slots, and most caches rebuild quickly enough that ephemerality is the simpler default.
Creating custom container profiles
Copy a managed default to a new name and edit it:
cp ~/.config/pi-tin/profiles/node-dev.yaml ~/.config/pi-tin/profiles/my-profile.yamlRemove the # This profile is managed by pi-tin... header line so pi-tin won't overwrite it, then adjust the fields above to match your stack. Reference it from a workspace with profile: my-profile.
Tip: You can ask an AI coding agent to build a custom container profile — point it at the Container profile schema table above and any existing container profile as a template.
[!NOTE] Terminal fonts: Nerd Font glyphs are rendered by the terminal app on your Mac, not by the container. If agent UIs show boxes or missing icons, install and select a Nerd Font in your terminal emulator (for example JetBrainsMono Nerd Font or FiraCode Nerd Font).
Container profiles can optionally configure container resources:
cpus: 8 # default: system cores - 2 (minimum 2)
memory: "16g" # default: 8g (supports K, M, G, T, P suffixes)When omitted, pi-tin allocates sensible defaults for development workloads. Resources take effect on the next workspace restart.
[!NOTE] Container memory is reserved on the host but not physically consumed until the container actually uses it. Memory is freed when the workspace stops.
Workspaces
A workspace defines your dev environment: which container profile to use, which projects to mount, and how the agent and host are configured.
The schema has two key sections: agent controls agent behaviour inside the workspace, host controls what the workspace can reach from your Mac.
profile: node-dev
projects:
- /Users/you/dev/my-app
- /Users/you/dev/my-lib
stopAfterLastSession: 30s
tools:
- name: Claude Code
package: "@anthropic-ai/claude-code@latest"
- name: Pi
package: "@earendil-works/pi-coding-agent@latest"
agent:
skipPermissions: true
profiles:
- personal
- pi-agent
host:
sshAgent: true
githubCLI: true
env:
COLORTERM: ${COLORTERM}
TZ: America/New_York
GIT_AUTHOR_NAME: Your Name
GIT_AUTHOR_EMAIL: [email protected]
GIT_COMMITTER_NAME: Your Name
GIT_COMMITTER_EMAIL: [email protected]
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}Notes:
projectsmust be absolute paths (set automatically bypi-tin create)toolsare installed by one of two methods. Claude Code, OpenCode, Copilot, and Antigravity use their official native installers, run in-container on the firstopenrather than at image build, into live host mounts; each then keeps itself current with its own auto-updater, and every update persists across container recreations because the install directory is host-backed. Everything else (Codex, Pi, Amp, Crush, and unrecognised packages) installs globally vianpm install -gat image build, then refreshes in the background on every open — including joins to a running workspace. The refresh installs into a separate prefix, so the current version stays available throughout; new sessions pick up the new version once it lands, and a failed refresh keeps the existing versions. The interactivecreatecommand writes onlynameandpackage. For known agents, pi-tin re-derives internal metadata (including the install method) frompackageat runtime. Manual tool entries should also use onlynameandpackage; extra keys are rejected.agent.profilesnames of agent profiles to mount (see Agent Profiles section below). Each profile provides isolated, persistent auth for an agent.agent.skipPermissions(defaulttrue) configures supported agents to skip interactive permission prompts (see Permissions).host.sshAgent(defaulttrue) forwards your SSH agent for git auth with any provider.host.githubCLI(defaultfalse) enables GitHub CLI integration — automatically mounts~/.config/ghand resolves aGH_TOKEN.host.mountsallows additional host directories to be mounted. Each entry is{ host, container, readonly }, all three required (e.g.{ host: ~/data, container: /data, readonly: true }).hostsupports~expansion; a mount whose host path doesn't exist is skipped with a warning. Must be directories — single file mounts are not supported. Mounting at a nested path (e.g.~/.nuget/packages) makes Applecontainercreate the missing parent directories root-owned; on each fresh start pi-tin hands parents under the container home back to the workspace user, so tools can write beside the mount. Parents outside the home directory are left as the runtime creates them.host.envvalues are passed to the container at runtime. Use${VAR}syntax to forward a host environment variable without hardcoding secrets in the YAML — if the host variable is unset, the entry is silently skipped. Only values that are exactly${VAR}are resolved; a${…}inside a longer string passes through literally, with a warning.sshd(defaultfalse) runs an sshd inside the workspace and publishes a host alias for it (see SSH access to workspaces). Toggling it applies on the next open via the normal rebuild/restart flow.attach(defaultshell) selects whatpi-tin openattaches:shell(a login shell viacontainer exec, as before) orherdr(a local herdr client connected over SSH — see herdr attach).attach: herdrimpliessshd. Override per open with--attach <mode>or the bare-invocation shorthand (pi-tin herdr,pi-tin shell).stopAfterLastSessioncontrols how long pi-tin keeps a workspace running after the last host-side session exits. Format: an integer with a singles/m/hunit (e.g.90s,5m,1h) — no combinations. Default:30s. On herdr workspaces the stop is agent-aware: while any herdr pane reports a working agent, the countdown re-arms instead of stopping.- If Apple's
containerCLI stops responding, pi-tin bounds all of its non-interactivecontainercalls (exec,cp,run,stop,kill,delete,listand image operations) and fails fast rather than hanging indefinitely; only the interactive shell attach and the streaming image build run without a deadline. (The agent install step is bounded too, but at ten minutes rather than the short wedge-detection deadline — it has a real download to wait for.) In that state you may need to restart the container system (container system stop, thencontainer system start) before the workspace can be attached or stopped cleanly. If those commands hang too, try restarting the relevant launchd service (for examplelaunchctl kickstart -k gui/$(id -u)/com.apple.container.apiserver, or a specificcom.apple.container.container-runtime-linux.<name>service). If that still does not recover it, log out/in or reboot macOS. - A workspace start may mount at most 22 distinct host directories — a conservative limit to avoid Apple
containerstartup failures with large mount sets. Projects,host.mounts, agent profiles, agent install mounts, tmux mounts, the herdr state mounts, and GitHub CLI mounts each count toward it. pi-tin createforwardsCOLORTERMfrom the host by default (COLORTERM: ${COLORTERM}) so tools inside the workspace can detect truecolor support when your terminal provides it.pi-tin createdetects your Mac's timezone (from/etc/localtime) and writes it as a literalhost.env.TZ(e.g.TZ: America/New_York) so the container shares your local time by default. Edit the value to use a different zone, or remove the line to fall back to UTC. The value is a snapshot taken at create time — it does not track later host changes. Existing workspaces are unaffected; addTZby hand to opt in. The managednode-devcontainer profile bundlestzdataso IANA zone names resolve; custom container profiles must include a zoneinfo source (e.g. thetzdatapackage) orTZwill silently fall back to UTC.- Git identity is detected from your host
~/.gitconfigduringpi-tin create.
Custom agents
pi-tin knows Claude Code, Pi, Codex, OpenCode, Amp, Copilot, Crush, and Antigravity out of the box. To add another, write an agent definition: a YAML file at ~/.config/pi-tin/agents/<name>.yaml, where the filename is the agent name.
# ~/.config/pi-tin/agents/oh-my-pi.yaml
package: oh-my-pi@latest
binary: omp
dot_dirs: [.oh-my-pi]
skip_permissions_flag: "--yolo"
container_env: { OMP_LOG: "info" }
api_key_vars: [OPENROUTER_API_KEY]A defined agent is then treated like a built-in: it is offered by pi-tin create, takes agent profiles (agent-profile add, agent-profile discover), is listed by detect-host, and gets its container_env and skip-permissions wrapper in the image. Write definitions with pi-tin agent apply <name> (JSON on stdin) or by hand; read one back with pi-tin agent show <name> — a built-in's output serves as a template, given a new name and package. pi-tin agent list shows the merged table with each entry's source.
Two names differing only by case cannot coexist: on a case-insensitive volume — the macOS default — OMP.yaml and omp.yaml are the same file. agent apply refuses such a name (exit 2) rather than replacing the definition already there, and the loader drops one of a pair reaching it by hand.
Agent definition schema
| Field | Required | Description |
| ----------------------- | -------- | ----------- |
| package | yes | npm package spec (e.g. oh-my-pi@latest). Identifies the agent in a workspace's tools, and is the install source unless install.method is native. |
| binary | yes | Executable name in the container. Must match ^[a-zA-Z0-9][a-zA-Z0-9_-]*$. |
| dot_dirs | no | Home-relative config directories an agent profile provides (e.g. [.oh-my-pi]). An agent with none cannot take an agent profile. Defaults to []. |
| host_mode_supported | no | Whether agent-profile add --host is allowed — see Host mode. Defaults to true. |
| install | no | { method: npm } (the default) or a native block — see below. |
| host_mode_warning | no | Caveat shown beside the host-mode option in agent-profile discover. |
| skip_permissions_flag | no | Flag the launcher wrapper adds when agent.skipPermissions is on (e.g. --yolo). Omit for agents that need none. Several space-separated tokens are allowed (--sandbox danger-full-access); the first must start with -, and only letters, digits and . _ - = : , / may appear. |
| container_env | no | Environment variables baked into the image of any workspace including this agent. Keys must match ^[A-Za-z_][A-Za-z0-9_]*$; values must be strings. Defaults to {}. |
| api_key_vars | no | API-key environment variables the agent reads (e.g. [FACTORY_API_KEY]). pi-tin create offers to forward those of the selected agents' vars set on the host — see API key users. Names must match ^[A-Za-z_][A-Za-z0-9_]*$. Defaults to []. |
| isolated_seed_files | no | Files written into a freshly created isolated agent profile, each { path, content } with path relative to the profile directory. Defaults to []. |
Every path field is home-relative: no leading /, no ./.. segments, and only letters, digits, ., _, -.
An agent with its own installer uses a native install block instead of npm, mirroring how pi-tin installs Claude Code and OpenCode — in-container on first open, onto live host mounts so its self-updates persist:
package: some-agent@latest # identity for `tools`; not an npm install here
binary: some-agent
dot_dirs: [".config/some-agent"]
install:
method: native
install_command: "curl -fsSL https://example.com/install.sh -o /tmp/i.sh && bash /tmp/i.sh && rm /tmp/i.sh"
installed_path: ".local/bin/some-agent"
persist_dirs: [".local/share/some-agent", ".local/bin"]
bin_dir: ".local/bin"
musl_packages: [libgcc, libstdc++]
musl_env: {}The native block's own fields (the npm block has nothing beyond method):
| Field | Required | Description |
| ----------------- | -------- | ----------- |
| install_command | yes | Single-line shell command, run in-container on the first open. |
| installed_path | yes | Path probed with test -x to decide whether the agent is already installed. |
| persist_dirs | yes | Directories live-mounted from the workspace state tree, so the install and its updates survive container recreation. |
| bin_dir | yes | Directory appended to the image PATH; must also appear in persist_dirs, or the definition is rejected — a bin dir outside the live mounts loses the install on every fresh container. |
| musl_packages | no | Extra system packages the binary needs on musl (Alpine) bases. Defaults to []. |
| musl_env | no | Extra image environment for musl bases. Defaults to {}. |
A native-install agent with no npm package uses its upstream binary name as package (e.g. package: some-agent): the field is identity only there — shown by agent list, written into workspace tools, and compared for collisions.
Agents authenticated by an API-key env var work on first open (declare the var in api_key_vars and forward it — see API key users). Agents that only offer a browser login must complete it once from an attached session; some cannot inside a container at all — check before writing the definition.
Agent recipes
Known-good definitions for agents pi-tin does not ship. Copy one to ~/.config/pi-tin/agents/<name>.yaml and adjust.
Qwen Code — npm install, Node 22+ (every managed profile qualifies). Qwen's own OAuth free tier ended in April 2026, so authenticate against any OpenAI-compatible endpoint: alongside the key, forward OPENAI_BASE_URL and OPENAI_MODEL via workspace host.env.
# ~/.config/pi-tin/agents/qwen-code.yaml
package: "@qwen-code/qwen-code@latest"
binary: qwen
dot_dirs: [.qwen]
skip_permissions_flag: "--approval-mode=yolo"
container_env: { QWEN_CODE_SUPPRESS_YOLO_WARNING: "1" }
api_key_vars: [OPENAI_API_KEY]Cursor CLI — native install; glibc bases only (no musl build). Sign in by forwarding CURSOR_API_KEY, or once from an attached session with NO_OPEN_BROWSER=1 cursor-agent login, which prints the URL rather than expecting a browser. Credentials land in ~/.cursor, so an agent profile carries the login.
# ~/.config/pi-tin/agents/cursor-cli.yaml
package: cursor-agent # no npm package — identity only
binary: cursor-agent
dot_dirs: [".config/cursor", ".cursor"]
skip_permissions_flag: "--force"
api_key_vars: [CURSOR_API_KEY]
install:
method: native
install_command: "curl -fsS https://cursor.com/install -o /tmp/cursor-install.sh && bash /tmp/cursor-install.sh && rm /tmp/cursor-install.sh"
installed_path: ".local/bin/cursor-agent"
persist_dirs: [".local/share/cursor-agent", ".local/bin"]
bin_dir: ".local/bin"Gemini CLI — no longer built in: Google retired it for individual accounts in June 2026; enterprise Code Assist continues. This definition restores the previous behaviour. Gemini CLI and Antigravity both keep state in ~/.gemini, so their agent profiles cannot share a workspace.
# ~/.config/pi-tin/agents/gemini-cli.yaml
package: "@google/gemini-cli@latest"
binary: gemini
dot_dirs: [.gemini]
skip_permissions_flag: "--approval-mode=yolo"
container_env: { NO_BROWSER: "true" }
api_key_vars: [GEMINI_API_KEY, GOOGLE_API_KEY]Built-ins always win. A definition whose name or package collides with a built-in — or with an earlier definition, compared case-insensitively and ignoring the version suffix — is skipped with a warning on stderr, as is one that fails to parse or validate. The remaining definitions still load, so one bad file never takes the CLI down.
Update notifications
pi-tin checks npm in the background — at most once every 24 hours — for a newer release, and prints a single-line notice the next time you return to your shell when one is available:
pi-tin <latest> available (you have <current>) · update: npm i -g pi-tinThe notice is automatically suppressed for non-interactive or machine-readable
use (piped output, --json, or a non-TTY stdout). To opt out entirely, set any
of the following to a non-empty value:
PI_TIN_NO_UPDATE_NOTIFIER— pi-tin's own opt-out.NO_UPDATE_NOTIFIER— the ecosystem-wide convention.CI— notices are off on CI by default.
[!NOTE] The check queries the public npm registry directly and does not honour a private/custom registry configuration.
Commands
| Command | Description |
|---------|-------------|
| pi-tin create [name] | Create a new workspace (interactive; prompts for a name when omitted; without a TTY exits 1 with error code interactive_only — use apply instead) |
| pi-tin [shell\|herdr] [--build] | Auto-open the workspace matching the current directory; when none match, offer to create a new workspace (or, if other workspaces exist, to add the directory to one of them); an attach token (pi-tin herdr, pi-tin shell) overrides the workspace's configured attach for this open; --build forces a rebuild when a match is found; without a TTY exits 1 with error code interactive_only |
| pi-tin add [workspace] | Add the current directory to an existing workspace (interactive picker), or add <name> to target one directly; the no-argument picker needs a TTY (exit 1, error code interactive_only) — add <name> works headless |
| pi-tin open <name> [--build] [--attach <mode>] | Start or join a workspace (interactive; attaches a login shell or, with attach: herdr/--attach herdr, a local herdr client; without a TTY exits 1 with error code interactive_only — use list/show instead) |
| pi-tin list [--json] | List all workspaces and their status (--json for machine-readable output: sessions/projects counts and shutdownMs, milliseconds until auto-shutdown or null; JSON is the default when output is piped) |
| pi-tin show <name> [--json] | Show a workspace definition as JSON (output is always JSON; --json is accepted for consistency) |
| pi-tin apply <name> [--dry-run] [--json] | Create or update a workspace from a JSON object on stdin (--json is accepted for consistency; see Editing workspaces) |
| pi-tin detect-host | Print host facts as JSON (output is always JSON) — { gitIdentity, tz, colorterm, apiKeys, agents } — for an agent to compose into a workspace apply payload |
| pi-tin agent-guide [--json] | Print the agent usage guide (--json for a machine-readable schema of commands, flags, and exit codes) — see Driving pi-tin from an agent |
| pi-tin stop <name> [--force] [--dry-run] [--json] | Stop a running workspace (prompts only when live sessions would be killed; non-interactive callers must then pass --force or get exit code 4; --force also escalates to container kill if a graceful stop exceeds 5s; --dry-run previews the effect; --json (default when piped) emits a structured result) |
| pi-tin delete <name> [--force] [--dry-run] [--json] | Delete a workspace, its image, and its saved workspace state (--force skips the confirmation prompt; non-interactive callers must pass it or get exit code 4; --dry-run previews the blast radius (container, image, saved state and its size); --json (default when piped) emits a structured result) |
| pi-tin cleanup [--all] [--force] [--dry-run] [--json] | Remove stopped containers, dangling images, unused volumes, and pi-tin images whose workspace no longer exists; --all does a full wipe (all pi-tin images, config, and data); --force skips the confirmation prompt; --dry-run previews what would be removed; --json (default when piped) emits a structured result; a full wipe refuses with error code workspaces_running while any workspace is running |
| pi-tin container-profile list [--json] | List all available container profiles (--json for machine-readable output; JSON is the default when output is piped) |
| pi-tin container-profile show <name> [--json] | Show details of a container profile (--json for machine-readable output; JSON is the default when output is piped) |
| pi-tin container-profile apply <name> [--dry-run] [--json] | Create or update a container profile from a JSON object on stdin (--json is accepted for consistency; see Editing container profiles) |
| pi-tin container-profile delete <name> [--force] [--dry-run] [--json] | Delete a container profile (--dry-run previews the impact, including referencing workspaces; non-interactive callers must pass --force or get exit code 4) |
| pi-tin agent-profile add <name> --agent <agent> [--host] [--json] | Create a new agent profile (the non-interactive creation path for agent profiles; --json for machine-readable output, JSON is the default when output is piped) |
| pi-tin agent-profile list [--json] | List all agent profiles (--json for machine-readable output; JSON is the default when output is piped) |
| pi-tin agent-profile show <name> [--json] | Show an agent profile (output is always JSON; --json is accepted for consistency) |
| pi-tin agent-profile delete <name> [--force] [--dry-run] [--json] | Delete an agent profile (--dry-run previews the impact, including referencing workspaces; non-interactive callers must pass --force or get exit code 4) |
| pi-tin agent-profile discover | Scan for agents and create agent profiles (interactive; without a TTY exits 1 with error code interactive_only — use agent-profile add) |
| pi-tin agent-profile finder [name] | Open agent profile directory in Finder (interactive; without a TTY exits 1 with error code interactive_only — use agent-profile show --json) |
| pi-tin agent list [--json] | List agents, built-in and custom, with each entry's source (--json for machine-readable output; JSON is the default when output is piped) |
| pi-tin agent show <name> [--json] | Show an agent definition, built-in or custom, as agent-definition JSON (output is always JSON; --json is accepted for consistency; a built-in's output is a template — it needs a new name and package before agent apply) |
| pi-tin agent apply <name> [--dry-run] [--json] | Create or update a custom agent definition from a JSON object on stdin (--dry-run prints the diff and writes nothing; --json is accepted for consistency; built-in agents cannot be overridden, and a package another definition already claims is rejected; so is a name differing from an existing definition only by case) |
| pi-tin agent delete <name> [--force] [--dry-run] [--json] | Delete a custom agent definition (--dry-run previews the impact, including referencing workspaces and agent profiles; non-interactive callers must pass --force or get exit code 4) |
pi-tin -v (--version) prints the version; --force accepts -f everywhere it appears.
Machine-Readable Output
pi-tin is built to be driven by scripts and AI coding agents, not just humans at a terminal.
Driving pi-tin from an agent
You can hand pi-tin to an AI coding agent and tell it, in plain language, to do the work — for example "use the pi-tin CLI to create a Python workspace for this project" or "use pi-tin to add an API key to my workspace". The agent learns the whole command surface from the binary itself, so it does not need any pre-placed instructions:
pi-tin agent-guideprints a concise usage guide written for agents. The top-levelpi-tin --helpprints this same guide automatically when its output is captured (non-TTY); on an interactive terminal it shows the normal help plus anAgents: run pi-tin agent-guidepointer.pi-tin agent-guide --json(orpi-tin --help --json) prints a machine-readable schema of commands, flags, and the exit-code contract. The schema also annotates destructive commands (destructive: true) and lists interactive-only commands (interactiveOnly) with their headless alternatives.
These describe the Agent surface (the JSON read-modify-write loop) and the stable exit codes covered below — start there for the details.
Agent surface
These commands form the JSON read-modify-write surface an agent uses to inspect and reconfigure pi-tin without a TTY:
| Read (--json / always JSON) | Write (JSON on stdin) |
|-------------------------------|-----------------------|
| show <name> (workspace) | apply <name> (workspace) |
| container-profile show <name> | container-profile apply <name> |
| agent-profile show <name> / agent-profile list | agent-profile add <name> --agent <agent> |
| agent show <name> / agent list | agent apply <name> (agent definition) |
| detect-host (host facts, no name) | — |
The contract is JSON in, JSON out:
- Each
showemits exactly the object its pairedapplyaccepts on stdin, so the loop is: read withshow --json, edit the object, write it back withapply(preview first with--dry-run). The one asymmetry:agent showalso prints built-ins, whose objectsagent applyrefuses under the same name orpackage— a built-in's output is a template for a custom definition, not a write-back.detect-hostsupplies host facts ({ gitIdentity, tz, colorterm, apiKeys, agents }) an agent composes into a workspaceapplypayload. applyis a full replace, not a merge — the target file is rewritten from the JSON object, so any YAML comments are dropped. Always preview with--dry-run(it prints the diff and writes nothing) before a real write. An existing file that no longer parses doesn't blockapply: the parse error becomes a warning on stderr and the diff treats the file as empty, soapplycan repair a corrupt workspace or container profile.- Invalid input is rejected against the relevant schema before anything is written. Every command exits with a stable, semantic code (see the Stable exit codes table below) and, in JSON mode, a structured error envelope — so callers branch on
code, never on prose. agent-profile addis the non-interactive creation path; agent-profile credentials are populated by logging in on first workspace use, not viaapply, so there is noagent-profile apply.
The subsections below — Editing container profiles and Editing workspaces — give the per-command detail.
JSON output. Data-returning commands (
list,show,container-profile list,container-profile show,agent-profile list,agent-profile show,agent list,agent show) accept--json. They also emit JSON by default when stdout is not a TTY — i.e. when the output is piped or captured — so a script never has to remember the flag. Pass--jsonexplicitly to force JSON even in an interactive terminal.Channels. Results go to stdout (the data channel); diagnostics, prompts, and errors go to stderr. Capturing stdout alone gives clean parseable output.
Stable exit codes. Every command exits with a semantic code so callers can branch on the outcome without parsing prose:
| Code | Name | Meaning | | ---- | ---- | ------- | | 0 |
SUCCESS| Success | | 1 |GENERAL| General / unexpected error | | 2 |VALIDATION| Bad input / schema validation failure | | 3 |NOT_FOUND| Named workspace, container profile, agent profile, or agent definition does not exist | | 4 |CONFIRMATION_REQUIRED| Destructive op without--forcein non-interactive mode |Destructive-command confirmation. Destructive commands (
stop,delete,cleanup,agent-profile delete,container-profile delete,agent delete) prompt for confirmation on an interactive terminal (stoponly when live sessions would be killed). Run without a TTY they exit with code4instead of hanging, unless--forceis passed. All six support--dry-run(preview the effect) and--json.Structured errors. In JSON mode, errors are emitted as a structured envelope on stderr —
{ "error": { "message", "code", … } }— so an agent can read the machine-stablecode(and anyremediation,validValues, orbadInputfields) instead of grepping the message text. An unknown command is a validation failure: exit 2 with codeunknown_commandand the valid command list invalidValues(this also applies when --help follows an unknown command, and topi-tin help <unknown>). Usage mistakes (unknown option, missing argument, a group command such asagent-profilewith no subcommand) are validation failures too: exit 2 with codeusage.Prerequisite failures. On an interactive terminal pi-tin offers to install the
containerCLI or start the container system service. Without a TTY it exits1with a structured error instead of prompting:container_not_installed,container_version_unsupported,container_system_not_running, orcontainer_system_probe_failed(platform_unsupportedon a non-macOS host). Caveat forcontainer_system_not_running: some sandboxed shells block access to the container system service, so a running service can be reported as not running — verify withcontainer system statusfrom an unsandboxed shell before starting or restarting the service.
Editing container profiles
container-profile apply <name> reads a single JSON object on stdin — the same shape that container-profile show --json emits — and writes it to ~/.config/pi-tin/profiles/<name>.yaml. This is the read-modify-write loop for scripts and agents:
pi-tin container-profile show node-dev --json > p.json
# edit p.json
pi-tin container-profile apply node-dev --dry-run < p.json # preview the diff, writes nothing
pi-tin container-profile apply node-dev < p.json # write- The JSON is validated against the container-profile schema before anything is written; invalid input exits with code
2(VALIDATION) and a field-naming message. - A name differing from an existing container profile only by case is refused with code
2as well: on a case-insensitive volume — the macOS default —Node-Dev.yamlandnode-dev.yamlare the same file, so the write would replace a different profile. applyis a full replace, not a merge: the file is rewritten from the JSON object, so any YAML comments (including the managed container-profile header) are dropped. An applied container profile is therefore user-managed — pi-tin's default container-profile sync will not overwrite it.--dry-runprints the diff envelope ({ "action": "create" | "update", "name", "dryRun": true, "changes": [...] }) and writes nothing. A real apply prints the result envelope ({ "action": "created" | "updated", "name", "changes": [...] }). Eachchangesentry is{ "path", "kind": "added" | "removed" | "changed", "before"?, "after"? }. Output is always JSON.
Editing workspaces
apply <name> reads a single JSON object on stdin — the same shape that show --json emits — and writes it to ~/.config/pi-tin/workspaces/<name>.yaml. This is the read-modify-write loop for scripts and agents:
pi-tin show my-workspace --json > w.json
# edit w.json
pi-tin apply my-workspace --dry-run < w.json # preview the diff, writes nothing
pi-tin apply my-workspace < w.json # write- The JSON is validated against the workspace schema before anything is written; invalid input exits with code
2(VALIDATION) and a field-naming message. applyis a full replace, not a merge: the file is rewritten from the JSON object, so any YAML comments are dropped.--dry-runprints the diff envelope ({ "action": "create" | "update", "name", "dryRun": true, "changes": [...] }) and writes nothing. A real apply prints the result envelope ({ "action": "created" | "updated", "name", "changes": [...] }). Eachchangesentry is{ "path", "kind": "added" | "removed" | "changed", "before"?, "after"? }. Output is always JSON.show <name>exits with code3(NOT_FOUND) when the workspace does not exist, listing the available workspaces.
Workspace Lifecycle
Workspaces are shared-session containers. pi-tin open starts the workspace if needed, or joins the existing workspace if it is already running. Multiple host terminals can connect to the same workspace at the same time, and pi-tin list shows session counts plus any pending shutdown countdown.
open: Starts the workspace if needed, otherwise joins it. On a fresh start, pi-tin automatically rebuilds the image if the container profile or workspace build config has changed.open --build: Forces an image rebuild on the next fresh start. If the workspace already has active sessions, pi-tin refuses and asks you to stop it first; if it has none but herdr reports working agents, it rejoins instead of rebuilding (see below). A rebuild does not touch the native agents (Claude Code, OpenCode) — their installs live on host mounts and their own updaters own their freshness.- Bare
pi-tin --build: From inside a directory matched by exactly one workspace (or after selecting one from multiple matches), behaves the same aspi-tin open <workspace> --build. - Rebuild failure: If a required rebuild fails (for example the machine is offline and the base image or a build step cannot be fetched) and a previously built image exists, pi-tin reports the failure and offers to open the workspace using that older image — your config changes stay unapplied until the next successful rebuild. It aborts instead when there is no previous image to fall back to, or when the session is non-interactive.
- Last session exit: When the last host-side
pi-tin opensession closes, pi-tin starts an auto-stop countdown usingstopAfterLastSession(default30s). Reopening during that grace period cancels the pending stop unless a fresh restart is needed to apply config changes. On herdr workspaces both halves are agent-aware: while any pane reports a working agent the countdown re-arms instead of stopping, and a reopen that would otherwise restart the workspace rejoins it instead — a detach ends the session but not the agents, so zero sessions is not zero work. Config changes and--buildapply on the next restart once they are idle. The final stop snapshots workspace state first, and so does that restart before it tears the old container down. - Interrupted open: closing the terminal window (SIGHUP), a logout, or a
killends the session and arms the same countdown before pi-tin exits, so an interrupted open never leaves a running workspace with nothing to reclaim it. Signalled once attached, the session closes normally,workspace_statesnapshot included — the close-out's copies decline the termination signal and run to their usual deadline. Each path's copy absorbs at most one occurrence of each termination signal; a repeat during the same copy quits immediately, keeping the countdown (armed the moment the first signal lands) but possibly losing the snapshot. Signalled earlier — during the state copy-in or an agent install — the countdown is armed but no snapshot is taken, since that needs container subprocesses pi-tin is in the middle of losing; the open stops there rather than going on to attach to a terminal that is gone. A signal arriving while a shell is attached is delivered when that shell exits, not before. - Crash forensics: every stop of a running container first snapshots the guest kernel log to
~/.config/pi-tin/diagnostics/<workspace>.dmesg.log(best-effort, latest container life only) — each container is its own VM, so without this a guest-side crash leaves no evidence once the container is deleted. stop: Stops a running workspace immediately, without aworkspace_statesnapshot.delete: Removes the workspace configuration and its image. It refuses while sessions are still active.
Opening a directory no workspace includes
When you run pi-tin from a directory that no workspace includes, what happens depends on whether any workspaces exist:
- No workspaces yet: pi-tin offers to create one.
- One or more workspaces exist: pi-tin offers to create a new workspace or add the current directory to an existing one.
Adding appends the directory to that workspace's projects list, preserving your YAML comments and formatting. pi-tin refuses without writing if the directory's basename collides with another project or if adding it would exceed the mount limit.
After the directory is added, the outcome depends on the target workspace's state:
- Stopped workspace: it starts immediately with the new project mounted, and you land in that project.
- Running workspace: the directory is added to config but is not mounted yet — the new project mounts on the workspace's next restart. pi-tin does not reopen it for you. Finish and exit every open session in that workspace, then reopen it (
pi-tin open <name>) to restart it and mount the project. (Reopening while a session is still active — or, on a herdr workspace, while agents are still working — just rejoins it unchanged.)
To add the current directory to a workspace at any time — including when it
already matches one (e.g. to also include it in a second workspace, or to fork
it into a new one) — run pi-tin add. It shows a picker of the workspaces the
directory is not already in, plus Create new workspace. pi-tin add <name>
adds it straight to that workspace. The same rules apply: comments are
preserved, a stopped workspace opens with the project mounted (headless —
no TTY — the add still completes and a pi-tin open <name> hint is printed
instead), a running one prints a restart reminder, and an add that would
collide on a project name or exceed the mount limit is refused without
writing.
SSH Agent Forwarding
pi-tin forwards your macOS SSH agent into the container (via --ssh on container run) instead of mounting ~/.ssh:
- Private keys never leave the host. The container talks to your Mac's SSH agent over a Unix socket; the agent performs each cryptographic operation and returns only the result. The key bytes are never present in the container, so there is nothing to exfiltrate.
- No configuration. With
ssh-agentrunning on your Mac (the macOS default), Git, scp, and other SSH tools inside the container authenticate transparently.
If you genuinely need keys present in the container, add ~/.ssh as a custom host mount at creation time — but agent forwarding is recommended.
SSH access to workspaces
With sshd: true (or attach: herdr), the workspace runs an unprivileged sshd on port 2222, owned by the workspace user. Each container has its own IP, and on every open pi-tin refreshes a Host block in ~/.config/pi-tin/ssh/config:
Host pi-tin-<workspace>One-time setup: ~/.ssh/config needs Include ~/.config/pi-tin/ssh/config near the top. The first sshd-enabled open offers to add it (the previous file is backed up to ~/.ssh/config.pi-tin.bak); decline and the instruction is printed instead.
Then any SSH-based tool works against the alias — ssh pi-tin-<workspace>, sftp, VS Code Remote-SSH, herdr:
- Auth uses a dedicated pi-tin keypair (
~/.config/pi-tin/ssh/id_ed25519, generated on first use); the public key is baked into the image. Password auth is disabled. - Environment matches
container exec. The container start snapshots its environment (imageENV,host.envvalues,GH_TOKEN, the forwardedSSH_AUTH_SOCK) into~/.ssh/environment, so ssh sessions see what shell sessions see. - Host keys are baked per image build and pinned per workspace (
~/.config/pi-tin/ssh/known_hosts.<workspace>,accept-new). A rebuild mints new host keys and clears that file, so reconnects stay prompt-free; stop/delete remove the Host block (and delete clears the pinned keys). - Session accounting caveat: raw ssh sessions are invisible to pi-tin — auto-stop counts only
pi-tin opensessions. For VS Code or other external-client workflows, raisestopAfterLastSessionor keep a pi-tin session open. - Landing directory: interactive ssh logins that would start in the (empty) home directory are redirected to
/workspace. Shells already started elsewhere — herdr panes opened from a project directory,ssh <alias> <command>— are unaffected.
herdr attach
herdr is a client/server terminal multiplexer for AI agents. With attach: herdr, pi-tin open (bare pt day to day) runs the local herdr binary (herdr --remote pi-tin-<workspace>) against a herdr server inside the workspace. The session UI runs workspace-side and streams to your terminal, so the herdr config that applies (config.toml: theme, toasts, sidebar; plugins) is the workspace's ~/.config/herdr — a live host mount backed by ~/.config/pi-tin/workspace-state/<workspace>/.config/herdr, so it survives container recreation (an explicit host.mounts entry at ~/.config/herdr overrides the managed mount). Keybindings are the exception: your local ones apply by default (snapshotted at attach; --remote-keybindings server uses the workspace's). The local clipboard — including image paste — bridges into agent panes, and panes survive detach.
- Prerequisite: herdr installed on the Mac. The server side needs nothing: on first attach the client installs a matching server into the workspace's
~/.local/bin(~10MB), which is a live host mount — so you're prompted to install once, not on every open, and it only re-installs after you upgrade the Mac's herdr client (the server must match it). The server refetches its agent-detection manifests from herdr.dev at every start; they land in~/.local/state/herdr, likewise a live host mount, so a start that cannot reach herdr.dev keeps the last manifests fetched rather than the older set bundled in the binary. - Detach and auto-stop: detach with
ctrl+b q(herdr's default binding) — a shellexitonly closes its pane, it does not end the session. Detaching the client ends the pi-tin session and starts thestopAfterLastSessioncountdown, but the lifecycle is agent-aware — while any herdr pane reports a working agent, the countdown re-arms, and a reopen that would otherwise restart the workspace to apply config changes (or--build) rejoins it and warns instead, leaving the agents alone. Once agents are idle the workspace stops; herdr session state lives on the host mount above — written continuously by the server, not snapshotted at teardown — so the next open restores the layout and herdr resumes supported agents (resume_agents_on_restore, on by default) even after a crash or hard stop. Pane scrollback is not part of that state (herdr persists layout and cwds). herdr's API sockets are redirected to the container's ephemeral disk (HERDR_SOCKET_PATH/HERDR_CLIENT_SOCKET_PATH, baked into sshd-enabled images): sockets cannot live on the mount (virtiofs rejectschmodon a socket), and dying with the container means a hard kill can never leave a stale socket behind. - Escape hatches:
--attach shell(orpt shell) opens a plain login shell on a herdr workspace;sshd: truewithattach: shellkeeps sshd in the image sopt herdrworks ad hoc. A herdr attach on an image built without sshd is refused with the fix (attach: herdrorsshd: true, then reopen to rebuild). open's working-directory behaviour (cdinto a project first) applies to shell attaches only — herdr manages its own pane cwds. Fresh panes that would start in the home directory land in/workspaceinstead (see SSH access to workspaces); panes opened from a project directory follow it as normal.
Git Authentication
Authentication depends on whether your remo
