@tintinweb/pi-subagents 0.10.2 → 0.10.4

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,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.4] - 2026-06-23
11
+
12
+ ### Fixed
13
+ - **Background agent records lost before result is read** ([#108](https://github.com/tintinweb/pi-subagents/issues/108) — thanks [@philipmw](https://github.com/philipmw)). On session switch or `/new`/`/resume`, `clearCompleted()` removed completed agent records regardless of whether the LLM had retrieved the result, causing `get_subagent_result` to return "Agent not found" for agents that had finished but hadn't been checked yet. `clearCompleted()` now accepts a `skipUnconsumed` flag; session event handlers pass `true`, so records with `resultConsumed=false` are preserved across session transitions. The 10-minute cleanup timer handles eventual eviction. Note: a full session shutdown (`session_shutdown`) calls `dispose()` which clears all records unconditionally — that path is not affected by this fix.
14
+
15
+ ### Added
16
+ - **Foreground agent lifecycle completion and conversation logging** ([#105](https://github.com/tintinweb/pi-subagents/pull/105) — thanks [@benrhodeland](https://github.com/benrhodeland)). Two gaps closed: (1) **`onComplete` now fires for foreground agents**, emitting `subagents:completed` / `subagents:failed` lifecycle events and writing a `subagents:record` entry to the parent JSONL — previously only background agents emitted these, leaving cross-extension observers with an orphaned `subagents:started` event and no matching completion. `resultConsumed` is pre-set so the callback skips notifications (the result is returned inline); no change to the tool's return value. (2) **Foreground agent conversations are now streamed to `.output` files** (same `.pi/output/agent-<id>.jsonl` path as background agents) — inline subagent transcripts were previously permanently lost after `spawnAndWait` returned.
17
+
18
+ ## [0.10.3] - 2026-06-12
19
+
20
+ ### Added
21
+ - **`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).
22
+
10
23
  ## [0.10.2] - 2026-06-10
11
24
 
12
25
  ### Added
package/README.md CHANGED
@@ -124,7 +124,7 @@ Individual agent results render Claude Code-style in the conversation:
124
124
 
125
125
  Completed results can be expanded (ctrl+o in pi) to show the full agent output inline.
126
126
 
127
- Background agent completion notifications render as styled boxes:
127
+ Both foreground and background agents stream their full conversation to a `.pi/output/agent-<id>.jsonl` transcript file. Background agent completion notifications render as styled boxes:
128
128
 
129
129
  ```
130
130
  ✓ Find auth files completed
@@ -418,8 +418,8 @@ Agent lifecycle events are emitted via `pi.events.emit()` so other extensions ca
418
418
  |-------|------|------------|
419
419
  | `subagents:created` | Background agent registered | `id`, `type`, `description`, `isBackground` |
420
420
  | `subagents:started` | Agent transitions to running (including queued→running) | `id`, `type`, `description` |
421
- | `subagents:completed` | Agent finished successfully | `id`, `type`, `durationMs`, `tokens` (lifetime `{ input, output, total }`), `toolUses`, `result` |
422
- | `subagents:failed` | Agent errored, stopped, or aborted | same as completed + `error`, `status` |
421
+ | `subagents:completed` | Agent finished successfully (background and foreground) | `id`, `type`, `durationMs`, `tokens` (lifetime `{ input, output, total }`), `toolUses`, `result` |
422
+ | `subagents:failed` | Agent errored, stopped, or aborted (background and foreground) | same as completed + `error`, `status` |
423
423
  | `subagents:steered` | Steering message sent | `id`, `message` |
424
424
  | `subagents:compacted` | Agent's session successfully compacted | `id`, `type`, `description`, `reason` (`"manual"` / `"threshold"` / `"overflow"`), `tokensBefore`, `compactionCount` |
425
425
  | `subagents:scheduled` | Schedule lifecycle change | `{ type: "added" \| "removed" \| "updated" \| "fired" \| "error", … }` (job/agentId/error fields per type) |
@@ -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. */
@@ -77,11 +89,24 @@ export declare class AgentManager {
77
89
  private startAgent;
78
90
  /** Start queued agents up to the concurrency limit. */
79
91
  private drainQueue;
92
+ /**
93
+ * Called synchronously right after spawn, before onSessionCreated fires.
94
+ * Lets the caller set up the output file path on the record.
95
+ * The record is guaranteed to be in this.agents at this point.
96
+ */
97
+ private onSpawned?;
80
98
  /**
81
99
  * Spawn an agent and wait for completion (foreground use).
82
100
  * Foreground agents bypass the concurrency queue.
101
+ * Returns { id, record } so callers can access the agent ID.
102
+ *
103
+ * @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires.
104
+ * Use this to set record.outputFile so streamToOutputFile can pick it up.
83
105
  */
84
- spawnAndWait(pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: Omit<SpawnOptions, "isBackground">): Promise<AgentRecord>;
106
+ spawnAndWait(pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: Omit<SpawnOptions, "isBackground">, onSpawned?: (id: string) => void): Promise<{
107
+ id: string;
108
+ record: AgentRecord;
109
+ }>;
85
110
  /**
86
111
  * Resume an existing agent session with a new prompt.
87
112
  */
@@ -95,8 +120,10 @@ export declare class AgentManager {
95
120
  /**
96
121
  * Remove all completed/stopped/errored records immediately.
97
122
  * Called on session start/switch so tasks from a prior session don't persist.
123
+ * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
124
+ * (resultConsumed=false) — they will be evicted by the 10-minute cleanup timer instead.
98
125
  */
99
- clearCompleted(): void;
126
+ clearCompleted(skipUnconsumed?: boolean): void;
100
127
  /** Whether any agents are still running or queued. */
101
128
  hasRunning(): boolean;
102
129
  /** Abort all running and queued agents immediately. */
@@ -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,14 +215,26 @@ 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
- if (options.isBackground) {
228
+ // Fire onComplete for foreground agents too — lifecycle symmetry.
229
+ // Mark resultConsumed so the callback skips notifications (result returned inline).
230
+ if (!options.isBackground) {
231
+ record.resultConsumed = true;
232
+ try {
233
+ this.onComplete?.(record);
234
+ }
235
+ catch { /* ignore completion side-effect errors */ }
236
+ }
237
+ else {
173
238
  this.runningBackground--;
174
239
  try {
175
240
  this.onComplete?.(record);
@@ -198,12 +263,18 @@ export class AgentManager {
198
263
  // Best-effort worktree cleanup on error
199
264
  if (record.worktree) {
200
265
  try {
201
- const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
266
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
202
267
  record.worktreeResult = wtResult;
203
268
  }
204
269
  catch { /* ignore cleanup errors */ }
205
270
  }
206
- if (options.isBackground) {
271
+ // Fire onComplete for foreground agents too — lifecycle symmetry.
272
+ // Mark resultConsumed so the callback skips notifications (result returned inline).
273
+ if (!options.isBackground) {
274
+ record.resultConsumed = true;
275
+ this.onComplete?.(record);
276
+ }
277
+ else {
207
278
  this.runningBackground--;
208
279
  this.onComplete?.(record);
209
280
  this.drainQueue();
@@ -211,6 +282,10 @@ export class AgentManager {
211
282
  return "";
212
283
  });
213
284
  record.promise = promise;
285
+ // Notify caller that spawn is complete (record is in the map, promise is set).
286
+ // Called synchronously — onSessionCreated fires asynchronously inside runAgent.
287
+ // Used by spawnAndWait to let the caller set up output files before streaming starts.
288
+ this.onSpawned?.(id);
214
289
  }
215
290
  /** Start queued agents up to the concurrency limit. */
216
291
  drainQueue() {
@@ -232,15 +307,33 @@ export class AgentManager {
232
307
  }
233
308
  }
234
309
  }
310
+ /**
311
+ * Called synchronously right after spawn, before onSessionCreated fires.
312
+ * Lets the caller set up the output file path on the record.
313
+ * The record is guaranteed to be in this.agents at this point.
314
+ */
315
+ onSpawned;
235
316
  /**
236
317
  * Spawn an agent and wait for completion (foreground use).
237
318
  * Foreground agents bypass the concurrency queue.
319
+ * Returns { id, record } so callers can access the agent ID.
320
+ *
321
+ * @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires.
322
+ * Use this to set record.outputFile so streamToOutputFile can pick it up.
238
323
  */
239
- async spawnAndWait(pi, ctx, type, prompt, options) {
240
- const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
241
- const record = this.agents.get(id);
242
- await record.promise;
243
- return record;
324
+ async spawnAndWait(pi, ctx, type, prompt, options, onSpawned) {
325
+ // Temporarily register the onSpawned hook so startAgent can call it.
326
+ const prevOnSpawned = this.onSpawned;
327
+ this.onSpawned = onSpawned;
328
+ try {
329
+ const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
330
+ const record = this.agents.get(id);
331
+ await record.promise;
332
+ return { id, record };
333
+ }
334
+ finally {
335
+ this.onSpawned = prevOnSpawned;
336
+ }
244
337
  }
245
338
  /**
246
339
  * Resume an existing agent session with a new prompt.
@@ -323,11 +416,15 @@ export class AgentManager {
323
416
  /**
324
417
  * Remove all completed/stopped/errored records immediately.
325
418
  * Called on session start/switch so tasks from a prior session don't persist.
419
+ * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
420
+ * (resultConsumed=false) — they will be evicted by the 10-minute cleanup timer instead.
326
421
  */
327
- clearCompleted() {
422
+ clearCompleted(skipUnconsumed = false) {
328
423
  for (const [id, record] of this.agents) {
329
424
  if (record.status === "running" || record.status === "queued")
330
425
  continue;
426
+ if (skipUnconsumed && !record.resultConsumed)
427
+ continue;
331
428
  this.removeRecord(id, record);
332
429
  }
333
430
  }
@@ -387,5 +484,13 @@ export class AgentManager {
387
484
  pruneWorktrees(process.cwd());
388
485
  }
389
486
  catch { /* ignore */ }
487
+ // Also prune repos that caller-supplied cwds created worktrees in — a clean
488
+ // exit with in-flight agents would otherwise leave stale registrations there.
489
+ for (const repo of this.worktreeRepos) {
490
+ try {
491
+ pruneWorktrees(repo);
492
+ }
493
+ catch { /* ignore */ }
494
+ }
390
495
  }
391
496
  }
@@ -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/index.js CHANGED
@@ -406,12 +406,12 @@ export default function (pi) {
406
406
  // Capture ctx from session_start for RPC spawn handler + start the scheduler.
407
407
  pi.on("session_start", async (_event, ctx) => {
408
408
  currentCtx = ctx;
409
- manager.clearCompleted();
409
+ manager.clearCompleted(true);
410
410
  if (isSchedulingEnabled() && !scheduler.isActive())
411
411
  startScheduler(ctx);
412
412
  });
413
413
  pi.on("session_before_switch", () => {
414
- manager.clearCompleted();
414
+ manager.clearCompleted(true);
415
415
  scheduler.stop();
416
416
  });
417
417
  const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc, unsubStop: unsubStopRpc } = registerRpcHandlers({
@@ -1065,7 +1065,9 @@ Terse command-style prompts produce shallow, generic work.
1065
1065
  });
1066
1066
  };
1067
1067
  const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
1068
- // Wire session creation to register in widget
1068
+ // Wire session creation: register in widget + stream to output file.
1069
+ // The output file path is set synchronously after spawn (below),
1070
+ // before onSessionCreated fires — same pattern as background agents.
1069
1071
  const origOnSession = fgCallbacks.onSessionCreated;
1070
1072
  fgCallbacks.onSessionCreated = (session) => {
1071
1073
  origOnSession(session);
@@ -1077,6 +1079,13 @@ Terse command-style prompts produce shallow, generic work.
1077
1079
  break;
1078
1080
  }
1079
1081
  }
1082
+ // Stream conversation to output file (foreground agent logging)
1083
+ if (fgId) {
1084
+ const rec = manager.getRecord(fgId);
1085
+ if (rec?.outputFile) {
1086
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
1087
+ }
1088
+ }
1080
1089
  };
1081
1090
  // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
1082
1091
  const spinnerInterval = setInterval(() => {
@@ -1086,7 +1095,7 @@ Terse command-style prompts produce shallow, generic work.
1086
1095
  streamUpdate();
1087
1096
  let record;
1088
1097
  try {
1089
- record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1098
+ const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1090
1099
  description: params.description,
1091
1100
  model,
1092
1101
  maxTurns: effectiveMaxTurns,
@@ -1097,7 +1106,16 @@ Terse command-style prompts produce shallow, generic work.
1097
1106
  invocation: agentInvocation,
1098
1107
  signal,
1099
1108
  ...fgCallbacks,
1109
+ }, (fgAgentId) => {
1110
+ // onSpawned: called synchronously after spawn, before onSessionCreated fires.
1111
+ // Set up the output file so streamToOutputFile can pick it up.
1112
+ const fgRec = manager.getRecord(fgAgentId);
1113
+ if (fgRec) {
1114
+ fgRec.outputFile = createOutputFilePath(ctx.cwd, fgAgentId, ctx.sessionManager.getSessionId());
1115
+ writeInitialEntry(fgRec.outputFile, fgAgentId, params.prompt, ctx.cwd);
1116
+ }
1100
1117
  });
1118
+ record = fgResult.record;
1101
1119
  }
1102
1120
  catch (err) {
1103
1121
  clearInterval(spinnerInterval);
@@ -1671,7 +1689,7 @@ Guidelines for choosing settings:
1671
1689
  - Only include frontmatter fields that differ from defaults — omit fields where the default is fine
1672
1690
 
1673
1691
  Write the file using the write tool. Only write the file, nothing else.`;
1674
- const record = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
1692
+ const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
1675
1693
  description: `Generate ${name} agent`,
1676
1694
  maxTurns: 5,
1677
1695
  });
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.4",
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,15 +300,23 @@ 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
 
248
- if (options.isBackground) {
314
+ // Fire onComplete for foreground agents too — lifecycle symmetry.
315
+ // Mark resultConsumed so the callback skips notifications (result returned inline).
316
+ if (!options.isBackground) {
317
+ record.resultConsumed = true;
318
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
319
+ } else {
249
320
  this.runningBackground--;
250
321
  try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
251
322
  this.drainQueue();
@@ -271,12 +342,17 @@ export class AgentManager {
271
342
  // Best-effort worktree cleanup on error
272
343
  if (record.worktree) {
273
344
  try {
274
- const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
345
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
275
346
  record.worktreeResult = wtResult;
276
347
  } catch { /* ignore cleanup errors */ }
277
348
  }
278
349
 
279
- if (options.isBackground) {
350
+ // Fire onComplete for foreground agents too — lifecycle symmetry.
351
+ // Mark resultConsumed so the callback skips notifications (result returned inline).
352
+ if (!options.isBackground) {
353
+ record.resultConsumed = true;
354
+ this.onComplete?.(record);
355
+ } else {
280
356
  this.runningBackground--;
281
357
  this.onComplete?.(record);
282
358
  this.drainQueue();
@@ -285,6 +361,11 @@ export class AgentManager {
285
361
  });
286
362
 
287
363
  record.promise = promise;
364
+
365
+ // Notify caller that spawn is complete (record is in the map, promise is set).
366
+ // Called synchronously — onSessionCreated fires asynchronously inside runAgent.
367
+ // Used by spawnAndWait to let the caller set up output files before streaming starts.
368
+ this.onSpawned?.(id);
288
369
  }
289
370
 
290
371
  /** Start queued agents up to the concurrency limit. */
@@ -306,9 +387,20 @@ export class AgentManager {
306
387
  }
307
388
  }
308
389
 
390
+ /**
391
+ * Called synchronously right after spawn, before onSessionCreated fires.
392
+ * Lets the caller set up the output file path on the record.
393
+ * The record is guaranteed to be in this.agents at this point.
394
+ */
395
+ private onSpawned?: (id: string) => void;
396
+
309
397
  /**
310
398
  * Spawn an agent and wait for completion (foreground use).
311
399
  * Foreground agents bypass the concurrency queue.
400
+ * Returns { id, record } so callers can access the agent ID.
401
+ *
402
+ * @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires.
403
+ * Use this to set record.outputFile so streamToOutputFile can pick it up.
312
404
  */
313
405
  async spawnAndWait(
314
406
  pi: ExtensionAPI,
@@ -316,11 +408,19 @@ export class AgentManager {
316
408
  type: SubagentType,
317
409
  prompt: string,
318
410
  options: Omit<SpawnOptions, "isBackground">,
319
- ): Promise<AgentRecord> {
320
- const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
321
- const record = this.agents.get(id)!;
322
- await record.promise;
323
- return record;
411
+ onSpawned?: (id: string) => void,
412
+ ): Promise<{ id: string; record: AgentRecord }> {
413
+ // Temporarily register the onSpawned hook so startAgent can call it.
414
+ const prevOnSpawned = this.onSpawned;
415
+ this.onSpawned = onSpawned;
416
+ try {
417
+ const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
418
+ const record = this.agents.get(id)!;
419
+ await record.promise;
420
+ return { id, record };
421
+ } finally {
422
+ this.onSpawned = prevOnSpawned;
423
+ }
324
424
  }
325
425
 
326
426
  /**
@@ -414,10 +514,13 @@ export class AgentManager {
414
514
  /**
415
515
  * Remove all completed/stopped/errored records immediately.
416
516
  * Called on session start/switch so tasks from a prior session don't persist.
517
+ * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
518
+ * (resultConsumed=false) — they will be evicted by the 10-minute cleanup timer instead.
417
519
  */
418
- clearCompleted(): void {
520
+ clearCompleted(skipUnconsumed = false): void {
419
521
  for (const [id, record] of this.agents) {
420
522
  if (record.status === "running" || record.status === "queued") continue;
523
+ if (skipUnconsumed && !record.resultConsumed) continue;
421
524
  this.removeRecord(id, record);
422
525
  }
423
526
  }
@@ -479,5 +582,10 @@ export class AgentManager {
479
582
  this.agents.clear();
480
583
  // Prune any orphaned git worktrees (crash recovery)
481
584
  try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
585
+ // Also prune repos that caller-supplied cwds created worktrees in — a clean
586
+ // exit with in-flight agents would otherwise leave stale registrations there.
587
+ for (const repo of this.worktreeRepos) {
588
+ try { pruneWorktrees(repo); } catch { /* ignore */ }
589
+ }
482
590
  }
483
591
  }
@@ -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/index.ts CHANGED
@@ -458,12 +458,12 @@ export default function (pi: ExtensionAPI) {
458
458
  // Capture ctx from session_start for RPC spawn handler + start the scheduler.
459
459
  pi.on("session_start", async (_event, ctx) => {
460
460
  currentCtx = ctx;
461
- manager.clearCompleted();
461
+ manager.clearCompleted(true);
462
462
  if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
463
463
  });
464
464
 
465
465
  pi.on("session_before_switch", () => {
466
- manager.clearCompleted();
466
+ manager.clearCompleted(true);
467
467
  scheduler.stop();
468
468
  });
469
469
 
@@ -1200,7 +1200,9 @@ Terse command-style prompts produce shallow, generic work.
1200
1200
 
1201
1201
  const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
1202
1202
 
1203
- // Wire session creation to register in widget
1203
+ // Wire session creation: register in widget + stream to output file.
1204
+ // The output file path is set synchronously after spawn (below),
1205
+ // before onSessionCreated fires — same pattern as background agents.
1204
1206
  const origOnSession = fgCallbacks.onSessionCreated;
1205
1207
  fgCallbacks.onSessionCreated = (session: any) => {
1206
1208
  origOnSession(session);
@@ -1212,6 +1214,13 @@ Terse command-style prompts produce shallow, generic work.
1212
1214
  break;
1213
1215
  }
1214
1216
  }
1217
+ // Stream conversation to output file (foreground agent logging)
1218
+ if (fgId) {
1219
+ const rec = manager.getRecord(fgId);
1220
+ if (rec?.outputFile) {
1221
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
1222
+ }
1223
+ }
1215
1224
  };
1216
1225
 
1217
1226
  // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
@@ -1224,7 +1233,7 @@ Terse command-style prompts produce shallow, generic work.
1224
1233
 
1225
1234
  let record: AgentRecord;
1226
1235
  try {
1227
- record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1236
+ const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1228
1237
  description: params.description,
1229
1238
  model,
1230
1239
  maxTurns: effectiveMaxTurns,
@@ -1235,7 +1244,16 @@ Terse command-style prompts produce shallow, generic work.
1235
1244
  invocation: agentInvocation,
1236
1245
  signal,
1237
1246
  ...fgCallbacks,
1247
+ }, (fgAgentId) => {
1248
+ // onSpawned: called synchronously after spawn, before onSessionCreated fires.
1249
+ // Set up the output file so streamToOutputFile can pick it up.
1250
+ const fgRec = manager.getRecord(fgAgentId);
1251
+ if (fgRec) {
1252
+ fgRec.outputFile = createOutputFilePath(ctx.cwd, fgAgentId, ctx.sessionManager.getSessionId());
1253
+ writeInitialEntry(fgRec.outputFile, fgAgentId, params.prompt, ctx.cwd);
1254
+ }
1238
1255
  });
1256
+ record = fgResult.record;
1239
1257
  } catch (err) {
1240
1258
  clearInterval(spinnerInterval);
1241
1259
  return textResult(err instanceof Error ? err.message : String(err));
@@ -1838,7 +1856,7 @@ Guidelines for choosing settings:
1838
1856
 
1839
1857
  Write the file using the write tool. Only write the file, nothing else.`;
1840
1858
 
1841
- const record = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
1859
+ const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
1842
1860
  description: `Generate ${name} agent`,
1843
1861
  maxTurns: 5,
1844
1862
  });
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;