pi-session-mcp 0.11.9 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.pi-session-mcp-cli/doctor.js +89 -4
  2. package/.pi-session-mcp-cli/execution-modes.js +16 -4
  3. package/.pi-session-mcp-cli/override-lock.js +4 -4
  4. package/.pi-session-mcp-cli/setup-process.js +5 -4
  5. package/.pi-session-mcp-cli/writer-boundary.js +10 -4
  6. package/.pi-session-mcp-cli/writer-fence.js +50 -0
  7. package/.pi-session-mcp-cli/writer-lease.js +140 -0
  8. package/.pi-session-mcp-cli/writer-lifecycle.js +55 -23
  9. package/.pi-session-mcp-cli/writer-state.js +4 -2
  10. package/CHANGELOG.md +69 -0
  11. package/README.md +3 -2
  12. package/dist/capabilities.d.ts +2 -0
  13. package/dist/capabilities.js +4 -1
  14. package/dist/child-stdio-transport.d.ts +21 -0
  15. package/dist/child-stdio-transport.js +88 -0
  16. package/dist/client-adapters/types.d.ts +1 -0
  17. package/dist/doctor.d.ts +3 -1
  18. package/dist/doctor.js +89 -4
  19. package/dist/execution-modes.d.ts +12 -2
  20. package/dist/execution-modes.js +16 -4
  21. package/dist/main.js +9 -2
  22. package/dist/override-lock.d.ts +2 -0
  23. package/dist/override-lock.js +4 -4
  24. package/dist/pi-adapter.d.ts +30 -0
  25. package/dist/pi-adapter.js +11 -0
  26. package/dist/refusal-server.d.ts +1 -1
  27. package/dist/refusal-server.js +24 -2
  28. package/dist/server.d.ts +2 -2
  29. package/dist/server.js +18 -53
  30. package/dist/session-registry.d.ts +9 -2
  31. package/dist/session-registry.js +17 -12
  32. package/dist/setup-cli.js +7 -2
  33. package/dist/setup-hooks.js +0 -2
  34. package/dist/setup-modes.js +7 -1
  35. package/dist/setup-process.d.ts +3 -1
  36. package/dist/setup-process.js +5 -4
  37. package/dist/setup-writers.d.ts +5 -0
  38. package/dist/setup-writers.js +200 -92
  39. package/dist/tool-schemas.d.ts +339 -0
  40. package/dist/tool-schemas.js +49 -0
  41. package/dist/wake-hook.d.ts +4 -5
  42. package/dist/wake-hook.js +3 -6
  43. package/dist/writer-acceptance.js +10 -1
  44. package/dist/writer-boundary.d.ts +6 -4
  45. package/dist/writer-boundary.js +10 -4
  46. package/dist/writer-fence.d.ts +12 -0
  47. package/dist/writer-fence.js +50 -0
  48. package/dist/writer-launch.d.ts +9 -1
  49. package/dist/writer-launch.js +127 -30
  50. package/dist/writer-lease.d.ts +28 -0
  51. package/dist/writer-lease.js +140 -0
  52. package/dist/writer-lifecycle.d.ts +7 -1
  53. package/dist/writer-lifecycle.js +55 -23
  54. package/dist/writer-relay.d.ts +33 -0
  55. package/dist/writer-relay.js +359 -0
  56. package/dist/writer-state.d.ts +13 -1
  57. package/dist/writer-state.js +4 -2
  58. package/docs/acceptance.md +47 -0
  59. package/docs/adr/0012-leased-writer-pool.md +357 -0
  60. package/docs/architecture.md +61 -13
  61. package/docs/client-setup.md +11 -15
  62. package/docs/doctor-design.md +10 -4
  63. package/docs/execution-modes.md +87 -44
  64. package/docs/installation.md +34 -22
  65. package/docs/isolation-setup.md +88 -102
  66. package/docs/roadmap.md +5 -0
  67. package/docs/threat-model.md +7 -4
  68. package/docs/tool-contracts.md +29 -12
  69. package/package.json +1 -1
  70. package/scripts/isolation-acceptance.mjs +7 -1
  71. package/skills/pi-session-implementation/SKILL.md +1 -0
  72. package/skills/pi-session-orchestration/SKILL.md +6 -5
  73. package/skills/pi-session-orchestration/references/start-errors.md +5 -1
@@ -0,0 +1,357 @@
1
+ # ADR 0012: One registration with a leased writer pool
2
+
3
+ **Status:** Accepted (2026-10-28, maintainer decision) for release 0.12.0;
4
+ implementation tracked in
5
+ [#157](https://github.com/plagor44-ux/pi-session-mcp/issues/157). Amends
6
+ [ADR 0002](0002-embedded-pi-sdk.md) (container sessions are relayed to a Pi
7
+ session in a writer process; host sessions stay embedded),
8
+ [ADR 0008](0008-coding-profile-isolation.md) (adopts its option 4 in container
9
+ form and retires the client registration per writer),
10
+ [ADR 0009](0009-execution-modes.md) (container entries record a default base;
11
+ coding on `container` workspaces runs through the host server) and
12
+ [ADR 0011](0011-turn-events-and-controller-wake.md) (relayed sessions use the
13
+ host spool).
14
+
15
+ ## Context
16
+
17
+ Today every container writer is its own client registration,
18
+ `pi-coding-<alias>-w<n>`, for Claude Code and optionally for Codex. Its
19
+ `wrapper.sh` runs the writer launcher (`writer-launch`), which checks state and
20
+ acceptance, projects boundary credentials, refreshes them every 60 s and runs
21
+ `podman run -i --rm …` with a pi-session-mcp server inside the writer's
22
+ container, bound to that writer's full clone. This follows ADR 0008: one
23
+ wrapped registration sees the union of what it mounts, so writers are isolated
24
+ from each other only with one boundary per writer.
25
+
26
+ Observed costs on 2026-10-27, with four writers for each of several
27
+ repositories:
28
+
29
+ - Each client session holds one tool set per writer registration plus the host
30
+ set, and the controller has to pick a writer by tool prefix.
31
+ - Each registration starts its container when the client connects and keeps it
32
+ for the whole client session: 15 idle writer containers were running on one
33
+ workstation.
34
+ - Reset, finish and recreate replace the writer's projected credentials, so the
35
+ running writer server needs an MCP reconnect before the next coding start
36
+ (#136, `writer_reconnect_required`).
37
+ - Nothing stops two client processes from launching the same writer at the
38
+ same time. The writer guard's `writer_busy` counts sessions inside one server
39
+ process only.
40
+ - The wake hook accepts writer tool names only up to `-w16`.
41
+ - Each writer has its own base branch. `--reset --base <branch>` moves a writer
42
+ to another branch, for example for a fix round on collected work, and a
43
+ later `--finish` resets it to that same branch again. Today the controller
44
+ reaches that writer by its tool prefix; a pool that picks any writer at
45
+ baseline would ignore which branch the writer is on.
46
+
47
+ ADR 0008 listed "one sandboxed worker process per session, with the server
48
+ relaying MCP calls" as option 4 and deferred it to its own decision. ADR 0002
49
+ chose embedding to avoid subprocess and RPC translation; that choice stays for
50
+ host sessions.
51
+
52
+ ## Decision
53
+
54
+ Coding on a `container` workspace runs through the one host registration,
55
+ `pi-session-mcp`. Per coding session the host server leases a writer of that
56
+ workspace, starts the writer's existing launcher as a child process and relays
57
+ the session to the pi-session-mcp server inside the writer's container.
58
+
59
+ ### 1. Transport and launch path
60
+
61
+ - Each client still starts its own host server over stdio. No HTTP, socket,
62
+ daemon or shared process is added.
63
+ - The host server spawns the writer's `wrapper.sh` with stdio pipes and talks
64
+ MCP to the inner server through it. The launcher keeps its role: state and
65
+ acceptance checks, credential projection and the maintainer-approved
66
+ `getAuth` refresh (#142) stay in the launcher process, so the AGENTS.md
67
+ exception is not widened.
68
+ - The launcher gives the session container a fixed name derived from the
69
+ writer name (`pi-writer-<name>`), and every container that touches a writer,
70
+ including setup's throwaway containers, a label `pi-session-mcp.writer=<name>`.
71
+ Podman refuses a second container with the fixed name while one exists.
72
+
73
+ ### 2. Fence: a lease held by the guardian process, plus container absence
74
+
75
+ Exclusive use of a writer needs both:
76
+
77
+ - **Lease held by the trusted host-side process that acts on the writer.** For a
78
+ session that is the launcher, not the host server; for setup operations it is
79
+ the setup process. A lock file per writer lives in a host-only lease directory
80
+ (`~/.local/state/pi-session-mcp/leases/<name>.lock`), outside every writer
81
+ root, never deleted, opened without following links and checked for type,
82
+ owner and mode. The launcher takes it with a true non-blocking `flock` as its
83
+ first step and holds it for its whole lifetime: credential warm-up and
84
+ projection, container run, refresh, and cleanup. Setup takes it with a
85
+ bounded wait.
86
+ - **The lease descriptor is inherited by every mutating host-side child.** The
87
+ holder passes the locked descriptor to each child that can change the writer
88
+ (Podman client calls, host Git against the bundles, a nested launcher started
89
+ by acceptance), so the lock's open file description stays locked until the
90
+ last of these processes ends. A killed holder therefore cannot leave a
91
+ pending `podman run` or Git child behind that acts after the next holder has
92
+ taken over. A nested launcher receives the descriptor and its number
93
+ explicitly, verifies that it refers to the lease file (device and inode) and
94
+ holds the lock, and uses it instead of opening a new one; this is how
95
+ acceptance launches the wrapper while setup holds the fence. The descriptor
96
+ is never passed into a container (no `--preserve-fds`); tests must confirm
97
+ that neither the container nor Podman's monitor process holds it.
98
+ - **Container absence.** After taking the lease, and before any credential
99
+ write or clone change, the holder checks through Podman that no container
100
+ with the writer's label exists and that no container mounts the writer's
101
+ clone (this also finds unlabeled containers started by older releases). A
102
+ container found while holding the lease belongs to a dead holder: the holder
103
+ stops it, waits a bounded time until Podman reports it removed, and kills it
104
+ if needed. If absence cannot be confirmed, the writer stays fenced and the
105
+ operation fails.
106
+
107
+ A launcher that outlives its host server keeps the lease until it has stopped
108
+ its container and joined its refresh. Only a killed launcher can leave a
109
+ container behind, and the next holder removes that container before it touches
110
+ the writer.
111
+
112
+ ### 3. Every mutating operation takes the fence
113
+
114
+ Coding sessions, `--collect`, `--reset`, `--finish`, `--recreate`, `--remove`,
115
+ acceptance and every provisioning reconciliation or recovery path run under the
116
+ same fence for the whole operation, not after a separate busy check. A setup
117
+ operation that cannot get the fence reports `writer_busy`.
118
+
119
+ ### 4. Base selection
120
+
121
+ - The controller selects work by base branch, not by writer. `pi_session_start`
122
+ gains an optional `base`: a short branch name, validated with the same
123
+ safe-ref rules setup applies to `--base`, without `refs/` prefix, commit IDs,
124
+ `HEAD` or path syntax. It is accepted only for coding profiles on `container`
125
+ workspaces; elsewhere the start fails with `base_not_applicable` before
126
+ creating a session.
127
+ - Each container workspace records a **default base** in its
128
+ `execution-modes.json` entry (`base`, a short branch name). Provisioning sets
129
+ it from `--base` or, without one, from the existing resolution
130
+ (`origin/HEAD`, else `main`). A repeated mode request without `--base` keeps
131
+ the recorded value.
132
+ - A start without `base` uses the default base. A writer is a candidate only if
133
+ its recorded `base.ref` equals `refs/heads/<base>`. Matching reads host-side
134
+ writer state only, never the clone, and compares the branch, not the commit:
135
+ the start result reports the writer's base commit, so the controller sees
136
+ which state it got. Selection happens before the lease; once the inner start
137
+ succeeded, the relay reads the writer state again and closes the session and
138
+ moves on if the base changed in between.
139
+ - `--finish` resets a persistent writer, and recreates an ephemeral one, on the
140
+ workspace default base instead of the writer's current base. Moving a writer
141
+ to another branch stays an explicit `--reset --base <branch>`. A writer
142
+ therefore leaves the default pool only for the time between that reset and
143
+ its finish.
144
+ - Doctor warns offline with `writer_off_default_base` and a count when ready
145
+ writers sit on another branch, because they shrink the default pool.
146
+
147
+ The fix-round flow becomes: create a local branch at the collected ref, run
148
+ `--writer <name> --reset --base <branch> --apply`, start with
149
+ `base: "<branch>"`. Default starts, from this or another client, cannot land on
150
+ that writer, and the fix-round start cannot land on a writer at the default
151
+ base.
152
+
153
+ ### 5. Allocation
154
+
155
+ - Candidates are the workspace's writers in index order whose state is `ready`,
156
+ whose acceptance is current and whose base matches (section 4). There is no
157
+ host-side pre-filter of the clone's contents.
158
+ - The host server starts the launcher of the first candidate. A launcher that
159
+ cannot take the lease returns `writer_busy`; an inner start that refuses
160
+ because the clone is not at baseline or holds uncollected work also moves on.
161
+ In both cases the host server closes that launcher and tries the next
162
+ candidate. The inner writer guard stays the authority for baseline admission
163
+ inside the boundary.
164
+ - Before the inner start the relay compares the requested profile's entry in
165
+ the writer's validated `pi_capabilities_get` (permission profile, provider,
166
+ model, thinking level) with the host's configured profile and moves on with
167
+ `writer_settings_changed` on any difference, so a writer never runs an
168
+ outdated version of a host profile. The launcher cannot do this check: it
169
+ starts before the profile is known.
170
+ - If no candidate is admitted, the start fails with `writer_unavailable`, a
171
+ content-free reason and content-free counts. `none_ready`: no writer of the
172
+ workspace is ready, migrated and has a recorded acceptance (whether that
173
+ acceptance is still current is decided by the launcher, which refuses a
174
+ stale one). `none_at_base`: ready writers
175
+ exist, none on the requested base. `none_admitted`: candidates existed and
176
+ each refused; the counts `busy`, `notAtBaseline` and `settingsChanged` say
177
+ why.
178
+ - The controller cannot name a writer in this version (see Alternatives).
179
+
180
+ ### 6. Start, budgets and uncertain outcomes
181
+
182
+ - A container start has its own end-to-end budget (initial value 60 s, to be
183
+ measured), split into launch and credential warm-up, MCP handshake,
184
+ validated `pi_capabilities_get`, and inner `pi_session_start`. Every step has
185
+ a bound below the remaining budget (cleanup of a refused candidate has its
186
+ own bound on top), and cancellation of the outer start is
187
+ propagated.
188
+ - The launcher's refusal server answers the handshake and lists tools, then
189
+ returns refusal codes. Readiness is therefore decided by the validated
190
+ capabilities and the inner start result, never by exit status or the tool
191
+ list. Inner refusals map to the existing public codes.
192
+ - Relayed prompts get an outer preflight deadline longer than the inner one.
193
+ If the outer side times out or loses the response while the inner turn may
194
+ have been accepted, the relay aborts the inner turn, waits for its terminal
195
+ state within the abort budget and reports the outer prompt as failed. If the
196
+ inner state cannot be determined, the session is marked lost (see 8).
197
+
198
+ ### 7. Relay behind `PiSessionAdapter`
199
+
200
+ - The relay is a `PiSessionAdapter`. The host registry keeps owning session
201
+ and turn IDs, the one-running-turn rule, terminal state and the host event
202
+ spool, so the wake hook sees relayed turns like host turns.
203
+ - The relay observes the inner turn with bounded `pi_turn_get` polling inside
204
+ the host process. It advances activity only when the inner `updatedAt`
205
+ changes, and carries the inner `truncated` flag into the outer result
206
+ (`PiTurnOutcome` gains an optional `truncated`).
207
+ - **Abort.** The relay sends `pi_session_abort`, then reads the inner turn and
208
+ settles the outer turn with the inner turn's actual terminal state: a turn
209
+ that completed before the abort stays `completed`. A failed or timed-out
210
+ abort marks the session lost (see 8): further prompts are refused, get and
211
+ close keep working.
212
+ - Inner responses are untrusted data: schema-validated, size-bounded and never
213
+ passed through as raw frames.
214
+
215
+ ### 8. Lost sessions and close
216
+
217
+ - If the child exits or the transport breaks, the running turn, if any, ends
218
+ `failed`, and the session becomes lost: further prompts return
219
+ `writer_session_lost`; get and close keep working. This also applies while
220
+ the session is idle.
221
+ - `pi_session_close`, server shutdown and client transport loss close the
222
+ inner session and then the launcher's stdin. The launcher runs in its own
223
+ process group and treats stdin end, `SIGTERM` and `SIGHUP` alike: it stops
224
+ scheduling refreshes, joins an outstanding refresh, stops and removes its
225
+ container, confirms its absence, and only then exits and so releases the
226
+ lease. Its cleanup has its own bound, longer than the longest refresh.
227
+ - The host server does not wait for the full cleanup: close returns once the
228
+ inner session is closed and the launcher has been told to stop. Fencing does
229
+ not depend on the host server, because the launcher still holds the lease. A
230
+ launcher whose cleanup exceeds its bound exits without releasing the
231
+ container check: the next holder finds and removes the container (section 2).
232
+
233
+ ### 9. Visibility
234
+
235
+ `pi_session_start` and `pi_session_get` report the leased writer name and its
236
+ base branch and commit for container sessions, so the controller can collect
237
+ that writer's work and see which state the session started from.
238
+ `pi_capabilities_get` reports each container workspace's configured writer
239
+ count and default base, not a free count or the branches of other writers.
240
+
241
+ ### 10. Events
242
+
243
+ Relayed sessions emit terminal events through the host registry into the host
244
+ spool. The launcher starts relayed containers without the writer spool mount.
245
+ The wake hook matches the host tool names only.
246
+
247
+ ### 11. Setup, migration and acceptance
248
+
249
+ - Container mode no longer registers writer servers with any client.
250
+ - Migration needs a quiescence barrier. The upgrade runbook keeps the existing
251
+ rule to close coding sessions and controllers first, so no launcher of the
252
+ old release is in flight, and the wrapper rebinding below makes every later
253
+ launch, also through a not yet removed writer registration, run the new
254
+ launcher, which takes the lease and checks container absence.
255
+ - The migration step then, per writer and under the fence, confirms that no
256
+ container mounts the clone, removes the `pi-coding-<alias>-w<n>`
257
+ registrations that setup owns, and marks the writer `launch: "relay"` in its
258
+ state. Removal uses the existing ownership-checked unregister path. A
259
+ client's inspection can health-check the registration and so start its
260
+ wrapper, but only while setup holds the writer's lease: that launch then
261
+ serves a `writer_busy` refusal and starts no container. A
262
+ writer whose registration is divergent stays out of the pool and is reported;
263
+ setup never replaces or deletes a divergent registration. Because every
264
+ launch repeats the absence check, a legacy launch that slips through later is
265
+ still fenced.
266
+ - Migration records the workspace default base (section 4). If all writers
267
+ share one base branch, that branch is recorded; otherwise migration requires
268
+ an explicit `--base` and stops with `workspace_base_ambiguous` without it.
269
+ - `wrapper.sh` stays as the launch entry point at its stable path. Wrappers
270
+ embed an absolute release root, so migration rewrites the wrapper of every
271
+ writer it migrates for the new release before the writer is marked
272
+ `launch: "relay"`. A writer held back by settings drift or a divergent
273
+ registration keeps its wrapper unchanged; it stays out of the pool, and its
274
+ retained registration must not be used until the operator resolves it. With
275
+ a release installed at the same path, such a wrapper already runs the new,
276
+ fenced launcher. Acceptance keeps binding the wrapper, release version and
277
+ launch settings. Because the launch arguments
278
+ change (container name, no spool mount for relayed sessions), every writer is
279
+ re-accepted provider-free through the relay launch path before it becomes a
280
+ pool candidate, as on any release upgrade.
281
+ - Verify checks the host registration, Doctor, and for each writer a bounded
282
+ spawn through the relay path with credential refresh suppressed, MCP
283
+ handshake, `tools/list` and validated capabilities, without starting a
284
+ session or using a provider.
285
+ - `writer_reconnect_required` is removed: reset and finish run under the fence
286
+ and no long-lived writer server exists between sessions.
287
+ - Doctor stays offline. It gains checks for the lease directory, for writers
288
+ not yet migrated, for container workspaces without a recorded default base,
289
+ and the `writer_off_default_base` warning (section 4).
290
+
291
+ ## Boundary
292
+
293
+ The product boundary stays local stdio, in-memory state and one trusted local
294
+ MCP peer per server process. The writer boundary is unchanged: one container
295
+ and one full clone per writer, the same mounts and exclusions, `.git/config`
296
+ and hooks read-only, and no host Git inside a clone. What changes is who starts
297
+ the launcher (the host server instead of the client) and when (per session
298
+ instead of per client connection). The host server already runs with the
299
+ operator's rights; setup and the launcher already run Podman today.
300
+
301
+ ## Consequences
302
+
303
+ - One tool set per client; no per-writer registrations; no idle writer
304
+ containers.
305
+ - A coding start on a container workspace takes seconds longer.
306
+ - No reconnect after reset or finish. Reset, finish and collect refuse while a
307
+ session holds the writer.
308
+ - Two clients cannot use the same writer at the same time, including after a
309
+ crash of the previous holder.
310
+ - Follow-up rounds on collected work keep the branch and reset steps and add
311
+ `base: "<branch>"` to the start (section 4). Default starts never land on a
312
+ writer moved to another branch.
313
+ - `--finish` returns a writer to the workspace default base instead of keeping
314
+ the branch it was last reset to.
315
+ - Contract change: the optional `base` start argument, new error codes
316
+ (`writer_unavailable` with reason and counts, `writer_session_lost`,
317
+ `base_not_applicable`), the writer and base fields in session results, the
318
+ default base in capabilities and an optional `truncated` in the adapter
319
+ outcome. Minor version bump (0.12.0) with re-acceptance of every writer.
320
+
321
+ ## Alternatives considered
322
+
323
+ - **Keep one registration per writer and add only the fence.** Fixes the
324
+ concurrent-launch gap, but keeps the tool sets, idle containers and
325
+ reconnects. Rejected as the end state; the fence is reused.
326
+ - **Fewer writers per repository.** Configuration only; reduces but keeps every
327
+ cost. Rejected by the maintainer (2026-10-28).
328
+ - **A shared pool daemon over HTTP or a Unix socket.** Would let clients share
329
+ sessions, but adds a long-lived service, persistence and multi-client trust,
330
+ all outside the product boundary. Not needed for the goal.
331
+ - **Spawn `podman run` from the host server directly.** Moves the credential
332
+ refresh into the host server and widens the AGENTS.md exception, and drops
333
+ the acceptance binding to the wrapper. Rejected in favour of spawning the
334
+ launcher.
335
+ - **Pass the lease descriptor into the container.** Would keep the lock until
336
+ the container ends, but a process inside could release it with `LOCK_UN` on
337
+ the shared open file description. Rejected in favour of a host-side holder.
338
+ - **Hold the lease in the host server.** The host server can die while its
339
+ launcher still writes credentials or starts a container. Rejected in favour of
340
+ the launcher as the holder.
341
+ - **Server picks any writer at baseline, ignoring its base.** Simplest
342
+ contract, but a fix-round start can land on a writer at the default base, and
343
+ an unrelated start can land on a writer just reset to a fix branch, also from
344
+ another client. Rejected.
345
+ - **Controller names the writer.** Deterministic and close to today's flow,
346
+ but capabilities would have to list writer names, a pinned busy writer fails
347
+ instead of falling back, and a start without a name could still land on a
348
+ writer moved to another branch, so base matching would be needed anyway.
349
+ Deferred; a diagnostic writer argument can be added later without breaking
350
+ the contract.
351
+ - **Preferred writer with silent fallback.** Can silently start work from the
352
+ wrong branch. Rejected.
353
+
354
+ ## Open questions
355
+
356
+ - Measured start budget on the reference machines.
357
+ - Whether ephemeral writers recreate on close instead of on finish.
@@ -13,9 +13,9 @@ validated config --> capability projection --> McpServer discovery tool
13
13
  |
14
14
  +---------> SessionRegistry --> PiSessionAdapter
15
15
  | |
16
- in-memory state SDK Pi adapter
17
- session + turn ModelRuntime + AgentSession
18
- read models
16
+ in-memory state routing adapter
17
+ session + turn host: SDK ModelRuntime + AgentSession
18
+ read models container coding: writer relay
19
19
  ```
20
20
 
21
21
  There are three separate local CLI paths: the MCP stdio server (`main.ts`),
@@ -49,6 +49,55 @@ registration.
49
49
 
50
50
  `pi_capabilities_get` projects the validated workspace and execution-profile maps plus the immutable configuration metadata captured while loading the file. It returns aliases plus the configured permission/provider/model/thinking intent, the configured extension specs and the selected skill aliases, sorted by alias, `configuration: { fingerprint, reloadPolicy: "restart-required" }` and `events: { delivery: "spool" | "off" }`. The fingerprint is SHA-256 over the exact loaded file bytes, including formatting; it is not a current-file freshness check. The projection makes no provider, authentication, filesystem, network, server-reachability, or catalog-state probe, so it cannot assert availability.
51
51
 
52
+ ## Leased container writers
53
+
54
+ [ADR 0012](adr/0012-leased-writer-pool.md) routes coding on `container`
55
+ workspaces through the one host registration `pi-session-mcp`, without
56
+ per-writer registrations, tool sets or writer selection by prefix. The host
57
+ spawns a writer's stable wrapper per session and relays MCP stdio behind
58
+ `PiSessionAdapter`. Host and read-only sessions stay embedded. Each client
59
+ still owns a separate host process; there is no daemon or shared session store.
60
+
61
+ Optional start `base` is a short branch name, applicable only to container
62
+ coding; elsewhere it fails with `base_not_applicable`. Omission uses the
63
+ workspace default base. Allocation tries ready accepted relay writers in index
64
+ order on that branch, checks the inner profile selection against the host and
65
+ leaves baseline admission to the inner guard. Start and get report `writer`
66
+ and `base: { ref, commit }`; capabilities report configured `writers` and
67
+ `defaultBase`, not free capacity. `writer_unavailable` gives content-free
68
+ reason/counts; transport loss makes the session lost (`writer_session_lost`
69
+ on later prompts), while get and close still work.
70
+
71
+ The launcher is the session lease holder, not the host server. Its host-only
72
+ `~/.local/state/pi-session-mcp/leases/<name>.lock` is opened without following
73
+ links, checked for type/owner/mode, never deleted, and held throughout warm-up,
74
+ credential projection, refresh, container use and cleanup. The directory is
75
+ 0700 and file 0600. Setup takes the same lease for every mutating operation,
76
+ including collection, acceptance and reconciliation, with a 10-second wait
77
+ before `writer_busy`. Mutating host children inherit the locked descriptor;
78
+ nested acceptance launchers verify and adopt it. It never enters a container
79
+ or Podman's monitor.
80
+
81
+ After taking the lease, before credential or clone changes, the holder confirms
82
+ absence of containers labeled `pi-session-mcp.writer=<name>` and any container
83
+ mounting the clone, including unlabeled legacy containers. It removes remnants
84
+ of a dead holder and fails closed (`writer_fenced`) if absence is uncertain.
85
+ Session containers use the fixed name `pi-writer-<name>`; setup throwaway
86
+ containers also carry the label. On stdin end, SIGTERM or SIGHUP, the launcher
87
+ stops refresh scheduling, joins outstanding refresh, stops/removes the
88
+ container and checks absence before exiting. Close tells the launcher to stop
89
+ without waiting for all cleanup; its lease still fences the clone. A killed
90
+ launcher leaves cleanup to the next holder's absence check.
91
+
92
+ `--finish` returns writers to the workspace default base. A fix round uses a
93
+ local branch at the collected ref, `--writer <name> --reset --base <branch>
94
+ --apply`, then start with `base: "<branch>"` and collect the returned `writer`.
95
+ No reconnect is needed after reset, finish or recreate. Inner replies are
96
+ validated and bounded; the host owns IDs, turn state and terminal events.
97
+ Relayed turns use bounded inner polling; only inner `updatedAt` changes advance
98
+ activity, and inner truncation survives projection. Abort observes the actual
99
+ inner outcome, so a completed turn remains completed in an abort race.
100
+
52
101
  ## Deterministic Pi selection
53
102
 
54
103
  The SDK adapter owns one durable `ModelRuntime` for profiles without extensions. (A profile with `extensions` creates a fresh runtime for each session; see [Pi extensions design](pi-extensions-design.md).) For the configured profile, it uses that runtime to call `getProvider(providerId)`, `getModel(providerId, modelId)`, `checkAuth()`, `getAuth(model)`, and `getAvailable(providerId)`. It rejects an absent provider or model, missing or unavailable local authentication, and a model not present in the local available-model set before constructing a session. Authentication uses only Pi's existing local mechanism; auth objects are presence-checked and never retained, serialized, or logged.
@@ -73,9 +122,9 @@ The registry owns public UUIDs, lifecycle state, the turn read model, background
73
122
 
74
123
  `pi_session_prompt` creates a provisional turn identity, invokes Pi preflight, and only after accepted preflight publishes `{session, turn}`; rejected preflight leaves no orphan turn. Completion returns to `idle`; failure is generic. Each active session retains at most 20 turns. A turn is `running`, `completed`, `failed`, or `aborted`; its public view contains IDs, timestamps, and terminal `completedAt`. While a turn runs, its `updatedAt` is the time of the last observed public SDK activity (assistant message and tool execution events, content-free) and is a liveness lower bound only; see [Tool contracts](tool-contracts.md). Completed text is normalized and capped at 64 KiB UTF-8 with Unicode-safe truncation. It can contain model-visible workspace data and remains potentially sensitive.
75
124
 
76
- Session metadata contains only whitelisted values: public session ID, workspace alias, execution-profile alias, derived permission `profile`, post-verified provider/model/effective thinking level, lifecycle state, and timestamps. Capability metadata uses a separate whitelist: server name/version, loaded configuration fingerprint/reload policy, workspace aliases, and configured profile fields, including the selected skill aliases. Neither view contains workspace paths, full model objects, SDK session IDs, auth sources, credential paths, headers, base URLs, prompts, transcripts, or raw provider errors.
125
+ Session metadata contains only whitelisted values: public session ID, workspace alias, execution-profile alias, derived permission `profile`, post-verified provider/model/effective thinking level, lifecycle state, timestamps, and for container sessions the writer and base ref/commit. Capability metadata uses a separate whitelist: server name/version, loaded configuration fingerprint/reload policy, workspace aliases, and configured profile fields, including the selected skill aliases. Neither view contains workspace paths, full model objects, SDK session IDs, auth sources, credential paths, headers, base URLs, prompts, transcripts, or raw provider errors.
77
126
 
78
- Profile-specific authentication/catalog operations and session creation have fixed server-side deadlines of 5,000 ms and 10,000 ms. Prompt preflight, abort, close cleanup, registry shutdown, and process shutdown have fixed server-side deadlines of 5,000 ms, 5,000 ms, 5,000 ms, 12,000 ms, and 15,000 ms respectively. A preflight timeout returns `prompt_timeout` without publishing a turn. The unpublished operation remains quarantined until its prompt and abort cannot race with later work; late settlement can release that quarantine but never publish the turn. An abort timeout returns `abort_timeout` without falsely marking the turn aborted; a later normal outcome or successful SDK abort may still terminalize it exactly once. Close marks the session terminal, removes its public registry entry deterministically, and performs best-effort abort/dispose. Repeated close calls return a cached closed view while its tombstone is retained. Each SDK handle is disposed at most once. A late promise cannot revive a removed session or mutate a removed turn.
127
+ Embedded profile-specific authentication/catalog operations and session creation have fixed server-side deadlines of 5,000 ms and 10,000 ms. Relayed container starts have a 60-second budget; their prompt preflight, abort and close cleanup use 15,000 ms, 15,000 ms and 10,000 ms respectively. Embedded prompt preflight, abort and close cleanup, plus registry shutdown and process shutdown, have fixed server-side deadlines of 5,000 ms, 5,000 ms, 5,000 ms, 12,000 ms, and 15,000 ms respectively. A preflight timeout returns `prompt_timeout` without publishing a turn. The unpublished operation remains quarantined until its prompt and abort cannot race with later work; late settlement can release that quarantine but never publish the turn. An abort timeout returns `abort_timeout` without falsely marking the turn aborted; a later normal outcome or successful SDK abort may still terminalize it exactly once. Close marks the session terminal, removes its public registry entry deterministically, and performs best-effort abort/dispose. Repeated close calls return a cached closed view while its tombstone is retained. Each SDK handle is disposed at most once. A late promise cannot revive a removed session or mutate a removed turn.
79
128
 
80
129
  The public SDK does not provide a hard-kill guarantee for a blocked provider/network operation, so a timeout must not be described as graceful provider cancellation. `SIGINT`, `SIGTERM`, and stdin EOF from the MCP client start the same process shutdown. The first trigger wins and sets the exit status (130, 143, or 0). Stdin EOF or the first `SIGINT` or `SIGTERM` during a running shutdown neither restarts it nor changes that status; a signal that arrives a second time keeps Node's default effect and ends the process immediately. Shutdown closes registry entries and the MCP transport best-effort, then explicitly exits the Pi Session MCP process no later than its 15,000 ms deadline. That terminal process policy may cut off still-running external work and does not prove the provider stopped cleanly. Closed tombstones are FIFO-retained up to 100 entries and contain no turn data. All sessions, turns, tombstones, and loaded configuration snapshots disappear on process exit; there is no persistence or reconnect/resume.
81
130
 
@@ -94,7 +143,7 @@ Claude Code asyncRewake <-- exit 2 + one line <-- pi-session-mcp-wake <---+
94
143
  ```
95
144
 
96
145
  - **Observer.** `SessionRegistry` calls the optional `onTurnTerminal` listener from the one place that makes a published turn terminal (`notifyTerminal`), once per turn, with session ID, turn ID, state (`completed`, `failed` or `aborted`) and `via`. `via` is `close` when `close` marked a still running turn aborted and `registry` otherwise. A rejected, timed-out or restored preflight never had a public turn and emits nothing. A throwing listener is swallowed; it cannot change registry state or reach a caller.
97
- - **Spool.** `src/main.ts` opens a `TurnEventSpool` at startup only when delivery is `spool` and writes one `turn_terminal` event per notice. An unusable spool root stops the server with the startup code `event_spool_unavailable`. Without any spool location (no `PI_SESSION_MCP_EVENTS_DIR` and no absolute `XDG_RUNTIME_DIR`, as when Codex starts the server) delivery stays inactive instead: the server starts, `pi_capabilities_get` reports `events.delivery: "off"`, and one `event_delivery_inactive` warning goes to stderr (#113). Later write failures never reach the registry: they are counted, and one sanitized `event_spool_write_failed` diagnostic goes to stderr. An instance keeps only its newest 256 event files (it deletes older ones by name from an in-memory list and never lists the directory), so a long-lived server cannot outgrow the hook's bounded scan. If a writer launch has pruned the instance directory as idle, the next write recreates it once under the same owner and mode rules.
146
+ - **Spool.** `src/main.ts` opens a `TurnEventSpool` at startup only when delivery is `spool` and writes one `turn_terminal` event per notice. An unusable spool root stops the server with the startup code `event_spool_unavailable`. Without any spool location (no `PI_SESSION_MCP_EVENTS_DIR` and no absolute `XDG_RUNTIME_DIR`, as when Codex starts the server) delivery stays inactive instead: the server starts, `pi_capabilities_get` reports `events.delivery: "off"`, and one `event_delivery_inactive` warning goes to stderr (#113). Later write failures never reach the registry: they are counted, and one sanitized `event_spool_write_failed` diagnostic goes to stderr. An instance keeps only its newest 256 event files (it deletes older ones by name from an in-memory list and never lists the directory), so a long-lived server cannot outgrow the hook's bounded scan.
98
147
  - **Event.** One file per event, `<8-digit sequence>-<kind>.json`, at most 1,024 bytes, mode 0600, written to a temporary name and renamed into place. It holds `v`, `seq`, `at`, `instance` and, for `turn_terminal`, `session`, `turn`, `state` and `via`; `instance_ended` holds a `reason` (`eof`, `signal` or `shutdown`) instead. Never a prompt, an answer, a path or an error text. `src/turn-events.ts` owns the contract and its strict parser; the server and the hook program share it.
99
148
  - **Hook.** `pi-session-mcp-wake` (`dist/wake-main.js`) is a Claude Code `PostToolUse` command hook with `asyncRewake`. It arms on `pi_session_prompt` and on `pi_turn_get` results that show a running turn, waits for that turn's event, claims it exactly once and exits 2 with one line on stderr, which Claude Code turns into a new model request. After its wait budget it wakes with a different line so that the controller reads once and re-arms. Every other path exits 0 silently.
100
149
  - **Controller.** It reads `pi_turn_get` once. An event is a wake trigger and never state: the registry read model stays the only truth, and the controller never acts on a state taken from a wake line.
@@ -106,20 +155,19 @@ $XDG_RUNTIME_DIR/pi-session-mcp/ runtime tmpfs, cleared at logout
106
155
  events/ 0700, owned by the operator
107
156
  inst-<16 hex>/ one per host server process
108
157
  claims/ wake claims, never inside events/
109
-
110
- ~/.local/state/pi-session-mcp/writers/<name>/ on disk, one per container writer
111
- events/ 0700, mounted at /pi/events
112
- inst-<16 hex>/ one per server process in that writer
113
158
  ```
114
159
 
115
- A host server uses `events/` itself as its spool root unless `PI_SESSION_MCP_EVENTS_DIR` names an absolute directory; the writer wrapper sets it to `/pi/events`, so inside the boundary the instance directories sit directly in the mount. The root is created with mode 0700 if missing and must be a real directory of the current user without group or other access. Each server process creates its own instance directory.
160
+ A host server uses `events/` itself as its spool root unless `PI_SESSION_MCP_EVENTS_DIR` names an absolute directory. The root is created with mode 0700 if missing and must be a real directory of the current user without group or other access. Each server process creates its own instance directory.
116
161
 
117
162
  - **Start.** The instance directory is created when the server starts with delivery enabled.
118
163
  - **Stop.** After the registry shutdown has finished, so that the terminal events which `close` generates come first, the spool writes `instance_ended` and removes its instance directory immediately afterwards. A reader therefore normally never sees `instance_ended`: it is a best-effort marker for a reader that is already scanning the directory at that moment, and no waiter relies on it. A waiter ends with a wake, silently on a `via: "close"` event for its turn, when its namespace has no instance directory after a short grace period, when its client process goes away, or at its budget. This runs on stdin EOF, signals, a failed start and the shutdown deadline. A crash or `SIGKILL` leaves a stale instance directory of content-free files; nothing cleans it up automatically, and the hook reads only the 64 newest instance directories of a namespace.
119
164
  - **Claims.** The hook creates `claims/<namespace>-<instance>-<sequence>` exclusively; the winner wakes, so one event wakes at most once even with several waiting hooks. A hook that is killed before its wake was delivered releases its claim. Claims older than 24 hours are removed by later hook runs.
120
- - **Not state.** The server never reads the spool back, nothing of the host spool survives a logout (the runtime directory is a tmpfs on systemd hosts), a container writer's spool is on disk and only what a crashed server left behind outlives a logout (a later launch prunes it after 24 hours), and no event is replayed. This is the narrow, content-free exception to in-memory-only operation that ADR 0011 records.
165
+ - **Not state.** The server never reads the spool back, nothing of the host spool survives a logout (the runtime directory is a tmpfs on systemd hosts), and no event is replayed. This is the narrow, content-free exception to in-memory-only operation that ADR 0011 records.
121
166
 
122
- Container writers get one dedicated spool directory each, on disk in the writer's own state directory (not on the runtime tmpfs), bind-mounted read-write; see [Isolation setup](isolation-setup.md#event-spool-mount). Codex has no external wake and keeps polling.
167
+ Relayed sessions emit events through the host registry and host spool, without
168
+ per-session writer spool mounts or writer hook entries. The wake hook matches
169
+ only `mcp__pi-session-mcp__*`; see [Isolation setup](isolation-setup.md#event-spool-mount).
170
+ Codex has no external wake and keeps polling.
123
171
 
124
172
  ## Scope
125
173
 
@@ -208,10 +208,10 @@ of the target's scope, after the registration succeeded. Guided steps and limits
208
208
 
209
209
  **Where the hook reads.** The hook that a `pi-session-mcp` tool call arms reads only
210
210
  the `inst-*` directories directly under `$XDG_RUNTIME_DIR/pi-session-mcp/events`.
211
- The hook that a `pi-coding-<name>-wN` tool call arms reads only the `inst-*`
212
- directories of that writer's own spool, `~/.local/state/pi-session-mcp/writers/<name>-wN/events`.
213
- It reads no other location, and it keeps its claims and waiter locks in
214
- `$XDG_RUNTIME_DIR/pi-session-mcp/claims`, outside both spools.
211
+ Relayed container sessions emit through that host spool too. The hook matches
212
+ only `mcp__pi-session-mcp__*`; writer hook entries and per-session writer spool
213
+ mounts are gone. It reads no other location, and keeps claims and waiter locks
214
+ in `$XDG_RUNTIME_DIR/pi-session-mcp/claims`, outside the spool.
215
215
 
216
216
  `<project>` is the real path of the directory in which setup runs. Claude Code
217
217
  has no command for hooks, so setup follows the contract of the settings file,
@@ -262,17 +262,13 @@ applicable), delivery off while setup holds no entry (`hook_not_enabled`), and
262
262
  setup report with delivery off and no owned entry therefore has the same
263
263
  findings, order and exit code as before the hook existed.
264
264
 
265
- Writer registrations (`pi-coding-<name>`, Claude Code local scope) get the entry
266
- in `<project>/.claude/settings.local.json` of the operator's project when the
267
- writer's own configuration enables delivery. One entry and one record serve the
268
- project, so it is removed only when no other owned registration needs it: a
269
- writer of the same project whose configuration enables delivery, or the owned
270
- host registration `claude-code:local:pi-session-mcp` while the host
271
- configuration enables delivery (its project is not recorded, so it counts for
272
- every project). When in doubt the entry stays. A hook problem while removing a
273
- writer is reported (`settings_*`, `hook_divergent`) but does not keep the
274
- writer: its registration and files are removed, and the record keeps the entry
275
- owned for the next removal. Codex writer registrations get no hook.
265
+ Container provisioning creates no writer client registrations or writer hooks.
266
+ Coding for either client uses the one host `pi-session-mcp` registration and a
267
+ coding profile, not a per-writer tool prefix. `--client codex` has no writer
268
+ effect. Migration removes owned legacy registrations and hook ownership under
269
+ the writer fence; divergent entries are retained and reported, never replaced.
270
+ An owned shared legacy hook can stay while another registration needs it.
271
+ Use the [container upgrade runbook](installation.md#container-writers).
276
272
 
277
273
  Setup never replaces or deletes an entry that it did not write or that was
278
274
  edited, and it keeps all other settings. The entry is not part of the MCP
@@ -34,10 +34,15 @@ and owners are:
34
34
  | `workspace_not_writable` | A workspace is not writable while a coding profile can target it | validated workspace alias |
35
35
  | `workspace_gitfile` | Warning. A workspace's `.git` is a regular file starting with `gitdir:` while at least one execution profile has the `coding` permission profile | validated workspace alias |
36
36
  | `workspace_mode_unset` | Warning. A workspace has mode `unset` while at least one execution profile has the `coding` permission profile | validated workspace alias |
37
+ | `workspace_base_unset` | Warning. A workspace has mode `container` without a recorded default base in its execution-modes entry | validated workspace alias |
37
38
  | `workspace_host_writes_accepted` | Always `ok`, without remediation. The operator has explicitly accepted host writes for this workspace | validated workspace alias |
38
39
  | `container_runtime_unavailable` | A workspace has mode `container` and the local rootless Podman probe fails | validated workspace alias |
39
40
  | `container_profile_refused` | A selected container coding profile cannot be rendered: missing profile, extensions or MCP grants, non-false default, non-coding permission, invalid skills, non-builtin/unshipped/operator-overridden skills, or schema-invalid rendered configuration (error); one ok per workspace when all selected profiles pass; one workspace error saying selected profiles could not be checked if the original configuration cannot be read | workspace alias:profile alias on refusal; workspace alias on success or unreadable configuration |
41
+ | `writer_lease_directory` | Error when `~/.local/state/pi-session-mcp/leases` cannot be inspected, or exists as a symbolic link, is not a real directory, is not owned by the current user, or has a mode other than exactly 0700; an absent directory is not reported. A wrong owner or mode is repaired in place: lease files keep their inodes, so the directory is never moved or deleted while a writer may hold a lease | `writer lease` |
40
42
  | `writer_not_ready` | State is missing or invalid, phase is not ready, acceptance fingerprint is stale, or the local image is absent (error) | validated writer name |
43
+ | `writer_not_migrated` | Warning. The writer state still records the legacy launch, so the writer is not in the relay pool | validated writer name |
44
+ | `writer_off_default_base` | Warning. One or more ready writers of a container workspace have a base other than the workspace default base; the remediation states only their count | validated workspace alias |
45
+ | `writer_git_identity_missing` | Clone Git config lacks a readable user name and email (warning); set both in the operator checkout, then reset the writer | validated writer name |
41
46
  | `writer_dirty` | HEAD is not the base branch, its commit differs, another branch exists, or Git metadata cannot safely be inspected (warning) | validated writer name |
42
47
  | `writer_uncollected_bundle` | Bundles directory is unsafe/unreadable or contains entries other than base.bundle (warning; an absent directory alone does not warn) | validated writer name |
43
48
  | `extension_missing` | Pi has no user install of a package that a profile's `extensions` names | `<profile>:<package>` |
@@ -70,9 +75,10 @@ never Doctor actions. Under Plan B, `--isolation-control --repo <alias> --apply`
70
75
  writes real canaries below $HOME and removes them; omitting apply only plans.
71
76
  Doctor never runs this control, refreshes credentials, inspects client
72
77
  registrations or starts containers. In contrast, setup's writer verify checks
73
- Doctor, registration equality and a bounded direct MCP handshake/tools/list/
74
- capabilities; Claude's registration inspection health-check briefly starts a
75
- container. Its direct verification launch disables credential refresh.
78
+ Doctor and a bounded relay-path MCP handshake/tools/list/capabilities;
79
+ client-target verify checks host registration equality separately. Claude's host
80
+ registration inspection starts no writer container. The direct writer
81
+ verification launch disables credential refresh and starts no session.
76
82
  Doctor's metadata-only dirty warning is not the server's stronger coding guard:
77
83
  that guard also checks stash and uncommitted/untracked work on every admission.
78
84
  See [Execution modes](execution-modes.md#writer-storage-launch-and-readiness).
@@ -85,7 +91,7 @@ The skill checks run after the extension checks. They look only at file availabi
85
91
 
86
92
  `skill_override_outdated` concerns the operator's skill overrides, not a profile. It is `ok` for a recorded base that still matches the shipped skill, and a warning (exit status 1, never blocking `setup`) when the shipped skill changed after the operator ran `pi-session-mcp-setup --customize-skill <name>`; its remediation names `pi-session-mcp-setup --apply --customize-skill <name>` to acknowledge a reviewed change. Only skills with a recorded base are compared, and a recorded name that is no longer shipped is skipped. A recorded base of 0.8.0 (a version 1 fingerprint) is compared through the version 2 value of the released 0.8.0 skill it names; a version 1 base that names no released skill cannot be confirmed and yields a warning that says so, with the same remediation. A corrupt bases file yields a warning with the subject `skill:bases`. Doctor reads the bases file and the shipped skill files only; nothing is executed and the subject is a validated skill name, never a path. The check sorts after `skill_duplicate`.
87
93
 
88
- `event_spool_root` is the last check and exists only for a configuration that enables turn-event delivery ([ADR 0011](adr/0011-turn-events-and-controller-wake.md)); with delivery off or unset it is absent from the report. It inspects `$XDG_RUNTIME_DIR/pi-session-mcp` and, when they exist, its `events` and `claims` entries with one `lstat` each, and never creates, repairs or reads anything below them. The rule is the one the server applies to the events root at startup (`TurnEventSpool.open`: a real directory of the current user without group or other access) and the hook applies to the claims directory, so Doctor is not green where startup would fail with `event_spool_unavailable` for these directories. When `PI_SESSION_MCP_EVENTS_DIR` is set in the server's environment the server uses that directory instead of `events`; Doctor does not read it. A missing runtime directory variable is only a warning, because Doctor may run in another environment than the server; the server itself fails with the startup code `event_spool_unavailable` when it has neither that directory nor `PI_SESSION_MCP_EVENTS_DIR`. The check says nothing about writer spool directories: a writer's launch creates and verifies its own (`events_directory_unavailable`, see [Isolation setup](isolation-setup.md#event-spool-mount)). The report holds the fixed subject `event spool` and a fixed remediation, never a path.
94
+ `event_spool_root` is the last check and exists only for a configuration that enables turn-event delivery ([ADR 0011](adr/0011-turn-events-and-controller-wake.md)); with delivery off or unset it is absent from the report. It inspects `$XDG_RUNTIME_DIR/pi-session-mcp` and, when they exist, its `events` and `claims` entries with one `lstat` each, and never creates, repairs or reads anything below them. The rule is the one the server applies to the events root at startup (`TurnEventSpool.open`: a real directory of the current user without group or other access) and the hook applies to the claims directory, so Doctor is not green where startup would fail with `event_spool_unavailable` for these directories. When `PI_SESSION_MCP_EVENTS_DIR` is set in the server's environment the server uses that directory instead of `events`; Doctor does not read it. A missing runtime directory variable is only a warning, because Doctor may run in another environment than the server; the server itself keeps delivery inactive when it has neither that directory nor `PI_SESSION_MCP_EVENTS_DIR`, with an `event_delivery_inactive` warning. Relayed container sessions use the host spool; there are no per-session writer spool mounts (see [Isolation setup](isolation-setup.md#event-spool-mount)). The report holds the fixed subject `event spool` and a fixed remediation, never a path.
89
95
 
90
96
  The Doctor reuses `loadConfig`; it does not duplicate alias, profile, thinking
91
97
  level, or read-only-default rules. Workspace values are used only for local