pi-session-mcp 0.12.0 → 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 +8 -3
  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 +13 -3
  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 +96 -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 +8 -3
  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 +15 -1
  24. package/dist/execution-modes.js +13 -3
  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 +37 -6
  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 +266 -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 +34 -1
  56. package/docs/isolation-setup.md +31 -2
  57. package/docs/roadmap.md +8 -1
  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;
@@ -6,6 +6,7 @@ import { loadConfig } from "./config.js";
6
6
  import { containerRuntimeUsable } from "./container-runtime.js";
7
7
  import { ALIAS_PATTERN } from "./execution-profile.js";
8
8
  import { runDoctor } from "./doctor.js";
9
+ import { MAX_WRITERS } from "./execution-modes.js";
9
10
  import { changeExecutionMode } from "./setup-modes.js";
10
11
  import { acquireOverrideLock, OverrideLockError } from "./override-lock.js";
11
12
  import { createCommandMcpStdioLauncher, createProcessRunner } from "./setup-process.js";
@@ -14,7 +15,8 @@ import { localHookNeededByOthers, ownsWakeHook, runWakeHook } from "./setup-hook
14
15
  import { result } from "./setup-result.js";
15
16
  import { createWriterRegistrationAdapter, unregisterWriter } from "./client-adapters/writer-registration.js";
16
17
  import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
17
- 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";
18
20
  import { acquireWriterLease, WriterLeaseError } from "./writer-lease.js";
19
21
  import { ensureWriterAbsent } from "./writer-fence.js";
20
22
  import * as lifecycle from "./writer-lifecycle.js";
@@ -158,7 +160,7 @@ async function remove(ctx, name, discard, deps, extras = []) {
158
160
  const writerIndex = (name) => Number(name.slice(name.lastIndexOf("-w") + 2));
159
161
  async function plan(ctx, entry) {
160
162
  const keep = [], missing = [], surplus = [];
161
- for (let index = 1; index <= 16; index++) {
163
+ for (let index = 1; index <= MAX_WRITERS; index++) {
162
164
  const name = writerName(ctx.alias, index), state = await readWriterState(ctx.home, name);
163
165
  if (entry.mode === "container" && index <= entry.container.writers) {
164
166
  keep.push(name);
@@ -208,6 +210,15 @@ async function reconcileUnlocked(configPath, alias, request, deps) {
208
210
  add(name, drift);
209
211
  return;
210
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
+ }
211
222
  }
212
223
  // Decide for both clients before removing anything, so a divergent registration of one client never
213
224
  // costs the writer its owned registration of the other. A client that is not installed has nothing to keep.
@@ -310,7 +321,7 @@ export async function changeWriterMode(configPath, request, deps) {
310
321
  return failed("alias_unknown");
311
322
  let runtimeProbe;
312
323
  const containerRuntime = () => runtimeProbe ??= (deps.containerRuntime ?? containerRuntimeUsable)().catch(() => false);
313
- const modeDependencies = { containerRuntime };
324
+ const modeDependencies = { containerRuntime, cgroupControllers: () => podmanCgroupControllers(services(deps).runner) };
314
325
  try {
315
326
  let writerWork = request.mode === "container";
316
327
  if (!writerWork) {
@@ -322,7 +333,7 @@ export async function changeWriterMode(configPath, request, deps) {
322
333
  return changeExecutionMode(configPath, request, modeDependencies);
323
334
  }
324
335
  writerWork = config.workspaceModes.get(request.alias)?.mode === "container";
325
- for (let index = 1; index <= 16 && !writerWork; index++) {
336
+ for (let index = 1; index <= MAX_WRITERS && !writerWork; index++) {
326
337
  try {
327
338
  await lstat(join(writersRoot(deps.home ?? homedir()), writerName(request.alias, index)));
328
339
  writerWork = true;
@@ -359,7 +370,7 @@ export async function changeWriterMode(configPath, request, deps) {
359
370
  const config = await loadConfig(configPath);
360
371
  if (!config.workspaceModes.get(request.alias)?.container?.base) {
361
372
  const bases = new Set();
362
- for (let index = 1; index <= 16; index++) {
373
+ for (let index = 1; index <= MAX_WRITERS; index++) {
363
374
  const state = await readWriterState(deps.home ?? homedir(), writerName(request.alias, index));
364
375
  if (state)
365
376
  bases.add(state.base.ref);
@@ -398,6 +409,24 @@ export async function changeWriterMode(configPath, request, deps) {
398
409
  throw error;
399
410
  }
400
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
+ }
401
430
  }
402
431
  else
403
432
  await preflight(ctx, planned.surplus, request.discard ?? false, deps);
@@ -419,7 +448,9 @@ export async function changeWriterMode(configPath, request, deps) {
419
448
  for (const name of planned.keep) {
420
449
  // The same settings check as apply, so a drifted writer shows before anything is applied (#134).
421
450
  const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: proposed.container, rawConfig, overrideDirectory });
422
- 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" }) });
423
454
  }
424
455
  const refused = kept.some((finding) => finding.status === "failed" || finding.status === "divergent");
425
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
  }