Skip to content

Repository files navigation

🦦 holt

The worktree-lifecycle substrate for parallel coding agents. Never loses work. Never reaps what's in use. The registry — not the filesystem — is truth.

pre-1.0 · Nothing here takes something it can't give back: a dirty tree is parked as a commit, and only merged, unoccupied lanes are ever reaped. That's the intent, not a warranty — keep a backup, and tell us what breaks.

Every vendor now ships worktree creationclaude --worktree, the Claude Agent SDK's isolation: worktree, Cursor, Copilot CLI — and every one of them stops there. Nobody owns the rest of the life: the branch that's still alive after the pane died, the checkout nobody is sitting in anymore, the tree with 40 uncommitted minutes in it, the branch whose PR merged yesterday and has kept committing since. If you've ever git worktree list'd your way through a graveyard trying to remember which of these you can safely delete — that's the problem holt makes go away.

Quickstart

# spin up a lane on this repo: new checkout, new branch, agent opens in it
holt new fix-flaky-test

# see every lane you've got going, live or parked, across every repo
holt

# pane's closing and the tree's dirty? don't reach for `git stash` — it's
# shared across every worktree of this repo. `park` commits it instead, as
# a wip: commit only this branch has
holt park "mid-refactor on the retry logic"

# back later — rebuild the checkout, reopen the agent, right where you left it
holt fix-flaky-test

# sweep every lane whose branch already landed and nobody's standing in
holt reap

# working on a DIFFERENT repo than this pane — never a raw `git worktree add`
cd "$(holt child ../other-repo)"

What's in a lane?

A lane is one agent's branch, checkout, and pane — not a "worktree" (a parked lane has no checkout on disk, only a branch), and not a "session" (that's your multiplexer's). holt --json returns lanes in this shape; the SDKs below wrap it.

Field Meaning
name lane name
repo / main repo identity, and the main checkout's path
branch full branch name
path checkout path on disk — empty once parked
parent the pane that spawned it via holt child, or ""
agent claude | codex | opencode
state live | parked | stray
occupied is anything actually standing in it right now
dirty uncommitted changes
landed reached the default branch? yes | no | contained
post_merge_ahead commits made after the PR merged
last_commit most recent commit

Full lifecycle (states, invariants, landing rules, policy hooks) → docs/lifecycle.md.

Commands

holt                    list every live/parked lane, across all repos
holt <name>             resume one: rebuild its checkout, reopen its agent
                        --pick to choose the session instead of the newest
holt new [name]         a lane on THIS repo, then open the default agent in it
holt child <repo>       a lane on ANOTHER repo, as a child of this pane
holt spawn <repo> <name>
                        a named lane for a spawner with no pane of its own
holt park [label]       set the working tree aside as a wip: commit on this branch
holt unpark             put the last parked commit's changes back, uncommitted
holt reap               sweep every LANDED lane that nobody is standing in
holt heartbeat [path]   hold the occupancy lease on a lane, so reap spares it
holt watch --json       lifecycle events on stdout, one NDJSON object per line
holt reship [name]      push a branch that outran its merged PR, open the follow-up
holt hook create        [hook] open a lane — JSON on stdin, path on stdout
holt hook remove        [hook] retire one without losing work — JSON on stdin

Config, exit codes, and building from source → docs/reference.md.

How the CLI and all five SDKs are cut from one version number → docs/releasing.md.


Why not your agent's built-in worktrees? No vendor ships cross-client (claude and codex and opencode, one registry), none ship cross-repo, and losing your work isn't their problem — it's holt's.

SDKs

TypeScript@hausfold/holt

sdk/tslist(), watch()/watchLane() as an async iterator over the NDJSON stream, child/spawn to create a lane without attaching an agent, park/unpark/reap/reship, and occupancy leases. Works from a Bun/Node TUI or a web backend; its types are safe to import into a browser bundle for the frontend.

npm install @hausfold/holt
# or: bun add @hausfold/holt
import { HoltClient } from "@hausfold/holt";

const holt = new HoltClient();
const envelope = await holt.list();
for await (const line of holt.watch()) {
  if (line.kind === "created") console.log("new lane:", line.lane?.name);
}

See sdk/ts/README.md for the full API.

Pythonhausfold-holt

sdk/python — the same client, async-first (asyncio.create_subprocess_exec): list(), watch()/watch_lane() as an async iterator, child/spawn, park/unpark/reap/reship, and occupancy leases. Drops into a FastAPI/asyncio backend or a plain script equally.

pip install hausfold-holt
# or: uv add hausfold-holt
import asyncio
from holt import HoltClient

async def main() -> None:
    holt = HoltClient()
    envelope = await holt.list()
    async for line in holt.watch():
        if line.kind == "created" and line.lane is not None:
            print("new lane:", line.lane.name)

asyncio.run(main())

See sdk/python/README.md for the full API.

SwiftHolt

sdk/swift — the same client over Foundation.Process: list(), watch()/watchLane(path:) as AsyncThrowingStream, child/spawn, park/unpark/reap/reship, and occupancy leases. macOS + Linux — not iOS/tvOS/watchOS, since Process can't spawn a subprocess there.

Ships from a generated mirror, hausfold/holt-swift — send changes to sdk/swift here, never to the mirror directly.

.package(url: "https://github.com/hausfold/holt-swift", from: "0.1.0")
import Holt

let holt = HoltClient()
let envelope = try await holt.list()
for try await line in holt.watch() {
    if case .event(let event) = line, event.kind == .created {
        print("new lane:", event.lane?.name ?? "?")
    }
}

See sdk/swift/README.md for the full API.

Gogithub.com/nebelhaus/holt/sdk/go

sdk/go — the same client over os/exec: List, Watch/WatchLane as Go 1.23 range-over-func iterators, Child/Spawn, Park/Unpark/Reap/Reship, and occupancy leases via Client.Lease.

The one SDK with zero setup: its own nested go.mod (github.com/nebelhaus/holt/sdk/go) means go get resolves it straight from this repo — no publish step, no separate mirror. No tagged release yet, so go get github.com/nebelhaus/holt/sdk/go@<commit-sha> for now.

The nebelhaus owner in that path is deliberate, and the one place in this repo that still carries it. Everything else moved to hausfold in the 2026-08-09 org migration; a Go module path can't, because it is already published under this name on an immutable proxy. GitHub's redirect from the old org — which is never deleted — is what keeps go get resolving.

import (
	"context"
	holt "github.com/nebelhaus/holt/sdk/go"
)

c := &holt.Client{}
envelope, err := c.List(context.Background())
for line, err := range c.Watch(context.Background()) {
	if err == nil && line.Kind == holt.WatchCreated {
		fmt.Println("new lane:", line.Lane.Name)
	}
}

See sdk/go/README.md for the full API.

Rusthausfold-holt

sdk/rust — the same client, async (tokio): list(), watch()/watch_lane() as a Stream of typed lines, child/spawn, park/unpark/reap/reship, and occupancy leases via HoltClient::lease. Drops into an axum/tonic backend or a plain async binary equally.

cargo add hausfold-holt
use futures_util::StreamExt;
use holt::HoltClient;

let client = HoltClient::default();
let envelope = client.list().await?;
let mut lines = Box::pin(client.watch());
while let Some(line) = lines.next().await {
    if line?.kind == holt::watch_kind::CREATED {
        println!("new lane created");
    }
}

See sdk/rust/README.md for the full API.


⌂ hausfold

About

The worktree-lifecycle substrate for parallel coding agents — park, resume, PR-verified reap

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages