@ilya-lesikov/pi-pi 0.8.0 → 0.10.0
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/3p/pi-ask-user/index.ts +71 -124
- package/3p/pi-ask-user/single-select-layout.ts +3 -14
- package/3p/pi-subagents/package.json +13 -7
- package/3p/pi-subagents/src/agent-manager.ts +243 -79
- package/3p/pi-subagents/src/agent-runner.ts +501 -365
- package/3p/pi-subagents/src/agent-types.ts +46 -57
- package/3p/pi-subagents/src/cross-extension-rpc.ts +52 -46
- package/3p/pi-subagents/src/custom-agents.ts +30 -6
- package/3p/pi-subagents/src/default-agents.ts +25 -43
- package/3p/pi-subagents/src/enabled-models.ts +180 -0
- package/3p/pi-subagents/src/index.ts +599 -839
- package/3p/pi-subagents/src/model-resolver.ts +26 -7
- package/3p/pi-subagents/src/output-file.ts +21 -2
- package/3p/pi-subagents/src/prompts.ts +17 -3
- package/3p/pi-subagents/src/schedule-store.ts +153 -0
- package/3p/pi-subagents/src/schedule.ts +365 -0
- package/3p/pi-subagents/src/settings.ts +261 -0
- package/3p/pi-subagents/src/skill-loader.ts +77 -54
- package/3p/pi-subagents/src/status-note.ts +25 -0
- package/3p/pi-subagents/src/types.ts +97 -6
- package/3p/pi-subagents/src/ui/agent-widget.ts +94 -21
- package/3p/pi-subagents/src/ui/conversation-viewer.ts +147 -35
- package/3p/pi-subagents/src/ui/schedule-menu.ts +104 -0
- package/3p/pi-subagents/src/ui/viewer-keys.ts +39 -0
- package/3p/pi-subagents/src/usage.ts +60 -0
- package/3p/pi-subagents/src/worktree.ts +49 -20
- package/extensions/orchestrator/agents/advisor.ts +13 -8
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +8 -3
- package/extensions/orchestrator/agents/code-reviewer.ts +29 -8
- package/extensions/orchestrator/agents/constraints.test.ts +65 -1
- package/extensions/orchestrator/agents/constraints.ts +73 -2
- package/extensions/orchestrator/agents/deep-debugger.ts +13 -8
- package/extensions/orchestrator/agents/explore.ts +12 -6
- package/extensions/orchestrator/agents/librarian.ts +13 -5
- package/extensions/orchestrator/agents/plan-reviewer.ts +8 -3
- package/extensions/orchestrator/agents/planner.ts +8 -3
- package/extensions/orchestrator/agents/pools.test.ts +56 -0
- package/extensions/orchestrator/agents/prompts.test.ts +52 -10
- package/extensions/orchestrator/agents/registry.test.ts +245 -0
- package/extensions/orchestrator/agents/registry.ts +145 -10
- package/extensions/orchestrator/agents/reviewer.ts +14 -8
- package/extensions/orchestrator/agents/task.ts +12 -6
- package/extensions/orchestrator/agents/tool-routing.ts +213 -69
- package/extensions/orchestrator/agents/wait-for-completion.test.ts +171 -0
- package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
- package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
- package/extensions/orchestrator/ast-search.test.ts +124 -0
- package/extensions/orchestrator/billing-spoof.test.ts +67 -0
- package/extensions/orchestrator/billing-spoof.ts +95 -0
- package/extensions/orchestrator/cbm.more.test.ts +358 -0
- package/extensions/orchestrator/command-handlers.test.ts +47 -0
- package/extensions/orchestrator/command-handlers.ts +49 -27
- package/extensions/orchestrator/commands.test.ts +15 -2
- package/extensions/orchestrator/commands.ts +1 -1
- package/extensions/orchestrator/config.test.ts +129 -2
- package/extensions/orchestrator/config.ts +115 -19
- package/extensions/orchestrator/context.test.ts +46 -0
- package/extensions/orchestrator/context.ts +34 -5
- package/extensions/orchestrator/custom-footer.test.ts +105 -0
- package/extensions/orchestrator/custom-footer.ts +22 -20
- package/extensions/orchestrator/doctor.more.test.ts +315 -0
- package/extensions/orchestrator/doctor.ts +6 -2
- package/extensions/orchestrator/event-handlers.more.test.ts +561 -0
- package/extensions/orchestrator/event-handlers.test.ts +411 -10
- package/extensions/orchestrator/event-handlers.ts +738 -349
- package/extensions/orchestrator/exa.more.test.ts +118 -0
- package/extensions/orchestrator/flant-infra.more.test.ts +326 -0
- package/extensions/orchestrator/flant-infra.test.ts +127 -0
- package/extensions/orchestrator/flant-infra.ts +113 -39
- package/extensions/orchestrator/index.test.ts +76 -0
- package/extensions/orchestrator/index.ts +2 -0
- package/extensions/orchestrator/integration.test.ts +488 -74
- package/extensions/orchestrator/model-registry.test.ts +2 -1
- package/extensions/orchestrator/model-registry.ts +12 -2
- package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/extensions/orchestrator/orchestrator.test.ts +67 -0
- package/extensions/orchestrator/orchestrator.ts +165 -85
- package/extensions/orchestrator/phases/brainstorm.test.ts +30 -1
- package/extensions/orchestrator/phases/brainstorm.ts +53 -12
- package/extensions/orchestrator/phases/implementation.ts +2 -0
- package/extensions/orchestrator/phases/machine.test.ts +53 -1
- package/extensions/orchestrator/phases/machine.ts +15 -2
- package/extensions/orchestrator/phases/planning.test.ts +47 -1
- package/extensions/orchestrator/phases/planning.ts +18 -3
- package/extensions/orchestrator/phases/review-task.test.ts +20 -0
- package/extensions/orchestrator/phases/review-task.ts +47 -14
- package/extensions/orchestrator/phases/review.test.ts +36 -0
- package/extensions/orchestrator/phases/review.ts +62 -10
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +1 -0
- package/extensions/orchestrator/phases/verdict.ts +6 -5
- package/extensions/orchestrator/plannotator-open-failure.test.ts +67 -0
- package/extensions/orchestrator/plannotator.test.ts +38 -1
- package/extensions/orchestrator/plannotator.ts +50 -3
- package/extensions/orchestrator/pp-menu.leaves.test.ts +590 -0
- package/extensions/orchestrator/pp-menu.more.test.ts +524 -0
- package/extensions/orchestrator/pp-menu.test.ts +314 -1
- package/extensions/orchestrator/pp-menu.ts +937 -450
- package/extensions/orchestrator/pp-state-tools.test.ts +80 -0
- package/extensions/orchestrator/pp-state-tools.ts +65 -0
- package/extensions/orchestrator/rate-limit-fallback.more.test.ts +241 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +20 -1
- package/extensions/orchestrator/rate-limit-fallback.ts +12 -0
- package/extensions/orchestrator/review-files.test.ts +26 -0
- package/extensions/orchestrator/review-files.ts +3 -0
- package/extensions/orchestrator/state.test.ts +82 -1
- package/extensions/orchestrator/state.ts +110 -23
- package/extensions/orchestrator/suppress-pierre-theme-spam.test.ts +56 -0
- package/extensions/orchestrator/suppress-pierre-theme-spam.ts +45 -0
- package/extensions/orchestrator/transition-controller.test.ts +100 -0
- package/extensions/orchestrator/transition-controller.ts +61 -3
- package/extensions/orchestrator/validate-artifacts.ts +1 -1
- package/package.json +6 -2
- package/scripts/test-3p.sh +52 -0
- package/AGENTS.md +0 -28
|
@@ -7,18 +7,45 @@
|
|
|
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";
|
|
13
|
-
import type { AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
|
|
15
|
+
import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
|
|
16
|
+
import { addUsage } from "./usage.js";
|
|
14
17
|
import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
|
|
15
18
|
|
|
16
19
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
17
20
|
export type OnAgentStart = (record: AgentRecord) => void;
|
|
21
|
+
export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
|
|
22
|
+
export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
|
|
18
23
|
|
|
19
24
|
/** Default max concurrent background agents. */
|
|
20
25
|
const DEFAULT_MAX_CONCURRENT = 4;
|
|
21
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
|
+
|
|
22
49
|
interface SpawnArgs {
|
|
23
50
|
pi: ExtensionAPI;
|
|
24
51
|
ctx: ExtensionContext;
|
|
@@ -35,8 +62,27 @@ interface SpawnOptions {
|
|
|
35
62
|
inheritContext?: boolean;
|
|
36
63
|
thinkingLevel?: ThinkingLevel;
|
|
37
64
|
isBackground?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Skip the maxConcurrent queue check for this spawn — start immediately even
|
|
67
|
+
* if the configured concurrency limit would otherwise queue it. Used by the
|
|
68
|
+
* scheduler so a fired job can't be deferred past its trigger window.
|
|
69
|
+
*/
|
|
70
|
+
bypassQueue?: boolean;
|
|
38
71
|
/** Isolation mode — "worktree" creates a temp git worktree for the agent. */
|
|
39
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;
|
|
82
|
+
/** Resolved invocation snapshot captured for UI display. */
|
|
83
|
+
invocation?: AgentInvocation;
|
|
84
|
+
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
85
|
+
signal?: AbortSignal;
|
|
40
86
|
/** Called on tool start/end with activity info (for streaming progress to UI). */
|
|
41
87
|
onToolActivity?: (activity: ToolActivity) => void;
|
|
42
88
|
/** Called on streaming text deltas from the assistant response. */
|
|
@@ -45,10 +91,10 @@ interface SpawnOptions {
|
|
|
45
91
|
onSessionCreated?: (session: AgentSession) => void;
|
|
46
92
|
/** Called at the end of each agentic turn with the cumulative count. */
|
|
47
93
|
onTurnEnd?: (turnCount: number) => void;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
|
|
94
|
+
/** Called once per assistant message_end with that message's usage delta. */
|
|
95
|
+
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
|
96
|
+
/** Called when the session successfully compacts. */
|
|
97
|
+
onCompaction?: (info: CompactionInfo) => void;
|
|
52
98
|
}
|
|
53
99
|
|
|
54
100
|
export class AgentManager {
|
|
@@ -56,19 +102,30 @@ export class AgentManager {
|
|
|
56
102
|
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
57
103
|
private onComplete?: OnAgentComplete;
|
|
58
104
|
private onStart?: OnAgentStart;
|
|
105
|
+
private onCompact?: OnAgentCompact;
|
|
59
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>();
|
|
60
110
|
|
|
61
111
|
/** Queue of background agents waiting to start. */
|
|
62
112
|
private queue: { id: string; args: SpawnArgs }[] = [];
|
|
63
113
|
/** Number of currently running background agents. */
|
|
64
114
|
private runningBackground = 0;
|
|
65
115
|
|
|
66
|
-
constructor(
|
|
116
|
+
constructor(
|
|
117
|
+
onComplete?: OnAgentComplete,
|
|
118
|
+
maxConcurrent = DEFAULT_MAX_CONCURRENT,
|
|
119
|
+
onStart?: OnAgentStart,
|
|
120
|
+
onCompact?: OnAgentCompact,
|
|
121
|
+
) {
|
|
67
122
|
this.onComplete = onComplete;
|
|
68
123
|
this.onStart = onStart;
|
|
124
|
+
this.onCompact = onCompact;
|
|
69
125
|
this.maxConcurrent = maxConcurrent;
|
|
70
126
|
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
|
71
127
|
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
|
128
|
+
this.cleanupInterval.unref();
|
|
72
129
|
}
|
|
73
130
|
|
|
74
131
|
/** Update the max concurrent background agents limit. */
|
|
@@ -93,6 +150,11 @@ export class AgentManager {
|
|
|
93
150
|
prompt: string,
|
|
94
151
|
options: SpawnOptions,
|
|
95
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
|
+
|
|
96
158
|
const id = randomUUID().slice(0, 17);
|
|
97
159
|
const abortController = new AbortController();
|
|
98
160
|
const record: AgentRecord = {
|
|
@@ -103,99 +165,116 @@ export class AgentManager {
|
|
|
103
165
|
toolUses: 0,
|
|
104
166
|
startedAt: Date.now(),
|
|
105
167
|
abortController,
|
|
168
|
+
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
169
|
+
compactionCount: 0,
|
|
170
|
+
// Raw tri-state (not coerced to a boolean): true = background, false =
|
|
171
|
+
// foreground (has an inline tool-result surface), undefined = caller never
|
|
172
|
+
// declared it (e.g. a cross-extension RPC spawn). The widget's background-
|
|
173
|
+
// only filter excludes only explicit `false`, so undefined agents — which
|
|
174
|
+
// have no inline surface — stay visible instead of vanishing.
|
|
175
|
+
isBackground: options.isBackground,
|
|
176
|
+
invocation: options.invocation,
|
|
106
177
|
};
|
|
107
178
|
this.agents.set(id, record);
|
|
108
179
|
|
|
109
180
|
const args: SpawnArgs = { pi, ctx, type, prompt, options };
|
|
110
181
|
|
|
111
|
-
if (options.isBackground && this.runningBackground >= this.maxConcurrent) {
|
|
182
|
+
if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
|
|
112
183
|
// Queue it — will be started when a running agent completes
|
|
113
184
|
this.queue.push({ id, args });
|
|
114
185
|
return id;
|
|
115
186
|
}
|
|
116
187
|
|
|
117
|
-
|
|
188
|
+
// startAgent can throw (e.g. strict worktree-isolation failure) — clean
|
|
189
|
+
// up the record so callers don't see an orphan in `listAgents()`.
|
|
190
|
+
try {
|
|
191
|
+
this.startAgent(id, record, args);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
this.agents.delete(id);
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
118
196
|
return id;
|
|
119
197
|
}
|
|
120
198
|
|
|
121
199
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
122
200
|
private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
|
|
201
|
+
// Re-validate a caller-supplied cwd: queued spawns can start minutes after
|
|
202
|
+
// spawn()'s check, and the directory may be gone by then (TOCTOU). Same
|
|
203
|
+
// curated errors; drainQueue parks a throw on the record as an error.
|
|
204
|
+
assertValidSpawnCwd(options.cwd);
|
|
205
|
+
// Single resolution point for the caller-supplied cwd — the worktree base
|
|
206
|
+
// repo and both cleanup calls below MUST agree on this value forever.
|
|
207
|
+
const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
|
|
208
|
+
const baseCwd = customCwd ?? ctx.cwd;
|
|
209
|
+
|
|
210
|
+
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
211
|
+
// fail loud if not possible (no silent fallback to main tree). Done
|
|
212
|
+
// BEFORE state mutation so a throw doesn't leave the record half-running.
|
|
213
|
+
let worktreeCwd: string | undefined;
|
|
214
|
+
if (options.isolation === "worktree") {
|
|
215
|
+
const wt = createWorktree(baseCwd, id);
|
|
216
|
+
if (!wt) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
219
|
+
'Initialize git and commit at least once, or omit `isolation`.',
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
record.worktree = wt;
|
|
223
|
+
// workPath preserves subdirectory scoping for caller-supplied cwds: a
|
|
224
|
+
// cwd deep in a monorepo maps to the same subdir inside the copy, not
|
|
225
|
+
// the copied repo's root. Plain worktree spawns keep the historical
|
|
226
|
+
// behavior (agent at the copy's root) — moving them to workPath would
|
|
227
|
+
// also move .pi config discovery when the parent session sits in a repo
|
|
228
|
+
// subdirectory, silently dropping extensions/skills.
|
|
229
|
+
worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
|
|
230
|
+
this.worktreeRepos.add(baseCwd);
|
|
231
|
+
}
|
|
232
|
+
|
|
123
233
|
record.status = "running";
|
|
124
234
|
record.startedAt = Date.now();
|
|
125
|
-
if (options.toolCallId && !record.toolCallId) record.toolCallId = options.toolCallId;
|
|
126
|
-
// Reset first-progress flags: a re-started (e.g. dequeued) record must be able
|
|
127
|
-
// to emit first_tool/first_turn again for its fresh run.
|
|
128
|
-
record.firstToolEmitted = false;
|
|
129
|
-
record.firstTurnEmitted = false;
|
|
130
235
|
if (options.isBackground) this.runningBackground++;
|
|
131
236
|
this.onStart?.(record);
|
|
132
237
|
|
|
133
|
-
//
|
|
134
|
-
let
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
record.worktree = wt;
|
|
140
|
-
worktreeCwd = wt.path;
|
|
141
|
-
} else {
|
|
142
|
-
worktreeWarning = "\n\n[WARNING: Worktree isolation was requested but failed (not a git repo, or no commits yet). Running in the main working directory instead.]";
|
|
143
|
-
}
|
|
238
|
+
// Wire parent abort signal to stop the subagent when the parent is interrupted
|
|
239
|
+
let detachParentSignal: (() => void) | undefined;
|
|
240
|
+
if (options.signal) {
|
|
241
|
+
const onParentAbort = () => this.abort(id);
|
|
242
|
+
options.signal.addEventListener("abort", onParentAbort, { once: true });
|
|
243
|
+
detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort);
|
|
144
244
|
}
|
|
245
|
+
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
|
|
145
246
|
|
|
146
|
-
|
|
147
|
-
const effectivePrompt = worktreeWarning ? worktreeWarning + "\n\n" + prompt : prompt;
|
|
148
|
-
|
|
149
|
-
const promise = runAgent(ctx, type, effectivePrompt, {
|
|
247
|
+
const promise = runAgent(ctx, type, prompt, {
|
|
150
248
|
pi,
|
|
249
|
+
agentId: id,
|
|
151
250
|
model: options.model,
|
|
152
251
|
maxTurns: options.maxTurns,
|
|
153
252
|
isolated: options.isolated,
|
|
154
253
|
inheritContext: options.inheritContext,
|
|
155
254
|
thinkingLevel: options.thinkingLevel,
|
|
156
|
-
|
|
255
|
+
// Worktree wins for the working dir (the agent must run in the copy —
|
|
256
|
+
// which, with a custom cwd, was created from that target). Config stays
|
|
257
|
+
// with the parent project when a caller-supplied cwd is in play; it must
|
|
258
|
+
// stay undefined otherwise so plain worktree runs keep resolving config
|
|
259
|
+
// (incl. relative extension paths and memory) inside the worktree copy.
|
|
260
|
+
cwd: worktreeCwd ?? customCwd,
|
|
261
|
+
configCwd: customCwd !== undefined ? ctx.cwd : undefined,
|
|
157
262
|
signal: record.abortController!.signal,
|
|
158
263
|
onToolActivity: (activity) => {
|
|
159
264
|
if (activity.type === "end") record.toolUses++;
|
|
160
|
-
// Emit first_tool once per run from this single choke point, so ALL spawn
|
|
161
|
-
// paths (RPC panels, Agent-tool background, foreground) report it — not
|
|
162
|
-
// just the Agent tool's background branch. Panels spawn via RPC and were
|
|
163
|
-
// previously silent, making it impossible to tell if a reviewer actually
|
|
164
|
-
// investigated (ran tools) or wrote from context alone.
|
|
165
|
-
if (activity.type === "start" && !record.firstToolEmitted) {
|
|
166
|
-
record.firstToolEmitted = true;
|
|
167
|
-
try {
|
|
168
|
-
pi.events.emit("subagents:first_tool", {
|
|
169
|
-
id,
|
|
170
|
-
type,
|
|
171
|
-
description: options.description,
|
|
172
|
-
toolName: activity.toolName,
|
|
173
|
-
});
|
|
174
|
-
} catch { /* ignore */ }
|
|
175
|
-
}
|
|
176
265
|
options.onToolActivity?.(activity);
|
|
177
266
|
},
|
|
178
|
-
onTurnEnd:
|
|
179
|
-
if (!record.firstTurnEmitted) {
|
|
180
|
-
record.firstTurnEmitted = true;
|
|
181
|
-
try {
|
|
182
|
-
pi.events.emit("subagents:first_turn", {
|
|
183
|
-
id,
|
|
184
|
-
type,
|
|
185
|
-
description: options.description,
|
|
186
|
-
turnCount,
|
|
187
|
-
});
|
|
188
|
-
} catch { /* ignore */ }
|
|
189
|
-
}
|
|
190
|
-
options.onTurnEnd?.(turnCount);
|
|
191
|
-
},
|
|
267
|
+
onTurnEnd: options.onTurnEnd,
|
|
192
268
|
onTextDelta: options.onTextDelta,
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
269
|
+
onAssistantUsage: (usage) => {
|
|
270
|
+
addUsage(record.lifetimeUsage, usage);
|
|
271
|
+
options.onAssistantUsage?.(usage);
|
|
272
|
+
},
|
|
273
|
+
onCompaction: (info) => {
|
|
274
|
+
record.compactionCount++;
|
|
275
|
+
this.onCompact?.(record, info);
|
|
276
|
+
options.onCompaction?.(info);
|
|
277
|
+
},
|
|
199
278
|
onSessionCreated: (session) => {
|
|
200
279
|
record.session = session;
|
|
201
280
|
// Flush any steers that arrived before the session was ready
|
|
@@ -217,6 +296,8 @@ export class AgentManager {
|
|
|
217
296
|
record.session = session;
|
|
218
297
|
record.completedAt ??= Date.now();
|
|
219
298
|
|
|
299
|
+
detach();
|
|
300
|
+
|
|
220
301
|
// Final flush of streaming output file
|
|
221
302
|
if (record.outputCleanup) {
|
|
222
303
|
try { record.outputCleanup(); } catch { /* ignore */ }
|
|
@@ -225,17 +306,25 @@ export class AgentManager {
|
|
|
225
306
|
|
|
226
307
|
// Clean up worktree if used
|
|
227
308
|
if (record.worktree) {
|
|
228
|
-
const wtResult = cleanupWorktree(
|
|
309
|
+
const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
|
|
229
310
|
record.worktreeResult = wtResult;
|
|
230
311
|
if (wtResult.hasChanges && wtResult.branch) {
|
|
312
|
+
// With a caller-supplied cwd the branch lives in THAT repo, not the
|
|
313
|
+
// parent session's — say so, or the orchestrator merges in the wrong repo.
|
|
314
|
+
const repoNote = customCwd !== undefined ? ` in \`${baseCwd}\`` : "";
|
|
231
315
|
record.result = (record.result ?? "") +
|
|
232
|
-
`\n\n---\nChanges saved to branch \`${wtResult.branch}
|
|
316
|
+
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`;
|
|
233
317
|
}
|
|
234
318
|
}
|
|
235
319
|
|
|
236
|
-
|
|
320
|
+
// Fire onComplete for foreground agents too — lifecycle symmetry.
|
|
321
|
+
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
322
|
+
if (!options.isBackground) {
|
|
323
|
+
record.resultConsumed = true;
|
|
324
|
+
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
325
|
+
} else {
|
|
237
326
|
this.runningBackground--;
|
|
238
|
-
this.onComplete?.(record);
|
|
327
|
+
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
239
328
|
this.drainQueue();
|
|
240
329
|
}
|
|
241
330
|
return responseText;
|
|
@@ -248,6 +337,8 @@ export class AgentManager {
|
|
|
248
337
|
record.error = err instanceof Error ? err.message : String(err);
|
|
249
338
|
record.completedAt ??= Date.now();
|
|
250
339
|
|
|
340
|
+
detach();
|
|
341
|
+
|
|
251
342
|
// Final flush of streaming output file on error
|
|
252
343
|
if (record.outputCleanup) {
|
|
253
344
|
try { record.outputCleanup(); } catch { /* ignore */ }
|
|
@@ -257,12 +348,17 @@ export class AgentManager {
|
|
|
257
348
|
// Best-effort worktree cleanup on error
|
|
258
349
|
if (record.worktree) {
|
|
259
350
|
try {
|
|
260
|
-
const wtResult = cleanupWorktree(
|
|
351
|
+
const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
|
|
261
352
|
record.worktreeResult = wtResult;
|
|
262
353
|
} catch { /* ignore cleanup errors */ }
|
|
263
354
|
}
|
|
264
355
|
|
|
265
|
-
|
|
356
|
+
// Fire onComplete for foreground agents too — lifecycle symmetry.
|
|
357
|
+
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
358
|
+
if (!options.isBackground) {
|
|
359
|
+
record.resultConsumed = true;
|
|
360
|
+
this.onComplete?.(record);
|
|
361
|
+
} else {
|
|
266
362
|
this.runningBackground--;
|
|
267
363
|
this.onComplete?.(record);
|
|
268
364
|
this.drainQueue();
|
|
@@ -271,6 +367,11 @@ export class AgentManager {
|
|
|
271
367
|
});
|
|
272
368
|
|
|
273
369
|
record.promise = promise;
|
|
370
|
+
|
|
371
|
+
// Notify caller that spawn is complete (record is in the map, promise is set).
|
|
372
|
+
// Called synchronously — onSessionCreated fires asynchronously inside runAgent.
|
|
373
|
+
// Used by spawnAndWait to let the caller set up output files before streaming starts.
|
|
374
|
+
this.onSpawned?.(id);
|
|
274
375
|
}
|
|
275
376
|
|
|
276
377
|
/** Start queued agents up to the concurrency limit. */
|
|
@@ -279,13 +380,33 @@ export class AgentManager {
|
|
|
279
380
|
const next = this.queue.shift()!;
|
|
280
381
|
const record = this.agents.get(next.id);
|
|
281
382
|
if (!record || record.status !== "queued") continue;
|
|
282
|
-
|
|
383
|
+
try {
|
|
384
|
+
this.startAgent(next.id, record, next.args);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
// Late failure (e.g. strict worktree-isolation) — surface on the record
|
|
387
|
+
// so the user/agent can see it via /agents, then keep draining.
|
|
388
|
+
record.status = "error";
|
|
389
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
390
|
+
record.completedAt = Date.now();
|
|
391
|
+
this.onComplete?.(record);
|
|
392
|
+
}
|
|
283
393
|
}
|
|
284
394
|
}
|
|
285
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Called synchronously right after spawn, before onSessionCreated fires.
|
|
398
|
+
* Lets the caller set up the output file path on the record.
|
|
399
|
+
* The record is guaranteed to be in this.agents at this point.
|
|
400
|
+
*/
|
|
401
|
+
private onSpawned?: (id: string) => void;
|
|
402
|
+
|
|
286
403
|
/**
|
|
287
404
|
* Spawn an agent and wait for completion (foreground use).
|
|
288
405
|
* Foreground agents bypass the concurrency queue.
|
|
406
|
+
* Returns { id, record } so callers can access the agent ID.
|
|
407
|
+
*
|
|
408
|
+
* @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires.
|
|
409
|
+
* Use this to set record.outputFile so streamToOutputFile can pick it up.
|
|
289
410
|
*/
|
|
290
411
|
async spawnAndWait(
|
|
291
412
|
pi: ExtensionAPI,
|
|
@@ -293,11 +414,19 @@ export class AgentManager {
|
|
|
293
414
|
type: SubagentType,
|
|
294
415
|
prompt: string,
|
|
295
416
|
options: Omit<SpawnOptions, "isBackground">,
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
417
|
+
onSpawned?: (id: string) => void,
|
|
418
|
+
): Promise<{ id: string; record: AgentRecord }> {
|
|
419
|
+
// Temporarily register the onSpawned hook so startAgent can call it.
|
|
420
|
+
const prevOnSpawned = this.onSpawned;
|
|
421
|
+
this.onSpawned = onSpawned;
|
|
422
|
+
try {
|
|
423
|
+
const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
|
|
424
|
+
const record = this.agents.get(id)!;
|
|
425
|
+
await record.promise;
|
|
426
|
+
return { id, record };
|
|
427
|
+
} finally {
|
|
428
|
+
this.onSpawned = prevOnSpawned;
|
|
429
|
+
}
|
|
301
430
|
}
|
|
302
431
|
|
|
303
432
|
/**
|
|
@@ -322,8 +451,14 @@ export class AgentManager {
|
|
|
322
451
|
onToolActivity: (activity) => {
|
|
323
452
|
if (activity.type === "end") record.toolUses++;
|
|
324
453
|
},
|
|
454
|
+
onAssistantUsage: (usage) => {
|
|
455
|
+
addUsage(record.lifetimeUsage, usage);
|
|
456
|
+
},
|
|
457
|
+
onCompaction: (info) => {
|
|
458
|
+
record.compactionCount++;
|
|
459
|
+
this.onCompact?.(record, info);
|
|
460
|
+
},
|
|
325
461
|
signal,
|
|
326
|
-
subagentId: id,
|
|
327
462
|
});
|
|
328
463
|
record.status = "completed";
|
|
329
464
|
record.result = responseText;
|
|
@@ -337,6 +472,27 @@ export class AgentManager {
|
|
|
337
472
|
return record;
|
|
338
473
|
}
|
|
339
474
|
|
|
475
|
+
/**
|
|
476
|
+
* Send a steering message to an agent from the UI (mirrors the steer_subagent
|
|
477
|
+
* tool). A live session delivers it now — it interrupts the agent after its
|
|
478
|
+
* current tool execution and appears as a user message. If the session isn't
|
|
479
|
+
* ready yet, the message is queued on `pendingSteers` and flushed when the
|
|
480
|
+
* session is created. Returns false if the agent can't accept steering
|
|
481
|
+
* (unknown id, or no longer running/queued).
|
|
482
|
+
*/
|
|
483
|
+
steer(id: string, message: string): boolean {
|
|
484
|
+
const record = this.agents.get(id);
|
|
485
|
+
if (!record) return false;
|
|
486
|
+
if (record.status !== "running" && record.status !== "queued") return false;
|
|
487
|
+
if (record.session) {
|
|
488
|
+
record.session.steer(message).catch(() => {});
|
|
489
|
+
} else {
|
|
490
|
+
if (!record.pendingSteers) record.pendingSteers = [];
|
|
491
|
+
record.pendingSteers.push(message);
|
|
492
|
+
}
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
|
|
340
496
|
getRecord(id: string): AgentRecord | undefined {
|
|
341
497
|
return this.agents.get(id);
|
|
342
498
|
}
|
|
@@ -385,10 +541,13 @@ export class AgentManager {
|
|
|
385
541
|
/**
|
|
386
542
|
* Remove all completed/stopped/errored records immediately.
|
|
387
543
|
* Called on session start/switch so tasks from a prior session don't persist.
|
|
544
|
+
* Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
|
|
545
|
+
* (resultConsumed=false) — they will be evicted by the 10-minute cleanup timer instead.
|
|
388
546
|
*/
|
|
389
|
-
clearCompleted(): void {
|
|
547
|
+
clearCompleted(skipUnconsumed = false): void {
|
|
390
548
|
for (const [id, record] of this.agents) {
|
|
391
549
|
if (record.status === "running" || record.status === "queued") continue;
|
|
550
|
+
if (skipUnconsumed && !record.resultConsumed) continue;
|
|
392
551
|
this.removeRecord(id, record);
|
|
393
552
|
}
|
|
394
553
|
}
|
|
@@ -450,5 +609,10 @@ export class AgentManager {
|
|
|
450
609
|
this.agents.clear();
|
|
451
610
|
// Prune any orphaned git worktrees (crash recovery)
|
|
452
611
|
try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
|
|
612
|
+
// Also prune repos that caller-supplied cwds created worktrees in — a clean
|
|
613
|
+
// exit with in-flight agents would otherwise leave stale registrations there.
|
|
614
|
+
for (const repo of this.worktreeRepos) {
|
|
615
|
+
try { pruneWorktrees(repo); } catch { /* ignore */ }
|
|
616
|
+
}
|
|
453
617
|
}
|
|
454
618
|
}
|