hausfold

haus

AI

Coding agents as a room: the clients this machine installs, holt giving each agent its own checkout, and the lifecycle around them. Any git repo, any client.

Run two coding agents in the same checkout and they fight: one switches the branch out from under the other, uncommitted edits collide, and closing a pane mid-thought can lose work. holt fixes that by giving every agent its own checkout, and haus puts it on your PATH, wired to a keybind.

Press ⌘A in any repo's terminal tab and you get a fresh agent session in an isolated git worktree: its own checkout on a worktree-<name> branch, branched from the repo's local HEAD, living outside the repo under ~/.cache/claude-worktrees/<repo>/<name> so your working tree stays clean and no build tool trips over it. ⌘⇧A does the same in place of the focused pane rather than beside it; the pane it replaces is only suspended, so quitting the agent brings your shell back with its scrollback. ⌃⌥⇧A is the resident variant: the one agent per tab allowed to edit the checkout you're looking at.

holt calls what the first two make a lane (one agent's branch, checkout and terminal, as a single thing you can name), and that's the word its output uses. Not a worktree: a parked lane has no checkout on disk and is still a lane you can resume. (⌃⌥⇧A makes no lane at all; it's an agent in the checkout you already have.) Where a lane's terminal lives is the Development room's call rather than holt's: haus.hearth.lanes.backend puts it in a zellij pane by default, or in a window of its own.

It works on any git repo, and it isn't really a haus feature: it's its own tool, which haus takes as an input and puts on your PATH. You don't have to be a haus user to run it.

Closing a pane never loses work

Closing a ⌘A pane doesn't just delete the checkout. Uncommitted edits are first parked as a wip: commit on the branch, and only branches whose work has already merged get reaped: everything unmerged survives on its branch, and the agent's transcript survives with it. The checkout is disposable; your work isn't.

Enable it

haus.ai = {
  enable = true;
  clients = [ "claude" "opencode" ];
  default = "claude";
};

That brings the clients you named, holt, and the worktree lifecycle wiring. Nothing else in the house has to be on for it to work.

Resuming

Because the work is on the branch and the chat is in your client's transcript, a closed pane is never a dead end. Run holt bare to see everything you can pick back up, across every repo:

🌫  lanes you can resume (holt <name>, or <repo>/<name>)
  repo         name         state  agent  last commit
  nebelung     sparkle      parked claude 2 hours ago — wip: auto-saved on pane close
  pounce       quick-emoji  live   codex  10 min ago — add fuzzy weights

Name one and holt rebuilds the checkout and picks the conversation back up through whichever client that lane was made with:

holt sparkle           # resume it
holt nebelung/sparkle  # qualify when the name exists in two repos
holt sparkle --pick    # choose from that client's session list instead

By default it continues the newest conversation. --pick opens the client's own picker, for when a lane has more than one.

Spawning from the launcher

For a lane named after the task rather than the random luminous-twirling-codd an unattended agent would coin, summon the launcher and run Spawn Agent: pick a repo, describe the job, and it creates the checkout and opens a session in that repo's zellij tab. Naming the branch from the prompt is the whole point.

How you commit the prompt picks what happens next:

KeyDoes
Spawn on what you typed
⇧↵Newline; a task is usually a list, not a sentence
⌘↵Grab a screenshot first, then spawn; cancel the capture and you're back in the box with your text intact. On an empty box it just attaches the shot and hands the box back
⌥↵Open your drafts; the box is saved on every dismissal, so a paragraph lost to a stray click is still here

Codex receives the screenshot as an image; Claude Code and OpenCode get its path in the first prompt.

Which client gets spawned

haus.ai.clients = [ "claude" "opencode" ];  # what's installed
haus.ai.default = "claude";                 # what ⌘A spawns

ai.default must be one of ai.clients, and the rebuild says so by name if it isn't rather than failing later, inside the pane, after the checkout already exists. clients defaults to an empty list, which is exempt from that check (there's nothing to be absent from) and means haus installs no client itself.

All of it hangs off one master switch, haus.ai.enable: holt on your PATH, the ⌘A keybinds, the tab marks, the statusline, the client wiring. Rooms are independent, so the switch is yours (or your desktop's) to set; hacker turns it on and names both options above. Turn the room off and ai.clients is ignored whole rather than half-wiring the machine, so a host can drop the room without having to blank the list its desktop set.

holt records the client with the lane, so changing the option only affects new spawns: a parked Codex task still reopens through codex resume --last, and an OpenCode one through opencode --continue.

Overlay a client, don't add one

Want a patched build of a client? Overlay the package (claude-code, codex, opencode) rather than putting your own derivation in home.packages beside it. Two derivations shipping the same bin/claude collide in one profile, and the rebuild fails with two given paths contain a conflicting subpath.

Never git stash: park instead

git stash looks like it belongs to the checkout you're standing in. It doesn't. The stash is one ref in the repo's shared .git directory, so every worktree of a repo, and the main checkout, push and pop the same stack. Two agents stashing at once means either can pop the other's entry into a tree that has never seen those files, and stash@{1} is no safer, being positional.

So park instead:

holt park "half-done refactor"  # whole dirty tree → one wip: commit on THIS branch
holt unpark                     # rewind it: changes back, uncommitted

holt park commits everything (tracked edits, untracked files, deletions) as a single wip: commit on the branch only this pane has checked out. Ignored files are never swept in, so parking can't smuggle a secret into history. It's the same thing the pane-close hook does, on demand, and holt lists the result as a resumable lane rather than a numbered entry in a shared pile.

unpark won't rewrite what you've pushed

If the wip: commit has already been pushed (it's visible in an open PR), holt unpark refuses, because rewinding it locally turns a file restore into a force-push. It prints the escape hatch (git reset --mixed HEAD^) and leaves the commit alone. Parking on a branch that isn't an agent branch works, but says so: a wip: commit shouldn't drift onto a branch you push.

Working on another repo: holt child

Sometimes an agent in one repo has to change another one. Don't reach for a raw git worktree add: that skips holt's registry, so the statusline never learns to track the lane and its PR goes invisible.

cd "$(holt child ~/code/other-repo)"

That makes a lane on the other repo, registers it as a child of your current pane so its PR shows up where you're working, and prints only the new checkout path, hence the cd "$(…)".

Pointing agents at haus itself rather than at your own projects? Contributing covers what's different in a family repo: building a lane's branch without merging it, and the one activation an agent is refused.

Cleaning up

Most cleanup is automatic: closing a pane reaps a merged branch, and every holt listing self-heals first by sweeping parked branches whose PR has since merged. Some things end a pane without the graceful hook, though: a reboot, a crash, or a holt child checkout, which no pane owns. For those, sweep on demand:

holt reap

holt reap is deliberately conservative. It leaves anything dirty, anything unmerged, and any checkout a pane is still open in: the one you're standing in included, and any other session's too, because a just-merged branch looks exactly like a finished one while its agent is still sitting in it. Only landed work nobody is standing in gets removed.

Every deletion is written to a ledger before the branch dies, so a sweep is never the end of anything: holt reaped prints what went, why, and the git branch line that brings it back. And a lane that will never land (its PR closed unmerged, the repo archived) is holt drop <name>, which waives the merged-branch requirement and nothing else.

When a session outran its PR

You merge the PR, keep talking to the agent, and it makes a few more commits. Those commits are now in an odd place: the PR that covered them is closed, and GitHub deleted the branch's remote counterpart when it merged, so they're committed, unpushed, unreviewed, on a branch nothing is watching.

holt never reaps such a branch, and it says so everywhere: the listing marks the row live+3 (three commits past the merge), holt reap names it instead of sweeping it, and the statusline shows an orange 3^. One command clears it:

holt reship          # push this branch, open the follow-up PR
holt reship sparkle  # …or name one, from anywhere

It re-creates the remote GitHub deleted and opens a PR against the default branch, listing what's new. If a PR is already open, it just pushes to it.

The lookalike is marked differently and wants the opposite treatment: ~3 means the branch has diverged: its tip doesn't build on what merged, so the checkout is stale rather than ahead. holt reship refuses it. Remove that lane instead of pushing it.

Knowing which agent needs you

Run agents in eight tabs and the question is always the same one: which of them is sitting on a permission prompt? The zellij tab bar answers it, with a coloured dot in front of the name of any tab holding agent panes:

MarkMeans
peach, and the tab washed peachan agent is blocked on you: a permission prompt, or a question
skyan agent is mid-turn, working
greenit finished its turn; nothing is waiting
(nothing)no agent panes in that tab

The dot shows the most urgent state in the tab, so peach always wins and the tab that needs you never hides behind one that's merely busy. (The peach wash is dropped on the tab you're already looking at.) It's the shape that carries the state, not the colour, and there's no count: how many agents a tab holds is the bar's popup to answer, and the bar is the same signal for when you're out in the GUI rather than in the terminal.

Both are reported by each client's own lifecycle hooks: the agent says what it's doing; nothing scrapes the screen for it. haus wires OpenCode's plugin and Codex's hooks for you. Claude Code's four are yours to wire, and haus deliberately leaves them alone.

What haus does write into `~/.claude/settings.json`

The worktree hooks that Claude Code's own --worktree flag fires: WorktreeCreate and WorktreeRemove, pointing at holt hook create / holt hook remove, plus the statusline, permissions.defaultMode and a few TUI keys. It merges into the file rather than replacing it, touches no other hook event, and re-asserts on every rebuild, because Claude Code rewrites its own settings on its own schedule and a hand-written hook can go with them. You'd find that out at pane close, by losing a lane's parking.

The statusline HUD

haus points Claude Code's status bar at a small HUD driven off holt's registry, so you can see where every agent stands without leaving the pane. Row one is this session's own lane: a status token, its GitHub PR pill, then its name. The rows below are the lanes this session spawned, across whatever repos they live in, eight at most with a +N more line under them. Rows that still need attention sort above landed ones, so a clipped HUD can only ever hide something already reapable.

The status token is one mutually-exclusive slot:

TokenMeans
the branch has landed; closing the pane reaps the lane
N^N commits on the branch, not merged yet
N^ (orange)the PR merged and N commits landed since, covered by nothing; holt reship
+A -Duncommitted line changes, when nothing's committed yet
(muted)nothing to report: clean tree, nothing ahead

Flush right on row one sit a few per-session chips: how far behind your pinned revision you are (⇡N, what haus update would pull), context used, session cost, the permission mode, and a short model tag. Context is coloured by token count, not percentage, because the percentage means different things on a 200k and a 1M model. Permission mode follows the same "no news is good news" rule as the status token: the default shows nothing, everything else is a positive mark.

Command summary

CommandWhat it does
holtList every parked and live lane across all repos (self-heals first)
holt <name>Resume one: rebuild its checkout, pick its conversation back up
holt reapSweep every landed lane now (keeps dirty, unmerged, and any open pane)
holt reapedThe ledger of what was swept, why, and the one line that brings a branch back
holt drop <name>Retire a lane that will never land: a PR closed unmerged, an archived repo
holt child <repo>A lane on another repo, as a child of this pane
holt park [label]Set the dirty tree aside as a wip: commit; use instead of git stash
holt unparkUndo it: changes back in the working tree, uncommitted
holt reship [name]The PR merged and the branch kept going: push it, open the follow-up PR
holt new [name]A lane on this repo, with the default client opened in it
holt spawn <repo> <name>A named lane for a spawner with no pane of its own; what Spawn Agent calls

Add --json to the listing for a machine-readable version, --version for holt's own.

Works with

Three contributions, each appearing only when its receiving room is on, and none of them switching that room on for you:

  • Development: the agent keybindings and pane lifecycle inside the multiplexer;
  • Bar: the agents pill, amber when a session is waiting on you;
  • Launcher: Spawn Agent, which names the worktree after the job.

Remove it

haus.ai.enable = false;

The clients, holt and the wiring go, including from a desktop that named clients, because the room's switch is what decides, not the list. What stays is everything on disk: the worktrees you created and their branches are ordinary git, and removing the room never deletes work.

Options

Every setting, with types and defaults: AI.

On this page