@tintinweb/pi-subagents 0.10.2 → 0.10.3

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
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.3] - 2026-06-12
11
+
12
+ ### Added
13
+ - **`SpawnOptions.cwd` — spawn a subagent in a different working directory** ([#96](https://github.com/tintinweb/pi-subagents/issues/96) — thanks [@madeleineostoja](https://github.com/madeleineostoja)). For RPC/programmatic callers (not exposed on the `Agent` tool — the LLM-visible surface is unchanged). The agent's tools operate in the target directory and the prompt's environment block describes it, but **`.pi` config keeps loading from the parent session's project** (new `RunOptions.configCwd` split): the target's `.pi` extensions never execute, and its agents/skills/settings/memory are not picked up — spawning into an untrusted directory sends a worker there with the parent's toolbox, rather than "opening pi there." Composes with `isolation: "worktree"`: the worktree is created *from* the target directory's repo, the agent works at the equivalent subdirectory inside the copy (a monorepo-package cwd keeps its scoping instead of silently widening to the repo root — new `WorktreeInfo.workPath`), and the resulting `pi-agent-*` branch lands in that repo, with the completion message naming it so the orchestrator merges in the right place. Validation is strict, typed, and early — non-strings, relative paths, nonexistent paths, and files all throw curated errors at `spawn()` (before queueing) and are re-checked at queue drain, surfacing as RPC error envelopes (`null` is treated as unset). On dispose, worktree registrations are pruned in every repo that received one; only a hard crash can leave a stale entry (then: `git worktree prune` in the target repo).
14
+
10
15
  ## [0.10.2] - 2026-06-10
11
16
 
12
17
  ### Added
package/README.md CHANGED
@@ -483,6 +483,8 @@ pi.events.emit("subagents:rpc:spawn", {
483
483
 
484
484
  `options.model` accepts either a `Model` object (e.g. `ctx.model`) or a `"provider/modelId"` string — strings are resolved against `ctx.modelRegistry` at the RPC boundary, so cross-extension callers can forward serializable values without losing auth context.
485
485
 
486
+ `options.cwd` (absolute path to an existing directory — anything else returns an error envelope; `null` means unset) runs the agent in a different working directory than the parent session. Its tools operate there and the prompt's environment block describes it, but **`.pi` config still loads from the parent session's project** — the target directory's `.pi` extensions never execute, and its agents/skills/settings are not picked up. Combined with `isolation: "worktree"`, the worktree is created *from* the target directory's repo, the agent works at the equivalent subdirectory inside the copy (a monorepo-package cwd stays scoped to that package), and the resulting `pi-agent-*` branch lands in that repo — the completion message names it. On session end, worktree registrations are pruned in every repo that received one; only a hard crash can leave a stale entry (then: `git worktree prune` in the target repo). Agents with `memory:` keep reading/writing the parent project's memory.
487
+
486
488
  ### Stop
487
489
 
488
490
  Stop a running agent by ID:
@@ -32,6 +32,15 @@ interface SpawnOptions {
32
32
  bypassQueue?: boolean;
33
33
  /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
34
34
  isolation?: IsolationMode;
35
+ /**
36
+ * Working directory for the agent (absolute path). Default: parent session
37
+ * cwd. The agent's tools operate here, but .pi config (extensions, skills,
38
+ * settings, memory) still loads from the parent session's project — the
39
+ * target directory's `.pi` extensions never execute. With isolation:
40
+ * "worktree", the worktree is created FROM this directory and the result
41
+ * branch lands in that repo.
42
+ */
43
+ cwd?: string;
35
44
  /** Resolved invocation snapshot captured for UI display. */
36
45
  invocation?: AgentInvocation;
37
46
  /** Parent abort signal — when aborted, the subagent is also stopped. */
@@ -60,6 +69,9 @@ export declare class AgentManager {
60
69
  private onStart?;
61
70
  private onCompact?;
62
71
  private maxConcurrent;
72
+ /** Base repos worktrees were created from — so dispose() can prune them all,
73
+ * not just the parent repo (caller-supplied cwd can target other repos). */
74
+ private worktreeRepos;
63
75
  /** Queue of background agents waiting to start. */
64
76
  private queue;
65
77
  /** Number of currently running background agents. */
@@ -6,11 +6,36 @@
6
6
  * Foreground agents bypass the queue (they block the parent anyway).
7
7
  */
8
8
  import { randomUUID } from "node:crypto";
9
+ import { statSync } from "node:fs";
10
+ import { isAbsolute } from "node:path";
9
11
  import { resumeAgent, runAgent } from "./agent-runner.js";
10
12
  import { addUsage } from "./usage.js";
11
13
  import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
12
14
  /** Default max concurrent background agents. */
13
15
  const DEFAULT_MAX_CONCURRENT = 4;
16
+ /**
17
+ * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
18
+ * (parent cwd). Anything else must be an absolute path to an existing
19
+ * directory — curated errors instead of TypeErrors from path/fs internals
20
+ * (RPC callers send arbitrary JSON: null, numbers, file paths).
21
+ */
22
+ function assertValidSpawnCwd(cwd) {
23
+ if (cwd == null)
24
+ return;
25
+ if (typeof cwd !== "string" || !isAbsolute(cwd)) {
26
+ throw new Error(`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`);
27
+ }
28
+ let isDirectory = false;
29
+ try {
30
+ isDirectory = statSync(cwd).isDirectory();
31
+ }
32
+ catch {
33
+ throw new Error(`SpawnOptions.cwd does not exist: "${cwd}"`);
34
+ }
35
+ if (!isDirectory) {
36
+ throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`);
37
+ }
38
+ }
14
39
  export class AgentManager {
15
40
  agents = new Map();
16
41
  cleanupInterval;
@@ -18,6 +43,9 @@ export class AgentManager {
18
43
  onStart;
19
44
  onCompact;
20
45
  maxConcurrent;
46
+ /** Base repos worktrees were created from — so dispose() can prune them all,
47
+ * not just the parent repo (caller-supplied cwd can target other repos). */
48
+ worktreeRepos = new Set();
21
49
  /** Queue of background agents waiting to start. */
22
50
  queue = [];
23
51
  /** Number of currently running background agents. */
@@ -45,6 +73,10 @@ export class AgentManager {
45
73
  * If the concurrency limit is reached, the agent is queued.
46
74
  */
47
75
  spawn(pi, ctx, type, prompt, options) {
76
+ // Validate before the queue branch — a queued spawn should fail at the
77
+ // call, not minutes later at drain. Throw (not warn): programmatic callers
78
+ // can fix and retry; the RPC layer converts throws into error envelopes.
79
+ assertValidSpawnCwd(options.cwd);
48
80
  const id = randomUUID().slice(0, 17);
49
81
  const abortController = new AbortController();
50
82
  const record = {
@@ -79,18 +111,33 @@ export class AgentManager {
79
111
  }
80
112
  /** Actually start an agent (called immediately or from queue drain). */
81
113
  startAgent(id, record, { pi, ctx, type, prompt, options }) {
114
+ // Re-validate a caller-supplied cwd: queued spawns can start minutes after
115
+ // spawn()'s check, and the directory may be gone by then (TOCTOU). Same
116
+ // curated errors; drainQueue parks a throw on the record as an error.
117
+ assertValidSpawnCwd(options.cwd);
118
+ // Single resolution point for the caller-supplied cwd — the worktree base
119
+ // repo and both cleanup calls below MUST agree on this value forever.
120
+ const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
121
+ const baseCwd = customCwd ?? ctx.cwd;
82
122
  // Worktree isolation: try to create a temporary git worktree. Strict —
83
123
  // fail loud if not possible (no silent fallback to main tree). Done
84
124
  // BEFORE state mutation so a throw doesn't leave the record half-running.
85
125
  let worktreeCwd;
86
126
  if (options.isolation === "worktree") {
87
- const wt = createWorktree(ctx.cwd, id);
127
+ const wt = createWorktree(baseCwd, id);
88
128
  if (!wt) {
89
129
  throw new Error('Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
90
130
  'Initialize git and commit at least once, or omit `isolation`.');
91
131
  }
92
132
  record.worktree = wt;
93
- worktreeCwd = wt.path;
133
+ // workPath preserves subdirectory scoping for caller-supplied cwds: a
134
+ // cwd deep in a monorepo maps to the same subdir inside the copy, not
135
+ // the copied repo's root. Plain worktree spawns keep the historical
136
+ // behavior (agent at the copy's root) — moving them to workPath would
137
+ // also move .pi config discovery when the parent session sits in a repo
138
+ // subdirectory, silently dropping extensions/skills.
139
+ worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
140
+ this.worktreeRepos.add(baseCwd);
94
141
  }
95
142
  record.status = "running";
96
143
  record.startedAt = Date.now();
@@ -113,7 +160,13 @@ export class AgentManager {
113
160
  isolated: options.isolated,
114
161
  inheritContext: options.inheritContext,
115
162
  thinkingLevel: options.thinkingLevel,
116
- cwd: worktreeCwd,
163
+ // Worktree wins for the working dir (the agent must run in the copy —
164
+ // which, with a custom cwd, was created from that target). Config stays
165
+ // with the parent project when a caller-supplied cwd is in play; it must
166
+ // stay undefined otherwise so plain worktree runs keep resolving config
167
+ // (incl. relative extension paths and memory) inside the worktree copy.
168
+ cwd: worktreeCwd ?? customCwd,
169
+ configCwd: customCwd !== undefined ? ctx.cwd : undefined,
117
170
  signal: record.abortController.signal,
118
171
  onToolActivity: (activity) => {
119
172
  if (activity.type === "end")
@@ -162,11 +215,14 @@ export class AgentManager {
162
215
  }
163
216
  // Clean up worktree if used
164
217
  if (record.worktree) {
165
- const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
218
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
166
219
  record.worktreeResult = wtResult;
167
220
  if (wtResult.hasChanges && wtResult.branch) {
221
+ // With a caller-supplied cwd the branch lives in THAT repo, not the
222
+ // parent session's — say so, or the orchestrator merges in the wrong repo.
223
+ const repoNote = customCwd !== undefined ? ` in \`${baseCwd}\`` : "";
168
224
  record.result = (record.result ?? "") +
169
- `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
225
+ `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`;
170
226
  }
171
227
  }
172
228
  if (options.isBackground) {
@@ -198,7 +254,7 @@ export class AgentManager {
198
254
  // Best-effort worktree cleanup on error
199
255
  if (record.worktree) {
200
256
  try {
201
- const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
257
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
202
258
  record.worktreeResult = wtResult;
203
259
  }
204
260
  catch { /* ignore cleanup errors */ }
@@ -387,5 +443,13 @@ export class AgentManager {
387
443
  pruneWorktrees(process.cwd());
388
444
  }
389
445
  catch { /* ignore */ }
446
+ // Also prune repos that caller-supplied cwds created worktrees in — a clean
447
+ // exit with in-flight agents would otherwise leave stale registrations there.
448
+ for (const repo of this.worktreeRepos) {
449
+ try {
450
+ pruneWorktrees(repo);
451
+ }
452
+ catch { /* ignore */ }
453
+ }
390
454
  }
391
455
  }
@@ -82,6 +82,20 @@ export interface RunOptions {
82
82
  thinkingLevel?: ThinkingLevel;
83
83
  /** Override working directory (e.g. for worktree isolation). */
84
84
  cwd?: string;
85
+ /**
86
+ * Where .pi config is discovered (project extensions, skills, pi settings,
87
+ * agent memory). Default: same as the working directory. The manager sets
88
+ * this to the parent session's cwd when `SpawnOptions.cwd` points the
89
+ * working directory elsewhere — the agent works *there* but carries the
90
+ * parent project's config (the target's `.pi` extensions never execute).
91
+ *
92
+ * WARNING for future callers: if you pass `cwd` pointing at a directory the
93
+ * user didn't open, you almost certainly must pass `configCwd` too —
94
+ * omitting it makes the target's `.pi` extensions execute in this process.
95
+ * (Worktree isolation is the one intentional exception: its copy IS the
96
+ * parent's repo, so config resolving inside it is correct.)
97
+ */
98
+ configCwd?: string;
85
99
  /** Called on tool start/end with activity info. */
86
100
  onToolActivity?: (activity: ToolActivity) => void;
87
101
  /** Called on streaming text deltas from the assistant response. */
@@ -199,6 +199,9 @@ export async function runAgent(ctx, type, prompt, options) {
199
199
  const agentConfig = getAgentConfig(type);
200
200
  // Resolve working directory: worktree override > parent cwd
201
201
  const effectiveCwd = options.cwd ?? ctx.cwd;
202
+ // Filesystem work happens in effectiveCwd; config discovery in configCwd.
203
+ // They differ only for SpawnOptions.cwd spawns (config stays with the parent).
204
+ const configCwd = options.configCwd ?? effectiveCwd;
202
205
  const env = await detectEnv(options.pi, effectiveCwd);
203
206
  // Get parent system prompt for append-mode agents
204
207
  const parentSystemPrompt = ctx.getSystemPrompt();
@@ -212,7 +215,7 @@ export async function runAgent(ctx, type, prompt, options) {
212
215
  const skills = options.isolated ? false : config.skills;
213
216
  // Skill preloading: when skills is string[], preload their content into prompt
214
217
  if (Array.isArray(skills)) {
215
- const loaded = preloadSkills(skills, effectiveCwd);
218
+ const loaded = preloadSkills(skills, configCwd);
216
219
  if (loaded.length > 0) {
217
220
  extras.skillBlocks = loaded;
218
221
  }
@@ -230,14 +233,14 @@ export async function runAgent(ctx, type, prompt, options) {
230
233
  const extraNames = getMemoryToolNames(existingNames);
231
234
  if (extraNames.length > 0)
232
235
  toolNames = [...toolNames, ...extraNames];
233
- extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
236
+ extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
234
237
  }
235
238
  else {
236
239
  // Read-only memory: only add read tool name, use read-only prompt
237
240
  const extraNames = getReadOnlyMemoryToolNames(existingNames);
238
241
  if (extraNames.length > 0)
239
242
  toolNames = [...toolNames, ...extraNames];
240
- extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
243
+ extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
241
244
  }
242
245
  }
243
246
  // Build system prompt from agent config
@@ -277,7 +280,7 @@ export async function runAgent(ctx, type, prompt, options) {
277
280
  const { extNames, narrowing } = parseExtSelectors(options.isolated ? [] : (agentConfig?.extSelectors ?? []));
278
281
  const noExtensions = extensions === false;
279
282
  const extensionsSpec = Array.isArray(extensions)
280
- ? parseExtensionsSpec(extensions, effectiveCwd)
283
+ ? parseExtensionsSpec(extensions, configCwd)
281
284
  : undefined;
282
285
  const keepNames = extensionsSpec?.names ?? new Set();
283
286
  // `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
@@ -310,7 +313,7 @@ export async function runAgent(ctx, type, prompt, options) {
310
313
  };
311
314
  };
312
315
  const loader = new DefaultResourceLoader({
313
- cwd: effectiveCwd,
316
+ cwd: configCwd,
314
317
  agentDir,
315
318
  noExtensions,
316
319
  additionalExtensionPaths,
@@ -438,7 +441,7 @@ export async function runAgent(ctx, type, prompt, options) {
438
441
  cwd: effectiveCwd,
439
442
  agentDir,
440
443
  sessionManager: SessionManager.inMemory(effectiveCwd),
441
- settingsManager: SettingsManager.create(effectiveCwd, agentDir),
444
+ settingsManager: SettingsManager.create(configCwd, agentDir),
442
445
  modelRegistry: ctx.modelRegistry,
443
446
  model,
444
447
  tools: allowedTools,
package/dist/types.d.ts CHANGED
@@ -78,6 +78,7 @@ export interface AgentRecord {
78
78
  path: string;
79
79
  branch: string;
80
80
  baseSha: string;
81
+ workPath: string;
81
82
  };
82
83
  /** Worktree cleanup result after agent completion. */
83
84
  worktreeResult?: {
@@ -6,12 +6,19 @@
6
6
  * If changes exist, a branch is created and returned in the result.
7
7
  */
8
8
  export interface WorktreeInfo {
9
- /** Absolute path to the worktree directory. */
9
+ /** Absolute path to the worktree directory (the copied repo's root). */
10
10
  path: string;
11
11
  /** Branch name created for this worktree (if changes exist). */
12
12
  branch: string;
13
13
  /** Commit SHA that the worktree was created from. */
14
14
  baseSha: string;
15
+ /**
16
+ * Where the agent should work inside the worktree: the equivalent of the
17
+ * cwd the worktree was created from. Equals `path` when that cwd was the
18
+ * repo root; points at the copied subdirectory when it was deeper (e.g. a
19
+ * monorepo package), so the requested scoping survives isolation.
20
+ */
21
+ workPath: string;
15
22
  }
16
23
  export interface WorktreeCleanupResult {
17
24
  /** Whether changes were found in the worktree. */
package/dist/worktree.js CHANGED
@@ -7,9 +7,9 @@
7
7
  */
8
8
  import { execFileSync } from "node:child_process";
9
9
  import { randomUUID } from "node:crypto";
10
- import { existsSync } from "node:fs";
10
+ import { existsSync, realpathSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
- import { join } from "node:path";
12
+ import { join, relative } from "node:path";
13
13
  /**
14
14
  * Create a temporary git worktree for an agent.
15
15
  * Returns the worktree path, or undefined if not in a git repo.
@@ -17,11 +17,20 @@ import { join } from "node:path";
17
17
  export function createWorktree(cwd, agentId) {
18
18
  // Verify we're in a git repo with at least one commit (HEAD must exist)
19
19
  let baseSha;
20
+ let subdir;
20
21
  try {
21
22
  execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
22
23
  baseSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 })
23
24
  .toString()
24
25
  .trim();
26
+ // Where cwd sits inside the repo ("" at the root): the agent must work at
27
+ // the same subdirectory inside the copy, or a monorepo-package cwd would
28
+ // silently widen to the whole repo. realpath both sides — git emits
29
+ // resolved paths while cwd may arrive through a symlink (macOS /tmp).
30
+ const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe", timeout: 5000 })
31
+ .toString()
32
+ .trim();
33
+ subdir = relative(realpathSync(topLevel), realpathSync(cwd));
25
34
  }
26
35
  catch {
27
36
  return undefined;
@@ -36,7 +45,7 @@ export function createWorktree(cwd, agentId) {
36
45
  stdio: "pipe",
37
46
  timeout: 30000,
38
47
  });
39
- return { path: worktreePath, branch, baseSha };
48
+ return { path: worktreePath, branch, baseSha, workPath: subdir ? join(worktreePath, subdir) : worktreePath };
40
49
  }
41
50
  catch {
42
51
  // If worktree creation fails, return undefined (agent runs in normal cwd)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { randomUUID } from "node:crypto";
10
+ import { statSync } from "node:fs";
11
+ import { isAbsolute } from "node:path";
10
12
  import type { Model } from "@earendil-works/pi-ai";
11
13
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
14
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
@@ -22,6 +24,28 @@ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; toke
22
24
  /** Default max concurrent background agents. */
23
25
  const DEFAULT_MAX_CONCURRENT = 4;
24
26
 
27
+ /**
28
+ * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
29
+ * (parent cwd). Anything else must be an absolute path to an existing
30
+ * directory — curated errors instead of TypeErrors from path/fs internals
31
+ * (RPC callers send arbitrary JSON: null, numbers, file paths).
32
+ */
33
+ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined | null {
34
+ if (cwd == null) return;
35
+ if (typeof cwd !== "string" || !isAbsolute(cwd)) {
36
+ throw new Error(`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`);
37
+ }
38
+ let isDirectory = false;
39
+ try {
40
+ isDirectory = statSync(cwd).isDirectory();
41
+ } catch {
42
+ throw new Error(`SpawnOptions.cwd does not exist: "${cwd}"`);
43
+ }
44
+ if (!isDirectory) {
45
+ throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`);
46
+ }
47
+ }
48
+
25
49
  interface SpawnArgs {
26
50
  pi: ExtensionAPI;
27
51
  ctx: ExtensionContext;
@@ -46,6 +70,15 @@ interface SpawnOptions {
46
70
  bypassQueue?: boolean;
47
71
  /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
48
72
  isolation?: IsolationMode;
73
+ /**
74
+ * Working directory for the agent (absolute path). Default: parent session
75
+ * cwd. The agent's tools operate here, but .pi config (extensions, skills,
76
+ * settings, memory) still loads from the parent session's project — the
77
+ * target directory's `.pi` extensions never execute. With isolation:
78
+ * "worktree", the worktree is created FROM this directory and the result
79
+ * branch lands in that repo.
80
+ */
81
+ cwd?: string;
49
82
  /** Resolved invocation snapshot captured for UI display. */
50
83
  invocation?: AgentInvocation;
51
84
  /** Parent abort signal — when aborted, the subagent is also stopped. */
@@ -71,6 +104,9 @@ export class AgentManager {
71
104
  private onStart?: OnAgentStart;
72
105
  private onCompact?: OnAgentCompact;
73
106
  private maxConcurrent: number;
107
+ /** Base repos worktrees were created from — so dispose() can prune them all,
108
+ * not just the parent repo (caller-supplied cwd can target other repos). */
109
+ private worktreeRepos = new Set<string>();
74
110
 
75
111
  /** Queue of background agents waiting to start. */
76
112
  private queue: { id: string; args: SpawnArgs }[] = [];
@@ -114,6 +150,11 @@ export class AgentManager {
114
150
  prompt: string,
115
151
  options: SpawnOptions,
116
152
  ): string {
153
+ // Validate before the queue branch — a queued spawn should fail at the
154
+ // call, not minutes later at drain. Throw (not warn): programmatic callers
155
+ // can fix and retry; the RPC layer converts throws into error envelopes.
156
+ assertValidSpawnCwd(options.cwd);
157
+
117
158
  const id = randomUUID().slice(0, 17);
118
159
  const abortController = new AbortController();
119
160
  const record: AgentRecord = {
@@ -151,12 +192,21 @@ export class AgentManager {
151
192
 
152
193
  /** Actually start an agent (called immediately or from queue drain). */
153
194
  private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
195
+ // Re-validate a caller-supplied cwd: queued spawns can start minutes after
196
+ // spawn()'s check, and the directory may be gone by then (TOCTOU). Same
197
+ // curated errors; drainQueue parks a throw on the record as an error.
198
+ assertValidSpawnCwd(options.cwd);
199
+ // Single resolution point for the caller-supplied cwd — the worktree base
200
+ // repo and both cleanup calls below MUST agree on this value forever.
201
+ const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
202
+ const baseCwd = customCwd ?? ctx.cwd;
203
+
154
204
  // Worktree isolation: try to create a temporary git worktree. Strict —
155
205
  // fail loud if not possible (no silent fallback to main tree). Done
156
206
  // BEFORE state mutation so a throw doesn't leave the record half-running.
157
207
  let worktreeCwd: string | undefined;
158
208
  if (options.isolation === "worktree") {
159
- const wt = createWorktree(ctx.cwd, id);
209
+ const wt = createWorktree(baseCwd, id);
160
210
  if (!wt) {
161
211
  throw new Error(
162
212
  'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
@@ -164,7 +214,14 @@ export class AgentManager {
164
214
  );
165
215
  }
166
216
  record.worktree = wt;
167
- worktreeCwd = wt.path;
217
+ // workPath preserves subdirectory scoping for caller-supplied cwds: a
218
+ // cwd deep in a monorepo maps to the same subdir inside the copy, not
219
+ // the copied repo's root. Plain worktree spawns keep the historical
220
+ // behavior (agent at the copy's root) — moving them to workPath would
221
+ // also move .pi config discovery when the parent session sits in a repo
222
+ // subdirectory, silently dropping extensions/skills.
223
+ worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
224
+ this.worktreeRepos.add(baseCwd);
168
225
  }
169
226
 
170
227
  record.status = "running";
@@ -189,7 +246,13 @@ export class AgentManager {
189
246
  isolated: options.isolated,
190
247
  inheritContext: options.inheritContext,
191
248
  thinkingLevel: options.thinkingLevel,
192
- cwd: worktreeCwd,
249
+ // Worktree wins for the working dir (the agent must run in the copy —
250
+ // which, with a custom cwd, was created from that target). Config stays
251
+ // with the parent project when a caller-supplied cwd is in play; it must
252
+ // stay undefined otherwise so plain worktree runs keep resolving config
253
+ // (incl. relative extension paths and memory) inside the worktree copy.
254
+ cwd: worktreeCwd ?? customCwd,
255
+ configCwd: customCwd !== undefined ? ctx.cwd : undefined,
193
256
  signal: record.abortController!.signal,
194
257
  onToolActivity: (activity) => {
195
258
  if (activity.type === "end") record.toolUses++;
@@ -237,11 +300,14 @@ export class AgentManager {
237
300
 
238
301
  // Clean up worktree if used
239
302
  if (record.worktree) {
240
- const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
303
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
241
304
  record.worktreeResult = wtResult;
242
305
  if (wtResult.hasChanges && wtResult.branch) {
306
+ // With a caller-supplied cwd the branch lives in THAT repo, not the
307
+ // parent session's — say so, or the orchestrator merges in the wrong repo.
308
+ const repoNote = customCwd !== undefined ? ` in \`${baseCwd}\`` : "";
243
309
  record.result = (record.result ?? "") +
244
- `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
310
+ `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`;
245
311
  }
246
312
  }
247
313
 
@@ -271,7 +337,7 @@ export class AgentManager {
271
337
  // Best-effort worktree cleanup on error
272
338
  if (record.worktree) {
273
339
  try {
274
- const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
340
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
275
341
  record.worktreeResult = wtResult;
276
342
  } catch { /* ignore cleanup errors */ }
277
343
  }
@@ -479,5 +545,10 @@ export class AgentManager {
479
545
  this.agents.clear();
480
546
  // Prune any orphaned git worktrees (crash recovery)
481
547
  try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
548
+ // Also prune repos that caller-supplied cwds created worktrees in — a clean
549
+ // exit with in-flight agents would otherwise leave stale registrations there.
550
+ for (const repo of this.worktreeRepos) {
551
+ try { pruneWorktrees(repo); } catch { /* ignore */ }
552
+ }
482
553
  }
483
554
  }
@@ -206,6 +206,20 @@ export interface RunOptions {
206
206
  thinkingLevel?: ThinkingLevel;
207
207
  /** Override working directory (e.g. for worktree isolation). */
208
208
  cwd?: string;
209
+ /**
210
+ * Where .pi config is discovered (project extensions, skills, pi settings,
211
+ * agent memory). Default: same as the working directory. The manager sets
212
+ * this to the parent session's cwd when `SpawnOptions.cwd` points the
213
+ * working directory elsewhere — the agent works *there* but carries the
214
+ * parent project's config (the target's `.pi` extensions never execute).
215
+ *
216
+ * WARNING for future callers: if you pass `cwd` pointing at a directory the
217
+ * user didn't open, you almost certainly must pass `configCwd` too —
218
+ * omitting it makes the target's `.pi` extensions execute in this process.
219
+ * (Worktree isolation is the one intentional exception: its copy IS the
220
+ * parent's repo, so config resolving inside it is correct.)
221
+ */
222
+ configCwd?: string;
209
223
  /** Called on tool start/end with activity info. */
210
224
  onToolActivity?: (activity: ToolActivity) => void;
211
225
  /** Called on streaming text deltas from the assistant response. */
@@ -285,6 +299,9 @@ export async function runAgent(
285
299
 
286
300
  // Resolve working directory: worktree override > parent cwd
287
301
  const effectiveCwd = options.cwd ?? ctx.cwd;
302
+ // Filesystem work happens in effectiveCwd; config discovery in configCwd.
303
+ // They differ only for SpawnOptions.cwd spawns (config stays with the parent).
304
+ const configCwd = options.configCwd ?? effectiveCwd;
288
305
 
289
306
  const env = await detectEnv(options.pi, effectiveCwd);
290
307
 
@@ -303,7 +320,7 @@ export async function runAgent(
303
320
 
304
321
  // Skill preloading: when skills is string[], preload their content into prompt
305
322
  if (Array.isArray(skills)) {
306
- const loaded = preloadSkills(skills, effectiveCwd);
323
+ const loaded = preloadSkills(skills, configCwd);
307
324
  if (loaded.length > 0) {
308
325
  extras.skillBlocks = loaded;
309
326
  }
@@ -323,12 +340,12 @@ export async function runAgent(
323
340
  // Read-write memory: add any missing memory tool names (read/write/edit)
324
341
  const extraNames = getMemoryToolNames(existingNames);
325
342
  if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
326
- extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
343
+ extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
327
344
  } else {
328
345
  // Read-only memory: only add read tool name, use read-only prompt
329
346
  const extraNames = getReadOnlyMemoryToolNames(existingNames);
330
347
  if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
331
- extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
348
+ extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
332
349
  }
333
350
  }
334
351
 
@@ -373,7 +390,7 @@ export async function runAgent(
373
390
  const noExtensions = extensions === false;
374
391
 
375
392
  const extensionsSpec = Array.isArray(extensions)
376
- ? parseExtensionsSpec(extensions, effectiveCwd)
393
+ ? parseExtensionsSpec(extensions, configCwd)
377
394
  : undefined;
378
395
  const keepNames = extensionsSpec?.names ?? new Set<string>();
379
396
  // `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
@@ -407,7 +424,7 @@ export async function runAgent(
407
424
  };
408
425
 
409
426
  const loader = new DefaultResourceLoader({
410
- cwd: effectiveCwd,
427
+ cwd: configCwd,
411
428
  agentDir,
412
429
  noExtensions,
413
430
  additionalExtensionPaths,
@@ -542,7 +559,7 @@ export async function runAgent(
542
559
  cwd: effectiveCwd,
543
560
  agentDir,
544
561
  sessionManager: SessionManager.inMemory(effectiveCwd),
545
- settingsManager: SettingsManager.create(effectiveCwd, agentDir),
562
+ settingsManager: SettingsManager.create(configCwd, agentDir),
546
563
  modelRegistry: ctx.modelRegistry,
547
564
  model,
548
565
  tools: allowedTools,
package/src/types.ts CHANGED
@@ -83,7 +83,7 @@ export interface AgentRecord {
83
83
  /** Steering messages queued before the session was ready. */
84
84
  pendingSteers?: string[];
85
85
  /** Worktree info if the agent is running in an isolated worktree. */
86
- worktree?: { path: string; branch: string; baseSha: string };
86
+ worktree?: { path: string; branch: string; baseSha: string; workPath: string };
87
87
  /** Worktree cleanup result after agent completion. */
88
88
  worktreeResult?: { hasChanges: boolean; branch?: string };
89
89
  /** The tool_use_id from the original Agent tool call. */
package/src/worktree.ts CHANGED
@@ -8,17 +8,24 @@
8
8
 
9
9
  import { execFileSync } from "node:child_process";
10
10
  import { randomUUID } from "node:crypto";
11
- import { existsSync } from "node:fs";
11
+ import { existsSync, realpathSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
- import { join } from "node:path";
13
+ import { join, relative } from "node:path";
14
14
 
15
15
  export interface WorktreeInfo {
16
- /** Absolute path to the worktree directory. */
16
+ /** Absolute path to the worktree directory (the copied repo's root). */
17
17
  path: string;
18
18
  /** Branch name created for this worktree (if changes exist). */
19
19
  branch: string;
20
20
  /** Commit SHA that the worktree was created from. */
21
21
  baseSha: string;
22
+ /**
23
+ * Where the agent should work inside the worktree: the equivalent of the
24
+ * cwd the worktree was created from. Equals `path` when that cwd was the
25
+ * repo root; points at the copied subdirectory when it was deeper (e.g. a
26
+ * monorepo package), so the requested scoping survives isolation.
27
+ */
28
+ workPath: string;
22
29
  }
23
30
 
24
31
  export interface WorktreeCleanupResult {
@@ -37,11 +44,20 @@ export interface WorktreeCleanupResult {
37
44
  export function createWorktree(cwd: string, agentId: string): WorktreeInfo | undefined {
38
45
  // Verify we're in a git repo with at least one commit (HEAD must exist)
39
46
  let baseSha: string;
47
+ let subdir: string;
40
48
  try {
41
49
  execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
42
50
  baseSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 })
43
51
  .toString()
44
52
  .trim();
53
+ // Where cwd sits inside the repo ("" at the root): the agent must work at
54
+ // the same subdirectory inside the copy, or a monorepo-package cwd would
55
+ // silently widen to the whole repo. realpath both sides — git emits
56
+ // resolved paths while cwd may arrive through a symlink (macOS /tmp).
57
+ const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe", timeout: 5000 })
58
+ .toString()
59
+ .trim();
60
+ subdir = relative(realpathSync(topLevel), realpathSync(cwd));
45
61
  } catch {
46
62
  return undefined;
47
63
  }
@@ -57,7 +73,7 @@ export function createWorktree(cwd: string, agentId: string): WorktreeInfo | und
57
73
  stdio: "pipe",
58
74
  timeout: 30000,
59
75
  });
60
- return { path: worktreePath, branch, baseSha };
76
+ return { path: worktreePath, branch, baseSha, workPath: subdir ? join(worktreePath, subdir) : worktreePath };
61
77
  } catch {
62
78
  // If worktree creation fails, return undefined (agent runs in normal cwd)
63
79
  return undefined;