# ADR-0040: Cross-Window Worktrees Daemon Service Fed by a Companion VS Code Extension

## Status

✅ Accepted (2026-06-23)

## Context

There is no way to see, across **all** open VS Code windows, which repositories
and git worktrees are open right now. This is not a missing feature in any one
extension — it is a structural limit of the VS Code extension model:

- The extension host is **sandboxed per window**. Each window's extension
  instance can read only its own `workspace.workspaceFolders`; it can never see a
  sibling window's. So any per-window "worktree list" view is scoped to the one
  repo that window has open.
- Cross-repo "folder" views in community extensions (e.g. jackiotyu's *Git
  Worktree Manager*) are **per-window curated state**. A folder added in window A
  does not propagate to window B — even `globalState` is loaded per window at
  activation and not live-replicated — so the same list must be re-curated by
  hand in every window, forever. The upstream community has hit the same wall
  (openai/codex#14354).

The only architecture that beats the per-window sandbox is a **rendezvous
point**: a single resident process that each window reports *its own* worktree
to, which aggregates them into one consistent view served back to every window,
the CLI, and the tray. omni-dev already runs exactly such a process — the daemon
([ADR-0039](adr-0039.md)) — with a service abstraction, a `0600` Unix control
socket, single-instance supervision, status aggregation, and a macOS menu bar.
This decision adds a service to it rather than building a new daemon.

The constraints that shaped the design:

- It must reuse the daemon's existing transport and trust model unchanged — no
  new network surface, no persisted secret (the control socket is already a
  `0600` file in a `0700` directory, gated by local-user filesystem permission).
- A window that crashes without firing a clean "unregister" must **age out**, or
  the view leaks dead entries. A flat shared file cannot do this cleanly; a
  resident process with liveness can.
- The companion extension is a **new external client** of the daemon socket — the
  first that is not first-party omni-dev code — so the wire contract it speaks
  must be explicit and forward-compatible.

## Decision

Add a **`worktrees` service** to the daemon
([`src/daemon/services/worktrees.rs`](../../src/daemon/services/worktrees.rs)),
implementing the `DaemonService` trait and registered in the default registry
alongside the browser bridge and Snowflake services. A small first-party
companion VS Code extension (a separate deliverable) feeds it from each window.

> **Implementation note (#1111):** the companion extension now ships in-repo at
> [`editors/vscode/`](../../editors/vscode/) — a thin TypeScript reporter
> speaking the contract below, kept isolated from the Rust build (excluded from
> the crate; its own path-filtered CI). Marketplace/Open VSX publishing is wired
> on a `vscode-v*` tag via a sibling release workflow (#1279), pending a one-time
> publisher/namespace + PAT-secrets setup.

### In-memory registry keyed by a companion-owned key

The **engine** ([`src/worktrees.rs`](../../src/worktrees.rs), the crate-root
`WorktreesRegistry` split out in #1154) holds an in-memory map of open windows
behind a `std::sync::Mutex` that is **never held across an `.await`** — the
engine/adapter concurrency invariant defined in [ADR-0039](adr-0039.md); the
`WorktreesService` adapter only routes ops to it. Each entry is
`{ key, folders, repo, title, pid, last_seen }`.

The map is keyed by a **companion-owned `key`** — a UUID the extension generates
once per `activate()` — rather than by `vscode.env.sessionId`. This deliberately
sidesteps any uncertainty about whether `sessionId` is unique *per window* versus
per application instance: the daemon never has to reason about VS Code's identity
semantics, and `sessionId`/`pid` ride along only as best-effort metadata.

The service is **cheap to construct** (in-memory only, no async setup, no I/O),
so unlike the bridge it is registered via a plain `new()` and persists nothing to
disk.

### Ops over the existing NDJSON control socket

The companion talks the daemon's existing wire protocol
([`protocol.rs`](../../src/daemon/protocol.rs)) — a `DaemonEnvelope { service:
"worktrees", op, payload }` per line — with five ops:

| Op | Payload | Reply | Meaning |
|---|---|---|---|
| `register` | `{ key, folders[], repo?, title?, pid? }` | `{ ok: true }` | Idempotent upsert; refreshes `last_seen`. |
| `heartbeat` | `{ key }` | `{ known: bool }` | Refreshes `last_seen`; `known: false` means the daemon has no such entry. |
| `unregister` | `{ key }` | `{ removed: bool }` | Removes the window (fired on `deactivate()`). |
| `list` | — | `{ windows: [entry, …] }` | The live cross-window set, sorted by `(repo, key)`. |
| `open` | `{ path }` | `{ ok: true }` | Focuses/opens `path` in VS Code (`code <path>`); `path` must be an existing **absolute** directory. Shares the tray's launcher path (#1266). |

`heartbeat → { known: false }` is the **resync signal**: because the registry is
in-memory, a window that was open before the daemon started — or that survives a
daemon restart — will heartbeat against an empty map; `known: false` tells it to
re-`register`. This keeps the design correct across restarts without persistence.

### Liveness by reap-on-read, not a background task

Entries are evicted when `now - last_seen` exceeds a 30 s TTL (three missed ~10 s
heartbeats). Reaping runs **inline on every read** (`list`/`status`/`menu`, and
opportunistically on writes), holding the lock only for pure-CPU work. A
background reaper task was rejected: the only consumers (the tray polling ~1 Hz,
`worktrees list`, `daemon status`) trigger reaping naturally, and staleness is
only ever *observable* on read — so a `tokio::spawn` reaper would add
shutdown/management cost for no correctness gain.

The TTL bounds *staleness* but not *count*, so the registry is additionally
capped at **256 entries** (#1140, from the #1101 security review): a `register`
of a new key at the cap evicts the longest-silent entry (oldest `last_seen`,
ties broken by key) instead of rejecting. Evict-over-reject keeps `register`
infallible — the companion contract gains no error path — and an evicted live
window self-heals through the same `heartbeat → { known: false } → re-register`
resync used across daemon restarts. Upserts of an existing key never evict.

### Tray submenu and CLI

`menu()` contributes a "Worktrees" submenu with **one item per open window**: a
`focus:<key>` **Action** whose label *is* the window's live stats line (the stats
and the focus action are one item, not two rows). A folderless window has nothing
to open, so it degrades to a non-clickable label. The action routes through the
daemon's existing generic tray dispatch to `menu_action`, which spawns the VS Code
CLI on the window's folder (`code <path>` — VS Code reuses the already-open
window, so it focuses rather than re-opens). It is **best-effort**: the launcher is resolved via
`OMNI_DEV_VSCODE_BIN`, then a short list of well-known absolute paths (so a
launchd daemon with a minimal `PATH` still finds it), then bare `code`; failure is
logged, not fatal. The folder is required to be an existing **absolute** directory
before spawning, which also rules out a path beginning with `-` being parsed as a
flag.

The same launcher path is exposed as a control-socket **`open` op** (payload
`{ path }`, #1266) so the companion — which cannot see a sibling window and so
must ask the daemon to focus one on double-click — reuses the daemon's tested
guard and launcher resolution rather than duplicating them in TypeScript. `open`
takes an arbitrary client-supplied path (not a registered window `key`), so the
absolute-existing-directory guard is a real check there, not just a
workspace-folder assertion; see the threat-model consequence below.

A new read-only client, `omni-dev worktrees list [--json]`
([`src/cli/worktrees.rs`](../../src/cli/worktrees.rs)), prints the same live view
as a table (or JSON for machines). It is Unix-only (`#[cfg(unix)]`), like the
`daemon`/`snowflake` commands, since it talks to the Unix control socket. The
register/heartbeat/unregister ops are **not** CLI subcommands — the companion
speaks them to the socket directly.

Both the tray labels and the CLI table surface the daemon-computed git
enrichment (branch, `+ahead -behind` sync state, and the parent-repo name — see
the last consequence below); the `menu`/`list`/`status` reads are also where the
enrichment is computed. The tray label leads with that parent-repo name and marks
a linked worktree with a `⑂` fork glyph, so a worktree reads distinctly from its
repo's main checkout.

## Consequences

- Registry memory is strictly bounded: at most 256 entries live at once, each
  evicted by TTL (staleness) or by cap pressure (count), so a same-user process
  flooding `register` cannot grow the daemon beyond tens of KB (#1140).
- The cross-window view is the **union of what is actually open**, auto-discovered
  and consistent everywhere by construction, because there is exactly one source
  of truth and nothing to replicate. This obsoletes the manual "Git folders"
  curation that exists only to compensate for the per-window sandbox.
- **No new trust boundary.** Requests ride the daemon's existing `0600` Unix
  socket in its `0700` directory ([ADR-0039](adr-0039.md)); no secret is
  persisted; everything stays loopback/filesystem-local. The bridge's
  ([ADR-0036](adr-0036.md)) and Snowflake's models are untouched. The residual
  exposure is narrow and bounded by socket ownership: anything that can read the
  socket can enumerate your open repo **paths** (`list`), and anything that can
  write it can inject fake entries or invoke the **`open` op** (#1266) to spawn
  `code` on a path it supplied. That op is a small, deliberate escalation over the
  original tray-only focus: previously only the human clicking the tray could
  spawn `code` (the tray's `menu_action` is not reachable over the socket), whereas
  a socket *writer* now can. It is bounded the same way everything on this socket
  is — writing it already requires being the owning local user — and the launcher
  only ever runs on an **existing absolute directory**, which keeps it to real
  local folders and blocks a `-`-leading path from being parsed by `code` as a
  flag (argument injection). That guard is defense-in-depth, not a trust boundary.
- The companion extension is the daemon's **first non-omni-dev client**, so the
  four-op contract above is the stable surface it depends on. New optional fields
  follow the protocol's existing `#[serde(default, skip_serializing_if = …)]`
  convention, so an older daemon and a newer companion (or vice versa) stay
  wire-compatible.
- The companion must compute the socket path the same way the daemon does
  (`dirs::data_dir()/omni-dev/daemon.sock`, honoring `$XDG_DATA_HOME` on Linux)
  and **no-op gracefully when the daemon is not running** — no hard dependency, no
  error spam. It assumes the default socket; a custom `--socket` daemon is out of
  scope for the companion.
- Like the rest of the daemon, the service and CLI are `#[cfg(unix)]`; on Windows
  there is nothing for the companion to talk to (tracked with the broader Windows
  daemon work, #1237).
- Git enrichment lives in Rust, not the extension: the companion reports raw
  folder paths (keeping it ~50 lines), and the richer per-worktree git data
  (current branch, ahead/behind, the parent-repository name from git's common
  dir, and an `is_worktree` flag) is computed by the daemon with the `git2`
  dependency it already has (#1186). The **adapter** — not the pure registry
  **engine**, per the engine/adapter split in [ADR-0039](adr-0039.md) — computes
  it **on read** from the primary folder's on-disk state, off
  the registry lock (on a blocking thread for `list`/`status`, inline for the
  sync tray `menu`), so the values are always live rather than a registration-time
  snapshot. Each field is optional and degrades independently (no `branch` for a
  non-repo or detached HEAD; no `ahead`/`behind` without an upstream; no
  `main_repo` for a non-repo folder), so it adds no error path and stays
  wire-compatible with older clients.

See [ADR-0039](adr-0039.md) for the daemon framework this builds on and
[docs/worktrees-service.md](../worktrees-service.md) for the operator-facing guide
and the companion's wire contract.
