docs(relay): add worktree-autodetect relay packet

Assessment of auto-detecting worktrees created outside PI WEB. Worktree
discovery is already derived per request with no cache and no registry, so
the gap is that the browser never re-lists. Recommends the reduced scope:
filter prunable worktrees, add a non-disruptive topology refresh, and call
it from the existing browser-resume path. No watchers, timers, processes, or
push channels.

Packet is repo-only and outside the published files allowlist, so no
changeset is needed.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-26 22:25:59 +02:00
parent bd4a891b95
commit 8a3fe0daa6
4 changed files with 586 additions and 0 deletions
@@ -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 <file>`, 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 <N> 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.
+197
View File
@@ -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/<name>` 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`.
+168
View File
@@ -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 <reason>` 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.
@@ -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.