CLI reference
Every command below is verified against cmd/offshoot/main.go's usage
strings and command dispatch — flags, arities, and defaults are taken from
the code, not from memory. If this doc and offshoot's own -h-style usage
text ever disagree, the code wins; please file an issue.
Looking for the narrative walkthrough instead of a flag-by-flag reference — install, seed-once-fork-many with the pytest/testkit fixtures, xdist/vitest parallelism, golden-file assertions, CI — see docs/eval-harness.md.
Global
offshoot [-store SPEC] <command> [args...]
-store SPEC selects the store. It can appear anywhere in the argument
list (it's extracted before the subcommand is parsed), so offshoot create app -store ./mystore and offshoot -store ./mystore create app are
equivalent. If omitted, offshoot uses the OFFSHOOT_STORE environment
variable, and falls back to ./.offshoot if that's unset too.
Store spec forms:
| Form | Meaning |
|---|---|
path or ./path |
Local directory (relative or absolute, no scheme) |
file:///abs/path |
Local directory, explicit scheme |
s3://bucket/prefix |
S3-compatible bucket (AWS S3, MinIO) |
Any other URL scheme is refused with unsupported store scheme.
Every store-touching command except init first attaches to the store
(offshoot version and a bare offshoot usage print return before
attaching): it opens the
backend and runs a CAS (compare-and-swap) capability probe. This runs on
every invocation — a fresh CLI process re-pays it every time — and refuses
to proceed if the store doesn't enforce conditional writes, rather than
silently degrading to a weaker guarantee. A long-lived daemon (offshoot serve) pays this cost once per process instead of once per command.
S3 environment variables (only consulted for s3:// specs):
| Variable | Meaning |
|---|---|
OFFSHOOT_S3_ENDPOINT |
Custom endpoint (MinIO, or any S3-compatible endpoint); unset means AWS's default endpoint |
OFFSHOOT_S3_REGION |
Region; defaults to auto when a custom endpoint is set |
OFFSHOOT_S3_PATH_STYLE |
Truthy (1, true, yes, on, case-insensitive) selects path-style addressing (needed for MinIO) |
Credentials are never read from an offshoot-specific variable — they come from the AWS SDK's default chain (environment, shared config/credentials file, IAM role).
Other environment variables:
| Variable | Meaning |
|---|---|
OFFSHOOT_STORE |
Default store spec when -store isn't passed |
OFFSHOOT_CHECKOUTS |
Where checkouts are materialized, for a remote (s3://) store; local stores always keep checkouts under the store directory itself. Defaults to a per-store directory under the user cache dir, keyed by the store's resolved identity (endpoint/region/path-style included, not just the literal spec string) |
OFFSHOOT_SOCKET |
Overrides the daemon socket path for offshoot serve, offshoot session ..., and offshoot mcp; if unset, all three derive the same default path from the store spec, so they agree without it |
OFFSHOOT_TOKEN |
The Bearer token for offshoot serve -http, in place of -token; see -http ADDR below |
Naming rules, enforced on every database name, branch name, and
checkpoint name: 1–128 characters, charset [a-z0-9-_.], and never exactly
. or .. or containing .. as a substring (those are directory-traversal
segments once joined into a storage key). A bad name fails fast with store: invalid name ....
offshoot init
offshoot init
Creates a new store at the resolved spec (a directory, or a bucket/prefix)
and writes its manifest (layout version, creation time) with a create-only
conditional write. Must be run once before any other command against a
fresh store. Running it again against an already-initialized store fails
(the manifest write loses its CAS) rather than silently succeeding — don't
script init unconditionally before every command.
Errors: manifest already exists (already initialized); any store-attach failure (e.g. the CAS probe failing against a bucket without conditional writes).
offshoot create <db> [--from file]
offshoot create app
offshoot create app --from existing.db
Creates a new, empty database with a main branch at transaction id 1,
protected by default (destroying or promoting onto main requires
--force). With --from file, imports an existing SQLite file instead:
the source is copied (including -wal/-shm if present), the copy is
quiesced with a full WAL checkpoint, and that becomes the root snapshot —
the source file itself is never modified or truncated. There is no mode that
overwrites an existing user file.
Errors: refuses if db already exists (ref CAS conflict); --from with
a source file that doesn't exist or isn't a valid SQLite file.
offshoot checkout <db>[@branch] / offshoot path <db>[@branch]
offshoot checkout app
offshoot checkout app@attempt-1
offshoot path app@attempt-1
checkout materializes db@branch's current head to its fixed local path
and prints that path. path prints the same fixed path without
materializing — useful for scripting against a checkout you know is already
current. branch defaults to main when omitted (db alone means
db@main).
The checkout path is always <store-root>/checkouts/<db>/<branch>.db
(local stores) or under OFFSHOOT_CHECKOUTS / the cache dir (remote
stores) — never used as an identifier, always re-derivable from db@branch.
If a checkout already exists at that path, checkout requires it to be
quiescent first (no live writer holding it open) — re-materializing renames
a fresh file into place, which would delete a live writer's WAL out from
under it. If the existing checkout has un-checkpointed local edits, the head
state still wins, but a warning is printed to stderr first since those edits
are about to be overwritten.
Errors: no such db@branch; checkout is busy (a live connection is
holding it) — closes connections and retry.
Read-only historical checkout: --at <checkpoint> --read-only [--force]
offshoot checkout app@main --at v1 --read-only
Materializes a NAMED checkpoint (never the head, and never omitted — --at
has no "current head" alias the way export does) into a SEPARATE,
dedicated read-only cache path — <store-root>/checkouts-ro/<db>/<branch>@<checkpoint>.db
— and prints that path. --at and --read-only must be given together;
either alone is refused as a malformed command. This path is never the
writable checkouts/<db>/<branch>.db path checkout (without --at) uses,
and this command never touches that path, its .sum sidecar, or a live
session's open file descriptors on it — safe to run alongside offshoot mcp
or a daemon session on the same branch.
The result is chmod 0444 (read-only) and has no .sum sidecar and no
lease — it has no ongoing relationship to the store once written, and the
whole checkouts-ro tree is safe to rm -rf at any time; the next call for
anything under it just rebuilds what it needs. A repeat call for the same
db@branch@checkpoint is a cache hit (returned as-is, no store access at
all) unless --force is given, which re-materializes unconditionally — see
docs/status.md for the exact staleness caveat this cache
convenience accepts (a branch destroyed and recreated with a
same-named checkpoint can leave a stale cache entry; --force or deleting
the cache file clears it).
Errors: --at without --read-only or vice versa; no such checkpoint on
db@branch.
offshoot export <db>[@branch[@checkpoint]] <out.db> [--force]
offshoot export app out.db
offshoot export app@attempt-1 out.db
offshoot export app@attempt-1@v1 out.db --force
Copies db@branch's state at checkpoint (third @-separated component;
omitted means the branch's current head) out to a plain SQLite file at
out.db, anywhere on the local filesystem. Unlike checkout, the result has
ZERO ongoing relationship to the store afterward: no .sum sidecar, no
lease, nothing else in this codebase will ever look at out.db again — it's
a one-shot copy-out, not a checkout.
Refuses to overwrite an existing out.db unless --force. The write itself
is always atomic regardless of --force: it's built via a temp file in
out.db's OWN directory, renamed into place only once every chain member
has been fetched and its checksum verified — a failed export (a fetch
error, a checksum mismatch) never leaves a truncated or partial file at
out.db, and the rename is guaranteed same-filesystem (same directory).
Errors: no such db@branch; no such checkpoint; out.db already exists
and --force was not given.
offshoot diff <db>[@branch[@checkpoint]] <db>[@branch[@checkpoint]] [--summary]
offshoot diff app@attempt-1@v1 app@attempt-2@v1
offshoot diff app@attempt-1@v1 app@attempt-2@v1 --summary
offshoot diff app@attempt-1 app@attempt-2 # both at head
offshoot diff evals@golden@v1 candidate@main@final # cross-db is legit
Materializes both sides READ-ONLY through the same primitives export/
checkout --at --read-only use (never a live checkout, never a lease — safe
alongside an open daemon session on either branch) and either streams
sqldiff's output over them (default) or prints a stdlib-only table-level
row-count summary (--summary, no sqldiff dependency at all;
row-counts-only — equal counts with different values still report same,
use the default sqldiff mode for content). Each target uses the same
triple-@ form export does — db alone means db@main head, db@branch
means that branch's head, db@branch@checkpoint means that named
checkpoint. The two targets may name the same db or two different ones.
Full walkthrough, the raw by-hand recipe, and the exact staleness rule for a
head-side (no-checkpoint) target: docs/diff.md.
Before either mode's own output, a header line names which raw target
string is which side: left: <target1> right: <target2> (verbatim, using
exactly what was typed on the command line — not a normalized/expanded
form). --summary's own table header row reuses those same two strings as
its count columns instead of bare LEFT/RIGHT, so the table stays
self-describing even scrolled away from the header line above it.
Default mode requires the separate sqldiff binary on PATH (NOT
included by installing plain sqlite3 on every platform) — its absence is a
clear, per-OS-hinted error (sudo apt-get install sqlite3-tools on Debian/
Ubuntu, brew install sqldiff on macOS — both verified, not guessed; see
docs/diff.md) naming --summary as the
sqldiff-free alternative.
Errors: no such db@branch or checkpoint on either side; sqldiff not
on PATH (default mode only — --summary never needs it).
offshoot checkpoint <db>[@branch] <name> [--meta k=v ...]
offshoot checkpoint app v1
offshoot checkpoint app v1 --meta eval_run=42 --meta git_sha=abc123
Snapshots the current checkout's state (not just the ref) as a named
checkpoint, quiescing the checkout first (busy timeout ~3s, then a clean
failure rather than a hang). Checkpoint names are unique per branch — this
is the only operation actually named "checkpoint"; continuous background
capture by the daemon is called "flush"/"commit," not "checkpoint." At rest
(no daemon), every checkpoint writes a full snapshot, because there's no
capture engine tracking which pages changed since the last one; the daemon's
session flush writes incremental segments instead (see What a flush
costs in the README).
Every checkpoint records a creation timestamp (created_at, RFC3339 UTC)
automatically. --meta k=v is repeatable and attaches a small string→string
map to this specific checkpoint (e.g. an eval run id, a git SHA, an agent
id) — capped at 32 keys, 64-byte keys, 512-byte values, enforced before
anything is written; a rejected --meta leaves the branch untouched. This is
branch/checkpoint-level metadata, not row-level provenance — see the design
spec's metadata note.
Children never inherit a parent's checkpoints — a fork's own history
begins at its fork point (even a shared, copy-on-write fork's ref carries
only its auto-created fork checkpoint), so a checkpoint made before the
fork isn't addressable from the child; resolve it on the parent instead.
Errors: checkpoint name already exists on this branch; no checkout
exists yet (run checkout first); checkout is busy; --meta over a cap
(key count, key length, or value length).
offshoot fork <db>[@branch] <new-branch> [--at checkpoint] [--ttl duration] [--meta k=v ...]
offshoot fork app attempt-1
offshoot fork app attempt-1 --at v1
offshoot fork app attempt-1 --ttl 2h
offshoot fork app attempt-1 --meta eval_run=42 --meta git_sha=abc123
Creates new-branch as an independent branch from db@branch's head, or
from a named checkpoint via --at. branch (the source) defaults to
main.
Fork is copy-on-write. A fork shares the parent's already-durable
store objects through a base pointer — it records where in the parent's
chain it forked from and writes new objects only as it diverges, so N
forks of a G-byte database no longer cost N×G in the store; they cost
near-zero until each child actually writes. Reads on the child resolve
through the parent's objects below the fork point and the child's own
objects above it. The exception is the fork-time snapshot floor: when
the fork point's fully-resolved chain is already at the depth bound, fork
falls back to materializing one fresh snapshot in the child's own lineage
(a full copy, the pre-copy-on-write behavior), which keeps read
materialization bounded no matter how deep a fork-of-fork spine grows. A
shared child also self-snapshots on the ordinary snapshot cadence once its
own divergence crosses it, after which its reads never touch the parent.
offshoot status reports which class each branch is in
(storage=shared vs storage=materialized — see offshoot status
below), and offshoot compact converts a shared branch into a
self-contained one on demand (see offshoot compact below).
The fork-time floor is a per-process setting, not a persisted one. The
depth bound above defaults to 16 (ops.ForkShareMaxDepth) but is
configurable — the daemon sets it from its own -snapshot-every N (see
offshoot serve below) so the fork floor agrees with the cadence at which
a daemon-managed session self-snapshots. That configured value lives only
in the serving process's memory; nothing about it is written to the store.
An at-rest CLI fork (no daemon involved) therefore always uses the
default of 16, even against a store a daemon elsewhere serves with a
different -snapshot-every N. This is harmless — materialization stays
bounded either way — just potentially DIFFERENT from what a daemon's
configured cadence would have used: looser (shares for a few more levels
before falling back to materializing) if the daemon runs a lower N than 16,
tighter (falls back to materializing sooner) if the daemon runs a higher N.
N can be any value >= 1 (-snapshot-every's only floor), so either
direction is possible depending on how a given daemon is tuned. There's no
mechanism today for the CLI to learn a daemon's flag, since it isn't
persisted anywhere the CLI could read it.
Destroying a parent remains instant and always allowed — but under
sharing, the parent's bytes can outlive its ref: they are reclaimed by
GC only once no surviving child's chain still reads through them (see
offshoot destroy below for the full semantics).
The first shared fork upgrades the store to layout version 2. This is one-way and intentional: pre-copy-on-write binaries refuse the entire store from that point on (their GC reasons about whole lineages and would sweep shared objects out from under live children — silent data loss — so they are locked out up front by the manifest check rather than allowed to corrupt). Don't point an old binary at a store any new binary has forked in.
If forking at head (no --at) and the source's local checkout has
un-checkpointed changes or is busy, a warning is printed and the fork
proceeds from the branch's last committed (ref) state, not whatever's
sitting uncommitted in the checkout.
--ttl duration sets the child's TTL (never the parent's — forking
doesn't reset the parent's activity clock either). Fork has no "none"
sentinel the way touch does — a brand-new branch has no existing TTL to
explicitly clear — so omit --ttl entirely for "no TTL." A non-positive
duration (0s, a negative value, or the literal string none) is refused
outright rather than silently treated as "no TTL," since that would let a
caller believe they set a TTL when they didn't.
--meta k=v (repeatable, same caps as checkpoint's) attaches a small
string→string map to the new branch's own lineage — this describes the
branch, not the auto-created fork checkpoint (which still gets its own
created_at, but no metadata of its own). This is the same knob
branches/the daemon fork op and SDK fork() calls expose; branches
output surfaces each checkpoint's created_at/txid (checkpoints_v2) but
not raw metadata values today — read them back via the store directly, or a
future op, if you need to query by value.
Errors: new-branch already exists; unknown --at checkpoint name;
non-positive or "none" --ttl value; --meta over a cap.
offshoot touch <db>[@branch] [--ttl duration|none]
offshoot touch app@attempt-1
offshoot touch app@attempt-1 --ttl 30m
offshoot touch app@attempt-1 --ttl none
Resets a branch's activity clock (deferring TTL-based reaping) without
changing anything else. With --ttl duration, also sets a new TTL. With
--ttl none, clears the TTL entirely (the branch then lives until
destroyed). Without --ttl, the TTL is left as-is — only the clock resets.
This is the only way to defer expiry on a branch nobody currently has open
(a daemon session holding a branch renews its lease continuously, which
defers reaping on its own).
Output re-renders the TTL through Go's canonical time.Duration.String() —
a branch forked with --ttl 1h reads back as ttl=1h0m0s.
Errors: the branch is currently being reaped (a reaper already claimed it) — "too late to touch."
offshoot rollback <db>[@branch] --to <checkpoint>
offshoot rollback app@attempt-1 --to fork
Repoints the branch at a new lineage seeded from checkpoint's state
(internally, the same machinery as fork). The old lineage is orphaned,
retained through the GC grace period, then collected. Checkpoints at or
before the target are kept (their snapshots are copied into the new
lineage so they survive the old lineage's eventual collection); checkpoints
after it are dropped. Any lease on the branch is cleared by the repoint, so
it's immediately acquirable afterward. Prints the (re-materialized) checkout
path.
The ref repoint (a CAS write) is the point of no return; the local checkout
refresh that follows is best-effort — if it fails (e.g. the checkout is
busy), the command reports a partial success: the branch did roll back,
but the checkout needs a manual offshoot checkout to catch up.
Errors: unknown checkpoint name; lost a concurrent CAS race (retry).
offshoot promote <db>@<source> --onto <target> [--force]
offshoot promote app@attempt-1 --onto main --force
Repoints target at a new lineage seeded from source's current head
(fork machinery again — this is why promoting never risks an epoch
collision). source survives unchanged (it's typically left to TTL-reap
later, or destroyed explicitly). target's old lineage is orphaned and
later garbage-collected; its checkpoint map resets to just {"promote": <txid>}. If target is protected (main is protected by default),
--force is required. Promote never checks target's lease at all — with
or without --force — and any lease on target is cleared by the repoint
(the same unconditional clearing rollback does; see above), so target is
immediately acquirable afterward regardless of who held it. target's
checkout, if any, is refreshed after a busy probe — same best-effort
semantics as rollback.
Errors: source == target; target is protected without --force;
target checkout is busy (repoint still lands; checkout refresh is skipped
and reported); lost a concurrent CAS race (retry).
offshoot compact <db>[@branch]
offshoot compact app@attempt-1
Turns a shared (copy-on-write) fork into a self-contained branch: its
full state at head is re-encoded as one snapshot in a fresh lineage and
the branch's base pointer is dropped, so it stops reading through — and
stops pinning — its ancestors' storage. This is the manual "pay the full
copy now to release a dead ancestor" lever: after compacting the last
sharing child, a destroyed ancestor's lingering bytes become reclaimable
by the next offshoot gc pass (compact itself deletes nothing; GC owns
reclaim). A branch that is already self-contained is a no-op (prints
the current head txid), so scripted "compact everything" loops never fail
on branches with nothing to do.
The flagged tradeoff: compact resets the branch's checkpoints to a
single compact checkpoint at the new head, exactly like promote resets
to promote (and unlike rollback, which preserves checkpoints at or
before its target). Old checkpoints were anchored on the shared ancestor's
storage and would not resolve in the new self-contained lineage. If you
need a pre-compact checkpoint, export it first.
Cost class: compact is a full materialize — one full copy of the branch's
state (~G bytes for a G-byte database), the same cost class a single
promote or rollback pays — not a cheap metadata flip. (The N×G figure
elsewhere in this page is the aggregate cost of N materialized forks;
one compact pays ~G, once.) Through the daemon (the
compact op, SDK compact()), compact refuses while this daemon has an
open session on the branch — close it first, exactly like rollback and
promote: the session owns the checkout compact would repoint out from
under it. With no session open, the durable head is already current (the
last flush persisted), so compact materializes exactly that head. A
concurrent flush from elsewhere that advances the head between the copy
and the ref swap loses the CAS and returns a retry error.
Errors: no such db@branch; branch has an open session on this daemon
(close it first); lost a concurrent CAS race to a flush (retry).
offshoot destroy <db>[@branch] [--force]
offshoot destroy app@attempt-1
offshoot destroy app@attempt-1 --force
Deletes the branch's ref and its local checkout files (.db, -wal,
-shm, .sum). Destroying a parent is always safe, instant, and allowed
regardless of live children — a parent's destruction can never corrupt a
child, whether that child is a shared (copy-on-write) fork or a
materialized one. --force is required to destroy a protected branch
(main by default), and also to destroy a branch under an active lease (a
live holder may still be mid-write; without --force this is refused
outright).
Under copy-on-write, "destroyed" and "reclaimed" are different events.
Destroying a branch removes its ref immediately, but if any surviving
child still shares its storage (forked from it and hasn't diverged past
it), the destroyed parent's bytes linger in the store: GC's reachability
mark follows every live branch's chain through its base pointers, so a
shared ancestor's objects stay live for exactly as long as some
descendant's reads still resolve through them. The lingering bytes are
reclaimed once the last sharing child is itself destroyed — or compacted
(offshoot compact, the manual release valve). Objects of the destroyed
parent that no child ever needed (e.g. everything above the last fork
point) are reclaimed on the normal GC schedule right away. In short:
destroy is instant; the storage refund waits for the last sharing
child.
Errors: protected without --force; live lease without --force;
checkout is busy (close connections first); the destroy lost a race to a
concurrent AcquireLease on the same branch (retry — see below).
Claim-guarded delete (Milestone 4 Task 6b)
Between checking a branch's lease and actually deleting its ref sits a
window: a lease could be acquired in that gap and have its brand-new
holder's branch deleted out from under it moments later. Destroy closes
this by CAS-writing a Deleting claim on the ref before it does anything
irreversible — the same shape as offshoot gc's reap claim (Reaping),
generalized to every destroy call, not just TTL reaping. A concurrent
AcquireLease (CLI lease acquire, the daemon open op, or the
embeddable library directly) checks for this claim and refuses outright
(retryable — the claim is always transient) rather than racing it. This
closes the exact TOCTOU an earlier design review documented for the M2
Destroy path.
--force still claim-guards: it bypasses the protected/live-lease checks
above — an operator's explicit override of those — never the claim
itself. A lease acquired a moment before a forced destroy lands still wins
the underlying compare-and-swap on the ref, and the forced destroy reports
a retryable race loss exactly like an unforced one would.
Backend-specific mechanics (deliberately: do not pretend S3
DeleteObject has preconditions it doesn't):
- Local gets a TRUE conditional delete on top of the claim: the same
per-key lock file
PutIfalready uses to implement compare-and-swap also backs a compare-and-delete (Local.DeleteIf) — belt-and-suspenders, since the claim above already serializes concurrent destroys/acquires on its own. - S3 has no compare-and-delete precondition in its API at all
(
DeleteObjectignoresIf-Match/If-None-Match; those headers only apply toGetObject/PutObject). Its delete stays unconditional; the CAS-writtenDeletingclaim marker is the entire safety mechanism on this backend, not a supplement to a conditional delete that doesn't exist.
A Destroy call that crashes after landing its claim but before finishing
the delete leaves the claim stranded — the branch is untouched (no partial
delete), but a naive read of the claim alone would block it forever. The
daemon's janitor self-heals this on the same cadence as reap/GC: a
Deleting claim older than 30 seconds (destroy is a handful of local
filesystem operations plus at most one checkout quiesce — anything stuck
longer means the process that claimed it is gone) is cleared, and the
branch becomes destroyable/leasable again. offshoot gc triggers the same
self-heal on demand, same as it does for a stranded reap claim.
offshoot gc [--grace duration]
offshoot gc
offshoot gc --grace 30m
Two steps in one command. First, reap: destroys every branch whose TTL
has expired (same logic the daemon's janitor runs on a timer — see serve -reap-every); a failure reaping one branch (e.g. its checkout is busy) is
reported to stderr but doesn't stop the rest of GC. Second, collect:
two-phase garbage collection over storage objects no live branch can reach
— reachability follows every branch's resolved chain at its head and at
every checkpoint, transitively through copy-on-write base pointers, so a
shared ancestor's objects stay live as long as any descendant's chain still
reads through them, while a destroyed parent's above-fork objects are
reclaimed. Unreachable objects are first tombstoned (marked, timestamped),
then actually deleted once a tombstone is older than --grace (default
1h) and still unreachable at sweep time (an object re-referenced during
the grace window, e.g. by a fork racing GC, is left alone). --grace 0
makes an object eligible for deletion on the very next gc run after being
tombstoned, rather than disabling collection.
Prints which branches were reaped, and how many objects were tombstoned vs. actually deleted.
offshoot status [-ro-cache-budget BYTES]
offshoot status
offshoot status -ro-cache-budget 500MB
Prints every branch across every database: its computed state (see
below), its storage class (see below), head transaction id, named
checkpoints, protected and checked-out flags (when applicable), and —
for a branch with a TTL — the TTL itself and time remaining until it's
reap-eligible (remaining=expired once past the deadline). TTL remaining is computed
from the later of the branch's last-touch time and its lease expiry,
whichever is later — matching exactly what the janitor's reap logic uses,
so status never disagrees with what will actually happen.
After the branch listing, a final ro-cache: N entries, B bytes used (budget: unlimited) line (Milestone 4 Task 5) reports checkouts-ro's
current usage (ops.Workspace.ROCacheUsage, an at-rest read — no daemon
required, exactly like the rest of this command). -ro-cache-budget is
display-only here: it echoes back serve -ro-cache-budget's own value
and grammar (see below) so an operator can see usage against the budget
they intend to run with, without needing a live daemon connection — the
budget itself is never persisted anywhere (like every other serve tuning
flag), so this at-rest command has no other way to know what a running
daemon was actually started with. Omitted, the line reads
(budget: unlimited) regardless of what any running daemon's own
-ro-cache-budget is actually set to.
Storage class: storage=shared vs storage=materialized
Every branch line carries its copy-on-write cost class, because the two classes have genuinely different storage bills and hiding that would be dishonest:
storage=shared— the branch is a base-pointer fork: it reads through an ancestor's durable objects and added near-zero storage of its own at fork time. Cheap to hold, but it pins whatever ancestor storage its chain still resolves through (seeoffshoot destroyabove).storage=materialized— the branch is a fully self-contained lineage: created roots, the results ofpromote/rollback/compact, and forks that tripped the fork-time snapshot floor. It pins nothing and nothing else's destruction can defer its reclaim.
The asymmetry to internalize: fork shares (near-free);
promote, rollback, and compact each materialize a full copy.
"Fork at checkpoint X is free but rollback to X costs a full copy" is a
real, deliberate wart — rollback and promote abandon their old lineage
(base-pointing into a lineage that is meant to die would pin it forever),
so they pay up front. The daemon's branches op reports the same bit as
BranchInfo.shared (wire-additive; an older client simply doesn't read
the key), computed from the same ref field, so the CLI and daemon surfaces
can never disagree.
Branch states
Every branch is in exactly one of six states, computed fresh on every call
— nothing about state is persisted anywhere. offshoot status
(ops.Workspace.Status, CLI/at-rest — see above) and the daemon's
branches op (BranchInfo.state; Python Client.branches()'s
Branch.state, TypeScript Client.branches()'s Branch.state) report the
identical computation for the states both can see; a daemon additionally
knows two states no at-rest computation can, since they depend on its own
in-memory session map:
| State | Meaning | Who can report it |
|---|---|---|
active |
The branch's ref carries a live lease — someone (in or out of a daemon) holds it right now. | Both |
pending |
This daemon has reserved a session slot for the branch and is still inside its (slow) session.Open — no live session yet, but the branch is spoken for. |
Daemon only |
error |
A session is open here and its Err() is non-nil (lease loss, a capture failure, any terminal session failure). |
Daemon only |
dirty |
No live lease; a checkout exists whose sidecar-recorded identity (lineage/epoch/txid) matches the ref but whose content hash doesn't — un-checkpointed local edits. | Both |
detached |
No live lease; a checkout exists whose sidecar-recorded lineage doesn't match the ref's current lineage — a checkout orphaned by a rollback/promote that repointed the branch at a new lineage before (or without) refreshing this checkout (their own checkout refresh is best-effort and can be skipped by a busy checkout at repoint time). |
Both |
idle |
None of the above. | Both |
Precedence when more than one condition technically holds, most to
least specific: error > pending > active > dirty > detached >
idle. In practice error and pending can never both apply to the same
branch at once (a daemon's session map holds at most one entry per
db@branch, either a reservation or a live session, never both) — the
ordering matters for active vs. dirty/detached: a branch can be both
leased AND locally modified/orphaned at the file level, and active wins
the report.
idle is a deliberate addition, not in the original design spec. The
spec's branch-state taxonomy (active/pending/dirty/detached/
error) assumed a daemon was always running to layer a state over every
branch. offshoot status's CLI/at-rest mode has no daemon and no session
map, so a branch with nothing going on (never checked out, never leased)
still needs a name to report — idle is that name. It also covers a
case checkout-staleness detection can't cleanly attribute to detached:
a checkout whose sidecar-recorded lineage still matches the ref, but whose
epoch/txid lags behind (the branch advanced within the SAME lineage — e.g.
a daemon session's flush — since this checkout was last refreshed), isn't
orphaned, just stale; it needs a re-materialize (offshoot checkout), not
a warning that its parent branch moved out from under it. That case reads
idle, not detached.
dirty and detached are structurally mutually exclusive on any
single evaluation: a lineage mismatch reports detached immediately,
before the identity/hash comparison that could ever produce dirty runs
at all.
Known blind spot: a checkout with no readable .sum sidecar at all
(never stamped, or corrupt/legacy) always reads idle, even if its
content has actually diverged from the ref — there's no sidecar to detect
that against. This is a deliberate "no evidence, stay silent" stance
(mirrors the sidecar mechanism's own internal "unknown" verdict for the
identical input), not an oversight; it's only reachable via a checkout
materialized entirely outside offshoot checkout/checkpoint/rollback/
promote, all of which always stamp a sidecar.
Cost: determining dirty (once a checkout's sidecar identity already
matches the ref) requires a real WAL checkpoint (wal_checkpoint(TRUNCATE),
up to a 3-second busy timeout) followed by a full SHA-256 hash of the
checkout's content — a committed write can sit in a checkout's WAL,
invisible to a bare hash of the main file, until something folds it in, so
skipping the checkpoint step would misreport a genuinely dirty checkout as
idle. This runs per branch, on every offshoot status / branches
call, for every branch that is checked out, unleased, and not already
detached — i.e. exactly the branches where "is it dirty" is still an
open question. A store with many large, checked-out, unleased branches
will feel this on every call. There is no cheap short-circuit: file
size/mtime cannot substitute for a real hash (mtime is exactly what this
codebase's own .last-used-style touch-file conventions elsewhere work
around, not something trustworthy for content identity). If the checkpoint
attempt itself reports busy — a live connection is actively using that
unleased checkout right now — the state is reported as dirty directly
(without a hash), on the reasoning that active, untracked use of an
unleased checkout IS itself the kind of activity dirty exists to
surface, even though the exact byte diff can't be observed at that
instant.
offshoot lease list
offshoot lease list
Lists every branch currently carrying a lease record: holder identity
(<hostname>/<pid> by convention), state (held or expired), epoch, and
expiry timestamp. A lease with a corrupt (unparseable) expiry is listed as
expired with a warning to stderr, rather than hiding the branch or crashing
the listing.
offshoot lease acquire <db>[@branch] [--ttl 30s]
offshoot lease acquire app@main
offshoot lease acquire app@main --ttl 60s
Claims (or renews, if already held by this same identity) a lease on the
branch, bumping its epoch. --ttl defaults to 30 seconds
(ops.DefaultLeaseTTL) if omitted. This command exits immediately — it
does not hold the process open — so the lease will simply expire unless
something else renews it before then. It exists for inspection and for
deliberately breaking/reclaiming a stuck lease (acquiring bumps the epoch,
fencing out whatever previously held it), not for long-running write
sessions — use offshoot serve + session open for that.
offshoot lease release <db>[@branch]
offshoot lease release app@main
Releases the branch's current lease (looked up first via the same listing
lease list uses).
Errors: no lease currently held on that branch.
offshoot serve [-socket PATH] [-reap-every DURATION] [-gc-grace DURATION] [-flush-every DURATION] [-snapshot-every N] [-ro-cache-budget BYTES] [-http ADDR] [-token TOKEN] [-http-allow-non-loopback]
offshoot serve
offshoot serve -socket /tmp/o.sock
offshoot serve -reap-every 1m -gc-grace 15m -flush-every 30s # all three are the defaults
offshoot serve -snapshot-every 4 # snapshot every 4th flush instead of every 16th
offshoot serve -http 127.0.0.1:8080 # opt-in HTTP: token auto-generated, printed once
OFFSHOOT_TOKEN=$(openssl rand -hex 32) offshoot serve -http 127.0.0.1:8080
Starts the daemon: a long-running process that serves a unix socket (mode
0600) for session ... commands, holds branch leases, captures every
committed WAL transaction continuously, and runs the janitor. Blocks until
SIGINT/SIGTERM, at which point it releases every lease and shuts down
cleanly (closing live sessions, draining in-flight opens, removing the
socket, and closing any HTTP listener) rather than leaving stale leases
behind.
-socket PATH overrides the default socket location (see OFFSHOOT_SOCKET
above); if a session command needs to reach this daemon, it must be given
the same -socket PATH (or OFFSHOOT_SOCKET) — there's no other way for
the CLI to discover a non-default socket.
-reap-every sets the janitor's interval for both TTL reaping and the
periodic GC sweep (default 1m); -reap-every 0 disables the janitor
entirely (GC and reaping are still available on demand via offshoot gc).
-gc-grace is the tombstone grace period the janitor's GC pass uses
(default 15m) — see offshoot gc above for what grace means.
-flush-every sets the background-flush cadence applied to every session
this daemon opens (default 30s, 0 disables, a negative value is a usage
error): each open session ships whatever it's captured but not yet flushed
on that timer, without the agent ever calling session flush itself. It
bounds exposure — worst case, a daemon that dies loses at most one
-flush-every interval's worth of committed-but-unflushed writes, instead
of everything since the last manual flush. See What a flush
costs in the README for what a background
flush (and every session's mandatory first "settling" flush) actually cost.
-snapshot-every N sets the full-snapshot cadence
(session.Options.SnapshotEvery) applied to every session this daemon
opens (default 16, unchanged if the flag is omitted; must be >= 1 —
there is no "unlimited"/"disabled" sentinel the way -flush-every 0
disables auto-flush, since every flush must eventually snapshot).
Options.SnapshotEvery has been configurable in the embeddable session
library since Milestone 2; this flag is what exposes the same knob to a
daemon-managed session, closing the gap the design spec's original
taxonomy left open. See What a flush
costs in the README for the cost
trade-off this cadence controls: a lower N (more frequent snapshots)
means cheaper, bounded reads (a chain never replays more than N-1
segments past its snapshot) at the cost of shipping a full-database
upload on every Nth flush instead of an incremental segment; a higher
N amortizes that upload cost across more flushes but lets read-side
replay grow proportionally longer between snapshots. There is no single
right answer — it is a bandwidth/write-cost vs. read-latency trade-off
tuned to a workload's actual write-vs-read ratio, the same trade-off
docs/benchmarks.md measures at the library's
default of 16.
Errors: socket path already in use by another listener; underlying
store-attach failure; -flush-every given a negative duration;
-snapshot-every given a value less than 1, or a non-integer;
-ro-cache-budget given a negative value.
-ro-cache-budget BYTES — checkouts-ro disk budget (Milestone 4 Task 5)
offshoot serve -ro-cache-budget 0 # unlimited (the default)
offshoot serve -ro-cache-budget 536870912 # 512 MiB, in bytes
offshoot serve -ro-cache-budget 500MB # same idea, via a size suffix
Bounds checkouts-ro — the read-only cache offshoot checkout --at --read-only / the daemon's checkout-at op materializes into (see
above)
— which otherwise grows without bound: one file per distinct
db@branch@checkpoint ever cached, never reclaimed on its own. Never
checkouts/ (the writable, leased tree checkout/session open use) —
see the writable-never-evicted guarantee below.
Default 0 means unlimited, matching CheckoutAt's own unbounded-by-default
behavior — the janitor still computes and reports usage every pass (see
below), it just never evicts. A bare integer is bytes, the contract this
flag, offshoot_ro_cache_bytes, and every eviction event/log line all
speak; as a convenience, a trailing power-of-1024 size suffix is also
accepted, case-insensitively: K/KB, M/MB, G/GB, T/TB (each
multiplies by 1024^n — not the SI decimal 1000-based convention some
tools use for the same letters; pass raw bytes if that distinction
matters to you). offshoot status's own -ro-cache-budget flag accepts
the identical grammar, purely for display (see offshoot status above) —
it is never persisted, so that at-rest command has no other way to know
what a running daemon was actually started with.
When it runs: on the same cadence as reap/GC — every -reap-every
tick (the janitor loop; -reap-every 0 disables the janitor entirely, so
no ro-cache pass runs either, exactly like reap/GC). Each pass:
- Computes
checkouts-ro's total current size and setsoffshoot_ro_cache_bytesto it — always, even when the budget is0or usage is already under it. This is a once-per-janitor-pass gauge, not a scrape-time one: between passes it can lag real usage by up to-reap-every's interval (e.g. aCheckoutAtcall materializing a large new entry moments after a pass won't be reflected until the next one). - If usage exceeds the budget, evicts entries oldest-by-LRU-clock first (see below) until usage is back at or under it.
The LRU clock (a deliberate design decision): a .last-used
touch-on-HIT marker file, not the cache file's own mtime. Every
force=false CheckoutAt cache HIT (a repeat call for an already-cached
db@branch@checkpoint) touches a <cachefile>.last-used sidecar file to
the current time. This exists because the cache file's own mtime is set
exactly once, by the materialize that created it — a cache HIT is a pure
read of an already-chmod 0444 file and must never write through it again
— so without a separate marker, "least recently used" would silently
collapse to "least recently created," exactly backwards for a cache whose
entire purpose is that a checkpoint hit over and over stays hot. Ranking:
the marker's mtime when one exists, falling back to the cache file's own
mtime (the materialize timestamp) as the floor for an entry that has never
been hit since it was created. A cache file with no marker therefore
ranks no more recently than its own creation time — the correct, and only
sensible, floor.
checkouts/ is never evicted — by construction, not a runtime check.
The eviction pass only ever lists and removes files under checkouts-ro
(a directory tree, and a filename shape — <branch>@<checkpoint>.db — that
checkouts/<db>/<branch>.db can never collide with; see the read-only
checkout section above for the same separation stated from CheckoutAt's
side). There is no code path in the eviction pass that can construct, or
be handed, a checkouts/ path at all — a leased, currently-open session's
writable checkout survives even the most aggressive budget (e.g. 1, which
forces every checkouts-ro entry out) untouched, by the same guarantee.
Eviction is loud: one offshoot: janitor: ro-cache: evicted <db>@<branch>@<checkpoint> (<bytes> bytes) line to stderr per entry
removed, offshoot_ro_cache_evictions_total (a counter) incremented once
per entry, and an evicted event published on the event
bus ({type:"evicted", db, branch, detail:{checkpoint, bytes}}) — subscribe (socket or GET /events) to
watch evictions happen in real time. Each eviction removes both the cache
file and its .last-used marker together.
TOCTOU under a configured budget: a path checkout --at --read-only /
checkout-at returns — from a fresh materialize OR a cache hit — is not a
guarantee the file still exists by the time you open it once a nonzero
budget is running: a concurrent janitor pass can evict that exact entry
in the window between the call returning and your own open. This is the
accepted cost of turning what used to be a purely caller/operator-driven,
manually-rm -rf-safe cache into one an automatic background reclaimer
also touches. Two rules cover it completely:
- Already opened the file? Keep reading — POSIX unlink-of-an-open-file
semantics mean an eviction racing your open connection never corrupts
or truncates what you already have a handle on; it just stops being
visible to a future
open/staton that path. - Get
ENOENTtrying to open a path you were just handed? That's not data loss or a corrupted store — it means "evicted since that call returned." Re-callcheckout --at --read-only(or thecheckout-atop): the checkpoint's content is immutable, so re-materializing produces byte-identical content, not stale or different data.
A .last-used touch (a cache hit) landing during the SAME pass that's
about to evict that exact entry usually spares it (the janitor re-checks
the marker immediately before removing anything); a touch landing in the
last, microscopic instant right before the actual delete does not save
it that round, but is still safe per the two rules above — the entry
simply gets re-hit as a fresh materialize on the next checkout-at call,
and self-heals into staying hot from then on.
checkouts-ro remains safe to rm -rf at any time regardless of the
budget (see the read-only checkout section above) — a budget just
automates what that manual cleanup would otherwise require doing by hand.
-http ADDR — opt-in HTTP listener (Milestone 4 Task 3)
Off by default. -http ADDR (e.g. 127.0.0.1:8080) starts an HTTP
listener alongside the unix socket, exposing:
See docs/operations.md for the operator-facing view of this whole surface (metrics reference table, HTTP/auth threat model, tuning-flag trade-offs) and docs/recipes/kubernetes.md for a real sidecar manifest — this section stays the flag-by-flag command reference.
| Method | Path | Auth | Body |
|---|---|---|---|
POST |
/rpc |
Bearer | The same Request/Response JSON the unix socket speaks, one op per POST (Content-Type: application/json required; body capped at 1MiB, oversized -> 413). Two ops are refused over HTTP: subscribe (use GET /events; refused in-band as a normal {"ok":false,...} JSON response) and export — the one op that writes to an unconfined, client-chosen path on the daemon host, safe under the unix socket's same-host trust model but an arbitrary-file-write primitive for a network client, so export alone answers 400 pre-dispatch and stays socket-only |
GET |
/metrics |
Bearer | Prometheus text exposition of the locked offshoot_* metric set — see docs/operations.md for the full name/type/label reference table |
GET |
/healthz |
none | {"ok":true,"sessions":N} — the one endpoint that needs no token, for liveness probes |
GET |
/events |
Bearer | Server-Sent Events: the daemon's event stream (Milestone 4 Task 4a — see Eventing below) |
GET |
/debug/pprof/* |
Bearer | net/http/pprof's standard handlers (index, cmdline, profile, symbol, trace) |
Token: -token TOKEN or OFFSHOOT_TOKEN sets it explicitly — an
explicit token shorter than 16 characters is a startup error (a
too-short bearer token is guessable; generate a real one). If
neither is given, one is generated and printed to stderr exactly once
at startup — treat that line, and your terminal scrollback/shell history,
as sensitive. Every request but /healthz requires Authorization: Bearer <token>, compared in constant time; the token is never logged again after
that one startup line — only an 8-character fingerprint (offshoot: http listening on ... (token fingerprint XXXXXXXX)) appears in any later
output.
Binding beyond localhost: a loopback bind (127.0.0.1, ::1,
localhost) needs nothing further. Any other address requires BOTH
-http-allow-non-loopback (an explicit acknowledgment) AND an explicit
-token/OFFSHOOT_TOKEN — the auto-generated, printed-once token is a
loopback-only convenience. Missing either is a distinct startup error (the
daemon never starts, rather than starting under-acknowledged):
$ offshoot serve -http 0.0.0.0:8080
offshoot: daemon: -http "0.0.0.0:8080" binds a non-loopback address; this requires
-http-allow-non-loopback (an explicit acknowledgment that this endpoint will be
reachable beyond localhost)
$ offshoot serve -http 0.0.0.0:8080 -http-allow-non-loopback
offshoot: daemon: -http "0.0.0.0:8080" with -http-allow-non-loopback also requires
an explicit -token or OFFSHOOT_TOKEN; the auto-generated, printed-once token is a
loopback-only convenience
Threat model: the token is a shared secret for a single-tenant, same-host-or-trusted-network deployment — anyone holding it can do everything any daemon client can do (identical to anyone able to open the unix socket today), not a multi-tenant isolation boundary. There is no TLS; a non-loopback bind should sit behind a trusted network boundary (VPN, private subnet) of its own. Treat stderr as sensitive at daemon startup: it is the only place a freshly auto-generated token is ever printed in full.
http.Server runs explicit timeouts rather than the stdlib's unbounded
defaults: ReadHeaderTimeout 5s (slow-header protection), ReadTimeout
30s, WriteTimeout 90s (sized to leave headroom for
/debug/pprof/profile's/trace's default 30s capture window — a longer
?seconds= capture than that budget allows will be cut off; request a
shorter window or profile out-of-band), IdleTimeout 2 minutes.
Errors: everything offshoot serve can already error on, plus: HTTP
address already in use; the two non-loopback-bind errors above; an explicit
token shorter than 16 characters; and -token or
-http-allow-non-loopback given without -http ADDR (they have no effect
alone, so serve refuses to start rather than silently ignore them).
Eventing (subscribe op / GET /events)
See docs/operations.md for the operator summary (drop-slow-consumer semantics, what to do when a subscriber disappears, the SDK helpers to reach for) — this section is the wire-level reference.
The daemon publishes one versioned JSON event per state transition it
observes, drainable two ways — the unix socket protocol's subscribe op,
and HTTP's GET /events (Server-Sent Events) — both carrying the exact
same JSON, encoded by the same function daemon-side. There is no history/replay: a subscriber only ever
sees events published after it subscribes.
Event shape:
{"v":1,"ts":"2026-08-07T12:00:00Z","type":"flushed","db":"app","branch":"main","detail":{"kind":"manual","txid":7,"duration_seconds":0.017}}
v is the schema version (currently always 1); ts is RFC3339 UTC.
type is one of:
type |
Fired when | detail |
|---|---|---|
session_opened |
A session opens (daemon open op) |
holder, epoch |
flushed |
A flush succeeds (manual flush op or background auto-flush) |
kind (manual/auto), txid, duration_seconds |
flush_failed |
A flush fails | kind, error, duration_seconds |
fenced |
A session is fenced out by a lease it no longer holds | cause |
session_closed |
A session closes (daemon close op, or shutdown) |
error (only if the close itself errored) |
reaped |
The janitor destroys a branch whose TTL expired | (none) |
evicted |
The janitor evicts a checkouts-ro entry over -ro-cache-budget |
checkpoint, bytes |
dropped_slow_consumer |
Sent to a subscriber being dropped (see below), never to anyone else | (none) |
Slow-subscriber drop: publishing never blocks the daemon (a session
transition or the janitor). A subscriber whose bounded buffer (64 events)
is full when an event is published is dropped immediately: removed from
the subscriber set, sent exactly one terminal dropped_slow_consumer
event, and has its stream closed. This can never slow down or fail a
session's own progress or the janitor's own cadence — see
docs/status.md's eventing row for the test that proves a
write-heavy session keeps flushing successfully while a subscriber that
never reads its channel gets dropped.
Stalled (still-connected) subscriber: the drop above handles a subscriber's bus-side buffer filling up, but not a subscriber that stays connected and simply stops reading its socket/HTTP connection at all — the daemon's write to that connection could otherwise block forever once the kernel's own send buffer fills. Every write to a subscriber's connection (both transports) is bounded by a per-write deadline, re-armed immediately before each send (default 45s); a write that cannot complete within that window means the reader is genuinely stuck, and the daemon gives up on that subscriber — closing the connection (socket) or ending the handler (SSE) — rather than leaking the goroutine and file descriptor. A live stream re-arms this deadline forward on every successful send (and, for SSE, at least every keepalive tick), so it never affects a merely slow but still-draining subscriber.
Unix socket: the subscribe op
{"op": "subscribe"}
The daemon acks ({"ok":true}) and the connection then permanently
leaves request/response mode: from that point on it streams one JSON
event per line until the client disconnects. No further op can ever be
sent on that same connection — the daemon stops reading it entirely.
Use a dedicated connection.
subscribeis unix-socket-only and takes over the whole connection for the life of the subscription — open a fresh socket connection for it and keep your original connection (or another fresh one) for ordinaryopen/flush/status/... ops. Sendingsubscribeover HTTPPOST /rpcis refused outright (with a message pointing atGET /events), since an HTTP request/response cycle has no way to switch modes mid-stream the way a raw socket connection can.
SDK helpers (Milestone 4 Task 4b): both SDKs ship a thin events()
helper that does exactly this — opens its own fresh, dedicated socket
connection (never the caller's own Client/Session connection), sends
subscribe, reads the ack, and yields one parsed event per line:
for ev in client.events():
print(ev.type, ev.db, ev.branch, ev.detail)
for await (const ev of client.events()) {
console.log(ev.type, ev.db, ev.branch, ev.detail);
}
Python's events() is a generator (nothing is opened until the first
iteration); TS's is an AsyncGenerator<OffshootEvent>. Both close their
dedicated socket cleanly when the caller stops iterating early (Python:
break/generator.close(), delivered as GeneratorExit; TS: break/
.return(), delivered through the async-iterator return protocol) — no
file descriptor is leaked by stopping mid-stream. The terminal
dropped_slow_consumer event (see above) is yielded like any other event
and then the stream simply ends (no exception) — a caller that cares
whether it was dropped checks the last event's type.
HTTP: GET /events
Same Bearer auth as everything but /healthz. Response is
Content-Type: text/event-stream; each event is data: <event JSON>\n\n.
A : ping comment line is written every 15 seconds to keep the connection
alive across any proxy/load balancer/kubelet that kills silent streams — ordinary SSE clients ignore comment lines
automatically. Unlike the other -http routes, this handler manages its
own per-connection write deadline (re-armed before every write, see
"Stalled (still-connected) subscriber" above) rather than being bound by
Task 3's http.Server WriteTimeout (90s, sized for
/rpc//metrics//debug/pprof/*, see above) — a live subscription is
never hard-cut by that 90s bound, while a stalled one is still cleaned up
promptly rather than left open indefinitely.
offshoot mcp
offshoot mcp [-default-ttl DURATION|none] [-socket PATH]
claude mcp add offshoot -- offshoot -store ./.offshoot mcp
Serves the Model Context Protocol on stdio: seven tools (offshoot_list,
offshoot_checkout, offshoot_checkpoint, offshoot_fork,
offshoot_rollback, offshoot_promote, offshoot_destroy), each described
so a model knows not just what it does but when to reach for it (fork
before risky work, checkpoint when tests pass, roll back when they fail,
promote the attempt that worked). Destructive tools honor the same
protected-branch rules as the CLI — an unforced offshoot_promote --onto main or offshoot_destroy on main is refused, and the refusal is
returned to the agent as the tool result, not a transport-level error.
Agent-initiated forks carry a TTL by default: offshoot_fork applies
-default-ttl (default 24h) to any call that omits its own ttl
argument, so a branch an agent forks and forgets is eligible for reaping
instead of accumulating forever. -default-ttl 0 or -default-ttl none
disables the default; an individual offshoot_fork call can still override
it either way — an explicit ttl:"<duration>" always wins, and
ttl:"none" always yields no TTL even under a configured default. The
fork tool's response echoes the applied TTL and, when there is one, the
computed expiry timestamp, so both land in the agent's own transcript.
TTL alone does not reap anything: reaping requires a running janitor
(offshoot serve); offshoot mcp runs no daemon of its own, so a
daemonless setup only sweeps expired branches when offshoot gc is run by
hand.
MCP rides a running daemon, but only for a branch a session is already
open on. No MCP tool ever opens a session itself (that's a harness's job —
the SDKs, offshoot session open, or a custom loop); each call to
offshoot_checkpoint, offshoot_fork, or offshoot_checkout freshly
checks whether the daemon named by -socket (default: the same socket
offshoot serve derives for this store) has one open for the branch in
question. If so, offshoot_checkpoint flushes it live through the daemon
instead of writing a full at-rest snapshot; offshoot_checkout
returns that session's own live checkout path. offshoot_fork goes
further: it routes through the daemon whenever one is merely reachable,
session or no session — so the fork uses the daemon's configured
-snapshot-every share floor and lands in the daemon's fork metrics; when
the source does have an open session, the daemon flushes it first so
unflushed writes land in the child. Without a reachable daemon — the
common case for a bare offshoot mcp — every tool behaves exactly as it
does with no daemon running at all; see docs/status.md for
what's tested.
Three tools take the opposite stance: offshoot_rollback,
offshoot_promote (checked against its target only), and
offshoot_destroy refuse — rather than proceeding at rest — whenever
the daemon has any session, healthy or fenced, open on the affected branch.
The CLI's own --force is not a uniform precedent here: offshoot destroy --force does override a live lease (see that section above), but offshoot promote --onto never gates on the target's lease at all, with or without
--force — its --force overrides only the protected-branch check, and a
promote unconditionally clears the target's lease as a side effect of the
repoint either way (see "Promote" above). Whatever the CLI does, the MCP
tools' force argument has no effect on this particular refusal: repointing
or deleting a branch's ref out from under a session the daemon still
believes it owns is refused unconditionally, and the fix is to close the
session first (offshoot session close) and retry.
offshoot_promote's source is the one exception not guarded this way: an
open session there doesn't block the promote, but the promoted state is the
source's last-flushed/checkpointed head, not whatever is unflushed in that
live session. Put together: the good path for offshoot mcp requires a
harness-opened session (the SDKs, offshoot session open, or a custom
loop) already open before the agent calls a tool — without one, every tool
still works, just entirely at rest.
offshoot session open <db>[@branch] [-socket PATH]
offshoot session open app
Opens a live daemon session on db@branch: acquires its lease, materializes
(or reuses) its checkout, and starts continuous WAL capture. Prints the
checkout path. Requires a running offshoot serve (reachable at the
resolved socket). branch defaults to main.
Errors: the branch is already open by this daemon; the branch's lease is held elsewhere; the daemon is shutting down; no daemon reachable at the socket.
offshoot session flush <db>[@branch] [name] [-socket PATH]
offshoot session flush app
offshoot session flush app v1
Flushes the session's pending WAL to a durable snapshot or incremental
segment in the store — writes since the last flush are committed to SQLite
but not durable in the bucket until this runs. An optional name also
records a named checkpoint at the resulting transaction id (same checkpoint
namespace as offshoot checkpoint, and stamped with the same created_at).
Prints the transaction id now durable. The daemon writes a full snapshot
every Nth flush (serve -snapshot-every, default 16) and an incremental
segment (only the changed pages) otherwise, so materializing state never
replays more than one snapshot plus N-1 segments.
There is no separate daemon "checkpoint" op — a live session's named flush
is how its checkpoints are created; the underlying daemon protocol's
flush op also accepts a meta map (same caps as checkpoint's --meta),
which the Python/TypeScript SDKs' flush(name, meta=...) expose. This CLI
subcommand does not have a --meta flag today — use an SDK client for
metadata on a live-session checkpoint.
Errors: db@branch is not open here; the session has lost its lease
(fenced — it will not write under a dead epoch); meta given with no
checkpoint name; meta over a cap (SDK-only, since this CLI subcommand
has no --meta flag).
offshoot session status [-socket PATH]
offshoot session status
Lists every session currently open on this daemon: db@branch, durable
transaction id, epoch, lease holder, checkout path, and — if the session has
hit an error (e.g. fenced by a lost lease, or a contract violation) — that
error inline. This is per-session detail; for the computed branch-state
taxonomy (active/pending/error/dirty/detached/idle) across
EVERY branch of a db — including ones with no session open at all — see
Branch states above and the daemon branches op (SDK
Client.branches(); no CLI session branches subcommand exists yet).
offshoot session close <db>[@branch] [-socket PATH]
offshoot session close app
Closes the session and releases its lease. branch defaults to main.
Errors: db@branch is not open here.
offshoot session shutdown [-socket PATH]
offshoot session shutdown
Asks the daemon to shut down gracefully (equivalent to sending it
SIGINT/SIGTERM): releases every lease, closes every session, removes the
socket.
offshoot session dbs [-socket PATH]
offshoot session dbs
Lists every database this store has at least one ref for (one per line,
sorted) — the daemon protocol's dbs op, the same one the Python/TypeScript
SDKs' dbs() calls. Useful for cleanup jobs that need to enumerate what
exists without shelling out to a store-directory listing. An empty store
prints nothing and is not an error (unlike branches, which errors on an
unrecognized db name — there's no single db to name here).
Daemon protocol ops with no CLI session subcommand: export, checkout-at
The daemon protocol's export and checkout-at ops (Python Client.export/
checkout_at, TypeScript Client.export/checkoutAt) are reachable only
through an SDK client today — there is no offshoot session export/
offshoot session checkout-at CLI subcommand; use the top-level offshoot export / offshoot checkout --at --read-only commands above for CLI/at-rest
access to the same underlying ops.Workspace.Export/CheckoutAt functions.
export's destination is a path on the daemon's own host, not the
client's — it must be given as an absolute path (a relative one is refused
outright) and is written with the same refuse/force/atomic-temp+rename
semantics as the CLI command above. It reads the branch's last durable
state from the store, never a live session's checkout: an open session's
unflushed writes are not in the export (flush first if you need them
included). checkout-at materializes into the same checkouts-ro cache
path the CLI command above uses, and is safe to call even while this same
daemon has a live session open on the target branch (unlike checkout/
rollback/promote, which all refuse in that case) — it never touches the
writable checkout.
Threat model: these ops trust their caller the way the local unix
socket (mode 0600, created with that mode from the first instant) does:
any process able to open it already runs as the same user on the same
host, so export's destination path is trusted as an ordinary filesystem
path that process can write — the daemon does not sandbox it or check it
against an allow-list beyond requiring it be absolute. That trust does NOT
extend to the opt-in HTTP listener: export is refused over POST /rpc
(it would be an arbitrary-file-write/exfiltration primitive for an
authenticated network client) and remains unix-socket-only; checkout-at
stays available over both transports since it only ever writes inside the
store's own checkouts-ro tree.
Surface parity: CLI vs daemon vs SDKs
Which operations exist on which surface today — verified against
cmd/offshoot/main.go's dispatch, the daemon protocol's op list
(internal/daemon/protocol.go: open, flush, status, close,
shutdown, create, checkout, fork, destroy, rollback,
promote, compact, touch, branches, dbs, export, checkout-at,
subscribe), and both SDK clients (sdk/python/offshoot/client.py,
sdk/typescript/src/client.ts).
| Operation | CLI | Daemon op | Python/TS SDK | Notes |
|---|---|---|---|---|
| create / checkout / fork / destroy / rollback / promote / compact / touch / branches / dbs | yes | yes | yes | Full parity. compact through the daemon refuses while a session is open on the branch (see above). |
| open / flush / status / close (sessions) | session ... |
yes | yes | SDK flush(name, meta=...) can attach checkpoint metadata; the CLI session flush subcommand has no --meta flag. |
| export / historical read-only checkout | yes | yes (export, checkout-at) |
yes | No CLI session subcommand — the CLI's export/checkout --at --read-only are the at-rest equivalents (see the section above); export is unix-socket-only over the daemon. |
| events | — | yes (subscribe / GET /events) |
yes (events()) |
No CLI subscriber today. |
| shutdown | session shutdown |
yes | no | Neither SDK exposes shutdown. |
create --from (import) |
CLI-only | no | no | Deliberately deferred, not an oversight — accepting a source file over the daemon boundary needs an upload-channel or path-trust design of its own; see docs/status.md's create --from row. |
gc (on-demand reap + collect) |
CLI-only | no | no | A running daemon's janitor performs the same reap/GC on its -reap-every timer, so daemon deployments don't lack GC — they lack an RPC to trigger it on demand. |
lease list / acquire / release |
CLI-only | no | no | Daemon sessions manage their own lease lifecycle (open acquires, close releases); the CLI commands are the manual inspect/break-glass surface. |
diff |
CLI-only | no | no | Scoped CLI-only by design; see docs/status.md's diff row. |
at-rest checkpoint |
CLI-only | n/a | n/a | Not a gap: a live session's named flush is how daemon/SDK checkpoints are created (see session flush above). |
whole-store status |
CLI-only | no | no | The daemon's per-db branches op reports the same branch states/storage classes; only the all-dbs-plus-ro-cache-summary view is CLI-only. |
init / serve / mcp / version / path |
CLI | n/a | n/a | Process-level or purely local commands; nothing to proxy (path is checkout's no-materialize sibling — see its section above). |
Summary: the SDKs cover the entire daemon protocol except shutdown;
what's genuinely CLI-only today is init, create --from, on-demand
gc, the lease commands, diff, at-rest checkpoint, and the
whole-store status view. For CI patterns that mix the two surfaces
(CLI seeding + SDK sessions), see docs/ci-recipes.md.
What's not here
See docs/status.md for the full implemented/deferred matrix
and links to the roadmap milestones tracking each — e.g. the FD budget
with idle-checkout eviction, still not yet implemented as of this page's
last update. See also docs/stability.md for what pre-1.0
means for the commands above (and the export → create --from format
escape hatch), docs/testing.md for how this surface is
tested, and docs/ci-recipes.md for ready-made GitHub
Actions workflows built from these commands.