# AI (/docs/haus/rooms/ai)



Two coding agents in one checkout fight: one switches the branch out from under
the other, and closing a pane mid-thought can lose work.
&#x2A;*[scruff](/docs/scruff)** gives each its own checkout, and haus puts it on your
`PATH` behind a keybind.

**⌘⏎**, from any terminal window, pane or lane, opens a fresh agent session in an
isolated worktree: a `worktree-<name>` branch off the repo's local `HEAD`,
checked out **outside** the repo at `~/.cache/scruff/<repo>/<name>`, on whatever
repo the focused window is looking at. scruff calls that a **lane**. `⌘W` only
detaches; a lane that really is torn down parks its uncommitted edits as a `wip:`
commit first, and only branches whose work has merged get reaped. For an agent in
the checkout you already have, with no lane at all, type `c` in that window's
shell.

scruff is [its own tool](/docs/scruff), on any git repo, haus or no haus.

## Enable it [#enable-it]

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

That brings the clients you named, `scruff`,
[factory](https://github.com/hausfold/factory), the ⌘⏎ keybind, the statusline
and the client wiring. A [bar](/docs/haus/rooms/bar) on this Mac picks up the
[merge-lease pill](/docs/haus/night-shift#granting-the-lease-from-the-desktop)
with it, since there is now a lease to report. `haus.ai.skill`, on by default,
adds every hausfold tool's agent skill to each client, so an agent asked to
change this Mac edits your host file and runs `haus rebuild` rather than guessing
at dotfiles. `haus.ai.skillExclude` names the tool skills to leave out, for the
ones your agent never invokes (Claude Code's `/skill-doctor` shows which).

`enable = false` takes all of it back, including from a desktop that named
clients, and deletes nothing on disk. A machine set up before scruff 1.1.0 keeps
the older base path until `scruff doctor --migrate-base` moves it
([Config](/docs/scruff/config#where-the-checkouts-live)).

## Spawning from the launcher [#spawning-from-the-launcher]

⌘⏎ coins a random name like `luminous-twirling-codd`. For one named after the
task, summon the [launcher](/docs/haus/rooms/launcher) and run **Spawn Agent**:
pick a repo, describe the job.

A **client** chip on `⇥` picks which agent this one lane opens in. It appears
once more than one of `claude`, `codex`, `opencode` and `pi` is installed, and
offers what is on `PATH`, not what `haus.ai.clients` asked for; it starts on
[`haus.ai.default`](/docs/haus/reference/options#ai), then on your last spawn.
Repos come as a grid of cards, most recently touched first.

`↵` spawns **in the background**, and the lane gets its window at once anyway,
tiled on `T/<repo>` with the agent already working, born off-screen and walked
onto its page without ever being activated, so `⌃⇥` finds it. `⌃↵` opens it in
front of you instead. With [trill](/docs/trill) the banner is the door: click the
card and you land on that window. Without trill it is a plain macOS banner with
nothing to click, and so is a repo or lane name carrying a character outside
letters, digits, `.`, `_` and `-`.

**Screen asleep, no window.** macOS reports no display, and a terminal asked for
one comes up as an error pane that swallows the task, so the agent starts
detached and the session is the lane. The banner still fires; the first click on
it, or `scruff <name>`, gives the lane its tiled window. `⌃⇥` cannot find it
before that, because it walks pages that hold windows.

### What names the lane [#what-names-the-lane]

Names come from your own words with the filler dropped, so "why the bar pill
flickers" becomes `bar-pill-flickers`. `haus.ai.namer` hands the job to a model
instead, which can name the *subject* rather than echo the sentence.

```nix
haus.ai.namer = "api";
```

The value is an id, and the id is a file you write, exactly at
`~/.config/scruff/adapters/namer/<id>.toml`, naming a program that takes the
brief on argv and prints one name. haus ships none, because the model and the API
key are yours; the key belongs in the login keychain via
[the Security room](/docs/haus/rooms/security#secrets-your-rooms-need), never
beside the script. Every failure is a warning and a fall back to the random pair,
so it cannot cost you a lane. Spawn Agent hands its own slug down as
`SCRUFF_NAMER_FALLBACK` for an adapter that cannot reach its model: honour it, or
an offline spawn is named worse than it would have been. `claude` is scruff's one
built-in and needs no file, but at 8 to 12 seconds it is slower than the palette
waits, so Spawn Agent keeps its slug and only a hand-run `scruff spawn` asks it.

A name also has a ceiling, because the lane's key `scruff/<repo>/<lane>` becomes
the name of a unix socket and a socket path is short. haus caps that key at 44
bytes, and the repo's own name spends part of it: a repo called `hausfold.co`
leaves 25 bytes of lane name, one called `nix` leaves 33. So the same task names
itself differently in two repos, and in the narrower one a long name loses its
last word.

Losing a word is the whole of what it costs, and only for a name nobody typed.
Spawn Agent derives yours from the task and tells scruff so, which is why the cut
lands on a word and the lane opens either way. Type a name yourself (`scruff
spawn <repo> <name>`, or `scruff new <name>`) and one over the ceiling is
[refused with both numbers](/docs/scruff/config#how-long-a-lane-name-may-be)
instead: shortening a name you chose would put your work on a branch you never
asked for. You are there to read that refusal. The palette would not be, so it
never gets one.

### Which repos it offers [#which-repos-it-offers]

```nix
haus.ai.repoRoots = [ "~/code" "~/src" "~/Developer" "~/Projects" "~/.config/nix" ];
```

An entry that **is** a repo is offered as itself and never descended into;
anything else is scanned two levels deep. A path that does not exist is skipped
in silence, so one host file can name roots that only some machines have. Naming
the list replaces the default, and repos scruff already knows are offered whether
or not they sit under a root. That last default entry is the config flake this
Mac is built from, which is the repo you most often want an agent in.

## Which client gets spawned [#which-client-gets-spawned]

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

The four names are `claude` (Claude Code), `codex` (OpenAI Codex), `opencode`
(OpenCode) and `pi` (pi), all from nixpkgs, so `haus update` moves them. Two are
held ahead of it: `claude`, because Claude Code gates models on the client
version and a stock pin greys out the newest one in `/model` for no visible
reason; and `pi`, because `--` reached it only in 0.84.3 and scruff puts a `--`
before a lane's first-turn prompt, so on an older pi a task starting with a dash
is read as a flag and the pane dies before the agent draws. Both step aside once
nixpkgs passes them.

`ai.default` must be one of `ai.clients`, and the rebuild says so by name rather
than failing later, inside the pane, after the checkout exists. `clients` defaults
to an empty list, which is exempt from that check and installs no client. The
client is recorded **with the lane**, so the option only affects new spawns.

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

## Lanes, parked and landed [#lanes-parked-and-landed]

[scruff's own docs](/docs/scruff) are the lifecycle. What you meet soonest:

* `scruff` lists every lane across every repo, sweeping landed ones first;
  `scruff sparkle` resumes one, `scruff reap` sweeps what a crash or a reboot
  left, and `scruff reaped` undoes that from a ledger.
* **Never `git stash`.** The stash stack is shared across every worktree of a
  repo, so one lane pops another's work into a tree that never asked for it.
  `scruff park [label]` and `scruff unpark` instead.
* **Another repo is `cd "$(scruff child ~/code/other-repo)"`**, never a raw
  `git worktree add`, which skips scruff's registry so the lane's PR never
  reaches the HUD. It is this pane's second checkout, not a second agent: no
  window, and so no row in the **Lanes** picker.
* Commits made after the PR merged are covered by nothing, because GitHub deleted
  the remote branch. The listing marks them &#x2A;*`live+3`** and the statusline an
  orange `3^`; `scruff reship [name]&#x60; pushes the branch and opens the follow-up
  PR. The lookalike &#x2A;*`~3`** is
  [the opposite case](/docs/scruff/landing#when-a-lane-outran-its-pr).

Pointing agents at haus itself?
[Contributing](/docs/haus/internals/contributing) covers a family repo.

## Knowing which agent needs you [#knowing-which-agent-needs-you]

Run eight agents at once and the question is which of them is sitting on a
permission prompt. The [bar](/docs/haus/rooms/bar)'s `agents` pill answers it for
a lane, an agent you started by hand, and a conversation in the desktop app
alike: one mark and a count per state, in urgency order, and a state with
nothing in it draws nothing at all. Ready for your turn is a filled `?` in red,
working an open ring in sky, done a tick in green, so the ink thins as the
urgency does and the shape carries the state where the colours are too small to
separate. The robot itself takes the most urgent live state's colour, which
answers "is anything asking for me" before you have read a digit.

Each client's own lifecycle hooks report it, so nothing scrapes the screen. haus
wires OpenCode's plugin, Codex's hooks and pi's extensions for you. **Claude
Code's four are yours to wire**, and haus deliberately leaves them alone.

<Callout type="info" title="What haus writes into `~/.claude/settings.json`">
  `WorktreeCreate` and `WorktreeRemove`, the hooks Claude Code's own `--worktree`
  flag fires, pointing at `scruff hook create` and `scruff hook remove`; a
  `PreToolUse` entry for [`agent-desktop-guard`](#the-screen-belongs-to-you); one
  `scruff hook notify` appended to `Notification` and `Stop`, which raises a
  [trill](/docs/trill) banner when a lane blocks on you or finishes; the
  statusline, `permissions.defaultMode` and a few TUI keys; and the
  [`ai.autoMode`](#what-auto-mode-is-told-about-this-machine) block when you name
  one.

  It merges rather than replaces, leaves the four agent-state hooks and your own
  `PreToolUse`, `Notification` and `Stop` entries alone, and re-asserts on every
  rebuild, because Claude Code rewrites its own settings on its own schedule. You
  would find that out at pane close, by losing a lane's parking.
</Callout>

### The statusline HUD [#the-statusline-hud]

haus points Claude Code's status bar at a HUD off scruff's registry. **Row one is
this session's own lane**: a status token, its GitHub PR pill, then its name.
Below it are the lanes this session spawned in other repos, eight at most with a
`+N more` line; a ⌘⏎ lane in this pane's own repo is a sibling with a window of
its own, not a child. Rows needing attention sort above landed ones.

| Token                     | Means                                                                                    |
| ------------------------- | ---------------------------------------------------------------------------------------- |
| `⏏`                       | the branch has landed; closing the pane reaps the lane                                   |
| `N^`                      | `N` commits on the branch, not merged yet                                                |
| `N^&#x60; &#x2A;(orange)* | the PR merged and `N` commits landed since, covered by nothing; `scruff reship`          |
| `+A -D`                   | uncommitted line changes, when nothing's committed yet                                   |
| `●&#x60; &#x2A;(muted)*   | nothing to report: clean tree, nothing ahead, including a lane that hasn't committed yet |

A lane that has *never committed* is empty rather than landed, whatever git's
ancestry says, so it takes the muted `●` and draws no PR row on the pill either.
Flush right on row one: 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, which
means different things on a 200k and a 1M model.

## When a rebuild fails [#when-a-rebuild-fails]

A failed `haus rebuild` leaves the phase, the error and the derivation on disk,
then makes one offer: `gum` rows under the error if you are at the terminal, a
**Fix it** notification if you have walked away. The notification survives you
closing the window it failed in, which is the point.

The button runs `haus fix`, and so can you. It gives the client `haus.ai.default`
names one turn in `~/.config/nix` (the failing phase, the error, what changed
since the last good rebuild, the haus revision you pin) and then checks the answer
itself rather than believing it. Its permission prompts are off, because the offer
starts from a notification and nobody is there to answer one. What bounds it is
the directory and the commit, so `git -C ~/.config/nix revert HEAD` undoes all of
it, and a `~/.config/nix` that is not a git repository never gets the offer.

It never activates: applying the fix is `haus rebuild`, and that stays yours.
`HAUS_NO_FIX=1` turns the offer off without turning the room off, and
[the haus CLI](/docs/haus/reference/haus#when-a-rebuild-fails) has the full
command.

## When GitHub goes red [#when-github-goes-red]

The [bar](/docs/haus/rooms/bar)'s `github` pill says *what* is broken; this room
adds the button that hands it off. Any row in the dropdown an agent can pick up,
a red default branch, a pull request whose checks came back red, a pull request
that conflicts, carries a **Fix with AI** row. It opens a
[lane](/docs/scruff/lanes) on that repo's local checkout, in the background,
briefed with the one thing the transcript could not recover: which failure you
clicked.

It never appears on "changes requested", which is a person waiting on a person,
and it never fixes anything itself: it spawns, so the undo question stays with
the lane's own permission gate. It never guesses at a repo you don't have either.
The checkout is found the way [Spawn Agent](#spawning-from-the-launcher) finds
one, under `haus.ai.repoRoots` plus anything you have opened a lane on, matched
on directory name and confirmed against its `origin` remote; no local clone gets
a banner saying so, not a lane on the wrong repo. The button is absent entirely
with no coding agent installed, and `haus.ai.default` is the client it spawns,
falling through to whichever one is actually on `PATH`.

## The screen belongs to you [#the-screen-belongs-to-you]

`permissions.defaultMode` is `auto`, which is right for files and wrong for the
screen: an agent that decides to foreground an app, move a window or click
something just does it, while you are typing into something else. With several
lanes running that lands as random focus theft.

**`agent-desktop-guard`** is the counterweight: one ruleset reached two ways, a
`PreToolUse` hook on Claude Code and `haus-desktop-guard.ts` on pi. It is **not**
a blocklist. Every verdict is a question with an Allow on it, and it returns no
opinion on everything else, so auto mode is untouched wherever it was already
fine. The line it draws is not *is this dangerous*. It is:

> does this change what is in front of my eyes, within about two seconds?

Which is about the *target*, not the command. **Prose is not a command**: heredoc
bodies and comment lines are dropped before anything looks for one, so
`haus rebuild` inside a `cat > README.md <<EOF` or a commit body is text. What is
left splits at unquoted `;`, `&&`, `||`, `|` and newlines, quote-aware, and
segments that run on another machine are dropped. Anything unclassifiable counts
as local, so the failure mode is one extra prompt. Wrappers are peeled:
`bash -c 'killall Dock'` and `sudo killall WindowServer` are read as what they
wrap.

| Silent: no prompt                                                                         | Asks first                                                                  |
| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `screencapture -x`, and a computer-use batch of only screenshots, zoom or cursor position | a click, a keystroke, `open -a Safari`, `osascript … activate`              |
| `defaults write`: invisible until something restarts                                      | `killall Dock`: instantly visible                                           |
| `open -ga Ghostty`, which takes no focus                                                  | `open` in its default, foregrounding form                                   |
| every `mcp__claude-in-chrome__*` call: that browser is not the one on screen              | a computer-use tool the guard has never heard of                            |
| `ssh` to another machine, a lane's headless VM above all                                  | `ssh -X`/`-Y`, which render on *this* display, and `ssh` to `localhost`     |
| `tart run --no-graphics`: a VM with no window anywhere                                    | `tart run` without it, which opens that guest in front of you               |
| `--help` on `tart run` or any gated `aerospace` verb                                      | **activating**: `darwin-rebuild switch`, `haus rebuild`, `bench try switch` |

Also on the ask side: `aerospace` focus, move and workspace commands,
`sketchybar --reload`, `launchctl kickstart`, writing your clipboard, and
teaching a computer-use step.

Set [`ai.instructions`](/docs/haus/reference/options#ai) and haus prepends a
**screen etiquette** section to the file every client reads: prefer looking to
touching, never foreground an app just to see it, hand feel-tests back to the
person at the keyboard. Left empty, the default, haus writes no instructions file
at all, so you get the guard and none of the prose; setting it moves a
hand-written file already at that client's path aside as `.backup`, quietly, so
look before the first rebuild after. `HAUS_DESKTOP_OK=1` in a pane's environment switches the
guard off there, for a long unattended run where forty prompts is the problem.
Claude Code and pi are the two that get the guard; the etiquette goes to every
client.

<Callout type="warn" title="`open -g` is quiet, not visible">
  The guard lets `-g` through because it takes no **focus**, which is all the guard
  polices. It is not a way to *see* something: measured 2026-08-23,
  `open -g -na Ghostty.app --args --initial-command=…` leaves a live process with
  no window, ever, and the command never runs. `open` exits 0 either way, because
  it returns the moment LaunchServices accepts. Reach for `-g` when you need
  something **running**, and for a VM when you need to **see** it.
</Callout>

### A VM, so it doesn't have to be your screen [#a-vm-so-it-doesnt-have-to-be-your-screen]

Nobody feel-tests a tiling keybind by reading a diff, so the room ships
[`tart`](https://tart.run) and a lane can stand up a disposable macOS of its own:

```sh
scruff runtime up    my-lane --backend tart   # clone, boot headless, wait for ssh
scruff runtime enter my-lane --backend tart   # ssh in
scruff runtime down  my-lane --backend tart   # delete the clone
```

Headless, so nothing it draws reaches your display, and real enough to run
`haus rebuild` inside. `up` returns when the guest answers ssh rather than when
it takes an address, and a clone that never gets that far names its boot log.
`SCRUFF_TART_BASE` names the image it clones; a bare
`ghcr.io/cirruslabs/macos-tahoe-base` boots with no haus in it to test, so the
haus repo carries `script/build-golden-vm.sh` to bake one that has.
[Runtimes](/docs/scruff/runtimes) is the backend itself.

## What auto mode is told about this machine [#what-auto-mode-is-told-about-this-machine]

In `auto` mode a classifier judges every tool call against a picture of the
machine it runs on, and Claude Code's own picture is a stranger's laptop: the
working repo and its remotes are trusted, everything else is not. So it asks
about the ordinary work of a machine running lanes across a dozen repos you own.
[`ai.autoMode`](/docs/haus/reference/options#ai) is where you correct it, in four
lists of prose, one fact or rule per string. `environment` is what this machine
and its repos are: who owns what, where secrets live, which hosts are disposable,
what counts as production. `allow` is what is ordinary here. `softDeny` is what
it should stop and ask about, unless you or an `allow` rule say otherwise, and
`hardDeny` what it refuses outright, whatever anyone says.

They are written into `~/.claude/settings.json` on every rebuild, per section, so
a host naming only `allow` never deletes a `hard_deny` you wrote by hand. Claude
Code's own entries stay in front of yours while `ai.autoMode.keepDefaults` is on;
turn that off and yours replace them, refusals included, so read the result back
with `claude auto-mode config` first.

Empty a list and the next rebuild takes that section back out of the file, along
with anything you changed inside it with `claude auto-mode`. That is the point:
an `allow` rule is a refusal you have lifted, and it should stop being lifted the
moment you stop asking for it. haus keeps a note of which sections it wrote, so a
section you wrote yourself and haus never named stays exactly where it is.

That note starts the first time you rebuild on a haus new enough to keep one. A
section you had already emptied before then has nothing behind it, so haus leaves
it alone rather than risk deleting a rule you wrote by hand. Set the list again,
rebuild, empty it, rebuild, and it goes. `claude auto-mode config` prints what is
actually in force, which is the thing worth reading either way.

The [guard](#the-screen-belongs-to-you) decides for itself whether a call touches
what is in front of your eyes, so an `allow` rule here should leave the screen to
it rather than gate it twice.

## Letting a run finish [#letting-a-run-finish]

A Mac left alone sleeps, and a sleeping Mac is not running your agent. Two
different things end an overnight run: **nobody touches the keyboard**, and
`haus.power.displaySleep` and `haus.power.computerSleep` run down; or **you close
the lid**, a separate path in macOS that no `caffeinate` assertion can cross,
[`awake`](/docs/haus/rooms/bar) included. One switch covers both, in two stops:

```nix
haus.ai.keepAwake = "idle";   # survive an untouched keyboard
haus.ai.keepAwake = "lid";    # survive that, and a closed lid
```

`idle` holds a `caffeinate` assertion for exactly as long as an agent is
mid-turn: no privilege, and safe on battery, because closing the lid still sleeps
the Mac. `lid` adds the deep lever, turning on `haus.power.lidAwake`, whose root
daemon holds macOS's `disablesleep`, the only thing that crosses a lid close. It
*asks* for that option rather than forcing it, so setting it `false` yourself
wins, warns, and leaves you the `idle` half.

The signal is the one the [bar's agents pill](/docs/haus/rooms/bar) draws, and an
agent parked at a permission prompt does **not** hold: it is waiting on a human
who is not there. That is also why this needs `haus.ai.enable`; setting it anyway
warns rather than failing the rebuild.

### Shaping the hold [#shaping-the-hold]

The dials live with the machinery, in
[`haus.power.lidAwake`](/docs/haus/reference/options#haus-power-lidawake-enable),
and both stops read them rather than carrying a second copy. `linger` (5 min)
keeps a hold going past the last turn, so the gap between two turns cannot sleep
the Mac. `maxHold` (8 hours) caps one unbroken hold, and a genuinely new agent
lifts the cap early. `requirePower` (on) guards the **lid** hold only, because
unplugging is also how you say stop; the `idle` hold ignores it.

A fourth key there is not a dial. `while = "always"` is plain closed-display
mode, agents or no agents, and it is the stop an unattended merge shift wants,
because a quiet night raises no agent-shaped hold at all.
[Merging pull requests overnight](/docs/haus/night-shift) is that setup.

<Callout type="warn" title="Shut lid, no display, blind agent">
  With the lid down and nothing external plugged in there is no display at all, so
  an agent that takes screenshots or drives the UI has nothing to look at. Work
  that has to *see* something wants the VM above, whose display was never this one.
</Callout>

## Options [#options]

Every setting, with types and defaults:
[AI](/docs/haus/reference/options#ai).
