@esso0428/pi-subagents 0.15.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/CHANGELOG.md +638 -0
- package/CONTRIBUTING.md +68 -0
- package/LICENSE +21 -0
- package/README.md +745 -0
- package/SECURITY.md +95 -0
- package/dist/agent-manager.d.ts +144 -0
- package/dist/agent-manager.js +542 -0
- package/dist/agent-runner.d.ts +212 -0
- package/dist/agent-runner.js +850 -0
- package/dist/agent-types.d.ts +67 -0
- package/dist/agent-types.js +168 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +56 -0
- package/dist/cross-extension-rpc.d.ts +46 -0
- package/dist/cross-extension-rpc.js +76 -0
- package/dist/custom-agents.d.ts +17 -0
- package/dist/custom-agents.js +156 -0
- package/dist/default-agents.d.ts +7 -0
- package/dist/default-agents.js +122 -0
- package/dist/enabled-models.d.ts +49 -0
- package/dist/enabled-models.js +145 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.js +28 -0
- package/dist/group-join.d.ts +32 -0
- package/dist/group-join.js +116 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +2209 -0
- package/dist/invocation-config.d.ts +22 -0
- package/dist/invocation-config.js +15 -0
- package/dist/memory.d.ts +53 -0
- package/dist/memory.js +165 -0
- package/dist/model-resolver.d.ts +19 -0
- package/dist/model-resolver.js +80 -0
- package/dist/nico-overrides.d.ts +53 -0
- package/dist/nico-overrides.js +169 -0
- package/dist/output-file.d.ts +24 -0
- package/dist/output-file.js +101 -0
- package/dist/prompts.d.ts +32 -0
- package/dist/prompts.js +73 -0
- package/dist/schedule-store.d.ts +38 -0
- package/dist/schedule-store.js +155 -0
- package/dist/schedule.d.ts +109 -0
- package/dist/schedule.js +338 -0
- package/dist/settings.d.ts +141 -0
- package/dist/settings.js +162 -0
- package/dist/skill-loader.d.ts +24 -0
- package/dist/skill-loader.js +93 -0
- package/dist/status-note.d.ts +13 -0
- package/dist/status-note.js +24 -0
- package/dist/types.d.ts +197 -0
- package/dist/types.js +5 -0
- package/dist/ui/agent-widget.d.ts +160 -0
- package/dist/ui/agent-widget.js +484 -0
- package/dist/ui/conversation-viewer.d.ts +57 -0
- package/dist/ui/conversation-viewer.js +354 -0
- package/dist/ui/fleet-list.d.ts +106 -0
- package/dist/ui/fleet-list.js +345 -0
- package/dist/ui/schedule-menu.d.ts +16 -0
- package/dist/ui/schedule-menu.js +95 -0
- package/dist/ui/viewer-keys.d.ts +20 -0
- package/dist/ui/viewer-keys.js +17 -0
- package/dist/usage.d.ts +50 -0
- package/dist/usage.js +49 -0
- package/dist/worktree.d.ts +45 -0
- package/dist/worktree.js +160 -0
- package/examples/agent-tool-description.md +42 -0
- package/package.json +56 -0
- package/src/agent-manager.ts +631 -0
- package/src/agent-runner.ts +1014 -0
- package/src/agent-types.ts +202 -0
- package/src/context.ts +58 -0
- package/src/cross-extension-rpc.ts +122 -0
- package/src/custom-agents.ts +167 -0
- package/src/default-agents.ts +126 -0
- package/src/enabled-models.ts +180 -0
- package/src/env.ts +33 -0
- package/src/group-join.ts +141 -0
- package/src/index.ts +2400 -0
- package/src/invocation-config.ts +40 -0
- package/src/memory.ts +179 -0
- package/src/model-resolver.ts +100 -0
- package/src/nico-overrides.ts +235 -0
- package/src/output-file.ts +110 -0
- package/src/prompts.ts +99 -0
- package/src/schedule-store.ts +153 -0
- package/src/schedule.ts +365 -0
- package/src/settings.ts +288 -0
- package/src/skill-loader.ts +102 -0
- package/src/status-note.ts +25 -0
- package/src/types.ts +208 -0
- package/src/ui/agent-widget.ts +566 -0
- package/src/ui/conversation-viewer.ts +362 -0
- package/src/ui/fleet-list.ts +380 -0
- package/src/ui/schedule-menu.ts +104 -0
- package/src/ui/viewer-keys.ts +39 -0
- package/src/usage.ts +60 -0
- package/src/worktree.ts +191 -0
- package/vitest.config.ts +18 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-manager.ts — Tracks agents, background execution, resume support.
|
|
3
|
+
*
|
|
4
|
+
* Background agents are subject to a configurable concurrency limit (default: 4).
|
|
5
|
+
* Excess agents are queued and auto-started as running agents complete.
|
|
6
|
+
* Foreground agents bypass the queue (they block the parent anyway).
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { statSync } from "node:fs";
|
|
10
|
+
import { isAbsolute } from "node:path";
|
|
11
|
+
import { resumeAgent, runAgent } from "./agent-runner.js";
|
|
12
|
+
import { addUsage } from "./usage.js";
|
|
13
|
+
import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
|
|
14
|
+
/** Default max concurrent background agents. */
|
|
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
|
+
}
|
|
39
|
+
export class AgentManager {
|
|
40
|
+
agents = new Map();
|
|
41
|
+
cleanupInterval;
|
|
42
|
+
onComplete;
|
|
43
|
+
onStart;
|
|
44
|
+
onCompact;
|
|
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();
|
|
49
|
+
/** Queue of background agents waiting to start. */
|
|
50
|
+
queue = [];
|
|
51
|
+
/** Number of currently running background agents. */
|
|
52
|
+
runningBackground = 0;
|
|
53
|
+
constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart, onCompact) {
|
|
54
|
+
this.onComplete = onComplete;
|
|
55
|
+
this.onStart = onStart;
|
|
56
|
+
this.onCompact = onCompact;
|
|
57
|
+
this.maxConcurrent = maxConcurrent;
|
|
58
|
+
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
|
59
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
|
60
|
+
this.cleanupInterval.unref();
|
|
61
|
+
}
|
|
62
|
+
/** Update the max concurrent background agents limit. */
|
|
63
|
+
setMaxConcurrent(n) {
|
|
64
|
+
this.maxConcurrent = Math.max(1, n);
|
|
65
|
+
// Start queued agents if the new limit allows
|
|
66
|
+
this.drainQueue();
|
|
67
|
+
}
|
|
68
|
+
getMaxConcurrent() {
|
|
69
|
+
return this.maxConcurrent;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Spawn an agent and return its ID immediately (for background use).
|
|
73
|
+
* If the concurrency limit is reached, the agent is queued.
|
|
74
|
+
*/
|
|
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);
|
|
80
|
+
const id = randomUUID().slice(0, 17);
|
|
81
|
+
const abortController = new AbortController();
|
|
82
|
+
const record = {
|
|
83
|
+
id,
|
|
84
|
+
type,
|
|
85
|
+
description: options.description,
|
|
86
|
+
status: options.isBackground ? "queued" : "running",
|
|
87
|
+
toolUses: 0,
|
|
88
|
+
startedAt: Date.now(),
|
|
89
|
+
abortController,
|
|
90
|
+
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
91
|
+
compactionCount: 0,
|
|
92
|
+
// Raw tri-state (not coerced to a boolean): true = background, false =
|
|
93
|
+
// foreground (has an inline tool-result surface), undefined = caller never
|
|
94
|
+
// declared it (e.g. a cross-extension RPC spawn). The widget's background-
|
|
95
|
+
// only filter excludes only explicit `false`, so undefined agents — which
|
|
96
|
+
// have no inline surface — stay visible instead of vanishing.
|
|
97
|
+
isBackground: options.isBackground,
|
|
98
|
+
invocation: options.invocation,
|
|
99
|
+
};
|
|
100
|
+
this.agents.set(id, record);
|
|
101
|
+
const args = { pi, ctx, type, prompt, options };
|
|
102
|
+
if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
|
|
103
|
+
// Queue it — will be started when a running agent completes
|
|
104
|
+
this.queue.push({ id, args });
|
|
105
|
+
return id;
|
|
106
|
+
}
|
|
107
|
+
// startAgent can throw (e.g. strict worktree-isolation failure) — clean
|
|
108
|
+
// up the record so callers don't see an orphan in `listAgents()`.
|
|
109
|
+
try {
|
|
110
|
+
this.startAgent(id, record, args);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
this.agents.delete(id);
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
return id;
|
|
117
|
+
}
|
|
118
|
+
/** Actually start an agent (called immediately or from queue drain). */
|
|
119
|
+
startAgent(id, record, { pi, ctx, type, prompt, options }) {
|
|
120
|
+
// Re-validate a caller-supplied cwd: queued spawns can start minutes after
|
|
121
|
+
// spawn()'s check, and the directory may be gone by then (TOCTOU). Same
|
|
122
|
+
// curated errors; drainQueue parks a throw on the record as an error.
|
|
123
|
+
assertValidSpawnCwd(options.cwd);
|
|
124
|
+
// Single resolution point for the caller-supplied cwd — the worktree base
|
|
125
|
+
// repo and both cleanup calls below MUST agree on this value forever.
|
|
126
|
+
const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
|
|
127
|
+
const baseCwd = customCwd ?? ctx.cwd;
|
|
128
|
+
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
129
|
+
// fail loud if not possible (no silent fallback to main tree). Done
|
|
130
|
+
// BEFORE state mutation so a throw doesn't leave the record half-running.
|
|
131
|
+
let worktreeCwd;
|
|
132
|
+
if (options.isolation === "worktree") {
|
|
133
|
+
const wt = createWorktree(baseCwd, id);
|
|
134
|
+
if (!wt) {
|
|
135
|
+
throw new Error('Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
136
|
+
'Initialize git and commit at least once, or omit `isolation`.');
|
|
137
|
+
}
|
|
138
|
+
record.worktree = wt;
|
|
139
|
+
// workPath preserves subdirectory scoping for caller-supplied cwds: a
|
|
140
|
+
// cwd deep in a monorepo maps to the same subdir inside the copy, not
|
|
141
|
+
// the copied repo's root. Plain worktree spawns keep the historical
|
|
142
|
+
// behavior (agent at the copy's root) — moving them to workPath would
|
|
143
|
+
// also move .pi config discovery when the parent session sits in a repo
|
|
144
|
+
// subdirectory, silently dropping extensions/skills.
|
|
145
|
+
worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
|
|
146
|
+
this.worktreeRepos.add(baseCwd);
|
|
147
|
+
}
|
|
148
|
+
record.status = "running";
|
|
149
|
+
record.startedAt = Date.now();
|
|
150
|
+
if (options.isBackground)
|
|
151
|
+
this.runningBackground++;
|
|
152
|
+
this.onStart?.(record);
|
|
153
|
+
// Wire parent abort signal to stop the subagent when the parent is interrupted
|
|
154
|
+
let detachParentSignal;
|
|
155
|
+
if (options.signal) {
|
|
156
|
+
const onParentAbort = () => this.abort(id);
|
|
157
|
+
options.signal.addEventListener("abort", onParentAbort, { once: true });
|
|
158
|
+
detachParentSignal = () => options.signal.removeEventListener("abort", onParentAbort);
|
|
159
|
+
}
|
|
160
|
+
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
|
|
161
|
+
const promise = runAgent(ctx, type, prompt, {
|
|
162
|
+
pi,
|
|
163
|
+
agentId: id,
|
|
164
|
+
model: options.model,
|
|
165
|
+
maxTurns: options.maxTurns,
|
|
166
|
+
isolated: options.isolated,
|
|
167
|
+
inheritContext: options.inheritContext,
|
|
168
|
+
thinkingLevel: options.thinkingLevel,
|
|
169
|
+
// Worktree wins for the working dir (the agent must run in the copy —
|
|
170
|
+
// which, with a custom cwd, was created from that target). Config stays
|
|
171
|
+
// with the parent project when a caller-supplied cwd is in play; it must
|
|
172
|
+
// stay undefined otherwise so plain worktree runs keep resolving config
|
|
173
|
+
// (incl. relative extension paths and memory) inside the worktree copy.
|
|
174
|
+
cwd: worktreeCwd ?? customCwd,
|
|
175
|
+
configCwd: customCwd !== undefined ? ctx.cwd : undefined,
|
|
176
|
+
signal: record.abortController.signal,
|
|
177
|
+
onToolActivity: (activity) => {
|
|
178
|
+
if (activity.type === "end")
|
|
179
|
+
record.toolUses++;
|
|
180
|
+
options.onToolActivity?.(activity);
|
|
181
|
+
},
|
|
182
|
+
onTurnEnd: options.onTurnEnd,
|
|
183
|
+
onTextDelta: options.onTextDelta,
|
|
184
|
+
onAssistantUsage: (usage) => {
|
|
185
|
+
addUsage(record.lifetimeUsage, usage);
|
|
186
|
+
options.onAssistantUsage?.(usage);
|
|
187
|
+
},
|
|
188
|
+
onCompaction: (info) => {
|
|
189
|
+
record.compactionCount++;
|
|
190
|
+
this.onCompact?.(record, info);
|
|
191
|
+
options.onCompaction?.(info);
|
|
192
|
+
},
|
|
193
|
+
onSessionCreated: (session) => {
|
|
194
|
+
record.session = session;
|
|
195
|
+
// Flush any steers that arrived before the session was ready
|
|
196
|
+
if (record.pendingSteers?.length) {
|
|
197
|
+
for (const msg of record.pendingSteers) {
|
|
198
|
+
session.steer(msg).catch(() => { });
|
|
199
|
+
}
|
|
200
|
+
record.pendingSteers = undefined;
|
|
201
|
+
}
|
|
202
|
+
options.onSessionCreated?.(session);
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
.then(({ responseText, session, aborted, steered, failure }) => {
|
|
206
|
+
// Don't overwrite status if externally stopped via abort()
|
|
207
|
+
if (record.status !== "stopped") {
|
|
208
|
+
// Precedence: a hard abort keeps "aborted"; then a failed final turn
|
|
209
|
+
// (provider error that pi resolved instead of rejecting, #144) is an
|
|
210
|
+
// honest "error" — not a completion with an empty or stale result.
|
|
211
|
+
if (aborted) {
|
|
212
|
+
record.status = "aborted";
|
|
213
|
+
}
|
|
214
|
+
else if (failure) {
|
|
215
|
+
record.status = "error";
|
|
216
|
+
record.error = failure;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
record.status = steered ? "steered" : "completed";
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
record.result = responseText;
|
|
223
|
+
record.session = session;
|
|
224
|
+
record.completedAt ??= Date.now();
|
|
225
|
+
detach();
|
|
226
|
+
// Final flush of streaming output file
|
|
227
|
+
if (record.outputCleanup) {
|
|
228
|
+
try {
|
|
229
|
+
record.outputCleanup();
|
|
230
|
+
}
|
|
231
|
+
catch { /* ignore */ }
|
|
232
|
+
record.outputCleanup = undefined;
|
|
233
|
+
}
|
|
234
|
+
// Clean up worktree if used
|
|
235
|
+
if (record.worktree) {
|
|
236
|
+
const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
|
|
237
|
+
record.worktreeResult = wtResult;
|
|
238
|
+
if (wtResult.hasChanges && wtResult.branch) {
|
|
239
|
+
// With a caller-supplied cwd the branch lives in THAT repo, not the
|
|
240
|
+
// parent session's — say so, or the orchestrator merges in the wrong repo.
|
|
241
|
+
const repoNote = customCwd !== undefined ? ` in \`${baseCwd}\`` : "";
|
|
242
|
+
record.result = (record.result ?? "") +
|
|
243
|
+
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Fire onComplete for foreground agents too — lifecycle symmetry.
|
|
247
|
+
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
248
|
+
if (!options.isBackground) {
|
|
249
|
+
record.resultConsumed = true;
|
|
250
|
+
try {
|
|
251
|
+
this.onComplete?.(record);
|
|
252
|
+
}
|
|
253
|
+
catch { /* ignore completion side-effect errors */ }
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
this.runningBackground--;
|
|
257
|
+
try {
|
|
258
|
+
this.onComplete?.(record);
|
|
259
|
+
}
|
|
260
|
+
catch { /* ignore completion side-effect errors */ }
|
|
261
|
+
this.drainQueue();
|
|
262
|
+
}
|
|
263
|
+
return responseText;
|
|
264
|
+
})
|
|
265
|
+
.catch((err) => {
|
|
266
|
+
// Don't overwrite status if externally stopped via abort()
|
|
267
|
+
if (record.status !== "stopped") {
|
|
268
|
+
record.status = "error";
|
|
269
|
+
}
|
|
270
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
271
|
+
record.completedAt ??= Date.now();
|
|
272
|
+
detach();
|
|
273
|
+
// Final flush of streaming output file on error
|
|
274
|
+
if (record.outputCleanup) {
|
|
275
|
+
try {
|
|
276
|
+
record.outputCleanup();
|
|
277
|
+
}
|
|
278
|
+
catch { /* ignore */ }
|
|
279
|
+
record.outputCleanup = undefined;
|
|
280
|
+
}
|
|
281
|
+
// Best-effort worktree cleanup on error
|
|
282
|
+
if (record.worktree) {
|
|
283
|
+
try {
|
|
284
|
+
const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
|
|
285
|
+
record.worktreeResult = wtResult;
|
|
286
|
+
}
|
|
287
|
+
catch { /* ignore cleanup errors */ }
|
|
288
|
+
}
|
|
289
|
+
// Fire onComplete for foreground agents too — lifecycle symmetry.
|
|
290
|
+
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
291
|
+
if (!options.isBackground) {
|
|
292
|
+
record.resultConsumed = true;
|
|
293
|
+
this.onComplete?.(record);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
this.runningBackground--;
|
|
297
|
+
this.onComplete?.(record);
|
|
298
|
+
this.drainQueue();
|
|
299
|
+
}
|
|
300
|
+
return "";
|
|
301
|
+
});
|
|
302
|
+
record.promise = promise;
|
|
303
|
+
// Notify caller that spawn is complete (record is in the map, promise is set).
|
|
304
|
+
// Called synchronously — onSessionCreated fires asynchronously inside runAgent.
|
|
305
|
+
// Used by spawnAndWait to let the caller set up output files before streaming starts.
|
|
306
|
+
this.onSpawned?.(id);
|
|
307
|
+
}
|
|
308
|
+
/** Start queued agents up to the concurrency limit. */
|
|
309
|
+
drainQueue() {
|
|
310
|
+
while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
|
|
311
|
+
const next = this.queue.shift();
|
|
312
|
+
const record = this.agents.get(next.id);
|
|
313
|
+
if (!record || record.status !== "queued")
|
|
314
|
+
continue;
|
|
315
|
+
try {
|
|
316
|
+
this.startAgent(next.id, record, next.args);
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
// Late failure (e.g. strict worktree-isolation) — surface on the record
|
|
320
|
+
// so the user/agent can see it via /agents, then keep draining.
|
|
321
|
+
record.status = "error";
|
|
322
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
323
|
+
record.completedAt = Date.now();
|
|
324
|
+
this.onComplete?.(record);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Called synchronously right after spawn, before onSessionCreated fires.
|
|
330
|
+
* Lets the caller set up the output file path on the record.
|
|
331
|
+
* The record is guaranteed to be in this.agents at this point.
|
|
332
|
+
*/
|
|
333
|
+
onSpawned;
|
|
334
|
+
/**
|
|
335
|
+
* Spawn an agent and wait for completion (foreground use).
|
|
336
|
+
* Foreground agents bypass the concurrency queue.
|
|
337
|
+
* Returns { id, record } so callers can access the agent ID.
|
|
338
|
+
*
|
|
339
|
+
* @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires.
|
|
340
|
+
* Use this to set record.outputFile so streamToOutputFile can pick it up.
|
|
341
|
+
*/
|
|
342
|
+
async spawnAndWait(pi, ctx, type, prompt, options, onSpawned) {
|
|
343
|
+
// Temporarily register the onSpawned hook so startAgent can call it.
|
|
344
|
+
const prevOnSpawned = this.onSpawned;
|
|
345
|
+
this.onSpawned = onSpawned;
|
|
346
|
+
try {
|
|
347
|
+
const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
|
|
348
|
+
const record = this.agents.get(id);
|
|
349
|
+
await record.promise;
|
|
350
|
+
return { id, record };
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
this.onSpawned = prevOnSpawned;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Resume an existing agent session with a new prompt.
|
|
358
|
+
*/
|
|
359
|
+
async resume(id, prompt, signal) {
|
|
360
|
+
const record = this.agents.get(id);
|
|
361
|
+
if (!record?.session)
|
|
362
|
+
return undefined;
|
|
363
|
+
record.status = "running";
|
|
364
|
+
record.startedAt = Date.now();
|
|
365
|
+
record.completedAt = undefined;
|
|
366
|
+
record.result = undefined;
|
|
367
|
+
record.error = undefined;
|
|
368
|
+
try {
|
|
369
|
+
const { text, failure } = await resumeAgent(record.session, prompt, {
|
|
370
|
+
onToolActivity: (activity) => {
|
|
371
|
+
if (activity.type === "end")
|
|
372
|
+
record.toolUses++;
|
|
373
|
+
},
|
|
374
|
+
onAssistantUsage: (usage) => {
|
|
375
|
+
addUsage(record.lifetimeUsage, usage);
|
|
376
|
+
},
|
|
377
|
+
onCompaction: (info) => {
|
|
378
|
+
record.compactionCount++;
|
|
379
|
+
this.onCompact?.(record, info);
|
|
380
|
+
},
|
|
381
|
+
signal,
|
|
382
|
+
});
|
|
383
|
+
// Same contract as the spawn path (#144): a failed final turn is an
|
|
384
|
+
// error, not a completion — but the resumed text stays available.
|
|
385
|
+
record.status = failure ? "error" : "completed";
|
|
386
|
+
if (failure)
|
|
387
|
+
record.error = failure;
|
|
388
|
+
record.result = text;
|
|
389
|
+
record.completedAt = Date.now();
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
record.status = "error";
|
|
393
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
394
|
+
record.completedAt = Date.now();
|
|
395
|
+
}
|
|
396
|
+
return record;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Send a steering message to an agent from the UI (mirrors the steer_subagent
|
|
400
|
+
* tool). A live session delivers it now — it interrupts the agent after its
|
|
401
|
+
* current tool execution and appears as a user message. If the session isn't
|
|
402
|
+
* ready yet, the message is queued on `pendingSteers` and flushed when the
|
|
403
|
+
* session is created. Returns false if the agent can't accept steering
|
|
404
|
+
* (unknown id, or no longer running/queued).
|
|
405
|
+
*/
|
|
406
|
+
steer(id, message) {
|
|
407
|
+
const record = this.agents.get(id);
|
|
408
|
+
if (!record)
|
|
409
|
+
return false;
|
|
410
|
+
if (record.status !== "running" && record.status !== "queued")
|
|
411
|
+
return false;
|
|
412
|
+
if (record.session) {
|
|
413
|
+
record.session.steer(message).catch(() => { });
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
if (!record.pendingSteers)
|
|
417
|
+
record.pendingSteers = [];
|
|
418
|
+
record.pendingSteers.push(message);
|
|
419
|
+
}
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
getRecord(id) {
|
|
423
|
+
return this.agents.get(id);
|
|
424
|
+
}
|
|
425
|
+
listAgents() {
|
|
426
|
+
return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
427
|
+
}
|
|
428
|
+
abort(id) {
|
|
429
|
+
const record = this.agents.get(id);
|
|
430
|
+
if (!record)
|
|
431
|
+
return false;
|
|
432
|
+
// Remove from queue if queued
|
|
433
|
+
if (record.status === "queued") {
|
|
434
|
+
this.queue = this.queue.filter(q => q.id !== id);
|
|
435
|
+
record.status = "stopped";
|
|
436
|
+
record.completedAt = Date.now();
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
if (record.status !== "running")
|
|
440
|
+
return false;
|
|
441
|
+
record.abortController?.abort();
|
|
442
|
+
record.status = "stopped";
|
|
443
|
+
record.completedAt = Date.now();
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
/** Dispose a record's session and remove it from the map. */
|
|
447
|
+
removeRecord(id, record) {
|
|
448
|
+
record.session?.dispose?.();
|
|
449
|
+
record.session = undefined;
|
|
450
|
+
this.agents.delete(id);
|
|
451
|
+
}
|
|
452
|
+
cleanup() {
|
|
453
|
+
const cutoff = Date.now() - 10 * 60_000;
|
|
454
|
+
for (const [id, record] of this.agents) {
|
|
455
|
+
if (record.status === "running" || record.status === "queued")
|
|
456
|
+
continue;
|
|
457
|
+
if ((record.completedAt ?? 0) >= cutoff)
|
|
458
|
+
continue;
|
|
459
|
+
this.removeRecord(id, record);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Remove all completed/stopped/errored records immediately.
|
|
464
|
+
* Called on session start/switch so tasks from a prior session don't persist.
|
|
465
|
+
* Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
|
|
466
|
+
* (resultConsumed=false) — they will be evicted by the 10-minute cleanup timer instead.
|
|
467
|
+
*/
|
|
468
|
+
clearCompleted(skipUnconsumed = false) {
|
|
469
|
+
for (const [id, record] of this.agents) {
|
|
470
|
+
if (record.status === "running" || record.status === "queued")
|
|
471
|
+
continue;
|
|
472
|
+
if (skipUnconsumed && !record.resultConsumed)
|
|
473
|
+
continue;
|
|
474
|
+
this.removeRecord(id, record);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/** Whether any agents are still running or queued. */
|
|
478
|
+
hasRunning() {
|
|
479
|
+
return [...this.agents.values()].some(r => r.status === "running" || r.status === "queued");
|
|
480
|
+
}
|
|
481
|
+
/** Abort all running and queued agents immediately. */
|
|
482
|
+
abortAll() {
|
|
483
|
+
let count = 0;
|
|
484
|
+
// Clear queued agents first
|
|
485
|
+
for (const queued of this.queue) {
|
|
486
|
+
const record = this.agents.get(queued.id);
|
|
487
|
+
if (record) {
|
|
488
|
+
record.status = "stopped";
|
|
489
|
+
record.completedAt = Date.now();
|
|
490
|
+
count++;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
this.queue = [];
|
|
494
|
+
// Abort running agents
|
|
495
|
+
for (const record of this.agents.values()) {
|
|
496
|
+
if (record.status === "running") {
|
|
497
|
+
record.abortController?.abort();
|
|
498
|
+
record.status = "stopped";
|
|
499
|
+
record.completedAt = Date.now();
|
|
500
|
+
count++;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return count;
|
|
504
|
+
}
|
|
505
|
+
/** Wait for all running and queued agents to complete (including queued ones). */
|
|
506
|
+
async waitForAll() {
|
|
507
|
+
// Loop because drainQueue respects the concurrency limit — as running
|
|
508
|
+
// agents finish they start queued ones, which need awaiting too.
|
|
509
|
+
while (true) {
|
|
510
|
+
this.drainQueue();
|
|
511
|
+
const pending = [...this.agents.values()]
|
|
512
|
+
.filter(r => r.status === "running" || r.status === "queued")
|
|
513
|
+
.map(r => r.promise)
|
|
514
|
+
.filter(Boolean);
|
|
515
|
+
if (pending.length === 0)
|
|
516
|
+
break;
|
|
517
|
+
await Promise.allSettled(pending);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
dispose() {
|
|
521
|
+
clearInterval(this.cleanupInterval);
|
|
522
|
+
// Clear queue
|
|
523
|
+
this.queue = [];
|
|
524
|
+
for (const record of this.agents.values()) {
|
|
525
|
+
record.session?.dispose();
|
|
526
|
+
}
|
|
527
|
+
this.agents.clear();
|
|
528
|
+
// Prune any orphaned git worktrees (crash recovery)
|
|
529
|
+
try {
|
|
530
|
+
pruneWorktrees(process.cwd());
|
|
531
|
+
}
|
|
532
|
+
catch { /* ignore */ }
|
|
533
|
+
// Also prune repos that caller-supplied cwds created worktrees in — a clean
|
|
534
|
+
// exit with in-flight agents would otherwise leave stale registrations there.
|
|
535
|
+
for (const repo of this.worktreeRepos) {
|
|
536
|
+
try {
|
|
537
|
+
pruneWorktrees(repo);
|
|
538
|
+
}
|
|
539
|
+
catch { /* ignore */ }
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|