Skip to content

WIP: Worktrees from the phone: three declared capabilities - #135

Open
broven wants to merge 7 commits into
AltanS:v1from
broven:feat/133-worktrees
Open

WIP: Worktrees from the phone: three declared capabilities#135
broven wants to merge 7 commits into
AltanS:v1from
broven:feat/133-worktrees

Conversation

@broven

@broven broven commented Aug 28, 2026

Copy link
Copy Markdown

Closes #133.

Branch off and put an agent on it from the phone. Three capabilities — listWorktrees, createWorktree, openWorktree — behind three routes, surfacing in two places.

A worktree nests under the space showing its repo

spaces list with two worktrees nested under their repo

MuxSpace gains repoRoot and isWorktree — declared facts, like spaces capacity, never capabilities — and Herdr answers both on the workspace record it already sends, so the nesting costs no extra call and needs nothing switched on.

Three rules, each answering a case the flat list never had:

  • A group takes the position of its most recent member. The list promises that what is fresh is near the top; sorting by the parent alone would bury a worktree used a minute ago under a checkout last touched last week.
  • A worktree whose repo is not open stays flat. There is no row to indent under, and indenting under nothing reads as a rendering fault.
  • Filtering flattens, for the same reason: a filter matching only the child would indent it under a parent that is not on screen.

A space outside any repo is the row it always was.

+ asks which kind

the new-space sheet's worktree tab: a repository picker and a branch name field

The worktree side asks which repo, because a sheet opened from the list has no current space to take one from. It offers the repos that are open, defaulting to the most recently used. Under the branch field sits "or open one that already exists" — the worktrees of the chosen repo that no space is showing, read once per repo choice. Those are the only checkouts the phone has no other route to; everything else is already a space in the list behind the sheet.

The tab strip renders only where there is a choice. No capability, or nothing open in a repo, and this is the plain new-space sheet, unchanged.

Opening navigates the phone. focus is never sent, so the operator's own screen stays where they left it — moving that is setFocus, which is its own capability for exactly this reason.

Both states are cards in the states playground, each with its "reach it for real" line.

Why the port, and not git worktree add + createSpace

The cheaper route is the obvious one, and ADR 0032 is there for when it is proposed again.

Herdr does not merely run Git; it keeps a record — open_workspace_id per checkout, a worktree block on every workspace. That record is the feature: it is how the phone knows which checkouts exist, which of them a space already shows, and which space nests under which repo. A checkout made behind Herdr's back has none of it, and Collie could only list it by doing its own Git work and then reconciling two answers about one directory.

Herdr declares all three. tmux and zellij decline them with their reasons: tmux could shell out to Git, but it keeps no mapping from a checkout to the session showing it, so what it made could not be found again. Declining is the honest answer until an adapter keeps that record itself and a probe proves it.

Probed, not assumed

Every Herdr cell in MUX_CONTRACT.md cites a first-hand probe of herdr 0.8.2 (protocol 20), 2026-08-28, run against an isolated herdr --session over a throwaway repo — never a live pane. Two findings shaped the code:

  • worktree.create is not atomic. In a session with no window server the checkout was created and the open failed with worktree_open_failed: the branch exists, nothing shows it, and creating again answers worktree_create_failed because the path is taken. That case gets its own code — worktree.created_not_opened — and the UI offers open, never create again.
  • worktree.open is idempotent, answering already_open with the space showing it. Treated as an answer rather than a refusal: either way the pane below is where to go.

Refusals reach the phone as catalogued codes. Three change what it does — a busy multiplexer means retry, an ambiguous branch means type a better one, a space outside a repo shows nothing at all — and the rest carry the multiplexer's own sentence in {reason}.

Notes

  • Version files and CHANGELOG untouched, per CLAUDE.md's fork rule. A line in my words, if you want one:
    - Worktrees from the phone: create or open a Git worktree from the spaces list, and see it nested under its repo. (<hash>)
  • Six locales, since the dictionary is total by type.
  • Conformance covers all three in both directions. The declared side creates its own target first — "open this" cannot be proven against a checkout nothing made.
  • bun run lint clean, bun test and the web suite green.

🤖 Generated with Claude Code

broven and others added 4 commits August 28, 2026 13:37
Herdr can create a Git worktree and open it as a space; Collie had no route
to it, so branching off from the phone meant walking back to the desk (AltanS#133).

The port learns four verbs — list, create, open, remove — because the ACT is
the multiplexer's even though the noun is Git's: each one ends in a space
appearing, moving or going away, and the record tying a checkout to the space
showing it is what a multiplexer may or may not keep. Herdr keeps it and
declares all four; tmux and zellij decline them with their reasons, since a
checkout they made could not be listed or removed again. ADR 0032 has the
argument, including why `git worktree add` + `createSpace` is the wrong cheap
route.

Removal is addressed by SPACE, never by path — that is Herdr's shape, not a
preference, and `MuxWorktree.openSpaceId` states it in the type so a caller
knows before it asks. `focus` is never sent: the phone navigates itself and
the operator's own screen stays put.

Every Herdr cell in the matrix cites a first-hand probe of herdr 0.8.2 on
2026-08-28 (isolated session, throwaway repo), including the one that matters
most: `worktree.create` is NOT atomic — a failed open leaves the branch on
disk with nothing showing it, and the recovery is to open it, never to create
it again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four routes behind the four capabilities: list the repo's worktrees, create
one on a new branch, open one that already exists, remove the one a space is
showing.

The space IS the repo context. `MuxSpace` learns `repoRoot` — a declared fact,
like `spaces` capacity, not a capability — and Herdr fills it from the block it
already puts on every workspace record. So no route takes a repo path, nothing
walks the filesystem looking for `.git`, and a space outside a repo simply has
no key (absence is the fail-closed answer the phone reads as "no worktree
rows"). The snapshot gains the field only where there is one, so an ETag moves
for a space in a repo and for nothing else.

Removal names its target in the BODY while the path segment stays the asking
space: the sheet lists siblings, so those are two different spaces. The asking
space must itself be in a repo, which keeps the verb from being a general
"close any space" door reachable by naming one.

Three refusals get their own code because the phone ACTS on them — a dirty
checkout arms the discarding tap, a busy multiplexer means retry, an ambiguous
branch means type a better one. A fourth is the half-done create: the checkout
exists and only the opening failed, so it is `worktree.created_not_opened` and
the recovery is to open it, never to create it again. Everything else shares one
code per verb and carries the multiplexer's own sentence in `{reason}`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The phone side of AltanS#133: branch a new worktree, open one that exists, remove
the one a space is showing — reachable from the space you are already in.

Shown only where BOTH answers are yes: the multiplexer declares
`listWorktrees`, and this space reports a `repoRoot`. Both ride on data the
route already has, so a space outside a repo shows nothing and costs no extra
call.

Three behaviours are load-bearing, and each is a probed reality rather than a
preference:

  • Remove is absent for a checkout no space is showing — removal is addressed
    by space, so there would be nothing to name. The row says why instead of
    offering a tap that must fail.
  • The discarding confirmation appears only AFTER the multiplexer has refused
    for being dirty, and only on the checkout that refused. `force` is never
    sent on a first attempt: two taps mean "remove it", and a third — worded as
    discarding the work — is a separate decision the operator has been told the
    cost of.
  • A half-created worktree offers "open", never "create again", because the
    branch is already on disk and a second create refuses.

Opening navigates the PHONE. `already_open` is treated as an answer, not a
failure — either way the pane is where to go — and the desktop's own screen is
never moved.

Six locales, because the dictionary is total by type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… which kind

Two things the flat list could not say, both from testing the sheet on a real
herd.

**The spaces list nests.** A worktree sits one level under the space showing
its repo, which is the relationship the list was flattening away — four of six
spaces here are two repos and their worktrees, and nothing said so. `MuxSpace`
gains `isWorktree` beside `repoRoot` (the pair travels together and Herdr
answers both on the workspace record), so the nesting needs no extra call.

Three rules, each answering a case the flat list never had: a group takes the
position of its MOST RECENT member, so a worktree used a minute ago does not
get buried under a checkout last touched a week ago; a worktree whose repo is
not open stays flat, because indenting under nothing reads as a rendering
fault; and filtering flattens, for the same reason.

**One create entry point, two kinds.** The `+` on the spaces list now opens a
sheet with a tab: a plain space, or a worktree. The worktree side has to ask
which repo — from the list there is no current space to carry one — so it
offers the repos that are open, defaulting to the most recent. The tab strip
appears only where there is a choice: no capability, or nothing open in a
repo, and it is a plain new-space sheet exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@broven
broven marked this pull request as draft August 28, 2026 06:53
@broven broven changed the title Worktrees from the phone: four declared capabilities, a sheet on the space WIP: Worktrees from the phone: four declared capabilities, a sheet on the space Aug 28, 2026
broven and others added 2 commits August 28, 2026 15:11
…their own

The Worktrees button and its sheet are gone. Testing them on a real herd, the
button read as a panel about a different concept — and it was, which is why
nothing about it said what it was for.

Every verb moves to where this repo already puts that verb:

  • list   — the spaces list itself, now that worktrees nest under their repo.
             A worktree IS a space; a second list of them was the same thing
             under two names.
  • open   — tapping the row, like any space.
  • create — the `+` sheet's Worktree tab.
  • remove — LONG-PRESS the space, then a two-tap destructive row: exactly the
             shape a tab's close and a pane's close already take.

The one worktree kind with no other route — one that exists on disk and is
open as nothing — gets a short "or open one that already exists" list inside
the Worktree tab, read once per repo choice rather than on any poll.

WORTH A REVIEWER'S EYE: Collie has no `closeSpace` capability and this does not
add one. The space actions sheet offers removal only where the thing removed is
a git worktree; the space going away is Herdr's own consequence of
`worktree.remove`, not a power Collie granted itself. A space that is not a
worktree says so, in those words.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removal is out: the port verb, the route, the codes, the sheet and its strings.
What is left is list, create and open.

The reason is not that it was hard, it is that it was reaching past what Collie
has decided. There is no `closeSpace` capability here, deliberately — and
Herdr's `worktree.remove` closes the space along with the checkout, so shipping
it would have handed Collie its first space-destroying verb through a side door
nobody argued for. A checkout that has outlived its use is still cleaned up at
the desk, which is where every other space-ending decision is made today.

`MuxWorktree.openSpaceId` stays, because it is what tells the phone which
checkouts are already spaces — the nesting and the "or open one that already
exists" list are both built on it. Removal can be added later against that same
field, as its own argument rather than as a rider on this one.

ADR 0032 is reframed to match: the bookkeeping argument for asking the
multiplexer instead of running Git now stands on listing and re-opening, which
is where it always did its real work; removal appears as the sharpest
illustration of the point rather than as one of the verbs shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@broven broven changed the title WIP: Worktrees from the phone: four declared capabilities, a sheet on the space WIP: Worktrees from the phone: three declared capabilities Aug 28, 2026
The states playground's rule is that every state Collie can reach is on that
page, and these two were not: a spaces list nesting a worktree under its repo,
and the new-space sheet's worktree tab.

Both mount the real components with real props — the nesting card passes an
empty herd deliberately, because with agents attached every row also carries its
triage tint and a wall of red says nothing about shape. `spacesWithWorktrees`
carries the fields the multiplexer actually reports (`repoRoot`, `isWorktree`),
so the card is the real shape rather than a staged one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@broven
broven marked this pull request as ready for review August 28, 2026 07:49
@AltanS

AltanS commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Just a heads up, this PR is against the v1 branch (not main) and I dont have a clear release path for v1 yet. Not sure if that was intended, but it definitely saves me some time as I won't have to port the feature back into v1. There is a path to install the beta which are outlined in the v1's readme. Let me know if you have troubles setting it up.

If this was meant for the current main branch, please close and resubmit the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants