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
@@ -1,4 +1,5 @@
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";
@@ -11,17 +12,22 @@ import { createCommandMcpStdioLauncher, createProcessRunner } from "./setup-proc
11
12
  import { DurableOwnershipStore } from "./setup-ownership.js";
12
13
  import { localHookNeededByOthers, ownsWakeHook, runWakeHook } from "./setup-hooks.js";
13
14
  import { result } from "./setup-result.js";
14
- import { createWriterRegistrationAdapter, registerWriter, unregisterWriter } from "./client-adapters/writer-registration.js";
15
+ import { createWriterRegistrationAdapter, unregisterWriter } from "./client-adapters/writer-registration.js";
15
16
  import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
16
17
  import { IMAGE_PULL_TIMEOUT_MS } from "./writer-boundary.js";
18
+ import { acquireWriterLease, WriterLeaseError } from "./writer-lease.js";
19
+ import { ensureWriterAbsent } from "./writer-fence.js";
17
20
  import * as lifecycle from "./writer-lifecycle.js";
18
21
  import * as acceptance from "./writer-acceptance.js";
19
22
  import { skillOverrideDirectory } from "./skill-library.js";
20
- import { readWriterState, writerEventDeliveryEnabled, writerName, writerPaths, writerRegistrationName, writersRoot, WRITER_NAME_PATTERN } from "./writer-state.js";
23
+ import { renderWrapper, wrapperDigest } from "./writer-wrapper.js";
24
+ import { readWriterState, writeWriterState, writerName, writerPaths, writerRegistrationName, writersRoot, WRITER_NAME_PATTERN } from "./writer-state.js";
25
+ export const SETUP_LEASE_WAIT_MS = 10_000;
26
+ const FENCE_CODES = ["writer_busy", "writer_fenced", "writer_lease_unavailable"];
21
27
  const safeAlias = (alias) => ALIAS_PATTERN.test(alias) ? alias : "invalid";
22
28
  const targetFor = (alias, name) => ({ client: "writer", scope: safeAlias(alias), alias: WRITER_NAME_PATTERN.test(name) || (alias === "host" && name === "control") ? name : "invalid" });
23
29
  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";
30
+ 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
31
  function services(deps) {
26
32
  const home = deps.home ?? homedir();
27
33
  const runner = deps.runner ?? createProcessRunner({ timeoutMs: IMAGE_PULL_TIMEOUT_MS, maxOutputBytes: 1_048_576, ...(deps.signal ? { signal: deps.signal } : {}) });
@@ -49,6 +55,27 @@ async function locked(deps, action) {
49
55
  await lock.release();
50
56
  }
51
57
  }
58
+ async function fenced(ctx, name, deps, action) {
59
+ let lease;
60
+ try {
61
+ lease = await (deps.lease ?? acquireWriterLease)(ctx.home, name, { waitMs: SETUP_LEASE_WAIT_MS, ...(deps.signal ? { signal: deps.signal } : {}) });
62
+ }
63
+ catch (error) {
64
+ throw new Error(error instanceof WriterLeaseError && error.code === "writer_busy" ? "writer_busy" : "writer_lease_unavailable");
65
+ }
66
+ try {
67
+ try {
68
+ await (deps.ensureAbsent ?? ensureWriterAbsent)(ctx.runner, writerPaths(ctx.home, name), name, { leaseFd: lease.fd });
69
+ }
70
+ catch {
71
+ throw new Error("writer_fenced");
72
+ }
73
+ return await action({ ...ctx, leaseFd: lease.fd });
74
+ }
75
+ finally {
76
+ await lease.release();
77
+ }
78
+ }
52
79
  function intent(ctx, name, client) {
53
80
  return { client, name: writerRegistrationName(name), wrapperPath: writerPaths(ctx.home, name).wrapper, projectDir: ctx.checkout };
54
81
  }
@@ -72,10 +99,6 @@ async function writerHook(ctx, name, deps, operation, enabled) {
72
99
  const outcome = operation === "apply" || operation === "remove" ? await ownership.transaction((access) => request(access, operation)) : await request(ownership, operation);
73
100
  return outcome.code === "hook_not_enabled" || outcome.code === "hook_not_owned" ? undefined : outcome;
74
101
  }
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
102
  class DoctorGateError extends Error {
80
103
  findings;
81
104
  constructor(findings) {
@@ -154,62 +177,121 @@ async function reconcileUnlocked(configPath, alias, request, deps) {
154
177
  const planned = await plan(ctx, entry), { life, accept, ownership, adapter } = services(deps);
155
178
  const add = (name, outcome) => { findings.push({ target: targetFor(alias, name), ...outcome }); };
156
179
  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
- }
180
+ // A surplus writer that cannot be fenced is reported on its own target and left in place; the others proceed.
181
+ const unfenced = new Set();
182
+ for (const name of planned.surplus) {
183
+ try {
184
+ await fenced(ctx, name, deps, (leased) => preflight(leased, [name], request.discard, deps));
179
185
  }
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" });
186
+ catch (error) {
187
+ if (!FENCE_CODES.includes(codeFor(error)))
188
+ throw error;
189
+ unfenced.add(name);
190
+ add(name, { status: "failed", code: codeFor(error) });
187
191
  }
188
192
  }
193
+ const rawConfig = JSON.parse(await readFile(configPath, "utf8")), overrideDirectory = skillOverrideDirectory(configPath);
194
+ // Each writer's reconciliation, including first provisioning and acceptance, owns one fence.
189
195
  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));
196
+ try {
197
+ await fenced(ctx, name, deps, async (ctx) => {
198
+ if (planned.missing.includes(name)) {
199
+ const outcome = await life.provisionWriter(ctx, { index: writerIndex(name), settings: entry.container, rawConfig, overrideDirectory, ...(request.baseRef ? { baseRef: request.baseRef } : {}) });
200
+ add(name, outcome);
201
+ if (outcome.status === "failed")
202
+ return;
203
+ }
204
+ // Settings first: a drifted writer is left exactly as it is, neither migrated nor accepted again (#134).
205
+ if (!planned.missing.includes(name)) {
206
+ const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: entry.container, rawConfig, overrideDirectory });
207
+ if (drift) {
208
+ add(name, drift);
209
+ return;
210
+ }
211
+ }
212
+ // Decide for both clients before removing anything, so a divergent registration of one client never
213
+ // costs the writer its owned registration of the other. A client that is not installed has nothing to keep.
214
+ let divergent = false;
215
+ const owned = [];
216
+ for (const client of ["claude-code", "codex"]) {
217
+ const expected = intent(ctx, name, client);
218
+ const record = await ownership.get({ client, scope: client === "codex" ? "user" : "local", alias: expected.name });
219
+ const current = await adapter.inspect(expected);
220
+ if (record ? current === "divergent" : current === "equivalent" || current === "divergent")
221
+ divergent = true;
222
+ else if (record)
223
+ owned.push(expected);
224
+ }
225
+ if (!divergent) {
226
+ for (const expected of owned) {
227
+ try {
228
+ await unregisterWriter(ownership, adapter, expected);
229
+ }
230
+ catch (error) {
231
+ if (codeFor(error) !== "ownership_diverged")
232
+ throw error;
233
+ divergent = true;
234
+ }
235
+ }
236
+ }
237
+ const hook = await writerHook(ctx, name, deps, "remove", false);
204
238
  if (hook)
205
239
  findings.push({ target: targetFor(alias, name), ...hook });
206
- }
240
+ const state = await readWriterState(ctx.home, name);
241
+ if (!state)
242
+ throw new Error("writer_state_invalid");
243
+ if (divergent) {
244
+ if (state.launch !== "legacy")
245
+ await writeWriterState(ctx.home, name, { ...state, launch: "legacy" });
246
+ add(name, { status: "failed", code: "registration_divergent" });
247
+ return;
248
+ }
249
+ const paths = writerPaths(ctx.home, name), wrapper = renderWrapper(ctx.nodePath, ctx.releaseRoot, name);
250
+ if (!(await lstat(paths.wrapper)).isFile())
251
+ throw new Error("writer_state_invalid");
252
+ const wrapperChanged = wrapperDigest(await readFile(paths.wrapper, "utf8")) !== wrapperDigest(wrapper);
253
+ if (wrapperChanged) {
254
+ const temporary = join(paths.root, `.wrapper-${randomUUID()}.tmp`);
255
+ try {
256
+ await writeFile(temporary, wrapper, { flag: "wx", mode: 0o700 });
257
+ await rename(temporary, paths.wrapper);
258
+ }
259
+ finally {
260
+ await rm(temporary, { force: true });
261
+ }
262
+ }
263
+ // A launch change also needs fresh provider-free acceptance, even when wrapper bytes match.
264
+ const migrated = state.launch !== "relay";
265
+ if (migrated || wrapperChanged) {
266
+ const { acceptance: _oldAcceptance, ...unaccepted } = state;
267
+ await writeWriterState(ctx.home, name, { ...unaccepted, launch: "relay", phase: "not_ready" });
268
+ }
269
+ // A ready phase with a stale binding (package upgrade, new image, Podman version) is accepted again (#130).
270
+ const current = await accept.acceptanceCurrent(ctx, name);
271
+ if (current === "stale") {
272
+ const outcome = await accept.acceptWriter(ctx, name);
273
+ add(name, outcome);
274
+ if (outcome.status === "failed")
275
+ return;
276
+ }
277
+ else if (current === "unknown") {
278
+ add(name, { status: "failed", code: "podman_unavailable" });
279
+ return;
280
+ }
281
+ });
282
+ }
283
+ catch (error) {
284
+ if (!FENCE_CODES.includes(codeFor(error)))
285
+ throw error;
286
+ add(name, { status: "failed", code: codeFor(error) });
207
287
  }
208
288
  }
209
289
  for (const name of planned.surplus) {
290
+ if (unfenced.has(name))
291
+ continue;
210
292
  const extras = [];
211
293
  try {
212
- add(name, await remove(ctx, name, request.discard, deps, extras));
294
+ add(name, await fenced(ctx, name, deps, (leased) => remove(leased, name, request.discard, deps, extras)));
213
295
  }
214
296
  catch (error) {
215
297
  add(name, { status: "failed", code: codeFor(error) });
@@ -263,15 +345,62 @@ export async function changeWriterMode(configPath, request, deps) {
263
345
  }
264
346
  const action = async () => {
265
347
  let proposed;
266
- return changeExecutionMode(configPath, request, {
348
+ // Record the base as provisioning resolves it, so a refused or remote-tracking form never reaches the modes file.
349
+ let recorded = request;
350
+ if (request.baseRef !== undefined) {
351
+ try {
352
+ recorded = { ...request, baseRef: await services(deps).life.baseBranchName(await context(request.alias, deps), request.baseRef) };
353
+ }
354
+ catch (error) {
355
+ return failed(codeFor(error));
356
+ }
357
+ }
358
+ else if (request.mode === "container" || request.mode === undefined && await containerRuntime()) {
359
+ const config = await loadConfig(configPath);
360
+ if (!config.workspaceModes.get(request.alias)?.container?.base) {
361
+ const bases = new Set();
362
+ for (let index = 1; index <= 16; index++) {
363
+ const state = await readWriterState(deps.home ?? homedir(), writerName(request.alias, index));
364
+ if (state)
365
+ bases.add(state.base.ref);
366
+ }
367
+ if (bases.size > 1)
368
+ return result(operation, [{ target: modesTarget(request.alias), status: "failed", code: "workspace_base_ambiguous" }]);
369
+ if (bases.size === 1)
370
+ recorded = { ...request, baseRef: [...bases][0].replace(/^refs\/heads\//, "") };
371
+ // First provisioning: record the base it will resolve (origin/HEAD, else main), so default starts find writers.
372
+ else if (request.mode === "container") {
373
+ try {
374
+ recorded = { ...request, baseRef: await services(deps).life.baseBranchName(await context(request.alias, deps)) };
375
+ }
376
+ catch (error) {
377
+ return failed(codeFor(error));
378
+ }
379
+ }
380
+ }
381
+ }
382
+ return changeExecutionMode(configPath, recorded, {
267
383
  ...modeDependencies,
268
384
  beforeWrite: async (entry) => {
269
385
  proposed = entry;
270
386
  const ctx = await context(request.alias, deps), planned = await plan(ctx, entry);
271
387
  try {
272
- if (request.apply)
388
+ if (request.apply) {
273
389
  await doctorGate(ctx, deps);
274
- await preflight(ctx, planned.surplus, request.discard ?? false, deps);
390
+ for (const name of planned.surplus) {
391
+ try {
392
+ await fenced(ctx, name, deps, (leased) => preflight(leased, [name], request.discard ?? false, deps));
393
+ }
394
+ catch (error) {
395
+ // Refuse the mode change, but name the writer that could not be fenced.
396
+ if (FENCE_CODES.includes(codeFor(error)))
397
+ return result(operation, [{ target: targetFor(request.alias, name), status: "failed", code: codeFor(error) }]);
398
+ throw error;
399
+ }
400
+ }
401
+ }
402
+ else
403
+ await preflight(ctx, planned.surplus, request.discard ?? false, deps);
275
404
  }
276
405
  catch (error) {
277
406
  return error instanceof DoctorGateError ? result(operation, [...failed(error.message).findings, ...error.findings]) : failed(codeFor(error));
@@ -282,32 +411,22 @@ export async function changeWriterMode(configPath, request, deps) {
282
411
  let writers;
283
412
  try {
284
413
  if (request.apply)
285
- writers = await reconcileUnlocked(configPath, request.alias, { discard: request.discard ?? false, codex: request.codex ?? false, ...(request.baseRef ? { baseRef: request.baseRef } : {}) }, deps);
414
+ writers = await reconcileUnlocked(configPath, request.alias, { discard: request.discard ?? false, ...(request.baseRef ? { baseRef: request.baseRef } : {}) }, deps);
286
415
  else {
287
416
  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 = [];
417
+ const kept = [];
289
418
  const rawConfig = JSON.parse(await readFile(configPath, "utf8")), overrideDirectory = skillOverrideDirectory(configPath);
290
419
  for (const name of planned.keep) {
291
420
  // The same settings check as apply, so a drifted writer shows before anything is applied (#134).
292
421
  const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: proposed.container, rawConfig, overrideDirectory });
293
422
  kept.push({ target: targetFor(request.alias, name), ...(drift ?? { status: "planned", code: "writer_planned" }) });
294
423
  }
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");
424
+ const refused = kept.some((finding) => finding.status === "failed" || finding.status === "divergent");
425
+ writers = result("dry-run", [...kept, ...planned.surplus.map((name) => ({ target: targetFor(request.alias, name), status: "planned", code: "writer_remove_planned" }))], refused ? "failed" : "planned");
307
426
  }
308
427
  }
309
428
  catch (error) {
310
- writers = error instanceof DoctorGateError ? result(operation, [...failed(error.message).findings, ...error.findings]) : failed("writer_reconcile_failed");
429
+ writers = error instanceof DoctorGateError ? result(operation, [...failed(error.message).findings, ...error.findings]) : failed(FENCE_CODES.includes(codeFor(error)) ? codeFor(error) : "writer_reconcile_failed");
311
430
  }
312
431
  const findings = [...modes.findings, ...writers.findings].filter((f) => f.code !== "controllers_restart_required");
313
432
  if (request.apply && findings.some((f) => f.status === "ok"))
@@ -329,12 +448,9 @@ export async function runWriterOperation(name, operation, opts, deps) {
329
448
  if (!WRITER_NAME_PATTERN.test(name))
330
449
  return result(reportOperation, [{ target: { client: "invalid", scope: "invalid", alias: "invalid" }, status: "failed", code: "usage_invalid" }], "failed", 64);
331
450
  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
451
  const done = (outcome, hooks = []) => result(reportOperation, [
335
452
  { target, ...outcome },
336
453
  ...hooks.map((hook) => ({ target, ...hook })),
337
- ...(reconnectRequired(outcome) ? [{ target, status: "ok", code: "writer_reconnect_required" }] : []),
338
454
  ], outcome.status === "planned" ? "planned" : undefined);
339
455
  if ((deps.platform ?? process.platform) !== "linux")
340
456
  return done({ status: "failed", code: "platform_unsupported" });
@@ -343,27 +459,16 @@ export async function runWriterOperation(name, operation, opts, deps) {
343
459
  await context(alias, deps);
344
460
  return done({ status: "planned", code: `writer_${operation}_planned` });
345
461
  }
346
- return await locked(deps, async () => {
347
- const ctx = await context(alias, deps), { life, ownership, adapter } = services(deps);
462
+ return await locked(deps, async () => fenced(await context(alias, deps), name, deps, async (ctx) => {
463
+ const { life } = services(deps);
348
464
  if (operation === "verify") {
349
465
  const doctor = await (deps.doctor ?? runDoctor)({ home: ctx.home, packageRoot: deps.releaseRoot, configPath: deps.configPath, loadConfig: () => loadConfig(deps.configPath) });
350
466
  if (doctor.checks.some((check) => check.severity === "error" && (check.subject === name || GLOBAL_ERRORS.has(check.id))))
351
467
  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 }) }) });
468
+ 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
469
  if (checked.status !== "verified")
361
470
  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] : []);
471
+ return done({ status: "ok", code: "mcp_verified" });
367
472
  }
368
473
  try {
369
474
  await doctorGate(ctx, deps);
@@ -373,14 +478,17 @@ export async function runWriterOperation(name, operation, opts, deps) {
373
478
  return result(reportOperation, [...done({ status: "failed", code: error.message }).findings, ...error.findings]);
374
479
  throw error;
375
480
  }
481
+ const entry = (await loadConfig(deps.configPath)).workspaceModes.get(alias);
482
+ const baseRef = opts.baseRef ?? entry?.container?.base;
483
+ const baseOptions = baseRef === undefined ? {} : { baseRef };
376
484
  const extras = [];
377
485
  const outcome = operation === "remove" ? await remove(ctx, name, opts.discard, deps, extras)
378
486
  : operation === "collect" ? await life.collectWriter(ctx, name)
379
487
  : operation === "reset" ? await life.resetWriter(ctx, name, opts)
380
- : operation === "recreate" ? await life.recreateWriter(ctx, name, opts)
381
- : await life.finishWriter(ctx, name);
488
+ : operation === "recreate" ? await life.recreateWriter(ctx, name, { ...opts, ...baseOptions })
489
+ : await life.finishWriter(ctx, name, baseOptions);
382
490
  return done(outcome, extras);
383
- });
491
+ }));
384
492
  }
385
493
  catch (error) {
386
494
  return done({ status: "failed", code: codeFor(error) });