State persistence
Synced from
outfitter/docs/documentation/state.md. The repository is the source of truth.
Outfitter launches agent CLIs from a temporary baked composition. During a run, Pi, Claude Code, or another adapter may write state such as settings, sessions, plugin installs, caches, auth metadata, or MCP configuration.
Outfitter does not silently copy every file back into your .agents tree. Instead, each adapter declares the state paths it understands, chooses safe defaults, and lets settings override how writes to those paths are handled.
Default behavior
Section titled “Default behavior”Most users do not need to configure state_persistence at all. By default, Outfitter keeps known agent CLI state durable and reports unexpected writes.
# This is the behavior most users get without writing any state_persistence block.# Known Pi and Claude Code state paths default to symlink, so normal setup survives.# Unknown writes default to warn, so surprising files are reported instead of silently persisted.state_persistence: auth.json: symlink # Pi login/auth state survives future runs. settings.json: symlink # Native CLI settings stay durable. mcp.json: symlink # MCP/server configuration stays durable. plugins/: symlink # Installed plugins can be reused. cache/: symlink # Useful package/cache state can be reused. sessions/: symlink # Session/project state is durable unless overridden. unknown: warn # Unexpected writes are visible and not silently persisted.Some generated runtime files, such as transformed settings or keybindings, may be treated as one-run generated files even though the underlying state path normally defaults to symlink. This keeps Outfitter-managed launch reconciliation from becoming accidental user state.
How state works
Section titled “How state works”Outfitter separates runtime files into three groups:
- Generated composition files — files Outfitter bakes from the resolved
.agentslayers and adapter rules. These are temporary and reproducible. - Declared state paths — files or directories the selected agent CLI is expected to read or write, such as
settings.json,mcp.json,plugins/, orsessions/. - Unknown writes — anything the agent writes outside declared state paths. Outfitter never silently persists these because it does not know their owner or merge rules.
Only declared state paths can be persisted automatically. Baked artifacts and dumps never contain persisted state — state is runtime, not configuration.
Configuring persistence
Section titled “Configuring persistence”Set state_persistence in settings — globally, per project, or in settings.local.yml for one machine:
# .agents/settings.yml — a stricter policy for a CI projectstate_persistence: settings.json: error # Fail if the agent changes settings during the run. mcp.json: error # Fail if tool/server config changes during the run. plugins/: error # Fail if plugin state changes during the run. unknown: error # Fail if the agent writes an undeclared file.Strategies
Section titled “Strategies”state_persistence values can be:
state_persistence: auth.json: symlink # Persist writes through a durable native CLI path. cache/: discard # Allow writes, then throw them away when the run ends. plugins/: warn # Allow writes, discard them, and report them after the run. settings.json: error # Allow the run, then fail if this path changed. mcp.json: prompt # Ask after the run: persist, discard, or always persist.Use symlink for state you want to keep, such as login state, durable settings, MCP config, or plugin installs. Use discard, warn, or error for state that should not become durable. Use prompt when you want to decide interactively after each run.
Prompt strategy
Section titled “Prompt strategy”When a prompt path changed during a run and both stdin and stdout are interactive terminals, Outfitter asks what to do with the change after the agent exits:
- persist — copy the change to the path’s durable destination for this run only.
- discard — throw the change away with the rest of the baked composition.
- always — persist the change and record a
state_persistence: <path>: symlinkoverride in the editable settings scope, so future runs persist writes to that path automatically. Outfitter never mutates a synced catalog cache: if the active configuration comes from a remote source, the change is persisted once and a warning explains that the choice could not be recorded.
In non-interactive sessions (CI, scripts, piped stdio), prompt falls back to warn and Outfitter prints an explicit prompt skipped: non-interactive notice.
Undeclared writes governed by unknown: prompt cannot be persisted because they have no durable destination; Outfitter reports them as warnings and says so.
Temporary directory cleanup
Section titled “Temporary directory cleanup”Baked composition directories are created under the system temporary directory and removed automatically when the Outfitter process exits or receives a handled signal. Removal deletes symlink entries without following them, so the durable auth/settings state the links point at is never touched. Pass --retain-projection to keep the directory for inspection; Outfitter prints its path.
Each startup also best-effort sweeps outfitter-* directories older than seven days from the temporary root. The sweep never follows symlinks, so a stale directory’s links are removed while their targets survive.
User stories
Section titled “User stories”Keep login working
Section titled “Keep login working”# Story: A developer connects Pi to a model provider during first-run setup.# Goal: The next `outfitter` launch remembers the login instead of asking again.state_persistence: auth.json: symlink # Keep provider login/auth metadata durable. models.json: symlink # Keep discovered/configured model metadata durable.Keep shared catalogs clean
Section titled “Keep shared catalogs clean”# Story: A team publishes a shared .agents catalog.# Goal: MCP config can come from the catalog, but one user's random runtime files# should not become shared team state.state_persistence: mcp.json: symlink # Keep intentional tool/server config durable. unknown: warn # Report unexpected writes instead of silently sharing them.Make CI reproducible
Section titled “Make CI reproducible”# Story: A platform engineer runs a baked Outfitter task in CI.# Goal: CI should prove the composition is complete, not depend on hidden runtime mutation.state_persistence: settings.json: error # Settings drift means the composition is incomplete. mcp.json: error # Tool config drift should fail the job. plugins/: error # Plugin installs/updates should be explicit in the tree. unknown: error # Any undeclared write is a reproducibility problem.Avoid cross-project leakage
Section titled “Avoid cross-project leakage”# Story: A consultant switches between client repositories.# Goal: Sessions, caches, and temp files from one client should not show up in another.state_persistence: sessions/: discard # Throw away conversation/session state after the run. cache/: discard # Throw away cache data tied to this run. tmp/: discard # Throw away temporary runtime artifacts. unknown: warn # Still report surprising writes for investigation.Experiment without losing visibility
Section titled “Experiment without losing visibility”# Story: An engineer tries new plugins or package installs locally.# Goal: Let the experiment run, but report what changed so the user can decide# whether to make it durable later.state_persistence: plugins/: warn # Allow plugin changes, but do not persist silently. unknown: warn # Surface other writes that may need a policy.Pi state paths
Section titled “Pi state paths”The Pi adapter declares these paths:
state_persistence: auth.json: symlink # Login/auth state; allowed: symlink, error, prompt. settings.json: symlink # Pi settings; generated launch transforms may be one-run. keybindings.json: symlink # Pi keybindings; Outfitter may generate launch keybindings. mcp.json: symlink # MCP/server configuration. models.json: symlink # Model/provider metadata. trust.json: symlink # Pi trust decisions. plugins/: symlink # Pi plugins. cache/: symlink # Pi cache data. sessions/: symlink # Pi sessions; the "Pi sessions" section covers how this works today. npm/: symlink # Pi npm package installs. git/: symlink # Pi git package checkouts. tmp/: symlink # Pi temporary runtime tree; allowed: symlink, discard. utilities/: symlink # Shared utility binaries such as rg/fd. bin/: symlink # Utility binary links. unknown: warn # Undeclared writes; allowed: discard, warn, error, prompt.Pi sessions
Section titled “Pi sessions”Pi stores conversation transcripts under its agent directory, and Outfitter points PI_CODING_AGENT_DIR at a baked composition that is deleted when the run ends. So that sessions are not deleted with it, every Pi launch sets PI_CODING_AGENT_SESSION_DIR to Pi’s own durable per-project session folder:
~/.pi/agent/sessions/--<your-project-path>--/This is the same folder a standalone pi uses in that project, so history is shared both ways and resuming works after the baked composition is gone:
outfitter run # first sessionoutfitter run -- --continue # picks up where the previous run left offThe default applies to every Pi launch, interactive or not, so outfitter run -p '…' in a script or CI job records a session in the same place. Nothing is copied back after the run: Pi writes straight to the durable directory.
To change or turn off session storage, pass Pi’s native flags through, or set the environment variable yourself — Outfitter never overrides a session directory you have already chosen:
outfitter run -- --no-session # ephemeral: record nothingoutfitter run -- --session-dir ./.pi/sessions # keep this project's sessions in the repoPI_CODING_AGENT_SESSION_DIR=/workspace/.pi/agent/sessions outfitter runThe last form is how a resident or in-cluster agent keeps continuity across restarts: point the variable at a persistent volume.
Claude Code state paths
Section titled “Claude Code state paths”By default a Claude run inherits the machine’s own configuration: Outfitter sets no
CLAUDE_CONFIG_DIR, and the composition reaches the session as a plugin directory instead. Claude
reads and writes ~/.claude exactly as it does in a native session, so credentials, workspace
trust, permission approvals, and session history need no bridge at all — there is nothing to seed
and nothing to copy back, and nothing Outfitter does can race your other Claude sessions. The rest
of this section describes an isolated run (--isolated, or isolation: isolated in your
~/.agents/settings.yml), where the projection is the whole configuration.
Under isolation, Claude credentials need a narrow adapter bridge in addition to the path-keyed state below. Claude
reads .credentials.json and .claude.json directly from CLAUDE_CONFIG_DIR; the ephemeral
projection gives .credentials.json no durable home, and .claude.json’s native location
(~/.claude.json, outside ~/.claude) does not share its config-dir-relative path. Outfitter
seeds .credentials.json before launch. It seeds oauthAccount and hasCompletedOnboarding when
those keys exist in durable state. It also seeds the current working directory’s accepted-trust bit
only when that exact trust decision already exists in durable state. Afterward it copies back the
whole .credentials.json when changed and atomically merges oauthAccount. If the durable
credentials also changed after seeding, Outfitter preserves that concurrent refresh and warns
instead of copying the projected credentials back. Claude MCP OAuth tokens
live under mcpOAuth in .credentials.json, keyed by <serverName>|<hash>, so server
authorizations acquired in an Outfitter-launched Claude session persist across runs through that
whole-file copy-back. Outfitter never copies the full machine-local ~/.claude.json into a
projection or merges its other projected state back. Trust accepted inside an isolated session is
therefore discarded, so an isolated run prompts for trust in a workspace that was never trusted
natively — which is one reason isolation is not the default.
Isolated Claude session history has a second narrow bridge because CLAUDE_CONFIG_DIR also redirects
Claude’s native projects/ tree into the temporary projection. Before launch, Outfitter derives
Claude’s project slug from the absolute working directory and copies only that slug directory from
~/.claude/projects/. This keeps other projects’ transcripts out of the projection while making
claude --continue and claude --resume see earlier native or Outfitter-launched sessions. After
the run exits or throws, Outfitter merges every new or content-changed regular session file from
the projection’s slug directories back into ~/.claude/projects/ atomically with mode 0600.
Durable files are never deleted. A seed or copy-back failure emits a warning and does not replace
Claude’s exit code or error.
These declared paths describe the isolated strategy; an inherited run writes to the native locations directly and declares nothing.
The Claude Code adapter declares these paths:
state_persistence: settings.json: symlink # Claude Code settings. agents/: symlink # Claude agent definitions. skills/: symlink # Claude skills. commands/: symlink # Claude commands/prompts. plugins/: symlink # Claude plugins. projects/: symlink # Claude project/session state. debug/: symlink # Claude debug state. unknown: warn # Undeclared writes; allowed: discard, warn, error, prompt.Where durable state lives
Section titled “Where durable state lives”When a path uses symlink, the durable destination is the native CLI state location — ~/.pi/agent/... for Pi, ~/.claude/... for Claude Code. The native location is not another configuration layer: it does not participate in resolution or merge precedence; it only provides a durable destination for state paths.
For a ported Claude Code setup, ~/.claude configuration entries are themselves symlinks into ~/.agents/, so persisted configuration state lands in the protocol tree while session and auth state stays native.
For the complete adapter contract and rationale, see State writeback strategy.