# ADR-0048: Repo/Worktree Tree View Fed by a Daemon Push Subscription

## Status

✅ Accepted (2026-07-10)

## Context

[ADR-0040](adr-0040.md) added the `worktrees` daemon service: an in-memory,
TTL-reaped registry of the VS Code windows open across the machine, served to the
CLI, the tray, and a thin companion extension that only *reports* its own window.
That models the **union of currently-open windows**. It does not model what a
*Git Worktree Manager*–style view needs: the **repositories** in play and **all**
of each repository's git worktrees — open in a window or not — with "is a window
open on it?" as a per-worktree attribute rather than the primary key.

Growing the service into that tree view ran into three structural facts about the
starting point:

1. **No worktree enumeration.** The daemon only ever inspected the folders a
   window reported open; it never ran the equivalent of `git worktree list`, so it
   could not show a repo's *other* worktrees.
2. **No GitHub-remote awareness.** Nothing in the service parsed a remote URL, so
   a repo could not be labelled with its `owner/repo` identity.
3. **The control socket cannot push.** `handle_connection` reads one line →
   `dispatch` returns one `Value` → writes one reply. There was no path for a
   service to emit asynchronous events, so a live view could only be built by
   polling. (The browser bridge streams over a *separate* WebSocket plane —
   [ADR-0036](adr-0036.md) — not this socket.)

The companion also had **no UI**: it was a ~50-line register/heartbeat/unregister
reporter, contributing only settings to `package.json`.

The design goal was a tree view — repositories at the top level (GitHub icon +
`owner/repo` where applicable), every worktree as a child with its branch and
`↑ahead ↓behind` and an open-window badge — that **updates itself live** as
windows open and close and as branches move, where **double-clicking** a worktree
focuses its window (or opens one). Four decisions were locked before the work
([#1264](https://github.com/rust-works/omni-dev/issues/1264)):

- **Which repos appear:** all git repos, GitHub ones enriched with `owner/repo`;
  non-GitHub repos still listed.
- **Repos with no open window (v1):** **open-window-derived** — repos are
  discovered from currently-open windows and a repo leaves the tree when its last
  window closes. This keeps the daemon's no-persistence property. Configured repo
  roots (so a repo shows with zero windows open) are a deliberate follow-up.
- **Live updates:** a **true push subscription** over the control socket, not
  polling.
- **Activation:** **double-click** via a manual click-timer, because the VS Code
  TreeView API has no native double-click event (single click only selects).

The constraints from ADR-0040 still bind: reuse the daemon's transport and trust
model unchanged (a `0600` socket in a `0700` directory), persist no secret and no
state, keep the engine's `Mutex`-never-across-`.await` invariant
([ADR-0039](adr-0039.md)), and stay wire-compatible with older clients through the
protocol's `#[serde(skip_serializing_if = …)]` convention.

## Decision

Grow the `worktrees` service along four coordinated axes — a new read op that
inverts the data model, a socket op that focuses a window, the control socket's
first streaming path, and the companion's first real UI — while leaving `list`,
the tray, and the reporter lifecycle byte-for-byte unchanged.

### 1. Invert the data model on read: the `tree` op

The open-window registry
([`src/worktrees.rs`](../../src/worktrees.rs)) stays exactly as ADR-0040 defined
it — it remains the **liveness source** (which worktrees are open, and the window
`key` a focus action resolves). The engine gains one read,
[`open_folders`](../../src/worktrees.rs), that snapshots the distinct
workspace folders across all live windows (reaping first, under the same lock,
pure CPU). That is the only engine change the `tree` op needs; the repo/worktree
derivation is disk I/O and lives entirely in the **adapter**
([`src/daemon/services/worktrees.rs`](../../src/daemon/services/worktrees.rs)),
off the registry lock, on a blocking thread — the engine/adapter split from
[ADR-0039](adr-0039.md).

The adapter's new `tree` op:

1. derives the **distinct repositories** by resolving each open folder to its git
   common dir (`Repository::discover`), deduped by common dir via a `BTreeMap` for
   deterministic order;
2. enumerates **all** of each repo's worktrees — the main working tree (the common
   dir's parent) plus every linked worktree (`Repository::worktrees` /
   `find_worktree`), sorted by path;
3. **enriches** each worktree by reusing the existing `git_status` building block
   (branch, ahead/behind, `is_main`);
4. tags the repo's **GitHub identity** by parsing `origin` (then the first
   `github.com` remote) — handling `https://`, `http://`, `ssh://`, `git://`, and
   the SCP-like `git@github.com:` forms, `None` for any non-GitHub host; and
5. **joins the open windows back on** by canonicalized path, so each worktree
   carries `open: bool` and, when open, the `window_key` for the focus action.

The reply is `{ repos: [ { main_repo, github?, root, worktrees: [ { path,
branch?, ahead?, behind?, is_main, open, window_key? } ] } ] }`. It is **additive**:
`list` is untouched and every new field follows the `skip_serializing_if`
convention, so an older client never sees a byte it does not expect. The CLI gains
a read-only `omni-dev worktrees tree [-o table|json]` rendering the grouped view.

This is the "open-window-derived (v1)" decision made concrete: because the repo
set is derived from open windows each time, a repo with no open window simply is
not in the tree — no persistence, no configured roots, nothing to reconcile across
a daemon restart.

### 2. Focus a window over the socket: the `open` op

ADR-0040's tray "focus" action spawns the VS Code CLI (`code <path>`) on a
window's folder. The companion cannot do this itself — its sandbox cannot see, let
alone focus, a sibling window — so double-click needs the **daemon** to launch
`code`. Rather than duplicate launcher logic in TypeScript, the adapter exposes the
tray's exact launcher path as a control-socket **`open` op** (payload `{ path }`):
it reuses `focus_window_with`'s guard (an **existing absolute directory**) and
launcher resolution (`OMNI_DEV_VSCODE_BIN` → well-known absolute paths → bare
`code`). `code <path>` uniformly focuses an already-open window or opens a new one,
so one op serves both open and not-open worktrees.

This is a small but real **escalation of the ADR-0040 threat model**, carried into
the Consequences below: before it, only the human clicking the tray could make the
daemon spawn `code`; now any *writer* of the socket can. It is bounded exactly as
everything on this socket is — writing already requires being the owning local
user (`0600`) — and the absolute-existing-directory guard both keeps it to real
local folders and blocks a `-`-leading path from being parsed by `code` as a flag
(argument injection). The guard is defense-in-depth, not a trust boundary.

### 3. The control socket's first streaming path: push subscriptions

The socket was strictly request→one-reply. Rather than bolt polling onto the
client, we taught the socket to **push**, as an opt-in that leaves every existing
op untouched:

- **Engine change-notify.** The registry gains a `tokio::sync::watch<u64>` version
  counter, bumped **only when the visible set changes** — a `register`, a removing
  `unregister`, or a mutation-path reap that actually drops a stale entry. A plain
  heartbeat (frequent) does **not** bump; a no-op unregister does not bump. `watch`
  needs no runtime and never blocks, so it fits the engine's no-async-setup
  posture, and every bump happens *after* the map guard is dropped, preserving the
  `Mutex`-never-across-`.await` (and never-nested-locks) invariant. A burst
  coalesces into a single wake because `watch` only tracks the newest version.
- **An optional trait capability.** `DaemonService` gains
  `fn subscribe(&self, op, payload) -> Option<Box<dyn ServiceStream>>`, defaulting
  to `None`. A `ServiceStream` exposes just `changed()` (resolves when state *may*
  have changed) and `snapshot()` (the current payload). Every existing service
  inherits the default and is unchanged.
- **A server drive loop.** When a line's op resolves to a stream, the server
  switches that connection to streaming mode (`run_stream`): it sends the initial
  `tree` snapshot, then `select!`s over { the engine change-notify, a periodic
  `STREAM_TICK` (3 s, so purely **on-disk** git changes — a branch switch, a new
  commit — that fire *no* registry event are still picked up), the framed read (an
  inbound line is an explicit cancel; `None`/`Err` is disconnect), and the shutdown
  `CancellationToken` }. Every wake re-samples and **diffs against the last-sent
  snapshot**, pushing only a real delta — so identical frames are never duplicated.
  This mirrors the browser bridge's `start_stream` coalescing shape, but on the
  control socket.
- **No new wire type.** Each pushed line is an ordinary `DaemonReply::ok(payload)`.
  A reader distinguishes a subscription only by continuing to read lines instead of
  stopping after one. The one subscription today is `worktrees` / `subscribe`,
  yielding the same `{ repos: [...] }` snapshot the `tree` op returns.

Back-compat is total: an older client never sends `subscribe`, so it only ever
sees the classic one-reply exchange.

### 4. The companion's first real UI

The companion ([`editors/vscode/`](../../editors/vscode/)) grows from a headless
reporter into a tree view, **alongside** the unchanged register/heartbeat/
unregister reporter lifecycle:

- an activity-bar `viewsContainer` + `views` tree, `commands` (open, refresh), and
  `menus`, activating on the view;
- a `TreeDataProvider<Node>` mapping a `tree` payload to repo nodes (a GitHub
  `ThemeIcon` + `owner/repo` label for GitHub repos, a `repo` icon otherwise) and
  worktree children (branch as the label, `↑ahead ↓behind` as the description, a
  three-way **open badge** — a blue tick for the worktree open in *this* window, a
  green dot for one open in *another*, the plain branch glyph otherwise — and a
  `contextValue` that marks the open/current state and gates the menu), firing
  `onDidChangeTreeData` on each pushed snapshot. The "current window" distinction
  (#1274) is a pure client-side consumer of the per-worktree `window_key` the
  `tree` payload already carries — matched against the window's own registry key —
  so it needed no daemon or protocol change;
- a **persistent subscribe connection** (`subscription.ts`) that reads pushed
  NDJSON lines and **reconnects with exponential backoff + jitter** (500 ms → 10 s)
  when the daemon drops or is absent, degrading to a hint message rather than an
  error dialog. The socket module stays `vscode`-free so it is unit-tested with
  `node --test`;
- a **manual double-click timer** in the click handler (a second click on the same
  item within 400 ms opens it; otherwise the click is recorded and native
  selection stands), calling the daemon `open` op — commented clearly that the
  TreeView API has no double-click event.

## Consequences

- **The view is a derived projection, not new stored state.** The repo/worktree
  tree is recomputed from the open-window registry (the ADR-0040 liveness source)
  plus the on-disk git state on every read and every pushed frame. No repo list,
  no worktree list, and no GitHub identity is persisted or cached across reads, so
  the daemon keeps its no-persistence property: nothing to reconcile on restart,
  and a repo leaves the tree the instant its last window closes.
- **Enumeration and enrichment are disk I/O, kept off the lock and off the async
  workers.** `tree` (and each subscription snapshot) opens repositories, walks
  worktrees, and parses git config on a **blocking thread**, never under the
  registry `Mutex` and never on an async worker — the same discipline ADR-0040 set
  for `list`/`status` enrichment. The registry lock still only ever guards
  pure-CPU work.
- **The socket now has two shapes, but only for opt-in ops.** A connection is
  either the classic request→one-reply or — when and only when its op resolves to a
  `ServiceStream` — a long-lived push stream. There is no new wire type and no
  change to any existing op, so every prior client (and every non-worktrees
  service) is byte-for-byte unaffected. The subscription tears down cleanly on
  client disconnect, an explicit cancel line, or daemon shutdown (the shutdown
  token is a `select!` arm).
- **Threat model: the `open` op is a deliberate, bounded escalation.** ADR-0040's
  residual exposure was "read the socket → enumerate open repo *paths*; write it →
  inject fake entries". This adds one capability to a *writer*: spawn `code` on a
  supplied path. It is same-user-bounded (the `0600` socket already requires the
  owning local user — no new principal gains anything), and the
  absolute-existing-directory guard confines it to real local folders while
  blocking argument injection. The subscription adds **no** new exposure beyond
  `tree` itself — it streams exactly what a reader could already pull — so the only
  new capability on the whole surface is the `open` spawn, and it is defense-in-
  depth, not a trust boundary. The companion is still the daemon's only non-omni-dev
  client, now reading a richer payload and holding a long-lived connection, but
  over the same socket with the same ownership gate.
- **GitHub enrichment is best-effort and never load-bearing.** A repo with no
  GitHub remote (or an unparseable URL) is still listed, just without the `github`
  field — the tree degrades exactly like the per-field git enrichment, adding no
  error path and staying wire-compatible.
- **Two known v1 limits, both deliberate.** A repo shows only while at least one of
  its windows is open (configured repo roots are a follow-up), and live updates use
  push with a 3 s safety tick rather than a filesystem watcher — so an on-disk
  branch change is reflected within the tick interval, not instantly. A polling
  fallback was explicitly *not* built (push was chosen); the tick already covers
  the on-disk-change case the change-notify cannot see.
- **Still Unix-only.** The service, CLI, and companion socket path remain
  `#[cfg(unix)]` / unix-socket-only; Windows support is tracked with the broader
  daemon work ([#1237](https://github.com/rust-works/omni-dev/issues/1237)).
  Marketplace/Open VSX publishing of the companion remains a follow-up.

This supersedes nothing: it extends ADR-0040 in place. See
[ADR-0039](adr-0039.md) for the daemon framework, [ADR-0040](adr-0040.md) for the
worktrees service this grows, [ADR-0036](adr-0036.md) for the bridge's separate
streaming plane, and [docs/worktrees-service.md](../worktrees-service.md) for the
operator guide and the companion's full wire contract.
