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_modulesand.venvat 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:
| Platform | Filesystem | Clones |
|---|---|---|
| macOS | APFS | yes |
| Linux | Btrfs | yes |
| Linux | XFS made with reflink=1 | yes |
| Linux | ext4, tmpfs, network volumes | no |
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
createaligns 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.