@bacnh85/pi-subagent 0.13.0 → 0.14.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.1 (2026-08-07)
4
+
5
+ ### Improvements
6
+
7
+ - Widen peer dependency range to support Pi 0.84.0 (`>=0.80.0 <0.85.0`).
8
+ No code changes — verified compatible against the 0.84.0 SDK types.
9
+
10
+ ## 0.14.0 (2026-08-05)
11
+
12
+ ### Git worktree isolation (`sandbox: worktree`)
13
+
14
+ Agents can now run in an isolated **git worktree** instead of the parent's
15
+ working tree — the safe way to run parallel implementation agents that edit
16
+ files. Set `sandbox: worktree` in agent frontmatter (see `agent-format.md`).
17
+
18
+ - Child file mutations land in `.pi-worktrees/<id>` under the repo root; the
19
+ main checkout stays untouched, so two parallel `worker` agents can never
20
+ clobber each other's edits.
21
+ - On completion, a unified diff of the child's changes is returned as
22
+ `result.patch` and shown in the thread viewer as a `🌿 worktree` badge.
23
+ - **Merging is explicit** — the parent receives the diff and applies it via
24
+ `apply_patch` / cherry-pick / discard; nothing is auto-merged.
25
+ - The worktree is removed in a `finally` on success, error, or abort.
26
+ - Falls back to in-process execution with a warning when the cwd is not a git
27
+ repo (`ponytail`: isolation optimization, not a hard requirement).
28
+ - Wired through the `subagent` tool, the service path (`pi-subagent:run` for
29
+ pi-review), and `runner.ts`'s `runSubAgent` (new `sandbox` + `exec` options).
30
+
3
31
  ## 0.13.0 (2026-07-31)
4
32
 
5
33
  ### Subagents inherit parent extensions & tools by default
package/README.md CHANGED
@@ -84,6 +84,25 @@ Child agent tools are validated against a fixed allowlist:
84
84
 
85
85
  Unknown or misspelled tool names produce clear diagnostics. Duplicate tool names are deduplicated.
86
86
 
87
+ ### Git worktree isolation (`sandbox: worktree`)
88
+
89
+ Set `sandbox: worktree` in an agent's frontmatter to run it in an isolated git
90
+ worktree (`.pi-worktrees/<id>` under the repo root) instead of the parent's
91
+ working tree. This is the safe way to run **parallel implementation** agents:
92
+ two `worker` agents editing the same files can no longer clobber each other —
93
+ each writes into its own checkout.
94
+
95
+ - All file mutations land in the worktree; the main checkout stays untouched.
96
+ - On completion, a unified diff of the child's changes is returned in the
97
+ result and shown in the thread viewer as a `🌿 worktree` badge.
98
+ - **Merging is explicit**: the parent receives the diff and applies it via
99
+ `apply_patch` / cherry-pick / discard. Nothing is auto-merged.
100
+ - The worktree is removed on completion (success, error, or abort).
101
+ - Requires git; when the cwd is not a git repo, the agent falls back to
102
+ in-process execution with a warning (`ponytail`: isolation optimization,
103
+ not a hard requirement).
104
+
105
+
87
106
  ### Timeouts
88
107
 
89
108
  Every child execution receives a timeout:
package/agent-format.md CHANGED
@@ -24,7 +24,7 @@ models: # Optional ordered fallbacks; comma form also accepted
24
24
  - provider/fast-model
25
25
  - provider/backup-model
26
26
  thinking: low # Optional: off|minimal|low|medium|high|xhigh|max.
27
- sandbox: read-only # Optional: read-only | workspace-write. Auto-derives tool restrictions.
27
+ sandbox: read-only # Optional: read-only | workspace-write | worktree. Auto-derives tool restrictions.
28
28
  color: cyan # Optional: red|blue|green|yellow|purple|orange|pink|cyan.
29
29
  ---
30
30
  ```
@@ -33,6 +33,7 @@ color: cyan # Optional: red|blue|green|yellow|purple|orange|pink|c
33
33
 
34
34
  - `read-only`: Restricts tools to `read`, `grep`, `find`, `ls`. Overrides any `tools` field.
35
35
  - `workspace-write` (default): Uses the agent's `tools` list or defaults to all tools.
36
+ - `worktree`: Runs the agent in an isolated git worktree (`.pi-worktrees/<id>` under the repo root). All file mutations land in the worktree; the main checkout is untouched. On completion, a unified diff of the changes is returned in the result (visible in the thread viewer as a `🌿 worktree` badge) — the parent merges explicitly via `apply_patch`/cherry-pick; nothing is applied automatically. Falls back to in-process execution when the cwd is not a git repo (with a warning). Requires git.
36
37
 
37
38
  ### `color`
38
39
 
@@ -22,7 +22,7 @@ export interface AgentConfig {
22
22
  model?: string;
23
23
  models?: string[];
24
24
  thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
25
- sandbox?: "read-only" | "workspace-write";
25
+ sandbox?: "read-only" | "workspace-write" | "worktree";
26
26
  color?: AgentColor;
27
27
  systemPrompt: string;
28
28
  source: "user" | "project" | "bundled";
@@ -203,7 +203,7 @@ function loadAgentsFromDir(
203
203
  }
204
204
 
205
205
  if (typeof frontmatter.sandbox === "string" && frontmatter.sandbox) {
206
- const validSandboxes = ["read-only", "workspace-write"];
206
+ const validSandboxes = ["read-only", "workspace-write", "worktree"];
207
207
  if (!validSandboxes.includes(frontmatter.sandbox)) {
208
208
  diagnostics.push({
209
209
  filePath,
@@ -231,8 +231,8 @@ function loadAgentsFromDir(
231
231
  thinking: typeof frontmatter.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(frontmatter.thinking)
232
232
  ? frontmatter.thinking as AgentConfig["thinking"]
233
233
  : undefined,
234
- sandbox: typeof frontmatter.sandbox === "string" && ["read-only", "workspace-write"].includes(frontmatter.sandbox)
235
- ? frontmatter.sandbox as "read-only" | "workspace-write"
234
+ sandbox: typeof frontmatter.sandbox === "string" && ["read-only", "workspace-write", "worktree"].includes(frontmatter.sandbox)
235
+ ? frontmatter.sandbox as "read-only" | "workspace-write" | "worktree"
236
236
  : undefined,
237
237
  color: typeof frontmatter.color === "string" && VALID_COLORS.includes(frontmatter.color as any)
238
238
  ? frontmatter.color as AgentColor
@@ -647,6 +647,7 @@ export default function (pi: ExtensionAPI) {
647
647
 
648
648
  const result = await runSubAgent({
649
649
  cwd: safeCwd,
650
+ sandbox: agent.sandbox === "worktree" ? "worktree" : undefined,
650
651
  systemPrompt: params.instructions
651
652
  ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
652
653
  : agent.systemPrompt,
@@ -236,6 +236,15 @@ export function renderSingleResult(
236
236
  container.addChild(new Spacer(1));
237
237
  container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
238
238
  }
239
+ if (result.patch) {
240
+ container.addChild(new Spacer(1));
241
+ container.addChild(new Text(theme.fg("success", "🌿 worktree"), 0, 0));
242
+ const patchLines = result.patch.split("\n").length;
243
+ container.addChild(new Text(theme.fg("dim", `${patchLines} diff lines — merge explicitly via apply_patch/cherry-pick`), 0, 0));
244
+ if (result.patch !== "(no changes)") {
245
+ container.addChild(new Text(theme.fg("muted", result.patch.slice(0, 2000)), 0, 0));
246
+ }
247
+ }
239
248
  return container;
240
249
  }
241
250
 
@@ -258,6 +267,7 @@ export function renderSingleResult(
258
267
  }
259
268
  const usageStr = formatUsageStats(result.usage, result.model);
260
269
  if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
270
+ if (result.patch) text += `\n${theme.fg("success", "🌿 worktree")} (${result.patch.split("\n").length} diff lines)`;
261
271
  return new Text(text, 0, 0);
262
272
  }
263
273
 
@@ -16,6 +16,7 @@
16
16
 
17
17
  import type { Message, Model } from "@earendil-works/pi-ai";
18
18
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
+ import * as path from "node:path";
19
20
  import {
20
21
  createAgentSession,
21
22
  createExtensionRuntime,
@@ -115,6 +116,8 @@ export interface SubAgentResult {
115
116
  model?: string;
116
117
  stopReason?: string;
117
118
  errorMessage?: string;
119
+ /** Unified diff of changes made in an isolated worktree (sandbox: "worktree"). */
120
+ patch?: string;
118
121
  /** Canonical result status (added in 0.6.0). */
119
122
  status?: SubagentStatus;
120
123
  }
@@ -135,6 +138,8 @@ export async function runSubAgent(options: {
135
138
  task: string;
136
139
  tools: string[];
137
140
  model: Model<any>;
141
+ /** "worktree" runs the child in an isolated git worktree; the resulting diff is returned as result.patch. */
142
+ sandbox?: "worktree";
138
143
  /** Pi 0.80.10's canonical credential/model runtime. */
139
144
  modelRuntime?: unknown;
140
145
  /** Legacy Pi SDK session options retained for 0.80.6 tests and hosts. */
@@ -147,6 +152,8 @@ export async function runSubAgent(options: {
147
152
  onProgress?: (progress: SubAgentProgress) => void;
148
153
  timeoutMs?: number;
149
154
  hardTimeoutMs?: number;
155
+ /** Exec used for the git worktree lifecycle (add/remove/diff). Defaults to a child_process spawn when unset. */
156
+ exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>;
150
157
  /**
151
158
  * When true, build a DefaultResourceLoader so the child inherits the parent's
152
159
  * extensions (and thus extension tools: web, serena, munin, …). When false or
@@ -164,7 +171,7 @@ export async function runSubAgent(options: {
164
171
  cwd, systemPrompt, task, tools, model, modelRuntime, authStorage, modelRegistry, signal,
165
172
  agentName = "subagent", thinkingLevel = "off", onMessage, onProgress,
166
173
  timeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS, hardTimeoutMs = HARD_TIMEOUT_MS,
167
- loadExtensions = false, projectTrusted = true,
174
+ loadExtensions = false, projectTrusted = true, sandbox, exec,
168
175
  } = options;
169
176
  const result: SubAgentResult = {
170
177
  agent: agentName, task, exitCode: 0, messages: [], stderr: "",
@@ -210,12 +217,36 @@ export async function runSubAgent(options: {
210
217
  result.status = classifyStopReason(result.stopReason, !timedOut, timedOut);
211
218
  return result;
212
219
  }
213
- const { session } = await createAgentSession({
214
- cwd, model, thinkingLevel, resourceLoader, tools, sessionManager: SessionManager.inMemory(cwd), settingsManager,
215
- ...(modelRuntime ? { modelRuntime } : {}),
216
- // Pi 0.80.10 owns credentials in ModelRuntime; older SDKs still accept these.
217
- ...(authStorage ? { authStorage, modelRegistry } : {}),
218
- } as any);
220
+
221
+ // Isolated git worktree: the child edits a sibling checkout; the resulting
222
+ // diff is returned as result.patch and the worktree is removed on exit.
223
+ // ponytail: falls back to the in-process cwd when git is unavailable — the
224
+ // worktree is an isolation optimization, not a hard requirement.
225
+ let worktreeDir: string | undefined;
226
+ let childCwd = cwd;
227
+ if (sandbox === "worktree") {
228
+ const wt = await createWorktree(cwd, exec);
229
+ if (wt.ok) {
230
+ worktreeDir = wt.path;
231
+ childCwd = wt.path!;
232
+ } else if (wt.error) {
233
+ result.stderr = `Worktree unavailable (${wt.error}); running in workspace.`;
234
+ }
235
+ }
236
+
237
+ let session: Awaited<ReturnType<typeof createAgentSession>>["session"];
238
+ try {
239
+ const created = await createAgentSession({
240
+ cwd: childCwd, model, thinkingLevel, resourceLoader, tools, sessionManager: SessionManager.inMemory(childCwd), settingsManager,
241
+ ...(modelRuntime ? { modelRuntime } : {}),
242
+ // Pi 0.80.10 owns credentials in ModelRuntime; older SDKs still accept these.
243
+ ...(authStorage ? { authStorage, modelRegistry } : {}),
244
+ } as any);
245
+ session = created.session;
246
+ } catch (error) {
247
+ if (worktreeDir) await removeWorktree(cwd, worktreeDir, exec);
248
+ throw error;
249
+ }
219
250
  let unsubscribe: (() => void) | undefined;
220
251
  let removeAbort: (() => void) | undefined;
221
252
  try {
@@ -257,10 +288,17 @@ export async function runSubAgent(options: {
257
288
  else if (combinedSignal.aborted) { result.stopReason = "aborted"; result.errorMessage ||= "Sub-agent aborted"; }
258
289
  result.status = classifyStopReason(result.stopReason, result.stopReason === "aborted", result.stopReason === "timeout");
259
290
  result.exitCode = result.status === "success" || result.status === "partial" ? 0 : 1;
291
+ if (worktreeDir) {
292
+ // Capture the child's changes as a unified diff before tearing down.
293
+ const diff = await captureWorktreeDiff(cwd, worktreeDir, exec);
294
+ if (diff.ok) result.patch = diff.diff;
295
+ else if (diff.error) result.stderr = result.stderr ? `${result.stderr}; diff unavailable (${diff.error})` : `Diff unavailable (${diff.error})`;
296
+ }
260
297
  return result;
261
298
  } finally {
262
299
  unsubscribe?.(); removeAbort?.();
263
300
  try { session.dispose(); } catch { /* best effort */ }
301
+ if (worktreeDir) await removeWorktree(cwd, worktreeDir, exec);
264
302
  }
265
303
  } catch (error) {
266
304
  result.exitCode = 1;
@@ -277,6 +315,88 @@ export async function runSubAgent(options: {
277
315
  // Helpers
278
316
  // ---------------------------------------------------------------------------
279
317
 
318
+ /** Minimal exec fallback (child_process spawn) used when no exec is injected. */
319
+ async function defaultExec(
320
+ command: string,
321
+ args: string[],
322
+ options?: { cwd?: string; timeout?: number },
323
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
324
+ const { spawn } = await import("node:child_process");
325
+ return new Promise((resolve) => {
326
+ const child = spawn(command, args, { cwd: options?.cwd, stdio: ["ignore", "pipe", "pipe"] });
327
+ // Decode each chunk as a complete UTF-8 stream (Buffer.toString per chunk
328
+ // would corrupt multi-byte sequences straddling chunk boundaries).
329
+ child.stdout.setEncoding("utf8");
330
+ child.stderr.setEncoding("utf8");
331
+ let stdout = ""; let stderr = "";
332
+ const timer = options?.timeout ? setTimeout(() => child.kill("SIGKILL"), options.timeout) : undefined;
333
+ child.stdout.on("data", (d) => { stdout += d; });
334
+ child.stderr.on("data", (d) => { stderr += d; });
335
+ child.on("error", (err) => { if (timer) clearTimeout(timer); resolve({ code: 1, stdout, stderr: String(err.message ?? err) }); });
336
+ child.on("close", (code) => { if (timer) clearTimeout(timer); resolve({ code: code ?? 1, stdout, stderr }); });
337
+ });
338
+ }
339
+
340
+ async function runGit(
341
+ cwd: string,
342
+ args: string[],
343
+ exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>,
344
+ ): Promise<{ ok: boolean; stdout: string; stderr: string }> {
345
+ const run = exec ?? defaultExec;
346
+ const res = await run("git", args, { cwd, timeout: 30_000 });
347
+ return { ok: res.code === 0, stdout: res.stdout, stderr: res.stderr };
348
+ }
349
+
350
+ /** Create a detached git worktree at .pi-worktrees/<rand> under the repo root. */
351
+ export async function createWorktree(
352
+ cwd: string,
353
+ exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>,
354
+ ): Promise<{ ok: boolean; path?: string; error?: string }> {
355
+ try {
356
+ const root = await runGit(cwd, ["rev-parse", "--show-toplevel"], exec);
357
+ if (!root.ok) return { ok: false, error: root.stderr.trim() || "not a git repo" };
358
+ const repoRoot = root.stdout.trim();
359
+ if (!repoRoot) return { ok: false, error: "empty git root" };
360
+ const id = `pi-subagent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
361
+ const wtPath = path.join(repoRoot, ".pi-worktrees", id);
362
+ const add = await runGit(repoRoot, ["worktree", "add", "--detach", wtPath, "HEAD"], exec);
363
+ if (!add.ok) return { ok: false, error: add.stderr.trim() || "git worktree add failed" };
364
+ return { ok: true, path: wtPath };
365
+ } catch (error) {
366
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
367
+ }
368
+ }
369
+
370
+ /** Unified diff of ALL changes in the worktree vs HEAD (tracked edits, staged
371
+ * changes, AND new untracked files). Stages first so untracked files are
372
+ * captured — the worktree is removed right after, so mutating its index is
373
+ * safe. A single `diff --cached HEAD` avoids duplicate hunks from combining
374
+ * `diff HEAD` + `diff --cached`. */
375
+ export async function captureWorktreeDiff(
376
+ repoRoot: string,
377
+ worktreeDir: string,
378
+ exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>,
379
+ ): Promise<{ ok: boolean; diff?: string; error?: string }> {
380
+ const add = await runGit(worktreeDir, ["add", "-A"], exec);
381
+ if (!add.ok) return { ok: false, error: add.stderr.trim() || "git add -A failed" };
382
+ const diff = await runGit(worktreeDir, ["diff", "--cached", "HEAD"], exec);
383
+ if (!diff.ok) return { ok: false, error: diff.stderr.trim() || "git diff --cached HEAD failed" };
384
+ const text = diff.stdout.trim();
385
+ return { ok: true, diff: text || "(no changes)" };
386
+ }
387
+
388
+ /** Remove a worktree and its metadata, best-effort. */
389
+ export async function removeWorktree(
390
+ repoRoot: string,
391
+ worktreeDir: string,
392
+ exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>,
393
+ ): Promise<void> {
394
+ try {
395
+ await runGit(repoRoot, ["worktree", "remove", "--force", worktreeDir], exec);
396
+ } catch { /* best effort */ }
397
+ }
398
+
399
+
280
400
  export function getFinalOutput(messages: Message[]): string {
281
401
  for (let i = messages.length - 1; i >= 0; i--) {
282
402
  const msg = messages[i];
@@ -117,6 +117,7 @@ export async function runNamedAgent(options: {
117
117
 
118
118
  const result = await runSubAgent({
119
119
  cwd: safeCwd.path,
120
+ sandbox: options.agent.sandbox === "worktree" ? "worktree" : undefined,
120
121
  systemPrompt: contract ? `${options.agent.systemPrompt}\n\n## Task Contract\n${contract}` : options.agent.systemPrompt,
121
122
  task: options.task,
122
123
  tools: toolValidation.tools,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "In-process subagents for Pi with isolated SDK sessions, parallel and chained delegation, and inspectable threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -56,17 +56,17 @@
56
56
  "check": "npm run typecheck && npm test"
57
57
  },
58
58
  "peerDependencies": {
59
- "@earendil-works/pi-coding-agent": ">=0.80.0 <0.84.0",
60
- "@earendil-works/pi-ai": ">=0.80.0 <0.84.0",
61
- "@earendil-works/pi-agent-core": ">=0.80.0 <0.84.0",
62
- "@earendil-works/pi-tui": ">=0.80.0 <0.84.0",
59
+ "@earendil-works/pi-coding-agent": ">=0.80.0 <0.85.0",
60
+ "@earendil-works/pi-ai": ">=0.80.0 <0.85.0",
61
+ "@earendil-works/pi-agent-core": ">=0.80.0 <0.85.0",
62
+ "@earendil-works/pi-tui": ">=0.80.0 <0.85.0",
63
63
  "typebox": ">=1.3.0 <2.0.0"
64
64
  },
65
65
  "devDependencies": {
66
- "@earendil-works/pi-agent-core": "^0.83.0",
67
- "@earendil-works/pi-ai": "^0.83.0",
68
- "@earendil-works/pi-coding-agent": "^0.83.0",
69
- "@earendil-works/pi-tui": "^0.83.0",
66
+ "@earendil-works/pi-agent-core": "^0.84.0",
67
+ "@earendil-works/pi-ai": "^0.84.0",
68
+ "@earendil-works/pi-coding-agent": "^0.84.0",
69
+ "@earendil-works/pi-tui": "^0.84.0",
70
70
  "@types/mocha": "^10.0.10",
71
71
  "@types/node": "^20.19.43",
72
72
  "mocha": "^10.8.2",