diff --git a/.adr/0032-a-worktree-is-opened-by-the-multiplexer-not-by-git.md b/.adr/0032-a-worktree-is-opened-by-the-multiplexer-not-by-git.md
new file mode 100644
index 00000000..78168656
--- /dev/null
+++ b/.adr/0032-a-worktree-is-opened-by-the-multiplexer-not-by-git.md
@@ -0,0 +1,91 @@
+# 0032 — A worktree is opened by the multiplexer, not by Git
+
+Status: **Accepted** (2026-08-28)
+
+Contract: [`MUX_CONTRACT.md`](../MUX_CONTRACT.md) · Code: [`bridge/mux/`](../bridge/mux/) ·
+Related: [ADR 0022](./0022-the-mux-seam-is-a-port-collie-owns.md) (the port these capabilities join) ·
+[ADR 0011](./0011-the-pack-protocol-is-the-mux-driver-seam.md) (a mux adapter is host-local)
+
+## Context
+
+Starting fresh work from the phone means starting it in whatever checkout the pane already sits in
+([#133](https://github.com/AltanS/collie/issues/133)). Branching off and putting an agent on it is a
+walk back to the desk — the one thing Collie exists to remove.
+
+There is a cheaper route than the one below, it is obvious, and it will be proposed again. **Collie
+already runs on the host; `git worktree add` is one command; `createSpace` already opens a space at a
+path.** Two calls Collie can make today, no port change, no capability, and it works on every adapter
+that can make a space. It is the right instinct — the mux port must not grow a vendor's feature list
+— and it is why this decision needs writing down rather than merely making.
+
+It fails on what happens next, and the failure is not visible on the day it ships.
+
+**Herdr does not merely run `git worktree add`; it keeps a record.** Probed 2026-08-28 against herdr
+0.8.2: `worktree.list` answers with `open_workspace_id` per checkout, and the session snapshot carries
+a `worktree` block on every workspace (`repo_key`, `repo_root`, `checkout_path`,
+`is_linked_worktree`). That record is the whole feature: it is how the phone knows which checkouts
+exist, which of them a space is already showing, and which space to nest under which repo. A checkout
+made behind Herdr's back has none of it — Herdr's desktop cannot show it as open, and Collie could
+only list it by doing its own Git work and then reconciling two answers about the same directory.
+
+Removal makes the point sharper still, and it is worth stating even though this decision does not
+implement it: Herdr's `worktree.remove` is addressed **by workspace**, with no path-addressed form at
+all. So a checkout created outside Herdr's bookkeeping could never be removed through the socket —
+the verb would have nothing to name. The cheap route does not produce a worse worktree; it produces
+one nothing can clean up.
+
+The second problem is placement. Herdr puts linked worktrees under `~/.herdr/worktrees//`
+and the operator's desktop finds them there. A Collie that chose its own directory would scatter
+checkouts a running Herdr already had opinions about.
+
+So the question is not "may the port know about Git". It is: **who owns the mapping from a checkout
+to the space showing it?** Whoever owns it must do the creating, because the record is made at
+creation time and cannot be reconstructed afterwards.
+
+## Decision
+
+**A worktree verb asks the multiplexer, never Git. Three declared capabilities —
+`listWorktrees`, `createWorktree`, `openWorktree` — with three routes behind them.**
+
+- **The port speaks checkouts and spaces, not Git.** `MuxWorktree` carries a path, a branch, and the
+ space showing it; there is no ref, no remote, no status. What the port asks for is the act that
+ ends in a space appearing, moving or going away — which is a multiplexer's job — and the Git
+ underneath is the adapter's business.
+- **Removal is NOT in this decision.** Collie has no `closeSpace` capability, and Herdr's
+ `worktree.remove` closes the space with the checkout — so shipping it here would hand Collie its
+ first space-destroying verb through a side door. `MuxWorktree.openSpaceId` is carried anyway,
+ because it is what tells the phone which checkouts are already spaces; removal can be added later
+ against that same field, deliberately, as its own argument.
+- **`focus` is never sent.** Creating from the phone must not move the operator's own screen; the
+ phone navigates itself. Changing what the terminal shows stays `setFocus`, which is its own
+ capability for exactly this reason.
+- **Herdr declares all three; tmux and zellij decline them, with reasons.** tmux could shell out to
+ Git, but it keeps no record tying a checkout to the session showing it, so what it produced could
+ not be listed or removed again. Declining is the honest answer until an adapter keeps that mapping
+ itself and a probe proves it.
+
+## Alternatives rejected
+
+- **`git worktree add` + `createSpace` (the cheap route).** Rejected above: it creates checkouts the
+ multiplexer cannot remove, in a directory it did not choose. Its one real advantage — working on
+ tmux too — buys a worse feature on the adapter every current operator runs.
+- **One umbrella `worktrees` capability.** Rejected because the repo splits by the control that
+ appears or does not (`createTab`/`renameTab`/`closeTab`), and these are three separate affordances.
+ An umbrella would also force an adapter that could only create to declare it could also list.
+- **Version-gating the declaration on Herdr's protocol number.** Rejected as a change of mechanism
+ smuggled in with a feature: no capability is gated that way today, and an old Herdr already answers
+ an unknown method with a refusal the phone can show.
+
+## Consequences
+
+- **Three rows in the matrix, three columns each, and every Herdr cell owes a probe.** Paid: the
+ cells cite a first-hand probe of herdr 0.8.2 on 2026-08-28.
+- **`createWorktree` is not atomic and callers must treat it as two steps.** Probed: in a session
+ with no window server the checkout was created and the open failed with `worktree_open_failed` —
+ the branch exists and nothing shows it. Recovery is `openWorktree`, never a second create, which
+ answers `worktree_create_failed` because the path is taken. The UI says so.
+- **Nothing is removed from the phone at all yet.** A worktree can be listed, created and opened; a
+ checkout that has outlived its use is still cleaned up at the desk.
+- **What would justify revisiting this.** A second multiplexer growing real worktree bookkeeping (so
+ the capability stops being one vendor's), or Collie deciding what it means to destroy a space —
+ which is the argument `removeWorktree` is waiting on, not a missing line of code.
diff --git a/MUX_CONTRACT.md b/MUX_CONTRACT.md
index 611f95c3..7d98cbf2 100644
--- a/MUX_CONTRACT.md
+++ b/MUX_CONTRACT.md
@@ -18,6 +18,7 @@ Sources, once:
| **T** | First-hand probe of **tmux 3.6b** on a throwaway server — [M10/04 Ground Truth](./.tracker/M10-mux-drivers/04-the-tmux-adapter.md) |
| **Z** | First-hand probe of **zellij 0.44.2** — [M10/05 Ground Truth](./.tracker/M10-mux-drivers/05-the-zellij-adapter.md) |
| **L** | First-hand probe of this host's **live test instances** — 2026-08-25, tmux socket `/run/user/1000/collie-tmux.sock` and zellij session `collie-zellij` |
+| **H8** | First-hand probe of **herdr 0.8.2** (protocol 20) on this host — 2026-08-28, an isolated `herdr --session wtprobe` over a throwaway repo, worktree verbs only |
| **?** | Not probed yet. The adapter's spec probes it and fills the cell in; **an unprobed cell is never declared supported.** |
## The floor — not capabilities
@@ -86,6 +87,9 @@ ceiling: attention is something the bridge observes, never something a caller ca
| `closeTab` | `POST /api/tab/:id/close` | `tab.close` — a bulk pane-close (**API** § Close) | `kill-window` (**T**) — and it ends the session too when it was the last window, exactly as tmux itself does | `close-tab-by-id ` (**Z**) — and zellij closes a tab whose last pane goes away, exactly as it does by hand |
| `setFocus` | `POST /api/pane/:id/focus` — the pane sheet's "Show in terminal" row | `pane.focus {pane_id}`, ONE call: probed 2026-08-25 against the `collie-demo` sandbox session, the reply was `pane_info` and the next snapshot moved `focused_pane_id`, `focused_tab_id` AND `focused_workspace_id` together, so `tab.focus`/`workspace.focus` are never called. A pane that has gone answers `pane_not_found` (probed read-only against the live server) → `gone` | `select-window -t ; select-pane -t ` — one invocation, both levels, because a screen showing the right window and the wrong pane is a half-kept promise (**T**, probed 2026-08-25 on the test server: `window_active` and `pane_active` moved together) — **plus `; switch-client -c -t ` for every attached non-control client sitting on another session**, because tmux's current window belongs to the SESSION and those two commands move nothing on a terminal that is showing a different one (**T**, probed 2026-08-25 on the two-session test server: focusing a `collie-tmux` pane answered `{ok:true}` while the client on `ss-wp` stayed put; with the switch-client leg the client moved and `#{session_name} #{pane_id}` followed). No client attached ⇒ nothing to switch, and the window/pane selection alone is right: the next attach lands there. The window id, the session id and the clients all come out of the same listing the snapshot uses, so a stale pane id is `gone` before anything is spawned; `can't find window:` / `can't find pane:` classify as `gone` | **no** (**Z**) — `action focus-pane-id` exists on 0.44.2 and does NOTHING: probed 2026-08-25 with a client attached, both `focus-pane-id terminal_3` and the bare `focus-pane-id 3` exited 0 while `list-clients` still reported the client on `terminal_0`, and `focus-next-pane` moved it, so the session was live. `go-to-tab ` DOES work (1-based over tab position, probed) — and a tab-level approximation is deliberately declined: the promise is "this pane is in front", and showing a tab whose focus sits on a neighbour is the quiet lie conformance exists to catch |
| `createSpace` | `POST /api/workspace` | `workspace.create` (**API**) | `new-session -d -P -F` (**T**) — claimed: it is one verb, it is detached, and a duplicate name comes back as `refused` with tmux's own sentence. It carries the same `window-size manual` refusal as `createTab`: `new-session` spawns a window too, so tmux #4849 kills the server here as well | **no** (**Z**) — `zellij attach --create-background` does make a detached session, but every zellij verb is scoped to ONE session, so a session created here would be invisible to the adapter that made it |
+| `listWorktrees` | `GET /api/workspace/:id/worktrees` | `worktree.list {cwd}` — answers for a repo NOTHING has open, so the sheet can list a checkout before there is a space to name it by (**H8**). The repo's own checkout comes back too, with `is_linked_worktree: false` | **no** (**T**) — tmux has no Git vocabulary; a `git worktree list` here would answer about the host, not about anything tmux knows | **no** (**Z**) — same, and see `createSpace` |
+| `createWorktree` | `POST /api/workspace/:id/worktree` | `worktree.create {cwd, branch, focus:false}` → the new workspace and its root pane, ready to navigate to (**H8**). **NOT ATOMIC:** probed in a session with no window server, the checkout was created and the open failed `worktree_open_failed` — the branch exists, nothing shows it, and a retry answers `worktree_create_failed` because the path is taken. Recovery is `openWorktree` | **no** (**T**) — `git worktree add` would run, but tmux keeps no record tying the checkout to the session showing it, so what it made could not be listed or removed again (ADR 0032) | **no** (**Z**) — same, plus one session means no second space to open it as |
+| `openWorktree` | `POST /api/workspace/:id/worktree/open` | `worktree.open {cwd, path, focus:false}` → `already_open` plus the space showing it; asking for one already up is an ANSWER, not a refusal (**H8**). `path` alone answers `not_git_worktree` — the repo must come with it | **no** (**T**) | **no** (**Z**) |
| `pushTopologyEvents` | `bridge/event-poker.ts` | full event catalog: workspace/tab/pane created, closed, renamed (**API** § Event stream) | control mode pushes `%window-add`, `%session-changed` (**T**) | **no** (**Z**) — no CLI verb announces one. `zellij watch` is a read-only attach, `zellij pipe` needs a WASM plugin on the other end, and `action --help` has no event verb. The adapter censuses `list-panes` instead: 3 s after any change, doubling to 12 s while nothing moves |
| `pushPaneEvents` | `bridge/event-poker.ts` | `pane.agent_status_changed`, pane-scoped (**API** § Event stream) | `%output`, but only for the panes of the session a control client is ATTACHED to (**T**) — so the adapter attaches one per watched session, capped, and a 5-second listing is the floor | `subscribe --ansi --format json --pane-id …` — several panes per stream, newline-delimited JSON `pane_update` frames (**Z**). It also emits one `pane_closed`, the single topology fact zellij does push, which shortens the census rather than replacing it |
diff --git a/bridge/beacon/decorate.test.ts b/bridge/beacon/decorate.test.ts
index 2303f108..0580626a 100644
--- a/bridge/beacon/decorate.test.ts
+++ b/bridge/beacon/decorate.test.ts
@@ -17,6 +17,10 @@ import {
type MuxSubscription,
type MuxTabRequest,
type MuxWatchOptions,
+ type MuxWorktree,
+ type MuxWorktreeCreateRequest,
+ type MuxWorktreeOpenRequest,
+ type MuxWorktreeScope,
} from "../mux/types.ts";
// THE DECORATOR'S OWN TESTS — the join, the two-way capability lift, and the pass-through.
@@ -133,6 +137,22 @@ class StubAdapter implements MuxAdapter {
return Promise.resolve(muxOk({ paneId: "%9", spaceId: "space", spaceLabel: "space", tabId: "tab", cwd: "/tmp" }));
}
+ listWorktrees(scope: MuxWorktreeScope) {
+ this.note("listWorktrees", scope);
+ return Promise.resolve(muxOk([]));
+ }
+
+ createWorktree(request: MuxWorktreeCreateRequest) {
+ this.note("createWorktree", request);
+ return Promise.resolve(muxOk({ paneId: "%9", spaceId: "space", spaceLabel: "space", tabId: "tab", cwd: "/tmp" }));
+ }
+
+ openWorktree(request: MuxWorktreeOpenRequest) {
+ this.note("openWorktree", request);
+ return Promise.resolve(muxOk({ pane: { paneId: "%9", spaceId: "space", spaceLabel: "space", tabId: "tab", cwd: "/tmp" }, alreadyOpen: false }));
+ }
+
+
watch(options: MuxWatchOptions): MuxSubscription {
this.note("watch", options);
return this.subscription;
@@ -238,6 +258,9 @@ describe("a decorator preserves the adapter's whole surface", () => {
renameTab: true,
closeTab: true,
createSpace: true,
+ listWorktrees: true,
+ createWorktree: true,
+ openWorktree: true,
watch: true,
} satisfies Record, true>;
diff --git a/bridge/beacon/decorate.ts b/bridge/beacon/decorate.ts
index 64ff9d4a..e374acd6 100644
--- a/bridge/beacon/decorate.ts
+++ b/bridge/beacon/decorate.ts
@@ -298,6 +298,9 @@ export function withAgentBeacons(
renameTab: (tabId, label) => adapter.renameTab(tabId, label),
closeTab: (tabId) => adapter.closeTab(tabId),
createSpace: (request) => adapter.createSpace(request),
+ listWorktrees: (scope) => adapter.listWorktrees(scope),
+ createWorktree: (request) => adapter.createWorktree(request),
+ openWorktree: (request) => adapter.openWorktree(request),
// Untouched, and the header says why a beacon change fires nothing here.
watch: (options) => adapter.watch(options),
};
diff --git a/bridge/beacon/hint.test.ts b/bridge/beacon/hint.test.ts
index f760932b..03b22804 100644
--- a/bridge/beacon/hint.test.ts
+++ b/bridge/beacon/hint.test.ts
@@ -14,6 +14,10 @@ import {
type MuxSubscription,
type MuxTabRequest,
type MuxWatchOptions,
+ type MuxWorktree,
+ type MuxWorktreeCreateRequest,
+ type MuxWorktreeOpenRequest,
+ type MuxWorktreeScope,
} from "../mux/types.ts";
// THE HINT'S OWN TESTS — the three suppression rules, and the promise that a hint changes nothing
@@ -184,6 +188,22 @@ class StubAdapter implements MuxAdapter {
return Promise.resolve(muxOk({ paneId: "%9", spaceId: "space", spaceLabel: "space", tabId: "tab", cwd: "/tmp" }));
}
+ listWorktrees(_scope: MuxWorktreeScope) {
+ this.calls.push("listWorktrees");
+ return Promise.resolve(muxOk([]));
+ }
+
+ createWorktree(_request: MuxWorktreeCreateRequest) {
+ this.calls.push("createWorktree");
+ return Promise.resolve(muxOk({ paneId: "%9", spaceId: "space", spaceLabel: "space", tabId: "tab", cwd: "/tmp" }));
+ }
+
+ openWorktree(_request: MuxWorktreeOpenRequest) {
+ this.calls.push("openWorktree");
+ return Promise.resolve(muxOk({ pane: { paneId: "%9", spaceId: "space", spaceLabel: "space", tabId: "tab", cwd: "/tmp" }, alreadyOpen: false }));
+ }
+
+
watch(_options: MuxWatchOptions): MuxSubscription {
this.calls.push("watch");
return this.subscription;
diff --git a/bridge/beacon/hint.ts b/bridge/beacon/hint.ts
index bed993bf..f6ba3ffa 100644
--- a/bridge/beacon/hint.ts
+++ b/bridge/beacon/hint.ts
@@ -36,6 +36,11 @@ import type {
MuxPane,
MuxSnapshot,
MuxSpaceRequest,
+ MuxWorktree,
+ MuxWorktreeCreateRequest,
+ MuxWorktreeOpenRequest,
+ MuxWorktreeOpened,
+ MuxWorktreeScope,
MuxSubscription,
MuxTabRequest,
MuxWatchOptions,
@@ -168,6 +173,12 @@ export function withAgentHints(adapter: MuxAdapter, deps: AgentHintDeps): MuxAda
renameTab: (tabId: string, label: string): Promise => adapter.renameTab(tabId, label),
closeTab: (tabId: string): Promise => adapter.closeTab(tabId),
createSpace: (request: MuxSpaceRequest): Promise> => adapter.createSpace(request),
+ listWorktrees: (scope: MuxWorktreeScope): Promise> =>
+ adapter.listWorktrees(scope),
+ createWorktree: (request: MuxWorktreeCreateRequest): Promise> =>
+ adapter.createWorktree(request),
+ openWorktree: (request: MuxWorktreeOpenRequest): Promise> =>
+ adapter.openWorktree(request),
watch: (options: MuxWatchOptions): MuxSubscription => adapter.watch(options),
};
}
diff --git a/bridge/error-codes.ts b/bridge/error-codes.ts
index 156a6155..393b8f2c 100644
--- a/bridge/error-codes.ts
+++ b/bridge/error-codes.ts
@@ -78,6 +78,27 @@ export const ERROR_CODES = {
"tab.workspace_required": "workspaceId required",
"workspace.create_failed": "{reason}",
+ // ── Worktrees: /api/workspace/:id/worktree[s|/open|/remove] (ADR 0032) ─────────────
+ /** The list could not be read — the space is not in a Git work tree, or the mux refused. */
+ "worktree.list_failed": "{reason}",
+ /** Creation refused. `{reason}` is the multiplexer's own words, Git's sentence included. */
+ "worktree.create_failed": "{reason}",
+ /**
+ * The checkout was made and could not be shown — the branch EXISTS and nothing displays it.
+ * Distinct from `create_failed` because the recovery is opposite: open it, never create it again
+ * (a second create answers `create_failed`, the path being taken). Probed on herdr 0.8.2.
+ */
+ "worktree.created_not_opened": "the worktree was created but could not be opened: {reason}",
+ "worktree.open_failed": "{reason}",
+ /** Another worktree operation is still running — herdr serialises them. Try again in a moment. */
+ "worktree.busy": "{reason}",
+ /** The branch name matched more than one thing, so the multiplexer would not guess. */
+ "worktree.ambiguous_branch": "{reason}",
+ /** The request named no branch, or named one that is only whitespace. */
+ "worktree.branch_required": "branch required",
+ /** This space is not in a Git work tree, so it has no worktrees to show. */
+ "worktree.not_a_repo": "{reason}",
+
// ── Image upload: POST /api/pane/:id/upload → UploadResponse ───────────────────────
/** Refused on the declared Content-Length (413) or on the decoded size (200 + ok:false). */
"upload.too_large": "image too large (max 10 MB)",
diff --git a/bridge/mux/capabilities.ts b/bridge/mux/capabilities.ts
index 4cc1b602..c285ab89 100644
--- a/bridge/mux/capabilities.ts
+++ b/bridge/mux/capabilities.ts
@@ -37,6 +37,9 @@ export const MUX_CAPABILITIES = [
"renameTab",
"closeTab",
"createSpace",
+ "listWorktrees",
+ "createWorktree",
+ "openWorktree",
"pushTopologyEvents",
"pushPaneEvents",
] as const;
@@ -90,6 +93,12 @@ export const MUX_CAPABILITY_ROUTES = {
renameTab: "POST /api/tab/:id/rename.",
closeTab: "POST /api/tab/:id/close — a bulk pane-close.",
createSpace: "POST /api/workspace — a new space, opening a fresh shell.",
+ listWorktrees:
+ "GET /api/workspace/:id/worktrees — the worktrees of the repo that space sits in, so the sheet can show them. Absent ⇒ no worktree section at all, which is the honest degrade: without the list there is nothing to open and nothing to remove.",
+ createWorktree:
+ "POST /api/workspace/:id/worktree — a new branch in a new worktree, opened as its own space.",
+ openWorktree:
+ "POST /api/workspace/:id/worktree/open — show a worktree that already exists on disk. Idempotent: a worktree already open answers with the space showing it rather than refusing.",
pushTopologyEvents:
"bridge/event-poker.ts — panes/tabs/spaces appearing, closing or being renamed arrive as a push, so the snapshot poll can idle. Absent ⇒ the adapter polls to keep the same promise, and the poker learns nothing.",
pushPaneEvents:
@@ -194,6 +203,9 @@ export function declareCapabilities(input: MuxCapabilityInput): MuxCapabilityDec
renameTab: claimed.has("renameTab"),
closeTab: claimed.has("closeTab"),
createSpace: claimed.has("createSpace"),
+ listWorktrees: claimed.has("listWorktrees"),
+ createWorktree: claimed.has("createWorktree"),
+ openWorktree: claimed.has("openWorktree"),
pushTopologyEvents: claimed.has("pushTopologyEvents"),
pushPaneEvents: claimed.has("pushPaneEvents"),
} satisfies Record;
diff --git a/bridge/mux/conformance.ts b/bridge/mux/conformance.ts
index 9705926d..a0778304 100644
--- a/bridge/mux/conformance.ts
+++ b/bridge/mux/conformance.ts
@@ -230,6 +230,17 @@ function callTargets(snapshot: MuxSnapshot): CallTargets {
};
}
+/** The repo the worktree calls aim at. A path, because that is what the port asks for. */
+const CONFORMANCE_REPO = "/tmp";
+
+let probeBranchCounter = 0;
+
+/** A branch nothing has taken, so a second run in the same world is not "already exists". */
+function nextProbeBranch(): string {
+ probeBranchCounter += 1;
+ return `collie/conformance-${String(probeBranchCounter)}`;
+}
+
function capabilityCalls(adapter: MuxAdapter, targets: CallTargets): CapabilityCall[] {
return [
{
@@ -281,6 +292,46 @@ function capabilityCalls(adapter: MuxAdapter, targets: CallTargets): CapabilityC
writes: true,
run: async () => refusalOf(await adapter.createSpace({ cwd: "/tmp" })),
},
+ // ── Worktrees ────────────────────────────────────────────────────────────
+ //
+ // Two directions, one table, and these three need the split spelled out. An adapter that DOES
+ // NOT declare them must refuse before it looks anything up, so a synthetic target is exactly
+ // right. An adapter that DOES declare them is being asked to prove the verb works — and
+ // "open this" and "remove that" cannot be proven against a checkout nothing made. So the
+ // declared side makes one first, through the adapter's own `createWorktree`, and aims at that.
+ {
+ capability: "listWorktrees",
+ // A read: it asks Git through the multiplexer and changes nothing.
+ writes: false,
+ run: async () => refusalOf(await adapter.listWorktrees({ repoRoot: CONFORMANCE_REPO })),
+ },
+ {
+ capability: "createWorktree",
+ writes: true,
+ run: async () =>
+ refusalOf(
+ await adapter.createWorktree({ repoRoot: CONFORMANCE_REPO, branch: nextProbeBranch() }),
+ ),
+ },
+ {
+ capability: "openWorktree",
+ writes: true,
+ run: async () => {
+ if (!declares(adapter, "openWorktree")) {
+ return refusalOf(
+ await adapter.openWorktree({ repoRoot: CONFORMANCE_REPO, path: "/tmp/collie-no-worktree" }),
+ );
+ }
+ const made = await adapter.createWorktree({
+ repoRoot: CONFORMANCE_REPO,
+ branch: nextProbeBranch(),
+ });
+ if (!made.ok) return made;
+ return refusalOf(
+ await adapter.openWorktree({ repoRoot: CONFORMANCE_REPO, path: made.value.cwd }),
+ );
+ },
+ },
{
capability: "setFocus",
// It moves the OPERATOR's screen, so it is a write in the sense that matters here: the live
diff --git a/bridge/mux/herdr/adapter.ts b/bridge/mux/herdr/adapter.ts
index fee9fb5f..90339f5b 100644
--- a/bridge/mux/herdr/adapter.ts
+++ b/bridge/mux/herdr/adapter.ts
@@ -48,6 +48,11 @@ import {
type MuxTab,
type MuxTabRequest,
type MuxWatchOptions,
+ type MuxWorktree,
+ type MuxWorktreeCreateRequest,
+ type MuxWorktreeOpenRequest,
+ type MuxWorktreeOpened,
+ type MuxWorktreeScope,
} from "../types.ts";
import {
DEFAULT_TIMEOUT_MS,
@@ -58,6 +63,7 @@ import {
type WirePane,
type WireTab,
type WireWorkspace,
+ type WireWorktree,
} from "./client.ts";
import { buildSubscriptions, changedPaneId } from "./events.ts";
import { HERDR_UNSENDABLE_KEYS, toHerdrKey } from "./keys.ts";
@@ -89,6 +95,9 @@ const HERDR_CAPABILITIES = declareCapabilities({
"renameTab",
"closeTab",
"createSpace",
+ "listWorktrees",
+ "createWorktree",
+ "openWorktree",
"pushTopologyEvents",
"pushPaneEvents",
],
@@ -139,6 +148,57 @@ function transportRefusal(err: T): MuxRefusalOutcome {
return GONE_CODES.some((code) => detail.includes(code)) ? muxGone(detail) : muxUnreachable(detail);
}
+/**
+ * The Herdr worktree codes that are the OPERATOR'S problem, not the transport's.
+ *
+ * Matched on the message for the same reason {@link GONE_CODES} is: `client.ts` folds Herdr's
+ * `{code, message}` into one Error whose text opens with the code. Every code below was seen
+ * first-hand on herdr 0.8.2 (2026-08-28) except the three marked, which come from herdr's own
+ * source (`src/app/api/worktrees.rs`) — they are classified, never declared, so an unprobed one
+ * still lands as a refusal the operator can read rather than a retry that cannot help.
+ */
+const WORKTREE_REFUSAL_CODES: readonly string[] = [
+ "dirty_worktree_requires_force",
+ "not_git_worktree",
+ "worktree_create_failed",
+ "worktree_open_failed",
+ "worktree_list_failed",
+ "worktree_remove_failed",
+ "ambiguous_worktree_branch", // source-only
+ "not_linked_worktree", // source-only
+ "worktree_operation_in_progress", // source-only
+ "stale_worktree_operation", // source-only
+];
+
+/** A checkout that is not there any more is `gone`, exactly like a pane that is not. */
+const WORKTREE_GONE_CODES: readonly string[] = ["worktree_not_found"];
+
+/**
+ * Which refusal a worktree exception is.
+ *
+ * Three buckets, and the split matters: `gone` says re-read, `refused` says read the sentence,
+ * `unreachable` says try again. Folding the middle one into `unreachable` would invite a retry of
+ * something a retry cannot fix — a dirty checkout stays dirty.
+ */
+function worktreeRefusal(err: T): MuxRefusalOutcome {
+ const detail = reason(err);
+ if (WORKTREE_GONE_CODES.some((code) => detail.includes(code))) return muxGone(detail);
+ if (GONE_CODES.some((code) => detail.includes(code))) return muxGone(detail);
+ if (WORKTREE_REFUSAL_CODES.some((code) => detail.includes(code))) return muxRefused(detail);
+ return muxUnreachable(detail);
+}
+
+/** One Herdr worktree record in the port's words. */
+function toMuxWorktree(raw: WireWorktree): MuxWorktree {
+ return {
+ path: raw.path,
+ branch: raw.branch ?? null,
+ openSpaceId: raw.open_workspace_id ?? null,
+ linked: raw.is_linked_worktree,
+ prunable: raw.is_prunable,
+ };
+}
+
export class HerdrMux implements MuxAdapter {
readonly mux = HERDR_MUX;
readonly capabilities = HERDR_CAPABILITIES;
@@ -319,6 +379,40 @@ export class HerdrMux implements MuxAdapter {
}
}
+ async listWorktrees(scope: MuxWorktreeScope): Promise> {
+ try {
+ const raw = await this.client.listWorktrees(scope.repoRoot);
+ return muxOk(raw.map(toMuxWorktree));
+ } catch (err) {
+ return worktreeRefusal(err);
+ }
+ }
+
+ async createWorktree(request: MuxWorktreeCreateRequest): Promise> {
+ try {
+ const created = await this.client.createWorktree({
+ cwd: request.repoRoot,
+ branch: request.branch,
+ });
+ return muxOk(toCreatedPane(created));
+ } catch (err) {
+ return worktreeRefusal(err);
+ }
+ }
+
+ async openWorktree(request: MuxWorktreeOpenRequest): Promise> {
+ try {
+ const opened = await this.client.openWorktree({
+ cwd: request.repoRoot,
+ path: request.path,
+ });
+ return muxOk({ pane: toCreatedPane(opened.shell), alreadyOpen: opened.alreadyOpen });
+ } catch (err) {
+ return worktreeRefusal(err);
+ }
+ }
+
+
/**
* The contract's watch over Herdr's one `events.subscribe` stream.
*
@@ -433,7 +527,7 @@ function toMuxPane(
/** One Herdr workspace as a {@link MuxSpace}. `agent_status` is carried on the wire and unused. */
function toMuxSpace(raw: WireWorkspace): MuxSpace {
- return {
+ const space: MutableMuxSpace = {
spaceId: raw.workspace_id,
number: raw.number,
label: raw.label,
@@ -442,8 +536,20 @@ function toMuxSpace(raw: WireWorkspace): MuxSpace {
tabCount: raw.tab_count,
paneCount: raw.pane_count,
};
+ // Herdr carries the repo on the workspace itself (`worktree.repo_root`), so no extra call and no
+ // filesystem walk — see MuxSpace.repoRoot. OMITTED, never set to undefined, when there is none.
+ if (raw.worktree?.repo_root !== undefined) {
+ space.repoRoot = raw.worktree.repo_root;
+ // Herdr's own word for it. `is_linked_worktree` is false for the repo's checkout and true for
+ // every worktree of it — probed 2026-08-28, and the pair is what lets a list nest one under the
+ // other without a second call.
+ space.isWorktree = raw.worktree.is_linked_worktree === true;
+ }
+ return space;
}
+type MutableMuxSpace = { -readonly [K in keyof MuxSpace]: MuxSpace[K] };
+
/** One Herdr tab as a {@link MuxTab}. */
function toMuxTab(raw: WireTab): MuxTab {
return {
diff --git a/bridge/mux/herdr/client.ts b/bridge/mux/herdr/client.ts
index de05a684..8196661b 100644
--- a/bridge/mux/herdr/client.ts
+++ b/bridge/mux/herdr/client.ts
@@ -15,6 +15,15 @@ import { decodeReplyLine, decodeStreamLine, type EventData } from "../../wire.ts
// ─────────────────────────────────────────────────────────────────────────────
/** Raw wire shape of a workspace from `workspace.list`. */
+/** The repo a workspace sits in, as Herdr reports it on the workspace record itself. */
+export interface WireWorkspaceWorktree {
+ repo_root: string;
+ repo_name?: string;
+ repo_key?: string;
+ checkout_path?: string;
+ is_linked_worktree?: boolean;
+}
+
export interface WireWorkspace {
workspace_id: string;
number: number;
@@ -24,6 +33,25 @@ export interface WireWorkspace {
tab_count: number;
active_tab_id: string;
agent_status: AgentStatus;
+ /** Present when the workspace sits in a Git work tree — probed 2026-08-28 on herdr 0.8.2. */
+ worktree?: WireWorkspaceWorktree | null;
+}
+
+/**
+ * Raw wire shape of a worktree from `worktree.list`.
+ *
+ * `open_workspace_id` is absent (not null) when nothing shows the checkout — probed 2026-08-28
+ * against herdr 0.8.2, where closing the workspace dropped the key entirely.
+ */
+export interface WireWorktree {
+ path: string;
+ branch?: string | null;
+ label?: string;
+ is_linked_worktree: boolean;
+ is_prunable: boolean;
+ is_bare: boolean;
+ is_detached: boolean;
+ open_workspace_id?: string | null;
}
/** Raw wire shape of a tab from `tab.list`. */
@@ -198,6 +226,9 @@ export type HerdrRpc = Pick<
| "renameTab"
| "closeTab"
| "createWorkspace"
+ | "listWorktrees"
+ | "createWorktree"
+ | "openWorktree"
| "subscribeEvents"
>;
@@ -501,6 +532,73 @@ export class HerdrClient {
};
}
+ /**
+ * The worktrees of the repo at `cwd` — including the repo's own checkout, which comes back with
+ * `is_linked_worktree: false`.
+ *
+ * Takes a PATH, not a workspace: probed 2026-08-28 against herdr 0.8.2, `worktree.list --cwd`
+ * answers for a repo nothing has open, which is what lets the sheet list a worktree before there
+ * is any space to name it by.
+ */
+ async listWorktrees(cwd: string): Promise {
+ const r = await this.request<{ worktrees: WireWorktree[] }>("worktree.list", { cwd });
+ return r.worktrees;
+ }
+
+ /**
+ * Create a worktree on a new branch and open it as its own workspace. `focus:false`, so the
+ * operator's own screen stays where they left it (the phone navigates itself).
+ *
+ * NOT ATOMIC, and the caller must know: probed 2026-08-28 against herdr 0.8.2 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. A retry then fails as `worktree_create_failed` (the path is
+ * taken), so the recovery is to open it, never to create it again.
+ */
+ async createWorktree(opts: { cwd: string; branch: string }): Promise {
+ const r = await this.request<{ workspace: WireWorkspace; root_pane: WirePane }>(
+ "worktree.create",
+ { cwd: opts.cwd, branch: opts.branch, focus: false },
+ );
+ const p = r.root_pane;
+ return {
+ paneId: p.pane_id,
+ workspaceId: p.workspace_id,
+ workspaceLabel: r.workspace.label,
+ tabId: p.tab_id,
+ cwd: p.cwd,
+ };
+ }
+
+ /**
+ * Show a worktree that already exists on disk, by its checkout path.
+ *
+ * `cwd` (the repo) is required alongside it: probed 2026-08-28, `worktree.open` with `path` alone
+ * answers `not_git_worktree`. A worktree already open answers `already_open: true` with the space
+ * showing it — an answer, not a refusal.
+ */
+ async openWorktree(opts: {
+ cwd: string;
+ path: string;
+ }): Promise<{ shell: CreatedShell; alreadyOpen: boolean }> {
+ const r = await this.request<{
+ workspace: WireWorkspace;
+ root_pane: WirePane;
+ already_open?: boolean;
+ }>("worktree.open", { cwd: opts.cwd, path: opts.path, focus: false });
+ const p = r.root_pane;
+ return {
+ shell: {
+ paneId: p.pane_id,
+ workspaceId: p.workspace_id,
+ workspaceLabel: r.workspace.label,
+ tabId: p.tab_id,
+ cwd: p.cwd,
+ },
+ alreadyOpen: r.already_open === true,
+ };
+ }
+
+
async readPane(
paneId: string,
source: ReadSource,
diff --git a/bridge/mux/herdr/fixture.ts b/bridge/mux/herdr/fixture.ts
index ea42da26..9b96184b 100644
--- a/bridge/mux/herdr/fixture.ts
+++ b/bridge/mux/herdr/fixture.ts
@@ -36,6 +36,7 @@ import type {
WireSnapshot,
WireTab,
WireWorkspace,
+ WireWorktree,
} from "./client.ts";
const IDLE: AgentStatus = "idle";
@@ -77,6 +78,8 @@ export class FakeHerdr implements HerdrRpc {
private readonly screens = new Map();
private readonly subscribers = new Set();
private readonly recorded: MuxWrite[] = [];
+ /** Worktrees per repo root — the bookkeeping herdr keeps and a plain `git worktree add` does not. */
+ private readonly worktreesByRepo = new Map();
/** Never decreases, and never reused — so a fresh pane can never land on a dead pane's id. */
private minted = 0;
/** False while the adapter's connection is down: every RPC rejects, as a closed socket does. */
@@ -298,6 +301,83 @@ export class FakeHerdr implements HerdrRpc {
};
}
+ // ── Worktrees ──────────────────────────────────────────────────────────────
+ //
+ // Modelled, not stubbed, because conformance EXERCISES every declared capability: the fake keeps a
+ // per-repo list, so create → list → open → remove tells the same story the real socket told when
+ // it was probed (2026-08-28, herdr 0.8.2). Two behaviours are copied deliberately because the
+ // adapter's contract leans on them: opening what is already open answers `already_open` instead of
+ // refusing, and removal is addressed by WORKSPACE, so a checkout nothing shows cannot be removed.
+
+ async listWorktrees(cwd: string): Promise {
+ this.assertConnected("worktree.list");
+ const repo = this.worktreesByRepo.get(cwd) ?? [];
+ return [
+ {
+ path: cwd,
+ branch: "main",
+ is_linked_worktree: false,
+ is_prunable: false,
+ is_bare: false,
+ is_detached: false,
+ },
+ ...repo,
+ ];
+ }
+
+ async createWorktree(opts: { cwd: string; branch: string }): Promise {
+ this.assertConnected("worktree.create");
+ const repo = this.worktreesByRepo.get(opts.cwd) ?? [];
+ const path = `${opts.cwd}/.worktrees/${opts.branch.replace(/\//g, "-")}`;
+ if (repo.some((w) => w.path === path)) {
+ throw new Error(`herdr worktree.create: worktree_create_failed: '${path}' already exists`);
+ }
+ const shell = await this.createWorkspace({ cwd: path, label: opts.branch });
+ repo.push({
+ path,
+ branch: opts.branch,
+ is_linked_worktree: true,
+ is_prunable: false,
+ is_bare: false,
+ is_detached: false,
+ open_workspace_id: shell.workspaceId,
+ });
+ this.worktreesByRepo.set(opts.cwd, repo);
+ return shell;
+ }
+
+ async openWorktree(opts: {
+ cwd: string;
+ path: string;
+ }): Promise<{ shell: CreatedShell; alreadyOpen: boolean }> {
+ this.assertConnected("worktree.open");
+ const repo = this.worktreesByRepo.get(opts.cwd) ?? [];
+ const found = repo.find((w) => w.path === opts.path);
+ if (found === undefined) {
+ throw new Error(`herdr worktree.open: worktree_not_found: ${opts.path}`);
+ }
+ if (found.open_workspace_id != null) {
+ const pane = this.panes.find((candidate) => candidate.workspace_id === found.open_workspace_id);
+ const workspace = this.workspaces.find((w) => w.workspace_id === found.open_workspace_id);
+ if (pane !== undefined && workspace !== undefined) {
+ return {
+ shell: {
+ paneId: pane.pane_id,
+ workspaceId: workspace.workspace_id,
+ workspaceLabel: workspace.label,
+ tabId: pane.tab_id,
+ cwd: pane.cwd,
+ },
+ alreadyOpen: true,
+ };
+ }
+ }
+ const shell = await this.createWorkspace({ cwd: found.path, label: found.branch ?? "worktree" });
+ found.open_workspace_id = shell.workspaceId;
+ return { shell, alreadyOpen: false };
+ }
+
+
subscribeEvents(opts: SubscribeOptions): EventStream {
const subscriber: Subscriber = { opts, down: false };
this.subscribers.add(subscriber);
diff --git a/bridge/mux/registry.test.ts b/bridge/mux/registry.test.ts
index 7fae3ac7..71b6f7a8 100644
--- a/bridge/mux/registry.test.ts
+++ b/bridge/mux/registry.test.ts
@@ -30,6 +30,9 @@ function stubAdapter(mux: string, target: MuxTarget): MuxAdapter {
renamePane: () => Promise.resolve(muxUnsupported("renamePane", "stub")),
closePane: () => Promise.resolve(muxUnsupported("closePane", "stub")),
setFocus: () => Promise.resolve(muxUnsupported("setFocus", "stub")),
+ listWorktrees: () => Promise.resolve(muxUnsupported("listWorktrees", "stub")),
+ createWorktree: () => Promise.resolve(muxUnsupported("createWorktree", "stub")),
+ openWorktree: () => Promise.resolve(muxUnsupported("openWorktree", "stub")),
createTab: () => Promise.resolve(muxUnsupported("createTab", "stub")),
renameTab: () => Promise.resolve(muxUnsupported("renameTab", "stub")),
closeTab: () => Promise.resolve(muxUnsupported("closeTab", "stub")),
diff --git a/bridge/mux/tmux/adapter.ts b/bridge/mux/tmux/adapter.ts
index a0f13018..a06ad611 100644
--- a/bridge/mux/tmux/adapter.ts
+++ b/bridge/mux/tmux/adapter.ts
@@ -55,6 +55,7 @@ import {
muxGone,
muxOk,
muxRefused,
+ muxUnsupported,
muxUnreachable,
type MuxAck,
type MuxAdapter,
@@ -70,6 +71,11 @@ import {
type MuxSubscription,
type MuxTab,
type MuxTabRequest,
+ type MuxWorktree,
+ type MuxWorktreeCreateRequest,
+ type MuxWorktreeOpenRequest,
+ type MuxWorktreeOpened,
+ type MuxWorktreeScope,
type MuxWatchOptions,
} from "../types.ts";
import {
@@ -482,6 +488,28 @@ export class TmuxMux implements MuxAdapter {
return this.created(args);
}
+ // ── Worktrees: declined, and why ───────────────────────────────────────────
+ //
+ // tmux knows nothing about Git. `git worktree add` would run fine on this host — but these verbs
+ // do not promise a checkout, they promise a checkout WITH the session showing it, and removal is
+ // addressed by space precisely because the multiplexer must close what it opened. tmux keeps no
+ // such record, so a `createWorktree` here could not answer `openSpaceId`, and `removeWorktree`
+ // would have nothing to name. Declining is the honest answer until an adapter keeps that mapping
+ // itself and a probe proves it (MUX_CONTRIBUTING.md, "probe first, declare second").
+
+ listWorktrees(_scope: MuxWorktreeScope): Promise> {
+ return Promise.resolve(muxUnsupported("listWorktrees", "tmux keeps no record tying a Git checkout to the session showing it, so a worktree opened here could not be found, listed or removed again"));
+ }
+
+ createWorktree(_request: MuxWorktreeCreateRequest): Promise> {
+ return Promise.resolve(muxUnsupported("createWorktree", "tmux keeps no record tying a Git checkout to the session showing it, so a worktree opened here could not be found, listed or removed again"));
+ }
+
+ openWorktree(_request: MuxWorktreeOpenRequest): Promise> {
+ return Promise.resolve(muxUnsupported("openWorktree", "tmux keeps no record tying a Git checkout to the session showing it, so a worktree opened here could not be found, listed or removed again"));
+ }
+
+
/** The contract's watch over control mode plus a bounded listing. All of it lives in watch.ts. */
watch(options: MuxWatchOptions): MuxSubscription {
const subscription = new TmuxWatch(this.exec, options);
diff --git a/bridge/mux/types.ts b/bridge/mux/types.ts
index cea7286f..db06c23f 100644
--- a/bridge/mux/types.ts
+++ b/bridge/mux/types.ts
@@ -234,6 +234,23 @@ export interface MuxSpace {
readonly activeTabId: string;
readonly tabCount: number;
readonly paneCount: number;
+ /**
+ * The root of the Git repo this space sits in, when the multiplexer knows one.
+ *
+ * A FACT, declared like {@link MuxCapabilityDeclaration.spaces} and for the same reason: it
+ * answers "which repo", never "can you". An adapter that keeps no repo mapping omits it, and
+ * omission is the honest fail-closed direction — no repo, no worktree rows, and nothing had to
+ * guess. It is here rather than derived from a pane's cwd because deriving it would mean Collie
+ * walking the filesystem for `.git`, which is exactly the Git work ADR 0032 keeps out of the port.
+ */
+ readonly repoRoot?: string;
+ /**
+ * Whether this space is a LINKED worktree of {@link repoRoot} rather than the repo's own checkout.
+ *
+ * Absent wherever `repoRoot` is: the pair travels together, and asking one without the other is
+ * always a bug. `false` means "this is the repo itself", which is what a worktree row nests under.
+ */
+ readonly isWorktree?: boolean;
}
/** One tab within a space — a layout holding one or more panes. */
@@ -323,6 +340,53 @@ export interface MuxSpaceRequest {
readonly label?: string;
}
+// ── Worktrees ─────────────────────────────────────────────────────────────────
+//
+// A worktree is Git's, not the multiplexer's — so why is it here? Because the ACT is the
+// multiplexer's: every verb below ends in a space appearing, moving or going away, which is the one
+// thing a mux adapter owns. What a multiplexer may not have is the BOOKKEEPING that ties a checkout
+// to the space showing it; that is what these capabilities declare. See ADR 0032.
+
+/** A Git worktree of the repo a space sits in. */
+export interface MuxWorktree {
+ /** Absolute checkout path. The identity: a branch may be absent, and labels repeat. */
+ readonly path: string;
+ /** The branch checked out there, or `null` for a detached head. */
+ readonly branch: string | null;
+ /** The space showing it, or `null` when it exists on disk and nothing shows it. */
+ readonly openSpaceId: string | null;
+ /** `false` for the repo's own checkout — listed for context, never removable. */
+ readonly linked: boolean;
+ /** The checkout is gone and the administrative files could be pruned. */
+ readonly prunable: boolean;
+}
+
+/** Where a worktree question is asked from — the repo the asking space sits in. */
+export interface MuxWorktreeScope {
+ readonly repoRoot: string;
+}
+
+/** What a new worktree asks for. */
+export interface MuxWorktreeCreateRequest extends MuxWorktreeScope {
+ readonly branch: string;
+}
+
+/** Which existing worktree to show. */
+export interface MuxWorktreeOpenRequest extends MuxWorktreeScope {
+ readonly path: string;
+}
+
+/**
+ * Opening one either made a space or found the space already showing it.
+ *
+ * `alreadyOpen` is not an error and must not be rendered as one: asking for a worktree that is
+ * already up is the operator saying "take me there", and the pane below is where to go.
+ */
+export interface MuxWorktreeOpened {
+ readonly pane: MuxCreatedPane;
+ readonly alreadyOpen: boolean;
+}
+
// ── Learning that something changed ───────────────────────────────────────────
/**
@@ -489,6 +553,15 @@ export interface MuxAdapter {
/** New space, opening a fresh shell. Needs `createSpace`. */
createSpace(request: MuxSpaceRequest): Promise>;
+ /** The worktrees of the repo a space sits in. Needs `listWorktrees`. */
+ listWorktrees(scope: MuxWorktreeScope): Promise>;
+
+ /** New worktree on a new branch, opened as a space. Needs `createWorktree`. */
+ createWorktree(request: MuxWorktreeCreateRequest): Promise>;
+
+ /** Show an existing worktree as a space. Needs `openWorktree`. */
+ openWorktree(request: MuxWorktreeOpenRequest): Promise>;
+
/** Watch for change. Always available — an adapter with no push satisfies it by polling. */
watch(options: MuxWatchOptions): MuxSubscription;
}
diff --git a/bridge/mux/zellij/adapter.ts b/bridge/mux/zellij/adapter.ts
index d963f1e1..0c5c173a 100644
--- a/bridge/mux/zellij/adapter.ts
+++ b/bridge/mux/zellij/adapter.ts
@@ -83,6 +83,11 @@ import {
type MuxSubscription,
type MuxTab,
type MuxTabRequest,
+ type MuxWorktree,
+ type MuxWorktreeCreateRequest,
+ type MuxWorktreeOpenRequest,
+ type MuxWorktreeOpened,
+ type MuxWorktreeScope,
type MuxWatchOptions,
} from "../types.ts";
import { resolveZellijBinary, SpawnZellijExec } from "./exec.ts";
@@ -482,6 +487,25 @@ export class ZellijMux implements MuxAdapter {
);
}
+ // ── Worktrees: declined, and why ───────────────────────────────────────────
+ //
+ // Two reasons, and the first alone settles it: a Collie on zellij drives exactly ONE session, so
+ // there is no second space for a worktree to be opened as — the same fact that declines
+ // `createSpace`. The second is tmux's: zellij keeps no record tying a checkout to what shows it.
+
+ listWorktrees(_scope: MuxWorktreeScope): Promise> {
+ return Promise.resolve(muxUnsupported("listWorktrees", "a Collie on zellij drives exactly one session, so a worktree has no second space to be opened as"));
+ }
+
+ createWorktree(_request: MuxWorktreeCreateRequest): Promise> {
+ return Promise.resolve(muxUnsupported("createWorktree", "a Collie on zellij drives exactly one session, so a worktree has no second space to be opened as"));
+ }
+
+ openWorktree(_request: MuxWorktreeOpenRequest): Promise> {
+ return Promise.resolve(muxUnsupported("openWorktree", "a Collie on zellij drives exactly one session, so a worktree has no second space to be opened as"));
+ }
+
+
/** The contract's watch over the pane stream plus a bounded census. All of it lives in watch.ts. */
watch(options: MuxWatchOptions): MuxSubscription {
const subscription = new ZellijWatch(this.session, options);
diff --git a/bridge/server.test.ts b/bridge/server.test.ts
index 5017a250..bf298e80 100644
--- a/bridge/server.test.ts
+++ b/bridge/server.test.ts
@@ -1601,9 +1601,10 @@ describe("the host gate — `?host=` selects among enrolled members and nothing
// The load-bearing claim: `?h=laptop` + `w1:p1` must never be served the DESK's `w1:p1`, and
// pane ids collide across machines, so a fall-through here is a cross-host write.
//
- // All FIVE session-scoped routes (tab create, workspace create, tab action, the pane family,
- // and "look now") reach their runtime through the caller's resolver and nothing else.
- expect([...src.matchAll(/await caller\.resolve\(\);/g)]).toHaveLength(5);
+ // All SEVEN session-scoped routes (tab create, workspace create, tab action, the pane family,
+ // "look now", the worktree listing and the worktree actions) reach their runtime through the
+ // caller's resolver and nothing else.
+ expect([...src.matchAll(/await caller\.resolve\(\);/g)]).toHaveLength(7);
// Exactly five `registry.get(` calls remain, and each is a sanctioned one, named here rather
// than exempted: assembling THIS collie's own snapshot body; `localRuntime`, the single
// "(session) → runtime, or 404" helper both callers share; `/api/config`, which reports THIS
diff --git a/bridge/server.ts b/bridge/server.ts
index b9c37480..902ae308 100644
--- a/bridge/server.ts
+++ b/bridge/server.ts
@@ -52,6 +52,8 @@ import type {
AgentView,
BridgeConfig,
CreateResponse,
+ WorktreeListResponse,
+ WorktreeOpenResponse,
DeviceAuth,
OperatorCommand,
MuxConfig,
@@ -163,6 +165,15 @@ const MAX_HISTORY_LIMIT = 5000;
// (create) is an exact match on `/api/tab`, so it never collides with this `/api/tab//`.
const TAB_ACTION_ROUTE = /^\/api\/tab\/([^/]+)\/(rename|close)$/;
+/**
+ * Worktree routes, all hung off the SPACE that asked (ADR 0032).
+ *
+ * The space is the repo context — its `repoRoot` comes off the snapshot Herdr already sends — so no
+ * route takes a path to a repo, only the checkout path inside one.
+ */
+const WORKTREE_LIST_ROUTE = /^\/api\/workspace\/([^/]+)\/worktrees$/;
+const WORKTREE_ACTION_ROUTE = /^\/api\/workspace\/([^/]+)\/worktree(?:\/(open))?$/;
+
/**
* Header the web app sets on its own pane reads, and the ONLY thing that lets a read mark a pane
* seen. See {@link marksPaneSeen} for why a header, of all things, is the check.
@@ -625,6 +636,28 @@ export function startServer(opts: {
return createWorkspace(rt.herdr, rt.engine, req, caller.audit, caller.device(), rt.name);
}
+ // ── Worktrees: list / create / open / remove, all scoped to a space (ADR 0032) ──
+ const worktreeListMatch = pathname.match(WORKTREE_LIST_ROUTE);
+ if (worktreeListMatch && req.method === "GET") {
+ const rt = await caller.resolve();
+ if (rt instanceof Response) return rt;
+ return listWorktrees(rt.herdr, rt.engine, decodeURIComponent(worktreeListMatch[1]!), req);
+ }
+ const worktreeMatch = pathname.match(WORKTREE_ACTION_ROUTE);
+ if (worktreeMatch && req.method === "POST") {
+ const denied = caller.gate("write");
+ if (denied) return denied;
+ const rt = await caller.resolve();
+ if (rt instanceof Response) return rt;
+ const spaceId = decodeURIComponent(worktreeMatch[1]!);
+ const action = worktreeMatch[2];
+ const device = caller.device();
+ if (action === "open") {
+ return openWorktree(rt.herdr, rt.engine, spaceId, req, caller.audit, device, rt.name);
+ }
+ return createWorktree(rt.herdr, rt.engine, spaceId, req, caller.audit, device, rt.name);
+ }
+
// ── Tab actions: rename (set its label) / close (kill it + every pane in it) ──
const tabMatch = pathname.match(TAB_ACTION_ROUTE);
if (tabMatch && req.method === "POST") {
@@ -1995,6 +2028,217 @@ async function createWorkspace(
} satisfies CreateResponse, ae);
}
+// ── Worktrees ────────────────────────────────────────────────────────────────
+//
+// Every route is scoped to a SPACE, and the space is how the repo is known: `repoRoot` rides on the
+// snapshot Herdr already sends, so nothing here walks a filesystem looking for `.git` (ADR 0032).
+
+/** The repo a space sits in, or a 400 saying it sits in none. */
+function repoRootOf(engine: StateEngine, spaceId: string): string | null {
+ const space = engine.current().workspaces.find((w) => w.workspaceId === spaceId);
+ return space?.repoRoot ?? null;
+}
+
+/**
+ * Which catalogued code a worktree refusal is.
+ *
+ * Only two refusals change what the phone DOES — a busy multiplexer means try again, an ambiguous
+ * branch means type a better one. Everything else is shown, so it shares one code per verb and
+ * carries the multiplexer's own sentence in `{reason}` (bridge/error-codes.ts: "a template that is
+ * only {reason} is not a mistake").
+ */
+function worktreeCode(detail: string, fallback: ErrorCode): ErrorCode {
+ if (detail.includes("worktree_operation_in_progress")) return "worktree.busy";
+ if (detail.includes("ambiguous_worktree_branch")) return "worktree.ambiguous_branch";
+ if (detail.includes("not_git_worktree")) return "worktree.not_a_repo";
+ return fallback;
+}
+
+async function listWorktrees(
+ herdr: MuxAdapter,
+ engine: StateEngine,
+ spaceId: string,
+ req: Request,
+): Promise {
+ const ae = req.headers.get("accept-encoding");
+ const repoRoot = repoRootOf(engine, spaceId);
+ if (repoRoot === null) {
+ return json(
+ {
+ ok: false,
+ ...apiError("worktree.not_a_repo", { reason: "this space is not in a Git work tree" }),
+ } satisfies WorktreeListResponse,
+ ae,
+ );
+ }
+ const outcome = await herdr.listWorktrees({ repoRoot });
+ if (!outcome.ok) {
+ return json(
+ {
+ ok: false,
+ ...apiError(worktreeCode(outcome.detail, "worktree.list_failed"), { reason: outcome.detail }),
+ } satisfies WorktreeListResponse,
+ ae,
+ );
+ }
+ return json(
+ {
+ ok: true,
+ worktrees: outcome.value.map((w) => ({
+ path: w.path,
+ branch: w.branch,
+ openWorkspaceId: w.openSpaceId,
+ linked: w.linked,
+ prunable: w.prunable,
+ })),
+ } satisfies WorktreeListResponse,
+ ae,
+ );
+}
+
+async function createWorktree(
+ herdr: MuxAdapter,
+ engine: StateEngine,
+ spaceId: string,
+ req: Request,
+ audit: AuditLog,
+ device: string | null,
+ session: string,
+): Promise {
+ const ae = req.headers.get("accept-encoding");
+ let body: JsonValue;
+ try {
+ // SAFETY: `Request.json()` output IS a JsonValue by construction; every field is checked below.
+ body = (await req.json()) as JsonValue;
+ } catch {
+ return text("bad body", 400);
+ }
+ const fields = asJsonRecord(body) ?? {};
+ const branch = typeof fields.branch === "string" ? fields.branch.trim() : "";
+ if (branch === "") {
+ return json(
+ { ok: false, ...apiError("worktree.branch_required", {}) } satisfies WorktreeOpenResponse,
+ ae,
+ );
+ }
+ const repoRoot = repoRootOf(engine, spaceId);
+ if (repoRoot === null) {
+ return json(
+ {
+ ok: false,
+ ...apiError("worktree.not_a_repo", { reason: "this space is not in a Git work tree" }),
+ } satisfies WorktreeOpenResponse,
+ ae,
+ );
+ }
+ const outcome = await herdr.createWorktree({ repoRoot, branch });
+ if (!outcome.ok) {
+ // The half-done case gets its OWN code, because the recovery is the opposite one: the branch is
+ // on disk and only the opening failed, so the phone must offer "open it", never "create it
+ // again" (a second create refuses — the path is taken). Probed on herdr 0.8.2, 2026-08-28.
+ const halfDone = outcome.detail.includes("worktree_open_failed");
+ return json(
+ {
+ ok: false,
+ ...apiError(
+ halfDone ? "worktree.created_not_opened" : worktreeCode(outcome.detail, "worktree.create_failed"),
+ { reason: outcome.detail },
+ ),
+ } satisfies WorktreeOpenResponse,
+ ae,
+ );
+ }
+ const created = outcome.value;
+ audit.record({
+ action: "worktree.create",
+ paneId: created.paneId,
+ session,
+ device,
+ detail: { branch, repoRoot },
+ });
+ await settleTopology(herdr, engine);
+ return json(
+ {
+ ok: true,
+ alreadyOpen: false,
+ pane: {
+ paneId: created.paneId,
+ workspaceId: created.spaceId,
+ workspaceLabel: created.spaceLabel,
+ tabId: created.tabId,
+ cwd: created.cwd,
+ },
+ } satisfies WorktreeOpenResponse,
+ ae,
+ );
+}
+
+async function openWorktree(
+ herdr: MuxAdapter,
+ engine: StateEngine,
+ spaceId: string,
+ req: Request,
+ audit: AuditLog,
+ device: string | null,
+ session: string,
+): Promise {
+ const ae = req.headers.get("accept-encoding");
+ let body: JsonValue;
+ try {
+ // SAFETY: as createWorktree — checked below, never trusted as declared.
+ body = (await req.json()) as JsonValue;
+ } catch {
+ return text("bad body", 400);
+ }
+ const fields = asJsonRecord(body) ?? {};
+ const path = typeof fields.path === "string" ? fields.path.trim() : "";
+ if (path === "") return text("bad body", 400);
+ const repoRoot = repoRootOf(engine, spaceId);
+ if (repoRoot === null) {
+ return json(
+ {
+ ok: false,
+ ...apiError("worktree.not_a_repo", { reason: "this space is not in a Git work tree" }),
+ } satisfies WorktreeOpenResponse,
+ ae,
+ );
+ }
+ const outcome = await herdr.openWorktree({ repoRoot, path });
+ if (!outcome.ok) {
+ return json(
+ {
+ ok: false,
+ ...apiError(worktreeCode(outcome.detail, "worktree.open_failed"), { reason: outcome.detail }),
+ } satisfies WorktreeOpenResponse,
+ ae,
+ );
+ }
+ const { pane, alreadyOpen } = outcome.value;
+ audit.record({
+ action: "worktree.open",
+ paneId: pane.paneId,
+ session,
+ device,
+ detail: { path, alreadyOpen: String(alreadyOpen) },
+ });
+ await settleTopology(herdr, engine);
+ return json(
+ {
+ ok: true,
+ alreadyOpen,
+ pane: {
+ paneId: pane.paneId,
+ workspaceId: pane.spaceId,
+ workspaceLabel: pane.spaceLabel,
+ tabId: pane.tabId,
+ cwd: pane.cwd,
+ },
+ } satisfies WorktreeOpenResponse,
+ ae,
+ );
+}
+
+
// Save an uploaded image to a host file and return its absolute path. The client then references
// that path in a message; Claude Code / Codex read images by path (the terminal can't take a
// pasted image over the socket). Validated by MIME and size; the filename is server-generated.
diff --git a/bridge/solo-baseline.test.ts b/bridge/solo-baseline.test.ts
index 1055b425..02f16532 100644
--- a/bridge/solo-baseline.test.ts
+++ b/bridge/solo-baseline.test.ts
@@ -319,6 +319,8 @@ const WORKSPACE_KEYS = {
activeTabId: true,
tabCount: true,
paneCount: true,
+ repoRoot: true,
+ isWorktree: true,
} satisfies Record;
const TAB_KEYS = {
@@ -400,9 +402,11 @@ describe("solo zero-tax — wire shapes carry no pack dimension", () => {
expect(Object.keys(WORKSPACE_KEYS).toSorted()).toEqual([
"activeTabId",
"focused",
+ "isWorktree",
"label",
"number",
"paneCount",
+ "repoRoot",
"tabCount",
"workspaceId",
]);
@@ -507,6 +511,8 @@ describe("solo zero-tax — routes", () => {
// the reason every other one is: a route arrives on purpose or it does not arrive.
"/^\\/api\\/pane\\/([^/]+)(?:\\/(reply|keys|upload|close|rename|history|focus))?$/",
"/^\\/api\\/tab\\/([^/]+)\\/(rename|close)$/",
+ "/^\\/api\\/workspace\\/([^/]+)\\/worktree(?:\\/(open))?$/",
+ "/^\\/api\\/workspace\\/([^/]+)\\/worktrees$/",
"/api/config",
// Device pairing (bridge/pairing.ts) — a SOLO feature that legitimately extends this list.
// It is named here, not exempted: the guard's job is that a route arrives on purpose.
diff --git a/bridge/state-engine.ts b/bridge/state-engine.ts
index 0ae7824e..bca832a2 100644
--- a/bridge/state-engine.ts
+++ b/bridge/state-engine.ts
@@ -360,15 +360,25 @@ export class StateEngine {
.toSorted((a, b) => a.workspaceNumber - b.workspaceNumber || a.paneId.localeCompare(b.paneId));
const workspaceViews: WorkspaceView[] = spaces
- .map((s) => ({
- workspaceId: s.spaceId,
- number: s.number,
- label: s.label,
- focused: s.focused,
- activeTabId: s.activeTabId,
- tabCount: s.tabCount,
- paneCount: s.paneCount,
- }))
+ .map((s) => {
+ const view: WorkspaceView = {
+ workspaceId: s.spaceId,
+ number: s.number,
+ label: s.label,
+ focused: s.focused,
+ activeTabId: s.activeTabId,
+ tabCount: s.tabCount,
+ paneCount: s.paneCount,
+ };
+ // Assigned only when there is one, so a space outside a repo carries no key at all: adding
+ // `repoRoot` to every space would move every snapshot ETag once for nothing (the argument
+ // bridge/types.ts makes about `pack`, applied here).
+ if (s.repoRoot !== undefined) {
+ view.repoRoot = s.repoRoot;
+ view.isWorktree = s.isWorktree === true;
+ }
+ return view;
+ })
.toSorted((a, b) => a.number - b.number);
const tabViews: TabView[] = tabs.map((t) => ({
diff --git a/bridge/types.ts b/bridge/types.ts
index debddc74..545cb2ba 100644
--- a/bridge/types.ts
+++ b/bridge/types.ts
@@ -150,6 +150,20 @@ export interface WorkspaceView {
activeTabId: string;
tabCount: number;
paneCount: number;
+ /**
+ * The Git repo this space sits in, when the multiplexer reports one (MuxSpace.repoRoot).
+ *
+ * Absent means "no repo here, or this multiplexer does not keep the mapping" — the phone reads
+ * absence as "no worktree rows", which is the fail-closed direction and needs no extra call.
+ */
+ repoRoot?: string;
+ /**
+ * Whether this space is a linked worktree of `repoRoot` rather than the repo's own checkout.
+ *
+ * Travels with `repoRoot` and is absent wherever that is. It is what lets the spaces list nest a
+ * worktree under the space showing the repo itself.
+ */
+ isWorktree?: boolean;
}
/** A tab within a workspace (a layout/view holding one or more panes). From `tab.list`. */
@@ -442,6 +456,41 @@ export type CreateResponse =
| { ok: true; pane: CreatedPane }
| { ok: false; error: string; code?: ErrorCode; detail?: ApiErrorDetail };
+/** One Git worktree of the repo a space sits in (ADR 0032). */
+export interface WorktreeView {
+ /** Absolute checkout path — the identity, and what `open` is asked with. */
+ path: string;
+ /** The branch checked out there; `null` for a detached head. */
+ branch: string | null;
+ /**
+ * The space showing it, or `null` when it exists on disk and nothing does.
+ *
+ * `null` is what hides the Remove row: removal is addressed by space, so a checkout nothing shows
+ * cannot be removed from the phone at all.
+ */
+ openWorkspaceId: string | null;
+ /** `false` for the repo's own checkout, which is listed for context and is never removable. */
+ linked: boolean;
+ /** The multiplexer believes the checkout is gone and its administrative files could be pruned. */
+ prunable: boolean;
+}
+
+/** GET /api/workspace/:id/worktrees — the worktrees of the repo that space sits in. */
+export type WorktreeListResponse =
+ | { ok: true; worktrees: WorktreeView[] }
+ | { ok: false; error: string; code?: ErrorCode; detail?: ApiErrorDetail };
+
+/**
+ * POST /api/workspace/:id/worktree[/open] — the space now showing the checkout.
+ *
+ * `alreadyOpen` says the space was already there, which is an ANSWER and not a failure: the phone
+ * navigates to `pane` either way.
+ */
+export type WorktreeOpenResponse =
+ | { ok: true; pane: CreatedPane; alreadyOpen: boolean }
+ | { ok: false; error: string; code?: ErrorCode; detail?: ApiErrorDetail };
+
+
/**
* Which role this collie plays in a pack (PACK_PROTOCOL.md §3). `solo` is a lead with zero peers —
* today's Collie, exactly — and is the only mode that needs no configuration whatsoever.
diff --git a/web/src/components/new-space-sheet.tsx b/web/src/components/new-space-sheet.tsx
index 780ed2e3..14b371c7 100644
--- a/web/src/components/new-space-sheet.tsx
+++ b/web/src/components/new-space-sheet.tsx
@@ -1,24 +1,75 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { listWorktrees } from "@/lib/api";
+import type { Scope } from "@/lib/scope";
+import type { WorktreeView } from "@/lib/types";
import { BottomSheet } from "@/components/ui/sheet";
import { useHoldReload } from "@/lib/reload-guard";
import { t } from "@/lib/i18n";
import { useLocale } from "@/hooks/use-locale";
+/** A repo the sheet can branch a worktree from — one entry per repo, however many spaces show it. */
+export interface WorktreeRepo {
+ /** The space the worktree call is addressed to (every route is scoped to a space). */
+ workspaceId: string;
+ repoRoot: string;
+ /** What to call it in the picker: the space's own label, which the operator already recognises. */
+ label: string;
+}
+
+/** Stable empty default: a fresh `[]` per render would break referential equality downstream. */
+const NO_REPOS: readonly WorktreeRepo[] = [];
+
interface NewSpaceSheetProps {
open: boolean;
onClose: () => void;
onCreate: (opts: { label?: string; cwd?: string }) => void;
+ /**
+ * The repos a worktree could be branched from. EMPTY means the worktree tab is not offered at
+ * all — either the multiplexer cannot do it, or nothing open sits in a repo. Hiding it beats
+ * showing a tab whose only content would be "no repos".
+ */
+ repos?: readonly WorktreeRepo[];
+ /** Branch a worktree from `workspaceId`. Absent alongside an empty `repos`. */
+ onCreateWorktree?: (workspaceId: string, branch: string) => void;
+ /** Show a worktree that exists on disk but is not open as a space. */
+ onOpenWorktree?: (workspaceId: string, path: string) => void;
+ /** Session scope for the listing read. */
+ scope?: Scope;
}
// Create a new space (workspace). Both fields are optional and dictation-friendly: leave the
// directory blank to open the shell in your home dir (it's a shell — cd from there), or set a path
// for a specific project. The new space opens a fresh shell you launch your own agent in.
-export function NewSpaceSheet({ open, onClose, onCreate }: NewSpaceSheetProps) {
+export function NewSpaceSheet({
+ open,
+ onClose,
+ onCreate,
+ repos = NO_REPOS,
+ onCreateWorktree,
+ onOpenWorktree,
+ scope,
+}: NewSpaceSheetProps) {
useLocale();
const [label, setLabel] = useState("");
const [cwd, setCwd] = useState("");
+ // Which kind of space this will be. Two tabs rather than two entry points: from the spaces list
+ // there is no "current space" to carry a repo, so the worktree side has to ask which repo anyway
+ // — and once it asks, the choice belongs beside the plain one, not behind a second button.
+ const [mode, setMode] = useState<"space" | "worktree">("space");
+ const [branch, setBranch] = useState("");
+ const [repo, setRepo] = useState("");
+ const worktreesOffered = repos.length > 0 && onCreateWorktree !== undefined;
+ /**
+ * Worktrees of the chosen repo that NOTHING is showing.
+ *
+ * The ones that are open are already spaces in the list behind this sheet — offering them again
+ * here would be the same thing under two names. These are the only worktrees the phone has no
+ * other route to, which is exactly why they are here and not in a panel of their own.
+ */
+ const [unopened, setUnopened] = useState([]);
// Don't let a self-update reload yank this tab/space form out from under a half-typed
// directory/label — hold while it's open; the self-updater shows the banner and updates on close.
@@ -28,17 +79,129 @@ export function NewSpaceSheet({ open, onClose, onCreate }: NewSpaceSheetProps) {
if (open) {
setLabel("");
setCwd("");
+ setBranch("");
+ setMode("space");
+ // Default to the first repo, which is the most recently used one: the list arrives in the
+ // spaces list's own order, so the top entry is the repo you were last in.
+ setRepo(repos[0]?.workspaceId ?? "");
}
+ // `repos` is derived per render; keying the reset on `open` alone is deliberate — a poll that
+ // reorders the repos must not wipe a half-typed branch name.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
+ useEffect(() => {
+ if (!open || mode !== "worktree" || repo === "") {
+ setUnopened([]);
+ return;
+ }
+ let live = true;
+ void (async () => {
+ const res = await listWorktrees(repo, scope);
+ // A read the operator asked for by opening this tab — not a poll, so it runs once per repo
+ // choice and never on the list behind it.
+ if (live) setUnopened(res.ok ? res.worktrees.filter((w) => w.linked && w.openWorkspaceId === null) : []);
+ })();
+ return () => {
+ live = false;
+ };
+ }, [open, mode, repo, scope]);
+
function create() {
onCreate({ label: label.trim() || undefined, cwd: cwd.trim() || undefined });
onClose();
}
+ function createWorktree() {
+ const name = branch.trim();
+ if (name === "" || repo === "" || onCreateWorktree === undefined) return;
+ onCreateWorktree(repo, name);
+ onClose();
+ }
+
return (
+ {/* Only where there is a choice to make: one tab is not a tab strip, it is noise. */}
+ {worktreesOffered && (
+
);
diff --git a/web/src/components/space-overview.tsx b/web/src/components/space-overview.tsx
index 88226afa..a16dd58c 100644
--- a/web/src/components/space-overview.tsx
+++ b/web/src/components/space-overview.tsx
@@ -5,7 +5,13 @@ import { cn } from "@/lib/utils";
import { useMuxCapability } from "@/lib/mux-capability";
import { SectionHeader } from "@/components/section-header";
import { StatusDot } from "@/components/status-badge";
-import { filterSpaces, sortSpacesByRecency, spaceLastSeenMap, spaceTriageMap } from "@/lib/spaces";
+import {
+ filterSpaces,
+ nestWorktrees,
+ sortSpacesByRecency,
+ spaceLastSeenMap,
+ spaceTriageMap,
+} from "@/lib/spaces";
import { spaceKey } from "@/lib/hosts";
import { TRIAGE_STATUS } from "@/lib/triage";
import { timeAgo } from "@/lib/format";
@@ -64,6 +70,10 @@ export function SpaceOverview({
const worstBySpace = spaceTriageMap(agents);
const blockedSpaces = [...worstBySpace.values()].filter((b) => b === "needs").length;
const visible = filterSpaces(sortSpacesByRecency(workspaces, panes, lastSeen, host), query);
+ // Worktrees sit under the space holding their repo — but NOT while filtering: a filter that
+ // matched only the child would indent a row under a parent that is not on screen, which reads as
+ // a rendering fault rather than as structure.
+ const rows = query.trim() ? visible.map((space) => ({ space, depth: 0 as const })) : nestWorktrees(visible);
return (
@@ -143,7 +153,7 @@ export function SpaceOverview({
{t("space.overview.empty.noMatch", { query })}
) : (
- visible.map((w) => {
+ rows.map(({ space: w, depth }) => {
// (host, workspaceId): these rows are the lead's spaces, so a peer that happens to
// expose the same workspace id contributes nothing to them.
const key = spaceKey(host, w.workspaceId);
@@ -162,6 +172,9 @@ export function SpaceOverview({
// so it keeps its radius.
"w-full text-left transition-colors active:scale-[0.99]",
!blocked && "hover:bg-muted/50",
+ // A worktree of the space above it. Indented rather than labelled: the nesting
+ // IS the sentence, and a badge would repeat it once per row.
+ depth === 1 && "pl-5",
)}
>
{/* Flat rows, not cards: these are single-line entries, so a card is 100% chrome
@@ -208,3 +221,4 @@ export function SpaceOverview({
);
}
+
diff --git a/web/src/lib/api-error-codes.ts b/web/src/lib/api-error-codes.ts
index d191cbe9..0270a46e 100644
--- a/web/src/lib/api-error-codes.ts
+++ b/web/src/lib/api-error-codes.ts
@@ -54,6 +54,16 @@ export const API_ERROR_CODES = [
"tab.workspace_required",
"workspace.create_failed",
+ // Worktrees — /api/workspace/:id/worktree[s|/open|/remove] (ADR 0032)
+ "worktree.list_failed",
+ "worktree.create_failed",
+ "worktree.created_not_opened",
+ "worktree.open_failed",
+ "worktree.busy",
+ "worktree.ambiguous_branch",
+ "worktree.branch_required",
+ "worktree.not_a_repo",
+
// Image upload — POST /api/pane/:id/upload
"upload.too_large",
"upload.no_file",
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 01923dda..370c9a94 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -22,6 +22,8 @@ import type {
SnapshotResponse,
UpdateInfo,
UploadResponse,
+ WorktreeListResponse,
+ WorktreeOpenResponse,
} from "./types";
export type { NotifyPrefs, UpdateInfo };
@@ -531,6 +533,38 @@ export function createWorkspace(
});
}
+/** The worktrees of the repo a space sits in. Empty-handed when the space is not in one. */
+export function listWorktrees(workspaceId: string, scope?: Scope): Promise {
+ return req(
+ withScope(`/api/workspace/${encodeURIComponent(workspaceId)}/worktrees`, scope),
+ );
+}
+
+/** Create a worktree on a new branch and open it as its own space. */
+export function createWorktree(
+ workspaceId: string,
+ branch: string,
+ scope?: Scope,
+): Promise {
+ return req(
+ withScope(`/api/workspace/${encodeURIComponent(workspaceId)}/worktree`, scope),
+ { method: "POST", body: JSON.stringify({ branch }) },
+ );
+}
+
+/** Show a worktree that already exists. Answers `alreadyOpen` rather than refusing. */
+export function openWorktree(
+ workspaceId: string,
+ path: string,
+ scope?: Scope,
+): Promise {
+ return req(
+ withScope(`/api/workspace/${encodeURIComponent(workspaceId)}/worktree/open`, scope),
+ { method: "POST", body: JSON.stringify({ path }) },
+ );
+}
+
+
/**
* The bridge's startup config: push setup, the build id, the operator's own rows, and the
* multiplexer's declared capabilities (M10/06 — read them through lib/mux-capability.ts, never by
diff --git a/web/src/lib/i18n/messages/de.ts b/web/src/lib/i18n/messages/de.ts
index f66f195f..11543466 100644
--- a/web/src/lib/i18n/messages/de.ts
+++ b/web/src/lib/i18n/messages/de.ts
@@ -672,4 +672,30 @@ export const de: Dictionary = {
"apiError.session.unknown": "Auf diesem collie gibt es keine Sitzung namens {session}.",
"apiError.host.unknown": "In diesem pack gibt es keinen collie namens {host}.",
"apiError.pack.not_lead": "Dieser collie führt kein pack, also gibt es kein pack zu zeigen.",
+ // --- worktrees (ADR 0032) ---
+ "apiError.worktree.list_failed": "Die Worktrees konnten nicht aufgelistet werden: {reason}",
+ "apiError.worktree.create_failed": "Der Worktree konnte nicht erstellt werden: {reason}",
+ "apiError.worktree.created_not_opened": "Der Branch wurde erstellt, aber nichts konnte darauf geöffnet werden: {reason}",
+ "apiError.worktree.open_failed": "Der Worktree konnte nicht geöffnet werden: {reason}",
+ "apiError.worktree.busy": "Ein anderer Worktree-Vorgang läuft noch — gleich noch einmal versuchen.",
+ "apiError.worktree.ambiguous_branch": "Dieser Branch-Name passt auf mehr als eine Sache: {reason}",
+ "apiError.worktree.branch_required": "Gib zuerst einen Branch-Namen ein.",
+ "apiError.worktree.not_a_repo": "Dieser Space liegt in keinem Git-Repository.",
+ "worktree.section": "Worktrees",
+ "worktree.new": "Neuer Worktree",
+ "worktree.branchLabel": "Branch-Name",
+ "worktree.branchPlaceholder": "feature/meine-aenderung",
+ "worktree.branchesFrom": "Zweigt ab von {branch}",
+ "worktree.create": "Erstellen",
+ "worktree.creating": "Wird erstellt…",
+ "worktree.open": "Öffnen",
+ "worktree.opening": "Wird geöffnet…",
+ "worktree.mainCheckout": "das Repository selbst",
+ "worktree.empty": "Noch keine Worktrees.",
+ "worktree.detached": "losgelöst",
+ "worktree.recoverOpen": "Erstellten Branch öffnen",
+ "space.new.tab.plain": "Space",
+ "space.new.tab.worktree": "Worktree",
+ "space.new.repo.label": "Repository",
+ "worktree.orOpenExisting": "Oder einen vorhandenen öffnen",
};
diff --git a/web/src/lib/i18n/messages/en.ts b/web/src/lib/i18n/messages/en.ts
index df6c7cab..ebec62c9 100644
--- a/web/src/lib/i18n/messages/en.ts
+++ b/web/src/lib/i18n/messages/en.ts
@@ -677,6 +677,32 @@ export const en = {
"apiError.session.unknown": "There is no session called {session} on this collie.",
"apiError.host.unknown": "There is no collie called {host} in this pack.",
"apiError.pack.not_lead": "This collie doesn't lead a pack, so there is no pack to show.",
+ // --- worktrees (ADR 0032) ---
+ "apiError.worktree.list_failed": "The worktrees couldn't be listed: {reason}",
+ "apiError.worktree.create_failed": "The worktree couldn't be created: {reason}",
+ "apiError.worktree.created_not_opened": "The branch was created, but nothing could be opened on it: {reason}",
+ "apiError.worktree.open_failed": "The worktree couldn't be opened: {reason}",
+ "apiError.worktree.busy": "Another worktree operation is still running — try again in a moment.",
+ "apiError.worktree.ambiguous_branch": "That branch name matches more than one thing: {reason}",
+ "apiError.worktree.branch_required": "Type a branch name first.",
+ "apiError.worktree.not_a_repo": "This space isn't in a Git repository.",
+ "worktree.section": "Worktrees",
+ "worktree.new": "New worktree",
+ "worktree.branchLabel": "Branch name",
+ "worktree.branchPlaceholder": "feature/my-change",
+ "worktree.branchesFrom": "Branches from {branch}",
+ "worktree.create": "Create",
+ "worktree.creating": "Creating…",
+ "worktree.open": "Open",
+ "worktree.opening": "Opening…",
+ "worktree.mainCheckout": "the repo itself",
+ "worktree.empty": "No worktrees yet.",
+ "worktree.detached": "detached",
+ "worktree.recoverOpen": "Open the branch that was created",
+ "space.new.tab.plain": "Space",
+ "space.new.tab.worktree": "Worktree",
+ "space.new.repo.label": "Repository",
+ "worktree.orOpenExisting": "Or open one that already exists",
} as const;
/** Every key that exists, as a union of string literals. The completeness contract. */
diff --git a/web/src/lib/i18n/messages/es.ts b/web/src/lib/i18n/messages/es.ts
index 5815ab3c..5d67bb47 100644
--- a/web/src/lib/i18n/messages/es.ts
+++ b/web/src/lib/i18n/messages/es.ts
@@ -669,4 +669,30 @@ export const es: Dictionary = {
"apiError.session.unknown": "No hay ninguna sesión llamada {session} en este collie.",
"apiError.host.unknown": "No hay ningún collie llamado {host} en este pack.",
"apiError.pack.not_lead": "Este collie no lidera ningún pack, así que no hay pack que mostrar.",
+ // --- worktrees (ADR 0032) ---
+ "apiError.worktree.list_failed": "No se pudieron listar los worktrees: {reason}",
+ "apiError.worktree.create_failed": "No se pudo crear el worktree: {reason}",
+ "apiError.worktree.created_not_opened": "La rama se creó, pero no se pudo abrir nada en ella: {reason}",
+ "apiError.worktree.open_failed": "No se pudo abrir el worktree: {reason}",
+ "apiError.worktree.busy": "Otra operación de worktree sigue en curso: inténtalo en un momento.",
+ "apiError.worktree.ambiguous_branch": "Ese nombre de rama coincide con más de una cosa: {reason}",
+ "apiError.worktree.branch_required": "Escribe primero un nombre de rama.",
+ "apiError.worktree.not_a_repo": "Este espacio no está en un repositorio Git.",
+ "worktree.section": "Worktrees",
+ "worktree.new": "Nuevo worktree",
+ "worktree.branchLabel": "Nombre de la rama",
+ "worktree.branchPlaceholder": "feature/mi-cambio",
+ "worktree.branchesFrom": "Se ramifica desde {branch}",
+ "worktree.create": "Crear",
+ "worktree.creating": "Creando…",
+ "worktree.open": "Abrir",
+ "worktree.opening": "Abriendo…",
+ "worktree.mainCheckout": "el repositorio en sí",
+ "worktree.empty": "Todavía no hay worktrees.",
+ "worktree.detached": "separado",
+ "worktree.recoverOpen": "Abrir la rama que se creó",
+ "space.new.tab.plain": "Espacio",
+ "space.new.tab.worktree": "Worktree",
+ "space.new.repo.label": "Repositorio",
+ "worktree.orOpenExisting": "O abre uno que ya existe",
};
diff --git a/web/src/lib/i18n/messages/ja.ts b/web/src/lib/i18n/messages/ja.ts
index 3280b455..63bfa502 100644
--- a/web/src/lib/i18n/messages/ja.ts
+++ b/web/src/lib/i18n/messages/ja.ts
@@ -658,4 +658,30 @@ export const ja: Dictionary = {
"apiError.session.unknown": "この collie に {session} というセッションはありません。",
"apiError.host.unknown": "この pack に {host} という collie はありません。",
"apiError.pack.not_lead": "この collie は pack を率いていないため、表示する pack がありません。",
+ // --- worktrees (ADR 0032) ---
+ "apiError.worktree.list_failed": "worktree を一覧できませんでした:{reason}",
+ "apiError.worktree.create_failed": "worktree を作成できませんでした:{reason}",
+ "apiError.worktree.created_not_opened": "ブランチは作成されましたが、その上で何も開けませんでした:{reason}",
+ "apiError.worktree.open_failed": "worktree を開けませんでした:{reason}",
+ "apiError.worktree.busy": "別の worktree 操作が実行中です。少し待ってからもう一度お試しください。",
+ "apiError.worktree.ambiguous_branch": "そのブランチ名は複数に一致します:{reason}",
+ "apiError.worktree.branch_required": "先にブランチ名を入力してください。",
+ "apiError.worktree.not_a_repo": "このスペースは Git リポジトリ内にありません。",
+ "worktree.section": "worktree",
+ "worktree.new": "新しい worktree",
+ "worktree.branchLabel": "ブランチ名",
+ "worktree.branchPlaceholder": "feature/my-change",
+ "worktree.branchesFrom": "{branch} から分岐",
+ "worktree.create": "作成",
+ "worktree.creating": "作成中…",
+ "worktree.open": "開く",
+ "worktree.opening": "オープン中…",
+ "worktree.mainCheckout": "リポジトリ本体",
+ "worktree.empty": "worktree はまだありません。",
+ "worktree.detached": "detached",
+ "worktree.recoverOpen": "作成されたブランチを開く",
+ "space.new.tab.plain": "スペース",
+ "space.new.tab.worktree": "worktree",
+ "space.new.repo.label": "リポジトリ",
+ "worktree.orOpenExisting": "または既存のものを開く",
};
diff --git a/web/src/lib/i18n/messages/ko.ts b/web/src/lib/i18n/messages/ko.ts
index c9c02401..42473b88 100644
--- a/web/src/lib/i18n/messages/ko.ts
+++ b/web/src/lib/i18n/messages/ko.ts
@@ -650,4 +650,30 @@ export const ko: Dictionary = {
"apiError.session.unknown": "이 collie에는 {session} 세션이 없어요.",
"apiError.host.unknown": "이 pack에는 {host}라는 collie가 없어요.",
"apiError.pack.not_lead": "이 collie는 pack을 이끌지 않아서 보여줄 pack이 없어요.",
+ // --- worktrees (ADR 0032) ---
+ "apiError.worktree.list_failed": "worktree 목록을 불러오지 못했습니다: {reason}",
+ "apiError.worktree.create_failed": "worktree를 만들지 못했습니다: {reason}",
+ "apiError.worktree.created_not_opened": "브랜치는 만들어졌지만 그 위에 아무것도 열지 못했습니다: {reason}",
+ "apiError.worktree.open_failed": "worktree를 열지 못했습니다: {reason}",
+ "apiError.worktree.busy": "다른 worktree 작업이 아직 실행 중입니다. 잠시 후 다시 시도하세요.",
+ "apiError.worktree.ambiguous_branch": "그 브랜치 이름은 두 개 이상과 일치합니다: {reason}",
+ "apiError.worktree.branch_required": "먼저 브랜치 이름을 입력하세요.",
+ "apiError.worktree.not_a_repo": "이 스페이스는 Git 저장소 안에 있지 않습니다.",
+ "worktree.section": "Worktree",
+ "worktree.new": "새 worktree",
+ "worktree.branchLabel": "브랜치 이름",
+ "worktree.branchPlaceholder": "feature/my-change",
+ "worktree.branchesFrom": "{branch} 에서 분기",
+ "worktree.create": "만들기",
+ "worktree.creating": "만드는 중…",
+ "worktree.open": "열기",
+ "worktree.opening": "여는 중…",
+ "worktree.mainCheckout": "저장소 자체",
+ "worktree.empty": "아직 worktree가 없습니다.",
+ "worktree.detached": "detached",
+ "worktree.recoverOpen": "생성된 브랜치 열기",
+ "space.new.tab.plain": "스페이스",
+ "space.new.tab.worktree": "Worktree",
+ "space.new.repo.label": "저장소",
+ "worktree.orOpenExisting": "또는 이미 있는 것을 열기",
};
diff --git a/web/src/lib/i18n/messages/zh.ts b/web/src/lib/i18n/messages/zh.ts
index 93144818..00bc4624 100644
--- a/web/src/lib/i18n/messages/zh.ts
+++ b/web/src/lib/i18n/messages/zh.ts
@@ -637,4 +637,30 @@ export const zh: Dictionary = {
"apiError.session.unknown": "此 collie 上没有名为 {session} 的会话。",
"apiError.host.unknown": "此 pack 中没有名为 {host} 的 collie。",
"apiError.pack.not_lead": "此 collie 不是 pack 的 lead,没有可显示的 pack。",
+ // --- worktrees (ADR 0032) ---
+ "apiError.worktree.list_failed": "无法列出 worktree:{reason}",
+ "apiError.worktree.create_failed": "无法创建 worktree:{reason}",
+ "apiError.worktree.created_not_opened": "分支已创建,但没能在它上面打开空间:{reason}",
+ "apiError.worktree.open_failed": "无法打开 worktree:{reason}",
+ "apiError.worktree.busy": "还有一个 worktree 操作在进行中 —— 稍等片刻再试。",
+ "apiError.worktree.ambiguous_branch": "这个分支名匹配到了多个目标:{reason}",
+ "apiError.worktree.branch_required": "请先填写分支名。",
+ "apiError.worktree.not_a_repo": "这个空间不在 Git 仓库里。",
+ "worktree.section": "Worktree",
+ "worktree.new": "新建 worktree",
+ "worktree.branchLabel": "分支名",
+ "worktree.branchPlaceholder": "feature/my-change",
+ "worktree.branchesFrom": "从 {branch} 分叉",
+ "worktree.create": "创建",
+ "worktree.creating": "创建中…",
+ "worktree.open": "打开",
+ "worktree.opening": "打开中…",
+ "worktree.mainCheckout": "仓库本体",
+ "worktree.empty": "还没有 worktree。",
+ "worktree.detached": "游离头指针",
+ "worktree.recoverOpen": "打开已创建的分支",
+ "space.new.tab.plain": "空间",
+ "space.new.tab.worktree": "Worktree",
+ "space.new.repo.label": "仓库",
+ "worktree.orOpenExisting": "或打开已有的",
};
diff --git a/web/src/lib/spaces-nest.test.ts b/web/src/lib/spaces-nest.test.ts
new file mode 100644
index 00000000..7ff985ae
--- /dev/null
+++ b/web/src/lib/spaces-nest.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+
+import { nestWorktrees } from "./spaces";
+import type { WorkspaceView } from "./types";
+
+// The grouping rules the spaces list leans on. Written against the shape a real herd produces:
+// a repo checkout open as one space, its worktrees open as others, and unrelated spaces between.
+
+function space(
+ workspaceId: string,
+ extra: Partial = {},
+): WorkspaceView {
+ return {
+ workspaceId,
+ number: 1,
+ label: workspaceId,
+ focused: false,
+ activeTabId: `${workspaceId}:t1`,
+ tabCount: 1,
+ paneCount: 1,
+ ...extra,
+ };
+}
+
+const REPO = "/repo/jmds";
+const parent = space("w1", { repoRoot: REPO, isWorktree: false });
+const child = space("w2", { repoRoot: REPO, isWorktree: true });
+const unrelated = space("w3");
+
+describe("nestWorktrees", () => {
+ it("puts a worktree one level under the space holding its repo", () => {
+ expect(nestWorktrees([parent, child])).toEqual([
+ { space: parent, depth: 0 },
+ { space: child, depth: 1 },
+ ]);
+ });
+
+ it("leaves spaces outside any repo flat", () => {
+ expect(nestWorktrees([unrelated])).toEqual([{ space: unrelated, depth: 0 }]);
+ });
+
+ it("keeps a worktree flat when its repo is not open — nothing to indent under", () => {
+ expect(nestWorktrees([child])).toEqual([{ space: child, depth: 0 }]);
+ });
+
+ it("moves the whole group to the position of its most recent member", () => {
+ // Incoming order is recency: the worktree was used most recently, the repo checkout long ago.
+ const rows = nestWorktrees([child, unrelated, parent]);
+ expect(rows.map((r) => r.space.workspaceId)).toEqual(["w1", "w2", "w3"]);
+ expect(rows.map((r) => r.depth)).toEqual([0, 1, 0]);
+ });
+
+ it("groups several worktrees of one repo, in the order they arrived", () => {
+ const second = space("w4", { repoRoot: REPO, isWorktree: true });
+ const rows = nestWorktrees([parent, second, unrelated, child]);
+ expect(rows.map((r) => r.space.workspaceId)).toEqual(["w1", "w4", "w2", "w3"]);
+ expect(rows.map((r) => r.depth)).toEqual([0, 1, 1, 0]);
+ });
+
+ it("keeps two repos apart", () => {
+ const otherParent = space("w5", { repoRoot: "/repo/infra", isWorktree: false });
+ const otherChild = space("w6", { repoRoot: "/repo/infra", isWorktree: true });
+ const rows = nestWorktrees([parent, otherParent, child, otherChild]);
+ expect(rows.map((r) => r.space.workspaceId)).toEqual(["w1", "w2", "w5", "w6"]);
+ expect(rows.map((r) => r.depth)).toEqual([0, 1, 0, 1]);
+ });
+});
diff --git a/web/src/lib/spaces.ts b/web/src/lib/spaces.ts
index 985a111b..c5f84e73 100644
--- a/web/src/lib/spaces.ts
+++ b/web/src/lib/spaces.ts
@@ -125,3 +125,84 @@ export function filterSpaces(
if (!q) return [...workspaces];
return workspaces.filter((w) => w.label.toLowerCase().includes(q));
}
+
+/**
+ * One row of the spaces list: a space, plus how deep it sits.
+ *
+ * `depth: 1` is a worktree shown under the space that holds its repo. It is a VIEW fact, computed
+ * per render, and deliberately not a field on the space: whether a worktree has a parent to sit
+ * under depends on what is open right now, which is not something a space can know about itself.
+ */
+export interface SpaceRow {
+ readonly space: WorkspaceView;
+ readonly depth: 0 | 1;
+}
+
+/**
+ * Nest each worktree under the space showing its repo, keeping the list's recency order.
+ *
+ * THREE RULES, and each answers a case the flat list never had:
+ *
+ * • **A group takes the position of its most recent member.** Sorting by the parent alone would
+ * bury a worktree you used a minute ago under a repo checkout you last touched last week — the
+ * list promises "what is fresh is near the top", and a group must keep that promise.
+ * • **A worktree whose repo is not open stays at depth 0.** There is no row to indent under, and
+ * indenting under nothing reads as a rendering bug.
+ * • **Order within a group is the incoming order**, which is already recency: the parent first,
+ * then its worktrees as they were sorted.
+ *
+ * `ordered` must already be in the order the caller wants (see {@link sortSpacesByRecency}); this
+ * function only regroups, never re-sorts.
+ */
+export function nestWorktrees(ordered: readonly WorkspaceView[]): SpaceRow[] {
+ // Only a space that IS the repo's own checkout can be a parent (`isWorktree === false`).
+ const parentByRepo = new Map();
+ for (const space of ordered) {
+ if (space.repoRoot !== undefined && space.isWorktree === false) {
+ parentByRepo.set(space.repoRoot, space);
+ }
+ }
+
+ const childrenByParent = new Map();
+ for (const space of ordered) {
+ if (space.repoRoot === undefined || space.isWorktree !== true) continue;
+ const parent = parentByRepo.get(space.repoRoot);
+ if (parent === undefined) continue; // orphan — rendered flat, below
+ const kin = childrenByParent.get(parent.workspaceId) ?? [];
+ kin.push(space);
+ childrenByParent.set(parent.workspaceId, kin);
+ }
+
+ const rows: SpaceRow[] = [];
+ const placed = new Set();
+ for (const space of ordered) {
+ if (placed.has(space.workspaceId)) continue;
+ const kin = childrenByParent.get(space.workspaceId);
+ // A parent reached through its own position, or dragged up here by a child that came first.
+ if (kin !== undefined) {
+ rows.push({ space, depth: 0 });
+ placed.add(space.workspaceId);
+ for (const child of kin) {
+ rows.push({ space: child, depth: 1 });
+ placed.add(child.workspaceId);
+ }
+ continue;
+ }
+ // A child met before its parent: emit the whole group HERE, at the child's (fresher) position.
+ if (space.repoRoot !== undefined && space.isWorktree === true) {
+ const parent = parentByRepo.get(space.repoRoot);
+ if (parent !== undefined && !placed.has(parent.workspaceId)) {
+ rows.push({ space: parent, depth: 0 });
+ placed.add(parent.workspaceId);
+ for (const child of childrenByParent.get(parent.workspaceId) ?? []) {
+ rows.push({ space: child, depth: 1 });
+ placed.add(child.workspaceId);
+ }
+ continue;
+ }
+ }
+ rows.push({ space, depth: 0 });
+ placed.add(space.workspaceId);
+ }
+ return rows;
+}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 34deb0fb..54c2d9e2 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -132,6 +132,15 @@ export interface WorkspaceView {
activeTabId: string;
tabCount: number;
paneCount: number;
+ /**
+ * The Git repo this space sits in, when the multiplexer reports one.
+ *
+ * Absent means "no repo, or this multiplexer keeps no such mapping" — and absence is what hides
+ * the worktree rows, so no extra call is needed to decide whether to show them.
+ */
+ repoRoot?: string;
+ /** Whether this space is a linked worktree of `repoRoot`, not the repo's own checkout. */
+ isWorktree?: boolean;
}
/** A tab within a workspace (holds one or more panes). */
@@ -532,6 +541,9 @@ export const MUX_CAPABILITIES = [
"renameTab",
"closeTab",
"createSpace",
+ "listWorktrees",
+ "createWorktree",
+ "openWorktree",
"pushTopologyEvents",
"pushPaneEvents",
] as const;
@@ -684,3 +696,25 @@ export const STATUS_RANK = {
export function statusLabel(status: AgentStatus): string {
return t(`status.label.${status}`);
}
+
+/** One Git worktree of the repo a space sits in. Mirrors `WorktreeView` in bridge/types.ts. */
+export interface WorktreeView {
+ path: string;
+ branch: string | null;
+ /** The space showing it, or `null` when nothing does — which is what hides its Remove row. */
+ openWorkspaceId: string | null;
+ /** `false` for the repo's own checkout: listed for context, never removable. */
+ linked: boolean;
+ prunable: boolean;
+}
+
+/** GET /api/workspace/:id/worktrees */
+export type WorktreeListResponse =
+ | { ok: true; worktrees: WorktreeView[] }
+ | { ok: false; error: string; code?: ApiErrorCode; detail?: ApiErrorDetail };
+
+/** POST /api/workspace/:id/worktree[/open] — `alreadyOpen` is an answer, never a failure. */
+export type WorktreeOpenResponse =
+ | { ok: true; pane: CreatedPane; alreadyOpen: boolean }
+ | { ok: false; error: string; code?: ApiErrorCode; detail?: ApiErrorDetail };
+
diff --git a/web/src/playground/app.tsx b/web/src/playground/app.tsx
index 19f3f1af..095f303f 100644
--- a/web/src/playground/app.tsx
+++ b/web/src/playground/app.tsx
@@ -16,6 +16,8 @@ import { AgentList } from "@/components/agent-list";
import { AppHeader, SettingsGear } from "@/components/app-header";
import { BuildStamp } from "@/components/build-stamp";
import { CollieHome } from "@/components/collie-home";
+import { NewSpaceSheet } from "@/components/new-space-sheet";
+import { SpaceOverview } from "@/components/space-overview";
import { CollieMark } from "@/components/collie-mark";
import { ConnectionBanner } from "@/components/connection-banner";
import { HostStaleBanner } from "@/components/host-stale-banner";
@@ -53,6 +55,7 @@ import {
paneShell,
paneWorking,
rosterFive,
+ spacesWithWorktrees,
updateMajor,
updateRelease,
updateRestart,
@@ -659,6 +662,47 @@ function SettingsSection() {
+
+
+ {}}
+ onNewSpace={() => {}}
+ open
+ onOpenChange={() => {}}
+ />
+
+
+
+
+
+ {}}
+ onCreate={() => {}}
+ repos={[
+ { workspaceId: "w1", repoRoot: "/src/collie", label: "collie" },
+ { workspaceId: "w9", repoRoot: "/src/nixcfg", label: "nixcfg" },
+ ]}
+ onCreateWorktree={() => {}}
+ onOpenWorktree={() => {}}
+ />
+
+
+
);
}
diff --git a/web/src/playground/fixtures.ts b/web/src/playground/fixtures.ts
index 69a4efed..73632c19 100644
--- a/web/src/playground/fixtures.ts
+++ b/web/src/playground/fixtures.ts
@@ -93,6 +93,50 @@ export const FIXTURE_TS = 400_000;
// than one of them. Herdr numbers spaces per machine, which is why the ids repeat across hosts in
// the pack snapshot below — that collision is the reason a pane row carries its own host.
+/**
+ * Spaces as a repo and its worktrees, for the nesting card.
+ *
+ * `repoRoot` + `isWorktree` are what the multiplexer reports on the workspace itself, so this is the
+ * real shape rather than a staged one: `collie` is the repo's own checkout, the two below it are
+ * worktrees of it, and `blog` sits outside any repo.
+ */
+export const spacesWithWorktrees: WorkspaceView[] = [
+ {
+ workspaceId: "w1",
+ number: 1,
+ label: "collie",
+ focused: true,
+ activeTabId: "w1:t1",
+ tabCount: 3,
+ paneCount: 4,
+ repoRoot: "/src/collie",
+ isWorktree: false,
+ },
+ {
+ workspaceId: "w2",
+ number: 2,
+ label: "feat-worktrees",
+ focused: false,
+ activeTabId: "w2:t1",
+ tabCount: 1,
+ paneCount: 1,
+ repoRoot: "/src/collie",
+ isWorktree: true,
+ },
+ {
+ workspaceId: "w3",
+ number: 3,
+ label: "fix-dirty-refusal",
+ focused: false,
+ activeTabId: "w3:t1",
+ tabCount: 1,
+ paneCount: 2,
+ repoRoot: "/src/collie",
+ isWorktree: true,
+ },
+ { workspaceId: "w4", number: 4, label: "blog", focused: false, activeTabId: "w4:t1", tabCount: 2, paneCount: 2 },
+];
+
export const spaces: WorkspaceView[] = [
{ workspaceId: "w1", number: 1, label: "collie", focused: true, activeTabId: "w1:t1", tabCount: 3, paneCount: 4 },
{ workspaceId: "w2", number: 2, label: "sprqvntrs-api", focused: false, activeTabId: "w2:t1", tabCount: 3, paneCount: 4 },
diff --git a/web/src/routes/home.tsx b/web/src/routes/home.tsx
index 714ccb37..81391d3c 100644
--- a/web/src/routes/home.tsx
+++ b/web/src/routes/home.tsx
@@ -8,7 +8,7 @@ import { PullToRefresh } from "@/components/pull-to-refresh";
import { ReadOnlyBanner } from "@/components/read-only-banner";
import { AgentList } from "@/components/agent-list";
import { SpaceOverview } from "@/components/space-overview";
-import { NewSpaceSheet } from "@/components/new-space-sheet";
+import { NewSpaceSheet, type WorktreeRepo } from "@/components/new-space-sheet";
import { StatusArea } from "@/components/status-area";
import { BuildStamp } from "@/components/build-stamp";
import { PackFooterLink } from "@/components/pack-footer-link";
@@ -16,6 +16,10 @@ import { UpdateBanner } from "@/components/update-banner";
import { useDashPrefs, openForCount } from "@/hooks/use-dash-prefs";
import { useLoadingStalled } from "@/hooks/use-loading-stalled";
import { useSpaceActions } from "@/hooks/use-spaces";
+import { useMuxCapability } from "@/lib/mux-capability";
+import { createWorktree, openWorktree } from "@/lib/api";
+import { describeApiError } from "@/lib/api-error-message";
+import { setStatus } from "@/lib/status";
import { leadHost, paneScope, sessionsOnHost } from "@/lib/hosts";
import { panePath, spacePath } from "@/lib/nav";
import type { AgentView } from "@/lib/types";
@@ -33,6 +37,17 @@ export function HomeRoute() {
const stalled = useLoadingStalled();
const navigate = useNavigate();
const { newSpace } = useSpaceActions();
+
+ // Which repos a worktree could be branched from: one entry per repo, taken from the space that
+ // shows the repo ITSELF (a worktree's own space would branch from the same repo, so listing both
+ // would offer the same thing twice under two names). In the spaces list's order, so the first
+ // entry — the sheet's default — is the repo most recently used.
+ const canCreateWorktree = useMuxCapability("createWorktree");
+ const worktreeRepos: WorktreeRepo[] = canCreateWorktree
+ ? data.workspaces
+ .filter((w) => w.repoRoot !== undefined && w.isWorktree === false)
+ .map((w) => ({ workspaceId: w.workspaceId, repoRoot: w.repoRoot!, label: w.label }))
+ : [];
const [newSpaceOpen, setNewSpaceOpen] = useState(false);
const { prefs, setSpacesOpen, setRecentOpen, setRecentDir } = useDashPrefs();
// No stored choice yet? The space count decides — a two-space install shouldn't be handed a
@@ -120,7 +135,30 @@ export function HomeRoute() {
- setNewSpaceOpen(false)} onCreate={newSpace} />
+ setNewSpaceOpen(false)}
+ onCreate={newSpace}
+ repos={worktreeRepos}
+ scope={data.scope}
+ onOpenWorktree={(workspaceId, path) => {
+ void (async () => {
+ const res = await openWorktree(workspaceId, path, data.scope);
+ // `alreadyOpen` is an answer, not a failure — either way the pane is where to go.
+ if (res.ok) navigate(panePath(res.pane.paneId, data.scope));
+ else setStatus(describeApiError(res), "error");
+ })();
+ }}
+ onCreateWorktree={(workspaceId, branch) => {
+ void (async () => {
+ const res = await createWorktree(workspaceId, branch, data.scope);
+ // Same promise the sheet on a space makes: the phone goes to the new pane, the
+ // operator's own screen is not moved.
+ if (res.ok) navigate(panePath(res.pane.paneId, data.scope));
+ else setStatus(describeApiError(res), "error");
+ })();
+ }}
+ />
);
}