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.
- package/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +6 -2
- package/.pi-session-mcp-cli/doctor.js +89 -4
- package/.pi-session-mcp-cli/execution-modes.js +19 -5
- 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 +91 -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/mcp-verifier.js +6 -2
- 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 +14 -2
- package/dist/execution-modes.js +19 -5
- 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 +203 -94
- 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 +172 -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 +43 -22
- package/docs/isolation-setup.md +88 -102
- package/docs/roadmap.md +6 -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,27 +1,34 @@
|
|
|
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";
|
|
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,
|
|
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 {
|
|
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 <=
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
add(name
|
|
184
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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 <=
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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");
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) });
|