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.
Files changed (73) hide show
  1. package/.pi-session-mcp-cli/doctor.js +89 -4
  2. package/.pi-session-mcp-cli/execution-modes.js +16 -4
  3. package/.pi-session-mcp-cli/override-lock.js +4 -4
  4. package/.pi-session-mcp-cli/setup-process.js +5 -4
  5. package/.pi-session-mcp-cli/writer-boundary.js +10 -4
  6. package/.pi-session-mcp-cli/writer-fence.js +50 -0
  7. package/.pi-session-mcp-cli/writer-lease.js +140 -0
  8. package/.pi-session-mcp-cli/writer-lifecycle.js +55 -23
  9. package/.pi-session-mcp-cli/writer-state.js +4 -2
  10. package/CHANGELOG.md +69 -0
  11. package/README.md +3 -2
  12. package/dist/capabilities.d.ts +2 -0
  13. package/dist/capabilities.js +4 -1
  14. package/dist/child-stdio-transport.d.ts +21 -0
  15. package/dist/child-stdio-transport.js +88 -0
  16. package/dist/client-adapters/types.d.ts +1 -0
  17. package/dist/doctor.d.ts +3 -1
  18. package/dist/doctor.js +89 -4
  19. package/dist/execution-modes.d.ts +12 -2
  20. package/dist/execution-modes.js +16 -4
  21. package/dist/main.js +9 -2
  22. package/dist/override-lock.d.ts +2 -0
  23. package/dist/override-lock.js +4 -4
  24. package/dist/pi-adapter.d.ts +30 -0
  25. package/dist/pi-adapter.js +11 -0
  26. package/dist/refusal-server.d.ts +1 -1
  27. package/dist/refusal-server.js +24 -2
  28. package/dist/server.d.ts +2 -2
  29. package/dist/server.js +18 -53
  30. package/dist/session-registry.d.ts +9 -2
  31. package/dist/session-registry.js +17 -12
  32. package/dist/setup-cli.js +7 -2
  33. package/dist/setup-hooks.js +0 -2
  34. package/dist/setup-modes.js +7 -1
  35. package/dist/setup-process.d.ts +3 -1
  36. package/dist/setup-process.js +5 -4
  37. package/dist/setup-writers.d.ts +5 -0
  38. package/dist/setup-writers.js +200 -92
  39. package/dist/tool-schemas.d.ts +339 -0
  40. package/dist/tool-schemas.js +49 -0
  41. package/dist/wake-hook.d.ts +4 -5
  42. package/dist/wake-hook.js +3 -6
  43. package/dist/writer-acceptance.js +10 -1
  44. package/dist/writer-boundary.d.ts +6 -4
  45. package/dist/writer-boundary.js +10 -4
  46. package/dist/writer-fence.d.ts +12 -0
  47. package/dist/writer-fence.js +50 -0
  48. package/dist/writer-launch.d.ts +9 -1
  49. package/dist/writer-launch.js +127 -30
  50. package/dist/writer-lease.d.ts +28 -0
  51. package/dist/writer-lease.js +140 -0
  52. package/dist/writer-lifecycle.d.ts +7 -1
  53. package/dist/writer-lifecycle.js +55 -23
  54. package/dist/writer-relay.d.ts +33 -0
  55. package/dist/writer-relay.js +359 -0
  56. package/dist/writer-state.d.ts +13 -1
  57. package/dist/writer-state.js +4 -2
  58. package/docs/acceptance.md +47 -0
  59. package/docs/adr/0012-leased-writer-pool.md +357 -0
  60. package/docs/architecture.md +61 -13
  61. package/docs/client-setup.md +11 -15
  62. package/docs/doctor-design.md +10 -4
  63. package/docs/execution-modes.md +87 -44
  64. package/docs/installation.md +34 -22
  65. package/docs/isolation-setup.md +88 -102
  66. package/docs/roadmap.md +5 -0
  67. package/docs/threat-model.md +7 -4
  68. package/docs/tool-contracts.md +29 -12
  69. package/package.json +1 -1
  70. package/scripts/isolation-acceptance.mjs +7 -1
  71. package/skills/pi-session-implementation/SKILL.md +1 -0
  72. package/skills/pi-session-orchestration/SKILL.md +6 -5
  73. 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
- async function baseBundle(ctx, paths, ref) {
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
- for (const path of [paths.root, paths.clone, paths.config, paths.agent, paths.bundles, ...spool, ...(settings.cache === "go" ? [paths.go] : [])])
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\/[^/]+\/.+))$/.exec(heads ?? "");
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
- const result = await ctx.runner.run("podman", throwawayArgs(paths, state.imageId, { clone: "rw", bundles: true }, script, [base.ref, base.short, base.commit]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
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.11.9**.
33
+ The package version is **0.12.0**.
34
34
 
35
- Since 0.9.0, [per-repository execution modes](docs/execution-modes.md) make host writes explicit and block host coding for container/read-only aliases, and setup provisions, accepts, collects and resets rootless-Podman container writers on Linux.
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
 
@@ -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;
@@ -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) => ({ alias, mode: workspaceModes.get(alias)?.mode ?? "unset" })),
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
  }