@tintinweb/pi-subagents 0.10.2 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +2 -0
- package/dist/agent-manager.d.ts +12 -0
- package/dist/agent-manager.js +70 -6
- package/dist/agent-runner.d.ts +14 -0
- package/dist/agent-runner.js +9 -6
- 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 +77 -6
- package/src/agent-runner.ts +23 -6
- package/src/types.ts +1 -1
- package/src/worktree.ts +20 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.3] - 2026-06-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`SpawnOptions.cwd` — spawn a subagent in a different working directory** ([#96](https://github.com/tintinweb/pi-subagents/issues/96) — thanks [@madeleineostoja](https://github.com/madeleineostoja)). For RPC/programmatic callers (not exposed on the `Agent` tool — the LLM-visible surface is unchanged). The agent's tools operate in the target directory and the prompt's environment block describes it, but **`.pi` config keeps loading from the parent session's project** (new `RunOptions.configCwd` split): the target's `.pi` extensions never execute, and its agents/skills/settings/memory are not picked up — spawning into an untrusted directory sends a worker there with the parent's toolbox, rather than "opening pi there." Composes with `isolation: "worktree"`: the worktree is created *from* the target directory's repo, the agent works at the equivalent subdirectory inside the copy (a monorepo-package cwd keeps its scoping instead of silently widening to the repo root — new `WorktreeInfo.workPath`), and the resulting `pi-agent-*` branch lands in that repo, with the completion message naming it so the orchestrator merges in the right place. Validation is strict, typed, and early — non-strings, relative paths, nonexistent paths, and files all throw curated errors at `spawn()` (before queueing) and are re-checked at queue drain, surfacing as RPC error envelopes (`null` is treated as unset). On dispose, worktree registrations are pruned in every repo that received one; only a hard crash can leave a stale entry (then: `git worktree prune` in the target repo).
|
|
14
|
+
|
|
10
15
|
## [0.10.2] - 2026-06-10
|
|
11
16
|
|
|
12
17
|
### Added
|
package/README.md
CHANGED
|
@@ -483,6 +483,8 @@ pi.events.emit("subagents:rpc:spawn", {
|
|
|
483
483
|
|
|
484
484
|
`options.model` accepts either a `Model` object (e.g. `ctx.model`) or a `"provider/modelId"` string — strings are resolved against `ctx.modelRegistry` at the RPC boundary, so cross-extension callers can forward serializable values without losing auth context.
|
|
485
485
|
|
|
486
|
+
`options.cwd` (absolute path to an existing directory — anything else returns an error envelope; `null` means unset) runs the agent in a different working directory than the parent session. Its tools operate there and the prompt's environment block describes it, but **`.pi` config still loads from the parent session's project** — the target directory's `.pi` extensions never execute, and its agents/skills/settings are not picked up. Combined with `isolation: "worktree"`, the worktree is created *from* the target directory's repo, the agent works at the equivalent subdirectory inside the copy (a monorepo-package cwd stays scoped to that package), and the resulting `pi-agent-*` branch lands in that repo — the completion message names it. On session end, worktree registrations are pruned in every repo that received one; only a hard crash can leave a stale entry (then: `git worktree prune` in the target repo). Agents with `memory:` keep reading/writing the parent project's memory.
|
|
487
|
+
|
|
486
488
|
### Stop
|
|
487
489
|
|
|
488
490
|
Stop a running agent by ID:
|
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. */
|
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,11 +215,14 @@ 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
|
if (options.isBackground) {
|
|
@@ -198,7 +254,7 @@ export class AgentManager {
|
|
|
198
254
|
// Best-effort worktree cleanup on error
|
|
199
255
|
if (record.worktree) {
|
|
200
256
|
try {
|
|
201
|
-
const wtResult = cleanupWorktree(
|
|
257
|
+
const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
|
|
202
258
|
record.worktreeResult = wtResult;
|
|
203
259
|
}
|
|
204
260
|
catch { /* ignore cleanup errors */ }
|
|
@@ -387,5 +443,13 @@ export class AgentManager {
|
|
|
387
443
|
pruneWorktrees(process.cwd());
|
|
388
444
|
}
|
|
389
445
|
catch { /* ignore */ }
|
|
446
|
+
// Also prune repos that caller-supplied cwds created worktrees in — a clean
|
|
447
|
+
// exit with in-flight agents would otherwise leave stale registrations there.
|
|
448
|
+
for (const repo of this.worktreeRepos) {
|
|
449
|
+
try {
|
|
450
|
+
pruneWorktrees(repo);
|
|
451
|
+
}
|
|
452
|
+
catch { /* ignore */ }
|
|
453
|
+
}
|
|
390
454
|
}
|
|
391
455
|
}
|
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/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,11 +300,14 @@ 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
|
|
|
@@ -271,7 +337,7 @@ export class AgentManager {
|
|
|
271
337
|
// Best-effort worktree cleanup on error
|
|
272
338
|
if (record.worktree) {
|
|
273
339
|
try {
|
|
274
|
-
const wtResult = cleanupWorktree(
|
|
340
|
+
const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
|
|
275
341
|
record.worktreeResult = wtResult;
|
|
276
342
|
} catch { /* ignore cleanup errors */ }
|
|
277
343
|
}
|
|
@@ -479,5 +545,10 @@ export class AgentManager {
|
|
|
479
545
|
this.agents.clear();
|
|
480
546
|
// Prune any orphaned git worktrees (crash recovery)
|
|
481
547
|
try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
|
|
548
|
+
// Also prune repos that caller-supplied cwds created worktrees in — a clean
|
|
549
|
+
// exit with in-flight agents would otherwise leave stale registrations there.
|
|
550
|
+
for (const repo of this.worktreeRepos) {
|
|
551
|
+
try { pruneWorktrees(repo); } catch { /* ignore */ }
|
|
552
|
+
}
|
|
482
553
|
}
|
|
483
554
|
}
|
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/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;
|