pi-session-mcp 0.12.1 → 0.13.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 (62) hide show
  1. package/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +2 -1
  2. package/.pi-session-mcp-cli/config.js +15 -1
  3. package/.pi-session-mcp-cli/doctor.js +44 -1
  4. package/.pi-session-mcp-cli/execution-modes.js +10 -2
  5. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  6. package/.pi-session-mcp-cli/sandbox-shell.js +254 -0
  7. package/.pi-session-mcp-cli/session-tools.js +6 -2
  8. package/.pi-session-mcp-cli/writer-boundary.js +27 -0
  9. package/.pi-session-mcp-cli/writer-lifecycle.js +6 -4
  10. package/.pi-session-mcp-cli/writer-limits.js +19 -0
  11. package/.pi-session-mcp-cli/writer-state.js +5 -2
  12. package/CHANGELOG.md +74 -0
  13. package/README.md +2 -1
  14. package/dist/capabilities.d.ts +1 -0
  15. package/dist/capabilities.js +3 -1
  16. package/dist/client-adapters/mcp-verifier.js +2 -1
  17. package/dist/config.d.ts +1 -0
  18. package/dist/config.js +15 -1
  19. package/dist/confined-tools.d.ts +29 -0
  20. package/dist/confined-tools.js +118 -0
  21. package/dist/doctor.d.ts +5 -1
  22. package/dist/doctor.js +44 -1
  23. package/dist/execution-modes.d.ts +13 -1
  24. package/dist/execution-modes.js +10 -2
  25. package/dist/execution-profile.d.ts +4 -0
  26. package/dist/execution-profile.js +1 -0
  27. package/dist/main.js +6 -2
  28. package/dist/pi-adapter.d.ts +1 -1
  29. package/dist/pi-adapter.js +1 -0
  30. package/dist/sandbox-shell.d.ts +71 -0
  31. package/dist/sandbox-shell.js +254 -0
  32. package/dist/sdk-pi-adapter.d.ts +17 -1
  33. package/dist/sdk-pi-adapter.js +64 -10
  34. package/dist/session-tools.d.ts +4 -2
  35. package/dist/session-tools.js +6 -2
  36. package/dist/setup-cli.js +40 -6
  37. package/dist/setup-modes.d.ts +3 -0
  38. package/dist/setup-modes.js +18 -1
  39. package/dist/setup-writers.js +33 -3
  40. package/dist/tool-schemas.d.ts +4 -0
  41. package/dist/tool-schemas.js +1 -0
  42. package/dist/writer-boundary.d.ts +2 -0
  43. package/dist/writer-boundary.js +27 -0
  44. package/dist/writer-lifecycle.d.ts +1 -0
  45. package/dist/writer-lifecycle.js +6 -4
  46. package/dist/writer-limits.d.ts +13 -0
  47. package/dist/writer-limits.js +19 -0
  48. package/dist/writer-relay.js +1 -1
  49. package/dist/writer-state.d.ts +11 -0
  50. package/dist/writer-state.js +5 -2
  51. package/docs/acceptance.md +141 -0
  52. package/docs/adr/0013-hardened-read-only-profiles.md +182 -0
  53. package/docs/doctor-design.md +2 -0
  54. package/docs/execution-modes.md +59 -3
  55. package/docs/installation.md +25 -1
  56. package/docs/isolation-setup.md +31 -2
  57. package/docs/roadmap.md +6 -0
  58. package/docs/session-mcp.md +13 -0
  59. package/docs/threat-model.md +50 -0
  60. package/docs/tool-contracts.md +30 -3
  61. package/package.json +1 -1
  62. package/skills/pi-session-orchestration/references/start-errors.md +1 -1
package/dist/setup-cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { cpusSchema, cpuSharesSchema, memorySchema } from "./writer-limits.js";
1
2
  import { WRITER_NAME_PATTERN } from "./writer-state.js";
2
3
  export function parseSetupArgs(argv) {
3
4
  let operation;
@@ -11,6 +12,7 @@ export function parseSetupArgs(argv) {
11
12
  let discard = false, codex = false, isolationControl = false;
12
13
  const container = {};
13
14
  const modeFlags = new Set();
15
+ const clearLimits = [];
14
16
  for (let index = 0; index < argv.length; index += 1) {
15
17
  const argument = argv[index];
16
18
  if (["--collect", "--reset", "--recreate", "--finish"].includes(argument)) {
@@ -81,7 +83,7 @@ export function parseSetupArgs(argv) {
81
83
  customizeSkill = value;
82
84
  continue;
83
85
  }
84
- if (["--repo", "--mode", "--accept-host-writes", "--writers", "--lifecycle", "--profiles", "--image", "--network", "--cache", "--tags"].includes(argument)) {
86
+ if (["--repo", "--mode", "--accept-host-writes", "--writers", "--lifecycle", "--profiles", "--image", "--network", "--cache", "--tags", "--clean", "--cpus", "--cpu-shares", "--memory"].includes(argument)) {
85
87
  if (modeFlags.has(argument))
86
88
  throw new Error("repo_argument_repeated");
87
89
  modeFlags.add(argument);
@@ -89,8 +91,8 @@ export function parseSetupArgs(argv) {
89
91
  acceptHostWrites = true;
90
92
  continue;
91
93
  }
92
- const value = argv[++index];
93
- if (!value || value.startsWith("-"))
94
+ const value = argv[++index] ?? "";
95
+ if ((!value || value.startsWith("-")) && !["--cpus", "--cpu-shares", "--memory"].includes(argument))
94
96
  throw new Error("repo_argument_invalid");
95
97
  switch (argument) {
96
98
  case "--repo":
@@ -129,8 +131,40 @@ export function parseSetupArgs(argv) {
129
131
  throw new Error("tags_invalid");
130
132
  container.tags = value;
131
133
  break;
134
+ case "--clean":
135
+ if (value !== "untracked" && value !== "all")
136
+ throw new Error("clean_invalid");
137
+ container.clean = value;
138
+ break;
139
+ case "--cpus":
140
+ if (value === "none") {
141
+ clearLimits.push("cpus");
142
+ break;
143
+ }
144
+ if (!value || !/^[0-9]+(?:\.[0-9]{1,2})?$/.test(value) || !cpusSchema.safeParse(Number(value)).success)
145
+ throw new Error("cpus_invalid");
146
+ container.cpus = Number(value);
147
+ break;
148
+ case "--cpu-shares":
149
+ if (value === "none") {
150
+ clearLimits.push("cpuShares");
151
+ break;
152
+ }
153
+ if (!value || !/^[0-9]+$/.test(value) || !cpuSharesSchema.safeParse(Number(value)).success)
154
+ throw new Error("cpu_shares_invalid");
155
+ container.cpuShares = Number(value);
156
+ break;
157
+ case "--memory":
158
+ if (value === "none") {
159
+ clearLimits.push("memory");
160
+ break;
161
+ }
162
+ if (!memorySchema.safeParse(value).success)
163
+ throw new Error("memory_invalid");
164
+ container.memory = value;
165
+ break;
132
166
  case "--cache":
133
- if (value !== "none" && value !== "go" && value !== "uv")
167
+ if (value !== "none" && value !== "go" && value !== "uv" && value !== "npm")
134
168
  throw new Error("cache_invalid");
135
169
  container.cache = value;
136
170
  break;
@@ -163,9 +197,9 @@ export function parseSetupArgs(argv) {
163
197
  if (modeFlags.size > 0) {
164
198
  if (repo === undefined || targets.length > 0 || customizeSkill !== undefined || (operation !== undefined && operation !== "dry-run" && operation !== "apply"))
165
199
  throw new Error("repo_combination");
166
- if ((mode === "host" || mode === "read-only") && Object.keys(container).length > 0)
200
+ if ((mode === "host" || mode === "read-only") && (Object.keys(container).length > 0 || clearLimits.length > 0))
167
201
  throw new Error("repo_combination");
168
- const request = { alias: repo, acceptHostWrites, apply: operation === "apply", discard, codex, ...(baseRef ? { baseRef } : {}), ...(mode === undefined ? {} : { mode }), ...(Object.keys(container).length === 0 ? {} : { container }) };
202
+ const request = { alias: repo, acceptHostWrites, apply: operation === "apply", discard, codex, ...(baseRef ? { baseRef } : {}), ...(mode === undefined ? {} : { mode }), ...(Object.keys(container).length === 0 ? {} : { container }), ...(clearLimits.length === 0 ? {} : { clearLimits }) };
169
203
  return { ...(operation === undefined ? {} : { operation }), targets, json, repo: request };
170
204
  }
171
205
  if (customizeSkill !== undefined) {
@@ -9,9 +9,12 @@ export interface ModeRequest {
9
9
  readonly discard?: boolean;
10
10
  readonly baseRef?: string;
11
11
  readonly codex?: boolean;
12
+ /** Limits to remove (`--cpus none` and so on); an omitted limit keeps the recorded one (#166). */
13
+ readonly clearLimits?: readonly ("cpus" | "cpuShares" | "memory")[];
12
14
  }
13
15
  /** A dry run writes nothing; apply serializes the entire read/validate/replace transaction. */
14
16
  export declare function changeExecutionMode(configPath: string, request: ModeRequest, dependencies?: {
17
+ cgroupControllers?: () => Promise<readonly string[]>;
15
18
  containerRuntime?: () => Promise<boolean>;
16
19
  beforeWrite?: (entry: import("./execution-modes.js").WorkspaceModeEntry) => Promise<SetupResult | undefined>;
17
20
  afterChange?: (output: SetupResult) => Promise<SetupResult>;
@@ -1,3 +1,6 @@
1
+ import { podmanCgroupControllers } from "./writer-boundary.js";
2
+ import { createProcessRunner } from "./setup-process.js";
3
+ import { writerLimits } from "./writer-limits.js";
1
4
  import { randomUUID } from "node:crypto";
2
5
  import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
3
6
  import { dirname, join } from "node:path";
@@ -65,7 +68,14 @@ export async function changeExecutionMode(configPath, request, dependencies = {}
65
68
  const previousBase = existing?.mode === "container" ? existing.base : undefined;
66
69
  const requestedBase = request.baseRef?.replace(/^refs\/heads\//, "");
67
70
  const base = requestedBase ?? previousBase ?? request.container?.base;
68
- return { ...request.container, mode, ...(base === undefined ? {} : { base }) };
71
+ // Limits are outside the settings drift check, so an omitted flag must not drop one silently (#166).
72
+ const limits = Object.fromEntries(["cpus", "cpuShares", "memory"].flatMap((key) => {
73
+ if (request.clearLimits?.includes(key))
74
+ return [];
75
+ const value = request.container?.[key] ?? (existing?.mode === "container" ? existing[key] : undefined);
76
+ return value === undefined ? [] : [[key, value]];
77
+ }));
78
+ return { ...request.container, ...limits, mode, ...(base === undefined ? {} : { base }) };
69
79
  })() : { mode };
70
80
  let proposed;
71
81
  try {
@@ -76,6 +86,13 @@ export async function changeExecutionMode(configPath, request, dependencies = {}
76
86
  return done("failed", "modes_file_invalid");
77
87
  }
78
88
  const resolved = resolveExecutionModes(proposed, aliases, config.executionProfiles).get(request.alias);
89
+ const limits = resolved.container && writerLimits(resolved.container);
90
+ if (limits) {
91
+ const controllers = await (dependencies.cgroupControllers ?? (() => podmanCgroupControllers(createProcessRunner())))().catch(() => []);
92
+ if (((limits.cpus !== undefined || limits.cpuShares !== undefined) && !controllers.includes("cpu"))
93
+ || (limits.memory !== undefined && !controllers.includes("memory")))
94
+ return done("failed", "cgroup_controller_unavailable");
95
+ }
79
96
  const refused = await dependencies.beforeWrite?.(resolved);
80
97
  if (refused)
81
98
  return refused;
@@ -15,7 +15,8 @@ import { localHookNeededByOthers, ownsWakeHook, runWakeHook } from "./setup-hook
15
15
  import { result } from "./setup-result.js";
16
16
  import { createWriterRegistrationAdapter, unregisterWriter } from "./client-adapters/writer-registration.js";
17
17
  import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
18
- import { IMAGE_PULL_TIMEOUT_MS } from "./writer-boundary.js";
18
+ import { sameWriterLimits, writerLimits } from "./writer-limits.js";
19
+ import { IMAGE_PULL_TIMEOUT_MS, podmanCgroupControllers } from "./writer-boundary.js";
19
20
  import { acquireWriterLease, WriterLeaseError } from "./writer-lease.js";
20
21
  import { ensureWriterAbsent } from "./writer-fence.js";
21
22
  import * as lifecycle from "./writer-lifecycle.js";
@@ -209,6 +210,15 @@ async function reconcileUnlocked(configPath, alias, request, deps) {
209
210
  add(name, drift);
210
211
  return;
211
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
+ }
212
222
  }
213
223
  // Decide for both clients before removing anything, so a divergent registration of one client never
214
224
  // costs the writer its owned registration of the other. A client that is not installed has nothing to keep.
@@ -311,7 +321,7 @@ export async function changeWriterMode(configPath, request, deps) {
311
321
  return failed("alias_unknown");
312
322
  let runtimeProbe;
313
323
  const containerRuntime = () => runtimeProbe ??= (deps.containerRuntime ?? containerRuntimeUsable)().catch(() => false);
314
- const modeDependencies = { containerRuntime };
324
+ const modeDependencies = { containerRuntime, cgroupControllers: () => podmanCgroupControllers(services(deps).runner) };
315
325
  try {
316
326
  let writerWork = request.mode === "container";
317
327
  if (!writerWork) {
@@ -399,6 +409,24 @@ export async function changeWriterMode(configPath, request, deps) {
399
409
  throw error;
400
410
  }
401
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
+ }
402
430
  }
403
431
  else
404
432
  await preflight(ctx, planned.surplus, request.discard ?? false, deps);
@@ -420,7 +448,9 @@ export async function changeWriterMode(configPath, request, deps) {
420
448
  for (const name of planned.keep) {
421
449
  // The same settings check as apply, so a drifted writer shows before anything is applied (#134).
422
450
  const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: proposed.container, rawConfig, overrideDirectory });
423
- kept.push({ target: targetFor(request.alias, name), ...(drift ?? { status: "planned", code: "writer_planned" }) });
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" }) });
424
454
  }
425
455
  const refused = kept.some((finding) => finding.status === "failed" || finding.status === "divergent");
426
456
  writers = result("dry-run", [...kept, ...planned.surplus.map((name) => ({ target: targetFor(request.alias, name), status: "planned", code: "writer_remove_planned" }))], refused ? "failed" : "planned");
@@ -334,6 +334,10 @@ export declare const capabilitiesSchema: z.ZodObject<{
334
334
  }>;
335
335
  extensions: z.ZodArray<z.ZodString>;
336
336
  grantedExtensionTools: z.ZodArray<z.ZodString>;
337
+ shell: z.ZodEnum<{
338
+ none: "none";
339
+ sandboxed: "sandboxed";
340
+ }>;
337
341
  skills: z.ZodArray<z.ZodString>;
338
342
  }, z.core.$strict>>;
339
343
  }, z.core.$strict>;
@@ -44,6 +44,7 @@ export const capabilitiesSchema = z.object({
44
44
  thinkingLevel: thinkingLevelSchema,
45
45
  extensions: z.array(z.string()),
46
46
  grantedExtensionTools: z.array(z.string()),
47
+ shell: z.enum(["none", "sandboxed"]),
47
48
  skills: z.array(aliasSchema),
48
49
  }).strict()),
49
50
  }).strict();
@@ -46,6 +46,8 @@ export declare function podmanInfo(runner: CommandRunner): Promise<{
46
46
  usable: boolean;
47
47
  version?: string;
48
48
  }>;
49
+ /** A failed or malformed host probe provides no usable controllers. */
50
+ export declare function podmanCgroupControllers(runner: CommandRunner): Promise<readonly string[]>;
49
51
  export declare function resolveImage(runner: CommandRunner, image: string, pull: boolean): Promise<string>;
50
52
  export declare function verifyCloneLayout(clone: string): Promise<void>;
51
53
  export declare function isSafeRefName(name: string): boolean;
@@ -35,6 +35,12 @@ export function serveArgs(paths, state, command, releaseRoot, eventsDir, options
35
35
  "-e", "PI_CODING_AGENT_DIR=/pi/agent",
36
36
  "-e", "PI_SESSION_MCP_WRITER_GUARD=/pi/config/writer-guard.json",
37
37
  ];
38
+ if (state.limits?.cpus !== undefined)
39
+ args.push(`--cpus=${state.limits.cpus}`);
40
+ if (state.limits?.cpuShares !== undefined)
41
+ args.push(`--cpu-shares=${state.limits.cpuShares}`);
42
+ if (state.limits?.memory !== undefined)
43
+ args.push(`--memory=${state.limits.memory}`);
38
44
  if (options?.name !== undefined)
39
45
  args.push("--name", writerContainerName(options.name));
40
46
  if (eventsDir !== undefined)
@@ -43,6 +49,8 @@ export function serveArgs(paths, state, command, releaseRoot, eventsDir, options
43
49
  args.push("-v", `${paths.go}:/pi/go:rw`, "-e", "GOPATH=/pi/go", "-e", "GOCACHE=/pi/go/cache", "-e", "GOTOOLCHAIN=local");
44
50
  if (state.cache === "uv")
45
51
  args.push("-v", `${paths.uv}:/pi/uv:rw`, "-e", "UV_CACHE_DIR=/pi/uv/cache", "-e", "npm_config_cache=/pi/uv/npm");
52
+ if (state.cache === "npm")
53
+ args.push("-v", `${paths.npm}:/pi/npm:rw`, "-e", "npm_config_cache=/pi/npm/cache");
46
54
  return [...args, state.imageId, ...command];
47
55
  }
48
56
  export class EventsDirectoryError extends Error {
@@ -261,6 +269,25 @@ export async function podmanInfo(runner) {
261
269
  return { usable: false };
262
270
  }
263
271
  }
272
+ /** A failed or malformed host probe provides no usable controllers. */
273
+ export async function podmanCgroupControllers(runner) {
274
+ try {
275
+ const output = await podman(runner, ["info", "--format", "json"]);
276
+ if (output.exitCode !== 0)
277
+ return [];
278
+ const info = JSON.parse(output.stdout);
279
+ if (!info || typeof info !== "object" || !("host" in info))
280
+ return [];
281
+ const host = info.host;
282
+ if (!host || typeof host !== "object" || !("cgroupControllers" in host))
283
+ return [];
284
+ const controllers = host.cgroupControllers;
285
+ return Array.isArray(controllers) && controllers.every((value) => typeof value === "string") ? controllers : [];
286
+ }
287
+ catch {
288
+ return [];
289
+ }
290
+ }
264
291
  export async function resolveImage(runner, image, pull) {
265
292
  if (image !== "node" && !/^[0-9a-f]{64}$/.test(image))
266
293
  throw new BoundaryError("image_unavailable");
@@ -35,6 +35,7 @@ export declare function renderContainedConfig(alias: string, clonePath: string,
35
35
  mcpServers?: Readonly<Record<string, import("./mcp-config.js").McpServerConfig>> | undefined;
36
36
  extensions?: string[] | undefined;
37
37
  grantedExtensionTools?: string[] | undefined;
38
+ shell?: "sandboxed" | undefined;
38
39
  skills?: string[] | undefined;
39
40
  }>;
40
41
  };
@@ -2,6 +2,7 @@ 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
4
  import { basename, join } from "node:path";
5
+ import { writerLimits } from "./writer-limits.js";
5
6
  import { configSchema } from "./config.js";
6
7
  import { renderWrapper } from "./writer-wrapper.js";
7
8
  import { builtinSkillName } from "./skill-catalog.js";
@@ -220,7 +221,7 @@ async function storedConfig(paths) {
220
221
  async function sameSettings(paths, existing, settings, now) {
221
222
  const stored = await storedConfig(paths);
222
223
  return stored !== undefined && existing.imageId === now.imageId && existing.lifecycle === settings.lifecycle
223
- && existing.network === settings.network && existing.cache === settings.cache && existing.tags === settings.tags
224
+ && existing.network === settings.network && existing.cache === settings.cache && existing.tags === settings.tags && existing.clean === settings.clean
224
225
  && JSON.stringify(existing.profiles) === JSON.stringify(settings.profiles)
225
226
  && JSON.stringify(existing.providers) === JSON.stringify(now.providers) && canonical(withoutDeliveryOff(stored)) === canonical(withoutDeliveryOff(now.config));
226
227
  }
@@ -295,12 +296,13 @@ export async function provisionWriter(ctx, input) {
295
296
  // With spool delivery the spool directory exists from provisioning on: launch would otherwise add it to the
296
297
  // writer root, a host directory the acceptance harness digests around its own first launch.
297
298
  const spool = config.events?.delivery === "spool" ? [paths.events] : [];
298
- const cache = settings.cache === "go" ? [paths.go] : settings.cache === "uv" ? [paths.uv] : [];
299
+ const cache = settings.cache === "none" ? [] : [paths[settings.cache]];
299
300
  for (const path of [paths.root, paths.clone, paths.config, paths.agent, paths.bundles, ...spool, ...cache])
300
301
  await privateDirectory(path);
301
302
  const state = {
302
303
  schemaVersion: 1, alias: ctx.alias, index, phase: "provisioning", lifecycle: settings.lifecycle,
303
- profiles: [...settings.profiles], providers, network: settings.network, cache: settings.cache, tags: settings.tags, imageId,
304
+ profiles: [...settings.profiles], providers, network: settings.network, cache: settings.cache, tags: settings.tags, clean: settings.clean, imageId,
305
+ ...(writerLimits(settings) ? { limits: writerLimits(settings) } : {}),
304
306
  base: { ref: `refs/heads/${short}`, commit },
305
307
  };
306
308
  await writeWriterState(ctx.home, name, state);
@@ -511,7 +513,7 @@ ${BOUNDARY_GIT} for-each-ref --format='%(refname)' refs/heads | while read -r re
511
513
  if [ "$ref" != "refs/heads/$2" ]; then ${BOUNDARY_GIT} update-ref -d "$ref"; fi
512
514
  done
513
515
  ${BOUNDARY_GIT} stash clear
514
- ${BOUNDARY_GIT} clean -fd
516
+ ${BOUNDARY_GIT} clean ${state.clean === "all" ? "-ffdx" : "-fd"}
515
517
  ${identityScript(4, 5)}`;
516
518
  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));
517
519
  if (result.exitCode !== 0)
@@ -0,0 +1,13 @@
1
+ import { z } from "zod";
2
+ export declare const cpusSchema: z.ZodNumber;
3
+ export declare const cpuSharesSchema: z.ZodNumber;
4
+ export declare const memorySchema: z.ZodString;
5
+ export declare const writerLimitsSchema: z.ZodObject<{
6
+ cpus: z.ZodOptional<z.ZodNumber>;
7
+ cpuShares: z.ZodOptional<z.ZodNumber>;
8
+ memory: z.ZodOptional<z.ZodString>;
9
+ }, z.core.$strict>;
10
+ export type WriterLimits = z.infer<typeof writerLimitsSchema>;
11
+ /** Omitted settings remove previous limits; never persist an empty limits object. */
12
+ export declare function writerLimits(settings: WriterLimits): WriterLimits | undefined;
13
+ export declare function sameWriterLimits(left: WriterLimits | undefined, right: WriterLimits | undefined): boolean;
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+ export const cpusSchema = z.number().positive().max(1024).multipleOf(0.01);
3
+ export const cpuSharesSchema = z.number().int().min(2).max(262144);
4
+ export const memorySchema = z.string().regex(/^[1-9][0-9]{0,6}[mg]$/);
5
+ export const writerLimitsSchema = z.object({
6
+ cpus: cpusSchema.optional(),
7
+ cpuShares: cpuSharesSchema.optional(),
8
+ memory: memorySchema.optional(),
9
+ }).strict();
10
+ /** Omitted settings remove previous limits; never persist an empty limits object. */
11
+ export function writerLimits(settings) {
12
+ const { cpus, cpuShares, memory } = settings;
13
+ if (cpus === undefined && cpuShares === undefined && memory === undefined)
14
+ return undefined;
15
+ return { ...(cpus === undefined ? {} : { cpus }), ...(cpuShares === undefined ? {} : { cpuShares }), ...(memory === undefined ? {} : { memory }) };
16
+ }
17
+ export function sameWriterLimits(left, right) {
18
+ return left?.cpus === right?.cpus && left?.cpuShares === right?.cpuShares && left?.memory === right?.memory;
19
+ }
@@ -69,7 +69,7 @@ const creationCodes = new Set([
69
69
  "model_unavailable", "thinking_level_unavailable", "execution_selection_mismatch", "external_mcp_unavailable",
70
70
  "external_mcp_no_tools", "external_mcp_grant_tool_missing", "external_mcp_activation_mismatch",
71
71
  "extension_not_installed", "extension_version_mismatch", "extension_load_failed", "extension_tool_conflict",
72
- "extension_tool_missing", "skill_load_failed", "pi_session_creation_failed",
72
+ "extension_tool_missing", "skill_load_failed", "sandbox_unavailable", "pi_session_creation_failed",
73
73
  ]);
74
74
  class InnerError extends Error {
75
75
  code;
@@ -33,12 +33,22 @@ export declare const writerStateSchema: z.ZodObject<{
33
33
  cache: z.ZodEnum<{
34
34
  go: "go";
35
35
  none: "none";
36
+ npm: "npm";
36
37
  uv: "uv";
37
38
  }>;
38
39
  tags: z.ZodDefault<z.ZodEnum<{
39
40
  none: "none";
40
41
  reachable: "reachable";
41
42
  }>>;
43
+ clean: z.ZodDefault<z.ZodEnum<{
44
+ all: "all";
45
+ untracked: "untracked";
46
+ }>>;
47
+ limits: z.ZodOptional<z.ZodObject<{
48
+ cpus: z.ZodOptional<z.ZodNumber>;
49
+ cpuShares: z.ZodOptional<z.ZodNumber>;
50
+ memory: z.ZodOptional<z.ZodString>;
51
+ }, z.core.$strict>>;
42
52
  imageId: z.ZodString;
43
53
  base: z.ZodObject<{
44
54
  ref: z.ZodString;
@@ -73,6 +83,7 @@ export declare function writerPaths(home: string, name: string): {
73
83
  events: string;
74
84
  go: string;
75
85
  uv: string;
86
+ npm: string;
76
87
  wrapper: string;
77
88
  state: string;
78
89
  acceptanceFailure: string;
@@ -5,6 +5,7 @@ import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
5
5
  import { dirname, join } from "node:path";
6
6
  import { z } from "zod";
7
7
  import { PACKAGE_VERSION } from "./package-metadata.js";
8
+ import { writerLimitsSchema } from "./writer-limits.js";
8
9
  import { ALIAS_PATTERN } from "./execution-profile.js";
9
10
  export const WRITER_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}-w([1-9]|1[0-6])$/;
10
11
  export class WriterStateError extends Error {
@@ -31,8 +32,10 @@ export const writerStateSchema = z.object({
31
32
  model: z.string().min(1).max(200),
32
33
  }).strict()).refine((providers) => new Set(providers.map(({ provider }) => provider)).size === providers.length).readonly(),
33
34
  network: z.enum(["pasta", "none"]),
34
- cache: z.enum(["none", "go", "uv"]),
35
+ cache: z.enum(["none", "go", "uv", "npm"]),
35
36
  tags: z.enum(["none", "reachable"]).default("none"),
37
+ clean: z.enum(["untracked", "all"]).default("untracked"),
38
+ limits: writerLimitsSchema.optional(),
36
39
  imageId: hex64,
37
40
  base: z.object({ ref: z.string(), commit: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/) }).strict(),
38
41
  acceptance: z.object({
@@ -62,7 +65,7 @@ export function writerPaths(home, name) {
62
65
  const root = join(writersRoot(home), name);
63
66
  return {
64
67
  root, clone: join(root, "clone"), agent: join(root, "agent"), bundles: join(root, "bundles"),
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"),
68
+ config: join(root, "config"), events: join(root, "events"), go: join(root, "go"), uv: join(root, "uv"), npm: join(root, "npm"), wrapper: join(root, "wrapper.sh"), state: join(root, "state.json"),
66
69
  acceptanceFailure: join(root, "acceptance-failure.json"),
67
70
  };
68
71
  }
@@ -2457,6 +2457,147 @@ Not established for `0.11.6`:
2457
2457
  wrapper's periodic credential copy (#136);
2458
2458
  - the VS Code wake; macOS delivery.
2459
2459
 
2460
+ ### v0.13.0 pre-release acceptance
2461
+
2462
+ `v0.13.0` was accepted on 2026-10-28 from 19:30 to 19:36 UTC on its release
2463
+ candidate `f7442b3`, on Linux (`x64`) with Node.js `26.12.1`, npm `12.2.1`,
2464
+ Codex CLI `0.162.1` and Claude Code `2.1.312`. Level 2 passed in the working
2465
+ tree and for the packed tarball, each with delivery off and with delivery on.
2466
+ Level 1 passed except `npm audit`. Later commits on the release pull request
2467
+ change only this record.
2468
+
2469
+ The minor release ships ADR 0013 (#169) and the writer settings changes #162,
2470
+ #163, #165 and #166. It changes the MCP contract (read-only confinement,
2471
+ profile field and capabilities field `shell`, start error
2472
+ `sandbox_unavailable`); the setup report schema is unchanged.
2473
+
2474
+ - Level 1 in a clean worktree of the candidate after `npm ci`: every command
2475
+ exited with `0`, except `npm audit`; `git status --short` printed nothing.
2476
+ - The test run, with `NODE_OPTIONS=--unhandled-rejections=strict`, reported
2477
+ 125 files, 3936 passed and 14 skipped, and no unhandled error.
2478
+ - `npm audit` and `npm audit --omit=dev` exited with `1` and name only the
2479
+ known `undici` finding through `@earendil-works/pi-coding-agent` 1.4.0
2480
+ ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
2481
+ - `npm pack` listed 916 files (tarball shasum
2482
+ `1259d57d2d59099ad72e796609ecab3d581e9b06`, identical in two packs). A scan
2483
+ of the packed files for private names, hosts and paths found none; its only
2484
+ matches for `/home/` are the already redacted
2485
+ `<repo>/eval/results/.../home/.pi/...` paths of disposable evaluation homes
2486
+ in six `docs/evaluation/extension-functions-2026-10-17/cursor/` evidence
2487
+ files, which `0.12.1` shipped unchanged.
2488
+ - Doctor reported `packageVersion` `0.13.0`. It exited with `2` because no
2489
+ configuration was set (`config_missing`).
2490
+ - Level 2 in the working tree: 160 of 160 checks passed with delivery off and
2491
+ 228 of 228 with `--events spool`.
2492
+ - Packed-tarball Level 2 from a fresh prefix: 160 of 160 with delivery off and
2493
+ 228 of 228 with `--events spool`. The install smoke from another fresh prefix
2494
+ reported `smoke_passed` with version `0.13.0`.
2495
+ - In every Level 2 run the real client registrations were unchanged, no process
2496
+ remained, the leak check passed and the disposable directories were removed.
2497
+ - Podman-gated writer tests on the candidate: 7 of 7 passed.
2498
+ - Bubblewrap-gated sandbox tests (`test/sandbox-shell.bwrap.test.ts`, bubblewrap
2499
+ `0.13.1`): 12 of 12 passed, none skipped.
2500
+ - Review: #169 had an independent review of its full diff (same model family as
2501
+ the author) that found no confinement or sandbox escape; its follow-ups were
2502
+ fixed before the merge of #170.
2503
+
2504
+ Not established for `0.13.0`: a live session with `shell: "sandboxed"` against
2505
+ a real provider, client-target `--verify` through the installed package, which
2506
+ follows the upgrade; Level 3.
2507
+
2508
+ ### v0.12.1 post-release evidence
2509
+
2510
+ Recorded on 2026-10-28 for the published release `0.12.1`.
2511
+
2512
+ Tag binding: a fresh clone of the annotated tag `v0.12.1` (tag object
2513
+ `eecd6dbf0c220ded997c697f281f6b984d9ebf4e`) peels to the merge commit
2514
+ `91799f2e84b514110f77c24986d59e72c5ed98e5` of #161, and
2515
+ `git describe --tags --exact-match` reports `v0.12.1`. CI on the merge commit
2516
+ and on the tag passed on the first attempt.
2517
+
2518
+ - Level 1 on the tag clone after `npm ci`, from 14:43 to 14:47 UTC with
2519
+ Node.js `26.12.1` and npm `12.2.1`: typecheck, `build:cli`, the test run
2520
+ (120 files, 3708 passed, 14 skipped, with
2521
+ `NODE_OPTIONS=--unhandled-rejections=strict`), `build` and
2522
+ `git diff --check` exited with `0`, and the status stayed clean.
2523
+ - `pi-session-mcp@0.12.1` on npm: 907 files, shasum
2524
+ `58dc7235e0d54d4290d92934d877223e22eec03d`, `gitHead` `cc25693` (the head of
2525
+ #161, whose tree equals the merge commit). `npm pack` of the tag clone gives
2526
+ the same shasum and file count.
2527
+ - Published npm package, Level 2 from a fresh prefix: 160 of 160 with delivery
2528
+ off and 228 of 228 with `--events spool`; real registrations and built files
2529
+ unchanged, no process left, leak check passed. Clients: Codex CLI `0.162.1`
2530
+ and Claude Code `2.1.312`. The install smoke on the published package
2531
+ reported `smoke_passed` with version `0.12.1`.
2532
+ - The GitHub release `v0.12.1` was published the same day.
2533
+
2534
+ Workstation, upgraded from `0.12.0` to `0.12.1` by the release session: Doctor
2535
+ reported no error, client-target `--verify` passed for `claude-code:user` and
2536
+ `codex:user` (`mcp_verified`, `skill_verified`, and `hook_verified` for the
2537
+ wake hook), the open point of the pre-release record, and
2538
+ `--writer <name> --verify` passed for all 18 writers (`mcp_verified`).
2539
+
2540
+ Operator upgrade of the second machine (Linux) from `0.11.8` to `0.12.1` on
2541
+ 2026-10-28 from 14:50 UTC, following
2542
+ [Installation](installation.md#container-writers), with Node.js `24.22.0`:
2543
+
2544
+ - No pi-session-mcp server process and no writer container ran at the start.
2545
+ - Both client targets were removed with `0.11.8`, then
2546
+ `npm install -g pi-session-mcp@0.12.1`. Doctor reported every writer
2547
+ `writer_not_ready`, each container workspace `workspace_base_unset` and each
2548
+ writer `writer_git_identity_missing`.
2549
+ - Four container repositories with 14 writers were migrated by repeating their
2550
+ provisioning commands with unchanged settings, each first as a dry run
2551
+ (`planned`), then with `--apply`: 2, 4, 4 (operator image, `--cache go`) and
2552
+ 4 (operator image) writers. Every writer reported `writer_accepted`.
2553
+ - `--finish --apply` of all 14 writers reported `writer_reset`; no work was
2554
+ collected. Doctor then reported no writer or base finding.
2555
+ - `--apply` and `--verify` of both client targets passed (`mcp_verified`), and
2556
+ `--writer <name> --verify` passed for all 14 writers. No writer registration
2557
+ and no container remained.
2558
+
2559
+ Live checks of #154 and #151 on the workstation, 15:02 to 15:04 UTC, with the
2560
+ coding profile `code-deepseek` through the one host registration and wake
2561
+ events from the host spool:
2562
+
2563
+ - Settings change: a writer whose settings differ is refused with
2564
+ `writer_settings_changed`. Both repositories' writers were collected
2565
+ (`nothing_to_collect`), removed with `--writer <name> --remove` and
2566
+ provisioned again with `--tags reachable` (2 writers) and `--cache uv`
2567
+ (4 writers, operator image with uv `0.13.6`); every writer reported
2568
+ `writer_accepted`.
2569
+ - Tags (#154): the writer with `--tags reachable` held 14 tags, the tags of
2570
+ the operator checkout merged into `main`. In the session,
2571
+ `git describe --tags` reported `v0.12.1` and
2572
+ `git show v0.12.0:package.json` read the file at that tag. The writer without
2573
+ the setting held no tags.
2574
+ - Identity (#154): an empty commit in the session, without `GIT_AUTHOR_*` or
2575
+ `GIT_COMMITTER_*` and without `--author`, recorded the operator's name as
2576
+ author and committer. `git config user.name` inside the writer failed with
2577
+ exit `4` because `.git/config` is mounted read-only. The collected ref showed
2578
+ the same identity. The test ref was removed afterwards.
2579
+ - uv cache (#151): three sessions on the same writer, with `--finish --apply`
2580
+ between the second and the third. In the first, `UV_CACHE_DIR` was
2581
+ `/pi/uv/cache` and empty; `uv sync --frozen` took 1.9 s and left 95,576 KiB
2582
+ in 1,648 files. The second and third sessions found the same size and file
2583
+ count before they ran anything, and `uv sync --frozen --offline` after
2584
+ removing `.venv` took 0.07 s and 0.08 s.
2585
+
2586
+ Findings from these checks:
2587
+
2588
+ - Reset and finish keep Git-ignored files in the clone, as designed: `.venv`
2589
+ from the first session was still present in the third, after the finish.
2590
+ The opt-in `--clean all` removes them
2591
+ ([#162](https://github.com/plagor44-ux/pi-session-mcp/issues/162)); it is
2592
+ not part of the released `0.12.1`.
2593
+ - A provisioning command with changed settings reported `mode_applied` and
2594
+ recorded the new mode although every writer failed with
2595
+ `writer_settings_changed`; Doctor reported no mismatch
2596
+ ([#163](https://github.com/plagor44-ux/pi-session-mcp/issues/163), open).
2597
+
2598
+ Not established for `0.12.1`: a busy pool (`writer_unavailable`) and a `base`
2599
+ fix round in live use; Level 3; the macOS upgrade.
2600
+
2460
2601
  ### v0.12.1 pre-release acceptance
2461
2602
 
2462
2603
  `v0.12.1` was accepted on 2026-10-28 from 13:12 to 13:17 UTC on its release