Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Agent workspaces

Several agents working in one repository each need their own checkout, and each checkout builds its own target/ or node_modules. On one measured Cargo project that is 2.7 GiB per checkout before anyone edits a line. openxet workspace makes each agent's checkout a Git worktree on its own branch whose build outputs start as copy-on-write clones of a sibling's, so a new workspace shares its disk blocks until it changes them. A per-user daemon keeps a free-space floor by evicting idle workspaces' build outputs, and never touches your work.

It runs on your machine only and needs no OpenXet server. Every command takes --json and --dry-run where it changes something; the command line reference has each option and output field, and exit codes each error code.

The commands below run in CI, in order, in a scratch repository with a pushed commit and a built target/. Run them from the top of a repository of yours to follow along; the last ones change ~/.config/openxet/workspace.toml. Until there is a release, install openxet from source (Rust 1.97 or newer):

cargo install --locked --git https://github.com/stubbi/openxet openxet

For agents: install once, then nothing

Claude Code runs a WorktreeCreate hook in place of its own git worktree add when one is configured, and a WorktreeRemove hook when it removes a worktree. Install OpenXet's two hooks for every repository:

openxet workspace install claude --user

That merges them into ~/.claude/settings.json, keeping every other key and hook. From then on, every worktree Claude Code makes (claude --worktree, the EnterWorktree tool, subagents with isolation: "worktree") is an OpenXet workspace, and the agent does nothing different. Use --project instead to put the hooks in the repository's .claude/settings.json for everyone who clones it, and --dry-run to see the merged file first. If another tool already has a worktree hook there, the install stops with hook_conflict and writes nothing.

This is what Claude Code runs, by hand. claude-create reads the hook's JSON on stdin and prints only the new worktree's path; claude-remove removes it:

path=$(printf '{"cwd": "%s", "name": "Try Hooks"}' "$PWD" | openxet workspace hook claude-create)
echo "$path"
printf '{"worktree_path": "%s"}' "$path" | openxet workspace hook claude-remove

The name became the slug try-hooks, which is also the branch. claude-remove refuses, exits 1 and leaves the directory when the workspace holds unsaved work (see Removing a workspace), so Claude Code keeps it.

Claude Code does not run the remove hook when a subagent finishes, so a subagent's workspace stays until it is removed. Its build outputs don't: the daemon evicts them once it is idle (see What is evicted).

Other agents, and scripts, use the same commands directly. MCP tools for them come with the MCP server, openxet mcp (#129).

Creating and listing workspaces

ws=$(openxet workspace create --name fix-login --json | python3 -c 'import json, sys; print(json.load(sys.stdin)["path"])')
openxet workspace list

create adds a worktree on a new branch fix-login from HEAD (or --base). Without --path it goes to .claude/worktrees/fix-login when the repository has a .claude directory, else beside the repository in <repo>-worktrees/fix-login. Its target/, node_modules and the directories your .openxet/workspace.toml lists start as clones of the nearest sibling's: the registered workspace or main worktree closest in history to the base. Files keep their mtimes, and tracked files the sibling has unchanged get the sibling's mtimes, so Cargo finds the cloned outputs fresh instead of rebuilding them. .venv is never cloned, since a virtual environment holds its own absolute path.

A file still being written is never cloned. If the sibling's build outputs changed in the last two seconds, or a Cargo build there holds its lock, create waits for them to settle, for at most 30 seconds, and reports the wait as seed_waited_ms. After that it clones what has settled, and seed_unsettled says the sibling was still building.

create starts the daemon in the background when none runs. Set OPENXET_WORKSPACE_DAEMON=off where something else manages disk space.

Removing a workspace

remove deletes the worktree, its branch and its registration, unless that would lose work that exists nowhere else: changed tracked files, untracked files, ignored files outside the build output directories (results, a local .env), and commits no remote branch, tag or other local branch has. Then it refuses, exits 1 with unsaved_work, and lists each item:

echo "draft" > "$ws/notes.md"
openxet workspace remove "$ws"

Commit and push the work, or pass --discard to delete it with the workspace:

openxet workspace remove "$ws" --discard

remove --dry-run reports what it would remove and free, and refuses just as remove would.

Worktrees made without OpenXet

A worktree made by git worktree add in a shell, by another tool, or by Claude Code before the hooks were installed is not registered, so nothing evicts its build outputs. adopt --scan registers every linked worktree of the repository; adopt PATH registers one:

git worktree add -b by-hand ../by-hand
openxet workspace adopt --scan
openxet workspace remove ../by-hand

Both are safe to run again: a registered worktree is reported as already. The main worktree, your own checkout, is never adopted. An adopted workspace's branch may be yours, so remove keeps it.

Cursor runs the commands in .cursor/worktrees.json inside each worktree it creates (Cursor docs). Add the adoption as a setup step:

{
  "setup-worktree-unix": ["openxet workspace adopt ."]
}

Codex runs a local environment's setup script when it creates a worktree for a new chat (Codex docs). Its docs don't say which directory the script runs in, so add openxet workspace adopt --scan, which works from any worktree of the repository, to the setup script in the app's local environment settings.

For humans: set the limits, read the status

The daemon keeps free space on each volume with a workspace above a floor, by default the larger of 20 GiB and a tenth of the volume, and evicts the build outputs of workspaces idle for idle_ttl, by default 24 hours. Set your own in ~/.config/openxet/workspace.toml (or $XDG_CONFIG_HOME/openxet/workspace.toml):

mkdir -p ~/.config/openxet
cat > ~/.config/openxet/workspace.toml <<'EOF'
floor = "50 GiB"
idle_ttl = "12h"
EOF
openxet workspace status

floor is a number of bytes or a whole number with B, KiB, MiB, GiB or TiB; idle_ttl a whole number with s, m, h or d. Any other key or value fails create, status and the daemon with config, naming the file. A running daemon keeps the limits it started with; stop it (pkill -f 'openxet workspace daemon') and the next create starts it with the new ones.

status reports, for the volume the current repository is on, the free space and the floor, whether files can be cloned there, whether the daemon runs, how many workspaces are registered, and how many bytes the daemon has replaced by clones of identical files. When free space is under the floor even after evicting, create fails with disk_budget and creates nothing, so agents back off instead of filling the disk.

What is evicted and what never is

Every path in a workspace is durable or reconstructible.

  • Reconstructible: what Git ignores under target, node_modules and .venv at the workspace root, and under the directories a repository lists in .openxet/workspace.toml (reconstructible = ["build", "web/dist"]). Losing it costs a rebuild or a download.
  • Durable, never evicted: tracked files, untracked files Git doesn't ignore, every ignored path outside those directories, and anything unclear. Nothing is reconstructible by guess.

The daemon evicts only reconstructible directories, and only of workspaces that no process has its current directory in. Under the floor, it takes workspaces idle for 10 minutes, least recently active first, and deletes Cargo's incremental/ directories in all of them before any whole directory, until free space is 5% over the floor. incremental/ is the part least shared between workspaces and the cheapest to lose. After idle_ttl, a workspace loses all its reconstructible directories. The next build makes them again.

The main worktree is never registered, so the daemon never evicts its build outputs and remove never deletes it. A workspace's files under 64 KiB, and all files outside its reconstructible directories, are never replaced by clones either.

Filesystems

Clones need a filesystem that shares blocks between files:

PlatformFilesystemClones
macOSAPFSyes
LinuxBtrfsyes
LinuxXFS made with reflink=1yes
Linuxext4, tmpfs, network volumesno

Without clones, workspaces still work as plain worktrees: nothing is seeded and nothing is deduplicated, and OpenXet never copies build outputs in full instead. create reports seed_skipped: "reflink_unsupported" and status reports clones as not supported on that volume. Eviction and the floor work on every filesystem.

Measured

On an Apple M3 with APFS, debug builds of OpenXet's own workspace (373 units, 2.68 to 2.78 GiB of target/ each), with a script that seeds as create does (S16):

  • Six plain checkouts write about 16.7 GiB.
  • A checkout seeded by clone, with mtimes aligned as create aligns them, compiled 0 units, wrote 0 bytes and finished a no-op build in 2 s. Without the alignment, each compiled 12 units and wrote 1.31 GiB, 49% of a build.
  • Once workspaces diverge, rebuilding the 12 workspace crates wrote 1.31 GiB. Of a second complete build, whole-file dedup (what the daemon does) leaves 45% new, and chunk-level dedup would leave 14%; incremental/ is 65% of that 14%, which is why it is evicted first.

Linux, npm and Python projects and test targets are not measured yet.

Read this page as Markdown · Report a docs issue