pi-session-mcp 0.11.9 → 0.12.1

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 (75) hide show
  1. package/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +6 -2
  2. package/.pi-session-mcp-cli/doctor.js +89 -4
  3. package/.pi-session-mcp-cli/execution-modes.js +19 -5
  4. package/.pi-session-mcp-cli/override-lock.js +4 -4
  5. package/.pi-session-mcp-cli/setup-process.js +5 -4
  6. package/.pi-session-mcp-cli/writer-boundary.js +10 -4
  7. package/.pi-session-mcp-cli/writer-fence.js +50 -0
  8. package/.pi-session-mcp-cli/writer-lease.js +140 -0
  9. package/.pi-session-mcp-cli/writer-lifecycle.js +55 -23
  10. package/.pi-session-mcp-cli/writer-state.js +4 -2
  11. package/CHANGELOG.md +91 -0
  12. package/README.md +3 -2
  13. package/dist/capabilities.d.ts +2 -0
  14. package/dist/capabilities.js +4 -1
  15. package/dist/child-stdio-transport.d.ts +21 -0
  16. package/dist/child-stdio-transport.js +88 -0
  17. package/dist/client-adapters/mcp-verifier.js +6 -2
  18. package/dist/client-adapters/types.d.ts +1 -0
  19. package/dist/doctor.d.ts +3 -1
  20. package/dist/doctor.js +89 -4
  21. package/dist/execution-modes.d.ts +14 -2
  22. package/dist/execution-modes.js +19 -5
  23. package/dist/main.js +9 -2
  24. package/dist/override-lock.d.ts +2 -0
  25. package/dist/override-lock.js +4 -4
  26. package/dist/pi-adapter.d.ts +30 -0
  27. package/dist/pi-adapter.js +11 -0
  28. package/dist/refusal-server.d.ts +1 -1
  29. package/dist/refusal-server.js +24 -2
  30. package/dist/server.d.ts +2 -2
  31. package/dist/server.js +18 -53
  32. package/dist/session-registry.d.ts +9 -2
  33. package/dist/session-registry.js +17 -12
  34. package/dist/setup-cli.js +7 -2
  35. package/dist/setup-hooks.js +0 -2
  36. package/dist/setup-modes.js +7 -1
  37. package/dist/setup-process.d.ts +3 -1
  38. package/dist/setup-process.js +5 -4
  39. package/dist/setup-writers.d.ts +5 -0
  40. package/dist/setup-writers.js +203 -94
  41. package/dist/tool-schemas.d.ts +339 -0
  42. package/dist/tool-schemas.js +49 -0
  43. package/dist/wake-hook.d.ts +4 -5
  44. package/dist/wake-hook.js +3 -6
  45. package/dist/writer-acceptance.js +10 -1
  46. package/dist/writer-boundary.d.ts +6 -4
  47. package/dist/writer-boundary.js +10 -4
  48. package/dist/writer-fence.d.ts +12 -0
  49. package/dist/writer-fence.js +50 -0
  50. package/dist/writer-launch.d.ts +9 -1
  51. package/dist/writer-launch.js +127 -30
  52. package/dist/writer-lease.d.ts +28 -0
  53. package/dist/writer-lease.js +140 -0
  54. package/dist/writer-lifecycle.d.ts +7 -1
  55. package/dist/writer-lifecycle.js +55 -23
  56. package/dist/writer-relay.d.ts +33 -0
  57. package/dist/writer-relay.js +359 -0
  58. package/dist/writer-state.d.ts +13 -1
  59. package/dist/writer-state.js +4 -2
  60. package/docs/acceptance.md +172 -0
  61. package/docs/adr/0012-leased-writer-pool.md +357 -0
  62. package/docs/architecture.md +61 -13
  63. package/docs/client-setup.md +11 -15
  64. package/docs/doctor-design.md +10 -4
  65. package/docs/execution-modes.md +87 -44
  66. package/docs/installation.md +43 -22
  67. package/docs/isolation-setup.md +88 -102
  68. package/docs/roadmap.md +6 -0
  69. package/docs/threat-model.md +7 -4
  70. package/docs/tool-contracts.md +29 -12
  71. package/package.json +1 -1
  72. package/scripts/isolation-acceptance.mjs +7 -1
  73. package/skills/pi-session-implementation/SKILL.md +1 -0
  74. package/skills/pi-session-orchestration/SKILL.md +6 -5
  75. package/skills/pi-session-orchestration/references/start-errors.md +5 -1
@@ -1,27 +1,34 @@
1
- import { chmod, lstat, mkdir, readFile } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { loadConfig } from "./config.js";
5
6
  import { containerRuntimeUsable } from "./container-runtime.js";
6
7
  import { ALIAS_PATTERN } from "./execution-profile.js";
7
8
  import { runDoctor } from "./doctor.js";
9
+ import { MAX_WRITERS } from "./execution-modes.js";
8
10
  import { changeExecutionMode } from "./setup-modes.js";
9
11
  import { acquireOverrideLock, OverrideLockError } from "./override-lock.js";
10
12
  import { createCommandMcpStdioLauncher, createProcessRunner } from "./setup-process.js";
11
13
  import { DurableOwnershipStore } from "./setup-ownership.js";
12
14
  import { localHookNeededByOthers, ownsWakeHook, runWakeHook } from "./setup-hooks.js";
13
15
  import { result } from "./setup-result.js";
14
- import { createWriterRegistrationAdapter, registerWriter, unregisterWriter } from "./client-adapters/writer-registration.js";
16
+ import { createWriterRegistrationAdapter, unregisterWriter } from "./client-adapters/writer-registration.js";
15
17
  import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
16
18
  import { IMAGE_PULL_TIMEOUT_MS } from "./writer-boundary.js";
19
+ import { acquireWriterLease, WriterLeaseError } from "./writer-lease.js";
20
+ import { ensureWriterAbsent } from "./writer-fence.js";
17
21
  import * as lifecycle from "./writer-lifecycle.js";
18
22
  import * as acceptance from "./writer-acceptance.js";
19
23
  import { skillOverrideDirectory } from "./skill-library.js";
20
- import { readWriterState, writerEventDeliveryEnabled, writerName, writerPaths, writerRegistrationName, writersRoot, WRITER_NAME_PATTERN } from "./writer-state.js";
24
+ import { renderWrapper, wrapperDigest } from "./writer-wrapper.js";
25
+ import { readWriterState, writeWriterState, writerName, writerPaths, writerRegistrationName, writersRoot, WRITER_NAME_PATTERN } from "./writer-state.js";
26
+ export const SETUP_LEASE_WAIT_MS = 10_000;
27
+ const FENCE_CODES = ["writer_busy", "writer_fenced", "writer_lease_unavailable"];
21
28
  const safeAlias = (alias) => ALIAS_PATTERN.test(alias) ? alias : "invalid";
22
29
  const targetFor = (alias, name) => ({ client: "writer", scope: safeAlias(alias), alias: WRITER_NAME_PATTERN.test(name) || (alias === "host" && name === "control") ? name : "invalid" });
23
30
  const modesTarget = (alias) => ({ client: "modes", scope: "workspace", alias: safeAlias(alias) });
24
- const codeFor = (error) => error instanceof OverrideLockError ? error.code : error instanceof Error && /^[a-z][a-z0-9_]{0,63}$/.test(error.message) ? error.message : "writer_operation_failed";
31
+ const codeFor = (error) => error instanceof OverrideLockError || error instanceof WriterLeaseError ? error.code : error instanceof Error && /^[a-z][a-z0-9_]{0,63}$/.test(error.message) ? error.message : "writer_operation_failed";
25
32
  function services(deps) {
26
33
  const home = deps.home ?? homedir();
27
34
  const runner = deps.runner ?? createProcessRunner({ timeoutMs: IMAGE_PULL_TIMEOUT_MS, maxOutputBytes: 1_048_576, ...(deps.signal ? { signal: deps.signal } : {}) });
@@ -49,6 +56,27 @@ async function locked(deps, action) {
49
56
  await lock.release();
50
57
  }
51
58
  }
59
+ async function fenced(ctx, name, deps, action) {
60
+ let lease;
61
+ try {
62
+ lease = await (deps.lease ?? acquireWriterLease)(ctx.home, name, { waitMs: SETUP_LEASE_WAIT_MS, ...(deps.signal ? { signal: deps.signal } : {}) });
63
+ }
64
+ catch (error) {
65
+ throw new Error(error instanceof WriterLeaseError && error.code === "writer_busy" ? "writer_busy" : "writer_lease_unavailable");
66
+ }
67
+ try {
68
+ try {
69
+ await (deps.ensureAbsent ?? ensureWriterAbsent)(ctx.runner, writerPaths(ctx.home, name), name, { leaseFd: lease.fd });
70
+ }
71
+ catch {
72
+ throw new Error("writer_fenced");
73
+ }
74
+ return await action({ ...ctx, leaseFd: lease.fd });
75
+ }
76
+ finally {
77
+ await lease.release();
78
+ }
79
+ }
52
80
  function intent(ctx, name, client) {
53
81
  return { client, name: writerRegistrationName(name), wrapperPath: writerPaths(ctx.home, name).wrapper, projectDir: ctx.checkout };
54
82
  }
@@ -72,10 +100,6 @@ async function writerHook(ctx, name, deps, operation, enabled) {
72
100
  const outcome = operation === "apply" || operation === "remove" ? await ownership.transaction((access) => request(access, operation)) : await request(ownership, operation);
73
101
  return outcome.code === "hook_not_enabled" || outcome.code === "hook_not_owned" ? undefined : outcome;
74
102
  }
75
- /** Whether a provisioned writer's own configuration enables delivery; before provisioning the host configuration decides, as provisioning copies it. */
76
- async function writerDelivery(ctx, name, hostDelivery) {
77
- return await readWriterState(ctx.home, name) ? writerEventDeliveryEnabled(writerPaths(ctx.home, name).config) : hostDelivery;
78
- }
79
103
  class DoctorGateError extends Error {
80
104
  findings;
81
105
  constructor(findings) {
@@ -135,7 +159,7 @@ async function remove(ctx, name, discard, deps, extras = []) {
135
159
  const writerIndex = (name) => Number(name.slice(name.lastIndexOf("-w") + 2));
136
160
  async function plan(ctx, entry) {
137
161
  const keep = [], missing = [], surplus = [];
138
- for (let index = 1; index <= 16; index++) {
162
+ for (let index = 1; index <= MAX_WRITERS; index++) {
139
163
  const name = writerName(ctx.alias, index), state = await readWriterState(ctx.home, name);
140
164
  if (entry.mode === "container" && index <= entry.container.writers) {
141
165
  keep.push(name);
@@ -154,62 +178,121 @@ async function reconcileUnlocked(configPath, alias, request, deps) {
154
178
  const planned = await plan(ctx, entry), { life, accept, ownership, adapter } = services(deps);
155
179
  const add = (name, outcome) => { findings.push({ target: targetFor(alias, name), ...outcome }); };
156
180
  await doctorGate(ctx, deps);
157
- await preflight(ctx, planned.surplus, request.discard, deps);
158
- const rawConfig = JSON.parse(await readFile(configPath, "utf8")), overrideDirectory = skillOverrideDirectory(configPath);
159
- const failedProvision = new Set();
160
- for (const name of planned.missing) {
161
- const outcome = await life.provisionWriter(ctx, { index: writerIndex(name), settings: entry.container, rawConfig, overrideDirectory, ...(request.baseRef ? { baseRef: request.baseRef } : {}) });
162
- add(name, outcome);
163
- if (outcome.status === "failed")
164
- failedProvision.add(name);
165
- }
166
- // Writers left exactly as they are: neither accepted nor registered in this run.
167
- const held = new Set();
168
- for (const name of planned.keep) {
169
- if (failedProvision.has(name))
170
- continue;
171
- if (!planned.missing.includes(name)) {
172
- // Settings first: a writer that no longer matches its configuration is not accepted or registered again (#134).
173
- const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: entry.container, rawConfig, overrideDirectory });
174
- if (drift) {
175
- held.add(name);
176
- add(name, drift);
177
- continue;
178
- }
181
+ // A surplus writer that cannot be fenced is reported on its own target and left in place; the others proceed.
182
+ const unfenced = new Set();
183
+ for (const name of planned.surplus) {
184
+ try {
185
+ await fenced(ctx, name, deps, (leased) => preflight(leased, [name], request.discard, deps));
179
186
  }
180
- // A ready phase with a stale binding (package upgrade, new image, Podman version) is accepted again (#130).
181
- const current = await accept.acceptanceCurrent(ctx, name);
182
- if (current === "stale")
183
- add(name, await accept.acceptWriter(ctx, name));
184
- else if (current === "unknown") {
185
- held.add(name);
186
- add(name, { status: "failed", code: "podman_unavailable" });
187
+ catch (error) {
188
+ if (!FENCE_CODES.includes(codeFor(error)))
189
+ throw error;
190
+ unfenced.add(name);
191
+ add(name, { status: "failed", code: codeFor(error) });
187
192
  }
188
193
  }
194
+ const rawConfig = JSON.parse(await readFile(configPath, "utf8")), overrideDirectory = skillOverrideDirectory(configPath);
195
+ // Each writer's reconciliation, including first provisioning and acceptance, owns one fence.
189
196
  for (const name of planned.keep) {
190
- if (failedProvision.has(name) || held.has(name) || (await readWriterState(ctx.home, name))?.phase !== "ready")
191
- continue;
192
- for (const client of request.codex ? ["claude-code", "codex"] : ["claude-code"]) {
193
- const expected = intent(ctx, name, client), before = await adapter.inspect(expected);
194
- try {
195
- await registerWriter(ownership, adapter, expected);
196
- add(name, { status: before === "equivalent" ? "unchanged" : "ok", code: "writer_registered" });
197
- }
198
- catch (error) {
199
- add(name, { status: "failed", code: codeFor(error) });
200
- continue;
201
- }
202
- if (client === "claude-code") {
203
- const hook = await writerHook(ctx, name, deps, "apply", await writerEventDeliveryEnabled(writerPaths(ctx.home, name).config));
197
+ try {
198
+ await fenced(ctx, name, deps, async (ctx) => {
199
+ if (planned.missing.includes(name)) {
200
+ const outcome = await life.provisionWriter(ctx, { index: writerIndex(name), settings: entry.container, rawConfig, overrideDirectory, ...(request.baseRef ? { baseRef: request.baseRef } : {}) });
201
+ add(name, outcome);
202
+ if (outcome.status === "failed")
203
+ return;
204
+ }
205
+ // Settings first: a drifted writer is left exactly as it is, neither migrated nor accepted again (#134).
206
+ if (!planned.missing.includes(name)) {
207
+ const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: entry.container, rawConfig, overrideDirectory });
208
+ if (drift) {
209
+ add(name, drift);
210
+ return;
211
+ }
212
+ }
213
+ // Decide for both clients before removing anything, so a divergent registration of one client never
214
+ // costs the writer its owned registration of the other. A client that is not installed has nothing to keep.
215
+ let divergent = false;
216
+ const owned = [];
217
+ for (const client of ["claude-code", "codex"]) {
218
+ const expected = intent(ctx, name, client);
219
+ const record = await ownership.get({ client, scope: client === "codex" ? "user" : "local", alias: expected.name });
220
+ const current = await adapter.inspect(expected);
221
+ if (record ? current === "divergent" : current === "equivalent" || current === "divergent")
222
+ divergent = true;
223
+ else if (record)
224
+ owned.push(expected);
225
+ }
226
+ if (!divergent) {
227
+ for (const expected of owned) {
228
+ try {
229
+ await unregisterWriter(ownership, adapter, expected);
230
+ }
231
+ catch (error) {
232
+ if (codeFor(error) !== "ownership_diverged")
233
+ throw error;
234
+ divergent = true;
235
+ }
236
+ }
237
+ }
238
+ const hook = await writerHook(ctx, name, deps, "remove", false);
204
239
  if (hook)
205
240
  findings.push({ target: targetFor(alias, name), ...hook });
206
- }
241
+ const state = await readWriterState(ctx.home, name);
242
+ if (!state)
243
+ throw new Error("writer_state_invalid");
244
+ if (divergent) {
245
+ if (state.launch !== "legacy")
246
+ await writeWriterState(ctx.home, name, { ...state, launch: "legacy" });
247
+ add(name, { status: "failed", code: "registration_divergent" });
248
+ return;
249
+ }
250
+ const paths = writerPaths(ctx.home, name), wrapper = renderWrapper(ctx.nodePath, ctx.releaseRoot, name);
251
+ if (!(await lstat(paths.wrapper)).isFile())
252
+ throw new Error("writer_state_invalid");
253
+ const wrapperChanged = wrapperDigest(await readFile(paths.wrapper, "utf8")) !== wrapperDigest(wrapper);
254
+ if (wrapperChanged) {
255
+ const temporary = join(paths.root, `.wrapper-${randomUUID()}.tmp`);
256
+ try {
257
+ await writeFile(temporary, wrapper, { flag: "wx", mode: 0o700 });
258
+ await rename(temporary, paths.wrapper);
259
+ }
260
+ finally {
261
+ await rm(temporary, { force: true });
262
+ }
263
+ }
264
+ // A launch change also needs fresh provider-free acceptance, even when wrapper bytes match.
265
+ const migrated = state.launch !== "relay";
266
+ if (migrated || wrapperChanged) {
267
+ const { acceptance: _oldAcceptance, ...unaccepted } = state;
268
+ await writeWriterState(ctx.home, name, { ...unaccepted, launch: "relay", phase: "not_ready" });
269
+ }
270
+ // A ready phase with a stale binding (package upgrade, new image, Podman version) is accepted again (#130).
271
+ const current = await accept.acceptanceCurrent(ctx, name);
272
+ if (current === "stale") {
273
+ const outcome = await accept.acceptWriter(ctx, name);
274
+ add(name, outcome);
275
+ if (outcome.status === "failed")
276
+ return;
277
+ }
278
+ else if (current === "unknown") {
279
+ add(name, { status: "failed", code: "podman_unavailable" });
280
+ return;
281
+ }
282
+ });
283
+ }
284
+ catch (error) {
285
+ if (!FENCE_CODES.includes(codeFor(error)))
286
+ throw error;
287
+ add(name, { status: "failed", code: codeFor(error) });
207
288
  }
208
289
  }
209
290
  for (const name of planned.surplus) {
291
+ if (unfenced.has(name))
292
+ continue;
210
293
  const extras = [];
211
294
  try {
212
- add(name, await remove(ctx, name, request.discard, deps, extras));
295
+ add(name, await fenced(ctx, name, deps, (leased) => remove(leased, name, request.discard, deps, extras)));
213
296
  }
214
297
  catch (error) {
215
298
  add(name, { status: "failed", code: codeFor(error) });
@@ -240,7 +323,7 @@ export async function changeWriterMode(configPath, request, deps) {
240
323
  return changeExecutionMode(configPath, request, modeDependencies);
241
324
  }
242
325
  writerWork = config.workspaceModes.get(request.alias)?.mode === "container";
243
- for (let index = 1; index <= 16 && !writerWork; index++) {
326
+ for (let index = 1; index <= MAX_WRITERS && !writerWork; index++) {
244
327
  try {
245
328
  await lstat(join(writersRoot(deps.home ?? homedir()), writerName(request.alias, index)));
246
329
  writerWork = true;
@@ -263,15 +346,62 @@ export async function changeWriterMode(configPath, request, deps) {
263
346
  }
264
347
  const action = async () => {
265
348
  let proposed;
266
- return changeExecutionMode(configPath, request, {
349
+ // Record the base as provisioning resolves it, so a refused or remote-tracking form never reaches the modes file.
350
+ let recorded = request;
351
+ if (request.baseRef !== undefined) {
352
+ try {
353
+ recorded = { ...request, baseRef: await services(deps).life.baseBranchName(await context(request.alias, deps), request.baseRef) };
354
+ }
355
+ catch (error) {
356
+ return failed(codeFor(error));
357
+ }
358
+ }
359
+ else if (request.mode === "container" || request.mode === undefined && await containerRuntime()) {
360
+ const config = await loadConfig(configPath);
361
+ if (!config.workspaceModes.get(request.alias)?.container?.base) {
362
+ const bases = new Set();
363
+ for (let index = 1; index <= MAX_WRITERS; index++) {
364
+ const state = await readWriterState(deps.home ?? homedir(), writerName(request.alias, index));
365
+ if (state)
366
+ bases.add(state.base.ref);
367
+ }
368
+ if (bases.size > 1)
369
+ return result(operation, [{ target: modesTarget(request.alias), status: "failed", code: "workspace_base_ambiguous" }]);
370
+ if (bases.size === 1)
371
+ recorded = { ...request, baseRef: [...bases][0].replace(/^refs\/heads\//, "") };
372
+ // First provisioning: record the base it will resolve (origin/HEAD, else main), so default starts find writers.
373
+ else if (request.mode === "container") {
374
+ try {
375
+ recorded = { ...request, baseRef: await services(deps).life.baseBranchName(await context(request.alias, deps)) };
376
+ }
377
+ catch (error) {
378
+ return failed(codeFor(error));
379
+ }
380
+ }
381
+ }
382
+ }
383
+ return changeExecutionMode(configPath, recorded, {
267
384
  ...modeDependencies,
268
385
  beforeWrite: async (entry) => {
269
386
  proposed = entry;
270
387
  const ctx = await context(request.alias, deps), planned = await plan(ctx, entry);
271
388
  try {
272
- if (request.apply)
389
+ if (request.apply) {
273
390
  await doctorGate(ctx, deps);
274
- await preflight(ctx, planned.surplus, request.discard ?? false, deps);
391
+ for (const name of planned.surplus) {
392
+ try {
393
+ await fenced(ctx, name, deps, (leased) => preflight(leased, [name], request.discard ?? false, deps));
394
+ }
395
+ catch (error) {
396
+ // Refuse the mode change, but name the writer that could not be fenced.
397
+ if (FENCE_CODES.includes(codeFor(error)))
398
+ return result(operation, [{ target: targetFor(request.alias, name), status: "failed", code: codeFor(error) }]);
399
+ throw error;
400
+ }
401
+ }
402
+ }
403
+ else
404
+ await preflight(ctx, planned.surplus, request.discard ?? false, deps);
275
405
  }
276
406
  catch (error) {
277
407
  return error instanceof DoctorGateError ? result(operation, [...failed(error.message).findings, ...error.findings]) : failed(codeFor(error));
@@ -282,32 +412,22 @@ export async function changeWriterMode(configPath, request, deps) {
282
412
  let writers;
283
413
  try {
284
414
  if (request.apply)
285
- writers = await reconcileUnlocked(configPath, request.alias, { discard: request.discard ?? false, codex: request.codex ?? false, ...(request.baseRef ? { baseRef: request.baseRef } : {}) }, deps);
415
+ writers = await reconcileUnlocked(configPath, request.alias, { discard: request.discard ?? false, ...(request.baseRef ? { baseRef: request.baseRef } : {}) }, deps);
286
416
  else {
287
417
  const ctx = await context(request.alias, deps), planned = await plan(ctx, proposed), { life } = services(deps);
288
- const hostDelivery = (await loadConfig(configPath)).eventDelivery === "spool", hooks = [], kept = [];
418
+ const kept = [];
289
419
  const rawConfig = JSON.parse(await readFile(configPath, "utf8")), overrideDirectory = skillOverrideDirectory(configPath);
290
420
  for (const name of planned.keep) {
291
421
  // The same settings check as apply, so a drifted writer shows before anything is applied (#134).
292
422
  const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: proposed.container, rawConfig, overrideDirectory });
293
423
  kept.push({ target: targetFor(request.alias, name), ...(drift ?? { status: "planned", code: "writer_planned" }) });
294
424
  }
295
- for (const name of planned.keep) {
296
- const hook = await writerHook(ctx, name, deps, "dry-run", await writerDelivery(ctx, name, hostDelivery));
297
- if (hook)
298
- hooks.push({ target: targetFor(request.alias, name), ...hook });
299
- }
300
- for (const name of planned.surplus) {
301
- const hook = await writerHook(ctx, name, deps, "dry-run", false);
302
- if (hook)
303
- hooks.push({ target: targetFor(request.alias, name), ...hook });
304
- }
305
- const refused = [...kept, ...hooks].some((finding) => finding.status === "failed" || finding.status === "divergent");
306
- writers = result("dry-run", [...kept, ...planned.surplus.map((name) => ({ target: targetFor(request.alias, name), status: "planned", code: "writer_remove_planned" })), ...hooks], refused ? "failed" : "planned");
425
+ const refused = kept.some((finding) => finding.status === "failed" || finding.status === "divergent");
426
+ writers = result("dry-run", [...kept, ...planned.surplus.map((name) => ({ target: targetFor(request.alias, name), status: "planned", code: "writer_remove_planned" }))], refused ? "failed" : "planned");
307
427
  }
308
428
  }
309
429
  catch (error) {
310
- writers = error instanceof DoctorGateError ? result(operation, [...failed(error.message).findings, ...error.findings]) : failed("writer_reconcile_failed");
430
+ writers = error instanceof DoctorGateError ? result(operation, [...failed(error.message).findings, ...error.findings]) : failed(FENCE_CODES.includes(codeFor(error)) ? codeFor(error) : "writer_reconcile_failed");
311
431
  }
312
432
  const findings = [...modes.findings, ...writers.findings].filter((f) => f.code !== "controllers_restart_required");
313
433
  if (request.apply && findings.some((f) => f.status === "ok"))
@@ -329,12 +449,9 @@ export async function runWriterOperation(name, operation, opts, deps) {
329
449
  if (!WRITER_NAME_PATTERN.test(name))
330
450
  return result(reportOperation, [{ target: { client: "invalid", scope: "invalid", alias: "invalid" }, status: "failed", code: "usage_invalid" }], "failed", 64);
331
451
  const alias = name.slice(0, name.lastIndexOf("-w")), target = targetFor(alias, name);
332
- // A reset, recreate or finish clears the writer's projected credentials; its running server keeps stale ones until reconnected (#136).
333
- const reconnectRequired = (outcome) => opts.apply && outcome.status === "ok" && (operation === "reset" || operation === "recreate" || operation === "finish");
334
452
  const done = (outcome, hooks = []) => result(reportOperation, [
335
453
  { target, ...outcome },
336
454
  ...hooks.map((hook) => ({ target, ...hook })),
337
- ...(reconnectRequired(outcome) ? [{ target, status: "ok", code: "writer_reconnect_required" }] : []),
338
455
  ], outcome.status === "planned" ? "planned" : undefined);
339
456
  if ((deps.platform ?? process.platform) !== "linux")
340
457
  return done({ status: "failed", code: "platform_unsupported" });
@@ -343,27 +460,16 @@ export async function runWriterOperation(name, operation, opts, deps) {
343
460
  await context(alias, deps);
344
461
  return done({ status: "planned", code: `writer_${operation}_planned` });
345
462
  }
346
- return await locked(deps, async () => {
347
- const ctx = await context(alias, deps), { life, ownership, adapter } = services(deps);
463
+ return await locked(deps, async () => fenced(await context(alias, deps), name, deps, async (ctx) => {
464
+ const { life } = services(deps);
348
465
  if (operation === "verify") {
349
466
  const doctor = await (deps.doctor ?? runDoctor)({ home: ctx.home, packageRoot: deps.releaseRoot, configPath: deps.configPath, loadConfig: () => loadConfig(deps.configPath) });
350
467
  if (doctor.checks.some((check) => check.severity === "error" && (check.subject === name || GLOBAL_ERRORS.has(check.id))))
351
468
  return done({ status: "failed", code: "doctor_failed" });
352
- for (const client of ["claude-code", "codex"]) {
353
- const expected = intent(ctx, name, client);
354
- const owned = await ownership.get({ client, scope: client === "codex" ? "user" : "local", alias: expected.name });
355
- const current = await adapter.inspect(expected);
356
- if ((client === "claude-code" || owned || current !== "absent") && current !== "equivalent")
357
- return done({ status: "failed", code: "registration_not_equivalent" });
358
- }
359
- const checked = await (deps.verify ?? verifyMcpCapabilities)({ launch: (deps.launch ?? createCommandMcpStdioLauncher)([writerPaths(ctx.home, name).wrapper], { HOME: ctx.home, PATH: process.env.PATH ?? "", PI_WRITER_NO_CREDENTIAL_REFRESH: "1", ...(process.env.XDG_RUNTIME_DIR === undefined ? {} : { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR }) }) });
469
+ const checked = await (deps.verify ?? verifyMcpCapabilities)({ launch: (deps.launch ?? createCommandMcpStdioLauncher)([writerPaths(ctx.home, name).wrapper], { HOME: ctx.home, PATH: process.env.PATH ?? "", PI_WRITER_NO_CREDENTIAL_REFRESH: "1", PI_WRITER_LEASE_FD: "3", ...(process.env.XDG_RUNTIME_DIR === undefined ? {} : { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR }) }, { inheritFds: [ctx.leaseFd] }) });
360
470
  if (checked.status !== "verified")
361
471
  return done({ status: "failed", code: `mcp_${checked.status}` });
362
- // The hook is part of the Claude Code registration: verified after it, from the writer's own configuration.
363
- const hook = await writerHook(ctx, name, deps, "verify", await writerEventDeliveryEnabled(writerPaths(ctx.home, name).config));
364
- if (hook?.status === "failed" || hook?.status === "divergent")
365
- return done({ status: "failed", code: hook.code });
366
- return done({ status: "ok", code: "mcp_verified" }, hook ? [hook] : []);
472
+ return done({ status: "ok", code: "mcp_verified" });
367
473
  }
368
474
  try {
369
475
  await doctorGate(ctx, deps);
@@ -373,14 +479,17 @@ export async function runWriterOperation(name, operation, opts, deps) {
373
479
  return result(reportOperation, [...done({ status: "failed", code: error.message }).findings, ...error.findings]);
374
480
  throw error;
375
481
  }
482
+ const entry = (await loadConfig(deps.configPath)).workspaceModes.get(alias);
483
+ const baseRef = opts.baseRef ?? entry?.container?.base;
484
+ const baseOptions = baseRef === undefined ? {} : { baseRef };
376
485
  const extras = [];
377
486
  const outcome = operation === "remove" ? await remove(ctx, name, opts.discard, deps, extras)
378
487
  : operation === "collect" ? await life.collectWriter(ctx, name)
379
488
  : operation === "reset" ? await life.resetWriter(ctx, name, opts)
380
- : operation === "recreate" ? await life.recreateWriter(ctx, name, opts)
381
- : await life.finishWriter(ctx, name);
489
+ : operation === "recreate" ? await life.recreateWriter(ctx, name, { ...opts, ...baseOptions })
490
+ : await life.finishWriter(ctx, name, baseOptions);
382
491
  return done(outcome, extras);
383
- });
492
+ }));
384
493
  }
385
494
  catch (error) {
386
495
  return done({ status: "failed", code: codeFor(error) });