diff --git a/.pi-web/relays/worktree-autodetect/charter.md b/.pi-web/relays/worktree-autodetect/charter.md new file mode 100644 index 0000000..a722b09 --- /dev/null +++ b/.pi-web/relays/worktree-autodetect/charter.md @@ -0,0 +1,131 @@ +# Charter — relay "worktree-autodetect" + +## Relay identity + +- **Name:** `worktree-autodetect` +- **Root:** `.pi-web/relays/worktree-autodetect/` in worktree `/srv/dev/pi-web-worktrees/worktree-autodetect` +- **Branch:** `feat/worktree-autodetect` (based on `main`) + +## Goal / finish line + +Worktrees created or removed outside PI WEB become visible in the browser workspace +list **with no user action of any kind**, on the next natural browser resume, on both +local and remote machines. + +Concretely, the relay is finished when all of the following are true: + +1. `discoverGitWorktrees` no longer reports worktrees whose checkout directory is gone + (`prunable` in `git worktree list --porcelain`), so worktrees deleted outside PI WEB + stop appearing as selectable ghost workspaces. +2. `WorkspaceController` can re-list the workspaces of the selected project and apply the + result **without disturbing the current selection, session, or scroll state** when the + selected workspace still exists, and without silently yanking the user out of a + workspace that vanished while they were working in it. +3. `PiWebApp` calls that refresh from the existing browser-resume path + (`refreshAfterBrowserResume`) and the existing plugin-facing `refreshAppData` path. + No new timer, no new watcher, no new process, no new WebSocket channel. +4. Tests cover: prunable parsing/filtering, refresh-preserves-selection, + refresh-when-selected-workspace-disappeared, and the resume wiring. +5. `npm run verify` is green, and a changeset exists describing the user-visible behavior. + +**Explicitly out of scope** (decided in leg 0, do not re-open without the human): + +- Filesystem watchers on `.git/worktrees` or anywhere else. +- Polling timers for worktree discovery. +- Any server→browser push channel for workspace topology. +- Instant (sub-second) detection while the browser tab already has focus. +- Auto-*adopting* anything into `projects.json`. Worktrees are derived, never registered; + nothing is being adopted, and no project registry write is part of this work. + +## Sizing + +**One leg = one vertical slice that leaves the tree green and committed.** + +A leg is done when its slice is implemented, its tests are written and passing, the +narrowest meaningful checks are run (`npm test -- --run `, plus `npm run typecheck` +if exported types changed), and the work is committed. Do not carry uncommitted work +across a handoff. + +Expected shape is three legs (see `plan.md`). If a leg turns out bigger than one slice, +split it and hand off the remainder rather than doing "just a bit more". + +## Task selection policy + +1. Take the explicit **next leg** named in `status.md`. +2. If `status.md` does not name one, take the next unfinished slice in `plan.md` in order. +3. If neither is clear, or the next slice would change the design rather than implement it, + **stop and raise the intervention signal**. Do not redesign inside a leg. + +## Handover protocol + +Before handing off, in this order: + +1. Make the work durable: source + tests written, checks run, changes **committed** with a + Conventional Commit message. +2. Update `status.md`: current position, last completed leg, next leg to run, next task, + relevant context for the next runner, blockers. +3. Append a concise entry to `log.md`: what you did, decisions and why, artifacts changed, + exact checks run and their results, handing-off vs stopping. +4. Then `spawn_session` **once**, with a prompt starting: + +```text +Relay "worktree-autodetect" leg begins now. + +You are the next runner in this Relay method chain. + +Read: +- .pi-web/relays/worktree-autodetect/charter.md +- .pi-web/relays/worktree-autodetect/status.md + +Do not read log.md end-to-end. Use it only for targeted lookup if status.md or charter.md points you there. + +Run one leg according to the charter. Before handing off, update status.md, append log.md, make work durable, then either spawn the next leg once or stop with a clear intervention note. +``` + +## Intervention signal + +**Stop, do not spawn**, and write a clearly marked `## BLOCKED` section at the top of +`status.md` plus a log entry, if any of these happen: + +- The next task is ambiguous, or doing it would require a design decision not in this charter. +- You are tempted to add a watcher, a timer, a new process, or a new push channel. That + means the design boundary is being crossed — get the human. +- Refresh-on-resume cannot be made to preserve selection without visible UI churn + (list reordering, chat scroll jump, session reload, terminal teardown). This is the + main known risk; it is a stop, not a workaround. +- Filtering `prunable` would remove a workspace the user could plausibly still want + (for example a temporarily unmounted network path) and you cannot bound that safely. +- `npm run verify` fails for a reason you did not introduce. + +## Reading discipline + +Read to orient: `charter.md`, then `status.md`, then only the files `status.md` names. + +Do **not** read `log.md` end-to-end; use it only for targeted lookup when pointed there. +Do **not** read the sibling worktrees `/srv/dev/pi-web-worktrees/worktree-create-ui` or +`/srv/dev/pi-web-worktrees/model-questions-ux` — they are separate, parallel efforts. Per +the human's decision, assume they contribute nothing to this relay; this relay owns the +workspace-topology refresh seam outright. + +Relevant source surface, small enough to read directly when your leg touches it: + +- `src/server/workspaces/gitWorktreeDiscovery.ts` (39 lines) +- `src/server/workspaces/workspaceService.ts` (44 lines) +- `src/client/src/controllers/workspaceController.ts` (~105 lines) +- `src/client/src/appShell/browserResumeController.ts` + its test +- `src/client/src/components/PiWebApp.ts` — only `refreshAfterBrowserResume` + (~line 432) and `refreshAppData` (~line 485). Do not read this 2300-line file whole. + +## Project conventions that apply + +- **Changesets:** this is user-visible. Add a `.changeset/*.md` fragment + (see `.agents/skills/changeset-changelog/SKILL.md`). Never hand-edit `CHANGELOG.md`. +- **Skills:** use `.agents/skills/code-quality-architecture/SKILL.md` when writing + production code and `.agents/skills/testing-guide/SKILL.md` when writing tests. +- **Session daemon:** this design deliberately touches **no** sessiond code, no session + runtime ownership, and no daemon protocol. **No manual session daemon restart is + required.** Changes land on the autoreloading `pi-web-web-ui-dev.service` path only. + If a leg finds itself editing `src/server/sessiond.ts`, that is the intervention signal. +- **Client URL conventions:** no new endpoints are added; the existing + `workspacesApi.workspaces()` request path is reused unchanged. +- **No `npm install`** — `node_modules` here is a symlink to the main checkout. diff --git a/.pi-web/relays/worktree-autodetect/log.md b/.pi-web/relays/worktree-autodetect/log.md new file mode 100644 index 0000000..05baee3 --- /dev/null +++ b/.pi-web/relays/worktree-autodetect/log.md @@ -0,0 +1,197 @@ +# Log — relay "worktree-autodetect" + +Append-only. One entry per leg. Do not read end-to-end unless `status.md` points you here. + +--- + +## Leg 0 — Assessment, design, packet creation + +**Runner:** assessment/design session +**Outcome:** recommendation = **reduced scope**. Packet created. Relay parked pending +human approval. No production code written. + +### Feature request + +> "auto detect and show new worktrees, even when created outside of pi-web." + +With the user's framing: nice-to-have, not a must, expects it may not be feasible, and +**must require zero user intervention**. + +### What I found in the codebase + +The single most important finding reframed the whole feature: + +**Worktree discovery is already fully dynamic. There is no cache and no registry.** + +`WorkspaceService.list()` (`src/server/workspaces/workspaceService.ts`, 44 lines) calls +`isGitRepository()` then `discoverGitWorktrees()` — which shells out to +`git worktree list --porcelain` — on **every single** `GET /projects/:projectId/workspaces` +request. I grepped for any cache/memo in that path: there is none. Workspace ids are +derived by hashing `${project.id}:${worktree.path}`, so they are stable across calls +without being stored anywhere. + +And `projects.json` (`src/server/storage/projectStore.ts`) stores only +`{ id, name, path, createdAt }` per **project**. Workspaces are never persisted. + +Two consequences, both of which delete a large chunk of the anticipated problem: + +1. **A worktree created outside PI WEB is already detected.** The server has no stale + state to invalidate. The gap is not detection at all — it is that **the browser never + re-asks**. `WorkspaceController` fetches workspaces in `selectProject()` and in + `refreshProjectWorkspaces()`, and the only caller of the latter is the + workspace-*deletion* flow. So the list is fetched on project selection and then frozen + for the lifetime of that selection. +2. **"Auto-adoption" is a non-question.** The brief asked whether zero-intervention + detection implies zero-intervention adoption, and whether a discovered worktree should + be a distinct "discovered" state. Since worktrees are derived and nothing is written to + a registry, there is nothing to adopt and no state to distinguish. A new worktree is + simply a member of the derived list. This was the design's biggest apparent risk and it + evaporated on inspection. + +Measured cost of the discovery itself: **~2ms**. 20 sequential +`git worktree list --porcelain` runs on this repo took 42ms total. + +I also found the delivery mechanism already built and already debounced: +`src/client/src/appShell/browserResumeController.ts` listens to window `focus` and +document `visibilitychange`, batches signals per animation frame, and collapses +concurrent refreshes through `TrailingRefreshCoordinator`. It drives +`PiWebApp.refreshAfterBrowserResume()`, which today refreshes the selected session, +machine activities, and workspace-deletion runs. Workspace topology is conspicuously +absent from that list. + +And remote machines need no work: `GET /projects/:projectId/workspaces` is already in +`FEDERATED_HTTP_ROUTES` (`src/shared/federatedRoutes.ts:25`) and `workspacesApi.workspaces` +already takes a `machineId` and routes via `machinePrefix`. + +### The inverse case, verified against real git + +I built a throwaway repo in `/tmp/wtprobe` and checked what git actually reports. + +- `.git/worktrees/` does not exist until the first linked worktree is added, then gains + one directory per worktree. +- After `rm -rf`ing a worktree's directory **without** `git worktree remove`, + `git worktree list --porcelain` **still lists it**, with an extra line: + `prunable gitdir file points to non-existent location`. +- A locked worktree gets a bare valueless `locked` line. + +The current parser ignores both keys. So **PI WEB today shows worktrees that no longer +exist as normal, selectable workspaces** — a real bug, present regardless of whether the +detection feature is built. Selecting one produces a workspace whose path does not exist. + +### Options compared + +**A. `git worktree list` on a timer (server or client poll).** +Rejected. It is the obvious answer and it is the wrong one. A timer runs forever to catch +an event that happens a few times a week, and it must run per project, per machine, or it +does not actually satisfy "no intervention". For a nice-to-have, a permanent background +cost to serve a rare event is exactly the trade the user warned against. It also has no +natural interval: fast enough to feel automatic is wasteful, slow enough to be cheap is +not noticeably better than the resume trigger, which is free. + +**B. Watch `.git/worktrees/` with `fs.watch`/inotify.** +This was the most interesting candidate and the one I most wanted to work. The watch +target is genuinely small and precise — one directory in the main repo, one entry per +worktree, written by git itself. That is far better than watching filesystems for new +directories. + +Rejected anyway, on cost and correctness: + +- **Lifecycle ownership is the real problem, not the watcher.** A watcher must be created + and destroyed as projects are added/removed, and it must live somewhere long-lived. The + web/API process autoreloads (`pi-web-web-ui-dev.service`), so watchers there churn + constantly. The natural long-lived home is the session daemon — but that would drag a + purely presentational concern into session runtime ownership and, per `AGENTS.md`, make + every change to it require a manual daemon restart. For a nice-to-have, that is a + disproportionate architectural commitment. +- **The directory does not exist until the first worktree exists**, so a repo with no + linked worktrees needs a watch on `.git/` itself to catch `worktrees/` being created — + a noisier target that fires on every ref update, index write, and fetch. +- **It only fixes the local case.** Remote machines would need the event pushed over the + machine transport, which means a new workspace-topology realtime event type, publishing + it from the daemon, adding it to `FEDERATED_WEBSOCKET_ROUTES` plumbing, parsing it in + `sessionSocket.ts`, and handling it in `PiWebApp`. That is a meaningful new protocol + surface for a feature the user called optional. +- **Environment caveats are real.** `fs.watch` is unreliable on Docker bind mounts on + macOS/Windows (the repo ships `docker/compose.yml` with bind-mounted checkouts) and on + network filesystems, and inotify watch limits are a known operational failure mode. So + the "instant" promise would be silently broken for a subset of users — worse than an + honest "updates when you come back to the tab". +- **It still would not be enough.** `fs.watch` on `.git/worktrees` catches creation but + the removal case still needs the `prunable` fix, because `rm -rf` of the *checkout* + does not touch `.git/worktrees/` at all — I verified the metadata directory + survives. So the watcher does not even subsume the cheaper fix. + +**C. Piggyback on events that already happen.** ← chosen +The refresh already exists, is already debounced, already covers remote machines, and +costs one ~2ms request per tab refocus. Marginal cost is as close to zero as this feature +can get, and the code surface is ~60 production lines. + +### Recommendation: reduced scope + +Do the cheap 90%: + +1. **Fix the inverse case** — filter `prunable` worktrees out of the workspace list. + Read-only; PI WEB must not run `git worktree prune` as a side effect of listing. +2. **Add a non-disruptive topology refresh** to `WorkspaceController` that re-lists the + selected project's workspaces without touching selection or session state. +3. **Call it from the existing resume path** — no new timer, watcher, process, or channel. + +**Scope boundary, stated plainly:** detection is **resume-scoped, not instant**. A worktree +created in another terminal while the PI WEB tab already has focus is not noticed until the +tab is refocused or becomes visible again. That is the honest limit, and it should be +documented rather than papered over. + +Within that boundary it is genuinely zero-intervention: no button, no config, no opt-in, +works on local and remote machines, works for creation and removal. + +### The risk that could kill it + +`WorkspaceController.selectWorkspace()` calls `clearActiveSession()` and +`resetWorkspaceScopedState()`. If a background refresh routes through it, the user's chat +is torn down every time they refocus the tab. The refresh must apply the list through +`applyProjectWorkspaces` only. `ProjectActivityOwnershipCoordinator` is the existing +precedent for background topology hydration that deliberately leaves selection alone, and +is the model to follow. Leg 2 proves this with tests before leg 3 wires the trigger — and +if it cannot be made non-disruptive, that is an explicit stop. + +### What would change the recommendation + +- If the user says instant-while-focused is actually required, option B comes back on the + table — but with the session daemon commitment, the new realtime event type, and the + Docker/network-filesystem caveats accepted as the price. +- If a future need arises for reliable server-pushed workspace topology for another reason, + the watcher becomes incremental rather than a feature-specific cost, and the ledger flips. + +### Artifacts created + +- `.pi-web/relays/worktree-autodetect/charter.md` +- `.pi-web/relays/worktree-autodetect/status.md` +- `.pi-web/relays/worktree-autodetect/plan.md` +- `.pi-web/relays/worktree-autodetect/log.md` (this file) + +### Checks run + +None — no code was changed in this leg. + +### Human decisions received at the end of leg 0 + +All three open questions were answered, and the reduced scope was approved: + +1. **Latency** — resume-scoped detection is acceptable. No timer, no watcher. +2. **Removed worktrees** — yes, hide worktrees whose directory is gone. +3. **Sibling overlap** — assume the other session does nothing; this relay owns the + refresh seam outright and should not design for sharing. + +The human also asked for the `selectWorkspace` risk to be explained concretely. The +worked failure mode is now recorded inline in `plan.md` → Leg 2, because the buggy version +is the one that looks correct: re-resolving the selection after a refresh via +`selectPreferredWorkspace` + `selectWorkspace` (mirroring `selectProject`) tears down the +session on **every** browser resume, since `selectWorkspace` has no already-selected guard +and always runs `clearActiveSession()` (closing the session socket mid-stream and dropping +buffered deltas) plus `resetWorkspaceScopedState()` (clearing chat, file tree, open file, +git status, open diff, terminal selection). + +### Handing off? + +**Yes.** Packet committed, `status.md` un-parked with the decisions recorded, leg 1 +dispatched via `spawn_session`. diff --git a/.pi-web/relays/worktree-autodetect/plan.md b/.pi-web/relays/worktree-autodetect/plan.md new file mode 100644 index 0000000..9dabddb --- /dev/null +++ b/.pi-web/relays/worktree-autodetect/plan.md @@ -0,0 +1,168 @@ +# Implementation plan — worktree-autodetect (reduced scope) + +Three legs. Each is a vertical slice: source + tests + checks + commit. + +The order is deliberate: server truth first, then client application of that truth, +then the trigger that makes it zero-intervention. + +--- + +## Leg 1 — Stop reporting removed worktrees (the inverse case) + +**Why first:** it is independently valuable, has zero UI risk, and is the only part of +the feature that is a straight bug fix. Today a worktree deleted with `rm -rf` outside +PI WEB stays in the workspace list forever as a selectable ghost. + +**Files** + +- `src/server/workspaces/gitWorktreeDiscovery.ts` +- new `src/server/workspaces/gitWorktreeDiscovery.test.ts` + +**Work** + +1. Extend the porcelain parser to read the valueless `prunable` and `locked` keys. + `git worktree list --porcelain` emits `prunable ` for a linked worktree whose + checkout directory no longer exists, and a bare `locked` line for a locked one. + Verified in leg 0 against real git. +2. Surface `prunable` on `GitWorktreeInfo`, and filter prunable entries out of what + `discoverGitWorktrees` returns — or return them and filter in `WorkspaceService`, + whichever keeps the parser honest and the policy visible. Prefer: parser reports + facts, `workspaceService` decides policy. +3. Do **not** run `git worktree prune`. Read-only. PI WEB must not mutate the user's + repo metadata as a side effect of listing. +4. Keep the main worktree unconditionally: never filter the entry whose path equals + `project.path`, so a project can never end up with an empty workspace list. + +**Tests** (pure parser tests, no git process needed — inject or fake the exec boundary) + +- parses `prunable` with a reason and `locked` without a value +- a prunable linked worktree is excluded from the workspace list +- a locked worktree is still included +- the main worktree survives even if git somehow marks it prunable + +**Checks:** `npm test -- --run src/server/workspaces/gitWorktreeDiscovery.test.ts`, +plus the workspaceService/app.projects tests if they touch the shape, plus +`npm run typecheck` (`GitWorktreeInfo` is exported). + +--- + +## Leg 2 — Non-disruptive workspace topology refresh in the client + +**Why second:** this is the risky part, and it must be provably non-disruptive before +anything starts calling it automatically. + +**Files** + +- `src/client/src/controllers/workspaceController.ts` +- new `src/client/src/controllers/workspaceController.test.ts` + +**Work** + +1. Add a method — suggested name `refreshSelectedProjectTopology()` — that re-lists the + selected project's workspaces and applies them via the existing + `applyProjectWorkspaces` path. +2. **Selection invariants it must hold:** + - If the currently selected workspace is still present, do **not** call + `selectWorkspace`, do **not** clear the active session, do **not** reset + workspace-scoped state. Only `workspaces` / `workspacesByProjectId` change. + + **Read this before writing the method — the wrong version looks correct.** The + tempting shape, mirroring `selectProject()` six lines above it, is: refresh the list, + then "re-resolve the selection to be safe" via + `selectPreferredWorkspace(...)` + `await this.selectWorkspace(...)`. That is the bug. + `selectWorkspace` has **no already-selected guard**, so even when it re-picks the very + same workspace it unconditionally runs: + - `sessions.clearActiveSession()` → `socket.close()` (closes the session WebSocket + mid-stream), `clearPendingUpdates()`, `streamWatermark = undefined` (buffered deltas + dropped), and `setState({ selectedSession: undefined, messages: [] })` (chat empties); + - `setState({ ...resetWorkspaceScopedState() })` → clears `sessions`, `fileTree`, + `expandedDirs`, `selectedFilePath`, `selectedFileContent`, `gitStatus`, + `selectedDiffPath`, `selectedDiff`, `selectedStagedDiff`, `selectedTerminalId`. + + Because leg 3 calls this from `refreshAfterBrowserResume`, that would fire on **every** + alt-tab back into PI WEB — not only when a worktree actually changed — blanking the + chat, collapsing the file tree, and closing any open diff every time, and losing stream + deltas that arrive while the socket is down. Applying the list via + `applyProjectWorkspaces` alone is sufficient for the feature; `handleWorkspaceChange` + early-returns when the selected workspace id is unchanged, so a fresh-but-equal list + causes no downstream churn on its own. + - If nothing is selected, just apply the list. + - If the selected workspace **disappeared**, do not silently jump. Leave the + selection as-is and let the existing deletion path own recovery; the user is + currently working there and a surprise switch is worse than a stale label. + If leg 2 finds this cannot be left alone safely, that is the intervention signal. +3. Guard against machine/project changing mid-flight, exactly like `selectProject` does + (compare `selectedMachineId` and `selectedProject?.id` before applying). +4. Swallow-and-report errors the way sibling background refreshes do (`console.warn`, + not `setState({ error })`) — a background topology refresh must never paint an error + banner over a working session. + +**Tests** (controller-layer, fake `api.workspaces`) + +- a newly appeared worktree lands in `workspaces` and `workspacesByProjectId` +- the selected workspace is preserved; `sessions.clearActiveSession` is **not** called +- a stale response for a project the user has since left is discarded +- a rejected request does not set `state.error` + +**Checks:** `npm test -- --run src/client/src/controllers/workspaceController.test.ts`. + +--- + +## Leg 3 — Wire it to the existing resume path, document, changeset + +**Why last:** only after leg 2 proves the refresh is inert. + +**Files** + +- `src/client/src/components/PiWebApp.ts` — `refreshAfterBrowserResume` (~432) and + `refreshAppData` (~485). Touch only these two methods. +- possibly `src/client/src/components/PiWebApp.*.test.ts` (a focused new test file is fine) +- `docs/` — one short paragraph where workspaces/worktrees are explained; follow + `.agents/skills/documentation-guide/SKILL.md` and do **not** grow `README.md` +- `.changeset/*.md` + +**Work** + +1. Add `this.workspaces.refreshSelectedProjectTopology()` to the `Promise.all` in + `refreshAfterBrowserResume` and to `refreshAppData`. + - `BrowserResumeController` already debounces per animation frame and collapses + concurrent requests through `TrailingRefreshCoordinator`, so no extra throttling + is needed. Verified in leg 0. + - This inherits remote-machine support for free: `api.workspaces(projectId, machineId)` + already routes through the machine proxy, and `/projects/:projectId/workspaces` is + already in `FEDERATED_HTTP_ROUTES`. +2. Optionally also refresh on realtime-socket reconnect (`connectRealtime`'s + `onReconnect`), which is the same class of natural event. Only if it costs nothing. + Add the call directly; this relay owns the seam and is not coordinating with any + other branch. +3. Document the behavior honestly: detection happens when the tab regains focus / + becomes visible, not instantly. +4. Add the changeset (`npm run changeset`, or write the fragment directly). + +**Checks:** the new/affected client tests, then **`npm run verify`** — this is the final +leg and the change is cross-cutting. + +--- + +## Cost ledger (accepted in leg 0) + +| Cost | Amount | +|---|---| +| New processes | 0 | +| New watchers (inotify/fs.watch) | 0 | +| New timers | 0 | +| New endpoints / push channels | 0 | +| Extra request per browser resume, per selected project | 1 (~2ms of `git worktree list` server-side) | +| Production lines changed | ~60 | +| New test files | 3 | + +## Known risks + +- **UI churn on refresh.** Mitigated by leg 2's invariants and its tests. This is the + one that can kill the feature; it is an explicit intervention trigger. +- **Latency expectation.** Detection is resume-scoped. A user staring at an already-focused + tab while a worktree appears in another window sees nothing until they refocus. This is + an accepted, documented limit — not a bug to fix with a timer. +- **Overlap with the sibling `worktree-create-ui` effort.** Settled by the human: assume + that session does nothing. This relay owns the refresh seam; build it here without + designing for reuse, and do not read that worktree. diff --git a/.pi-web/relays/worktree-autodetect/status.md b/.pi-web/relays/worktree-autodetect/status.md new file mode 100644 index 0000000..abea448 --- /dev/null +++ b/.pi-web/relays/worktree-autodetect/status.md @@ -0,0 +1,90 @@ +# Status — relay "worktree-autodetect" + +## ✅ APPROVED — relay is live + +The human approved the reduced scope in leg 0 and answered every open question. There are +no outstanding decisions. Run leg 1. + +## Current position + +Leg 0 (assessment/design) is complete. No production code has been written. + +The recommendation is **reduced scope**: detection piggybacked on browser resume, plus a +fix for the inverse (removed-worktree) case. No watchers, no timers, no new processes, +no new push channel. Rationale is in `log.md` leg 0; the implementation breakdown is in +`plan.md`. + +## Leg tracking + +- **Last completed leg:** 0 (assessment and packet creation) +- **Next leg to run:** 1 + +## Next task — leg 1 + +Stop reporting worktrees whose checkout directory has been removed outside PI WEB. + +See `plan.md` → "Leg 1". Summary: teach the `git worktree list --porcelain` parser about +the `prunable` and `locked` keys, exclude prunable linked worktrees from the workspace +list, never filter the main worktree, never mutate the repo (no `git worktree prune`). + +Files: `src/server/workspaces/gitWorktreeDiscovery.ts`, `src/server/workspaces/workspaceService.ts`, +new `src/server/workspaces/gitWorktreeDiscovery.test.ts`. + +## Relevant context for the next runner + +Facts established in leg 0 — trust these, they were verified against the running code and +real git; do not re-derive them: + +- **Worktrees are already derived, never registered.** `WorkspaceService.list()` shells out + to `git worktree list --porcelain` on **every** `GET /projects/:projectId/workspaces` + request. There is no server-side cache and no invalidation to design. A worktree created + outside PI WEB is *already* discovered — the gap is purely that the browser never re-asks. +- **`projects.json` holds projects only, not workspaces.** So "auto-adoption" is a + non-problem: nothing needs to be written to a registry, and there is no adopt-vs-visible + distinction to design. This collapsed most of the feature's apparent complexity. +- **Cost of discovery is ~2ms** (measured: 20 sequential `git worktree list --porcelain` + runs in 42ms on this repo). +- **`prunable` is real and load-bearing.** After `rm -rf`ing a worktree directory without + `git worktree remove`, `git worktree list --porcelain` still lists it, with an added + `prunable gitdir file points to non-existent location` line. PI WEB currently shows this + as a normal selectable workspace. `locked` appears as a bare valueless line. +- **The resume path already exists and is already debounced.** + `src/client/src/appShell/browserResumeController.ts` listens to window `focus` and + document `visibilitychange`, batches per animation frame, and collapses concurrent + requests via `TrailingRefreshCoordinator`. It calls + `PiWebApp.refreshAfterBrowserResume()` (~line 432), which already refreshes the selected + session, machine activities, and workspace-deletion runs. Workspace topology is the one + thing missing from that list. +- **Remote machines come for free.** `workspacesApi.workspaces(projectId, machineId)` + routes through `machinePrefix`, and `GET /projects/:projectId/workspaces` is already in + `FEDERATED_HTTP_ROUTES` in `src/shared/federatedRoutes.ts`. No transport work needed. +- **The danger is `selectWorkspace`.** It calls `clearActiveSession()` and + `resetWorkspaceScopedState()`. A refresh must apply the new list via + `applyProjectWorkspaces` **only**, and must not route through `selectWorkspace` when the + selection is still valid. `ProjectActivityOwnershipCoordinator` is the existing precedent + for background topology hydration that does not disturb selection — read it if leg 2 + needs a model. +- **No session daemon involvement.** Nothing in this design touches `src/server/sessiond.ts`, + session runtime ownership, or the daemon protocol. **No manual sessiond restart needed.** + +## Progress documentation expected of each runner + +- Commit the slice (Conventional Commit message). +- Update this file: current position, leg tracking, next task, blockers. +- Append to `log.md`: what, why, artifacts, exact checks run and results. +- Add the `.changeset/*.md` fragment no later than leg 3. + +## Decisions settled by the human (do not re-open) + +1. **Latency: resume-scoped is acceptable.** Detection on tab refocus/visibility is the + agreed behavior. Do not add a timer or a watcher to shorten it. +2. **Removed worktrees: hide them.** Filtering `prunable` worktrees out of the workspace + list is wanted and approved. +3. **Sibling overlap: assume the other session does nothing.** This relay owns the + workspace-topology refresh seam outright. Build it here, do not design for sharing, + and do not read `/srv/dev/pi-web-worktrees/worktree-create-ui`. If that branch later + merges something overlapping, resolving it is that branch's problem, not this one's. + +## Blockers + +None. Leg 1 is clear to run.