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.
- package/.pi-session-mcp-cli/doctor.js +89 -4
- package/.pi-session-mcp-cli/execution-modes.js +16 -4
- package/.pi-session-mcp-cli/override-lock.js +4 -4
- package/.pi-session-mcp-cli/setup-process.js +5 -4
- package/.pi-session-mcp-cli/writer-boundary.js +10 -4
- package/.pi-session-mcp-cli/writer-fence.js +50 -0
- package/.pi-session-mcp-cli/writer-lease.js +140 -0
- package/.pi-session-mcp-cli/writer-lifecycle.js +55 -23
- package/.pi-session-mcp-cli/writer-state.js +4 -2
- package/CHANGELOG.md +69 -0
- package/README.md +3 -2
- package/dist/capabilities.d.ts +2 -0
- package/dist/capabilities.js +4 -1
- package/dist/child-stdio-transport.d.ts +21 -0
- package/dist/child-stdio-transport.js +88 -0
- package/dist/client-adapters/types.d.ts +1 -0
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +89 -4
- package/dist/execution-modes.d.ts +12 -2
- package/dist/execution-modes.js +16 -4
- package/dist/main.js +9 -2
- package/dist/override-lock.d.ts +2 -0
- package/dist/override-lock.js +4 -4
- package/dist/pi-adapter.d.ts +30 -0
- package/dist/pi-adapter.js +11 -0
- package/dist/refusal-server.d.ts +1 -1
- package/dist/refusal-server.js +24 -2
- package/dist/server.d.ts +2 -2
- package/dist/server.js +18 -53
- package/dist/session-registry.d.ts +9 -2
- package/dist/session-registry.js +17 -12
- package/dist/setup-cli.js +7 -2
- package/dist/setup-hooks.js +0 -2
- package/dist/setup-modes.js +7 -1
- package/dist/setup-process.d.ts +3 -1
- package/dist/setup-process.js +5 -4
- package/dist/setup-writers.d.ts +5 -0
- package/dist/setup-writers.js +200 -92
- package/dist/tool-schemas.d.ts +339 -0
- package/dist/tool-schemas.js +49 -0
- package/dist/wake-hook.d.ts +4 -5
- package/dist/wake-hook.js +3 -6
- package/dist/writer-acceptance.js +10 -1
- package/dist/writer-boundary.d.ts +6 -4
- package/dist/writer-boundary.js +10 -4
- package/dist/writer-fence.d.ts +12 -0
- package/dist/writer-fence.js +50 -0
- package/dist/writer-launch.d.ts +9 -1
- package/dist/writer-launch.js +127 -30
- package/dist/writer-lease.d.ts +28 -0
- package/dist/writer-lease.js +140 -0
- package/dist/writer-lifecycle.d.ts +7 -1
- package/dist/writer-lifecycle.js +55 -23
- package/dist/writer-relay.d.ts +33 -0
- package/dist/writer-relay.js +359 -0
- package/dist/writer-state.d.ts +13 -1
- package/dist/writer-state.js +4 -2
- package/docs/acceptance.md +47 -0
- package/docs/adr/0012-leased-writer-pool.md +357 -0
- package/docs/architecture.md +61 -13
- package/docs/client-setup.md +11 -15
- package/docs/doctor-design.md +10 -4
- package/docs/execution-modes.md +87 -44
- package/docs/installation.md +34 -22
- package/docs/isolation-setup.md +88 -102
- package/docs/roadmap.md +5 -0
- package/docs/threat-model.md +7 -4
- package/docs/tool-contracts.md +29 -12
- package/package.json +1 -1
- package/scripts/isolation-acceptance.mjs +7 -1
- package/skills/pi-session-implementation/SKILL.md +1 -0
- package/skills/pi-session-orchestration/SKILL.md +6 -5
- package/skills/pi-session-orchestration/references/start-errors.md +5 -1
package/dist/setup-writers.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
add(name
|
|
184
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
296
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) });
|