Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .adr/0032-a-worktree-is-opened-by-the-multiplexer-not-by-git.md
Original file line number Diff line number Diff line change
@@ -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/<repo>/<branch>`
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.
4 changes: 4 additions & 0 deletions MUX_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <n>` (**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 <window> ; select-pane -t <pane>` — 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 <client_tty> -t <session>` 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 <n>` 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 <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 |

Expand Down
23 changes: 23 additions & 0 deletions bridge/beacon/decorate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<readonly MuxWorktree[]>([]));
}

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;
Expand Down Expand Up @@ -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<keyof Required<MuxAdapter>, true>;

Expand Down
3 changes: 3 additions & 0 deletions bridge/beacon/decorate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
Expand Down
20 changes: 20 additions & 0 deletions bridge/beacon/hint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<readonly MuxWorktree[]>([]));
}

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;
Expand Down
11 changes: 11 additions & 0 deletions bridge/beacon/hint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ import type {
MuxPane,
MuxSnapshot,
MuxSpaceRequest,
MuxWorktree,
MuxWorktreeCreateRequest,
MuxWorktreeOpenRequest,
MuxWorktreeOpened,
MuxWorktreeScope,
MuxSubscription,
MuxTabRequest,
MuxWatchOptions,
Expand Down Expand Up @@ -168,6 +173,12 @@ export function withAgentHints(adapter: MuxAdapter, deps: AgentHintDeps): MuxAda
renameTab: (tabId: string, label: string): Promise<MuxAck> => adapter.renameTab(tabId, label),
closeTab: (tabId: string): Promise<MuxAck> => adapter.closeTab(tabId),
createSpace: (request: MuxSpaceRequest): Promise<MuxOutcome<MuxCreatedPane>> => adapter.createSpace(request),
listWorktrees: (scope: MuxWorktreeScope): Promise<MuxOutcome<readonly MuxWorktree[]>> =>
adapter.listWorktrees(scope),
createWorktree: (request: MuxWorktreeCreateRequest): Promise<MuxOutcome<MuxCreatedPane>> =>
adapter.createWorktree(request),
openWorktree: (request: MuxWorktreeOpenRequest): Promise<MuxOutcome<MuxWorktreeOpened>> =>
adapter.openWorktree(request),
watch: (options: MuxWatchOptions): MuxSubscription => adapter.watch(options),
};
}
21 changes: 21 additions & 0 deletions bridge/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Loading
Loading