@themoltnet/pi-extension 0.16.0 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -126,10 +126,21 @@ the base snapshot is used (Alpine + git + gh + MoltNet CLI + agent user).
126
126
  "GOPATH": "/home/agent/go",
127
127
  "GOROOT": "/usr/lib/go"
128
128
  },
129
+ "hostExec": {
130
+ "autoApprove": [
131
+ {
132
+ "argsExcludes": ["--mirror", "--all"],
133
+ "argsPrefix": ["push"],
134
+ "executable": "git"
135
+ },
136
+ { "argsPrefix": ["pr", "create"], "executable": "gh" }
137
+ ]
138
+ },
129
139
  "resources": {
130
140
  "cpus": 2,
131
141
  "memory": "6G"
132
142
  },
143
+ "resumeCommands": ["corepack enable"],
133
144
  "snapshot": {
134
145
  "allowedHosts": ["unofficial-builds.nodejs.org"],
135
146
  "overlaySize": "8G",
@@ -165,6 +176,25 @@ VM resource limits applied at runtime.
165
176
  | `cpus` | Number of virtual CPUs |
166
177
  | `memory` | RAM limit (e.g. `"6G"`) |
167
178
 
179
+ ### `resumeCommands`
180
+
181
+ Shell commands that run on every VM resume, after platform setup and before the
182
+ agent session starts.
183
+
184
+ Use this for per-session bootstrap that should not invalidate the snapshot
185
+ cache: mounting tmpfs, warming package-manager state, lightweight repo-local
186
+ setup.
187
+
188
+ Important properties:
189
+
190
+ - runs after TLS, DNS, and `git safe.directory` setup
191
+ - not part of the snapshot cache key
192
+ - each command runs in a fresh shell with `set -eu` and `set -o pipefail`
193
+ - first non-zero exit aborts VM resume
194
+
195
+ This split exists so repo-specific bootstrap can live in `sandbox.json` while
196
+ `pi-extension` stays consumer-agnostic.
197
+
168
198
  ### `vfs`
169
199
 
170
200
  VFS shadow configuration — hide host paths from the guest mount.
@@ -177,12 +207,76 @@ VFS shadow configuration — hide host paths from the guest mount.
177
207
  Use `shadow: ["node_modules"]` to hide host binaries (wrong platform) and let
178
208
  the guest install its own with `pnpm install`.
179
209
 
210
+ #### VFS caveat: shadowing is not a pnpm performance fix
211
+
212
+ For this repo we hit two distinct `/workspace` problems:
213
+
214
+ - the FUSE bridge makes file-write-heavy installs much slower than guest-local filesystems
215
+ - the `/workspace` VFS path drops `chmod()` calls, which breaks tools that create files and chmod them later
216
+
217
+ Dogfood trail:
218
+
219
+ - `47b67636-067a-4254-9098-38d00b4867bb` — `/workspace` install path measured at roughly 80x slower than guest tmpfs
220
+ - `62082ec9-0554-4bdc-9c64-9d89ece3fa40` — `chmod()` gap on the workspace mount
221
+ - `17f0ac6f-07f0-4e12-b5e5-d35a0fa2df6c` — first 100x pnpm recipe
222
+ - `2e4e25a9-ef4b-46bf-a55d-6c2b1159ee61` — follow-up fix for per-workspace `node_modules`
223
+
224
+ `vfs.shadow: ["node_modules"]` is still useful to hide host-built artifacts,
225
+ but it does not solve the hot-path problem by itself. For fast pnpm setup, move
226
+ both endpoints off the FUSE bridge:
227
+
228
+ - package store on guest-local disk, e.g. `NPM_CONFIG_STORE_DIR=/opt/pnpm-store`
229
+ - install target on guest tmpfs via `resumeCommands`
230
+
231
+ Current themoltnet `sandbox.json` does this by mounting tmpfs over the root and
232
+ per-workspace `node_modules` directories before running `pnpm install
233
+ --frozen-lockfile`.
234
+
180
235
  ### `env`
181
236
 
182
237
  Environment variable overrides applied to the guest VM. Use this to fix host
183
238
  env pollution (e.g. `GOROOT` from mise/asdf pointing at a macOS path leaking
184
239
  into the Linux guest).
185
240
 
241
+ ### `hostExec`
242
+
243
+ Host-side escape hatch policy for `moltnet_host_exec`. The executable must
244
+ still be in the built-in host-exec allowlist (`git`, `gh`, `moltnet`); this
245
+ setting only controls whether the per-call UI approval dialog is skipped.
246
+
247
+ `autoApprove: true` skips the dialog for every allowed host command. Use that
248
+ only on isolated hosts or disposable machines.
249
+
250
+ For local daemon runs, prefer rule-based approval:
251
+
252
+ ```json
253
+ {
254
+ "hostExec": {
255
+ "autoApprove": [
256
+ {
257
+ "argsExcludes": ["--mirror", "--all"],
258
+ "argsPrefix": ["push"],
259
+ "executable": "git"
260
+ },
261
+ { "argsPrefix": ["pr", "create"], "executable": "gh" },
262
+ { "argsPrefix": ["pr", "view"], "executable": "gh" }
263
+ ]
264
+ }
265
+ }
266
+ ```
267
+
268
+ Each rule matches an exact executable plus optional argument constraints:
269
+
270
+ | Field | Description |
271
+ | -------------- | ----------------------------------------------------------- |
272
+ | `executable` | Exact executable name |
273
+ | `argsPrefix` | Ordered argument prefix; later flags/args are still allowed |
274
+ | `argsContains` | Tokens that must appear anywhere in the args |
275
+ | `argsExcludes` | Tokens that block auto-approval when present |
276
+
277
+ If a rule only sets `executable`, all argument lists for that executable are
278
+ auto-approved after the built-in executable allowlist check.
279
+
186
280
  ## Base snapshot
187
281
 
188
282
  Every snapshot includes:
package/dist/index.d.ts CHANGED
@@ -271,6 +271,12 @@ export declare interface ExecutePiTaskOptions {
271
271
  * Default `3`. Set to `0` to disable. Closes part of #1094.
272
272
  */
273
273
  maxBashTimeouts?: number;
274
+ /**
275
+ * Skip per-call UI approval for matching `moltnet_host_exec` commands.
276
+ * Keep false/undefined for interactive consumers. `true` skips every dialog
277
+ * after HOST_EXEC_ALLOWED; an array limits auto-approval to matching rules.
278
+ */
279
+ hostExecAutoApprove?: HostExecAutoApproveConfig;
274
280
  }
275
281
 
276
282
  /**
@@ -285,6 +291,19 @@ export declare function findMainWorktree(): string;
285
291
  */
286
292
  export declare const HOST_EXEC_DEFAULT_BASE_ENV: ReadonlySet<string>;
287
293
 
294
+ declare type HostExecAutoApproveConfig = boolean | readonly HostExecAutoApproveRule[];
295
+
296
+ declare interface HostExecAutoApproveRule {
297
+ /** Exact executable name. Must still pass HOST_EXEC_ALLOWED. */
298
+ executable: string;
299
+ /** Optional ordered argument prefix; flags after the prefix are allowed. */
300
+ argsPrefix?: readonly string[];
301
+ /** Optional unordered argument tokens that must appear somewhere. */
302
+ argsContains?: readonly string[];
303
+ /** Optional argument tokens that prevent auto-approval when present. */
304
+ argsExcludes?: readonly string[];
305
+ }
306
+
288
307
  export declare interface InjectedTaskContext {
289
308
  /** Refs that were delivered, in declared order, for audit. */
290
309
  injected: ContextRef[];
@@ -363,6 +382,19 @@ declare interface MoltNetToolsConfig {
363
382
  * Defaults to HOST_EXEC_DEFAULT_BASE_ENV when omitted.
364
383
  */
365
384
  hostExecBaseEnv?: ReadonlySet<string>;
385
+ /**
386
+ * When true, `moltnet_host_exec` skips the per-call UI approval dialog.
387
+ * Intended for non-interactive daemon automation only; interactive
388
+ * consumers should keep the default false behavior.
389
+ */
390
+ autoApproveHostExec?: boolean;
391
+ /**
392
+ * Host-exec auto-approval policy. `true` skips all dialogs after the
393
+ * executable allowlist check. An array skips only commands matching one of
394
+ * the supplied executable/argument rules. Omitted/false preserves the
395
+ * interactive approval flow.
396
+ */
397
+ hostExecAutoApprove?: HostExecAutoApproveConfig;
366
398
  /**
367
399
  * Active-task context, populated by the agent-daemon path. When set,
368
400
  * `moltnet_create_entry` enforces `diaryId === taskContext.diaryId` and
@@ -419,6 +451,19 @@ export declare interface SandboxConfig {
419
451
  };
420
452
  /** Environment variable overrides for the guest VM (applied on top of defaults). */
421
453
  env?: Record<string, string>;
454
+ /** Host-side escape hatch policy. Applies only to `moltnet_host_exec`. */
455
+ hostExec?: {
456
+ /**
457
+ * `true` auto-approves every allowed executable. An array auto-approves
458
+ * only commands matching one of the executable/argument rules.
459
+ */
460
+ autoApprove?: boolean | {
461
+ executable: string;
462
+ argsPrefix?: string[];
463
+ argsContains?: string[];
464
+ argsExcludes?: string[];
465
+ }[];
466
+ };
422
467
  /** VM resource allocation. */
423
468
  resources?: {
424
469
  /** Memory size in qemu syntax (default '1G'). */
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
4
- import path, { join } from "node:path";
4
+ import path, { join, relative } from "node:path";
5
5
  import { DefaultResourceLoader, SessionManager, createAgentSession, createBashTool, createBashToolDefinition, createEditTool, createEditToolDefinition, createReadTool, createReadToolDefinition, createSyntheticSourceInfo, createWriteTool, createWriteToolDefinition, defineTool, parseFrontmatter } from "@earendil-works/pi-coding-agent";
6
6
  import { createHash } from "node:crypto";
7
7
  import { readFile } from "node:fs/promises";
@@ -7274,6 +7274,7 @@ function renderPhase6Markdown(pack) {
7274
7274
  * These tools run on the host (not in the VM) via the MoltNet SDK,
7275
7275
  * so agent credentials never touch the VM filesystem.
7276
7276
  */
7277
+ var DIARY_TAG_MAX_LENGTH = 128;
7277
7278
  /**
7278
7279
  * Baseline env keys forwarded to host-exec child processes.
7279
7280
  * Callers can extend this set at sandbox startup via `MoltNetToolsConfig.hostExecBaseEnv`.
@@ -7302,6 +7303,19 @@ function ensureConnected(config) {
7302
7303
  teamId: config.getTeamId() ?? ""
7303
7304
  };
7304
7305
  }
7306
+ function hostExecMatchesAutoApproveRule(params, rule) {
7307
+ if (params.executable !== rule.executable) return false;
7308
+ if (rule.argsExcludes?.some((arg) => params.args.includes(arg))) return false;
7309
+ if (rule.argsPrefix && !rule.argsPrefix.every((arg, index) => params.args[index] === arg)) return false;
7310
+ if (rule.argsContains && !rule.argsContains.every((arg) => params.args.includes(arg))) return false;
7311
+ return true;
7312
+ }
7313
+ function shouldAutoApproveHostExec(params, config) {
7314
+ const policy = config.autoApproveHostExec === true ? true : config.hostExecAutoApprove ?? false;
7315
+ if (policy === true) return true;
7316
+ if (!Array.isArray(policy)) return false;
7317
+ return policy.some((rule) => hostExecMatchesAutoApproveRule(params, rule));
7318
+ }
7305
7319
  /**
7306
7320
  * Expand the `taskFilter` shorthand on the diary list/search tools into
7307
7321
  * the matching `task:*` provenance tags emitted by `moltnet_create_entry`
@@ -7520,14 +7534,14 @@ function createMoltNetTools(config) {
7520
7534
  limit: Type.Optional(Type.Number({ description: "Max entries to return (default 10)" })),
7521
7535
  tags: Type.Optional(Type.Array(Type.String({
7522
7536
  minLength: 1,
7523
- maxLength: 50
7537
+ maxLength: DIARY_TAG_MAX_LENGTH
7524
7538
  }), {
7525
7539
  description: "Tags filter — entry must have ALL listed tags (AND). Max 20.",
7526
7540
  maxItems: 20
7527
7541
  })),
7528
7542
  excludeTags: Type.Optional(Type.Array(Type.String({
7529
7543
  minLength: 1,
7530
- maxLength: 50
7544
+ maxLength: DIARY_TAG_MAX_LENGTH
7531
7545
  }), {
7532
7546
  description: "Tags to exclude — entry must have NONE of these. Max 20.",
7533
7547
  maxItems: 20
@@ -7620,14 +7634,14 @@ function createMoltNetTools(config) {
7620
7634
  limit: Type.Optional(Type.Number({ description: "Max results (default 5)" })),
7621
7635
  tags: Type.Optional(Type.Array(Type.String({
7622
7636
  minLength: 1,
7623
- maxLength: 50
7637
+ maxLength: DIARY_TAG_MAX_LENGTH
7624
7638
  }), {
7625
7639
  description: "Entry must have ALL listed tags (AND). Max 20.",
7626
7640
  maxItems: 20
7627
7641
  })),
7628
7642
  excludeTags: Type.Optional(Type.Array(Type.String({
7629
7643
  minLength: 1,
7630
- maxLength: 50
7644
+ maxLength: DIARY_TAG_MAX_LENGTH
7631
7645
  }), {
7632
7646
  description: "Entry must have NONE of these tags. Max 20.",
7633
7647
  maxItems: 20
@@ -7813,7 +7827,7 @@ function createMoltNetTools(config) {
7813
7827
  }),
7814
7828
  async execute(_id, params, _signal, _onUpdate, ctx) {
7815
7829
  if (!HOST_EXEC_ALLOWED.has(params.executable)) throw new Error(`host_exec: '${params.executable}' is not in the allowed list (${[...HOST_EXEC_ALLOWED].join(", ")}). Extend HOST_EXEC_ALLOWED only after explicit security review.`);
7816
- if (ctx?.ui) {
7830
+ if (ctx?.ui && !shouldAutoApproveHostExec(params, config)) {
7817
7831
  const cmdDisplay = [params.executable, ...params.args].join(" ");
7818
7832
  if (!await ctx.ui.confirm("Allow host command?", `The agent wants to run on your machine:\n\n ${cmdDisplay}\n\nAllow?`)) throw new Error(`host_exec: user declined approval for: ${cmdDisplay}`);
7819
7833
  }
@@ -8810,22 +8824,6 @@ function validateRubricWeights(rubric) {
8810
8824
  if (Math.abs(sum - 1) > 1e-6) return `Rubric weights must sum to 1.0 (got ${sum.toFixed(6)})`;
8811
8825
  return null;
8812
8826
  }
8813
- `
8814
- You are reviewing a GitHub pull request for **complexity** — how hard
8815
- this change is to review safely, NOT whether it's correct or whether
8816
- the feature is worthwhile. The diff has already been opened by the
8817
- producer; your job is to score reviewability.
8818
-
8819
- You may run \`gh pr diff <number>\`, \`gh pr view <number>\`, and read
8820
- files in the workspace. Don't run tests, don't push commits, don't
8821
- modify anything. The PR's GitHub URL is in the target metadata.
8822
-
8823
- When in doubt about a criterion, score conservatively (lower) and
8824
- explain what made the call ambiguous. Reviewers will read your
8825
- rationale; "looks fine" is not useful, "the change touches three
8826
- unrelated subsystems and the test coverage on the auth path is
8827
- unchanged" is.
8828
- `.trim();
8829
8827
  //#endregion
8830
8828
  //#region ../tasks/src/success-criteria.ts
8831
8829
  /**
@@ -9704,6 +9702,7 @@ var BUILT_IN_TASK_TYPES = {
9704
9702
  inputSchema: FulfillBriefInput,
9705
9703
  outputSchema: FulfillBriefOutput,
9706
9704
  outputKind: "artifact",
9705
+ workspaceMode: "dedicated_worktree",
9707
9706
  requiresReferences: false,
9708
9707
  validateOutput: requireVerificationWhenCriteriaPresent
9709
9708
  },
@@ -9712,6 +9711,7 @@ var BUILT_IN_TASK_TYPES = {
9712
9711
  inputSchema: AssessBriefInput,
9713
9712
  outputSchema: AssessBriefOutput,
9714
9713
  outputKind: "judgment",
9714
+ workspaceMode: "dedicated_worktree",
9715
9715
  requiresReferences: true,
9716
9716
  validateInput: validateJudgmentInput,
9717
9717
  validateInputAsync: validateAssessBriefInputAsync
@@ -9825,6 +9825,15 @@ function getTaskOutputSchema(taskType) {
9825
9825
  function taskTypeUsesSubagents(taskType) {
9826
9826
  return getTaskTypeEntry(taskType)?.usesSubagents === true;
9827
9827
  }
9828
+ /**
9829
+ * Filesystem isolation policy requested by the task type.
9830
+ *
9831
+ * Unknown task types and task types without an explicit policy default to the
9832
+ * legacy/shared behaviour.
9833
+ */
9834
+ function taskTypeWorkspaceMode(taskType) {
9835
+ return getTaskTypeEntry(taskType)?.workspaceMode ?? "shared_mount";
9836
+ }
9828
9837
  //#endregion
9829
9838
  //#region ../tasks/src/wire.ts
9830
9839
  /**
@@ -10312,6 +10321,15 @@ function buildAssessBriefUserPrompt(input, ctx) {
10312
10321
  rubric.preamble,
10313
10322
  ""
10314
10323
  ].join("\n") : "";
10324
+ const workspaceSection = ctx.workspace?.mode === "dedicated_worktree" ? [
10325
+ "### Workspace",
10326
+ "",
10327
+ "This review attempt is running inside a dedicated disposable git",
10328
+ "worktree created for this task. If you need to check out the target",
10329
+ "branch or inspect refs locally, do it only inside this worktree.",
10330
+ ctx.workspace.branch ? `The current review branch is \`${ctx.workspace.branch}\`. You may replace it with the target branch locally if that helps your inspection.` : "The current checkout is disposable and will be cleaned up when the task ends.",
10331
+ ""
10332
+ ].join("\n") : "";
10315
10333
  return [
10316
10334
  "# Assess Brief Judge",
10317
10335
  "",
@@ -10352,6 +10370,7 @@ function buildAssessBriefUserPrompt(input, ctx) {
10352
10370
  " read it from the task you fetched in step 1 and pass",
10353
10371
  " `taskFilter: { correlationId: \"<id>\" }`.",
10354
10372
  "",
10373
+ workspaceSection,
10355
10374
  preambleSection,
10356
10375
  "## Criteria",
10357
10376
  "",
@@ -10611,6 +10630,14 @@ function buildFulfillBriefUserPrompt(input, ctx) {
10611
10630
  "from this branch naming scheme when correlationId is set.",
10612
10631
  ""
10613
10632
  ].join("\n") : "";
10633
+ const workspaceSection = ctx.workspace?.mode === "dedicated_worktree" ? [
10634
+ "### Workspace",
10635
+ "",
10636
+ "This attempt is running inside a dedicated git worktree created",
10637
+ "for this task. Do not repurpose or switch the primary checkout.",
10638
+ ctx.workspace.branch ? `The current branch is \`${ctx.workspace.branch}\`. Stay on this branch unless the runtime instructor explicitly tells you otherwise.` : "Stay on the branch that was pre-provisioned for this task.",
10639
+ ""
10640
+ ].join("\n") : "";
10614
10641
  return [
10615
10642
  "# Fulfill Brief Agent",
10616
10643
  "",
@@ -10631,9 +10658,10 @@ function buildFulfillBriefUserPrompt(input, ctx) {
10631
10658
  criteriaSection,
10632
10659
  seedSection,
10633
10660
  correlationSection,
10661
+ workspaceSection,
10634
10662
  "### Workflow",
10635
10663
  "",
10636
- `1. Create a feature branch (starting prefix suggestion: \`${branchSlug}<short-slug>\`).`,
10664
+ ctx.workspace?.mode === "dedicated_worktree" ? `1. Use the already-provisioned dedicated worktree branch${ctx.workspace.branch ? ` (\`${ctx.workspace.branch}\`)` : ""}; do not create or switch the primary checkout.` : `1. Create a feature branch (starting prefix suggestion: \`${branchSlug}<short-slug>\`).`,
10637
10665
  "2. Understand the problem — read relevant code; do not speculate.",
10638
10666
  "3. Implement the change. Keep commits small and coherent.",
10639
10667
  "4. Add tests if applicable.",
@@ -11031,7 +11059,8 @@ function buildTaskUserPrompt(task, ctx) {
11031
11059
  return buildFulfillBriefUserPrompt(task.input, {
11032
11060
  diaryId: ctx.diaryId,
11033
11061
  taskId: ctx.taskId,
11034
- correlationId: task.correlationId
11062
+ correlationId: task.correlationId,
11063
+ workspace: ctx.workspace
11035
11064
  });
11036
11065
  case ASSESS_BRIEF_TYPE:
11037
11066
  if (!Value.Check(AssessBriefInput, task.input)) {
@@ -11040,7 +11069,8 @@ function buildTaskUserPrompt(task, ctx) {
11040
11069
  }
11041
11070
  return buildAssessBriefUserPrompt(task.input, {
11042
11071
  diaryId: ctx.diaryId,
11043
- taskId: ctx.taskId
11072
+ taskId: ctx.taskId,
11073
+ workspace: ctx.workspace
11044
11074
  });
11045
11075
  case CURATE_PACK_TYPE:
11046
11076
  if (!Value.Check(CuratePackInput, task.input)) {
@@ -15216,7 +15246,8 @@ async function executePiTask(claimedTask, reporter, opts) {
15216
15246
  const task = claimedTask.task;
15217
15247
  const attemptN = claimedTask.attemptN;
15218
15248
  const startTime = Date.now();
15219
- const mountPath = opts.mountPath ?? process.cwd();
15249
+ const workspace = prepareTaskWorkspace(task, opts.mountPath ?? process.cwd());
15250
+ const mountPath = workspace.mountPath;
15220
15251
  if (reporter.cancelSignal.aborted) return {
15221
15252
  taskId: task.id,
15222
15253
  attemptN,
@@ -15247,7 +15278,8 @@ async function executePiTask(claimedTask, reporter, opts) {
15247
15278
  "--relative-paths"
15248
15279
  ], { stdio: "pipe" });
15249
15280
  } catch {}
15250
- const managed = await resumeVm({
15281
+ let managed = null;
15282
+ managed = await resumeVm({
15251
15283
  checkpointPath,
15252
15284
  agentName: opts.agentName,
15253
15285
  mountPath,
@@ -15307,13 +15339,19 @@ async function executePiTask(claimedTask, reporter, opts) {
15307
15339
  taskType: task.taskType,
15308
15340
  teamId: task.teamId,
15309
15341
  provider: opts.provider,
15310
- model: opts.model
15342
+ model: opts.model,
15343
+ workspaceMode: workspace.mode,
15344
+ workspaceBranch: workspace.branch
15311
15345
  });
15312
15346
  let taskPrompt;
15313
15347
  try {
15314
15348
  taskPrompt = buildTaskUserPrompt(task, {
15315
15349
  diaryId,
15316
15350
  taskId: task.id,
15351
+ workspace: {
15352
+ mode: workspace.mode,
15353
+ branch: workspace.branch
15354
+ },
15317
15355
  extras: opts.promptExtras
15318
15356
  });
15319
15357
  } catch (err) {
@@ -15366,6 +15404,7 @@ async function executePiTask(claimedTask, reporter, opts) {
15366
15404
  clearSessionErrors: () => {},
15367
15405
  getHostCwd: () => mountPath,
15368
15406
  hostExecBaseEnv: new Set([...HOST_EXEC_DEFAULT_BASE_ENV, ...Object.keys(managed.credentials.agentEnv)]),
15407
+ hostExecAutoApprove: opts.hostExecAutoApprove ?? opts.sandboxConfig?.hostExec?.autoApprove ?? false,
15369
15408
  getTaskContext: () => ({
15370
15409
  taskId: task.id,
15371
15410
  taskType: task.taskType,
@@ -15642,7 +15681,114 @@ async function executePiTask(claimedTask, reporter, opts) {
15642
15681
  console.error(`executePiTask: reporter.close() failed for task ${task.id} attempt ${attemptN}: ${detail}`);
15643
15682
  }
15644
15683
  }
15645
- await managed.vm.close();
15684
+ if (managed) await managed.vm.close();
15685
+ try {
15686
+ workspace.cleanup();
15687
+ } catch (err) {
15688
+ const detail = err instanceof Error ? err.message : String(err);
15689
+ console.error(`executePiTask: workspace cleanup failed for task ${task.id} attempt ${attemptN}: ${detail}`);
15690
+ }
15691
+ }
15692
+ }
15693
+ function resolveTaskWorktreeBranch(task) {
15694
+ if (taskTypeWorkspaceMode(task.taskType) !== "dedicated_worktree") return null;
15695
+ if (task.taskType === "fulfill_brief") {
15696
+ const input = task.input;
15697
+ const slug = slugifyBranchComponent(typeof input.title === "string" && input.title.trim().length > 0 ? input.title : typeof input.brief === "string" && input.brief.trim().length > 0 ? input.brief : task.taskType) || "task";
15698
+ if (task.correlationId) return `moltnet/${task.correlationId}/${slug}`;
15699
+ return `feat/${(typeof input.scopeHint === "string" && input.scopeHint.trim().length > 0 ? slugifyBranchComponent(input.scopeHint) : "task") || "task"}-${slug}`;
15700
+ }
15701
+ return `task/${slugifyBranchComponent(task.taskType) || "task"}-${task.id.slice(0, 8)}`;
15702
+ }
15703
+ function slugifyBranchComponent(input) {
15704
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "");
15705
+ }
15706
+ function prepareTaskWorkspace(task, requestedMountPath) {
15707
+ const branch = resolveTaskWorktreeBranch(task);
15708
+ if (!branch) return {
15709
+ mountPath: requestedMountPath,
15710
+ mode: "shared_mount",
15711
+ branch: null,
15712
+ cleanup: () => {}
15713
+ };
15714
+ const mainRepo = findMainWorktree();
15715
+ const worktreeDir = join(mainRepo, ".worktrees", `task-${task.id}`);
15716
+ removeExistingTaskWorktree(mainRepo, worktreeDir);
15717
+ const relMount = relative(mainRepo, requestedMountPath);
15718
+ const mountPath = relMount === "" || relMount.startsWith("..") ? worktreeDir : join(worktreeDir, relMount);
15719
+ const baseRef = resolveWorktreeBaseRef(mainRepo);
15720
+ execFileSync("git", gitRefExists(mainRepo, `refs/heads/${branch}`) ? [
15721
+ "-C",
15722
+ mainRepo,
15723
+ "worktree",
15724
+ "add",
15725
+ worktreeDir,
15726
+ branch
15727
+ ] : [
15728
+ "-C",
15729
+ mainRepo,
15730
+ "worktree",
15731
+ "add",
15732
+ "-b",
15733
+ branch,
15734
+ worktreeDir,
15735
+ baseRef
15736
+ ], { stdio: "pipe" });
15737
+ return {
15738
+ mountPath,
15739
+ mode: "dedicated_worktree",
15740
+ branch,
15741
+ cleanup: () => {
15742
+ execFileSync("git", [
15743
+ "-C",
15744
+ mainRepo,
15745
+ "worktree",
15746
+ "remove",
15747
+ "--force",
15748
+ worktreeDir
15749
+ ], { stdio: "pipe" });
15750
+ }
15751
+ };
15752
+ }
15753
+ function removeExistingTaskWorktree(mainRepo, worktreeDir) {
15754
+ if (!existsSync(worktreeDir)) return;
15755
+ const list = execFileSync("git", [
15756
+ "-C",
15757
+ mainRepo,
15758
+ "worktree",
15759
+ "list",
15760
+ "--porcelain"
15761
+ ], {
15762
+ encoding: "utf8",
15763
+ stdio: "pipe"
15764
+ });
15765
+ const marker = `worktree ${worktreeDir}\n`;
15766
+ if (!list.includes(marker) && !list.endsWith(`worktree ${worktreeDir}`)) return;
15767
+ execFileSync("git", [
15768
+ "-C",
15769
+ mainRepo,
15770
+ "worktree",
15771
+ "remove",
15772
+ "--force",
15773
+ worktreeDir
15774
+ ], { stdio: "pipe" });
15775
+ }
15776
+ function resolveWorktreeBaseRef(mainRepo) {
15777
+ return gitRefExists(mainRepo, "refs/heads/main") ? "main" : "HEAD";
15778
+ }
15779
+ function gitRefExists(mainRepo, ref) {
15780
+ try {
15781
+ execFileSync("git", [
15782
+ "-C",
15783
+ mainRepo,
15784
+ "show-ref",
15785
+ "--verify",
15786
+ "--quiet",
15787
+ ref
15788
+ ], { stdio: "pipe" });
15789
+ return true;
15790
+ } catch {
15791
+ return false;
15646
15792
  }
15647
15793
  }
15648
15794
  function emptyUsage(provider, model) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/pi-extension",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "type": "module",
5
5
  "description": "MoltNet pi extension — sandboxed tool execution in Gondolin VMs with MoltNet identity and persistent memory",
6
6
  "license": "MIT",
@@ -31,8 +31,8 @@
31
31
  "@earendil-works/gondolin": "^0.9.1",
32
32
  "@opentelemetry/api": "^1.9.0",
33
33
  "@sinclair/typebox": "^0.34.0",
34
- "@themoltnet/agent-runtime": "0.15.0",
35
- "@themoltnet/sdk": "0.102.0"
34
+ "@themoltnet/sdk": "0.102.0",
35
+ "@themoltnet/agent-runtime": "0.15.1"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": ">=0.74.0",