@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 +13 -0
- package/README.md +5 -3
- package/dist/agent-manager.d.ts +29 -2
- package/dist/agent-manager.js +119 -14
- package/dist/agent-runner.d.ts +14 -0
- package/dist/agent-runner.js +9 -6
- package/dist/index.js +23 -5
- package/dist/types.d.ts +1 -0
- package/dist/worktree.d.ts +8 -1
- package/dist/worktree.js +12 -3
- package/package.json +1 -1
- package/src/agent-manager.ts +122 -14
- package/src/agent-runner.ts +23 -6
- package/src/index.ts +23 -5
- package/src/types.ts +1 -1
- package/src/worktree.ts +20 -4
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:
|
package/dist/agent-manager.d.ts
CHANGED
|
@@ -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"
|
|
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. */
|
package/dist/agent-manager.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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}
|
|
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
|
+
// 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(
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
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
|
}
|
package/dist/agent-runner.d.ts
CHANGED
|
@@ -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. */
|
package/dist/agent-runner.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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:
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
package/dist/worktree.d.ts
CHANGED
|
@@ -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
package/src/agent-manager.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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}
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
}
|
package/src/agent-runner.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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:
|
|
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(
|
|
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
|
|
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
|
-
|
|
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;
|