killeros 1.4.8 → 1.5.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 +21 -4
- package/Killeros.ts +25 -2542
- package/README.md +23 -8
- package/killeros/commands.ts +164 -0
- package/killeros/concise.ts +23 -0
- package/killeros/display.ts +39 -0
- package/killeros/errors.ts +6 -0
- package/killeros/footer.ts +209 -0
- package/killeros/goals.ts +720 -0
- package/killeros/hooks.ts +219 -0
- package/killeros/init.ts +470 -0
- package/killeros/personal-instructions.ts +66 -0
- package/killeros/question.ts +468 -0
- package/killeros/runtime.ts +61 -0
- package/killeros/shell-ui.ts +270 -0
- package/killeros/subagent-lifecycle.ts +532 -0
- package/killeros/subagent-process.ts +601 -0
- package/killeros/subagent-ui.ts +227 -0
- package/killeros/subagents.ts +1917 -0
- package/killeros/variants.ts +136 -0
- package/package.json +7 -6
- package/subagent-lifecycle.ts +1 -506
- package/subagent-process.ts +1 -595
- package/subagent-ui.ts +1 -227
- package/subagents.ts +1 -1556
|
@@ -0,0 +1,1917 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { getSupportedThinkingLevels, StringEnum, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
8
|
+
import {
|
|
9
|
+
CONFIG_DIR_NAME,
|
|
10
|
+
getAgentDir,
|
|
11
|
+
getMarkdownTheme,
|
|
12
|
+
parseFrontmatter,
|
|
13
|
+
type ExtensionAPI,
|
|
14
|
+
type ExtensionContext,
|
|
15
|
+
} from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
import { SubagentThreadRegistry, type SubagentThread, type SubagentThreadId, type SubagentThreadState } from "./subagent-lifecycle.ts";
|
|
19
|
+
import { MAX_NODE_TIMER_MS, runSubagentProcess, type SubagentProcessHandle, type SubagentProcessResult } from "./subagent-process.ts";
|
|
20
|
+
import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadBoardRecord } from "./subagent-ui.ts";
|
|
21
|
+
|
|
22
|
+
export const SUBAGENT_LIMITS = {
|
|
23
|
+
maxTasks: 10,
|
|
24
|
+
maxReadConcurrency: 4,
|
|
25
|
+
toolOutputBytes: 50 * 1024,
|
|
26
|
+
traceRetentionBytes: 8 * 1024 * 1024,
|
|
27
|
+
stderrRetentionBytes: 1 * 1024 * 1024,
|
|
28
|
+
taskOutputRetentionBytes: 1 * 1024 * 1024,
|
|
29
|
+
threadRetentionRecords: 64,
|
|
30
|
+
threadRetentionBytes: 128 * 1024 * 1024,
|
|
31
|
+
roleFileBytes: 64 * 1024,
|
|
32
|
+
taskCharacters: 20_000,
|
|
33
|
+
killGraceMs: 5_000,
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
const WEB_TOOLS = new Set(["web_search", "source_check", "fetch_content", "get_search_content"]);
|
|
37
|
+
const READ_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOLS]);
|
|
38
|
+
const WRITE_TOOLS = new Set(["bash", "edit", "write"]);
|
|
39
|
+
const KNOWN_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
|
|
40
|
+
const SUBAGENT_WEB_EXTENSION = "npm:pi-web-access";
|
|
41
|
+
const INHERIT_SETTING = "inherit";
|
|
42
|
+
const MAX_RUNTIME_STEERING_MESSAGES = 20;
|
|
43
|
+
const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "thinking", "timeoutMs"]);
|
|
44
|
+
|
|
45
|
+
type ThinkingLevel = ModelThinkingLevel;
|
|
46
|
+
export type AgentAccess = "read" | "write";
|
|
47
|
+
export type AgentSource = "bundled" | "personal" | "project";
|
|
48
|
+
export type AgentScope = "user" | "project" | "both";
|
|
49
|
+
export type SubagentStatus = "queued" | "running" | "complete" | "failed" | "cancelled" | "limited";
|
|
50
|
+
|
|
51
|
+
export interface AgentRole {
|
|
52
|
+
name: string;
|
|
53
|
+
description: string;
|
|
54
|
+
access: AgentAccess;
|
|
55
|
+
tools: string[];
|
|
56
|
+
model?: string;
|
|
57
|
+
thinking?: string;
|
|
58
|
+
timeoutMs?: number;
|
|
59
|
+
prompt: string;
|
|
60
|
+
source: AgentSource;
|
|
61
|
+
filePath: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AgentDiscoveryResult {
|
|
65
|
+
agents: AgentRole[];
|
|
66
|
+
projectAgentsDir: string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SubagentUsage {
|
|
70
|
+
input: number;
|
|
71
|
+
output: number;
|
|
72
|
+
cacheRead: number;
|
|
73
|
+
cacheWrite: number;
|
|
74
|
+
totalTokens: number;
|
|
75
|
+
cost: {
|
|
76
|
+
input: number;
|
|
77
|
+
output: number;
|
|
78
|
+
cacheRead: number;
|
|
79
|
+
cacheWrite: number;
|
|
80
|
+
total: number;
|
|
81
|
+
};
|
|
82
|
+
turns: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SubagentTaskResult {
|
|
86
|
+
id: string;
|
|
87
|
+
agent: string;
|
|
88
|
+
agentSource: AgentSource | "unknown";
|
|
89
|
+
sourcePath?: string;
|
|
90
|
+
task: string;
|
|
91
|
+
access?: AgentAccess;
|
|
92
|
+
status: SubagentStatus;
|
|
93
|
+
model?: string;
|
|
94
|
+
thinking?: ThinkingLevel;
|
|
95
|
+
tools: string[];
|
|
96
|
+
trace: string[];
|
|
97
|
+
traceBytes: number;
|
|
98
|
+
traceTruncatedBytes: number;
|
|
99
|
+
stderr: string;
|
|
100
|
+
stderrBytes: number;
|
|
101
|
+
stderrTruncatedBytes: number;
|
|
102
|
+
output: string;
|
|
103
|
+
outputBytes: number;
|
|
104
|
+
outputTruncatedBytes: number;
|
|
105
|
+
toolCallCount: number;
|
|
106
|
+
usage: SubagentUsage;
|
|
107
|
+
durationMs: number;
|
|
108
|
+
exitCode: number | null;
|
|
109
|
+
terminationReason?: string;
|
|
110
|
+
errorMessage?: string;
|
|
111
|
+
step?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface SubagentDetails {
|
|
115
|
+
mode: "single" | "parallel" | "chain";
|
|
116
|
+
agentScope: AgentScope;
|
|
117
|
+
projectAgentsDir: string | null;
|
|
118
|
+
executionNote?: string;
|
|
119
|
+
results: SubagentTaskResult[];
|
|
120
|
+
aggregateUsage: SubagentUsage;
|
|
121
|
+
parentId?: string;
|
|
122
|
+
threads?: SubagentThread[];
|
|
123
|
+
activeThreads?: SubagentThread[];
|
|
124
|
+
doneThreads?: SubagentThread[];
|
|
125
|
+
selectedThreadId?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface ModelContext {
|
|
129
|
+
model?: Model<any>;
|
|
130
|
+
thinkingLevel?: ThinkingLevel;
|
|
131
|
+
modelRegistry: {
|
|
132
|
+
getAvailable(): Model<any>[];
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface ResolvedModel {
|
|
137
|
+
model: string;
|
|
138
|
+
thinking: ThinkingLevel;
|
|
139
|
+
definition: Model<any>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface SpawnedProcess {
|
|
143
|
+
stdout: NodeJS.ReadableStream;
|
|
144
|
+
stderr: NodeJS.ReadableStream;
|
|
145
|
+
pid?: number;
|
|
146
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
147
|
+
on(event: "error", listener: (error: Error) => void): this;
|
|
148
|
+
once(event: "close", listener: (code: number | null) => void): this;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number } & {
|
|
152
|
+
wallTimeMs?: number;
|
|
153
|
+
jsonlLineBytes?: number;
|
|
154
|
+
traceBytes?: number;
|
|
155
|
+
stderrBytes?: number;
|
|
156
|
+
taskOutputBytes?: number;
|
|
157
|
+
quotaTokens?: number;
|
|
158
|
+
quotaUsd?: number;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export interface SubagentRuntimeOptions {
|
|
162
|
+
bundledAgentsDir?: string;
|
|
163
|
+
userAgentsDir?: string;
|
|
164
|
+
webExtension?: string;
|
|
165
|
+
spawnProcess?: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SpawnedProcess;
|
|
166
|
+
limits?: Partial<SubagentLimits>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
class AgentConfigurationError extends Error {
|
|
170
|
+
constructor(filePath: string, field: string, message: string) {
|
|
171
|
+
super(`${filePath} [${field}]: ${message}`);
|
|
172
|
+
this.name = "AgentConfigurationError";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function emptyUsage(): SubagentUsage {
|
|
177
|
+
return {
|
|
178
|
+
input: 0,
|
|
179
|
+
output: 0,
|
|
180
|
+
cacheRead: 0,
|
|
181
|
+
cacheWrite: 0,
|
|
182
|
+
totalTokens: 0,
|
|
183
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
184
|
+
turns: 0,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function addUsage(target: SubagentUsage, source: Partial<SubagentUsage> | undefined): void {
|
|
189
|
+
if (!source) return;
|
|
190
|
+
target.input += source.input ?? 0;
|
|
191
|
+
target.output += source.output ?? 0;
|
|
192
|
+
target.cacheRead += source.cacheRead ?? 0;
|
|
193
|
+
target.cacheWrite += source.cacheWrite ?? 0;
|
|
194
|
+
target.totalTokens += source.totalTokens ?? 0;
|
|
195
|
+
target.cost.input += source.cost?.input ?? 0;
|
|
196
|
+
target.cost.output += source.cost?.output ?? 0;
|
|
197
|
+
target.cost.cacheRead += source.cost?.cacheRead ?? 0;
|
|
198
|
+
target.cost.cacheWrite += source.cost?.cacheWrite ?? 0;
|
|
199
|
+
target.cost.total += source.cost?.total ?? 0;
|
|
200
|
+
target.turns += source.turns ?? 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function aggregateUsage(results: SubagentTaskResult[]): SubagentUsage {
|
|
204
|
+
const total = emptyUsage();
|
|
205
|
+
for (const result of results) addUsage(total, result.usage);
|
|
206
|
+
return total;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function boundedText(text: string, maxBytes: number, marker: string): string {
|
|
210
|
+
const capped = truncateUtf8(text, maxBytes);
|
|
211
|
+
if (!capped.omittedBytes) return text;
|
|
212
|
+
const markerBytes = Buffer.byteLength(marker, "utf8");
|
|
213
|
+
if (markerBytes >= maxBytes) return truncateUtf8(text, maxBytes).text;
|
|
214
|
+
return `${truncateUtf8(text, maxBytes - markerBytes).text}${marker}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readBoundedFile(filePath: string, maxBytes: number): string {
|
|
218
|
+
let descriptor: number | undefined;
|
|
219
|
+
try {
|
|
220
|
+
descriptor = openSync(filePath, "r");
|
|
221
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
222
|
+
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
223
|
+
if (bytesRead > maxBytes) throw new AgentConfigurationError(filePath, "file", `exceeds ${maxBytes} bytes`);
|
|
224
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
225
|
+
} finally {
|
|
226
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function requiredString(frontmatter: Record<string, unknown>, filePath: string, field: string): string {
|
|
231
|
+
const value = frontmatter[field];
|
|
232
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
233
|
+
throw new AgentConfigurationError(filePath, field, "must be a non-empty string");
|
|
234
|
+
}
|
|
235
|
+
return value.trim();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function optionalPositiveInteger(
|
|
239
|
+
frontmatter: Record<string, unknown>,
|
|
240
|
+
filePath: string,
|
|
241
|
+
field: string,
|
|
242
|
+
fallback?: number,
|
|
243
|
+
maximum?: number,
|
|
244
|
+
): number | undefined {
|
|
245
|
+
const value = frontmatter[field];
|
|
246
|
+
if (value === undefined || value === "") return fallback;
|
|
247
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
248
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || maximum !== undefined && parsed > maximum) {
|
|
249
|
+
const bound = maximum === undefined ? "" : " no greater than " + maximum;
|
|
250
|
+
throw new AgentConfigurationError(filePath, field, `must be a positive integer${bound}`);
|
|
251
|
+
}
|
|
252
|
+
return parsed;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentLimits): AgentRole {
|
|
256
|
+
const content = readBoundedFile(filePath, limits.roleFileBytes);
|
|
257
|
+
let parsed: { frontmatter: Record<string, unknown>; body: string };
|
|
258
|
+
try {
|
|
259
|
+
parsed = parseFrontmatter<Record<string, unknown>>(content);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
throw new AgentConfigurationError(filePath, "frontmatter", error instanceof Error ? error.message : String(error));
|
|
262
|
+
}
|
|
263
|
+
const frontmatter = parsed.frontmatter;
|
|
264
|
+
for (const field of Object.keys(frontmatter)) {
|
|
265
|
+
if (!ROLE_FIELDS.has(field)) throw new AgentConfigurationError(filePath, field, "unknown role field");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const name = requiredString(frontmatter, filePath, "name");
|
|
269
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(name)) {
|
|
270
|
+
throw new AgentConfigurationError(filePath, "name", "must use 1-64 letters, numbers, dots, underscores, or hyphens");
|
|
271
|
+
}
|
|
272
|
+
const description = requiredString(frontmatter, filePath, "description");
|
|
273
|
+
if (description.length > 500) throw new AgentConfigurationError(filePath, "description", "must not exceed 500 characters");
|
|
274
|
+
|
|
275
|
+
const accessValue = requiredString(frontmatter, filePath, "access");
|
|
276
|
+
if (accessValue !== "read" && accessValue !== "write") {
|
|
277
|
+
throw new AgentConfigurationError(filePath, "access", 'must be "read" or "write"');
|
|
278
|
+
}
|
|
279
|
+
const toolsValue = requiredString(frontmatter, filePath, "tools");
|
|
280
|
+
const tools = [...new Set(toolsValue.split(",").map((tool) => tool.trim()).filter(Boolean))];
|
|
281
|
+
if (tools.length === 0) throw new AgentConfigurationError(filePath, "tools", "must contain at least one tool");
|
|
282
|
+
for (const tool of tools) {
|
|
283
|
+
if (!KNOWN_TOOLS.has(tool)) throw new AgentConfigurationError(filePath, "tools", `unknown child tool ${JSON.stringify(tool)}`);
|
|
284
|
+
if (accessValue === "read" && WRITE_TOOLS.has(tool)) {
|
|
285
|
+
throw new AgentConfigurationError(filePath, "tools", `read-only roles cannot use ${tool}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const prompt = parsed.body.trim();
|
|
290
|
+
if (!prompt) throw new AgentConfigurationError(filePath, "prompt", "Markdown body must be non-empty");
|
|
291
|
+
const modelValue = frontmatter.model;
|
|
292
|
+
if (modelValue !== undefined && (typeof modelValue !== "string" || !modelValue.trim())) {
|
|
293
|
+
throw new AgentConfigurationError(filePath, "model", "must be a non-empty string when provided");
|
|
294
|
+
}
|
|
295
|
+
const thinkingValue = frontmatter.thinking;
|
|
296
|
+
if (thinkingValue !== undefined && (typeof thinkingValue !== "string" || !thinkingValue.trim())) {
|
|
297
|
+
throw new AgentConfigurationError(filePath, "thinking", "must be a non-empty string when provided");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
name,
|
|
302
|
+
description,
|
|
303
|
+
access: accessValue,
|
|
304
|
+
tools,
|
|
305
|
+
model: typeof modelValue === "string" ? modelValue.trim() : undefined,
|
|
306
|
+
thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
|
|
307
|
+
timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", undefined, MAX_NODE_TIMER_MS),
|
|
308
|
+
prompt,
|
|
309
|
+
source,
|
|
310
|
+
filePath,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function loadAgentDirectory(dir: string, source: AgentSource, limits: SubagentLimits): AgentRole[] {
|
|
315
|
+
let entries;
|
|
316
|
+
try {
|
|
317
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
320
|
+
throw new Error(`Could not read ${source} agent directory ${dir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
321
|
+
}
|
|
322
|
+
const agents: AgentRole[] = [];
|
|
323
|
+
const names = new Set<string>();
|
|
324
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
325
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
326
|
+
const agent = parseAgentFile(path.join(dir, entry.name), source, limits);
|
|
327
|
+
if (names.has(agent.name)) throw new AgentConfigurationError(agent.filePath, "name", `duplicate ${source} role ${JSON.stringify(agent.name)}`);
|
|
328
|
+
names.add(agent.name);
|
|
329
|
+
agents.push(agent);
|
|
330
|
+
}
|
|
331
|
+
return agents;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isDirectory(candidate: string): boolean {
|
|
335
|
+
try {
|
|
336
|
+
return statSync(candidate).isDirectory();
|
|
337
|
+
} catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function findProjectAgentsDir(cwd: string): string | null {
|
|
343
|
+
let current = path.resolve(cwd);
|
|
344
|
+
while (true) {
|
|
345
|
+
const candidate = path.join(current, CONFIG_DIR_NAME, "agents");
|
|
346
|
+
if (isDirectory(candidate)) return candidate;
|
|
347
|
+
const parent = path.dirname(current);
|
|
348
|
+
if (parent === current) return null;
|
|
349
|
+
current = parent;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function discoverAgentRoles(
|
|
354
|
+
cwd: string,
|
|
355
|
+
scope: AgentScope,
|
|
356
|
+
projectTrusted: boolean,
|
|
357
|
+
options: Pick<SubagentRuntimeOptions, "bundledAgentsDir" | "userAgentsDir" | "limits"> = {},
|
|
358
|
+
): AgentDiscoveryResult {
|
|
359
|
+
const limits = { ...SUBAGENT_LIMITS, ...options.limits };
|
|
360
|
+
const bundledDir = options.bundledAgentsDir ?? fileURLToPath(new URL("./agents/", import.meta.url));
|
|
361
|
+
const userDir = options.userAgentsDir ?? path.join(getAgentDir(), "agents");
|
|
362
|
+
const wantsProject = scope === "project" || scope === "both";
|
|
363
|
+
if (wantsProject && !projectTrusted) throw new Error("Project agents require a trusted project");
|
|
364
|
+
const projectAgentsDir = wantsProject ? findProjectAgentsDir(cwd) : null;
|
|
365
|
+
|
|
366
|
+
const layers: Array<{ dir: string; source: AgentSource }> = [{ dir: bundledDir, source: "bundled" }];
|
|
367
|
+
if (scope === "user" || scope === "both") layers.push({ dir: userDir, source: "personal" });
|
|
368
|
+
if (wantsProject && projectAgentsDir) layers.push({ dir: projectAgentsDir, source: "project" });
|
|
369
|
+
|
|
370
|
+
const byName = new Map<string, AgentRole>();
|
|
371
|
+
for (const layer of layers) {
|
|
372
|
+
for (const agent of loadAgentDirectory(layer.dir, layer.source, limits)) byName.set(agent.name, agent);
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
agents: [...byName.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
|
376
|
+
projectAgentsDir,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function matchingModels(value: string, available: Model<any>[]): Model<any>[] {
|
|
381
|
+
const slash = value.indexOf("/");
|
|
382
|
+
if (slash >= 1 && slash < value.length - 1) {
|
|
383
|
+
const provider = value.slice(0, slash);
|
|
384
|
+
const id = value.slice(slash + 1);
|
|
385
|
+
return available.filter((model) => model.provider === provider && model.id === id);
|
|
386
|
+
}
|
|
387
|
+
return available.filter((model) => model.id === value);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function splitModelAndThinking(value: string, filePath: string, available: Model<any>[]): { model: string; thinking?: string } {
|
|
391
|
+
if (matchingModels(value, available).length > 0) return { model: value };
|
|
392
|
+
const colon = value.lastIndexOf(":");
|
|
393
|
+
if (colon < 0) return { model: value };
|
|
394
|
+
const model = value.slice(0, colon);
|
|
395
|
+
if (!model) throw new AgentConfigurationError(filePath, "model", "model identifier is missing");
|
|
396
|
+
if (matchingModels(model, available).length > 0) return { model, thinking: value.slice(colon + 1) };
|
|
397
|
+
return { model: value };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function resolveAvailableModel(value: string, filePath: string, available: Model<any>[]): Model<any> {
|
|
401
|
+
const matches = matchingModels(value, available);
|
|
402
|
+
if (matches.length === 0) throw new AgentConfigurationError(filePath, "model", `unavailable model ${JSON.stringify(value)}`);
|
|
403
|
+
if (matches.length > 1) throw new AgentConfigurationError(filePath, "model", `ambiguous model ${JSON.stringify(value)}; use provider/model`);
|
|
404
|
+
return matches[0]!;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function configuredSetting(override: string | undefined, roleSetting: string | undefined): string | undefined {
|
|
408
|
+
const overrideValue = override?.trim();
|
|
409
|
+
const roleValue = roleSetting?.trim();
|
|
410
|
+
const selected = overrideValue && overrideValue !== INHERIT_SETTING ? overrideValue : roleValue;
|
|
411
|
+
return selected && selected !== INHERIT_SETTING ? selected : undefined;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function resolveAgentModel(
|
|
415
|
+
agent: AgentRole,
|
|
416
|
+
ctx: ModelContext,
|
|
417
|
+
modelOverride?: string,
|
|
418
|
+
thinkingOverride?: string,
|
|
419
|
+
): ResolvedModel {
|
|
420
|
+
const inheritedThinking = ctx.thinkingLevel ?? "off";
|
|
421
|
+
const configuredModel = configuredSetting(modelOverride, agent.model);
|
|
422
|
+
const configuredThinking = configuredSetting(thinkingOverride, agent.thinking);
|
|
423
|
+
let definition: Model<any> | undefined;
|
|
424
|
+
let thinking: string = configuredThinking ?? inheritedThinking;
|
|
425
|
+
|
|
426
|
+
if (configuredModel) {
|
|
427
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
428
|
+
const requested = splitModelAndThinking(configuredModel, agent.filePath, available);
|
|
429
|
+
thinking = configuredThinking ?? requested.thinking ?? inheritedThinking;
|
|
430
|
+
definition = resolveAvailableModel(requested.model, agent.filePath, available);
|
|
431
|
+
} else {
|
|
432
|
+
definition = ctx.model;
|
|
433
|
+
if (!definition) throw new AgentConfigurationError(agent.filePath, "model", "no active parent model is available to inherit");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const supportedThinking = getSupportedThinkingLevels(definition) as readonly string[];
|
|
437
|
+
if (!supportedThinking.includes(thinking)) {
|
|
438
|
+
throw new AgentConfigurationError(agent.filePath, "thinking", `${definition.provider}/${definition.id} does not support thinking level ${thinking}`);
|
|
439
|
+
}
|
|
440
|
+
return { model: `${definition.provider}/${definition.id}`, thinking: thinking as ThinkingLevel, definition };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
444
|
+
const currentScript = process.argv[1];
|
|
445
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
446
|
+
if (currentScript && !isBunVirtualScript) {
|
|
447
|
+
try {
|
|
448
|
+
if (statSync(currentScript).isFile()) return { command: process.execPath, args: [currentScript, ...args] };
|
|
449
|
+
} catch {
|
|
450
|
+
// Fall through to the installed pi command.
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const executable = path.basename(process.execPath).toLocaleLowerCase();
|
|
454
|
+
return /^(node|bun)(\.exe)?$/u.test(executable)
|
|
455
|
+
? { command: "pi", args }
|
|
456
|
+
: { command: process.execPath, args };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function childProcessEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
460
|
+
const childEnvironment = { ...environment };
|
|
461
|
+
delete childEnvironment.PI_SESSION_FILE;
|
|
462
|
+
delete childEnvironment.PI_SESSION_ID;
|
|
463
|
+
return childEnvironment;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function defaultSpawnProcess(args: string[], cwd: string): SpawnedProcess {
|
|
467
|
+
const invocation = getPiInvocation(args);
|
|
468
|
+
return spawn(invocation.command, invocation.args, {
|
|
469
|
+
cwd,
|
|
470
|
+
detached: process.platform !== "win32",
|
|
471
|
+
env: childProcessEnvironment(),
|
|
472
|
+
shell: false,
|
|
473
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
474
|
+
windowsHide: true,
|
|
475
|
+
}) as unknown as SpawnedProcess;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBytes: number } {
|
|
479
|
+
const bytes = Buffer.from(text, "utf8");
|
|
480
|
+
if (bytes.length <= maxBytes) return { text, omittedBytes: 0 };
|
|
481
|
+
let truncated = bytes.subarray(0, maxBytes).toString("utf8");
|
|
482
|
+
if (truncated.endsWith("�")) truncated = truncated.slice(0, -1);
|
|
483
|
+
return { text: truncated, omittedBytes: bytes.length - Buffer.byteLength(truncated, "utf8") };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function makeQueuedResult(id: string, agent: string, task: string, step?: number): SubagentTaskResult {
|
|
487
|
+
return {
|
|
488
|
+
id,
|
|
489
|
+
agent,
|
|
490
|
+
agentSource: "unknown",
|
|
491
|
+
task,
|
|
492
|
+
status: "queued",
|
|
493
|
+
tools: [],
|
|
494
|
+
trace: [],
|
|
495
|
+
traceBytes: 0,
|
|
496
|
+
traceTruncatedBytes: 0,
|
|
497
|
+
stderr: "",
|
|
498
|
+
stderrBytes: 0,
|
|
499
|
+
stderrTruncatedBytes: 0,
|
|
500
|
+
output: "",
|
|
501
|
+
outputBytes: 0,
|
|
502
|
+
outputTruncatedBytes: 0,
|
|
503
|
+
toolCallCount: 0,
|
|
504
|
+
usage: emptyUsage(),
|
|
505
|
+
durationMs: 0,
|
|
506
|
+
exitCode: null,
|
|
507
|
+
step,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
|
|
512
|
+
return {
|
|
513
|
+
...result,
|
|
514
|
+
tools: [...result.tools],
|
|
515
|
+
trace: [...result.trace],
|
|
516
|
+
usage: { ...result.usage, cost: { ...result.usage.cost } },
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function mergeTaskResults(
|
|
521
|
+
previous: SubagentTaskResult | undefined,
|
|
522
|
+
next: SubagentTaskResult,
|
|
523
|
+
maxTraceBytes?: number,
|
|
524
|
+
maxStderrBytes?: number,
|
|
525
|
+
): SubagentTaskResult {
|
|
526
|
+
if (!previous) return cloneResult(next);
|
|
527
|
+
const merged = cloneResult(next);
|
|
528
|
+
const trace: string[] = [];
|
|
529
|
+
let traceBytes = 0;
|
|
530
|
+
let traceTruncatedBytes = previous.traceTruncatedBytes + next.traceTruncatedBytes;
|
|
531
|
+
for (const entry of [...previous.trace, ...next.trace]) {
|
|
532
|
+
const retained = truncateUtf8(entry, maxTraceBytes === undefined ? Buffer.byteLength(entry, "utf8") : Math.max(0, maxTraceBytes - traceBytes));
|
|
533
|
+
if (retained.text) trace.push(retained.text);
|
|
534
|
+
const retainedBytes = Buffer.byteLength(retained.text, "utf8");
|
|
535
|
+
traceBytes += retainedBytes;
|
|
536
|
+
traceTruncatedBytes += retained.omittedBytes;
|
|
537
|
+
}
|
|
538
|
+
merged.trace = trace;
|
|
539
|
+
merged.traceBytes = traceBytes;
|
|
540
|
+
merged.traceTruncatedBytes = traceTruncatedBytes;
|
|
541
|
+
const stderr = [previous.stderr, next.stderr].filter(Boolean).join("\n");
|
|
542
|
+
const retainedStderr = truncateUtf8(stderr, maxStderrBytes === undefined ? Buffer.byteLength(stderr, "utf8") : maxStderrBytes);
|
|
543
|
+
merged.stderr = retainedStderr.text;
|
|
544
|
+
merged.stderrBytes = previous.stderrBytes + next.stderrBytes;
|
|
545
|
+
merged.stderrTruncatedBytes = previous.stderrTruncatedBytes + next.stderrTruncatedBytes + retainedStderr.omittedBytes;
|
|
546
|
+
merged.output = next.output || previous.output;
|
|
547
|
+
merged.outputBytes = previous.outputBytes + next.outputBytes;
|
|
548
|
+
merged.outputTruncatedBytes = previous.outputTruncatedBytes + next.outputTruncatedBytes;
|
|
549
|
+
merged.toolCallCount = previous.toolCallCount + next.toolCallCount;
|
|
550
|
+
merged.usage = emptyUsage();
|
|
551
|
+
addUsage(merged.usage, previous.usage);
|
|
552
|
+
addUsage(merged.usage, next.usage);
|
|
553
|
+
merged.durationMs = previous.durationMs + next.durationMs;
|
|
554
|
+
return merged;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectAgentsDir: string | null, results: SubagentTaskResult[]): SubagentDetails {
|
|
558
|
+
const cloned = results.map(cloneResult);
|
|
559
|
+
return { mode, agentScope: scope, projectAgentsDir, results: cloned, aggregateUsage: aggregateUsage(cloned) };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function writeRolePrompt(agent: AgentRole): Promise<{ directory: string; filePath: string }> {
|
|
563
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-"));
|
|
564
|
+
const filePath = path.join(directory, `${agent.name.replace(/[^A-Za-z0-9_.-]/gu, "_")}.md`);
|
|
565
|
+
await writeFile(filePath, agent.prompt, { encoding: "utf8", mode: 0o600 });
|
|
566
|
+
return { directory, filePath };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
interface RunTaskOptions {
|
|
570
|
+
cwd: string;
|
|
571
|
+
agent: AgentRole;
|
|
572
|
+
task: string;
|
|
573
|
+
id: string;
|
|
574
|
+
step?: number;
|
|
575
|
+
model: ResolvedModel;
|
|
576
|
+
signal?: AbortSignal;
|
|
577
|
+
spawnProcess: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SpawnedProcess;
|
|
578
|
+
webExtension?: string;
|
|
579
|
+
projectTrusted: boolean;
|
|
580
|
+
limits: SubagentLimits;
|
|
581
|
+
sessionDirectory: string;
|
|
582
|
+
sessionId: string;
|
|
583
|
+
timeoutMs?: number;
|
|
584
|
+
onChange: (result: SubagentTaskResult) => void;
|
|
585
|
+
onHandle?: (handle: SubagentProcessHandle) => void;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function applyProcessResult(
|
|
589
|
+
target: SubagentTaskResult,
|
|
590
|
+
source: Readonly<SubagentProcessResult>,
|
|
591
|
+
startedAt: number,
|
|
592
|
+
onChange: (result: SubagentTaskResult) => void,
|
|
593
|
+
): void {
|
|
594
|
+
target.status = source.status;
|
|
595
|
+
target.trace = [...source.trace];
|
|
596
|
+
target.traceBytes = source.traceBytes;
|
|
597
|
+
target.traceTruncatedBytes = source.traceTruncatedBytes;
|
|
598
|
+
target.stderr = source.stderr;
|
|
599
|
+
target.stderrBytes = source.stderrBytes;
|
|
600
|
+
target.stderrTruncatedBytes = source.stderrTruncatedBytes;
|
|
601
|
+
target.output = source.output;
|
|
602
|
+
target.outputBytes = source.outputBytes;
|
|
603
|
+
target.outputTruncatedBytes = source.outputTruncatedBytes;
|
|
604
|
+
target.toolCallCount = source.toolCallCount;
|
|
605
|
+
target.usage = { ...source.usage, cost: { ...source.usage.cost } };
|
|
606
|
+
target.model = source.model ?? target.model;
|
|
607
|
+
target.terminationReason = source.terminationReason;
|
|
608
|
+
target.errorMessage = source.errorMessage;
|
|
609
|
+
target.exitCode = source.exitCode;
|
|
610
|
+
target.durationMs = source.durationMs || Date.now() - startedAt;
|
|
611
|
+
onChange(target);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
615
|
+
const { agent, limits } = options;
|
|
616
|
+
const result = makeQueuedResult(options.id, agent.name, options.task, options.step);
|
|
617
|
+
result.agentSource = agent.source;
|
|
618
|
+
result.sourcePath = agent.filePath;
|
|
619
|
+
result.access = agent.access;
|
|
620
|
+
result.tools = [...agent.tools];
|
|
621
|
+
result.model = options.model.model;
|
|
622
|
+
result.thinking = options.model.thinking;
|
|
623
|
+
result.status = "running";
|
|
624
|
+
const startedAt = Date.now();
|
|
625
|
+
options.onChange(result);
|
|
626
|
+
|
|
627
|
+
if (options.signal?.aborted) {
|
|
628
|
+
result.status = "cancelled";
|
|
629
|
+
result.terminationReason = "abort";
|
|
630
|
+
result.durationMs = Date.now() - startedAt;
|
|
631
|
+
options.onChange(result);
|
|
632
|
+
return result;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let promptDirectory: string | undefined;
|
|
636
|
+
try {
|
|
637
|
+
const prompt = await writeRolePrompt(agent);
|
|
638
|
+
promptDirectory = prompt.directory;
|
|
639
|
+
if (options.signal?.aborted) {
|
|
640
|
+
result.status = "cancelled";
|
|
641
|
+
result.terminationReason = "abort";
|
|
642
|
+
result.durationMs = Date.now() - startedAt;
|
|
643
|
+
options.onChange(result);
|
|
644
|
+
return result;
|
|
645
|
+
}
|
|
646
|
+
const args = [
|
|
647
|
+
"--mode", "json",
|
|
648
|
+
"-p",
|
|
649
|
+
"--session-dir", options.sessionDirectory,
|
|
650
|
+
"--session-id", options.sessionId,
|
|
651
|
+
"--no-extensions",
|
|
652
|
+
"--extension", options.webExtension ?? SUBAGENT_WEB_EXTENSION,
|
|
653
|
+
"--no-prompt-templates",
|
|
654
|
+
options.projectTrusted ? "--approve" : "--no-approve",
|
|
655
|
+
"--model", options.model.model,
|
|
656
|
+
"--thinking", options.model.thinking,
|
|
657
|
+
"--tools", agent.tools.join(","),
|
|
658
|
+
"--append-system-prompt", prompt.filePath,
|
|
659
|
+
`Task: ${options.task}`,
|
|
660
|
+
];
|
|
661
|
+
const handle = runSubagentProcess({
|
|
662
|
+
args,
|
|
663
|
+
cwd: options.cwd,
|
|
664
|
+
signal: options.signal,
|
|
665
|
+
spawnProcess: options.spawnProcess,
|
|
666
|
+
limits: {
|
|
667
|
+
...(options.timeoutMs === undefined ? {} : { wallTimeMs: options.timeoutMs }),
|
|
668
|
+
...(limits.jsonlLineBytes === undefined ? {} : { jsonlLineBytes: limits.jsonlLineBytes }),
|
|
669
|
+
...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes }),
|
|
670
|
+
...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes }),
|
|
671
|
+
...(limits.taskOutputBytes === undefined ? {} : { outputBytes: limits.taskOutputBytes }),
|
|
672
|
+
...(limits.quotaTokens === undefined ? {} : { quotaTokens: limits.quotaTokens }),
|
|
673
|
+
...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd }),
|
|
674
|
+
killGraceMs: limits.killGraceMs,
|
|
675
|
+
},
|
|
676
|
+
retention: {
|
|
677
|
+
traceBytes: limits.traceRetentionBytes,
|
|
678
|
+
stderrBytes: limits.stderrRetentionBytes,
|
|
679
|
+
outputBytes: limits.taskOutputRetentionBytes,
|
|
680
|
+
},
|
|
681
|
+
onUpdate: (next) => applyProcessResult(result, next, startedAt, options.onChange),
|
|
682
|
+
});
|
|
683
|
+
options.onHandle?.(handle);
|
|
684
|
+
const final = await handle.result;
|
|
685
|
+
applyProcessResult(result, final, startedAt, options.onChange);
|
|
686
|
+
return result;
|
|
687
|
+
} catch (error) {
|
|
688
|
+
result.status = options.signal?.aborted ? "cancelled" : "failed";
|
|
689
|
+
result.terminationReason = options.signal?.aborted ? "abort" : "spawn_error";
|
|
690
|
+
result.errorMessage = error instanceof Error ? error.message : String(error);
|
|
691
|
+
result.durationMs = Date.now() - startedAt;
|
|
692
|
+
options.onChange(result);
|
|
693
|
+
return result;
|
|
694
|
+
} finally {
|
|
695
|
+
if (promptDirectory) {
|
|
696
|
+
try {
|
|
697
|
+
await rm(promptDirectory, { recursive: true, force: true });
|
|
698
|
+
} catch {
|
|
699
|
+
// Temporary prompt cleanup is best effort after child termination.
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function mapReadTasks<T>(items: T[], concurrency: number, run: (item: T, index: number) => Promise<void>): Promise<void> {
|
|
706
|
+
let next = 0;
|
|
707
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
708
|
+
while (true) {
|
|
709
|
+
const index = next;
|
|
710
|
+
next += 1;
|
|
711
|
+
if (index >= items.length) return;
|
|
712
|
+
await run(items[index]!, index);
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
await Promise.all(workers);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function waitForConfirmedProcessExit(handle: SubagentProcessHandle, timeoutMs = 1_000): Promise<boolean> {
|
|
719
|
+
if (handle.hasExited) return Promise.resolve(true);
|
|
720
|
+
return new Promise((resolve) => {
|
|
721
|
+
let settled = false;
|
|
722
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
723
|
+
const finish = (exited: boolean): void => {
|
|
724
|
+
if (settled) return;
|
|
725
|
+
settled = true;
|
|
726
|
+
if (timeout) clearTimeout(timeout);
|
|
727
|
+
resolve(exited);
|
|
728
|
+
};
|
|
729
|
+
timeout = setTimeout(() => finish(handle.hasExited), timeoutMs);
|
|
730
|
+
void handle.exited.then(() => finish(true));
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
type TaskInput = { agent: string; task: string };
|
|
735
|
+
const SUBAGENT_ACTION = {
|
|
736
|
+
spawn: "spawn",
|
|
737
|
+
list: "list",
|
|
738
|
+
inspect: "inspect",
|
|
739
|
+
steer: "steer",
|
|
740
|
+
interrupt: "interrupt",
|
|
741
|
+
collect: "collect",
|
|
742
|
+
close: "close",
|
|
743
|
+
} as const;
|
|
744
|
+
type SubagentAction = typeof SUBAGENT_ACTION[keyof typeof SUBAGENT_ACTION];
|
|
745
|
+
|
|
746
|
+
type SpawnOptions = {
|
|
747
|
+
model?: string;
|
|
748
|
+
thinking?: string;
|
|
749
|
+
agentScope?: AgentScope;
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
type SpawnSingleRequest = SpawnOptions & {
|
|
753
|
+
action?: "spawn";
|
|
754
|
+
agent: string;
|
|
755
|
+
task: string;
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
type SpawnParallelRequest = SpawnOptions & {
|
|
759
|
+
action?: "spawn";
|
|
760
|
+
tasks: TaskInput[];
|
|
761
|
+
writerConcurrency?: number;
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
type SpawnChainRequest = SpawnOptions & {
|
|
765
|
+
action?: "spawn";
|
|
766
|
+
chain: TaskInput[];
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
export type NormalizedSubagentRequest =
|
|
770
|
+
| { kind: "spawn-single"; input: SpawnSingleRequest }
|
|
771
|
+
| { kind: "spawn-parallel"; input: SpawnParallelRequest }
|
|
772
|
+
| { kind: "spawn-chain"; input: SpawnChainRequest }
|
|
773
|
+
| { kind: "list"; input: { action: "list" } }
|
|
774
|
+
| { kind: "inspect"; input: { action: "inspect"; threadId: string } }
|
|
775
|
+
| { kind: "steer"; input: { action: "steer"; threadId: string; message: string } }
|
|
776
|
+
| { kind: "interrupt-one"; input: { action: "interrupt"; threadId: string } }
|
|
777
|
+
| { kind: "interrupt-all"; input: { action: "interrupt"; all: true } }
|
|
778
|
+
| { kind: "collect"; input: { action: "collect"; threadId: string } }
|
|
779
|
+
| { kind: "close"; input: { action: "close"; threadId: string } };
|
|
780
|
+
|
|
781
|
+
type SubagentRequestParse =
|
|
782
|
+
| { ok: true; request: NormalizedSubagentRequest }
|
|
783
|
+
| { ok: false; message: string };
|
|
784
|
+
|
|
785
|
+
const SUBAGENT_ACTIONS = Object.values(SUBAGENT_ACTION);
|
|
786
|
+
const SPAWN_OPTION_FIELDS = ["model", "thinking", "agentScope"] as const;
|
|
787
|
+
|
|
788
|
+
function requireRecord(value: unknown, name: string): Record<string, unknown> {
|
|
789
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
790
|
+
throw new Error(`Invalid ${name}: expected an object.`);
|
|
791
|
+
}
|
|
792
|
+
return value as Record<string, unknown>;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function requireAction(value: unknown): SubagentAction {
|
|
796
|
+
if (typeof value !== "string" || !SUBAGENT_ACTIONS.includes(value as SubagentAction)) {
|
|
797
|
+
throw new Error(`Invalid subagent request: action must be one of ${SUBAGENT_ACTIONS.join(", ")}.`);
|
|
798
|
+
}
|
|
799
|
+
return value as SubagentAction;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function requireTextField(record: Record<string, unknown>, field: string, maxLength: number): string {
|
|
803
|
+
const value = record[field];
|
|
804
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
805
|
+
throw new Error(`Invalid subagent request: ${field} must be a non-empty string.`);
|
|
806
|
+
}
|
|
807
|
+
if ([...value].length > maxLength) {
|
|
808
|
+
throw new Error(`Invalid subagent request: ${field} must be no longer than ${maxLength} characters.`);
|
|
809
|
+
}
|
|
810
|
+
return value;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function requireOnlyFields(record: Record<string, unknown>, allowed: readonly string[], action: string): void {
|
|
814
|
+
const allowedFields = new Set(allowed);
|
|
815
|
+
const invalid = Object.keys(record).find((field) => !allowedFields.has(field));
|
|
816
|
+
if (invalid) throw new Error(`Invalid subagent request: field ${JSON.stringify(invalid)} is not valid with action ${JSON.stringify(action)}.`);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function parseTaskInputs(value: unknown, field: "tasks" | "chain", limits: Pick<SubagentLimits, "maxTasks" | "taskCharacters">): TaskInput[] {
|
|
820
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
821
|
+
throw new Error(`Invalid subagent request: ${field} must be a non-empty array.`);
|
|
822
|
+
}
|
|
823
|
+
if (value.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
|
|
824
|
+
return value.map((entry, index) => {
|
|
825
|
+
const task = requireRecord(entry, `${field}[${index}]`);
|
|
826
|
+
requireOnlyFields(task, ["agent", "task"], `spawn ${field}`);
|
|
827
|
+
return {
|
|
828
|
+
agent: requireTextField(task, "agent", 64),
|
|
829
|
+
task: requireTextField(task, "task", limits.taskCharacters),
|
|
830
|
+
};
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function parseSpawnOptions(record: Record<string, unknown>): SpawnOptions {
|
|
835
|
+
const options: SpawnOptions = {};
|
|
836
|
+
if (Object.hasOwn(record, "model")) options.model = requireTextField(record, "model", 256);
|
|
837
|
+
if (Object.hasOwn(record, "thinking")) options.thinking = requireTextField(record, "thinking", 16);
|
|
838
|
+
if (Object.hasOwn(record, "agentScope")) {
|
|
839
|
+
const scope = record.agentScope;
|
|
840
|
+
if (scope !== "user" && scope !== "project" && scope !== "both") {
|
|
841
|
+
throw new Error('Invalid subagent request: agentScope must be "user", "project", or "both".');
|
|
842
|
+
}
|
|
843
|
+
options.agentScope = scope;
|
|
844
|
+
}
|
|
845
|
+
return options;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export function normalizeSubagentRequest(
|
|
849
|
+
value: unknown,
|
|
850
|
+
limits: Pick<SubagentLimits, "maxTasks" | "taskCharacters"> = SUBAGENT_LIMITS,
|
|
851
|
+
): NormalizedSubagentRequest {
|
|
852
|
+
const record = requireRecord(value, "subagent request");
|
|
853
|
+
const action = record.action === undefined ? SUBAGENT_ACTION.spawn : requireAction(record.action);
|
|
854
|
+
|
|
855
|
+
if (action !== "steer" && Object.hasOwn(record, "message")) {
|
|
856
|
+
throw new Error('Invalid subagent request: message is only valid with action "steer". Use {"action":"steer","threadId":"...","message":"..."}.');
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (action === "spawn") {
|
|
860
|
+
const hasSingle = Object.hasOwn(record, "agent") || Object.hasOwn(record, "task");
|
|
861
|
+
const hasParallel = Object.hasOwn(record, "tasks");
|
|
862
|
+
const hasChain = Object.hasOwn(record, "chain");
|
|
863
|
+
if (Number(hasSingle) + Number(hasParallel) + Number(hasChain) !== 1) {
|
|
864
|
+
throw new Error("Invalid subagent request: choose exactly one spawn shape: agent + task, tasks, or chain.");
|
|
865
|
+
}
|
|
866
|
+
if (Object.hasOwn(record, "writerConcurrency") && !hasParallel) {
|
|
867
|
+
throw new Error("Invalid subagent request: writerConcurrency is only valid with parallel tasks.");
|
|
868
|
+
}
|
|
869
|
+
const actionField = Object.hasOwn(record, "action") ? { action: "spawn" as const } : {};
|
|
870
|
+
const options = parseSpawnOptions(record);
|
|
871
|
+
if (hasSingle) {
|
|
872
|
+
requireOnlyFields(record, ["action", "agent", "task", ...SPAWN_OPTION_FIELDS], "spawn single");
|
|
873
|
+
return {
|
|
874
|
+
kind: "spawn-single",
|
|
875
|
+
input: {
|
|
876
|
+
...actionField,
|
|
877
|
+
agent: requireTextField(record, "agent", 64),
|
|
878
|
+
task: requireTextField(record, "task", limits.taskCharacters),
|
|
879
|
+
...options,
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
if (hasParallel) {
|
|
884
|
+
requireOnlyFields(record, ["action", "tasks", "writerConcurrency", ...SPAWN_OPTION_FIELDS], "spawn parallel");
|
|
885
|
+
let writerConcurrency: number | undefined;
|
|
886
|
+
if (Object.hasOwn(record, "writerConcurrency")) {
|
|
887
|
+
writerConcurrency = record.writerConcurrency as number;
|
|
888
|
+
if (!Number.isSafeInteger(writerConcurrency) || writerConcurrency < 1 || writerConcurrency > limits.maxTasks) {
|
|
889
|
+
throw new Error(`writerConcurrency must be a positive integer no greater than ${limits.maxTasks}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return {
|
|
893
|
+
kind: "spawn-parallel",
|
|
894
|
+
input: {
|
|
895
|
+
...actionField,
|
|
896
|
+
tasks: parseTaskInputs(record.tasks, "tasks", limits),
|
|
897
|
+
...(writerConcurrency === undefined ? {} : { writerConcurrency }),
|
|
898
|
+
...options,
|
|
899
|
+
},
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
requireOnlyFields(record, ["action", "chain", ...SPAWN_OPTION_FIELDS], "spawn chain");
|
|
903
|
+
return {
|
|
904
|
+
kind: "spawn-chain",
|
|
905
|
+
input: { ...actionField, chain: parseTaskInputs(record.chain, "chain", limits), ...options },
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (action === "list") {
|
|
910
|
+
requireOnlyFields(record, ["action"], action);
|
|
911
|
+
return { kind: "list", input: { action } };
|
|
912
|
+
}
|
|
913
|
+
if (action === "inspect" || action === "collect" || action === "close") {
|
|
914
|
+
requireOnlyFields(record, ["action", "threadId"], action);
|
|
915
|
+
const input = { action, threadId: requireTextField(record, "threadId", 128) };
|
|
916
|
+
return { kind: action, input } as NormalizedSubagentRequest;
|
|
917
|
+
}
|
|
918
|
+
if (action === "steer") {
|
|
919
|
+
requireOnlyFields(record, ["action", "threadId", "message"], action);
|
|
920
|
+
return {
|
|
921
|
+
kind: "steer",
|
|
922
|
+
input: {
|
|
923
|
+
action,
|
|
924
|
+
threadId: requireTextField(record, "threadId", 128),
|
|
925
|
+
message: requireTextField(record, "message", 4_000),
|
|
926
|
+
},
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
requireOnlyFields(record, ["action", "threadId", "all"], action);
|
|
931
|
+
const hasThreadId = Object.hasOwn(record, "threadId");
|
|
932
|
+
const hasAll = Object.hasOwn(record, "all");
|
|
933
|
+
if (Number(hasThreadId) + Number(hasAll) !== 1 || hasAll && record.all !== true) {
|
|
934
|
+
throw new Error('Invalid subagent request: action "interrupt" requires exactly one of threadId or all: true.');
|
|
935
|
+
}
|
|
936
|
+
if (hasThreadId) {
|
|
937
|
+
return { kind: "interrupt-one", input: { action, threadId: requireTextField(record, "threadId", 128) } };
|
|
938
|
+
}
|
|
939
|
+
return { kind: "interrupt-all", input: { action, all: true } };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
export function tryNormalizeSubagentRequest(
|
|
943
|
+
value: unknown,
|
|
944
|
+
limits: Pick<SubagentLimits, "maxTasks" | "taskCharacters"> = SUBAGENT_LIMITS,
|
|
945
|
+
): SubagentRequestParse {
|
|
946
|
+
try {
|
|
947
|
+
return { ok: true, request: normalizeSubagentRequest(value, limits) };
|
|
948
|
+
} catch (error) {
|
|
949
|
+
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxReadConcurrency" | "taskCharacters">) {
|
|
954
|
+
const taskSchema = Type.Object({
|
|
955
|
+
agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
|
|
956
|
+
task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Bounded task for the role" }),
|
|
957
|
+
}, { additionalProperties: false });
|
|
958
|
+
const chainTaskSchema = Type.Object({
|
|
959
|
+
agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
|
|
960
|
+
task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task with optional {previous} handoff placeholder" }),
|
|
961
|
+
}, { additionalProperties: false });
|
|
962
|
+
const action = Type.Optional(Type.Literal(SUBAGENT_ACTION.spawn, { default: SUBAGENT_ACTION.spawn, description: "Spawn child threads" }));
|
|
963
|
+
const spawnOptions = {
|
|
964
|
+
model: Type.Optional(Type.String({ minLength: 1, maxLength: 256, description: "Model for every task as provider/model; inherit uses each role setting or the active parent" })),
|
|
965
|
+
thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
|
|
966
|
+
agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
|
|
967
|
+
default: "user",
|
|
968
|
+
description: "Role sources: user includes bundled and personal; project includes bundled and trusted project; both includes all",
|
|
969
|
+
})),
|
|
970
|
+
};
|
|
971
|
+
const threadId = Type.String({ minLength: 1, maxLength: 128, description: "Stable child thread ID" });
|
|
972
|
+
const exportProperties = {
|
|
973
|
+
action: Type.String({ description: "Spawn, list, inspect, steer, interrupt, collect, or close" }),
|
|
974
|
+
threadId: Type.String({ description: "Child thread ID for a lifecycle action" }),
|
|
975
|
+
message: Type.String({ description: "Steering message; valid only with action steer" }),
|
|
976
|
+
all: Type.Boolean({ description: "Interrupt every active child thread" }),
|
|
977
|
+
agent: Type.String({ description: "Agent role for a single spawn" }),
|
|
978
|
+
task: Type.String({ description: "Task for a single spawn" }),
|
|
979
|
+
tasks: Type.Array(Type.Any(), { description: "Parallel role tasks" }),
|
|
980
|
+
writerConcurrency: Type.Integer({ description: "Shared-pool cap for parallel writer tasks" }),
|
|
981
|
+
chain: Type.Array(Type.Any(), { description: "Sequential role tasks" }),
|
|
982
|
+
model: Type.String({ description: "Model for every spawned task" }),
|
|
983
|
+
thinking: Type.String({ description: "Thinking effort for every spawned task" }),
|
|
984
|
+
agentScope: Type.String({ description: "Role sources for a spawn" }),
|
|
985
|
+
};
|
|
986
|
+
const schema = Type.Union([
|
|
987
|
+
Type.Object({ action, agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" }), task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task for single mode" }), ...spawnOptions }, { additionalProperties: false }),
|
|
988
|
+
Type.Object({ action, tasks: Type.Array(taskSchema, { minItems: 1, maxItems: limits.maxTasks, description: `Parallel role tasks: read-only batches run concurrently up to ${limits.maxReadConcurrency}; batches with writers use one shared slot by default. Set writerConcurrency to opt into a larger shared pool; concurrent writers share the parent worktree, so callers must prove path ownership` }), writerConcurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: limits.maxTasks, description: `Optional shared-pool cap for parallel tasks that include writers. Defaults to 1 when writers are selected; values above 1 opt into concurrent shared-worktree writes. Concurrent writers must prove path ownership` })), ...spawnOptions }, { additionalProperties: false }),
|
|
989
|
+
Type.Object({ action, chain: Type.Array(chainTaskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" }), ...spawnOptions }, { additionalProperties: false }),
|
|
990
|
+
Type.Object({ action: Type.Literal(SUBAGENT_ACTION.list) }, { additionalProperties: false }),
|
|
991
|
+
Type.Object({ action: Type.Literal(SUBAGENT_ACTION.inspect), threadId }, { additionalProperties: false }),
|
|
992
|
+
Type.Object({ action: Type.Literal(SUBAGENT_ACTION.steer), threadId, message: Type.String({ minLength: 1, maxLength: 4_000, description: "Bounded steering message" }) }, { additionalProperties: false }),
|
|
993
|
+
Type.Object({ action: Type.Literal(SUBAGENT_ACTION.interrupt), threadId }, { additionalProperties: false }),
|
|
994
|
+
Type.Object({ action: Type.Literal(SUBAGENT_ACTION.interrupt), all: Type.Literal(true, { description: "Interrupt every active child thread" }) }, { additionalProperties: false }),
|
|
995
|
+
Type.Object({ action: Type.Literal(SUBAGENT_ACTION.collect), threadId }, { additionalProperties: false }),
|
|
996
|
+
Type.Object({ action: Type.Literal(SUBAGENT_ACTION.close), threadId }, { additionalProperties: false }),
|
|
997
|
+
]);
|
|
998
|
+
// Pi's HTML export reads properties; providers receive only the action-specific union.
|
|
999
|
+
Object.defineProperty(schema, "properties", { value: exportProperties });
|
|
1000
|
+
return schema;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
type ToolUpdate = (partial: { content: Array<{ type: "text"; text: string }>; details: SubagentDetails }) => void;
|
|
1004
|
+
|
|
1005
|
+
function requestedAgents(params: { agent?: string; tasks?: TaskInput[]; chain?: TaskInput[] }): string[] {
|
|
1006
|
+
if (params.agent) return [params.agent];
|
|
1007
|
+
return (params.tasks ?? params.chain ?? []).map((task) => task.agent);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function clipCharacters(text: string, maxCharacters: number, fromEnd = false): string {
|
|
1011
|
+
const characters = [...text];
|
|
1012
|
+
if (characters.length <= maxCharacters) return text;
|
|
1013
|
+
return (fromEnd ? characters.slice(-maxCharacters) : characters.slice(0, maxCharacters)).join("");
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function codePointLength(text: string): number {
|
|
1017
|
+
let length = 0;
|
|
1018
|
+
for (const _character of text) length += 1;
|
|
1019
|
+
return length;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function expandChainTask(template: string, previous: string, maxCharacters: number): string | undefined {
|
|
1023
|
+
const placeholder = "{previous}";
|
|
1024
|
+
let occurrences = 0;
|
|
1025
|
+
let searchFrom = 0;
|
|
1026
|
+
while (true) {
|
|
1027
|
+
const index = template.indexOf(placeholder, searchFrom);
|
|
1028
|
+
if (index < 0) break;
|
|
1029
|
+
occurrences += 1;
|
|
1030
|
+
searchFrom = index + placeholder.length;
|
|
1031
|
+
}
|
|
1032
|
+
if (occurrences === 0) return codePointLength(template) <= maxCharacters ? template : undefined;
|
|
1033
|
+
|
|
1034
|
+
const expandedCharacters = codePointLength(template) + occurrences * (codePointLength(previous) - codePointLength(placeholder));
|
|
1035
|
+
if (expandedCharacters > maxCharacters) return undefined;
|
|
1036
|
+
|
|
1037
|
+
const pieces: string[] = [];
|
|
1038
|
+
let start = 0;
|
|
1039
|
+
while (true) {
|
|
1040
|
+
const index = template.indexOf(placeholder, start);
|
|
1041
|
+
if (index < 0) {
|
|
1042
|
+
pieces.push(template.slice(start));
|
|
1043
|
+
break;
|
|
1044
|
+
}
|
|
1045
|
+
pieces.push(template.slice(start, index), previous);
|
|
1046
|
+
start = index + placeholder.length;
|
|
1047
|
+
}
|
|
1048
|
+
return pieces.join("");
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function buildSteeredTask(task: string, steering: readonly string[], maxCharacters: number): string {
|
|
1052
|
+
const steeringLabel = "\n\nParent steering:\n";
|
|
1053
|
+
const steeringText = clipCharacters(steering.join("\n"), Math.max(0, maxCharacters - [...steeringLabel].length), true);
|
|
1054
|
+
const required = [...steeringLabel, ...steeringText].length;
|
|
1055
|
+
const taskText = clipCharacters(task, Math.max(0, maxCharacters - required));
|
|
1056
|
+
return `${taskText}${steeringLabel}${steeringText}`;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function formatUsage(usage: SubagentUsage): string {
|
|
1060
|
+
const parts = [`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`, `${usage.totalTokens} tokens`];
|
|
1061
|
+
if (usage.cost.total) parts.push(`$${usage.cost.total.toFixed(4)}`);
|
|
1062
|
+
return parts.join(" · ");
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskResult[], maxBytes: number): string {
|
|
1066
|
+
const sections = results.map((result) => {
|
|
1067
|
+
const heading = `### ${result.id} · ${result.agent} · ${result.status}`;
|
|
1068
|
+
const reason = result.terminationReason && result.terminationReason !== "completed" ? `\nReason: ${result.terminationReason}` : "";
|
|
1069
|
+
const body = result.output || result.errorMessage || result.stderr.trim() || "(no output)";
|
|
1070
|
+
const truncation = result.outputTruncatedBytes ? `\n\n[Task output truncated: ${result.outputTruncatedBytes} bytes omitted; bounded detail is available when expanded.]` : "";
|
|
1071
|
+
return `${heading}${reason}\n\n${body}${truncation}`;
|
|
1072
|
+
});
|
|
1073
|
+
const complete = results.filter((result) => result.status === "complete").length;
|
|
1074
|
+
const text = `${mode}: ${complete}/${results.length} complete · ${formatUsage(aggregateUsage(results))}\n\n${sections.join("\n\n---\n\n")}`;
|
|
1075
|
+
const marker = "\n\n[Combined subagent output truncated to 50 KiB; inspect the expanded tool result for bounded per-task details.]";
|
|
1076
|
+
return boundedText(text, maxBytes, marker);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function statusColor(status: SubagentStatus): "accent" | "success" | "error" | "warning" | "muted" {
|
|
1080
|
+
if (status === "running") return "accent";
|
|
1081
|
+
if (status === "complete") return "success";
|
|
1082
|
+
if (status === "failed") return "error";
|
|
1083
|
+
if (status === "limited" || status === "cancelled") return "warning";
|
|
1084
|
+
return "muted";
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function statusIcon(status: SubagentStatus): string {
|
|
1088
|
+
if (status === "running") return "✻";
|
|
1089
|
+
if (status === "complete") return "✓";
|
|
1090
|
+
if (status === "failed") return "✗";
|
|
1091
|
+
if (status === "queued") return "○";
|
|
1092
|
+
return "!";
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
interface ActiveThreadRuntime {
|
|
1096
|
+
controller: AbortController;
|
|
1097
|
+
handle?: SubagentProcessHandle;
|
|
1098
|
+
handles: Set<SubagentProcessHandle>;
|
|
1099
|
+
steering: string[];
|
|
1100
|
+
restarting: boolean;
|
|
1101
|
+
traceCount: number;
|
|
1102
|
+
startedAt: number;
|
|
1103
|
+
aggregate?: SubagentTaskResult;
|
|
1104
|
+
requestedReason?: string;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function parentThreadId(ctx: ExtensionContext): string {
|
|
1108
|
+
try {
|
|
1109
|
+
const id = ctx.sessionManager?.getSessionId?.();
|
|
1110
|
+
if (id) return `main:${id}`;
|
|
1111
|
+
} catch {
|
|
1112
|
+
// Test and RPC contexts may not expose a session manager.
|
|
1113
|
+
}
|
|
1114
|
+
return "main";
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function threadCapabilityBoundary(agent: AgentRole): {
|
|
1118
|
+
filesystem: "read" | "write";
|
|
1119
|
+
network: "none" | "read";
|
|
1120
|
+
process: "none" | "limited";
|
|
1121
|
+
childThreads: false;
|
|
1122
|
+
} {
|
|
1123
|
+
return {
|
|
1124
|
+
filesystem: agent.access,
|
|
1125
|
+
network: agent.tools.some((tool) => WEB_TOOLS.has(tool)) ? "read" : "none",
|
|
1126
|
+
process: agent.tools.includes("bash") ? "limited" : "none",
|
|
1127
|
+
childThreads: false,
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function threadUsage(usage: SubagentUsage): SubagentThread["usage"] {
|
|
1132
|
+
return {
|
|
1133
|
+
inputTokens: usage.input,
|
|
1134
|
+
outputTokens: usage.output,
|
|
1135
|
+
cacheReadTokens: usage.cacheRead,
|
|
1136
|
+
cacheWriteTokens: usage.cacheWrite,
|
|
1137
|
+
totalTokens: usage.totalTokens,
|
|
1138
|
+
costUsd: usage.cost.total,
|
|
1139
|
+
turns: usage.turns,
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function legacyStatus(state: SubagentThreadState): SubagentStatus {
|
|
1144
|
+
if (state === "active") return "running";
|
|
1145
|
+
if (state === "done" || state === "closed") return "complete";
|
|
1146
|
+
if (state === "failed") return "failed";
|
|
1147
|
+
if (state === "stopped") return "cancelled";
|
|
1148
|
+
return "queued";
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function threadResult(thread: SubagentThread, source?: SubagentTaskResult): SubagentTaskResult {
|
|
1152
|
+
if (source) {
|
|
1153
|
+
const result = cloneResult(source);
|
|
1154
|
+
if (thread.state === "queued") result.status = "queued";
|
|
1155
|
+
else if (thread.state === "active") result.status = "running";
|
|
1156
|
+
return result;
|
|
1157
|
+
}
|
|
1158
|
+
return {
|
|
1159
|
+
...makeQueuedResult(thread.id, thread.role, thread.prompt),
|
|
1160
|
+
status: legacyStatus(thread.state),
|
|
1161
|
+
agentSource: "unknown",
|
|
1162
|
+
model: thread.model,
|
|
1163
|
+
tools: [...thread.tools],
|
|
1164
|
+
trace: thread.trace.map((event) => event.message ?? event.kind),
|
|
1165
|
+
usage: {
|
|
1166
|
+
input: thread.usage.inputTokens,
|
|
1167
|
+
output: thread.usage.outputTokens,
|
|
1168
|
+
cacheRead: thread.usage.cacheReadTokens,
|
|
1169
|
+
cacheWrite: thread.usage.cacheWriteTokens,
|
|
1170
|
+
totalTokens: thread.usage.totalTokens,
|
|
1171
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: thread.usage.costUsd },
|
|
1172
|
+
turns: thread.usage.turns,
|
|
1173
|
+
},
|
|
1174
|
+
output: thread.result ?? "",
|
|
1175
|
+
toolCallCount: 0,
|
|
1176
|
+
terminationReason: thread.stopReason,
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function threadBoardRecord(result: SubagentTaskResult): ThreadBoardRecord {
|
|
1181
|
+
return {
|
|
1182
|
+
id: result.id,
|
|
1183
|
+
agent: result.agent,
|
|
1184
|
+
task: result.task,
|
|
1185
|
+
status: result.status,
|
|
1186
|
+
usage: {
|
|
1187
|
+
input: result.usage.input,
|
|
1188
|
+
output: result.usage.output,
|
|
1189
|
+
cacheRead: result.usage.cacheRead,
|
|
1190
|
+
cacheWrite: result.usage.cacheWrite,
|
|
1191
|
+
totalTokens: result.usage.totalTokens,
|
|
1192
|
+
turns: result.usage.turns,
|
|
1193
|
+
cost: result.usage.cost.total,
|
|
1194
|
+
},
|
|
1195
|
+
trace: result.trace,
|
|
1196
|
+
traceTruncatedBytes: result.traceTruncatedBytes,
|
|
1197
|
+
handoff: result.output,
|
|
1198
|
+
output: result.output,
|
|
1199
|
+
terminationReason: result.terminationReason,
|
|
1200
|
+
errorMessage: result.errorMessage,
|
|
1201
|
+
durationMs: result.durationMs,
|
|
1202
|
+
step: result.step,
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}): void {
|
|
1207
|
+
const limits = { ...SUBAGENT_LIMITS, ...options.limits };
|
|
1208
|
+
const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
|
|
1209
|
+
const threads = new SubagentThreadRegistry();
|
|
1210
|
+
const activeRuntimes = new Map<string, ActiveThreadRuntime>();
|
|
1211
|
+
const savedResults = new Map<string, SubagentTaskResult>();
|
|
1212
|
+
const evictedThreadParents = new Map<string, string | undefined>();
|
|
1213
|
+
const maxClosedThreads = Number.isSafeInteger(limits.threadRetentionRecords) && limits.threadRetentionRecords > 0
|
|
1214
|
+
? limits.threadRetentionRecords
|
|
1215
|
+
: SUBAGENT_LIMITS.threadRetentionRecords;
|
|
1216
|
+
|
|
1217
|
+
const rememberEvictedThreads = (threadsToRemember: readonly SubagentThread[]): void => {
|
|
1218
|
+
for (const thread of threadsToRemember) evictedThreadParents.set(thread.id, thread.parentId);
|
|
1219
|
+
while (evictedThreadParents.size > maxClosedThreads) {
|
|
1220
|
+
const oldest = evictedThreadParents.keys().next().value;
|
|
1221
|
+
if (oldest === undefined) break;
|
|
1222
|
+
evictedThreadParents.delete(oldest);
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
const pruneClosedThreads = (): void => {
|
|
1226
|
+
if (threads.isDisposed) return;
|
|
1227
|
+
rememberEvictedThreads(threads.pruneClosed(maxClosedThreads));
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
const resultBytes = (result: SubagentTaskResult): number => Buffer.byteLength([
|
|
1231
|
+
result.task,
|
|
1232
|
+
...result.trace,
|
|
1233
|
+
result.stderr,
|
|
1234
|
+
result.output,
|
|
1235
|
+
result.errorMessage ?? "",
|
|
1236
|
+
].join("\n"), "utf8");
|
|
1237
|
+
const trimSavedResults = (): void => {
|
|
1238
|
+
const candidates = threads.listAll()
|
|
1239
|
+
.filter((thread) => ["done", "failed", "stopped"].includes(thread.state))
|
|
1240
|
+
.sort((left, right) => left.timestamps.createdAt - right.timestamps.createdAt);
|
|
1241
|
+
const retainedBytes = (): number => [...savedResults.values()].reduce((total, result) => total + resultBytes(result), 0);
|
|
1242
|
+
while ((savedResults.size > limits.threadRetentionRecords || retainedBytes() > limits.threadRetentionBytes) && candidates.length) {
|
|
1243
|
+
const candidate = candidates.shift()!;
|
|
1244
|
+
savedResults.delete(candidate.id);
|
|
1245
|
+
const current = threads.inspect(candidate.id);
|
|
1246
|
+
if (current && ["done", "failed", "stopped"].includes(current.state)) threads.close(candidate.id);
|
|
1247
|
+
}
|
|
1248
|
+
pruneClosedThreads();
|
|
1249
|
+
};
|
|
1250
|
+
const saveResult = (threadId: string, result: SubagentTaskResult): void => {
|
|
1251
|
+
savedResults.delete(threadId);
|
|
1252
|
+
savedResults.set(threadId, cloneResult(result));
|
|
1253
|
+
trimSavedResults();
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
const detailsFor = (
|
|
1257
|
+
parentId: string,
|
|
1258
|
+
mode: SubagentDetails["mode"] = "single",
|
|
1259
|
+
scope: AgentScope = "user",
|
|
1260
|
+
projectAgentsDir: string | null = null,
|
|
1261
|
+
selectedThreadId?: string,
|
|
1262
|
+
): SubagentDetails => {
|
|
1263
|
+
const all = threads.listAll().filter((thread) => thread.parentId === parentId);
|
|
1264
|
+
const visible = all.filter((thread) => thread.state !== "closed");
|
|
1265
|
+
const selectedClosed = selectedThreadId
|
|
1266
|
+
? all.find((thread) => thread.id === selectedThreadId && thread.state === "closed")
|
|
1267
|
+
: undefined;
|
|
1268
|
+
const listed = selectedClosed ? [...visible, selectedClosed] : visible;
|
|
1269
|
+
const results = visible.map((thread) => threadResult(thread, savedResults.get(thread.id)));
|
|
1270
|
+
return {
|
|
1271
|
+
...cloneDetails(mode, scope, projectAgentsDir, results),
|
|
1272
|
+
parentId,
|
|
1273
|
+
threads: listed,
|
|
1274
|
+
activeThreads: visible.filter((thread) => thread.state === "active"),
|
|
1275
|
+
doneThreads: visible.filter((thread) => ["done", "failed", "stopped"].includes(thread.state)),
|
|
1276
|
+
selectedThreadId,
|
|
1277
|
+
};
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
const threadBoardText = (parentId: string, selectedThreadId?: string): string => {
|
|
1281
|
+
const details = detailsFor(parentId, "single", "user", null, selectedThreadId);
|
|
1282
|
+
const active = details.activeThreads ?? [];
|
|
1283
|
+
const done = details.doneThreads ?? [];
|
|
1284
|
+
const row = (thread: SubagentThread): string => `- ${thread.id} · ${thread.role} · ${thread.state} · ${thread.prompt}`;
|
|
1285
|
+
const lines = [
|
|
1286
|
+
`parent ${parentId}`,
|
|
1287
|
+
`Active (${active.length})`,
|
|
1288
|
+
...(active.length ? active.map(row) : ["- none"]),
|
|
1289
|
+
`Done (${done.length})`,
|
|
1290
|
+
...(done.length ? done.map(row) : ["- none"]),
|
|
1291
|
+
"Controls: inspect · steer · interrupt · collect · close",
|
|
1292
|
+
];
|
|
1293
|
+
if (selectedThreadId) {
|
|
1294
|
+
const selected = details.threads?.find((thread) => thread.id === selectedThreadId);
|
|
1295
|
+
if (selected) {
|
|
1296
|
+
lines.push(`Inspect ${selected.id}: ${selected.state}`);
|
|
1297
|
+
lines.push(`Role: ${selected.role}`);
|
|
1298
|
+
lines.push(`Model: ${selected.model}`);
|
|
1299
|
+
lines.push(`Tools: ${selected.tools.join(", ")}`);
|
|
1300
|
+
lines.push(`Trace: ${selected.trace.length} entries`);
|
|
1301
|
+
if (selected.result) lines.push(`Handoff: ${selected.result}`);
|
|
1302
|
+
if (selected.stopReason) lines.push(`Reason: ${selected.stopReason}`);
|
|
1303
|
+
if (selected.evicted) lines.push("Retention: heavy thread data was evicted after close");
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
return boundedText(lines.join("\n"), limits.toolOutputBytes, "\n\n[Thread board truncated; inspect a child thread for its bounded detail.]");
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
const syncThread = (threadId: SubagentThreadId, next: SubagentTaskResult, runtime?: ActiveThreadRuntime): SubagentTaskResult => {
|
|
1310
|
+
const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
|
|
1311
|
+
if (runtime?.requestedReason && next.status === "cancelled") effective.terminationReason = runtime.requestedReason;
|
|
1312
|
+
saveResult(threadId, effective);
|
|
1313
|
+
let thread = threads.inspect(threadId);
|
|
1314
|
+
if (!thread || threads.isDisposed) return effective;
|
|
1315
|
+
if (thread.state === "queued" && next.status === "running") {
|
|
1316
|
+
thread = threads.begin(threadId);
|
|
1317
|
+
}
|
|
1318
|
+
if (thread.state !== "active") return effective;
|
|
1319
|
+
if (runtime && next.trace.length < runtime.traceCount) runtime.traceCount = 0;
|
|
1320
|
+
const from = runtime?.traceCount ?? 0;
|
|
1321
|
+
let retainedTraceBytes = thread.trace.reduce((total, entry) => total + Buffer.byteLength(entry.message ?? "", "utf8"), 0);
|
|
1322
|
+
for (const entry of next.trace.slice(from)) {
|
|
1323
|
+
const retained = truncateUtf8(entry, limits.traceRetentionBytes === undefined
|
|
1324
|
+
? Buffer.byteLength(entry, "utf8")
|
|
1325
|
+
: Math.max(0, limits.traceRetentionBytes - retainedTraceBytes));
|
|
1326
|
+
if (retained.text) {
|
|
1327
|
+
threads.trace(threadId, { kind: "child", message: retained.text });
|
|
1328
|
+
retainedTraceBytes += Buffer.byteLength(retained.text, "utf8");
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
if (runtime) runtime.traceCount = next.trace.length;
|
|
1332
|
+
const handoff = effective.output ? { summary: effective.output } : undefined;
|
|
1333
|
+
thread = threads.patch(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
|
|
1334
|
+
const restartPending = runtime?.restarting === true && ["cancelled", "complete"].includes(next.status);
|
|
1335
|
+
if (restartPending) return effective;
|
|
1336
|
+
if (effective.status === "complete") {
|
|
1337
|
+
threads.complete(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
|
|
1338
|
+
} else if (effective.status === "failed") {
|
|
1339
|
+
threads.fail(threadId, {
|
|
1340
|
+
usage: threadUsage(effective.usage),
|
|
1341
|
+
result: effective.output || undefined,
|
|
1342
|
+
handoff,
|
|
1343
|
+
message: effective.errorMessage ?? effective.terminationReason ?? "child failed",
|
|
1344
|
+
code: effective.terminationReason,
|
|
1345
|
+
});
|
|
1346
|
+
} else if (effective.status === "cancelled" || effective.status === "limited") {
|
|
1347
|
+
threads.stop(threadId, {
|
|
1348
|
+
usage: threadUsage(effective.usage),
|
|
1349
|
+
result: effective.output || undefined,
|
|
1350
|
+
handoff,
|
|
1351
|
+
reason: effective.terminationReason ?? (effective.status === "limited" ? "resource_limit" : "interrupted"),
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
return effective;
|
|
1355
|
+
};
|
|
1356
|
+
|
|
1357
|
+
if (typeof pi.on === "function") {
|
|
1358
|
+
pi.on("session_shutdown", () => {
|
|
1359
|
+
for (const runtime of activeRuntimes.values()) {
|
|
1360
|
+
runtime.restarting = false;
|
|
1361
|
+
runtime.requestedReason = "session_shutdown";
|
|
1362
|
+
runtime.handle?.stop("session_shutdown");
|
|
1363
|
+
runtime.controller.abort();
|
|
1364
|
+
}
|
|
1365
|
+
threads.dispose();
|
|
1366
|
+
savedResults.clear();
|
|
1367
|
+
evictedThreadParents.clear();
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
pi.registerTool({
|
|
1372
|
+
name: "subagent",
|
|
1373
|
+
label: "Subagents",
|
|
1374
|
+
description: `Spawn and manage named child threads. Children finish naturally. Parallel tasks with write-capable roles use one shared slot by default; read-only batches run concurrently up to ${limits.maxReadConcurrency}. Set writerConcurrency above 1 only after proving path ownership in the shared worktree. The message parameter is only valid with action steer. Use action list, inspect, steer, interrupt, collect, and close to manage active and completed handoffs.`,
|
|
1375
|
+
promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
|
|
1376
|
+
promptGuidelines: [
|
|
1377
|
+
"Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
|
|
1378
|
+
"Parallel tasks with write-capable roles use one shared slot by default because all children share the parent worktree. Set writerConcurrency above 1 only when callers have proved path ownership; callers remain responsible for file conflicts.",
|
|
1379
|
+
"Every child can load relevant skills with read and can use web_search, source_check, fetch_content, and get_search_content for external research.",
|
|
1380
|
+
"When the user names a model or thinking effort, pass model and thinking separately; use inherit when the active parent or role setting should decide.",
|
|
1381
|
+
"Keep completed and stopped threads inspectable until the parent explicitly closes them.",
|
|
1382
|
+
],
|
|
1383
|
+
parameters: createSubagentParams(limits),
|
|
1384
|
+
prepareArguments(args) {
|
|
1385
|
+
return normalizeSubagentRequest(args, limits).input;
|
|
1386
|
+
},
|
|
1387
|
+
executionMode: "parallel",
|
|
1388
|
+
|
|
1389
|
+
async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
|
|
1390
|
+
const request = normalizeSubagentRequest(rawParams, limits);
|
|
1391
|
+
const parentId = parentThreadId(ctx);
|
|
1392
|
+
const actionDetails = (selectedThreadId?: string): SubagentDetails => detailsFor(parentId, "single", "user", null, selectedThreadId);
|
|
1393
|
+
const actionResult = (text: string, selectedThreadId?: string) => {
|
|
1394
|
+
const details = actionDetails(selectedThreadId);
|
|
1395
|
+
return {
|
|
1396
|
+
content: [{ type: "text" as const, text: boundedText(text, limits.toolOutputBytes, "\n\n[Thread action output truncated.]") }],
|
|
1397
|
+
details,
|
|
1398
|
+
usage: details.aggregateUsage,
|
|
1399
|
+
};
|
|
1400
|
+
};
|
|
1401
|
+
|
|
1402
|
+
if (request.kind === "list") return actionResult(threadBoardText(parentId));
|
|
1403
|
+
if (request.kind === "inspect") {
|
|
1404
|
+
const { threadId } = request.input;
|
|
1405
|
+
const thread = threads.inspect(threadId as SubagentThreadId);
|
|
1406
|
+
if (!thread) {
|
|
1407
|
+
if (evictedThreadParents.get(threadId) === parentId) {
|
|
1408
|
+
return actionResult(`Thread ${threadId} was evicted from bounded retention; its heavy data is no longer available.`, threadId);
|
|
1409
|
+
}
|
|
1410
|
+
throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
|
|
1411
|
+
}
|
|
1412
|
+
if (thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
|
|
1413
|
+
return actionResult(threadBoardText(parentId, threadId), threadId);
|
|
1414
|
+
}
|
|
1415
|
+
if (request.kind === "steer") {
|
|
1416
|
+
const { threadId, message } = request.input;
|
|
1417
|
+
const brandedThreadId = threadId as SubagentThreadId;
|
|
1418
|
+
const thread = threads.inspect(brandedThreadId);
|
|
1419
|
+
if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
|
|
1420
|
+
threads.steer(brandedThreadId, message);
|
|
1421
|
+
const runtime = activeRuntimes.get(threadId);
|
|
1422
|
+
if (runtime) {
|
|
1423
|
+
runtime.steering.push(message);
|
|
1424
|
+
if (runtime.steering.length > MAX_RUNTIME_STEERING_MESSAGES) runtime.steering.splice(0, runtime.steering.length - MAX_RUNTIME_STEERING_MESSAGES);
|
|
1425
|
+
runtime.restarting = true;
|
|
1426
|
+
runtime.requestedReason = "steer";
|
|
1427
|
+
runtime.handle?.stop("steer");
|
|
1428
|
+
}
|
|
1429
|
+
return actionResult(`Steering queued for ${threadId}. The child keeps the same thread and handoff record.`, threadId);
|
|
1430
|
+
}
|
|
1431
|
+
if (request.kind === "interrupt-one" || request.kind === "interrupt-all") {
|
|
1432
|
+
let targets: SubagentThread[];
|
|
1433
|
+
if (request.kind === "interrupt-all") {
|
|
1434
|
+
targets = threads.listActive().filter((thread) => thread.parentId === parentId);
|
|
1435
|
+
} else {
|
|
1436
|
+
const { threadId } = request.input;
|
|
1437
|
+
const target = threads.inspect(threadId as SubagentThreadId);
|
|
1438
|
+
if (!target || target.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
|
|
1439
|
+
if (target.state !== "active" && target.state !== "queued") {
|
|
1440
|
+
throw new Error(`Cannot interrupt thread ${threadId} from ${target.state}`);
|
|
1441
|
+
}
|
|
1442
|
+
targets = [target];
|
|
1443
|
+
}
|
|
1444
|
+
for (const thread of targets) {
|
|
1445
|
+
if (thread.parentId !== parentId) continue;
|
|
1446
|
+
const runtime = activeRuntimes.get(thread.id);
|
|
1447
|
+
if (runtime) {
|
|
1448
|
+
runtime.restarting = false;
|
|
1449
|
+
runtime.requestedReason = "interrupt";
|
|
1450
|
+
runtime.handle?.stop("interrupt");
|
|
1451
|
+
runtime.controller.abort();
|
|
1452
|
+
} else if (thread.state === "active" || thread.state === "queued") {
|
|
1453
|
+
threads.stop(thread.id, { reason: "interrupt" });
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
return actionResult(request.kind === "interrupt-all"
|
|
1457
|
+
? "Interrupt requested for all active child threads."
|
|
1458
|
+
: `Interrupt requested for ${request.input.threadId}.`);
|
|
1459
|
+
}
|
|
1460
|
+
if (request.kind === "collect") {
|
|
1461
|
+
const { threadId } = request.input;
|
|
1462
|
+
const thread = threads.inspect(threadId as SubagentThreadId);
|
|
1463
|
+
if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
|
|
1464
|
+
const collected = threads.collect(threadId as SubagentThreadId);
|
|
1465
|
+
return actionResult(`Collected ${threadId}: ${collected.result ?? collected.failure?.message ?? collected.stopReason ?? "no handoff"}`, threadId);
|
|
1466
|
+
}
|
|
1467
|
+
if (request.kind === "close") {
|
|
1468
|
+
const { threadId } = request.input;
|
|
1469
|
+
const thread = threads.inspect(threadId as SubagentThreadId);
|
|
1470
|
+
if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
|
|
1471
|
+
threads.close(threadId as SubagentThreadId);
|
|
1472
|
+
savedResults.delete(threadId);
|
|
1473
|
+
pruneClosedThreads();
|
|
1474
|
+
return actionResult(`Closed ${threadId}. Heavy trace and handoff data were evicted; a tombstone remains inspectable.`, threadId);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const spawnRequest = request as Extract<NormalizedSubagentRequest, { kind: "spawn-single" | "spawn-parallel" | "spawn-chain" }>;
|
|
1478
|
+
const params = spawnRequest.input;
|
|
1479
|
+
const scope: AgentScope = params.agentScope ?? "user";
|
|
1480
|
+
const hasParallel = spawnRequest.kind === "spawn-parallel";
|
|
1481
|
+
const hasChain = spawnRequest.kind === "spawn-chain";
|
|
1482
|
+
const writerConcurrencyOverride = hasParallel ? spawnRequest.input.writerConcurrency : undefined;
|
|
1483
|
+
|
|
1484
|
+
const discovery = discoverAgentRoles(ctx.cwd, scope, ctx.isProjectTrusted(), options);
|
|
1485
|
+
const roles = new Map(discovery.agents.map((agent) => [agent.name, agent]));
|
|
1486
|
+
const requested = requestedAgents(params);
|
|
1487
|
+
for (const name of requested) {
|
|
1488
|
+
if (!roles.has(name)) {
|
|
1489
|
+
const available = discovery.agents.map((agent) => `${agent.name} (${agent.source})`).join(", ") || "none";
|
|
1490
|
+
throw new Error(`Unknown subagent ${JSON.stringify(name)}. Available: ${available}`);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const projectRoles = [...new Set(requested.map((name) => roles.get(name)!).filter((role) => role.source === "project"))];
|
|
1495
|
+
if (projectRoles.length) {
|
|
1496
|
+
if (!ctx.hasUI) throw new Error("Project-local subagents require interactive confirmation");
|
|
1497
|
+
const approved = await ctx.ui.confirm(
|
|
1498
|
+
"Run project-local subagents?",
|
|
1499
|
+
`Roles: ${projectRoles.map((role) => role.name).join(", ")}\nSources:\n${projectRoles.map((role) => role.filePath).join("\n")}\n\nThese trusted repository files control child prompts and tools.`,
|
|
1500
|
+
);
|
|
1501
|
+
if (!approved) throw new Error("Project-local subagents were not approved");
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
const resolvedModels = new Map<string, ResolvedModel>();
|
|
1505
|
+
for (const name of new Set(requested)) {
|
|
1506
|
+
resolvedModels.set(name, resolveAgentModel(roles.get(name)!, ctx, params.model, params.thinking));
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const mode: SubagentDetails["mode"] = hasParallel ? "parallel" : hasChain ? "chain" : "single";
|
|
1510
|
+
const inputs: TaskInput[] = spawnRequest.kind === "spawn-single"
|
|
1511
|
+
? [{ agent: spawnRequest.input.agent, task: spawnRequest.input.task }]
|
|
1512
|
+
: spawnRequest.kind === "spawn-parallel" ? spawnRequest.input.tasks : spawnRequest.input.chain;
|
|
1513
|
+
if (inputs.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
|
|
1514
|
+
const readIndexes = hasParallel
|
|
1515
|
+
? inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "read")
|
|
1516
|
+
: [];
|
|
1517
|
+
const writerIndexes = hasParallel
|
|
1518
|
+
? inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "write").map(({ index }) => index)
|
|
1519
|
+
: [];
|
|
1520
|
+
if (writerConcurrencyOverride !== undefined && writerIndexes.length === 0) {
|
|
1521
|
+
throw new Error("writerConcurrency requires at least one write-capable role");
|
|
1522
|
+
}
|
|
1523
|
+
const writerConcurrency = writerConcurrencyOverride ?? (writerIndexes.length > 0 ? 1 : limits.maxReadConcurrency);
|
|
1524
|
+
const useSharedParallelPool = hasParallel && writerIndexes.length > 0;
|
|
1525
|
+
const executionNote = hasParallel
|
|
1526
|
+
? writerIndexes.length
|
|
1527
|
+
? `Parallel schedule: all tasks run through a shared pool of up to ${writerConcurrency}${writerConcurrencyOverride === undefined ? " (safe default)" : " (explicit)"}; concurrent write-capable tasks share the parent worktree, so callers must prove path ownership.`
|
|
1528
|
+
: `Parallel schedule: read-only tasks run concurrently up to ${limits.maxReadConcurrency}.`
|
|
1529
|
+
: undefined;
|
|
1530
|
+
|
|
1531
|
+
const inFlight = threads.listAll().filter((thread) => ["queued", "active"].includes(thread.state)).length;
|
|
1532
|
+
if (inFlight + inputs.length > limits.maxTasks) {
|
|
1533
|
+
throw new Error(`At most ${limits.maxTasks} child threads may be active at once`);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const threadRecords = inputs.map((input, index) => threads.spawn({
|
|
1537
|
+
parentId: parentId as SubagentThreadId,
|
|
1538
|
+
role: input.agent,
|
|
1539
|
+
prompt: input.task,
|
|
1540
|
+
model: resolvedModels.get(input.agent)!.model,
|
|
1541
|
+
tools: roles.get(input.agent)!.tools,
|
|
1542
|
+
capabilityBoundary: threadCapabilityBoundary(roles.get(input.agent)!),
|
|
1543
|
+
}));
|
|
1544
|
+
const results = threadRecords.map((thread, index) => {
|
|
1545
|
+
return makeQueuedResult(thread.id, inputs[index]!.agent, inputs[index]!.task, hasChain ? index + 1 : undefined);
|
|
1546
|
+
});
|
|
1547
|
+
const emit = (message = `${mode}: ${results.filter((result) => !["queued", "running"].includes(result.status)).length}/${results.length} settled`): void => {
|
|
1548
|
+
const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir);
|
|
1549
|
+
const currentResults = results.map(cloneResult);
|
|
1550
|
+
(onUpdate as ToolUpdate | undefined)?.({
|
|
1551
|
+
content: [{ type: "text", text: message }],
|
|
1552
|
+
details: { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
|
|
1553
|
+
});
|
|
1554
|
+
};
|
|
1555
|
+
const failQueuedTask = (index: number, reason: string, message: string): void => {
|
|
1556
|
+
const threadId = threadRecords[index]!.id;
|
|
1557
|
+
const thread = threads.inspect(threadId);
|
|
1558
|
+
if (thread?.state === "queued") threads.begin(threadId);
|
|
1559
|
+
results[index] = {
|
|
1560
|
+
...results[index]!,
|
|
1561
|
+
status: "failed",
|
|
1562
|
+
terminationReason: reason,
|
|
1563
|
+
errorMessage: message,
|
|
1564
|
+
};
|
|
1565
|
+
if (threads.inspect(threadId)?.state === "active") threads.fail(threadId, { message, code: reason });
|
|
1566
|
+
saveResult(threadId, results[index]!);
|
|
1567
|
+
emit();
|
|
1568
|
+
};
|
|
1569
|
+
const runAt = async (index: number, task: string): Promise<void> => {
|
|
1570
|
+
const threadId = threadRecords[index]!.id;
|
|
1571
|
+
const initialThread = threads.inspect(threadId);
|
|
1572
|
+
if (signal?.aborted) {
|
|
1573
|
+
results[index] = { ...results[index]!, status: "cancelled", terminationReason: "abort" };
|
|
1574
|
+
if (initialThread?.state === "queued" || initialThread?.state === "active") threads.stop(threadId, { reason: "abort" });
|
|
1575
|
+
saveResult(threadId, results[index]!);
|
|
1576
|
+
emit();
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
if (!initialThread) return;
|
|
1580
|
+
if (initialThread.state === "closed") {
|
|
1581
|
+
results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "disposed" };
|
|
1582
|
+
saveResult(threadId, results[index]!);
|
|
1583
|
+
emit();
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
if (initialThread.state === "stopped") {
|
|
1587
|
+
results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "interrupted" };
|
|
1588
|
+
saveResult(threadId, results[index]!);
|
|
1589
|
+
emit();
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
if (initialThread.state !== "queued") return;
|
|
1593
|
+
const input = inputs[index]!;
|
|
1594
|
+
threads.begin(threadId);
|
|
1595
|
+
if (codePointLength(task) > limits.taskCharacters) {
|
|
1596
|
+
failQueuedTask(index, "task_limit", `Expanded task exceeds ${limits.taskCharacters} characters`);
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
const controller = new AbortController();
|
|
1600
|
+
const runtime: ActiveThreadRuntime = {
|
|
1601
|
+
controller,
|
|
1602
|
+
handles: new Set(),
|
|
1603
|
+
steering: [],
|
|
1604
|
+
restarting: false,
|
|
1605
|
+
traceCount: 0,
|
|
1606
|
+
startedAt: Date.now(),
|
|
1607
|
+
};
|
|
1608
|
+
activeRuntimes.set(threadId, runtime);
|
|
1609
|
+
let sessionDirectory: string;
|
|
1610
|
+
try {
|
|
1611
|
+
sessionDirectory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-session-"));
|
|
1612
|
+
} catch (error) {
|
|
1613
|
+
activeRuntimes.delete(threadId);
|
|
1614
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1615
|
+
results[index] = {
|
|
1616
|
+
...results[index]!,
|
|
1617
|
+
status: "failed",
|
|
1618
|
+
terminationReason: "session_error",
|
|
1619
|
+
errorMessage: message,
|
|
1620
|
+
};
|
|
1621
|
+
threads.fail(threadId, { message, code: "session_error" });
|
|
1622
|
+
saveResult(threadId, results[index]!);
|
|
1623
|
+
emit();
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
const currentThread = threads.inspect(threadId);
|
|
1627
|
+
if (controller.signal.aborted || threads.isDisposed || currentThread?.state !== "active") {
|
|
1628
|
+
activeRuntimes.delete(threadId);
|
|
1629
|
+
try {
|
|
1630
|
+
await rm(sessionDirectory, { recursive: true, force: true });
|
|
1631
|
+
} catch {
|
|
1632
|
+
// Temporary child session cleanup is best effort before process startup.
|
|
1633
|
+
}
|
|
1634
|
+
const reason = runtime.requestedReason ?? currentThread?.stopReason ?? (threads.isDisposed ? "session_shutdown" : "interrupted");
|
|
1635
|
+
results[index] = { ...results[index]!, status: "cancelled", terminationReason: reason };
|
|
1636
|
+
if (!threads.isDisposed && currentThread?.state === "active") threads.stop(threadId, { reason });
|
|
1637
|
+
saveResult(threadId, results[index]!);
|
|
1638
|
+
emit();
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
const sessionId = `killeros-${threadId.replace(/[^A-Za-z0-9_.-]/gu, "_")}`;
|
|
1642
|
+
const agent = roles.get(input.agent)!;
|
|
1643
|
+
const queuedSteering = initialThread.steering.map((entry) => entry.message);
|
|
1644
|
+
let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, limits.taskCharacters) : task;
|
|
1645
|
+
const stopForBudget = (reason: string, message: string): void => {
|
|
1646
|
+
const limited = cloneResult(runtime.aggregate ?? results[index]!);
|
|
1647
|
+
limited.status = "limited";
|
|
1648
|
+
limited.terminationReason = reason;
|
|
1649
|
+
limited.errorMessage = message;
|
|
1650
|
+
runtime.aggregate = limited;
|
|
1651
|
+
results[index] = cloneResult(limited);
|
|
1652
|
+
saveResult(threadId, limited);
|
|
1653
|
+
if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
|
|
1654
|
+
threads.stop(threadId, {
|
|
1655
|
+
usage: threadUsage(limited.usage),
|
|
1656
|
+
result: limited.output || undefined,
|
|
1657
|
+
handoff: limited.output ? { summary: limited.output } : undefined,
|
|
1658
|
+
reason,
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
emit();
|
|
1662
|
+
};
|
|
1663
|
+
try {
|
|
1664
|
+
while (true) {
|
|
1665
|
+
const aggregate = runtime.aggregate;
|
|
1666
|
+
const wallTimeMs = agent.timeoutMs ?? limits.wallTimeMs;
|
|
1667
|
+
const remainingWallTimeMs = wallTimeMs === undefined ? undefined : wallTimeMs - (Date.now() - runtime.startedAt);
|
|
1668
|
+
const usedTraceBytes = (aggregate?.traceBytes ?? 0) + (aggregate?.traceTruncatedBytes ?? 0);
|
|
1669
|
+
const usedStderrBytes = aggregate?.stderrBytes ?? 0;
|
|
1670
|
+
const usedOutputBytes = aggregate?.outputBytes ?? 0;
|
|
1671
|
+
const usedTokens = aggregate?.usage.totalTokens ?? 0;
|
|
1672
|
+
const usedCost = aggregate?.usage.cost.total ?? 0;
|
|
1673
|
+
if (remainingWallTimeMs !== undefined && remainingWallTimeMs <= 0) {
|
|
1674
|
+
stopForBudget("wall_time_limit", `Child thread exceeds ${wallTimeMs} ms`);
|
|
1675
|
+
break;
|
|
1676
|
+
}
|
|
1677
|
+
if (limits.traceBytes !== undefined && usedTraceBytes >= limits.traceBytes) {
|
|
1678
|
+
stopForBudget("trace_limit", `Child thread retains more than ${limits.traceBytes} trace bytes`);
|
|
1679
|
+
break;
|
|
1680
|
+
}
|
|
1681
|
+
if (limits.stderrBytes !== undefined && usedStderrBytes >= limits.stderrBytes) {
|
|
1682
|
+
stopForBudget("stderr_limit", `Child thread emits more than ${limits.stderrBytes} stderr bytes`);
|
|
1683
|
+
break;
|
|
1684
|
+
}
|
|
1685
|
+
if (limits.taskOutputBytes !== undefined && usedOutputBytes >= limits.taskOutputBytes) {
|
|
1686
|
+
stopForBudget("output_limit", `Child thread emits more than ${limits.taskOutputBytes} output bytes`);
|
|
1687
|
+
break;
|
|
1688
|
+
}
|
|
1689
|
+
if (limits.quotaTokens !== undefined && usedTokens >= limits.quotaTokens) {
|
|
1690
|
+
stopForBudget("quota_tokens", `Child thread exceeds ${limits.quotaTokens} tokens`);
|
|
1691
|
+
break;
|
|
1692
|
+
}
|
|
1693
|
+
if (limits.quotaUsd !== undefined && usedCost >= limits.quotaUsd) {
|
|
1694
|
+
stopForBudget("quota_cost", `Child thread exceeds $${limits.quotaUsd}`);
|
|
1695
|
+
break;
|
|
1696
|
+
}
|
|
1697
|
+
runtime.traceCount = 0;
|
|
1698
|
+
const next = await runTask({
|
|
1699
|
+
cwd: ctx.cwd,
|
|
1700
|
+
agent: roles.get(input.agent)!,
|
|
1701
|
+
task: currentTask,
|
|
1702
|
+
id: results[index]!.id,
|
|
1703
|
+
step: results[index]!.step,
|
|
1704
|
+
model: resolvedModels.get(input.agent)!,
|
|
1705
|
+
signal: controller.signal,
|
|
1706
|
+
webExtension: options.webExtension,
|
|
1707
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
1708
|
+
spawnProcess,
|
|
1709
|
+
sessionDirectory,
|
|
1710
|
+
sessionId,
|
|
1711
|
+
limits: {
|
|
1712
|
+
...limits,
|
|
1713
|
+
...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes - usedTraceBytes }),
|
|
1714
|
+
...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes - usedStderrBytes }),
|
|
1715
|
+
...(limits.taskOutputBytes === undefined ? {} : { taskOutputBytes: limits.taskOutputBytes - usedOutputBytes }),
|
|
1716
|
+
...(limits.quotaTokens === undefined ? {} : { quotaTokens: limits.quotaTokens - usedTokens }),
|
|
1717
|
+
...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd - usedCost }),
|
|
1718
|
+
},
|
|
1719
|
+
timeoutMs: remainingWallTimeMs,
|
|
1720
|
+
onHandle: (handle) => {
|
|
1721
|
+
runtime.handle = handle;
|
|
1722
|
+
runtime.handles.add(handle);
|
|
1723
|
+
},
|
|
1724
|
+
onChange: (changed) => {
|
|
1725
|
+
results[index] = syncThread(threadId, changed, runtime);
|
|
1726
|
+
emit();
|
|
1727
|
+
},
|
|
1728
|
+
});
|
|
1729
|
+
next.task = task;
|
|
1730
|
+
runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
|
|
1731
|
+
runtime.aggregate.task = task;
|
|
1732
|
+
results[index] = cloneResult(runtime.aggregate);
|
|
1733
|
+
saveResult(threadId, runtime.aggregate);
|
|
1734
|
+
const shouldRestart = runtime.steering.length > 0 && !controller.signal.aborted && (runtime.restarting || next.status === "complete" || next.status === "cancelled");
|
|
1735
|
+
if (!shouldRestart) break;
|
|
1736
|
+
const previousHandle = runtime.handle;
|
|
1737
|
+
if (previousHandle && !(await waitForConfirmedProcessExit(previousHandle))) {
|
|
1738
|
+
const message = "Child process exit was not confirmed before the steering restart";
|
|
1739
|
+
const unconfirmed = cloneResult(runtime.aggregate ?? results[index]!);
|
|
1740
|
+
unconfirmed.status = "failed";
|
|
1741
|
+
unconfirmed.terminationReason = "process_exit_unconfirmed";
|
|
1742
|
+
unconfirmed.errorMessage = message;
|
|
1743
|
+
runtime.aggregate = unconfirmed;
|
|
1744
|
+
results[index] = cloneResult(unconfirmed);
|
|
1745
|
+
saveResult(threadId, unconfirmed);
|
|
1746
|
+
if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
|
|
1747
|
+
threads.fail(threadId, {
|
|
1748
|
+
usage: threadUsage(unconfirmed.usage),
|
|
1749
|
+
result: unconfirmed.output || undefined,
|
|
1750
|
+
handoff: unconfirmed.output ? { summary: unconfirmed.output } : undefined,
|
|
1751
|
+
message,
|
|
1752
|
+
code: "process_exit_unconfirmed",
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
emit();
|
|
1756
|
+
break;
|
|
1757
|
+
}
|
|
1758
|
+
if (controller.signal.aborted || threads.isDisposed) break;
|
|
1759
|
+
const steering = runtime.steering.splice(0);
|
|
1760
|
+
runtime.restarting = false;
|
|
1761
|
+
runtime.requestedReason = undefined;
|
|
1762
|
+
if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
|
|
1763
|
+
threads.patch(threadId, {
|
|
1764
|
+
usage: threadUsage(runtime.aggregate.usage),
|
|
1765
|
+
result: runtime.aggregate.output || undefined,
|
|
1766
|
+
handoff: runtime.aggregate.output ? { summary: runtime.aggregate.output } : undefined,
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
currentTask = buildSteeredTask(task, steering, limits.taskCharacters);
|
|
1770
|
+
}
|
|
1771
|
+
} finally {
|
|
1772
|
+
activeRuntimes.delete(threadId);
|
|
1773
|
+
const removeSessionDirectory = async (): Promise<void> => {
|
|
1774
|
+
try {
|
|
1775
|
+
await rm(sessionDirectory, { recursive: true, force: true });
|
|
1776
|
+
} catch {
|
|
1777
|
+
// Temporary child session cleanup is best effort after process termination.
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
const pendingExits = [...runtime.handles]
|
|
1781
|
+
.filter((handle) => !handle.hasExited)
|
|
1782
|
+
.map((handle) => handle.exited);
|
|
1783
|
+
if (!pendingExits.length) await removeSessionDirectory();
|
|
1784
|
+
else void Promise.all(pendingExits).then(removeSessionDirectory);
|
|
1785
|
+
}
|
|
1786
|
+
emit();
|
|
1787
|
+
};
|
|
1788
|
+
|
|
1789
|
+
const settleQueued = (reason: string): void => {
|
|
1790
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
1791
|
+
const result = results[index]!;
|
|
1792
|
+
if (result.status !== "queued") continue;
|
|
1793
|
+
const thread = threads.inspect(threadRecords[index]!.id);
|
|
1794
|
+
const alreadyStopped = thread?.state === "stopped";
|
|
1795
|
+
result.status = signal?.aborted || alreadyStopped || reason === "chain_stopped" ? "cancelled" : "failed";
|
|
1796
|
+
result.terminationReason = alreadyStopped
|
|
1797
|
+
? thread.stopReason ?? "interrupted"
|
|
1798
|
+
: signal?.aborted ? "abort" : reason;
|
|
1799
|
+
if (thread?.state === "queued" || thread?.state === "active") {
|
|
1800
|
+
threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
|
|
1801
|
+
}
|
|
1802
|
+
saveResult(threadRecords[index]!.id, result);
|
|
1803
|
+
}
|
|
1804
|
+
};
|
|
1805
|
+
|
|
1806
|
+
emit(`${mode}: ${results.length} queued`);
|
|
1807
|
+
if (hasChain) {
|
|
1808
|
+
let previous = "";
|
|
1809
|
+
for (let index = 0; index < inputs.length; index += 1) {
|
|
1810
|
+
const task = expandChainTask(inputs[index]!.task, previous, limits.taskCharacters);
|
|
1811
|
+
if (task === undefined) {
|
|
1812
|
+
failQueuedTask(index, "task_limit", `Expanded task exceeds ${limits.taskCharacters} characters`);
|
|
1813
|
+
break;
|
|
1814
|
+
}
|
|
1815
|
+
await runAt(index, task);
|
|
1816
|
+
if (results[index]!.status !== "complete") break;
|
|
1817
|
+
previous = results[index]!.output;
|
|
1818
|
+
}
|
|
1819
|
+
settleQueued("chain_stopped");
|
|
1820
|
+
} else if (hasParallel) {
|
|
1821
|
+
try {
|
|
1822
|
+
if (useSharedParallelPool) {
|
|
1823
|
+
const indexes = inputs.map((_, index) => index);
|
|
1824
|
+
await mapReadTasks(indexes, writerConcurrency, async (index) => runAt(index, inputs[index]!.task));
|
|
1825
|
+
} else {
|
|
1826
|
+
await mapReadTasks(readIndexes, limits.maxReadConcurrency, async ({ index }) => runAt(index, inputs[index]!.task));
|
|
1827
|
+
for (const index of writerIndexes) await runAt(index, inputs[index]!.task);
|
|
1828
|
+
}
|
|
1829
|
+
} finally {
|
|
1830
|
+
settleQueued("parallel_stopped");
|
|
1831
|
+
}
|
|
1832
|
+
} else {
|
|
1833
|
+
await runAt(0, inputs[0]!.task);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir);
|
|
1837
|
+
const currentResults = results.map(cloneResult);
|
|
1838
|
+
const details: SubagentDetails = { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
|
|
1839
|
+
return {
|
|
1840
|
+
content: [{ type: "text", text: buildToolContent(mode, details.results, limits.toolOutputBytes) }],
|
|
1841
|
+
details,
|
|
1842
|
+
usage: details.aggregateUsage,
|
|
1843
|
+
};
|
|
1844
|
+
},
|
|
1845
|
+
|
|
1846
|
+
renderCall(args, theme) {
|
|
1847
|
+
const parsed = tryNormalizeSubagentRequest(args, limits);
|
|
1848
|
+
if (!parsed.ok) {
|
|
1849
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagent"))}${theme.fg("error", " · invalid request")}`, 0, 0);
|
|
1850
|
+
}
|
|
1851
|
+
const request = parsed.request;
|
|
1852
|
+
if (!request.kind.startsWith("spawn-")) {
|
|
1853
|
+
const threadId = "threadId" in request.input ? request.input.threadId : undefined;
|
|
1854
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("threads "))}${theme.fg("accent", request.input.action ?? "spawn")}${theme.fg("dim", threadId ? ` · ${threadId}` : "")}`, 0, 0);
|
|
1855
|
+
}
|
|
1856
|
+
const spawnRequest = request as Extract<NormalizedSubagentRequest, { kind: "spawn-single" | "spawn-parallel" | "spawn-chain" }>;
|
|
1857
|
+
const scope = spawnRequest.input.agentScope ?? "user";
|
|
1858
|
+
if (spawnRequest.kind === "spawn-parallel") {
|
|
1859
|
+
const schedule = spawnRequest.input.writerConcurrency === undefined ? "parallel default" : `shared pool ${spawnRequest.input.writerConcurrency}`;
|
|
1860
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `parallel ${spawnRequest.input.tasks.length} · ${schedule}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
1861
|
+
}
|
|
1862
|
+
if (spawnRequest.kind === "spawn-chain") return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `chain ${spawnRequest.input.chain.length}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
1863
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", spawnRequest.input.agent)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
1864
|
+
},
|
|
1865
|
+
|
|
1866
|
+
renderResult(result, { expanded }, theme) {
|
|
1867
|
+
const details = result.details as SubagentDetails | undefined;
|
|
1868
|
+
if (!details?.results.length) {
|
|
1869
|
+
const first = result.content[0];
|
|
1870
|
+
return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
|
|
1871
|
+
}
|
|
1872
|
+
const board = formatThreadBoard({
|
|
1873
|
+
title: `Subagents · ${details.mode}`,
|
|
1874
|
+
threads: details.results.map(threadBoardRecord),
|
|
1875
|
+
selectedThreadId: details.selectedThreadId,
|
|
1876
|
+
});
|
|
1877
|
+
if (!expanded) {
|
|
1878
|
+
const lines = [
|
|
1879
|
+
theme.fg("toolTitle", theme.bold(`Active (${board.active.length})`)),
|
|
1880
|
+
...board.active.map((task) => `${theme.fg("accent", "✻")} ${theme.fg("toolTitle", theme.bold(task.agent))}${theme.fg("dim", ` · ${task.id} · ${task.state.label} · ${task.usage.text}`)}`),
|
|
1881
|
+
theme.fg("toolTitle", theme.bold(`Done (${board.done.length})`)),
|
|
1882
|
+
...board.done.map((task) => `${theme.fg(task.state.status === "complete" ? "success" : "warning", `${task.state.label}`)} ${theme.fg("toolTitle", theme.bold(task.agent))}${theme.fg("dim", ` · ${task.id} · ${task.usage.text}`)}`),
|
|
1883
|
+
];
|
|
1884
|
+
if (details.executionNote) lines.push(theme.fg("dim", details.executionNote));
|
|
1885
|
+
lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
|
|
1886
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
const container = new Container();
|
|
1890
|
+
container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
|
|
1891
|
+
container.addChild(new Text(theme.fg("dim", `Active ${board.active.length} · Done ${board.done.length} · Controls: Inspect · Steer · Interrupt · Collect · Close`), 0, 0));
|
|
1892
|
+
if (details.executionNote) container.addChild(new Text(theme.fg("dim", details.executionNote), 0, 0));
|
|
1893
|
+
if (board.selected) {
|
|
1894
|
+
const inspection = formatThreadInspection(threadBoardRecord(details.results.find((task) => task.id === board.selected!.id)!));
|
|
1895
|
+
container.addChild(new Spacer(1));
|
|
1896
|
+
container.addChild(new Text(theme.fg("accent", `Inspect ${inspection.id} · ${inspection.state.label} · ${inspection.usage.text}`), 0, 0));
|
|
1897
|
+
for (const entry of inspection.trace.entries) container.addChild(new Text(`${theme.fg("muted", "→ ")}${theme.fg("toolOutput", entry)}`, 0, 0));
|
|
1898
|
+
}
|
|
1899
|
+
for (const task of details.results) {
|
|
1900
|
+
container.addChild(new Spacer(1));
|
|
1901
|
+
const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);
|
|
1902
|
+
container.addChild(new Text(`${status} ${theme.fg("accent", task.agent)}${theme.fg("dim", ` · ${task.id} · ${task.agentSource}`)}`, 0, 0));
|
|
1903
|
+
container.addChild(new Text(theme.fg("dim", `${task.model ?? "no model"} · ${task.thinking ?? "off"} · ${task.tools.join(", ")} · ${formatUsage(task.usage)} · ${task.durationMs}ms`), 0, 0));
|
|
1904
|
+
container.addChild(new Text(theme.fg("muted", `Task: ${task.task}`), 0, 0));
|
|
1905
|
+
for (const entry of task.trace) container.addChild(new Text(`${theme.fg("muted", "→ ")}${theme.fg("toolOutput", entry)}`, 0, 0));
|
|
1906
|
+
if (task.traceTruncatedBytes || task.stderrTruncatedBytes || task.outputTruncatedBytes) {
|
|
1907
|
+
container.addChild(new Text(theme.fg("warning", `Truncated · trace ${task.traceTruncatedBytes} B · stderr ${task.stderrTruncatedBytes} B · output ${task.outputTruncatedBytes} B`), 0, 0));
|
|
1908
|
+
}
|
|
1909
|
+
if (task.output) container.addChild(new Markdown(task.output, 0, 0, getMarkdownTheme()));
|
|
1910
|
+
else if (task.errorMessage || task.stderr) container.addChild(new Text(theme.fg("error", task.errorMessage || task.stderr), 0, 0));
|
|
1911
|
+
}
|
|
1912
|
+
container.addChild(new Spacer(1));
|
|
1913
|
+
container.addChild(new Text(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)}`), 0, 0));
|
|
1914
|
+
return container;
|
|
1915
|
+
},
|
|
1916
|
+
});
|
|
1917
|
+
}
|