pi-session-mcp 0.13.0 → 0.14.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 (55) hide show
  1. package/.pi-session-mcp-cli/config.js +40 -3
  2. package/.pi-session-mcp-cli/doctor.js +13 -4
  3. package/.pi-session-mcp-cli/skill-library.js +1 -1
  4. package/CHANGELOG.md +121 -0
  5. package/CONTRIBUTING.md +8 -1
  6. package/README.md +4 -2
  7. package/dist/config.d.ts +13 -1
  8. package/dist/config.js +40 -3
  9. package/dist/confined-tools.d.ts +1 -0
  10. package/dist/confined-tools.js +6 -2
  11. package/dist/doctor.d.ts +1 -1
  12. package/dist/doctor.js +13 -4
  13. package/dist/main.js +6 -2
  14. package/dist/model-catalog.d.ts +40 -0
  15. package/dist/model-catalog.js +38 -0
  16. package/dist/profile-probe.d.ts +28 -0
  17. package/dist/profile-probe.js +164 -0
  18. package/dist/sdk-pi-adapter.js +3 -2
  19. package/dist/setup-cli.d.ts +8 -2
  20. package/dist/setup-cli.js +36 -3
  21. package/dist/setup-main.js +10 -3
  22. package/dist/setup-probe-main.d.ts +14 -0
  23. package/dist/setup-probe-main.js +76 -0
  24. package/dist/setup-probe-result.d.ts +39 -0
  25. package/dist/setup-probe-result.js +62 -0
  26. package/dist/setup-writers-common.d.ts +78 -0
  27. package/dist/setup-writers-common.js +177 -0
  28. package/dist/setup-writers-plan.d.ts +34 -0
  29. package/dist/setup-writers-plan.js +105 -0
  30. package/dist/setup-writers-reconcile.d.ts +8 -0
  31. package/dist/setup-writers-reconcile.js +169 -0
  32. package/dist/setup-writers.d.ts +6 -30
  33. package/dist/setup-writers.js +18 -382
  34. package/dist/skill-library.js +1 -1
  35. package/dist/wake-hook.d.ts +11 -0
  36. package/dist/wake-hook.js +51 -8
  37. package/docs/acceptance.md +254 -1
  38. package/docs/adr/0009-execution-modes.md +3 -2
  39. package/docs/adr/0012-leased-writer-pool.md +3 -3
  40. package/docs/adr/0013-hardened-read-only-profiles.md +6 -4
  41. package/docs/adr/0014-profile-probe-and-model-catalog.md +89 -0
  42. package/docs/architecture.md +1 -1
  43. package/docs/client-setup.md +21 -15
  44. package/docs/doctor-design.md +2 -1
  45. package/docs/execution-modes.md +1 -1
  46. package/docs/installation.md +87 -0
  47. package/docs/roadmap.md +23 -5
  48. package/docs/session-mcp.md +5 -3
  49. package/docs/skills.md +42 -25
  50. package/docs/threat-model.md +2 -1
  51. package/docs/tool-contracts.md +4 -2
  52. package/package.json +1 -1
  53. package/skills/pi-session-orchestration/SKILL.md +2 -2
  54. package/skills/pi-session-orchestration/references/start-errors.md +1 -1
  55. package/skills/pi-session-profiles/SKILL.md +124 -0
@@ -1,5 +1,4 @@
1
- import { randomUUID } from "node:crypto";
2
- import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
1
+ import { lstat } from "node:fs/promises";
3
2
  import { homedir } from "node:os";
4
3
  import { join } from "node:path";
5
4
  import { loadConfig } from "./config.js";
@@ -8,310 +7,20 @@ import { ALIAS_PATTERN } from "./execution-profile.js";
8
7
  import { runDoctor } from "./doctor.js";
9
8
  import { MAX_WRITERS } from "./execution-modes.js";
10
9
  import { changeExecutionMode } from "./setup-modes.js";
11
- import { acquireOverrideLock, OverrideLockError } from "./override-lock.js";
12
- import { createCommandMcpStdioLauncher, createProcessRunner } from "./setup-process.js";
13
- import { DurableOwnershipStore } from "./setup-ownership.js";
14
- import { localHookNeededByOthers, ownsWakeHook, runWakeHook } from "./setup-hooks.js";
10
+ import { createCommandMcpStdioLauncher } from "./setup-process.js";
15
11
  import { result } from "./setup-result.js";
16
- import { createWriterRegistrationAdapter, unregisterWriter } from "./client-adapters/writer-registration.js";
17
12
  import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
18
- import { sameWriterLimits, writerLimits } from "./writer-limits.js";
19
- import { IMAGE_PULL_TIMEOUT_MS, podmanCgroupControllers } from "./writer-boundary.js";
20
- import { acquireWriterLease, WriterLeaseError } from "./writer-lease.js";
21
- import { ensureWriterAbsent } from "./writer-fence.js";
22
- import * as lifecycle from "./writer-lifecycle.js";
13
+ import { podmanCgroupControllers } from "./writer-boundary.js";
23
14
  import * as acceptance from "./writer-acceptance.js";
24
- import { skillOverrideDirectory } from "./skill-library.js";
25
- import { renderWrapper, wrapperDigest } from "./writer-wrapper.js";
26
- import { readWriterState, writeWriterState, writerName, writerPaths, writerRegistrationName, writersRoot, WRITER_NAME_PATTERN } from "./writer-state.js";
27
- export const SETUP_LEASE_WAIT_MS = 10_000;
28
- const FENCE_CODES = ["writer_busy", "writer_fenced", "writer_lease_unavailable"];
29
- const safeAlias = (alias) => ALIAS_PATTERN.test(alias) ? alias : "invalid";
30
- const targetFor = (alias, name) => ({ client: "writer", scope: safeAlias(alias), alias: WRITER_NAME_PATTERN.test(name) || (alias === "host" && name === "control") ? name : "invalid" });
31
- const modesTarget = (alias) => ({ client: "modes", scope: "workspace", alias: safeAlias(alias) });
32
- 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";
33
- function services(deps) {
34
- const home = deps.home ?? homedir();
35
- const runner = deps.runner ?? createProcessRunner({ timeoutMs: IMAGE_PULL_TIMEOUT_MS, maxOutputBytes: 1_048_576, ...(deps.signal ? { signal: deps.signal } : {}) });
36
- return { home, runner, life: deps.lifecycle ?? lifecycle, accept: deps.acceptance ?? acceptance,
37
- ownership: deps.ownership ?? new DurableOwnershipStore(join(home, ".local", "state", "pi-session-mcp", "ownership.json"), { ...(deps.signal ? { signal: deps.signal } : {}) }),
38
- adapter: deps.adapter ?? createWriterRegistrationAdapter(runner) };
39
- }
40
- async function context(alias, deps) {
41
- const config = await loadConfig(deps.configPath);
42
- const checkout = config.workspaces.get(alias);
43
- if (!checkout)
44
- throw new Error("alias_unknown");
45
- const { home, runner } = services(deps);
46
- return { home, runner, checkout, alias, releaseRoot: deps.releaseRoot, nodePath: process.execPath };
47
- }
48
- async function locked(deps, action) {
49
- const directory = writersRoot(deps.home ?? homedir());
50
- await mkdir(directory, { recursive: true, mode: 0o700 });
51
- await chmod(directory, 0o700);
52
- const lock = await (deps.lock ?? acquireOverrideLock)(directory, { ...(deps.signal ? { signal: deps.signal } : {}) });
53
- try {
54
- return await action();
55
- }
56
- finally {
57
- await lock.release();
58
- }
59
- }
60
- async function fenced(ctx, name, deps, action) {
61
- let lease;
62
- try {
63
- lease = await (deps.lease ?? acquireWriterLease)(ctx.home, name, { waitMs: SETUP_LEASE_WAIT_MS, ...(deps.signal ? { signal: deps.signal } : {}) });
64
- }
65
- catch (error) {
66
- throw new Error(error instanceof WriterLeaseError && error.code === "writer_busy" ? "writer_busy" : "writer_lease_unavailable");
67
- }
68
- try {
69
- try {
70
- await (deps.ensureAbsent ?? ensureWriterAbsent)(ctx.runner, writerPaths(ctx.home, name), name, { leaseFd: lease.fd });
71
- }
72
- catch {
73
- throw new Error("writer_fenced");
74
- }
75
- return await action({ ...ctx, leaseFd: lease.fd });
76
- }
77
- finally {
78
- await lease.release();
79
- }
80
- }
81
- function intent(ctx, name, client) {
82
- return { client, name: writerRegistrationName(name), wrapperPath: writerPaths(ctx.home, name).wrapper, projectDir: ctx.checkout };
83
- }
15
+ import { writerName, writerPaths, writersRoot, WRITER_NAME_PATTERN } from "./writer-state.js";
16
+ import { codeFor, context, DoctorGateError, doctorGate, FENCE_CODES, fenced, GLOBAL_ERRORS, locked, modesTarget, plan, preflight, remove, services, targetFor } from "./setup-writers-common.js";
17
+ import { gateBeforeWrite, inferBase, plannedWriters } from "./setup-writers-plan.js";
18
+ import { reconcileUnlocked } from "./setup-writers-reconcile.js";
84
19
  /**
85
- * The wake hook of a writer's Claude Code registration, in the local settings of the operator's
86
- * project. Claude Code only: a Codex writer has no hook. The writer's own configuration decides
87
- * whether delivery is wanted; the entry is shared per project, see `localHookNeededByOthers`.
88
- * Findings without news (`hook_not_enabled`, `hook_not_owned`) are not reported.
20
+ * Writer setup entry points: a repository's mode change with its writers (planning and base inference in
21
+ * `setup-writers-plan.ts`, reconciliation in `setup-writers-reconcile.ts`), single-writer operations and the isolation control.
89
22
  */
90
- async function writerHook(ctx, name, deps, operation, enabled) {
91
- const { ownership } = services(deps);
92
- const roots = { packageRoot: deps.releaseRoot, home: ctx.home, cwd: ctx.checkout, env: process.env, nodePath: ctx.nodePath };
93
- // Nothing wanted and nothing owned: not even the project's settings are looked at.
94
- if (!enabled && !await ownsWakeHook("local", roots, ownership))
95
- return undefined;
96
- const request = (access, op) => runWakeHook({
97
- operation: op, scope: "local", enabled, ownership: access,
98
- roots,
99
- keep: () => localHookNeededByOthers({ ownership: access, home: ctx.home, cwd: ctx.checkout, configPath: deps.configPath, except: { writer: name } }),
100
- });
101
- const outcome = operation === "apply" || operation === "remove" ? await ownership.transaction((access) => request(access, operation)) : await request(ownership, operation);
102
- return outcome.code === "hook_not_enabled" || outcome.code === "hook_not_owned" ? undefined : outcome;
103
- }
104
- class DoctorGateError extends Error {
105
- findings;
106
- constructor(findings) {
107
- super("doctor_failed");
108
- this.findings = findings;
109
- }
110
- }
111
- async function doctorGate(ctx, deps) {
112
- const doctor = await (deps.doctor ?? runDoctor)({ home: ctx.home, packageRoot: deps.releaseRoot, configPath: deps.configPath, loadConfig: () => loadConfig(deps.configPath) });
113
- const ownSubjects = new Set([ctx.alias, ...Array.from({ length: 16 }, (_, index) => writerName(ctx.alias, index + 1))]);
114
- // A refused profile of this repository is its own finding: collect, finish and the profile change that fixes it must stay possible (#141).
115
- // Another repository's writer that is not ready is untouched by this step and expected after an upgrade; counting it would deadlock
116
- // the repository steps of two container repositories (#148).
117
- const own = (check) => ownSubjects.has(check.subject)
118
- || (check.id === "container_profile_refused" && check.subject.startsWith(`${ctx.alias}:`))
119
- || (check.id === "writer_not_ready" && WRITER_NAME_PATTERN.test(check.subject));
120
- const blocking = doctor.checks.filter((check) => check.severity === "error" && !own(check));
121
- if (blocking.length)
122
- throw new DoctorGateError(blocking.map((check) => ({ status: "failed", code: check.id, target: { client: "doctor", scope: "check", alias: gateAlias(check.id, check.subject) } })));
123
- }
124
- /** The alias that leads a Doctor subject: `<profile>:<package>` and writer names report their alias part; global checks have none. */
125
- function gateAlias(id, subject) {
126
- if (GLOBAL_ERRORS.has(id))
127
- return "invalid";
128
- const head = subject.split(":")[0] ?? "";
129
- return safeAlias(WRITER_NAME_PATTERN.test(head) ? head.slice(0, head.lastIndexOf("-w")) : head);
130
- }
131
- async function preflight(ctx, names, discard, deps) {
132
- if (discard)
133
- return;
134
- for (const name of names) {
135
- const state = await readWriterState(ctx.home, name);
136
- const work = await (deps.lifecycle ?? lifecycle).inspectWork(ctx, name);
137
- if (!work.baseline && state?.collected?.digest !== work.digest)
138
- throw new Error("writer_uncollected");
139
- }
140
- }
141
- async function remove(ctx, name, discard, deps, extras = []) {
142
- await preflight(ctx, [name], discard, deps);
143
- const { adapter, ownership, life } = services(deps);
144
- // Remove registrations before deleting the wrapper used to prove ownership.
145
- for (const client of ["claude-code", "codex"]) {
146
- const expected = intent(ctx, name, client);
147
- const owned = await ownership.get({ client, scope: client === "codex" ? "user" : "local", alias: expected.name });
148
- if (owned)
149
- await unregisterWriter(ownership, adapter, expected);
150
- else if (["equivalent", "divergent"].includes(await adapter.inspect(expected)))
151
- throw new Error("ownership_unavailable");
152
- }
153
- // After the registration is gone. A hook that cannot be edited is reported, not a reason to keep the
154
- // writer: the operator's settings are not the writer's to hold hostage, and the record keeps the entry owned.
155
- const hook = await writerHook(ctx, name, deps, "remove", false).catch(() => ({ status: "failed", code: "hook_install_failed" }));
156
- if (hook)
157
- extras.push(hook);
158
- return life.removeWriter(ctx, name, { discard });
159
- }
160
- const writerIndex = (name) => Number(name.slice(name.lastIndexOf("-w") + 2));
161
- async function plan(ctx, entry) {
162
- const keep = [], missing = [], surplus = [];
163
- for (let index = 1; index <= MAX_WRITERS; index++) {
164
- const name = writerName(ctx.alias, index), state = await readWriterState(ctx.home, name);
165
- if (entry.mode === "container" && index <= entry.container.writers) {
166
- keep.push(name);
167
- if (!state || state.phase === "provisioning")
168
- missing.push(name);
169
- }
170
- else if (state)
171
- surplus.push(name);
172
- }
173
- return { keep, missing, surplus };
174
- }
175
- async function reconcileUnlocked(configPath, alias, request, deps) {
176
- const findings = [];
177
- const ctx = await context(alias, { ...deps, configPath });
178
- const config = await loadConfig(configPath), entry = config.workspaceModes.get(alias);
179
- const planned = await plan(ctx, entry), { life, accept, ownership, adapter } = services(deps);
180
- const add = (name, outcome) => { findings.push({ target: targetFor(alias, name), ...outcome }); };
181
- await doctorGate(ctx, deps);
182
- // A surplus writer that cannot be fenced is reported on its own target and left in place; the others proceed.
183
- const unfenced = new Set();
184
- for (const name of planned.surplus) {
185
- try {
186
- await fenced(ctx, name, deps, (leased) => preflight(leased, [name], request.discard, deps));
187
- }
188
- catch (error) {
189
- if (!FENCE_CODES.includes(codeFor(error)))
190
- throw error;
191
- unfenced.add(name);
192
- add(name, { status: "failed", code: codeFor(error) });
193
- }
194
- }
195
- const rawConfig = JSON.parse(await readFile(configPath, "utf8")), overrideDirectory = skillOverrideDirectory(configPath);
196
- // Each writer's reconciliation, including first provisioning and acceptance, owns one fence.
197
- for (const name of planned.keep) {
198
- try {
199
- await fenced(ctx, name, deps, async (ctx) => {
200
- if (planned.missing.includes(name)) {
201
- const outcome = await life.provisionWriter(ctx, { index: writerIndex(name), settings: entry.container, rawConfig, overrideDirectory, ...(request.baseRef ? { baseRef: request.baseRef } : {}) });
202
- add(name, outcome);
203
- if (outcome.status === "failed")
204
- return;
205
- }
206
- // Settings first: a drifted writer is left exactly as it is, neither migrated nor accepted again (#134).
207
- if (!planned.missing.includes(name)) {
208
- const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: entry.container, rawConfig, overrideDirectory });
209
- if (drift) {
210
- add(name, drift);
211
- return;
212
- }
213
- const state = await readWriterState(ctx.home, name);
214
- if (!state)
215
- throw new Error("writer_state_invalid");
216
- const limits = writerLimits(entry.container);
217
- if (!sameWriterLimits(state.limits, limits)) {
218
- const { limits: _previous, ...rest } = state;
219
- await writeWriterState(ctx.home, name, { ...rest, ...(limits ? { limits } : {}) });
220
- add(name, { status: "ok", code: "writer_limits_updated" });
221
- }
222
- }
223
- // Decide for both clients before removing anything, so a divergent registration of one client never
224
- // costs the writer its owned registration of the other. A client that is not installed has nothing to keep.
225
- let divergent = false;
226
- const owned = [];
227
- for (const client of ["claude-code", "codex"]) {
228
- const expected = intent(ctx, name, client);
229
- const record = await ownership.get({ client, scope: client === "codex" ? "user" : "local", alias: expected.name });
230
- const current = await adapter.inspect(expected);
231
- if (record ? current === "divergent" : current === "equivalent" || current === "divergent")
232
- divergent = true;
233
- else if (record)
234
- owned.push(expected);
235
- }
236
- if (!divergent) {
237
- for (const expected of owned) {
238
- try {
239
- await unregisterWriter(ownership, adapter, expected);
240
- }
241
- catch (error) {
242
- if (codeFor(error) !== "ownership_diverged")
243
- throw error;
244
- divergent = true;
245
- }
246
- }
247
- }
248
- const hook = await writerHook(ctx, name, deps, "remove", false);
249
- if (hook)
250
- findings.push({ target: targetFor(alias, name), ...hook });
251
- const state = await readWriterState(ctx.home, name);
252
- if (!state)
253
- throw new Error("writer_state_invalid");
254
- if (divergent) {
255
- if (state.launch !== "legacy")
256
- await writeWriterState(ctx.home, name, { ...state, launch: "legacy" });
257
- add(name, { status: "failed", code: "registration_divergent" });
258
- return;
259
- }
260
- const paths = writerPaths(ctx.home, name), wrapper = renderWrapper(ctx.nodePath, ctx.releaseRoot, name);
261
- if (!(await lstat(paths.wrapper)).isFile())
262
- throw new Error("writer_state_invalid");
263
- const wrapperChanged = wrapperDigest(await readFile(paths.wrapper, "utf8")) !== wrapperDigest(wrapper);
264
- if (wrapperChanged) {
265
- const temporary = join(paths.root, `.wrapper-${randomUUID()}.tmp`);
266
- try {
267
- await writeFile(temporary, wrapper, { flag: "wx", mode: 0o700 });
268
- await rename(temporary, paths.wrapper);
269
- }
270
- finally {
271
- await rm(temporary, { force: true });
272
- }
273
- }
274
- // A launch change also needs fresh provider-free acceptance, even when wrapper bytes match.
275
- const migrated = state.launch !== "relay";
276
- if (migrated || wrapperChanged) {
277
- const { acceptance: _oldAcceptance, ...unaccepted } = state;
278
- await writeWriterState(ctx.home, name, { ...unaccepted, launch: "relay", phase: "not_ready" });
279
- }
280
- // A ready phase with a stale binding (package upgrade, new image, Podman version) is accepted again (#130).
281
- const current = await accept.acceptanceCurrent(ctx, name);
282
- if (current === "stale") {
283
- const outcome = await accept.acceptWriter(ctx, name);
284
- add(name, outcome);
285
- if (outcome.status === "failed")
286
- return;
287
- }
288
- else if (current === "unknown") {
289
- add(name, { status: "failed", code: "podman_unavailable" });
290
- return;
291
- }
292
- });
293
- }
294
- catch (error) {
295
- if (!FENCE_CODES.includes(codeFor(error)))
296
- throw error;
297
- add(name, { status: "failed", code: codeFor(error) });
298
- }
299
- }
300
- for (const name of planned.surplus) {
301
- if (unfenced.has(name))
302
- continue;
303
- const extras = [];
304
- try {
305
- add(name, await fenced(ctx, name, deps, (leased) => remove(leased, name, request.discard, deps, extras)));
306
- }
307
- catch (error) {
308
- add(name, { status: "failed", code: codeFor(error) });
309
- }
310
- for (const extra of extras)
311
- findings.push({ target: targetFor(alias, name), ...extra });
312
- }
313
- return result("apply", findings);
314
- }
23
+ export { SETUP_LEASE_WAIT_MS } from "./setup-writers-common.js";
315
24
  /** Serializes planning, destructive-work refusal, mode commit and writer reconciliation. */
316
25
  export async function changeWriterMode(configPath, request, deps) {
317
26
  const operation = request.apply ? "apply" : "dry-run";
@@ -356,80 +65,18 @@ export async function changeWriterMode(configPath, request, deps) {
356
65
  }
357
66
  const action = async () => {
358
67
  let proposed;
359
- // Record the base as provisioning resolves it, so a refused or remote-tracking form never reaches the modes file.
360
- let recorded = request;
361
- if (request.baseRef !== undefined) {
362
- try {
363
- recorded = { ...request, baseRef: await services(deps).life.baseBranchName(await context(request.alias, deps), request.baseRef) };
364
- }
365
- catch (error) {
366
- return failed(codeFor(error));
367
- }
368
- }
369
- else if (request.mode === "container" || request.mode === undefined && await containerRuntime()) {
370
- const config = await loadConfig(configPath);
371
- if (!config.workspaceModes.get(request.alias)?.container?.base) {
372
- const bases = new Set();
373
- for (let index = 1; index <= MAX_WRITERS; index++) {
374
- const state = await readWriterState(deps.home ?? homedir(), writerName(request.alias, index));
375
- if (state)
376
- bases.add(state.base.ref);
377
- }
378
- if (bases.size > 1)
379
- return result(operation, [{ target: modesTarget(request.alias), status: "failed", code: "workspace_base_ambiguous" }]);
380
- if (bases.size === 1)
381
- recorded = { ...request, baseRef: [...bases][0].replace(/^refs\/heads\//, "") };
382
- // First provisioning: record the base it will resolve (origin/HEAD, else main), so default starts find writers.
383
- else if (request.mode === "container") {
384
- try {
385
- recorded = { ...request, baseRef: await services(deps).life.baseBranchName(await context(request.alias, deps)) };
386
- }
387
- catch (error) {
388
- return failed(codeFor(error));
389
- }
390
- }
391
- }
392
- }
68
+ const recorded = await inferBase(configPath, request, deps, containerRuntime, failed);
69
+ if ("schemaVersion" in recorded)
70
+ return recorded;
393
71
  return changeExecutionMode(configPath, recorded, {
394
72
  ...modeDependencies,
395
73
  beforeWrite: async (entry) => {
396
74
  proposed = entry;
397
75
  const ctx = await context(request.alias, deps), planned = await plan(ctx, entry);
398
76
  try {
399
- if (request.apply) {
400
- await doctorGate(ctx, deps);
401
- for (const name of planned.surplus) {
402
- try {
403
- await fenced(ctx, name, deps, (leased) => preflight(leased, [name], request.discard ?? false, deps));
404
- }
405
- catch (error) {
406
- // Refuse the mode change, but name the writer that could not be fenced.
407
- if (FENCE_CODES.includes(codeFor(error)))
408
- return result(operation, [{ target: targetFor(request.alias, name), status: "failed", code: codeFor(error) }]);
409
- throw error;
410
- }
411
- }
412
- // Settings drift before the write, so a kept writer with changed settings refuses the mode
413
- // change itself and the modes file never records a mode no writer matches (#163). Read-only like
414
- // the dry run, which fences nothing; reconcile re-checks under its fence. Missing writers have
415
- // no settings yet.
416
- if (entry.mode === "container") {
417
- const { life } = services(deps);
418
- const rawConfig = JSON.parse(await readFile(configPath, "utf8")), overrideDirectory = skillOverrideDirectory(configPath);
419
- const drifted = [];
420
- for (const name of planned.keep) {
421
- if (planned.missing.includes(name))
422
- continue;
423
- const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: entry.container, rawConfig, overrideDirectory });
424
- if (drift)
425
- drifted.push({ target: targetFor(request.alias, name), ...drift });
426
- }
427
- if (drifted.length)
428
- return result(operation, drifted);
429
- }
430
- }
431
- else
432
- await preflight(ctx, planned.surplus, request.discard ?? false, deps);
77
+ if (request.apply)
78
+ return await gateBeforeWrite(configPath, ctx, entry, planned, request.discard ?? false, operation, deps);
79
+ await preflight(ctx, planned.surplus, request.discard ?? false, deps);
433
80
  }
434
81
  catch (error) {
435
82
  return error instanceof DoctorGateError ? result(operation, [...failed(error.message).findings, ...error.findings]) : failed(codeFor(error));
@@ -442,18 +89,8 @@ export async function changeWriterMode(configPath, request, deps) {
442
89
  if (request.apply)
443
90
  writers = await reconcileUnlocked(configPath, request.alias, { discard: request.discard ?? false, ...(request.baseRef ? { baseRef: request.baseRef } : {}) }, deps);
444
91
  else {
445
- const ctx = await context(request.alias, deps), planned = await plan(ctx, proposed), { life } = services(deps);
446
- const kept = [];
447
- const rawConfig = JSON.parse(await readFile(configPath, "utf8")), overrideDirectory = skillOverrideDirectory(configPath);
448
- for (const name of planned.keep) {
449
- // The same settings check as apply, so a drifted writer shows before anything is applied (#134).
450
- const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: proposed.container, rawConfig, overrideDirectory });
451
- const state = await readWriterState(ctx.home, name);
452
- const updatingLimits = state && state.phase !== "provisioning" && !sameWriterLimits(state.limits, writerLimits(proposed.container));
453
- kept.push({ target: targetFor(request.alias, name), ...(drift ?? { status: "planned", code: updatingLimits ? "writer_limits_update_planned" : "writer_planned" }) });
454
- }
455
- const refused = kept.some((finding) => finding.status === "failed" || finding.status === "divergent");
456
- writers = result("dry-run", [...kept, ...planned.surplus.map((name) => ({ target: targetFor(request.alias, name), status: "planned", code: "writer_remove_planned" }))], refused ? "failed" : "planned");
92
+ const ctx = await context(request.alias, deps);
93
+ writers = await plannedWriters(configPath, ctx, proposed, await plan(ctx, proposed), deps);
457
94
  }
458
95
  }
459
96
  catch (error) {
@@ -473,7 +110,6 @@ export async function changeWriterMode(configPath, request, deps) {
473
110
  return failed(codeFor(error));
474
111
  }
475
112
  }
476
- const GLOBAL_ERRORS = new Set(["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid"]);
477
113
  export async function runWriterOperation(name, operation, opts, deps) {
478
114
  const reportOperation = operation === "verify" ? "verify" : opts.apply ? operation === "remove" ? "remove" : "apply" : "dry-run";
479
115
  if (!WRITER_NAME_PATTERN.test(name))
@@ -13,7 +13,7 @@ export const SKILL_NAME_PATTERN = /^(?=.{1,64}$)[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
13
  /** A `skillCatalog` value `builtin:<name>` names a library skill instead of a path. */
14
14
  export const BUILTIN_SKILL_PREFIX = "builtin:";
15
15
  /** Skills for the controlling agent, which setup installs into Claude Code and Codex. */
16
- export const CONTROLLER_SKILLS = Object.freeze(["pi-session-orchestration"]);
16
+ export const CONTROLLER_SKILLS = Object.freeze(["pi-session-orchestration", "pi-session-profiles"]);
17
17
  export const MAX_SKILL_TREE_FILES = 32;
18
18
  export const MAX_SKILL_TREE_BYTES = 1024 * 1024;
19
19
  const MAX_SKILL_TREE_DEPTH = 8;
@@ -139,11 +139,22 @@ export interface WaiterLockOptions {
139
139
  readonly pid?: number;
140
140
  readonly isAlive?: (pid: number) => boolean;
141
141
  readonly maxAgeMs?: number;
142
+ /** Test seam: runs after a lock was judged stale and before its reclamation starts. */
143
+ readonly beforeReclaim?: () => void;
142
144
  }
143
145
  /**
144
146
  * At most one waiter per turn: `<claims>/wait-…` is created exclusively and holds the owner's pid. A lock whose owner
145
147
  * is alive (and younger than `maxAgeMs`, which guards against a reused pid) means another waiter already covers this
146
148
  * turn: returns `undefined`. A stale lock (dead pid, too old, unreadable content) is replaced. Returns the lock path.
149
+ *
150
+ * Reclaiming a stale lock is serialized by the exclusive guard `<lock>.reclaim`, and the lock is judged again while
151
+ * holding it: an earlier observation never deletes a lock that another waiter created after it. A guard that is already
152
+ * there means another waiter is reclaiming and will cover the turn. A guard left by a crash between two calls blocks
153
+ * reclamation for that one turn only, until `cleanupOldClaims` removes it. A guard that cannot be created (for example
154
+ * in a full runtime directory) fails like the lock itself, and the caller waits without a lock rather than dropping the
155
+ * wake. Remaining limit: the guard does not serialize a fresh create or a release, so a lock judged stale by age alone
156
+ * (its pid alive) that its owner releases between the inode check and the unlink, and that a new waiter recreates with
157
+ * the same inode number, can still be removed.
147
158
  */
148
159
  export declare function acquireWaiterLock(claims: string, namespace: WakeNamespace, sessionId: string, turnId: string, options?: WaiterLockOptions): string | undefined;
149
160
  /** Removes the lock only if it still names `pid`, so a replacement lock of another waiter is never deleted. */
package/dist/wake-hook.js CHANGED
@@ -448,12 +448,12 @@ function readLockPid(path) {
448
448
  fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
449
449
  const stat = fstatSync(fd);
450
450
  if (!stat.isFile())
451
- return { pid: undefined, ageMs: Number.POSITIVE_INFINITY };
451
+ return { pid: undefined, ageMs: Number.POSITIVE_INFINITY, ino: stat.ino };
452
452
  const buffer = Buffer.alloc(32);
453
453
  const length = readSync(fd, buffer, 0, buffer.length, null);
454
454
  const text = buffer.toString("utf8", 0, length).trim();
455
455
  const pid = /^[0-9]{1,10}$/.test(text) ? Number(text) : undefined;
456
- return { pid: pid !== undefined && pid > 0 ? pid : undefined, ageMs: Date.now() - stat.mtimeMs };
456
+ return { pid: pid !== undefined && pid > 0 ? pid : undefined, ageMs: Date.now() - stat.mtimeMs, ino: stat.ino };
457
457
  }
458
458
  catch {
459
459
  return undefined;
@@ -470,11 +470,23 @@ function readLockPid(path) {
470
470
  * At most one waiter per turn: `<claims>/wait-…` is created exclusively and holds the owner's pid. A lock whose owner
471
471
  * is alive (and younger than `maxAgeMs`, which guards against a reused pid) means another waiter already covers this
472
472
  * turn: returns `undefined`. A stale lock (dead pid, too old, unreadable content) is replaced. Returns the lock path.
473
+ *
474
+ * Reclaiming a stale lock is serialized by the exclusive guard `<lock>.reclaim`, and the lock is judged again while
475
+ * holding it: an earlier observation never deletes a lock that another waiter created after it. A guard that is already
476
+ * there means another waiter is reclaiming and will cover the turn. A guard left by a crash between two calls blocks
477
+ * reclamation for that one turn only, until `cleanupOldClaims` removes it. A guard that cannot be created (for example
478
+ * in a full runtime directory) fails like the lock itself, and the caller waits without a lock rather than dropping the
479
+ * wake. Remaining limit: the guard does not serialize a fresh create or a release, so a lock judged stale by age alone
480
+ * (its pid alive) that its owner releases between the inode check and the unlink, and that a new waiter recreates with
481
+ * the same inode number, can still be removed.
473
482
  */
474
483
  export function acquireWaiterLock(claims, namespace, sessionId, turnId, options = {}) {
475
484
  const path = join(claims, waiterLockName(namespace, sessionId, turnId));
476
485
  const pid = options.pid ?? process.pid, isAlive = options.isAlive ?? processAlive, maxAgeMs = options.maxAgeMs ?? DEFAULT_BUDGET_MS + WAITER_LOCK_GRACE_MS;
477
- for (let attempt = 0; attempt < 3; attempt += 1) {
486
+ const stale = (held) =>
487
+ // A lock that is still being written (no pid yet) is young; it is stale only once it has been unreadable for a while.
488
+ !(held.pid !== undefined ? isAlive(held.pid) && held.ageMs <= maxAgeMs : held.ageMs < 2000);
489
+ const create = () => {
478
490
  try {
479
491
  const fd = openSync(path, "wx", 0o600);
480
492
  try {
@@ -488,18 +500,49 @@ export function acquireWaiterLock(claims, namespace, sessionId, turnId, options
488
500
  catch (error) {
489
501
  if (error.code !== "EEXIST")
490
502
  throw error;
503
+ return undefined;
491
504
  }
505
+ };
506
+ for (let attempt = 0; attempt < 3; attempt += 1) {
507
+ if (create() !== undefined)
508
+ return path;
492
509
  const held = readLockPid(path);
493
510
  if (held === undefined)
494
511
  continue; // vanished meanwhile: try to create it again
495
- // A lock that is still being written (no pid yet) is young; it is stale only once it has been unreadable for a while.
496
- const live = held.pid !== undefined ? isAlive(held.pid) && held.ageMs <= maxAgeMs : held.ageMs < 2000;
497
- if (live)
512
+ if (!stale(held))
498
513
  return undefined;
514
+ options.beforeReclaim?.();
515
+ const guard = `${path}.reclaim`;
499
516
  try {
500
- unlinkSync(path);
517
+ closeSync(openSync(guard, "wx", 0o600));
518
+ }
519
+ catch (error) {
520
+ if (error.code === "EEXIST")
521
+ return undefined;
522
+ throw error;
523
+ }
524
+ try {
525
+ const current = readLockPid(path);
526
+ if (current !== undefined) {
527
+ if (!stale(current))
528
+ return undefined;
529
+ // Only the file that was judged stale is removed; a different one at the path is decided by the create below.
530
+ try {
531
+ if (lstatSync(path, { throwIfNoEntry: false })?.ino === current.ino)
532
+ unlinkSync(path);
533
+ }
534
+ catch { /* gone or replaced */ }
535
+ }
536
+ // Created while holding the guard, so a reclamation on the last attempt still covers the turn.
537
+ if (create() !== undefined)
538
+ return path;
539
+ }
540
+ finally {
541
+ try {
542
+ unlinkSync(guard);
543
+ }
544
+ catch { /* already gone */ }
501
545
  }
502
- catch { /* replaced by someone else: the next attempt decides */ }
503
546
  }
504
547
  return undefined;
505
548
  }