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.
- package/.pi-session-mcp-cli/config.js +40 -3
- package/.pi-session-mcp-cli/doctor.js +13 -4
- package/.pi-session-mcp-cli/skill-library.js +1 -1
- package/CHANGELOG.md +121 -0
- package/CONTRIBUTING.md +8 -1
- package/README.md +4 -2
- package/dist/config.d.ts +13 -1
- package/dist/config.js +40 -3
- package/dist/confined-tools.d.ts +1 -0
- package/dist/confined-tools.js +6 -2
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +13 -4
- package/dist/main.js +6 -2
- package/dist/model-catalog.d.ts +40 -0
- package/dist/model-catalog.js +38 -0
- package/dist/profile-probe.d.ts +28 -0
- package/dist/profile-probe.js +164 -0
- package/dist/sdk-pi-adapter.js +3 -2
- package/dist/setup-cli.d.ts +8 -2
- package/dist/setup-cli.js +36 -3
- package/dist/setup-main.js +10 -3
- package/dist/setup-probe-main.d.ts +14 -0
- package/dist/setup-probe-main.js +76 -0
- package/dist/setup-probe-result.d.ts +39 -0
- package/dist/setup-probe-result.js +62 -0
- package/dist/setup-writers-common.d.ts +78 -0
- package/dist/setup-writers-common.js +177 -0
- package/dist/setup-writers-plan.d.ts +34 -0
- package/dist/setup-writers-plan.js +105 -0
- package/dist/setup-writers-reconcile.d.ts +8 -0
- package/dist/setup-writers-reconcile.js +169 -0
- package/dist/setup-writers.d.ts +6 -30
- package/dist/setup-writers.js +18 -382
- package/dist/skill-library.js +1 -1
- package/dist/wake-hook.d.ts +11 -0
- package/dist/wake-hook.js +51 -8
- package/docs/acceptance.md +254 -1
- package/docs/adr/0009-execution-modes.md +3 -2
- package/docs/adr/0012-leased-writer-pool.md +3 -3
- package/docs/adr/0013-hardened-read-only-profiles.md +6 -4
- package/docs/adr/0014-profile-probe-and-model-catalog.md +89 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup.md +21 -15
- package/docs/doctor-design.md +2 -1
- package/docs/execution-modes.md +1 -1
- package/docs/installation.md +87 -0
- package/docs/roadmap.md +23 -5
- package/docs/session-mcp.md +5 -3
- package/docs/skills.md +42 -25
- package/docs/threat-model.md +2 -1
- package/docs/tool-contracts.md +4 -2
- package/package.json +1 -1
- package/skills/pi-session-orchestration/SKILL.md +2 -2
- package/skills/pi-session-orchestration/references/start-errors.md +1 -1
- package/skills/pi-session-profiles/SKILL.md +124 -0
package/dist/setup-writers.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
|
|
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
|
-
*
|
|
86
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
401
|
-
|
|
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)
|
|
446
|
-
|
|
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))
|
package/dist/skill-library.js
CHANGED
|
@@ -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;
|
package/dist/wake-hook.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|