pi-session-mcp 0.11.9 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi-session-mcp-cli/doctor.js +89 -4
- package/.pi-session-mcp-cli/execution-modes.js +16 -4
- package/.pi-session-mcp-cli/override-lock.js +4 -4
- package/.pi-session-mcp-cli/setup-process.js +5 -4
- package/.pi-session-mcp-cli/writer-boundary.js +10 -4
- package/.pi-session-mcp-cli/writer-fence.js +50 -0
- package/.pi-session-mcp-cli/writer-lease.js +140 -0
- package/.pi-session-mcp-cli/writer-lifecycle.js +55 -23
- package/.pi-session-mcp-cli/writer-state.js +4 -2
- package/CHANGELOG.md +69 -0
- package/README.md +3 -2
- package/dist/capabilities.d.ts +2 -0
- package/dist/capabilities.js +4 -1
- package/dist/child-stdio-transport.d.ts +21 -0
- package/dist/child-stdio-transport.js +88 -0
- package/dist/client-adapters/types.d.ts +1 -0
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +89 -4
- package/dist/execution-modes.d.ts +12 -2
- package/dist/execution-modes.js +16 -4
- package/dist/main.js +9 -2
- package/dist/override-lock.d.ts +2 -0
- package/dist/override-lock.js +4 -4
- package/dist/pi-adapter.d.ts +30 -0
- package/dist/pi-adapter.js +11 -0
- package/dist/refusal-server.d.ts +1 -1
- package/dist/refusal-server.js +24 -2
- package/dist/server.d.ts +2 -2
- package/dist/server.js +18 -53
- package/dist/session-registry.d.ts +9 -2
- package/dist/session-registry.js +17 -12
- package/dist/setup-cli.js +7 -2
- package/dist/setup-hooks.js +0 -2
- package/dist/setup-modes.js +7 -1
- package/dist/setup-process.d.ts +3 -1
- package/dist/setup-process.js +5 -4
- package/dist/setup-writers.d.ts +5 -0
- package/dist/setup-writers.js +200 -92
- package/dist/tool-schemas.d.ts +339 -0
- package/dist/tool-schemas.js +49 -0
- package/dist/wake-hook.d.ts +4 -5
- package/dist/wake-hook.js +3 -6
- package/dist/writer-acceptance.js +10 -1
- package/dist/writer-boundary.d.ts +6 -4
- package/dist/writer-boundary.js +10 -4
- package/dist/writer-fence.d.ts +12 -0
- package/dist/writer-fence.js +50 -0
- package/dist/writer-launch.d.ts +9 -1
- package/dist/writer-launch.js +127 -30
- package/dist/writer-lease.d.ts +28 -0
- package/dist/writer-lease.js +140 -0
- package/dist/writer-lifecycle.d.ts +7 -1
- package/dist/writer-lifecycle.js +55 -23
- package/dist/writer-relay.d.ts +33 -0
- package/dist/writer-relay.js +359 -0
- package/dist/writer-state.d.ts +13 -1
- package/dist/writer-state.js +4 -2
- package/docs/acceptance.md +47 -0
- package/docs/adr/0012-leased-writer-pool.md +357 -0
- package/docs/architecture.md +61 -13
- package/docs/client-setup.md +11 -15
- package/docs/doctor-design.md +10 -4
- package/docs/execution-modes.md +87 -44
- package/docs/installation.md +34 -22
- package/docs/isolation-setup.md +88 -102
- package/docs/roadmap.md +5 -0
- package/docs/threat-model.md +7 -4
- package/docs/tool-contracts.md +29 -12
- package/package.json +1 -1
- package/scripts/isolation-acceptance.mjs +7 -1
- package/skills/pi-session-implementation/SKILL.md +1 -0
- package/skills/pi-session-orchestration/SKILL.md +6 -5
- package/skills/pi-session-orchestration/references/start-errors.md +5 -1
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { chmod, mkdir, open, readdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
5
|
import { configSchema } from "./config.js";
|
|
6
6
|
import { renderWrapper } from "./writer-wrapper.js";
|
|
7
7
|
import { builtinSkillName } from "./skill-catalog.js";
|
|
8
8
|
import { resolveLibrarySkill, shippedLibrarySkill } from "./skill-library.js";
|
|
9
9
|
import { BOUNDARY_GIT, BoundaryError, IMAGE_PULL_TIMEOUT_MS, isSafeRefName, podmanInfo, resolveImage, throwawayArgs, verifyCloneLayout } from "./writer-boundary.js";
|
|
10
10
|
import { readWriterState, writerName, writerPaths, writeWriterState, WriterStateError } from "./writer-state.js";
|
|
11
|
+
function leasedContext(ctx) {
|
|
12
|
+
if (ctx.leaseFd === undefined)
|
|
13
|
+
return ctx;
|
|
14
|
+
const runner = ctx.runner;
|
|
15
|
+
return { ...ctx, runner: { run: (command, args, signal, options) => runner.run(command, args, signal, { ...options, inheritFds: [ctx.leaseFd] }) } };
|
|
16
|
+
}
|
|
11
17
|
class LifecycleError extends Error {
|
|
12
18
|
code;
|
|
13
19
|
constructor(code) {
|
|
@@ -100,13 +106,23 @@ function buildContainedConfig(alias, clonePath, rawConfig, selected, availableBu
|
|
|
100
106
|
}
|
|
101
107
|
// CommandRunner has no per-call environment option. Hooks are disabled even for injected runners.
|
|
102
108
|
async function git(ctx, args, optional = false) {
|
|
109
|
+
ctx = leasedContext(ctx);
|
|
103
110
|
const result = await ctx.runner.run("git", ["-c", "core.hooksPath=/dev/null", "-C", ctx.checkout, ...args], AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
104
111
|
if (result.exitCode !== 0) {
|
|
105
112
|
if (optional)
|
|
106
113
|
return undefined;
|
|
107
114
|
throw new LifecycleError("writer_base_unavailable");
|
|
108
115
|
}
|
|
109
|
-
return result.stdout.trim();
|
|
116
|
+
return args[0] === "config" ? result.stdout.replace(/\n$/, "") : result.stdout.trim();
|
|
117
|
+
}
|
|
118
|
+
async function operatorIdentity(ctx) {
|
|
119
|
+
const name = await git(ctx, ["config", "--get", "user.name"], true);
|
|
120
|
+
const email = await git(ctx, ["config", "--get", "user.email"], true);
|
|
121
|
+
const valid = (value) => value !== undefined && value.length >= 1 && value.length <= 256 && !value.startsWith("-") && !/[\p{Cc}]/u.test(value);
|
|
122
|
+
return valid(name) && valid(email) ? [name, email] : ["", ""];
|
|
123
|
+
}
|
|
124
|
+
function identityScript(name, email) {
|
|
125
|
+
return `if [ -n "$${name}" ] && [ -n "$${email}" ]; then\n${BOUNDARY_GIT} config user.name "$${name}"\n${BOUNDARY_GIT} config user.email "$${email}"\nelse\n${BOUNDARY_GIT} config --unset-all user.name || :\n${BOUNDARY_GIT} config --unset-all user.email || :\nfi`;
|
|
110
126
|
}
|
|
111
127
|
async function collectGit(ctx, args) {
|
|
112
128
|
try {
|
|
@@ -143,15 +159,21 @@ async function resolveBase(ctx, requested) {
|
|
|
143
159
|
throw new LifecycleError("writer_base_unavailable");
|
|
144
160
|
return { ref, short, commit };
|
|
145
161
|
}
|
|
146
|
-
|
|
162
|
+
/** The short branch name provisioning would record for `requested` (or its default base), normalized like the writer state. */
|
|
163
|
+
export async function baseBranchName(ctx, requested) {
|
|
164
|
+
return (await resolveBase(ctx, requested)).short;
|
|
165
|
+
}
|
|
166
|
+
async function baseBundle(ctx, paths, ref, commit, tags) {
|
|
167
|
+
// for-each-ref prints one full ref per line; `git tag` honours column.ui/column.tag and can print several per line.
|
|
168
|
+
const reachable = tags === "reachable" ? (await git(ctx, ["for-each-ref", "--format=%(refname)", "--merged", commit, "refs/tags"]))?.split("\n").filter((ref) => ref.startsWith("refs/tags/")) ?? [] : [];
|
|
147
169
|
await rm(join(paths.bundles, "base.bundle"), { force: true });
|
|
148
|
-
await git(ctx, ["bundle", "create", join(paths.bundles, "base.bundle"), ref]);
|
|
170
|
+
await git(ctx, ["bundle", "create", join(paths.bundles, "base.bundle"), ref, ...reachable]);
|
|
149
171
|
await chmod(join(paths.bundles, "base.bundle"), 0o600);
|
|
150
172
|
}
|
|
151
|
-
async function cloneFromBundle(ctx, paths, imageId, base, remote) {
|
|
173
|
+
async function cloneFromBundle(ctx, paths, imageId, base, remote, tags) {
|
|
152
174
|
// Clone only auto-fetches refs/heads; fetch the exact bundled ref before checkout.
|
|
153
|
-
const script = `set -eu\nexport GIT_CONFIG_NOSYSTEM=1\n${BOUNDARY_GIT} clone --no-hardlinks /bundles/base.bundle "$1"\ncd "$1"\n${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle "$5"\n${BOUNDARY_GIT} checkout -B "$2" "$3"\nif [ -n "$4" ]; then ${BOUNDARY_GIT} remote set-url origin "$4"; else ${BOUNDARY_GIT} remote remove origin; fi`;
|
|
154
|
-
const clone = await ctx.runner.run("podman", throwawayArgs(paths, imageId, { clone: "rw", bundles: true }, script, [paths.clone, base.short, base.commit, remote ?? "", base.ref]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
175
|
+
const script = `set -eu\nexport GIT_CONFIG_NOSYSTEM=1\n${BOUNDARY_GIT} clone --no-hardlinks /bundles/base.bundle "$1"\ncd "$1"\n${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle "$5"\n${BOUNDARY_GIT} checkout -B "$2" "$3"\nif [ -n "$4" ]; then ${BOUNDARY_GIT} remote set-url origin "$4"; else ${BOUNDARY_GIT} remote remove origin; fi\n${identityScript(6, 7)}\n${tags === "reachable" ? `${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle '+refs/tags/*:refs/tags/*'` : ""}`;
|
|
176
|
+
const clone = await ctx.runner.run("podman", throwawayArgs(basename(paths.root), paths, imageId, { clone: "rw", bundles: true }, script, [paths.clone, base.short, base.commit, remote ?? "", base.ref, ...await operatorIdentity(ctx)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
155
177
|
if (clone.exitCode !== 0)
|
|
156
178
|
throw new LifecycleError("writer_clone_failed");
|
|
157
179
|
await verifyCloneLayout(paths.clone);
|
|
@@ -198,7 +220,7 @@ async function storedConfig(paths) {
|
|
|
198
220
|
async function sameSettings(paths, existing, settings, now) {
|
|
199
221
|
const stored = await storedConfig(paths);
|
|
200
222
|
return stored !== undefined && existing.imageId === now.imageId && existing.lifecycle === settings.lifecycle
|
|
201
|
-
&& existing.network === settings.network && existing.cache === settings.cache
|
|
223
|
+
&& existing.network === settings.network && existing.cache === settings.cache && existing.tags === settings.tags
|
|
202
224
|
&& JSON.stringify(existing.profiles) === JSON.stringify(settings.profiles)
|
|
203
225
|
&& JSON.stringify(existing.providers) === JSON.stringify(now.providers) && canonical(withoutDeliveryOff(stored)) === canonical(withoutDeliveryOff(now.config));
|
|
204
226
|
}
|
|
@@ -217,6 +239,7 @@ function withoutDeliveryOff(config) {
|
|
|
217
239
|
* answers but cannot resolve the image.
|
|
218
240
|
*/
|
|
219
241
|
export async function writerSettingsDrift(ctx, input) {
|
|
242
|
+
ctx = leasedContext(ctx);
|
|
220
243
|
try {
|
|
221
244
|
const name = writerName(ctx.alias, input.index), paths = writerPaths(ctx.home, name);
|
|
222
245
|
const existing = await readWriterState(ctx.home, name);
|
|
@@ -233,6 +256,7 @@ export async function writerSettingsDrift(ctx, input) {
|
|
|
233
256
|
}
|
|
234
257
|
}
|
|
235
258
|
export async function provisionWriter(ctx, input) {
|
|
259
|
+
ctx = leasedContext(ctx);
|
|
236
260
|
try {
|
|
237
261
|
const { index, settings } = input;
|
|
238
262
|
const name = writerName(ctx.alias, index), paths = writerPaths(ctx.home, name);
|
|
@@ -271,16 +295,17 @@ export async function provisionWriter(ctx, input) {
|
|
|
271
295
|
// With spool delivery the spool directory exists from provisioning on: launch would otherwise add it to the
|
|
272
296
|
// writer root, a host directory the acceptance harness digests around its own first launch.
|
|
273
297
|
const spool = config.events?.delivery === "spool" ? [paths.events] : [];
|
|
274
|
-
|
|
298
|
+
const cache = settings.cache === "go" ? [paths.go] : settings.cache === "uv" ? [paths.uv] : [];
|
|
299
|
+
for (const path of [paths.root, paths.clone, paths.config, paths.agent, paths.bundles, ...spool, ...cache])
|
|
275
300
|
await privateDirectory(path);
|
|
276
301
|
const state = {
|
|
277
302
|
schemaVersion: 1, alias: ctx.alias, index, phase: "provisioning", lifecycle: settings.lifecycle,
|
|
278
|
-
profiles: [...settings.profiles], providers, network: settings.network, cache: settings.cache, imageId,
|
|
303
|
+
profiles: [...settings.profiles], providers, network: settings.network, cache: settings.cache, tags: settings.tags, imageId,
|
|
279
304
|
base: { ref: `refs/heads/${short}`, commit },
|
|
280
305
|
};
|
|
281
306
|
await writeWriterState(ctx.home, name, state);
|
|
282
|
-
await baseBundle(ctx, paths, ref);
|
|
283
|
-
await cloneFromBundle(ctx, paths, imageId, base, remote);
|
|
307
|
+
await baseBundle(ctx, paths, ref, commit, settings.tags);
|
|
308
|
+
await cloneFromBundle(ctx, paths, imageId, base, remote, settings.tags);
|
|
284
309
|
await jsonFile(join(paths.config, "pi-session-mcp.json"), config);
|
|
285
310
|
await jsonFile(join(paths.config, "execution-modes.json"), { schemaVersion: 1, workspaces: { [ctx.alias]: { mode: "host", acceptHostWrites: true } } });
|
|
286
311
|
await jsonFile(join(paths.config, "writer-guard.json"), { schemaVersion: 1, workspace: ctx.alias, baseRef: state.base.ref, baseCommit: commit });
|
|
@@ -294,13 +319,14 @@ export async function provisionWriter(ctx, input) {
|
|
|
294
319
|
}
|
|
295
320
|
}
|
|
296
321
|
export async function inspectWork(ctx, name) {
|
|
322
|
+
ctx = leasedContext(ctx);
|
|
297
323
|
const state = await readWriterState(ctx.home, name);
|
|
298
324
|
if (!state || state.alias !== ctx.alias)
|
|
299
325
|
throw new LifecycleError("writer_state_invalid");
|
|
300
326
|
const paths = writerPaths(ctx.home, name);
|
|
301
327
|
await verifyCloneLayout(paths.clone);
|
|
302
328
|
const script = `set -eu\nexport GIT_CONFIG_NOSYSTEM=1\ncp .git/index /tmp/index\nmkdir /tmp/objects\nexport GIT_INDEX_FILE=/tmp/index GIT_OBJECT_DIRECTORY=/tmp/objects GIT_ALTERNATE_OBJECT_DIRECTORIES="$1/.git/objects"\n${BOUNDARY_GIT} add -A\n${BOUNDARY_GIT} rev-parse --verify HEAD\n${BOUNDARY_GIT} for-each-ref --format='%(refname) %(objectname)' refs/heads refs/stash\n${BOUNDARY_GIT} write-tree\n${BOUNDARY_GIT} rev-parse 'HEAD^{tree}'\n${BOUNDARY_GIT} rev-parse "$2^{tree}"`;
|
|
303
|
-
const result = await ctx.runner.run("podman", throwawayArgs(paths, state.imageId, { clone: "ro", bundles: false }, script, [paths.clone, state.base.commit]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
329
|
+
const result = await ctx.runner.run("podman", throwawayArgs(name, paths, state.imageId, { clone: "ro", bundles: false }, script, [paths.clone, state.base.commit]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
304
330
|
if (result.exitCode !== 0)
|
|
305
331
|
throw new LifecycleError("writer_inspect_failed");
|
|
306
332
|
const lines = result.stdout.trim().split("\n");
|
|
@@ -332,7 +358,7 @@ async function writerBase(ctx, name, state, requested) {
|
|
|
332
358
|
// State normalizes remote branches to local heads. The original source ref remains
|
|
333
359
|
// in the single-ref base bundle, including when no corresponding host head exists.
|
|
334
360
|
const heads = await git(ctx, ["bundle", "list-heads", join(writerPaths(ctx.home, name).bundles, "base.bundle")], true);
|
|
335
|
-
const match = /^(?:[0-9a-f]{40}|[0-9a-f]{64}) (refs\/(?:heads\/.+|remotes\/[^/]+\/.+))
|
|
361
|
+
const match = /^(?:[0-9a-f]{40}|[0-9a-f]{64}) (refs\/(?:heads\/.+|remotes\/[^/]+\/.+))$/m.exec(heads ?? "");
|
|
336
362
|
const source = match?.[1];
|
|
337
363
|
if (source && isSafeRefName(source) && shortBranch(source) === shortBranch(state.base.ref))
|
|
338
364
|
return resolveBase(ctx, source);
|
|
@@ -353,6 +379,7 @@ async function refreshPrivateFiles(ctx, name, state) {
|
|
|
353
379
|
await jsonFile(join(paths.agent, "auth.json"), {});
|
|
354
380
|
}
|
|
355
381
|
export async function collectWriter(ctx, name) {
|
|
382
|
+
ctx = leasedContext(ctx);
|
|
356
383
|
const refs = [], findings = [];
|
|
357
384
|
try {
|
|
358
385
|
const paths = writerPaths(ctx.home, name);
|
|
@@ -394,7 +421,7 @@ if [ -z "$(${BOUNDARY_GIT} --git-dir=/tmp/scratch for-each-ref --format='%(refna
|
|
|
394
421
|
else
|
|
395
422
|
${BOUNDARY_GIT} --git-dir=/tmp/scratch bundle create /bundles/collect.bundle --all
|
|
396
423
|
fi`;
|
|
397
|
-
const result = await ctx.runner.run("podman", throwawayArgs(paths, state.imageId, { clone: "ro", bundles: true }, script, [paths.clone, state.base.ref, state.base.commit, shortBranch(state.base.ref)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
424
|
+
const result = await ctx.runner.run("podman", throwawayArgs(name, paths, state.imageId, { clone: "ro", bundles: true }, script, [paths.clone, state.base.ref, state.base.commit, shortBranch(state.base.ref)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
398
425
|
if (result.exitCode !== 0)
|
|
399
426
|
throw new LifecycleError("writer_collect_failed");
|
|
400
427
|
const messages = result.stdout.split("\n");
|
|
@@ -437,7 +464,7 @@ async function recreate(ctx, name, state, base) {
|
|
|
437
464
|
const remote = await git(ctx, ["remote", "get-url", "origin"], true);
|
|
438
465
|
if (remote && /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/]*@/.test(remote))
|
|
439
466
|
throw new LifecycleError("remote_url_refused");
|
|
440
|
-
await baseBundle(ctx, paths, base.ref);
|
|
467
|
+
await baseBundle(ctx, paths, base.ref, base.commit, state.tags);
|
|
441
468
|
const { collected: _collected, ...retained } = state;
|
|
442
469
|
const next = { ...retained, base: { ref: `refs/heads/${base.short}`, commit: base.commit } };
|
|
443
470
|
await writeWriterState(ctx.home, name, { ...next, phase: "provisioning" });
|
|
@@ -445,7 +472,7 @@ async function recreate(ctx, name, state, base) {
|
|
|
445
472
|
await rm(path, { recursive: true, force: true });
|
|
446
473
|
await privateDirectory(path);
|
|
447
474
|
}
|
|
448
|
-
await cloneFromBundle(ctx, paths, state.imageId, base, remote);
|
|
475
|
+
await cloneFromBundle(ctx, paths, state.imageId, base, remote, state.tags);
|
|
449
476
|
await refreshPrivateFiles(ctx, name, next);
|
|
450
477
|
await writeWriterState(ctx.home, name, next);
|
|
451
478
|
if (!(await inspectWork(ctx, name)).baseline)
|
|
@@ -453,26 +480,30 @@ async function recreate(ctx, name, state, base) {
|
|
|
453
480
|
return { status: "ok", code: "writer_recreated" };
|
|
454
481
|
}
|
|
455
482
|
export async function recreateWriter(ctx, name, opts) {
|
|
483
|
+
ctx = leasedContext(ctx);
|
|
456
484
|
try {
|
|
457
485
|
const state = await requiredState(ctx, name);
|
|
458
486
|
await requireCollected(ctx, name, state, opts.discard);
|
|
459
|
-
return await recreate(ctx, name, state, await writerBase(ctx, name, state));
|
|
487
|
+
return await recreate(ctx, name, state, await writerBase(ctx, name, state, opts.baseRef));
|
|
460
488
|
}
|
|
461
489
|
catch (error) {
|
|
462
490
|
return failure(error);
|
|
463
491
|
}
|
|
464
492
|
}
|
|
465
493
|
export async function resetWriter(ctx, name, opts) {
|
|
494
|
+
ctx = leasedContext(ctx);
|
|
466
495
|
try {
|
|
467
496
|
const state = await requiredState(ctx, name);
|
|
468
497
|
const base = await writerBase(ctx, name, state, opts.baseRef);
|
|
469
498
|
await requireCollected(ctx, name, state, opts.discard);
|
|
470
499
|
const paths = writerPaths(ctx.home, name);
|
|
471
500
|
await verifyCloneLayout(paths.clone);
|
|
472
|
-
await baseBundle(ctx, paths, base.ref);
|
|
501
|
+
await baseBundle(ctx, paths, base.ref, base.commit, state.tags);
|
|
473
502
|
const script = `set -eu
|
|
474
503
|
export GIT_CONFIG_NOSYSTEM=1
|
|
475
504
|
${BOUNDARY_GIT} checkout -f --detach
|
|
505
|
+
${BOUNDARY_GIT} for-each-ref --format='%(refname)' refs/tags | while read -r ref; do ${BOUNDARY_GIT} update-ref -d "$ref"; done
|
|
506
|
+
${state.tags === "reachable" ? `${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle '+refs/tags/*:refs/tags/*'` : ""}
|
|
476
507
|
${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle "+$1:$1"
|
|
477
508
|
${BOUNDARY_GIT} checkout -f -B "$2" "$3"
|
|
478
509
|
${BOUNDARY_GIT} reset --hard "$3"
|
|
@@ -480,8 +511,9 @@ ${BOUNDARY_GIT} for-each-ref --format='%(refname)' refs/heads | while read -r re
|
|
|
480
511
|
if [ "$ref" != "refs/heads/$2" ]; then ${BOUNDARY_GIT} update-ref -d "$ref"; fi
|
|
481
512
|
done
|
|
482
513
|
${BOUNDARY_GIT} stash clear
|
|
483
|
-
${BOUNDARY_GIT} clean -fd
|
|
484
|
-
|
|
514
|
+
${BOUNDARY_GIT} clean -fd
|
|
515
|
+
${identityScript(4, 5)}`;
|
|
516
|
+
const result = await ctx.runner.run("podman", throwawayArgs(name, paths, state.imageId, { clone: "rw", bundles: true }, script, [base.ref, base.short, base.commit, ...await operatorIdentity(ctx)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
485
517
|
if (result.exitCode !== 0)
|
|
486
518
|
throw new LifecycleError("writer_reset_failed");
|
|
487
519
|
const next = { ...state, base: { ref: `refs/heads/${base.short}`, commit: base.commit } };
|
|
@@ -503,13 +535,13 @@ ${BOUNDARY_GIT} clean -fd`;
|
|
|
503
535
|
return failure(error);
|
|
504
536
|
}
|
|
505
537
|
}
|
|
506
|
-
export async function finishWriter(ctx, name) {
|
|
538
|
+
export async function finishWriter(ctx, name, opts = {}) {
|
|
507
539
|
try {
|
|
508
540
|
const state = await requiredState(ctx, name);
|
|
509
541
|
const collected = await collectWriter(ctx, name);
|
|
510
542
|
if (collected.status === "failed")
|
|
511
543
|
return collected;
|
|
512
|
-
return state.lifecycle === "ephemeral" ? await recreateWriter(ctx, name, { discard: false }) : await resetWriter(ctx, name, { discard: false });
|
|
544
|
+
return state.lifecycle === "ephemeral" ? await recreateWriter(ctx, name, { discard: false, ...opts }) : await resetWriter(ctx, name, { discard: false, ...opts });
|
|
513
545
|
}
|
|
514
546
|
catch (error) {
|
|
515
547
|
return failure(error);
|
|
@@ -24,13 +24,15 @@ export const writerStateSchema = z.object({
|
|
|
24
24
|
index: indexSchema,
|
|
25
25
|
phase: z.enum(["provisioning", "ready", "not_ready"]),
|
|
26
26
|
lifecycle: z.enum(["persistent", "ephemeral"]),
|
|
27
|
+
launch: z.enum(["legacy", "relay"]).default("legacy"),
|
|
27
28
|
profiles: z.array(aliasSchema).min(1).readonly(),
|
|
28
29
|
providers: z.array(z.object({
|
|
29
30
|
provider: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/),
|
|
30
31
|
model: z.string().min(1).max(200),
|
|
31
32
|
}).strict()).refine((providers) => new Set(providers.map(({ provider }) => provider)).size === providers.length).readonly(),
|
|
32
33
|
network: z.enum(["pasta", "none"]),
|
|
33
|
-
cache: z.enum(["none", "go"]),
|
|
34
|
+
cache: z.enum(["none", "go", "uv"]),
|
|
35
|
+
tags: z.enum(["none", "reachable"]).default("none"),
|
|
34
36
|
imageId: hex64,
|
|
35
37
|
base: z.object({ ref: z.string(), commit: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/) }).strict(),
|
|
36
38
|
acceptance: z.object({
|
|
@@ -60,7 +62,7 @@ export function writerPaths(home, name) {
|
|
|
60
62
|
const root = join(writersRoot(home), name);
|
|
61
63
|
return {
|
|
62
64
|
root, clone: join(root, "clone"), agent: join(root, "agent"), bundles: join(root, "bundles"),
|
|
63
|
-
config: join(root, "config"), events: join(root, "events"), go: join(root, "go"), wrapper: join(root, "wrapper.sh"), state: join(root, "state.json"),
|
|
65
|
+
config: join(root, "config"), events: join(root, "events"), go: join(root, "go"), uv: join(root, "uv"), wrapper: join(root, "wrapper.sh"), state: join(root, "state.json"),
|
|
64
66
|
acceptanceFailure: join(root, "acceptance-failure.json"),
|
|
65
67
|
};
|
|
66
68
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,75 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.12.0 — 2026-10-28
|
|
6
|
+
|
|
7
|
+
Minor release under ADR 0003 for
|
|
8
|
+
[ADR 0012](docs/adr/0012-leased-writer-pool.md)
|
|
9
|
+
([#157](https://github.com/plagor44-ux/pi-session-mcp/issues/157)): one host
|
|
10
|
+
registration codes in container workspaces through a leased writer pool. The
|
|
11
|
+
MCP contract changes: `pi_session_start` gains an optional `base`, sessions and
|
|
12
|
+
capabilities gain writer fields, and there are new error codes. It also ships
|
|
13
|
+
the writer cache and identity changes of
|
|
14
|
+
[#151](https://github.com/plagor44-ux/pi-session-mcp/issues/151) and
|
|
15
|
+
[#154](https://github.com/plagor44-ux/pi-session-mcp/issues/154). `npm audit`
|
|
16
|
+
still reports `undici` 8.10.2, pinned by Pi SDK 1.4.0, the latest SDK release
|
|
17
|
+
([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
|
|
18
|
+
|
|
19
|
+
Upgrading needs a one-time migration of every container repository; follow
|
|
20
|
+
[Installation](docs/installation.md#upgrade). Writers must be accepted again
|
|
21
|
+
after the upgrade, as after every release.
|
|
22
|
+
|
|
23
|
+
- Changed: coding on a `container` workspace goes through the one host
|
|
24
|
+
registration `pi-session-mcp`. The host leases a ready, accepted writer at
|
|
25
|
+
the requested or default base and relays the session to the writer's
|
|
26
|
+
contained server over stdio. Setup no longer adds `pi-coding-<alias>-w<n>`
|
|
27
|
+
registrations or writer hook entries, and `--client codex` has no writer
|
|
28
|
+
effect any more. Relayed start has a 60-second budget, prompt preflight and
|
|
29
|
+
abort 15 seconds each, and close cleanup 10 seconds; inner replies are
|
|
30
|
+
validated and size-bounded.
|
|
31
|
+
- Changed: every writer operation, from launch to setup's collect, finish,
|
|
32
|
+
reset and recreate, runs under a per-writer host lease
|
|
33
|
+
(`~/.local/state/pi-session-mcp/leases/<name>.lock`) and a container fence
|
|
34
|
+
(container name, label and clone mount). A held lease fails with
|
|
35
|
+
`writer_busy`; setup waits up to 10 seconds for it. A container that cannot be
|
|
36
|
+
removed fails with `writer_fenced`. The lease descriptor never enters a
|
|
37
|
+
container.
|
|
38
|
+
- Changed: reset, finish and recreate no longer need a reconnect of the writer
|
|
39
|
+
server; setup no longer reports `writer_reconnect_required`.
|
|
40
|
+
- Added: `pi_session_start` accepts an optional `base`, a short branch name,
|
|
41
|
+
for coding profiles on `container` workspaces; elsewhere it fails with
|
|
42
|
+
`base_not_applicable`. Container coding sessions carry `writer` and `base`,
|
|
43
|
+
and `pi_capabilities_get` lists each container workspace's `writers` and
|
|
44
|
+
`defaultBase`.
|
|
45
|
+
- Added: error codes `writer_unavailable` (with `reason` `none_ready`,
|
|
46
|
+
`none_at_base` or `none_admitted`, and the counts `busy`, `notAtBaseline`
|
|
47
|
+
and `settingsChanged`), `writer_session_lost` and `base_not_applicable`.
|
|
48
|
+
- Added: container modes record a default base. Setup records the base that
|
|
49
|
+
provisioning resolves (`--base`, else `origin/HEAD`, else `main`); migration
|
|
50
|
+
records the writers' shared base and fails with `workspace_base_ambiguous` if
|
|
51
|
+
they differ and no `--base` is given. Finish returns a writer to the default
|
|
52
|
+
base.
|
|
53
|
+
- Added: re-running a repository's provisioning command migrates its writers:
|
|
54
|
+
it removes the owned writer registrations and hooks, rewrites the wrappers,
|
|
55
|
+
marks the writers `launch: "relay"` and accepts them again. A divergent
|
|
56
|
+
registration is never removed; it reports `registration_divergent`, and that
|
|
57
|
+
writer stays outside the pool.
|
|
58
|
+
- Added: Doctor checks `writer_lease_directory`, `writer_not_migrated`,
|
|
59
|
+
`workspace_base_unset` and `writer_off_default_base`.
|
|
60
|
+
- Added: `--cache uv` mounts a private per-writer cache at `/pi/uv` with
|
|
61
|
+
`UV_CACHE_DIR=/pi/uv/cache` and `npm_config_cache=/pi/uv/npm`
|
|
62
|
+
([#151](https://github.com/plagor44-ux/pi-session-mcp/issues/151)).
|
|
63
|
+
- Added: writer clones get the operator checkout's commit identity when both
|
|
64
|
+
values are valid, and Doctor warns with `writer_git_identity_missing`
|
|
65
|
+
otherwise. `--tags reachable` bundles the tags reachable from the base
|
|
66
|
+
commit; the default stays `none`
|
|
67
|
+
([#154](https://github.com/plagor44-ux/pi-session-mcp/issues/154)).
|
|
68
|
+
- Changed: the wake hook matches only `mcp__pi-session-mcp__*` and reads the
|
|
69
|
+
host spool, which now also carries relayed container turns. Migrated writers
|
|
70
|
+
get no spool mount; a writer that is not yet migrated keeps it.
|
|
71
|
+
- Limits: lease, fence and relay need Linux. The live migration of an existing
|
|
72
|
+
installation and a relayed coding turn follow the release.
|
|
73
|
+
|
|
5
74
|
## 0.11.9 — 2026-10-27
|
|
6
75
|
|
|
7
76
|
Documentation-only release. The published `docs/` of 0.9.0 to 0.11.8 contained
|
package/README.md
CHANGED
|
@@ -30,9 +30,9 @@ It is not the right tool when:
|
|
|
30
30
|
|
|
31
31
|
## Status
|
|
32
32
|
|
|
33
|
-
The package version is **0.
|
|
33
|
+
The package version is **0.12.0**.
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
[Per-repository execution modes](docs/execution-modes.md) make host writes explicit and block coding on read-only aliases. On container workspaces, the one host `pi-session-mcp` registration leases a rootless-Podman writer per coding session on Linux; no per-writer registrations or tool prefixes are needed ([ADR 0012](docs/adr/0012-leased-writer-pool.md)). Setup provisions, accepts, collects and resets the pool. Starts normally use the workspace default base; fix rounds can pass a short branch `base`. Start/get report `writer` and `base` for collection, and capabilities report configured `writers` and `defaultBase`. Reset, finish and recreate need no reconnect; finish returns the writer to the default base. See the [container upgrade runbook](docs/installation.md#container-writers).
|
|
36
36
|
|
|
37
37
|
- **Pre-1.0.** The public tool contract is stable within a minor line: a change to tools, fields, states or error codes needs a new minor release.
|
|
38
38
|
- **Linux and macOS**, Node.js 22.19.0 or newer. macOS is tested in CI on macOS 15 and 26 on Apple silicon; Intel Macs run the same code but are untested. Setup acceptance on a real Mac is recorded for Claude Code only; Codex is still pending. See [Acceptance](docs/acceptance.md#status). On other platforms `setup` refuses with `platform_unsupported`.
|
|
@@ -173,6 +173,7 @@ Contract and design:
|
|
|
173
173
|
- [ADR 0004: setup accepts clients by contract, not by version](docs/adr/0004-client-contract-not-version.md)
|
|
174
174
|
- [ADR 0007: execution profiles select Pi skills from an operator catalog](docs/adr/0007-profile-skill-selection.md)
|
|
175
175
|
- [ADR 0011: turn events and controller wake](docs/adr/0011-turn-events-and-controller-wake.md)
|
|
176
|
+
- [ADR 0012: one registration with a leased writer pool](docs/adr/0012-leased-writer-pool.md)
|
|
176
177
|
|
|
177
178
|
Maintain:
|
|
178
179
|
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export declare function projectCapabilities(workspaces: ReadonlyMap<string, stri
|
|
|
18
18
|
workspaces: {
|
|
19
19
|
alias: string;
|
|
20
20
|
mode: import("./execution-modes.js").WorkspaceMode;
|
|
21
|
+
writers?: number;
|
|
22
|
+
defaultBase?: string | null;
|
|
21
23
|
}[];
|
|
22
24
|
executionProfiles: {
|
|
23
25
|
alias: string;
|
package/dist/capabilities.js
CHANGED
|
@@ -12,7 +12,10 @@ export function projectCapabilities(workspaces, executionProfiles, configuration
|
|
|
12
12
|
events: { delivery: eventDelivery },
|
|
13
13
|
workspaces: [...workspaces.keys()]
|
|
14
14
|
.sort(compareAliases)
|
|
15
|
-
.map((alias) =>
|
|
15
|
+
.map((alias) => {
|
|
16
|
+
const entry = workspaceModes.get(alias);
|
|
17
|
+
return { alias, mode: entry?.mode ?? "unset", ...(entry?.mode === "container" && entry.container ? { writers: entry.container.writers, defaultBase: entry.container.base ?? null } : {}) };
|
|
18
|
+
}),
|
|
16
19
|
executionProfiles: [...executionProfiles.values()]
|
|
17
20
|
.sort((left, right) => compareAliases(left.alias, right.alias))
|
|
18
21
|
.map(({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel, extensions, grantedExtensionTools, skills }) => ({
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Transport } from "@modelcontextprotocol/client";
|
|
2
|
+
/** A launcher is a lease guardian: EOF requests cleanup; killing it would break that contract. */
|
|
3
|
+
/** A frame carries a result twice (text content and structured content); 2 MiB covers both at the relay's size bound. */
|
|
4
|
+
export declare class ChildStdioTransport implements Transport {
|
|
5
|
+
private readonly command;
|
|
6
|
+
private readonly env;
|
|
7
|
+
private readonly args;
|
|
8
|
+
onclose: Transport["onclose"];
|
|
9
|
+
onerror: Transport["onerror"];
|
|
10
|
+
onmessage: Transport["onmessage"];
|
|
11
|
+
private child?;
|
|
12
|
+
private closed;
|
|
13
|
+
private started;
|
|
14
|
+
private readonly buffer;
|
|
15
|
+
private pending;
|
|
16
|
+
constructor(command: string, env: Record<string, string>, args?: readonly string[]);
|
|
17
|
+
start(): Promise<void>;
|
|
18
|
+
send(message: Parameters<Transport["send"]>[0]): Promise<void>;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
private finish;
|
|
21
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/client";
|
|
3
|
+
/** A launcher is a lease guardian: EOF requests cleanup; killing it would break that contract. */
|
|
4
|
+
/** A frame carries a result twice (text content and structured content); 2 MiB covers both at the relay's size bound. */
|
|
5
|
+
export class ChildStdioTransport {
|
|
6
|
+
command;
|
|
7
|
+
env;
|
|
8
|
+
args;
|
|
9
|
+
onclose;
|
|
10
|
+
onerror;
|
|
11
|
+
onmessage;
|
|
12
|
+
child;
|
|
13
|
+
closed = false;
|
|
14
|
+
started = false;
|
|
15
|
+
buffer = new ReadBuffer({ maxBufferSize: 2_097_152 });
|
|
16
|
+
pending = Buffer.alloc(0);
|
|
17
|
+
constructor(command, env, args = []) {
|
|
18
|
+
this.command = command;
|
|
19
|
+
this.env = env;
|
|
20
|
+
this.args = args;
|
|
21
|
+
}
|
|
22
|
+
async start() {
|
|
23
|
+
if (this.started || this.closed)
|
|
24
|
+
throw new Error("Transport cannot start");
|
|
25
|
+
this.started = true;
|
|
26
|
+
const child = this.child = spawn(this.command, [...this.args], { detached: true, stdio: ["pipe", "pipe", "pipe"], env: { ...this.env }, shell: false });
|
|
27
|
+
child.stderr.on("data", () => undefined);
|
|
28
|
+
child.stdin.on("error", () => this.onerror?.(new Error("Writer input failed")));
|
|
29
|
+
child.on("error", () => this.onerror?.(new Error("Writer process failed")));
|
|
30
|
+
child.on("close", () => this.finish());
|
|
31
|
+
child.stdout.on("data", (chunk) => {
|
|
32
|
+
if (this.closed)
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
this.pending = Buffer.concat([this.pending, chunk]);
|
|
36
|
+
let newline;
|
|
37
|
+
while ((newline = this.pending.indexOf(10)) !== -1) {
|
|
38
|
+
if (newline > 2_097_152)
|
|
39
|
+
throw new Error();
|
|
40
|
+
const line = this.pending.subarray(0, newline + 1);
|
|
41
|
+
this.pending = this.pending.subarray(newline + 1);
|
|
42
|
+
// ReadBuffer silently skips JSON syntax errors; detect them before decoding.
|
|
43
|
+
JSON.parse(line.toString("utf8"));
|
|
44
|
+
this.buffer.append(line);
|
|
45
|
+
const message = this.buffer.readMessage();
|
|
46
|
+
if (message === null)
|
|
47
|
+
throw new Error();
|
|
48
|
+
this.onmessage?.(message);
|
|
49
|
+
}
|
|
50
|
+
if (this.pending.length > 2_097_152)
|
|
51
|
+
throw new Error();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
this.onerror?.(new Error("Invalid writer frame"));
|
|
55
|
+
void this.close();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
await new Promise((resolve, reject) => {
|
|
59
|
+
child.once("spawn", resolve);
|
|
60
|
+
child.once("error", () => reject(new Error("Writer spawn failed")));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async send(message) {
|
|
64
|
+
if (this.closed || !this.child)
|
|
65
|
+
throw new Error("Writer transport is closed");
|
|
66
|
+
await new Promise((resolve, reject) => {
|
|
67
|
+
this.child.stdin.write(serializeMessage(message), error => error ? reject(new Error("Writer send failed")) : resolve());
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async close() {
|
|
71
|
+
if (this.closed)
|
|
72
|
+
return;
|
|
73
|
+
this.child?.stdin.end();
|
|
74
|
+
// Keep draining diagnostics/output while the guardian cleans up, without keeping the host alive.
|
|
75
|
+
this.child?.unref();
|
|
76
|
+
this.child?.stdout?.unref?.();
|
|
77
|
+
this.child?.stderr?.unref?.();
|
|
78
|
+
this.finish();
|
|
79
|
+
}
|
|
80
|
+
finish() {
|
|
81
|
+
if (this.closed)
|
|
82
|
+
return;
|
|
83
|
+
this.closed = true;
|
|
84
|
+
this.buffer.clear();
|
|
85
|
+
this.pending = Buffer.alloc(0);
|
|
86
|
+
this.onclose?.();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -11,6 +11,7 @@ export interface CommandRunner {
|
|
|
11
11
|
run(command: string, args: readonly string[], signal?: AbortSignal, options?: {
|
|
12
12
|
cwd?: string;
|
|
13
13
|
env?: NodeJS.ProcessEnv;
|
|
14
|
+
inheritFds?: readonly number[];
|
|
14
15
|
}): Promise<CommandResult>;
|
|
15
16
|
}
|
|
16
17
|
export interface CommandExecution {
|
package/dist/doctor.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type ExtensionSpec } from "./extension-spec.js";
|
|
|
3
3
|
import { type ReleasedSkillTree } from "./skill-library.js";
|
|
4
4
|
export type DoctorSeverity = "ok" | "warning" | "error";
|
|
5
5
|
export type DoctorExitCode = 0 | 1 | 2 | 64;
|
|
6
|
-
export type DoctorCheckId = "node_version_unsupported" | "package_metadata_unreadable" | "build_missing" | "build_stale" | "version_mismatch" | "config_missing" | "config_unreadable" | "config_invalid" | "workspace_missing" | "workspace_not_directory" | "workspace_unreadable" | "workspace_not_writable" | "workspace_gitfile" | "workspace_mode_unset" | "workspace_host_writes_accepted" | "container_runtime_unavailable" | "container_profile_refused" | "writer_not_ready" | "writer_dirty" | "writer_uncollected_bundle" | "extension_missing" | "extension_version_mismatch" | "extension_unpinned" | "skill_missing" | "skill_unreadable" | "skill_duplicate" | "skill_override_outdated" | "event_spool_root";
|
|
6
|
+
export type DoctorCheckId = "node_version_unsupported" | "package_metadata_unreadable" | "build_missing" | "build_stale" | "version_mismatch" | "config_missing" | "config_unreadable" | "config_invalid" | "workspace_missing" | "workspace_not_directory" | "workspace_unreadable" | "workspace_not_writable" | "workspace_gitfile" | "workspace_mode_unset" | "workspace_base_unset" | "workspace_host_writes_accepted" | "container_runtime_unavailable" | "container_profile_refused" | "writer_lease_directory" | "writer_not_ready" | "writer_not_migrated" | "writer_off_default_base" | "writer_dirty" | "writer_git_identity_missing" | "writer_uncollected_bundle" | "extension_missing" | "extension_version_mismatch" | "extension_unpinned" | "skill_missing" | "skill_unreadable" | "skill_duplicate" | "skill_override_outdated" | "event_spool_root";
|
|
7
7
|
export interface DoctorCheck {
|
|
8
8
|
readonly id: DoctorCheckId;
|
|
9
9
|
readonly severity: DoctorSeverity;
|
|
@@ -72,6 +72,8 @@ export interface DoctorDependencies {
|
|
|
72
72
|
readonly env?: NodeJS.ProcessEnv;
|
|
73
73
|
/** Metadata of the spool root without following a final symbolic link, `undefined` when absent; used only when event delivery is enabled. */
|
|
74
74
|
readonly eventSpoolRoot?: (path: string) => Promise<EventSpoolRootInfo | undefined>;
|
|
75
|
+
/** Metadata of the writer lease directory without following a final symbolic link, `undefined` when absent. */
|
|
76
|
+
readonly writerLeaseRoot?: (path: string) => Promise<EventSpoolRootInfo | undefined>;
|
|
75
77
|
/** Effective user id of the current process; defaults to the real one. */
|
|
76
78
|
readonly uid?: number;
|
|
77
79
|
}
|