killeros 1.5.8 → 2.0.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.
@@ -1,3257 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
3
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
- import os from "node:os";
5
- import path from "node:path";
6
- import { getSupportedThinkingLevels, StringEnum, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
7
- import {
8
- CONFIG_DIR_NAME,
9
- getAgentDir,
10
- getMarkdownTheme,
11
- parseFrontmatter,
12
- type ExtensionAPI,
13
- type ExtensionContext,
14
- } from "@earendil-works/pi-coding-agent";
15
- import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
16
- import { Type } from "typebox";
17
- import { SubagentThreadRegistry, type SubagentThread, type SubagentThreadId, type SubagentThreadState } from "./subagent-lifecycle.ts";
18
- import { MAX_NODE_TIMER_MS, runSubagentProcess, type SubagentProcessHandle, type SubagentProcessResult } from "./subagent-process.ts";
19
- import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadBoardRecord } from "./subagent-ui.ts";
20
-
21
- export const SUBAGENT_LIMITS = {
22
- maxTasks: 10,
23
- maxReadConcurrency: 4,
24
- toolOutputBytes: 50 * 1024,
25
- traceRetentionBytes: 8 * 1024 * 1024,
26
- stderrRetentionBytes: 1 * 1024 * 1024,
27
- taskOutputRetentionBytes: 1 * 1024 * 1024,
28
- threadRetentionRecords: 64,
29
- threadRetentionBytes: 128 * 1024 * 1024,
30
- roleFileBytes: 64 * 1024,
31
- taskCharacters: 20_000,
32
- killGraceMs: 5_000,
33
- defaultMaxTurns: 64,
34
- defaultQuotaTokens: 2_000_000,
35
- defaultWallTimeMs: 1_800_000,
36
- processExitWaitMs: 10_000,
37
- } as const;
38
-
39
- const WEB_TOOLS = new Set(["web_search", "source_check", "fetch_content", "get_search_content"]);
40
- const READ_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOLS]);
41
- const WRITE_TOOLS = new Set(["bash", "edit", "write"]);
42
- const KNOWN_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
43
- const SUBAGENT_WEB_EXTENSION = "npm:pi-web-access";
44
- const INHERIT_SETTING = "inherit";
45
- const MAX_RUNTIME_STEERING_MESSAGES = 20;
46
- const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "thinking", "timeoutMs"]);
47
-
48
- type ThinkingLevel = ModelThinkingLevel;
49
- export type AgentAccess = "read" | "write";
50
- export type AgentSource = "bundled" | "personal" | "project" | "inline" | "legacy";
51
- export type AgentScope = "user" | "project" | "both";
52
- export type SubagentStatus = "queued" | "running" | "complete" | "failed" | "cancelled" | "limited" | "orphaned";
53
-
54
- export interface AgentRole {
55
- name: string;
56
- description: string;
57
- access: AgentAccess;
58
- tools: string[];
59
- model?: string;
60
- thinking?: string;
61
- timeoutMs?: number;
62
- prompt: string;
63
- source: AgentSource;
64
- filePath: string;
65
- }
66
-
67
- export interface InlineAgentRole {
68
- name: string;
69
- description: string;
70
- access: AgentAccess;
71
- tools: string[];
72
- }
73
-
74
- export type AgentSpec = string | InlineAgentRole;
75
-
76
- export interface AgentDiscoveryResult {
77
- agents: AgentRole[];
78
- projectAgentsDir: string | null;
79
- }
80
-
81
- export interface SubagentUsage {
82
- input: number;
83
- output: number;
84
- cacheRead: number;
85
- cacheWrite: number;
86
- totalTokens: number;
87
- cost: {
88
- input: number;
89
- output: number;
90
- cacheRead: number;
91
- cacheWrite: number;
92
- total: number;
93
- };
94
- turns: number;
95
- }
96
-
97
- export interface SubagentTaskResult {
98
- id: string;
99
- name?: string;
100
- attempt: number;
101
- agent: string;
102
- agentSource: AgentSource | "unknown";
103
- sourcePath?: string;
104
- task: string;
105
- access?: AgentAccess;
106
- status: SubagentStatus;
107
- model?: string;
108
- thinking?: ThinkingLevel;
109
- tools: string[];
110
- trace: string[];
111
- traceBytes: number;
112
- traceTruncatedBytes: number;
113
- stderr: string;
114
- stderrBytes: number;
115
- stderrTruncatedBytes: number;
116
- output: string;
117
- outputBytes: number;
118
- outputTruncatedBytes: number;
119
- toolCallCount: number;
120
- usage: SubagentUsage;
121
- durationMs: number;
122
- exitCode: number | null;
123
- exitConfirmed: boolean;
124
- terminationReason?: string;
125
- errorMessage?: string;
126
- step?: number;
127
- }
128
-
129
- export interface SubagentDetails {
130
- mode: "single" | "parallel" | "chain";
131
- agentScope: AgentScope;
132
- projectAgentsDir: string | null;
133
- executionNote?: string;
134
- results: SubagentTaskResult[];
135
- aggregateUsage: SubagentUsage;
136
- parentId?: string;
137
- threads?: SubagentThread[];
138
- activeThreads?: SubagentThread[];
139
- doneThreads?: SubagentThread[];
140
- selectedThreadId?: string;
141
- wait?: SubagentWaitSummary;
142
- backgroundStarted?: boolean;
143
- }
144
-
145
- export interface SubagentWaitSummary {
146
- targetThreadIds: string[];
147
- completedThreadIds: string[];
148
- pendingThreadIds: string[];
149
- timedOut: boolean;
150
- waitedMs: number;
151
- }
152
-
153
- export interface SubagentControlRequest {
154
- action: "list" | "inspect" | "wait" | "steer" | "interrupt" | "collect" | "resume" | "close";
155
- threadId?: string;
156
- all?: true;
157
- message?: string;
158
- task?: string;
159
- timeoutMs?: number;
160
- }
161
-
162
- export interface SubagentControlResult {
163
- text: string;
164
- details: SubagentDetails;
165
- usage: SubagentUsage;
166
- }
167
-
168
- export interface SubagentControlApi {
169
- execute(request: SubagentControlRequest, ctx: ExtensionContext): Promise<SubagentControlResult>;
170
- }
171
-
172
- export const SUBAGENT_PERSISTENCE_TYPE = "killeros-subagent-v1";
173
-
174
- interface ModelContext {
175
- model?: Model<any>;
176
- thinkingLevel?: ThinkingLevel;
177
- modelRegistry: {
178
- getAvailable(): Model<any>[];
179
- };
180
- }
181
-
182
- interface ResolvedModel {
183
- model: string;
184
- thinking: ThinkingLevel;
185
- definition: Model<any>;
186
- }
187
-
188
- interface SpawnedProcess {
189
- stdout: NodeJS.ReadableStream;
190
- stderr: NodeJS.ReadableStream;
191
- pid?: number;
192
- kill(signal?: NodeJS.Signals | number): boolean;
193
- on(event: "error", listener: (error: Error) => void): this;
194
- once(event: "close", listener: (code: number | null) => void): this;
195
- }
196
-
197
- type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number } & {
198
- wallTimeMs?: number;
199
- maxTurns?: number;
200
- jsonlLineBytes?: number;
201
- traceBytes?: number;
202
- stderrBytes?: number;
203
- taskOutputBytes?: number;
204
- quotaTokens?: number;
205
- quotaUsd?: number;
206
- };
207
-
208
- export interface SubagentRuntimeOptions {
209
- /** Explicit test or embedding role directory; KillerOS ships no bundled roles. */
210
- bundledAgentsDir?: string;
211
- userAgentsDir?: string;
212
- webExtension?: string;
213
- spawnProcess?: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SpawnedProcess;
214
- limits?: Partial<SubagentLimits>;
215
- /** Test and embedding compatibility mode; production spawns return immediately. */
216
- awaitSpawnCompletion?: boolean;
217
- }
218
-
219
- class AgentConfigurationError extends Error {
220
- constructor(filePath: string, field: string, message: string) {
221
- super(`${filePath} [${field}]: ${message}`);
222
- this.name = "AgentConfigurationError";
223
- }
224
- }
225
-
226
- function emptyUsage(): SubagentUsage {
227
- return {
228
- input: 0,
229
- output: 0,
230
- cacheRead: 0,
231
- cacheWrite: 0,
232
- totalTokens: 0,
233
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
234
- turns: 0,
235
- };
236
- }
237
-
238
- function addUsage(target: SubagentUsage, source: Partial<SubagentUsage> | undefined): void {
239
- if (!source) return;
240
- target.input += source.input ?? 0;
241
- target.output += source.output ?? 0;
242
- target.cacheRead += source.cacheRead ?? 0;
243
- target.cacheWrite += source.cacheWrite ?? 0;
244
- target.totalTokens += source.totalTokens ?? 0;
245
- target.cost.input += source.cost?.input ?? 0;
246
- target.cost.output += source.cost?.output ?? 0;
247
- target.cost.cacheRead += source.cost?.cacheRead ?? 0;
248
- target.cost.cacheWrite += source.cost?.cacheWrite ?? 0;
249
- target.cost.total += source.cost?.total ?? 0;
250
- target.turns += source.turns ?? 0;
251
- }
252
-
253
- function aggregateUsage(results: SubagentTaskResult[]): SubagentUsage {
254
- const total = emptyUsage();
255
- for (const result of results) addUsage(total, result.usage);
256
- return total;
257
- }
258
-
259
- function boundedText(text: string, maxBytes: number, marker: string): string {
260
- const capped = truncateUtf8(text, maxBytes);
261
- if (!capped.omittedBytes) return text;
262
- const markerBytes = Buffer.byteLength(marker, "utf8");
263
- if (markerBytes >= maxBytes) return truncateUtf8(text, maxBytes).text;
264
- return `${truncateUtf8(text, maxBytes - markerBytes).text}${marker}`;
265
- }
266
-
267
- function readBoundedFile(filePath: string, maxBytes: number): string {
268
- let descriptor: number | undefined;
269
- try {
270
- descriptor = openSync(filePath, "r");
271
- const buffer = Buffer.alloc(maxBytes + 1);
272
- const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
273
- if (bytesRead > maxBytes) throw new AgentConfigurationError(filePath, "file", `exceeds ${maxBytes} bytes`);
274
- return buffer.toString("utf8", 0, bytesRead);
275
- } finally {
276
- if (descriptor !== undefined) closeSync(descriptor);
277
- }
278
- }
279
-
280
- function requiredString(frontmatter: Record<string, unknown>, filePath: string, field: string): string {
281
- const value = frontmatter[field];
282
- if (typeof value !== "string" || !value.trim()) {
283
- throw new AgentConfigurationError(filePath, field, "must be a non-empty string");
284
- }
285
- return value.trim();
286
- }
287
-
288
- function optionalPositiveInteger(
289
- frontmatter: Record<string, unknown>,
290
- filePath: string,
291
- field: string,
292
- fallback?: number,
293
- maximum?: number,
294
- ): number | undefined {
295
- const value = frontmatter[field];
296
- if (value === undefined || value === "") return fallback;
297
- const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
298
- if (!Number.isInteger(parsed) || parsed <= 0 || maximum !== undefined && parsed > maximum) {
299
- const bound = maximum === undefined ? "" : " no greater than " + maximum;
300
- throw new AgentConfigurationError(filePath, field, `must be a positive integer${bound}`);
301
- }
302
- return parsed;
303
- }
304
-
305
- function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentLimits): AgentRole {
306
- const content = readBoundedFile(filePath, limits.roleFileBytes);
307
- let parsed: { frontmatter: Record<string, unknown>; body: string };
308
- try {
309
- parsed = parseFrontmatter<Record<string, unknown>>(content);
310
- } catch (error) {
311
- throw new AgentConfigurationError(filePath, "frontmatter", error instanceof Error ? error.message : String(error));
312
- }
313
- const frontmatter = parsed.frontmatter;
314
- for (const field of Object.keys(frontmatter)) {
315
- if (!ROLE_FIELDS.has(field)) throw new AgentConfigurationError(filePath, field, "unknown role field");
316
- }
317
-
318
- const name = requiredString(frontmatter, filePath, "name");
319
- if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(name)) {
320
- throw new AgentConfigurationError(filePath, "name", "must use 1-64 letters, numbers, dots, underscores, or hyphens");
321
- }
322
- const description = requiredString(frontmatter, filePath, "description");
323
- if (description.length > 500) throw new AgentConfigurationError(filePath, "description", "must not exceed 500 characters");
324
-
325
- const accessValue = requiredString(frontmatter, filePath, "access");
326
- if (accessValue !== "read" && accessValue !== "write") {
327
- throw new AgentConfigurationError(filePath, "access", 'must be "read" or "write"');
328
- }
329
- const toolsValue = requiredString(frontmatter, filePath, "tools");
330
- const tools = [...new Set(toolsValue.split(",").map((tool) => tool.trim()).filter(Boolean))];
331
- if (tools.length === 0) throw new AgentConfigurationError(filePath, "tools", "must contain at least one tool");
332
- for (const tool of tools) {
333
- if (!KNOWN_TOOLS.has(tool)) throw new AgentConfigurationError(filePath, "tools", `unknown child tool ${JSON.stringify(tool)}`);
334
- if (accessValue === "read" && WRITE_TOOLS.has(tool)) {
335
- throw new AgentConfigurationError(filePath, "tools", `read-only roles cannot use ${tool}`);
336
- }
337
- }
338
-
339
- const prompt = parsed.body.trim();
340
- if (!prompt) throw new AgentConfigurationError(filePath, "prompt", "Markdown body must be non-empty");
341
- const modelValue = frontmatter.model;
342
- if (modelValue !== undefined && (typeof modelValue !== "string" || !modelValue.trim())) {
343
- throw new AgentConfigurationError(filePath, "model", "must be a non-empty string when provided");
344
- }
345
- const thinkingValue = frontmatter.thinking;
346
- if (thinkingValue !== undefined && (typeof thinkingValue !== "string" || !thinkingValue.trim())) {
347
- throw new AgentConfigurationError(filePath, "thinking", "must be a non-empty string when provided");
348
- }
349
-
350
- return {
351
- name,
352
- description,
353
- access: accessValue,
354
- tools,
355
- model: typeof modelValue === "string" ? modelValue.trim() : undefined,
356
- thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
357
- timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", undefined, MAX_NODE_TIMER_MS),
358
- prompt,
359
- source,
360
- filePath,
361
- };
362
- }
363
-
364
- function loadAgentDirectory(dir: string, source: AgentSource, limits: SubagentLimits): AgentRole[] {
365
- let entries;
366
- try {
367
- entries = readdirSync(dir, { withFileTypes: true });
368
- } catch (error) {
369
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
370
- throw new Error(`Could not read ${source} agent directory ${dir}: ${error instanceof Error ? error.message : String(error)}`);
371
- }
372
- const agents: AgentRole[] = [];
373
- const names = new Set<string>();
374
- for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
375
- if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
376
- const agent = parseAgentFile(path.join(dir, entry.name), source, limits);
377
- if (names.has(agent.name)) throw new AgentConfigurationError(agent.filePath, "name", `duplicate ${source} role ${JSON.stringify(agent.name)}`);
378
- names.add(agent.name);
379
- agents.push(agent);
380
- }
381
- return agents;
382
- }
383
-
384
- function isDirectory(candidate: string): boolean {
385
- try {
386
- return statSync(candidate).isDirectory();
387
- } catch {
388
- return false;
389
- }
390
- }
391
-
392
- function findProjectAgentsDir(cwd: string): string | null {
393
- let current = path.resolve(cwd);
394
- while (true) {
395
- const candidate = path.join(current, CONFIG_DIR_NAME, "agents");
396
- if (isDirectory(candidate)) return candidate;
397
- const parent = path.dirname(current);
398
- if (parent === current) return null;
399
- current = parent;
400
- }
401
- }
402
-
403
- export function discoverAgentRoles(
404
- cwd: string,
405
- scope: AgentScope,
406
- projectTrusted: boolean,
407
- options: Pick<SubagentRuntimeOptions, "bundledAgentsDir" | "userAgentsDir" | "limits"> = {},
408
- ): AgentDiscoveryResult {
409
- const limits = { ...SUBAGENT_LIMITS, ...options.limits };
410
- const bundledDir = options.bundledAgentsDir;
411
- const userDir = options.userAgentsDir ?? path.join(getAgentDir(), "agents");
412
- const wantsProject = scope === "project" || scope === "both";
413
- if (wantsProject && !projectTrusted) throw new Error("Project agents require a trusted project");
414
- const projectAgentsDir = wantsProject ? findProjectAgentsDir(cwd) : null;
415
-
416
- const layers: Array<{ dir: string; source: AgentSource }> = [];
417
- if (bundledDir) layers.push({ dir: bundledDir, source: "bundled" });
418
- if (scope === "user" || scope === "both") layers.push({ dir: userDir, source: "personal" });
419
- if (wantsProject && projectAgentsDir) layers.push({ dir: projectAgentsDir, source: "project" });
420
-
421
- const byName = new Map<string, AgentRole>();
422
- for (const layer of layers) {
423
- for (const agent of loadAgentDirectory(layer.dir, layer.source, limits)) byName.set(agent.name, agent);
424
- }
425
- return {
426
- agents: [...byName.values()].sort((left, right) => left.name.localeCompare(right.name)),
427
- projectAgentsDir,
428
- };
429
- }
430
-
431
- function matchingModels(value: string, available: Model<any>[]): Model<any>[] {
432
- const slash = value.indexOf("/");
433
- if (slash >= 1 && slash < value.length - 1) {
434
- const provider = value.slice(0, slash);
435
- const id = value.slice(slash + 1);
436
- return available.filter((model) => model.provider === provider && model.id === id);
437
- }
438
- return available.filter((model) => model.id === value);
439
- }
440
-
441
- function splitModelAndThinking(value: string, filePath: string, available: Model<any>[]): { model: string; thinking?: string } {
442
- if (matchingModels(value, available).length > 0) return { model: value };
443
- const colon = value.lastIndexOf(":");
444
- if (colon < 0) return { model: value };
445
- const model = value.slice(0, colon);
446
- if (!model) throw new AgentConfigurationError(filePath, "model", "model identifier is missing");
447
- if (matchingModels(model, available).length > 0) return { model, thinking: value.slice(colon + 1) };
448
- return { model: value };
449
- }
450
-
451
- function resolveAvailableModel(value: string, filePath: string, available: Model<any>[]): Model<any> {
452
- const matches = matchingModels(value, available);
453
- if (matches.length === 0) throw new AgentConfigurationError(filePath, "model", `unavailable model ${JSON.stringify(value)}`);
454
- if (matches.length > 1) throw new AgentConfigurationError(filePath, "model", `ambiguous model ${JSON.stringify(value)}; use provider/model`);
455
- return matches[0]!;
456
- }
457
-
458
- function configuredSetting(override: string | undefined, roleSetting: string | undefined): string | undefined {
459
- const overrideValue = override?.trim();
460
- const roleValue = roleSetting?.trim();
461
- const selected = overrideValue && overrideValue !== INHERIT_SETTING ? overrideValue : roleValue;
462
- return selected && selected !== INHERIT_SETTING ? selected : undefined;
463
- }
464
-
465
- export function resolveAgentModel(
466
- agent: AgentRole,
467
- ctx: ModelContext,
468
- modelOverride?: string,
469
- thinkingOverride?: string,
470
- ): ResolvedModel {
471
- const inheritedThinking = ctx.thinkingLevel ?? "off";
472
- const configuredModel = configuredSetting(modelOverride, agent.model);
473
- const configuredThinking = configuredSetting(thinkingOverride, agent.thinking);
474
- let definition: Model<any> | undefined;
475
- let thinking: string = configuredThinking ?? inheritedThinking;
476
-
477
- if (configuredModel) {
478
- const available = ctx.modelRegistry.getAvailable();
479
- const requested = splitModelAndThinking(configuredModel, agent.filePath, available);
480
- thinking = configuredThinking ?? requested.thinking ?? inheritedThinking;
481
- definition = resolveAvailableModel(requested.model, agent.filePath, available);
482
- } else {
483
- definition = ctx.model;
484
- if (!definition) throw new AgentConfigurationError(agent.filePath, "model", "no active parent model is available to inherit");
485
- }
486
-
487
- const supportedThinking = getSupportedThinkingLevels(definition) as readonly string[];
488
- if (!supportedThinking.includes(thinking)) {
489
- throw new AgentConfigurationError(agent.filePath, "thinking", `${definition.provider}/${definition.id} does not support thinking level ${thinking}`);
490
- }
491
- return { model: `${definition.provider}/${definition.id}`, thinking: thinking as ThinkingLevel, definition };
492
- }
493
-
494
- function getPiInvocation(args: string[]): { command: string; args: string[] } {
495
- const currentScript = process.argv[1];
496
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
497
- if (currentScript && !isBunVirtualScript) {
498
- try {
499
- if (statSync(currentScript).isFile()) return { command: process.execPath, args: [currentScript, ...args] };
500
- } catch {
501
- // Fall through to the installed pi command.
502
- }
503
- }
504
- const executable = path.basename(process.execPath).toLocaleLowerCase();
505
- return /^(node|bun)(\.exe)?$/u.test(executable)
506
- ? { command: "pi", args }
507
- : { command: process.execPath, args };
508
- }
509
-
510
- export function childProcessEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
511
- const childEnvironment = { ...environment };
512
- delete childEnvironment.PI_SESSION_FILE;
513
- delete childEnvironment.PI_SESSION_ID;
514
- return childEnvironment;
515
- }
516
-
517
- function defaultSpawnProcess(args: string[], cwd: string): SpawnedProcess {
518
- const invocation = getPiInvocation(args);
519
- return spawn(invocation.command, invocation.args, {
520
- cwd,
521
- detached: process.platform !== "win32",
522
- env: childProcessEnvironment(),
523
- shell: false,
524
- stdio: ["ignore", "pipe", "pipe"],
525
- windowsHide: true,
526
- }) as unknown as SpawnedProcess;
527
- }
528
-
529
- function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBytes: number } {
530
- const bytes = Buffer.from(text, "utf8");
531
- if (bytes.length <= maxBytes) return { text, omittedBytes: 0 };
532
- let truncated = bytes.subarray(0, maxBytes).toString("utf8");
533
- if (truncated.endsWith("�")) truncated = truncated.slice(0, -1);
534
- return { text: truncated, omittedBytes: bytes.length - Buffer.byteLength(truncated, "utf8") };
535
- }
536
-
537
- function makeQueuedResult(id: string, agent: string, task: string, step?: number, name?: string, attempt = 1): SubagentTaskResult {
538
- return {
539
- id,
540
- name,
541
- attempt,
542
- agent,
543
- agentSource: "unknown",
544
- task,
545
- status: "queued",
546
- tools: [],
547
- trace: [],
548
- traceBytes: 0,
549
- traceTruncatedBytes: 0,
550
- stderr: "",
551
- stderrBytes: 0,
552
- stderrTruncatedBytes: 0,
553
- output: "",
554
- outputBytes: 0,
555
- outputTruncatedBytes: 0,
556
- toolCallCount: 0,
557
- usage: emptyUsage(),
558
- durationMs: 0,
559
- exitCode: null,
560
- exitConfirmed: false,
561
- step,
562
- };
563
- }
564
-
565
- function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
566
- return {
567
- ...result,
568
- tools: [...result.tools],
569
- trace: [...result.trace],
570
- usage: { ...result.usage, cost: { ...result.usage.cost } },
571
- };
572
- }
573
-
574
- function mergeTaskResults(
575
- previous: SubagentTaskResult | undefined,
576
- next: SubagentTaskResult,
577
- maxTraceBytes?: number,
578
- maxStderrBytes?: number,
579
- ): SubagentTaskResult {
580
- if (!previous) return cloneResult(next);
581
- const merged = cloneResult(next);
582
- const trace: string[] = [];
583
- let traceBytes = 0;
584
- let traceTruncatedBytes = previous.traceTruncatedBytes + next.traceTruncatedBytes;
585
- for (const entry of [...previous.trace, ...next.trace]) {
586
- const retained = truncateUtf8(entry, maxTraceBytes === undefined ? Buffer.byteLength(entry, "utf8") : Math.max(0, maxTraceBytes - traceBytes));
587
- if (retained.text) trace.push(retained.text);
588
- const retainedBytes = Buffer.byteLength(retained.text, "utf8");
589
- traceBytes += retainedBytes;
590
- traceTruncatedBytes += retained.omittedBytes;
591
- }
592
- merged.trace = trace;
593
- merged.traceBytes = traceBytes;
594
- merged.traceTruncatedBytes = traceTruncatedBytes;
595
- const stderr = [previous.stderr, next.stderr].filter(Boolean).join("\n");
596
- const retainedStderr = truncateUtf8(stderr, maxStderrBytes === undefined ? Buffer.byteLength(stderr, "utf8") : maxStderrBytes);
597
- merged.stderr = retainedStderr.text;
598
- merged.stderrBytes = previous.stderrBytes + next.stderrBytes;
599
- merged.stderrTruncatedBytes = previous.stderrTruncatedBytes + next.stderrTruncatedBytes + retainedStderr.omittedBytes;
600
- merged.output = next.output || previous.output;
601
- merged.outputBytes = previous.outputBytes + next.outputBytes;
602
- merged.outputTruncatedBytes = previous.outputTruncatedBytes + next.outputTruncatedBytes;
603
- merged.toolCallCount = previous.toolCallCount + next.toolCallCount;
604
- merged.usage = emptyUsage();
605
- addUsage(merged.usage, previous.usage);
606
- addUsage(merged.usage, next.usage);
607
- merged.durationMs = previous.durationMs + next.durationMs;
608
- return merged;
609
- }
610
-
611
- function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectAgentsDir: string | null, results: SubagentTaskResult[]): SubagentDetails {
612
- const cloned = results.map(cloneResult);
613
- return { mode, agentScope: scope, projectAgentsDir, results: cloned, aggregateUsage: aggregateUsage(cloned) };
614
- }
615
-
616
- async function writeRolePrompt(agent: AgentRole): Promise<{ directory: string; filePath: string }> {
617
- const directory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-"));
618
- const filePath = path.join(directory, `${agent.name.replace(/[^A-Za-z0-9_.-]/gu, "_")}.md`);
619
- await writeFile(filePath, agent.prompt, { encoding: "utf8", mode: 0o600 });
620
- return { directory, filePath };
621
- }
622
-
623
- interface RunTaskOptions {
624
- cwd: string;
625
- agent: AgentRole;
626
- task: string;
627
- id: string;
628
- displayName: string;
629
- attempt: number;
630
- step?: number;
631
- model: ResolvedModel;
632
- signal?: AbortSignal;
633
- spawnProcess: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SpawnedProcess;
634
- webExtension?: string;
635
- projectTrusted: boolean;
636
- limits: SubagentLimits;
637
- sessionDirectory: string;
638
- sessionId: string;
639
- timeoutMs?: number;
640
- onChange: (result: SubagentTaskResult) => void;
641
- onHandle?: (handle: SubagentProcessHandle) => void;
642
- }
643
-
644
- function applyProcessResult(
645
- target: SubagentTaskResult,
646
- source: Readonly<SubagentProcessResult>,
647
- startedAt: number,
648
- onChange: (result: SubagentTaskResult) => void,
649
- ): void {
650
- target.status = source.status;
651
- target.trace = [...source.trace];
652
- target.traceBytes = source.traceBytes;
653
- target.traceTruncatedBytes = source.traceTruncatedBytes;
654
- target.stderr = source.stderr;
655
- target.stderrBytes = source.stderrBytes;
656
- target.stderrTruncatedBytes = source.stderrTruncatedBytes;
657
- target.output = source.output;
658
- target.outputBytes = source.outputBytes;
659
- target.outputTruncatedBytes = source.outputTruncatedBytes;
660
- target.toolCallCount = source.toolCallCount;
661
- target.usage = { ...source.usage, cost: { ...source.usage.cost } };
662
- target.model = source.model ?? target.model;
663
- target.terminationReason = source.terminationReason;
664
- target.errorMessage = source.errorMessage;
665
- target.exitCode = source.exitCode;
666
- target.exitConfirmed = source.exitConfirmed;
667
- target.durationMs = source.durationMs || Date.now() - startedAt;
668
- onChange(target);
669
- }
670
-
671
- async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
672
- const { agent, limits } = options;
673
- const result = makeQueuedResult(options.id, agent.name, options.task, options.step, options.displayName, options.attempt);
674
- result.agentSource = agent.source;
675
- result.sourcePath = agent.filePath;
676
- result.access = agent.access;
677
- result.tools = [...agent.tools];
678
- result.model = options.model.model;
679
- result.thinking = options.model.thinking;
680
- result.status = "running";
681
- const startedAt = Date.now();
682
- const notify = (changed: SubagentTaskResult): void => {
683
- try {
684
- options.onChange(changed);
685
- } catch {
686
- // Host update callbacks are telemetry; a throwing callback must not fail the task.
687
- }
688
- };
689
- notify(result);
690
-
691
- if (options.signal?.aborted) {
692
- result.status = "cancelled";
693
- result.terminationReason = "abort";
694
- result.durationMs = Date.now() - startedAt;
695
- notify(result);
696
- return result;
697
- }
698
-
699
- let promptDirectory: string | undefined;
700
- try {
701
- const prompt = await writeRolePrompt(agent);
702
- promptDirectory = prompt.directory;
703
- if (options.signal?.aborted) {
704
- result.status = "cancelled";
705
- result.terminationReason = "abort";
706
- result.durationMs = Date.now() - startedAt;
707
- notify(result);
708
- return result;
709
- }
710
- const args = [
711
- "--mode", "json",
712
- "-p",
713
- "--session-dir", options.sessionDirectory,
714
- "--session-id", options.sessionId,
715
- "--name", options.displayName,
716
- "--no-extensions",
717
- "--extension", options.webExtension ?? SUBAGENT_WEB_EXTENSION,
718
- "--no-prompt-templates",
719
- options.projectTrusted ? "--approve" : "--no-approve",
720
- "--model", options.model.model,
721
- "--thinking", options.model.thinking,
722
- "--tools", agent.tools.join(","),
723
- "--append-system-prompt", prompt.filePath,
724
- `Task: ${options.task}`,
725
- ];
726
- const handle = runSubagentProcess({
727
- args,
728
- cwd: options.cwd,
729
- signal: options.signal,
730
- spawnProcess: options.spawnProcess,
731
- limits: {
732
- ...(options.timeoutMs === undefined ? {} : { wallTimeMs: options.timeoutMs }),
733
- ...(limits.maxTurns === undefined ? {} : { maxTurns: limits.maxTurns }),
734
- ...(limits.jsonlLineBytes === undefined ? {} : { jsonlLineBytes: limits.jsonlLineBytes }),
735
- ...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes }),
736
- ...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes }),
737
- ...(limits.taskOutputBytes === undefined ? {} : { outputBytes: limits.taskOutputBytes }),
738
- ...(limits.quotaTokens === undefined ? {} : { quotaTokens: limits.quotaTokens }),
739
- ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd }),
740
- killGraceMs: limits.killGraceMs,
741
- processExitWaitMs: limits.processExitWaitMs,
742
- },
743
- retention: {
744
- traceBytes: limits.traceRetentionBytes,
745
- stderrBytes: limits.stderrRetentionBytes,
746
- outputBytes: limits.taskOutputRetentionBytes,
747
- },
748
- onUpdate: (next) => applyProcessResult(result, next, startedAt, notify),
749
- });
750
- options.onHandle?.(handle);
751
- const final = await handle.result;
752
- applyProcessResult(result, final, startedAt, notify);
753
- return result;
754
- } catch (error) {
755
- result.status = options.signal?.aborted ? "cancelled" : "failed";
756
- result.terminationReason = options.signal?.aborted ? "abort" : "spawn_error";
757
- result.errorMessage = error instanceof Error ? error.message : String(error);
758
- result.durationMs = Date.now() - startedAt;
759
- notify(result);
760
- return result;
761
- } finally {
762
- if (promptDirectory) {
763
- try {
764
- await rm(promptDirectory, { recursive: true, force: true });
765
- } catch {
766
- // Temporary prompt cleanup is best effort after child termination.
767
- }
768
- }
769
- }
770
- }
771
-
772
- async function mapReadTasks<T>(items: T[], concurrency: number, run: (item: T, index: number) => Promise<void>): Promise<void> {
773
- let next = 0;
774
- const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
775
- while (true) {
776
- const index = next;
777
- next += 1;
778
- if (index >= items.length) return;
779
- await run(items[index]!, index);
780
- }
781
- });
782
- await Promise.all(workers);
783
- }
784
-
785
- function waitForConfirmedProcessExit(handle: SubagentProcessHandle, timeoutMs = 1_000): Promise<boolean> {
786
- if (handle.hasExited) return Promise.resolve(true);
787
- return new Promise((resolve) => {
788
- let settled = false;
789
- let timeout: NodeJS.Timeout | undefined;
790
- const finish = (exited: boolean): void => {
791
- if (settled) return;
792
- settled = true;
793
- if (timeout) clearTimeout(timeout);
794
- resolve(exited);
795
- };
796
- timeout = setTimeout(() => finish(handle.hasExited), timeoutMs);
797
- void handle.exited.then(() => finish(true));
798
- });
799
- }
800
-
801
- const SUBAGENT_ACTION = {
802
- spawn: "spawn",
803
- list: "list",
804
- inspect: "inspect",
805
- steer: "steer",
806
- interrupt: "interrupt",
807
- collect: "collect",
808
- wait: "wait",
809
- resume: "resume",
810
- close: "close",
811
- } as const;
812
- type SubagentAction = typeof SUBAGENT_ACTION[keyof typeof SUBAGENT_ACTION];
813
-
814
- type SpawnOptions = {
815
- model?: string;
816
- thinking?: string;
817
- agentScope?: AgentScope;
818
- };
819
-
820
- type SpawnSingleRequest = SpawnOptions & {
821
- action?: "spawn";
822
- agent?: AgentSpec;
823
- task: string;
824
- name?: string;
825
- };
826
-
827
- type SpawnParallelRequest = SpawnOptions & {
828
- action?: "spawn";
829
- tasks: TaskInput[];
830
- writerConcurrency?: number;
831
- };
832
-
833
- type SpawnChainRequest = SpawnOptions & {
834
- action?: "spawn";
835
- chain: TaskInput[];
836
- };
837
-
838
- export type NormalizedSubagentRequest =
839
- | { kind: "spawn-single"; input: SpawnSingleRequest }
840
- | { kind: "spawn-parallel"; input: SpawnParallelRequest }
841
- | { kind: "spawn-chain"; input: SpawnChainRequest }
842
- | { kind: "list"; input: { action: "list" } }
843
- | { kind: "inspect"; input: { action: "inspect"; threadId: string } }
844
- | { kind: "steer"; input: { action: "steer"; threadId: string; message: string } }
845
- | { kind: "interrupt-one"; input: { action: "interrupt"; threadId: string } }
846
- | { kind: "interrupt-all"; input: { action: "interrupt"; all: true } }
847
- | { kind: "collect"; input: { action: "collect"; threadId: string } }
848
- | { kind: "wait"; input: { action: "wait"; threadId?: string; all?: true; timeoutMs: number } }
849
- | { kind: "resume"; input: { action: "resume"; threadId: string; task?: string } }
850
- | { kind: "close"; input: { action: "close"; threadId: string } };
851
-
852
- type SubagentRequestParse =
853
- | { ok: true; request: NormalizedSubagentRequest }
854
- | { ok: false; message: string };
855
-
856
- const SUBAGENT_ACTIONS = Object.values(SUBAGENT_ACTION);
857
- const SPAWN_OPTION_FIELDS = ["model", "thinking", "agentScope"] as const;
858
- const THREAD_NAME_PATTERN = "^[A-Za-z0-9][A-Za-z0-9._ -]{0,47}$";
859
- const THREAD_NAME_RE = new RegExp(THREAD_NAME_PATTERN, "u");
860
- const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
861
- const MAX_WAIT_TIMEOUT_MS = 3_600_000;
862
-
863
- export function validateThreadName(name: string): void {
864
- if (!name.trim()) throw new Error("Invalid subagent request: name must be a non-empty string.");
865
- if ([...name].length > 48 || !THREAD_NAME_RE.test(name)) {
866
- throw new Error(`Invalid subagent request: name must match ${THREAD_NAME_PATTERN}.`);
867
- }
868
- }
869
-
870
- function optionalThreadName(record: Record<string, unknown>): string | undefined {
871
- if (!Object.hasOwn(record, "name")) return undefined;
872
- const value = record.name;
873
- if (typeof value !== "string") throw new Error("Invalid subagent request: name must be a non-empty string.");
874
- validateThreadName(value);
875
- return value;
876
- }
877
-
878
- function requireRecord(value: unknown, name: string): Record<string, unknown> {
879
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
880
- throw new Error(`Invalid ${name}: expected an object.`);
881
- }
882
- return value as Record<string, unknown>;
883
- }
884
-
885
- function requireAction(value: unknown): SubagentAction {
886
- if (typeof value !== "string" || !SUBAGENT_ACTIONS.includes(value as SubagentAction)) {
887
- throw new Error(`Invalid subagent request: action must be one of ${SUBAGENT_ACTIONS.join(", ")}.`);
888
- }
889
- return value as SubagentAction;
890
- }
891
-
892
- function requireTextField(record: Record<string, unknown>, field: string, maxLength: number): string {
893
- const value = record[field];
894
- if (typeof value !== "string" || value.length === 0) {
895
- throw new Error(`Invalid subagent request: ${field} must be a non-empty string.`);
896
- }
897
- if ([...value].length > maxLength) {
898
- throw new Error(`Invalid subagent request: ${field} must be no longer than ${maxLength} characters.`);
899
- }
900
- return value;
901
- }
902
-
903
- function requireOnlyFields(record: Record<string, unknown>, allowed: readonly string[], action: string): void {
904
- const allowedFields = new Set(allowed);
905
- const invalid = Object.keys(record).find((field) => !allowedFields.has(field));
906
- if (invalid) throw new Error(`Invalid subagent request: field ${JSON.stringify(invalid)} is not valid with action ${JSON.stringify(action)}.`);
907
- }
908
-
909
- function parseAgentSpec(value: unknown): AgentSpec | undefined {
910
- if (value === undefined) return undefined;
911
- if (typeof value === "string") {
912
- if (!value.length) throw new Error("Invalid subagent request: agent must be a non-empty role name or inline role.");
913
- if ([...value].length > 64) throw new Error("Invalid subagent request: agent must be no longer than 64 characters.");
914
- return value;
915
- }
916
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
917
- throw new Error("Invalid subagent request: agent must be a role name, inline role, or omitted.");
918
- }
919
- const role = requireRecord(value, "inline role");
920
- for (const field of Object.keys(role)) {
921
- if (!["name", "description", "access", "tools"].includes(field)) {
922
- throw new AgentConfigurationError("inline role", field, "unknown role field");
923
- }
924
- }
925
- const name = requiredString(role, "inline role", "name");
926
- if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(name)) {
927
- throw new AgentConfigurationError("inline role", "name", "must use 1-64 letters, numbers, dots, underscores, or hyphens");
928
- }
929
- const description = requiredString(role, "inline role", "description");
930
- if (description.length > 500) throw new AgentConfigurationError("inline role", "description", "must not exceed 500 characters");
931
- const access = requiredString(role, "inline role", "access");
932
- if (access !== "read" && access !== "write") {
933
- throw new AgentConfigurationError("inline role", "access", 'must be "read" or "write"');
934
- }
935
- if (!Array.isArray(role.tools) || role.tools.length === 0) {
936
- throw new AgentConfigurationError("inline role", "tools", "must contain at least one tool");
937
- }
938
- const tools = [...new Set(role.tools.map((tool) => {
939
- if (typeof tool !== "string" || !tool.trim()) {
940
- throw new AgentConfigurationError("inline role", "tools", "must contain non-empty tool names");
941
- }
942
- return tool.trim();
943
- }))];
944
- for (const tool of tools) {
945
- if (!KNOWN_TOOLS.has(tool)) throw new AgentConfigurationError("inline role", "tools", `unknown child tool ${JSON.stringify(tool)}`);
946
- if (access === "read" && WRITE_TOOLS.has(tool)) {
947
- throw new AgentConfigurationError("inline role", "tools", `read-only roles cannot use ${tool}`);
948
- }
949
- }
950
- return { name, description, access, tools };
951
- }
952
-
953
- function inlineAgentRole(role: InlineAgentRole): AgentRole {
954
- return {
955
- ...role,
956
- prompt: role.description,
957
- source: "inline",
958
- filePath: `inline:${role.name}`,
959
- };
960
- }
961
-
962
- function activeParentTools(pi: ExtensionAPI): Set<string> | undefined {
963
- const getActiveTools = (pi as unknown as { getActiveTools?: () => string[] }).getActiveTools;
964
- return typeof getActiveTools === "function" ? new Set(getActiveTools.call(pi)) : undefined;
965
- }
966
-
967
- function validateAgentTools(agent: AgentRole, parentTools: ReadonlySet<string> | undefined): void {
968
- if (!parentTools) return;
969
- const unavailable = agent.tools.find((tool) => !parentTools.has(tool));
970
- if (unavailable) throw new Error(`Role ${JSON.stringify(agent.name)} tool ${JSON.stringify(unavailable)} is not active for the parent`);
971
- }
972
-
973
- function genericAgentRole(parentTools: ReadonlySet<string> | undefined): AgentRole {
974
- const tools = [...READ_TOOLS].filter((tool) => !parentTools || parentTools.has(tool));
975
- if (tools.length === 0) throw new Error("No read-only child tools are active for the parent");
976
- return {
977
- name: "generic",
978
- description: "Complete the assigned task and return the result to the parent.",
979
- access: "read",
980
- tools,
981
- prompt: "Complete the assigned task and return the result to the parent.",
982
- source: "inline",
983
- filePath: "inline:generic",
984
- };
985
- }
986
-
987
- function cloneAgentRole(agent: AgentRole): AgentRole {
988
- return { ...agent, tools: [...agent.tools] };
989
- }
990
-
991
- function restorePersistedAgent(value: unknown): AgentRole | undefined {
992
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
993
- const role = value as Record<string, unknown>;
994
- const name = role.name;
995
- const description = role.description;
996
- const access = role.access;
997
- const tools = role.tools;
998
- const prompt = role.prompt;
999
- const source = role.source;
1000
- const filePath = role.filePath;
1001
- if (typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(name)) return undefined;
1002
- if (typeof description !== "string" || !description.trim() || description.length > 500) return undefined;
1003
- if (access !== "read" && access !== "write") return undefined;
1004
- if (!Array.isArray(tools) || tools.length === 0 || tools.some((tool) => typeof tool !== "string" || !KNOWN_TOOLS.has(tool))) return undefined;
1005
- const uniqueTools = [...new Set(tools as string[])];
1006
- if (uniqueTools.length !== tools.length || access === "read" && uniqueTools.some((tool) => WRITE_TOOLS.has(tool))) return undefined;
1007
- if (typeof prompt !== "string" || !prompt.trim() || prompt.length > SUBAGENT_LIMITS.roleFileBytes) return undefined;
1008
- if (source !== "bundled" && source !== "personal" && source !== "project" && source !== "inline" && source !== "legacy") return undefined;
1009
- if (typeof filePath !== "string" || !filePath) return undefined;
1010
- const optionalString = (field: string): string | undefined => {
1011
- const item = role[field];
1012
- return item === undefined ? undefined : typeof item === "string" && item.trim() ? item : undefined;
1013
- };
1014
- const timeoutMs = role.timeoutMs;
1015
- if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_NODE_TIMER_MS)) return undefined;
1016
- return {
1017
- name,
1018
- description,
1019
- access,
1020
- tools: uniqueTools,
1021
- model: optionalString("model"),
1022
- thinking: optionalString("thinking"),
1023
- timeoutMs: typeof timeoutMs === "number" ? timeoutMs : undefined,
1024
- prompt,
1025
- source,
1026
- filePath,
1027
- };
1028
- }
1029
-
1030
- function restoreLegacyAgent(thread: ThreadSnapshot, sourceHint?: SubagentTaskResult["agentSource"]): AgentRole | undefined {
1031
- if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(thread.role)) return undefined;
1032
- if (!Array.isArray(thread.tools) || thread.tools.length === 0) return undefined;
1033
- const tools = [...new Set(thread.tools)];
1034
- if (tools.length !== thread.tools.length || tools.some((tool) => !KNOWN_TOOLS.has(tool))) return undefined;
1035
- const access = thread.capabilityBoundary?.filesystem;
1036
- if (access !== "read" && access !== "write") return undefined;
1037
- if (access === "read" && tools.some((tool) => WRITE_TOOLS.has(tool))) return undefined;
1038
- const source = sourceHint === "bundled" || sourceHint === "personal" || sourceHint === "project"
1039
- ? sourceHint
1040
- : "legacy";
1041
- return {
1042
- name: thread.role,
1043
- description: "Persisted role contract from an earlier KillerOS release.",
1044
- access,
1045
- tools,
1046
- prompt: "Continue the saved child task. Use only the retained role tools and return a handoff to the parent.",
1047
- source,
1048
- filePath: `legacy:${source}:${thread.role}`,
1049
- };
1050
- }
1051
-
1052
- function parseTaskInputs(value: unknown, field: "tasks" | "chain", limits: Pick<SubagentLimits, "maxTasks" | "taskCharacters">): TaskInput[] {
1053
- if (!Array.isArray(value) || value.length === 0) {
1054
- throw new Error(`Invalid subagent request: ${field} must be a non-empty array.`);
1055
- }
1056
- if (value.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
1057
- return value.map((entry, index) => {
1058
- const task = requireRecord(entry, `${field}[${index}]`);
1059
- requireOnlyFields(task, ["agent", "task", "name"], `spawn ${field}`);
1060
- const name = optionalThreadName(task);
1061
- const agent = parseAgentSpec(task.agent);
1062
- return {
1063
- ...(agent === undefined ? {} : { agent }),
1064
- task: requireTextField(task, "task", limits.taskCharacters),
1065
- ...(name === undefined ? {} : { name }),
1066
- };
1067
- });
1068
- }
1069
-
1070
- function parseSpawnOptions(record: Record<string, unknown>): SpawnOptions {
1071
- const options: SpawnOptions = {};
1072
- if (Object.hasOwn(record, "model")) options.model = requireTextField(record, "model", 256);
1073
- if (Object.hasOwn(record, "thinking")) options.thinking = requireTextField(record, "thinking", 16);
1074
- if (Object.hasOwn(record, "agentScope")) {
1075
- const scope = record.agentScope;
1076
- if (scope !== "user" && scope !== "project" && scope !== "both") {
1077
- throw new Error('Invalid subagent request: agentScope must be "user", "project", or "both".');
1078
- }
1079
- options.agentScope = scope;
1080
- }
1081
- return options;
1082
- }
1083
-
1084
- export function normalizeSubagentRequest(
1085
- value: unknown,
1086
- limits: Pick<SubagentLimits, "maxTasks" | "taskCharacters"> = SUBAGENT_LIMITS,
1087
- ): NormalizedSubagentRequest {
1088
- const record = requireRecord(value, "subagent request");
1089
- const action = record.action === undefined ? SUBAGENT_ACTION.spawn : requireAction(record.action);
1090
-
1091
- if (action !== "steer" && action !== "spawn" && Object.hasOwn(record, "message")) {
1092
- throw new Error('Invalid subagent request: message is only valid with action "steer". Use {"action":"steer","threadId":"...","message":"..."}.');
1093
- }
1094
-
1095
- if (action === "spawn") {
1096
- const hasSingle = Object.hasOwn(record, "agent") || Object.hasOwn(record, "task") || Object.hasOwn(record, "message");
1097
- const hasParallel = Object.hasOwn(record, "tasks");
1098
- const hasChain = Object.hasOwn(record, "chain");
1099
- if (Number(hasSingle) + Number(hasParallel) + Number(hasChain) !== 1) {
1100
- throw new Error("Invalid subagent request: choose exactly one spawn shape: agent + task, tasks, or chain.");
1101
- }
1102
- if (Object.hasOwn(record, "writerConcurrency") && !hasParallel) {
1103
- throw new Error("Invalid subagent request: writerConcurrency is only valid with parallel tasks.");
1104
- }
1105
- const actionField = Object.hasOwn(record, "action") ? { action: "spawn" as const } : {};
1106
- const options = parseSpawnOptions(record);
1107
- if (hasSingle) {
1108
- requireOnlyFields(record, ["action", "agent", "task", "message", "name", ...SPAWN_OPTION_FIELDS], "spawn single");
1109
- if (Object.hasOwn(record, "task") && Object.hasOwn(record, "message")) {
1110
- throw new Error("Invalid subagent request: spawn single cannot combine task with its message alias.");
1111
- }
1112
- const name = optionalThreadName(record);
1113
- return {
1114
- kind: "spawn-single",
1115
- input: {
1116
- ...actionField,
1117
- agent: parseAgentSpec(record.agent),
1118
- task: requireTextField(record, Object.hasOwn(record, "message") ? "message" : "task", limits.taskCharacters),
1119
- ...(name === undefined ? {} : { name }),
1120
- ...options,
1121
- },
1122
- };
1123
- }
1124
- if (hasParallel) {
1125
- requireOnlyFields(record, ["action", "tasks", "writerConcurrency", ...SPAWN_OPTION_FIELDS], "spawn parallel");
1126
- let writerConcurrency: number | undefined;
1127
- if (Object.hasOwn(record, "writerConcurrency")) {
1128
- writerConcurrency = record.writerConcurrency as number;
1129
- if (!Number.isSafeInteger(writerConcurrency) || writerConcurrency < 1 || writerConcurrency > limits.maxTasks) {
1130
- throw new Error(`writerConcurrency must be a positive integer no greater than ${limits.maxTasks}`);
1131
- }
1132
- }
1133
- return {
1134
- kind: "spawn-parallel",
1135
- input: {
1136
- ...actionField,
1137
- tasks: parseTaskInputs(record.tasks, "tasks", limits),
1138
- ...(writerConcurrency === undefined ? {} : { writerConcurrency }),
1139
- ...options,
1140
- },
1141
- };
1142
- }
1143
- requireOnlyFields(record, ["action", "chain", ...SPAWN_OPTION_FIELDS], "spawn chain");
1144
- return {
1145
- kind: "spawn-chain",
1146
- input: { ...actionField, chain: parseTaskInputs(record.chain, "chain", limits), ...options },
1147
- };
1148
- }
1149
-
1150
- if (action === "list") {
1151
- requireOnlyFields(record, ["action"], action);
1152
- return { kind: "list", input: { action } };
1153
- }
1154
- if (action === "inspect" || action === "collect" || action === "close") {
1155
- requireOnlyFields(record, ["action", "threadId"], action);
1156
- const input = { action, threadId: requireTextField(record, "threadId", 128) };
1157
- return { kind: action, input } as NormalizedSubagentRequest;
1158
- }
1159
- if (action === "steer") {
1160
- requireOnlyFields(record, ["action", "threadId", "message"], action);
1161
- return {
1162
- kind: "steer",
1163
- input: {
1164
- action,
1165
- threadId: requireTextField(record, "threadId", 128),
1166
- message: requireTextField(record, "message", 4_000),
1167
- },
1168
- };
1169
- }
1170
-
1171
- if (action === "wait") {
1172
- requireOnlyFields(record, ["action", "threadId", "all", "timeoutMs"], action);
1173
- const hasThreadId = Object.hasOwn(record, "threadId");
1174
- const hasAll = Object.hasOwn(record, "all");
1175
- if (hasThreadId && hasAll || hasAll && record.all !== true) {
1176
- throw new Error('Invalid subagent request: action "wait" cannot combine threadId with all: true.');
1177
- }
1178
- const timeoutValue = record.timeoutMs === undefined ? DEFAULT_WAIT_TIMEOUT_MS : record.timeoutMs;
1179
- if (typeof timeoutValue !== "number" || !Number.isSafeInteger(timeoutValue) || timeoutValue <= 0 || timeoutValue > MAX_WAIT_TIMEOUT_MS) {
1180
- throw new Error(`Invalid subagent request: timeoutMs must be a positive integer no greater than ${MAX_WAIT_TIMEOUT_MS}.`);
1181
- }
1182
- return {
1183
- kind: "wait",
1184
- input: {
1185
- action,
1186
- ...(hasThreadId ? { threadId: requireTextField(record, "threadId", 128) } : {}),
1187
- ...(hasAll || !hasThreadId ? { all: true as const } : {}),
1188
- timeoutMs: timeoutValue,
1189
- },
1190
- };
1191
- }
1192
-
1193
- if (action === "resume") {
1194
- requireOnlyFields(record, ["action", "threadId", "task"], action);
1195
- return {
1196
- kind: "resume",
1197
- input: {
1198
- action,
1199
- threadId: requireTextField(record, "threadId", 128),
1200
- ...(Object.hasOwn(record, "task") ? { task: requireTextField(record, "task", limits.taskCharacters) } : {}),
1201
- },
1202
- };
1203
- }
1204
-
1205
- requireOnlyFields(record, ["action", "threadId", "all"], action);
1206
- const hasThreadId = Object.hasOwn(record, "threadId");
1207
- const hasAll = Object.hasOwn(record, "all");
1208
- if (Number(hasThreadId) + Number(hasAll) !== 1 || hasAll && record.all !== true) {
1209
- throw new Error('Invalid subagent request: action "interrupt" requires exactly one of threadId or all: true.');
1210
- }
1211
- if (hasThreadId) {
1212
- return { kind: "interrupt-one", input: { action, threadId: requireTextField(record, "threadId", 128) } };
1213
- }
1214
- return { kind: "interrupt-all", input: { action, all: true } };
1215
- }
1216
-
1217
- export function tryNormalizeSubagentRequest(
1218
- value: unknown,
1219
- limits: Pick<SubagentLimits, "maxTasks" | "taskCharacters"> = SUBAGENT_LIMITS,
1220
- ): SubagentRequestParse {
1221
- try {
1222
- return { ok: true, request: normalizeSubagentRequest(value, limits) };
1223
- } catch (error) {
1224
- return { ok: false, message: error instanceof Error ? error.message : String(error) };
1225
- }
1226
- }
1227
-
1228
- function prepareSubagentRequest(
1229
- value: unknown,
1230
- limits: Pick<SubagentLimits, "maxTasks" | "taskCharacters">,
1231
- ): NormalizedSubagentRequest {
1232
- const record = requireRecord(value, "subagent request");
1233
- const action = record.action ?? SUBAGENT_ACTION.spawn;
1234
- if (action === SUBAGENT_ACTION.spawn && Object.hasOwn(record, "threadId")) {
1235
- const { threadId: _generatedThreadId, ...spawnRecord } = record;
1236
- return normalizeSubagentRequest(spawnRecord, limits);
1237
- }
1238
- return normalizeSubagentRequest(record, limits);
1239
- }
1240
-
1241
- function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxReadConcurrency" | "taskCharacters">) {
1242
- const inlineAgentSchema = Type.Object({
1243
- name: Type.String({ minLength: 1, maxLength: 64, pattern: "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$" }),
1244
- description: Type.String({ minLength: 1, maxLength: 500 }),
1245
- access: StringEnum(["read", "write"] as const),
1246
- tools: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, uniqueItems: true }),
1247
- }, { additionalProperties: false, description: "Optional role definition for this spawn; tools must be active for the parent" });
1248
- const agentSchema = Type.Union([
1249
- Type.String({ minLength: 1, maxLength: 64, description: "Optional custom role name from an approved agents folder" }),
1250
- inlineAgentSchema,
1251
- ]);
1252
- const taskSchema = Type.Object({
1253
- agent: Type.Optional(agentSchema),
1254
- task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Bounded task; omitted agent uses the generic read-only child" }),
1255
- name: Type.Optional(Type.String({ minLength: 1, maxLength: 48, pattern: THREAD_NAME_PATTERN, description: "Parent-scoped child display name" })),
1256
- }, { additionalProperties: false });
1257
- const chainTaskSchema = Type.Object({
1258
- agent: Type.Optional(agentSchema),
1259
- task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task with optional {previous}; omitted agent uses the generic read-only child" }),
1260
- name: Type.Optional(Type.String({ minLength: 1, maxLength: 48, pattern: THREAD_NAME_PATTERN, description: "Parent-scoped child display name" })),
1261
- }, { additionalProperties: false });
1262
- const threadId = Type.String({ minLength: 1, maxLength: 128, description: "Existing child thread ID; omit when spawning because KillerOS creates it" });
1263
- return Type.Object({
1264
- action: Type.Optional(StringEnum(SUBAGENT_ACTIONS, { default: SUBAGENT_ACTION.spawn, description: "Spawn, list, inspect, steer, interrupt, collect, wait, resume, or close. Omit threadId when spawning" })),
1265
- threadId: Type.Optional(threadId),
1266
- name: Type.Optional(Type.String({ minLength: 1, maxLength: 48, pattern: THREAD_NAME_PATTERN, description: "Parent-scoped child display name" })),
1267
- message: Type.Optional(Type.String({ minLength: 1, maxLength: Math.max(4_000, limits.taskCharacters), description: `Spawn task alias up to ${limits.taskCharacters.toLocaleString("en-US")} characters; steer message up to 4,000 characters` })),
1268
- all: Type.Optional(Type.Literal(true, { description: "Target every active or queued child thread for interrupt or wait" })),
1269
- timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_WAIT_TIMEOUT_MS, description: "Wait timeout in milliseconds; defaults to 30 seconds" })),
1270
- agent: Type.Optional(agentSchema),
1271
- task: Type.Optional(Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task for single mode" })),
1272
- tasks: Type.Optional(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` })),
1273
- 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` })),
1274
- chain: Type.Optional(Type.Array(chainTaskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
1275
- model: Type.Optional(Type.String({ minLength: 1, maxLength: 256, description: "Optional model for every task as provider/model; omitted or inherit uses the selected role default, then the active parent. Set this when the user requires one model for the batch" })),
1276
- thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Optional thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit. Omitted or inherit uses the selected role default, then the active parent" })),
1277
- agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
1278
- default: "user",
1279
- description: "Custom role sources: user includes personal; project includes trusted project; both includes both",
1280
- })),
1281
- }, { additionalProperties: false });
1282
- }
1283
-
1284
- type ToolUpdate = (partial: { content: Array<{ type: "text"; text: string }>; details: SubagentDetails }) => void;
1285
-
1286
- function clipCharacters(text: string, maxCharacters: number, fromEnd = false): string {
1287
- const characters = [...text];
1288
- if (characters.length <= maxCharacters) return text;
1289
- return (fromEnd ? characters.slice(-maxCharacters) : characters.slice(0, maxCharacters)).join("");
1290
- }
1291
-
1292
- function codePointLength(text: string): number {
1293
- let length = 0;
1294
- for (const _character of text) length += 1;
1295
- return length;
1296
- }
1297
-
1298
- function expandChainTask(template: string, previous: string, maxCharacters: number): string | undefined {
1299
- const placeholder = "{previous}";
1300
- let occurrences = 0;
1301
- let searchFrom = 0;
1302
- while (true) {
1303
- const index = template.indexOf(placeholder, searchFrom);
1304
- if (index < 0) break;
1305
- occurrences += 1;
1306
- searchFrom = index + placeholder.length;
1307
- }
1308
- if (occurrences === 0) return codePointLength(template) <= maxCharacters ? template : undefined;
1309
-
1310
- const expandedCharacters = codePointLength(template) + occurrences * (codePointLength(previous) - codePointLength(placeholder));
1311
- if (expandedCharacters > maxCharacters) return undefined;
1312
-
1313
- const pieces: string[] = [];
1314
- let start = 0;
1315
- while (true) {
1316
- const index = template.indexOf(placeholder, start);
1317
- if (index < 0) {
1318
- pieces.push(template.slice(start));
1319
- break;
1320
- }
1321
- pieces.push(template.slice(start, index), previous);
1322
- start = index + placeholder.length;
1323
- }
1324
- return pieces.join("");
1325
- }
1326
-
1327
- function buildSteeredTask(task: string, steering: readonly string[], maxCharacters: number): string {
1328
- const steeringLabel = "\n\nParent steering:\n";
1329
- const steeringText = clipCharacters(steering.join("\n"), Math.max(0, maxCharacters - [...steeringLabel].length), true);
1330
- const required = [...steeringLabel, ...steeringText].length;
1331
- const taskText = clipCharacters(task, Math.max(0, maxCharacters - required));
1332
- return `${taskText}${steeringLabel}${steeringText}`;
1333
- }
1334
-
1335
- export type TaskInput = { agent?: AgentSpec; task: string; name?: string };
1336
-
1337
- function steeredTaskWouldExceedLimit(task: string, steering: readonly string[], maxCharacters: number): boolean {
1338
- if (!steering.length) return false;
1339
- return codePointLength(task)
1340
- + codePointLength("\n\nParent steering:\n")
1341
- + codePointLength(steering.join("\n")) > maxCharacters;
1342
- }
1343
-
1344
- function formatUsage(usage: SubagentUsage): string {
1345
- const parts = [`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`, `${usage.totalTokens} tokens`];
1346
- if (usage.cost.total) parts.push(`$${usage.cost.total.toFixed(4)}`);
1347
- return parts.join(" · ");
1348
- }
1349
-
1350
- function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskResult[], maxBytes: number): string {
1351
- const sections = results.map((result) => {
1352
- const heading = `### ${result.id} · ${result.agent} · ${result.status}`;
1353
- const reason = result.terminationReason && result.terminationReason !== "completed" ? `\nReason: ${result.terminationReason}` : "";
1354
- const body = result.output || result.errorMessage || result.stderr.trim() || "(no output)";
1355
- const truncation = result.outputTruncatedBytes ? `\n\n[Task output truncated: ${result.outputTruncatedBytes} bytes omitted; bounded detail is available when expanded.]` : "";
1356
- return `${heading}${reason}\n\n${body}${truncation}`;
1357
- });
1358
- const complete = results.filter((result) => result.status === "complete").length;
1359
- const text = `${mode}: ${complete}/${results.length} complete · ${formatUsage(aggregateUsage(results))}\n\n${sections.join("\n\n---\n\n")}`;
1360
- const marker = "\n\n[Combined subagent output truncated to 50 KiB; inspect the expanded tool result for bounded per-task details.]";
1361
- return boundedText(text, maxBytes, marker);
1362
- }
1363
-
1364
- function statusColor(status: SubagentStatus): "accent" | "success" | "error" | "warning" | "muted" {
1365
- if (status === "running") return "accent";
1366
- if (status === "complete") return "success";
1367
- if (status === "failed") return "error";
1368
- if (status === "limited" || status === "cancelled") return "warning";
1369
- return "muted";
1370
- }
1371
-
1372
- function statusIcon(status: SubagentStatus): string {
1373
- if (status === "running") return "✻";
1374
- if (status === "complete") return "✓";
1375
- if (status === "failed") return "✗";
1376
- if (status === "queued") return "○";
1377
- return "!";
1378
- }
1379
-
1380
- interface ActiveThreadRuntime {
1381
- controller: AbortController;
1382
- handle?: SubagentProcessHandle;
1383
- handles: Set<SubagentProcessHandle>;
1384
- task: string;
1385
- steering: string[];
1386
- restarting: boolean;
1387
- traceCount: number;
1388
- startedAt: number;
1389
- sessionGeneration: number;
1390
- aggregate?: SubagentTaskResult;
1391
- requestedReason?: string;
1392
- }
1393
-
1394
- interface ChildSession {
1395
- id: string;
1396
- directory: string;
1397
- }
1398
-
1399
- interface ThreadMetadata {
1400
- displayName: string;
1401
- attempt: number;
1402
- session: ChildSession;
1403
- persistentSession: boolean;
1404
- agent?: AgentRole;
1405
- thinking?: ThinkingLevel;
1406
- }
1407
-
1408
- type ThreadSnapshot = SubagentThread & {
1409
- displayName?: string;
1410
- attempt?: number;
1411
- session?: ChildSession;
1412
- };
1413
-
1414
- function parentThreadId(ctx: ExtensionContext): string {
1415
- try {
1416
- const id = ctx.sessionManager?.getSessionId?.();
1417
- if (id) return `main:${id}`;
1418
- } catch {
1419
- // Test and RPC contexts may not expose a session manager.
1420
- }
1421
- return "main";
1422
- }
1423
-
1424
- function defaultThreadName(role: string, existing: readonly ThreadSnapshot[]): string {
1425
- const names = new Set(existing.map((thread) => thread.displayName?.toLocaleLowerCase() ?? thread.role.toLocaleLowerCase()));
1426
- const base = role.length <= 48 ? role : role.slice(0, 48);
1427
- if (!names.has(base.toLocaleLowerCase()) && THREAD_NAME_RE.test(base)) return base;
1428
- for (let suffix = 2; suffix < 10_000; suffix += 1) {
1429
- const suffixText = `-${suffix}`;
1430
- const candidate = `${base.slice(0, 48 - suffixText.length)}${suffixText}`;
1431
- if (THREAD_NAME_RE.test(candidate) && !names.has(candidate.toLocaleLowerCase())) return candidate;
1432
- }
1433
- throw new Error(`Could not allocate a unique display name for role ${JSON.stringify(role)}`);
1434
- }
1435
-
1436
- function safeSessionId(value: string): string {
1437
- return value.replace(/[^A-Za-z0-9_.-]/gu, "_");
1438
- }
1439
-
1440
- function childSessionPath(ctx: ExtensionContext, threadId: string): ChildSession | undefined {
1441
- try {
1442
- const directoryRoot = ctx.sessionManager?.getSessionDir?.();
1443
- if (typeof directoryRoot !== "string" || !directoryRoot) return undefined;
1444
- const rawParentId = ctx.sessionManager?.getSessionId?.() ?? parentThreadId(ctx);
1445
- const id = `killeros-${safeSessionId(threadId)}`;
1446
- return {
1447
- id,
1448
- directory: path.join(directoryRoot, "killeros-subagents", safeSessionId(rawParentId), safeSessionId(threadId)),
1449
- };
1450
- } catch {
1451
- return undefined;
1452
- }
1453
- }
1454
-
1455
- function threadDisplayName(thread: ThreadSnapshot, metadata: Map<string, ThreadMetadata>): string {
1456
- return thread.displayName ?? metadata.get(thread.id)?.displayName ?? thread.role;
1457
- }
1458
-
1459
- function threadAttempt(thread: ThreadSnapshot, metadata: Map<string, ThreadMetadata>): number {
1460
- return thread.attempt ?? metadata.get(thread.id)?.attempt ?? 1;
1461
- }
1462
-
1463
- function threadSession(thread: ThreadSnapshot, metadata: Map<string, ThreadMetadata>): ChildSession | undefined {
1464
- return thread.session ?? metadata.get(thread.id)?.session;
1465
- }
1466
-
1467
- function threadView(thread: SubagentThread, metadata: Map<string, ThreadMetadata>): ThreadSnapshot {
1468
- const view = { ...thread } as ThreadSnapshot;
1469
- const known = metadata.get(thread.id);
1470
- if (view.displayName === undefined && known) view.displayName = known.displayName;
1471
- if (view.attempt === undefined && known) view.attempt = known.attempt;
1472
- const isPendingSession = view.session?.id === "killeros-pending"
1473
- || view.session?.directory === path.join(os.tmpdir(), "killeros-subagent-pending");
1474
- if ((view.session === undefined || isPendingSession) && known) view.session = { ...known.session };
1475
- return view;
1476
- }
1477
-
1478
- function threadCapabilityBoundary(agent: AgentRole): {
1479
- filesystem: "read" | "write";
1480
- network: "none" | "read";
1481
- process: "none" | "limited";
1482
- childThreads: false;
1483
- } {
1484
- return {
1485
- filesystem: agent.access,
1486
- network: agent.tools.some((tool) => WEB_TOOLS.has(tool)) ? "read" : "none",
1487
- process: agent.tools.includes("bash") ? "limited" : "none",
1488
- childThreads: false,
1489
- };
1490
- }
1491
-
1492
- function threadUsage(usage: SubagentUsage): SubagentThread["usage"] {
1493
- return {
1494
- inputTokens: usage.input,
1495
- outputTokens: usage.output,
1496
- cacheReadTokens: usage.cacheRead,
1497
- cacheWriteTokens: usage.cacheWrite,
1498
- totalTokens: usage.totalTokens,
1499
- costUsd: usage.cost.total,
1500
- turns: usage.turns,
1501
- };
1502
- }
1503
-
1504
- function legacyStatus(state: SubagentThreadState): SubagentStatus {
1505
- if (state === "active") return "running";
1506
- if (state === "done" || state === "closed") return "complete";
1507
- if (state === "failed") return "failed";
1508
- if (state === "stopped") return "cancelled";
1509
- if (state === "orphaned") return "orphaned";
1510
- return "queued";
1511
- }
1512
-
1513
- function threadResult(thread: SubagentThread, source?: SubagentTaskResult): SubagentTaskResult {
1514
- if (source) {
1515
- const result = cloneResult(source);
1516
- if (thread.state === "queued") result.status = "queued";
1517
- else if (thread.state === "active") result.status = "running";
1518
- else if (thread.state === "orphaned") result.status = "orphaned";
1519
- return result;
1520
- }
1521
- return {
1522
- ...makeQueuedResult(thread.id, thread.role, thread.prompt),
1523
- status: legacyStatus(thread.state),
1524
- agentSource: "unknown",
1525
- model: thread.model,
1526
- tools: [...thread.tools],
1527
- trace: thread.trace.map((event) => event.message ?? event.kind),
1528
- usage: {
1529
- input: thread.usage.inputTokens,
1530
- output: thread.usage.outputTokens,
1531
- cacheRead: thread.usage.cacheReadTokens,
1532
- cacheWrite: thread.usage.cacheWriteTokens,
1533
- totalTokens: thread.usage.totalTokens,
1534
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: thread.usage.costUsd },
1535
- turns: thread.usage.turns,
1536
- },
1537
- output: thread.result ?? "",
1538
- toolCallCount: 0,
1539
- exitConfirmed: false,
1540
- terminationReason: thread.stopReason,
1541
- };
1542
- }
1543
-
1544
- function restorePersistedResult(value: unknown, thread: ThreadSnapshot): SubagentTaskResult | undefined {
1545
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1546
- const source = value as Record<string, any>;
1547
- const statuses = new Set<SubagentStatus>(["queued", "running", "complete", "failed", "cancelled", "limited"]);
1548
- const agentSources = new Set<SubagentTaskResult["agentSource"]>(["bundled", "personal", "project", "inline", "legacy", "unknown"]);
1549
- if (typeof source.id !== "string" || source.id !== thread.id) return undefined;
1550
- const status = source.status ?? (thread.state === "done" ? "complete" : thread.state === "failed" ? "failed" : "cancelled");
1551
- if (typeof status !== "string" || !statuses.has(status as SubagentStatus)) return undefined;
1552
- if (thread.state === "done" && status !== "complete") return undefined;
1553
- if (thread.state === "failed" && status !== "failed") return undefined;
1554
- if (thread.state === "stopped" && !["cancelled", "limited"].includes(status)) return undefined;
1555
- if (thread.state === "closed") return undefined;
1556
- const rawOutput = source.output === undefined ? thread.result ?? "" : source.output;
1557
- if (typeof rawOutput !== "string") return undefined;
1558
- const output = truncateUtf8(rawOutput, 256 * 1024).text;
1559
- const usage = source.usage;
1560
- if (!usage || typeof usage !== "object" || Array.isArray(usage)) return undefined;
1561
- const usageFields = ["input", "output", "cacheRead", "cacheWrite", "totalTokens", "turns"];
1562
- if (usageFields.some((field) => typeof usage[field] !== "number" || !Number.isFinite(usage[field]) || usage[field] < 0)) return undefined;
1563
- if (!usage.cost || typeof usage.cost !== "object" || Array.isArray(usage.cost)) return undefined;
1564
- const costFields = ["input", "output", "cacheRead", "cacheWrite", "total"];
1565
- if (costFields.some((field) => typeof usage.cost[field] !== "number" || !Number.isFinite(usage.cost[field]) || usage.cost[field] < 0)) return undefined;
1566
- const outputBytes = source.outputBytes === undefined ? Buffer.byteLength(output, "utf8") : source.outputBytes;
1567
- const outputTruncatedBytes = source.outputTruncatedBytes === undefined ? 0 : source.outputTruncatedBytes;
1568
- const durationMs = source.durationMs === undefined ? 0 : source.durationMs;
1569
- if (![outputBytes, outputTruncatedBytes, durationMs].every((item) => typeof item === "number" && Number.isFinite(item) && item >= 0)) return undefined;
1570
- const exitCode = source.exitCode === undefined || source.exitCode === null ? null : source.exitCode;
1571
- if (exitCode !== null && (!Number.isSafeInteger(exitCode) || exitCode < 0)) return undefined;
1572
- if (source.terminationReason !== undefined && typeof source.terminationReason !== "string") return undefined;
1573
- if (source.errorMessage !== undefined && typeof source.errorMessage !== "string") return undefined;
1574
- if (source.exitConfirmed !== undefined && typeof source.exitConfirmed !== "boolean") return undefined;
1575
- if (source.agentSource !== undefined && (typeof source.agentSource !== "string" || !agentSources.has(source.agentSource as SubagentTaskResult["agentSource"]))) return undefined;
1576
- const attempt = Number.isSafeInteger(source.attempt) && source.attempt > 0 ? source.attempt : thread.attempt;
1577
- const result = makeQueuedResult(
1578
- thread.id,
1579
- typeof source.agent === "string" && source.agent ? clipCharacters(source.agent, 64) : thread.role,
1580
- typeof source.task === "string" && source.task ? clipCharacters(source.task, 20_000) : thread.prompt,
1581
- undefined,
1582
- typeof source.name === "string" && source.name ? clipCharacters(source.name, 48) : thread.displayName,
1583
- attempt,
1584
- );
1585
- result.status = status as SubagentStatus;
1586
- result.agentSource = source.agentSource ?? "unknown";
1587
- result.output = output;
1588
- result.outputBytes = outputBytes;
1589
- result.outputTruncatedBytes = outputTruncatedBytes;
1590
- result.usage = {
1591
- input: usage.input,
1592
- output: usage.output,
1593
- cacheRead: usage.cacheRead,
1594
- cacheWrite: usage.cacheWrite,
1595
- totalTokens: usage.totalTokens,
1596
- cost: { input: usage.cost.input, output: usage.cost.output, cacheRead: usage.cost.cacheRead, cacheWrite: usage.cost.cacheWrite, total: usage.cost.total },
1597
- turns: usage.turns,
1598
- };
1599
- result.terminationReason = source.terminationReason;
1600
- result.errorMessage = source.errorMessage;
1601
- result.durationMs = durationMs;
1602
- result.exitCode = exitCode;
1603
- result.exitConfirmed = source.exitConfirmed === true;
1604
- return result;
1605
- }
1606
-
1607
- function threadBoardRecord(result: SubagentTaskResult): ThreadBoardRecord {
1608
- return {
1609
- id: result.id,
1610
- displayName: result.name,
1611
- attempt: result.attempt,
1612
- agent: result.agent,
1613
- task: result.task,
1614
- status: result.status as ThreadBoardRecord["status"],
1615
- usage: {
1616
- input: result.usage.input,
1617
- output: result.usage.output,
1618
- cacheRead: result.usage.cacheRead,
1619
- cacheWrite: result.usage.cacheWrite,
1620
- totalTokens: result.usage.totalTokens,
1621
- turns: result.usage.turns,
1622
- cost: result.usage.cost.total,
1623
- },
1624
- trace: result.trace,
1625
- traceTruncatedBytes: result.traceTruncatedBytes,
1626
- handoff: result.output,
1627
- output: result.output,
1628
- terminationReason: result.terminationReason,
1629
- errorMessage: result.errorMessage,
1630
- durationMs: result.durationMs,
1631
- step: result.step,
1632
- };
1633
- }
1634
-
1635
- export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}): SubagentControlApi {
1636
- const limits = { ...SUBAGENT_LIMITS, ...options.limits };
1637
- const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
1638
- let threads = new SubagentThreadRegistry();
1639
- const activeRuntimes = new Map<string, ActiveThreadRuntime>();
1640
- const threadMetadata = new Map<string, ThreadMetadata>();
1641
- type ThreadResource = { directory: string; persistent: boolean; handles: Set<SubagentProcessHandle> };
1642
- const threadResources = new Map<string, ThreadResource>();
1643
- const backgroundBatches = new Set<Promise<unknown>>();
1644
- const savedResults = new Map<string, SubagentTaskResult>();
1645
- const evictedThreadParents = new Map<string, string | undefined>();
1646
- let terminalCleanupQueue: Promise<void> = Promise.resolve();
1647
- let sessionGeneration = 0;
1648
- let persistenceWarning: string | undefined;
1649
-
1650
- const persistText = (value: string | undefined, maxBytes: number): string | undefined => {
1651
- if (value === undefined) return undefined;
1652
- return truncateUtf8(value, maxBytes).text;
1653
- };
1654
- const lifecycleOutput = (value: string | undefined): string | undefined => value?.trim() ? value : undefined;
1655
- const persistenceAppend = (record: Record<string, unknown>): void => {
1656
- const appendEntry = (pi as unknown as { appendEntry?: (type: string, data: unknown) => void }).appendEntry;
1657
- if (!appendEntry) return;
1658
- try {
1659
- appendEntry.call(pi, SUBAGENT_PERSISTENCE_TYPE, record);
1660
- } catch (error) {
1661
- persistenceWarning ??= `Subagent persistence is unavailable: ${error instanceof Error ? error.message : String(error)}`;
1662
- }
1663
- };
1664
- const persistedThread = (thread: ThreadSnapshot): Record<string, unknown> => {
1665
- const session = threadSession(thread, threadMetadata) ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" };
1666
- const metadata = threadMetadata.get(thread.id);
1667
- const agent = metadata?.agent;
1668
- return {
1669
- id: thread.id,
1670
- parentId: thread.parentId,
1671
- displayName: threadDisplayName(thread, threadMetadata),
1672
- attempt: threadAttempt(thread, threadMetadata),
1673
- role: thread.role,
1674
- prompt: clipCharacters(thread.prompt, limits.taskCharacters),
1675
- model: thread.model,
1676
- tools: thread.tools.slice(0, 32).map((tool) => clipCharacters(tool, 64)),
1677
- ...(agent ? {
1678
- roleDefinition: {
1679
- name: agent.name,
1680
- description: persistText(agent.description, 500),
1681
- access: agent.access,
1682
- tools: agent.tools.slice(0, 32).map((tool) => clipCharacters(tool, 64)),
1683
- model: agent.model,
1684
- thinking: agent.thinking,
1685
- timeoutMs: agent.timeoutMs,
1686
- prompt: persistText(agent.prompt, limits.roleFileBytes),
1687
- source: agent.source,
1688
- filePath: persistText(agent.filePath, 4_000),
1689
- },
1690
- thinking: metadata?.thinking,
1691
- } : {}),
1692
- capabilityBoundary: { ...thread.capabilityBoundary },
1693
- session: { ...session },
1694
- state: thread.state,
1695
- usage: { ...thread.usage },
1696
- handoff: thread.handoff
1697
- ? { ...thread.handoff, summary: persistText(thread.handoff.summary, 256 * 1024) }
1698
- : undefined,
1699
- result: persistText(thread.result, 256 * 1024),
1700
- failure: thread.failure ? { ...thread.failure, message: clipCharacters(thread.failure.message, 512) } : undefined,
1701
- stopReason: thread.stopReason,
1702
- evicted: thread.evicted,
1703
- timestamps: { ...thread.timestamps },
1704
- version: thread.version,
1705
- trace: thread.trace.slice(-64).map((entry) => ({
1706
- ...entry,
1707
- message: persistText(entry.message, 64 * 1024),
1708
- })),
1709
- steering: thread.steering.slice(-20).map((entry) => ({ ...entry, message: clipCharacters(entry.message, 4_000) })),
1710
- };
1711
- };
1712
- const recordSpawn = (thread: SubagentThread): void => {
1713
- const view = threadView(thread, threadMetadata);
1714
- persistenceAppend({ version: 1, event: "spawn", parentId: view.parentId, thread: persistedThread(view) });
1715
- };
1716
- const recordSnapshot = (thread: SubagentThread, result?: SubagentTaskResult): void => {
1717
- const view = threadView(thread, threadMetadata);
1718
- persistenceAppend({
1719
- version: 1,
1720
- event: "snapshot",
1721
- parentId: view.parentId,
1722
- id: view.id,
1723
- thread: persistedThread(view),
1724
- ...(result ? {
1725
- result: {
1726
- id: result.id,
1727
- name: result.name,
1728
- agent: result.agent,
1729
- agentSource: result.agentSource,
1730
- task: clipCharacters(result.task, limits.taskCharacters),
1731
- status: result.status,
1732
- output: persistText(result.output, 256 * 1024),
1733
- outputBytes: result.outputBytes,
1734
- outputTruncatedBytes: result.outputTruncatedBytes,
1735
- usage: result.usage,
1736
- terminationReason: result.terminationReason,
1737
- errorMessage: persistText(result.errorMessage, 8 * 1024),
1738
- durationMs: result.durationMs,
1739
- exitCode: result.exitCode,
1740
- exitConfirmed: result.exitConfirmed,
1741
- attempt: result.attempt,
1742
- },
1743
- } : {}),
1744
- });
1745
- };
1746
- const recordClose = (thread: SubagentThread): void => {
1747
- const view = threadView(thread, threadMetadata);
1748
- persistenceAppend({ version: 1, event: "close", parentId: view.parentId, id: view.id, closedAt: view.timestamps.closedAt ?? Date.now() });
1749
- };
1750
- let unsubscribePersistence = (): void => {};
1751
- const attachPersistence = (): void => {
1752
- unsubscribePersistence = threads.subscribe((change) => {
1753
- if (["complete", "fail", "stop", "interrupt", "resume"].includes(change.type)) {
1754
- recordSnapshot(change.thread, savedResults.get(change.thread.id));
1755
- } else if (change.type === "close") {
1756
- recordClose(change.thread);
1757
- }
1758
- });
1759
- };
1760
-
1761
- const restoreRecords = (
1762
- entries: readonly unknown[],
1763
- parentId: string,
1764
- expectedSession?: (threadId: string) => ChildSession | undefined,
1765
- ): Array<{ thread: ThreadSnapshot; result?: SubagentTaskResult; agent?: AgentRole; thinking?: ThinkingLevel }> => {
1766
- const records = new Map<string, { thread: ThreadSnapshot; result?: SubagentTaskResult; agent?: AgentRole; thinking?: ThinkingLevel }>();
1767
- for (const entry of entries) {
1768
- try {
1769
- if (!entry || typeof entry !== "object") continue;
1770
- const candidate = entry as Record<string, unknown>;
1771
- if (candidate.type !== "custom" || candidate.customType !== SUBAGENT_PERSISTENCE_TYPE) continue;
1772
- const data = candidate.data;
1773
- if (!data || typeof data !== "object") continue;
1774
- const record = data as Record<string, any>;
1775
- if (record.version !== 1 || typeof record.parentId !== "string" || record.parentId !== parentId) continue;
1776
- if (record.event === "spawn" || record.event === "snapshot") {
1777
- const rawThread = record.thread;
1778
- if (!rawThread || typeof rawThread !== "object" || typeof rawThread.id !== "string" || rawThread.parentId !== parentId) continue;
1779
- if (typeof rawThread.role !== "string" || typeof rawThread.prompt !== "string" || typeof rawThread.model !== "string") continue;
1780
- const thread = { ...rawThread } as ThreadSnapshot;
1781
- const thinking = rawThread.thinking === undefined
1782
- ? undefined
1783
- : typeof rawThread.thinking === "string" && rawThread.thinking.trim() ? rawThread.thinking as ThinkingLevel : undefined;
1784
- if (rawThread.thinking !== undefined && thinking === undefined) continue;
1785
- thread.prompt = clipCharacters(thread.prompt, limits.taskCharacters);
1786
- thread.result = typeof rawThread.result === "string" ? persistText(rawThread.result, 256 * 1024) : rawThread.result;
1787
- thread.tools = Array.isArray(rawThread.tools) ? rawThread.tools.slice(0, 32) : [];
1788
- thread.trace = Array.isArray(rawThread.trace) ? rawThread.trace.slice(-64) : [];
1789
- thread.steering = Array.isArray(rawThread.steering) ? rawThread.steering.slice(-20) : [];
1790
- thread.displayName = typeof rawThread.displayName === "string" ? rawThread.displayName : rawThread.role;
1791
- thread.attempt = Number.isSafeInteger(rawThread.attempt) && rawThread.attempt > 0 ? rawThread.attempt : 1;
1792
- const trustedSession = expectedSession?.(thread.id);
1793
- const rawSession = rawThread.session && typeof rawThread.session === "object" ? rawThread.session : undefined;
1794
- if (trustedSession && rawSession
1795
- && (String(rawSession.id ?? trustedSession.id) !== trustedSession.id
1796
- || String(rawSession.directory ?? trustedSession.directory) !== trustedSession.directory)) continue;
1797
- thread.session = trustedSession ?? {
1798
- id: `killeros-${safeSessionId(thread.id)}`,
1799
- directory: "",
1800
- };
1801
- if (thread.state === "queued" || thread.state === "active") {
1802
- thread.state = "orphaned" as SubagentThreadState;
1803
- thread.stopReason = "parent_restarted";
1804
- }
1805
- const result = record.result === undefined ? undefined : restorePersistedResult(record.result, thread);
1806
- if (record.result !== undefined && !result) continue;
1807
- const agent = rawThread.roleDefinition === undefined
1808
- ? restoreLegacyAgent(thread, result?.agentSource)
1809
- : restorePersistedAgent(rawThread.roleDefinition);
1810
- if (rawThread.roleDefinition !== undefined && !agent) continue;
1811
- records.set(thread.id, { thread, result, agent, thinking });
1812
- } else if (record.event === "close" && typeof record.id === "string") {
1813
- const previous = records.get(record.id);
1814
- if (!previous) continue;
1815
- if (typeof record.closedAt !== "number" || !Number.isFinite(record.closedAt) || record.closedAt < 0) continue;
1816
- previous.thread.state = "closed";
1817
- previous.thread.evicted = true;
1818
- previous.thread.prompt = "[closed thread prompt evicted]";
1819
- previous.thread.result = undefined;
1820
- previous.thread.trace = [];
1821
- previous.thread.steering = [];
1822
- previous.thread.handoff = undefined;
1823
- previous.thread.timestamps = { ...previous.thread.timestamps, closedAt: record.closedAt };
1824
- previous.result = undefined;
1825
- }
1826
- } catch {
1827
- // A malformed custom entry must not prevent the parent session from starting.
1828
- continue;
1829
- }
1830
- }
1831
- return [...records.values()];
1832
- };
1833
-
1834
- const installRestoredThreads = (restored: Array<{ thread: ThreadSnapshot; result?: SubagentTaskResult; agent?: AgentRole; thinking?: ThinkingLevel }>): void => {
1835
- if (!restored.length) return;
1836
- const ids = [...restored.map(({ thread }) => thread.id)];
1837
- let idIndex = 0;
1838
- const oldThreads = threads;
1839
- unsubscribePersistence();
1840
- threads = new SubagentThreadRegistry({ createId: () => ids[idIndex++] ?? `subagent-${Date.now()}` });
1841
- attachPersistence();
1842
- for (const entry of restored) {
1843
- try {
1844
- const thread = entry.thread;
1845
- const created = (threads as any).hydrate
1846
- ? (threads as any).hydrate(thread)
1847
- : threads.spawn({
1848
- parentId: thread.parentId as SubagentThreadId,
1849
- role: thread.role,
1850
- prompt: thread.prompt,
1851
- model: thread.model,
1852
- tools: thread.tools,
1853
- capabilityBoundary: thread.capabilityBoundary,
1854
- displayName: thread.displayName,
1855
- attempt: thread.attempt,
1856
- session: thread.session,
1857
- } as any);
1858
- threadMetadata.set(created.id, {
1859
- displayName: thread.displayName ?? thread.role,
1860
- attempt: thread.attempt ?? 1,
1861
- session: thread.session ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" },
1862
- persistentSession: Boolean(thread.session?.directory),
1863
- ...(entry.agent ? { agent: cloneAgentRole(entry.agent) } : {}),
1864
- ...(entry.thinking ? { thinking: entry.thinking } : {}),
1865
- });
1866
- if (entry.result) saveResult(created.id, entry.result);
1867
- if (!(threads as any).hydrate) {
1868
- if (thread.state === "done") {
1869
- threads.begin(created.id);
1870
- const output = lifecycleOutput(thread.result ?? entry.result?.output);
1871
- threads.complete(created.id, { result: output });
1872
- } else if (thread.state === "failed") {
1873
- threads.begin(created.id);
1874
- threads.fail(created.id, { message: thread.failure?.message ?? entry.result?.errorMessage ?? "restored failure" });
1875
- } else if (thread.state === "stopped" || thread.state === "orphaned") {
1876
- threads.stop(created.id, { reason: thread.stopReason ?? "parent_restarted" });
1877
- }
1878
- if (thread.state === "closed") {
1879
- if (threads.inspect(created.id)?.state === "queued") threads.begin(created.id);
1880
- if (threads.inspect(created.id)?.state === "active") threads.stop(created.id, { reason: thread.stopReason ?? "closed" });
1881
- threads.close(created.id);
1882
- }
1883
- }
1884
- } catch {
1885
- // Skip malformed or conflicting records and keep the rest of the board usable.
1886
- continue;
1887
- }
1888
- }
1889
- if (oldThreads !== threads && !oldThreads.isDisposed) oldThreads.dispose();
1890
- };
1891
-
1892
- attachPersistence();
1893
- const maxClosedThreads = Number.isSafeInteger(limits.threadRetentionRecords) && limits.threadRetentionRecords > 0
1894
- ? limits.threadRetentionRecords
1895
- : SUBAGENT_LIMITS.threadRetentionRecords;
1896
-
1897
- const stopActiveRuntimes = (reason: string): void => {
1898
- for (const runtime of activeRuntimes.values()) {
1899
- runtime.restarting = false;
1900
- runtime.requestedReason = reason;
1901
- runtime.handle?.stop(reason);
1902
- runtime.controller.abort();
1903
- }
1904
- };
1905
-
1906
- const rememberEvictedThreads = (threadsToRemember: readonly SubagentThread[]): void => {
1907
- for (const thread of threadsToRemember) {
1908
- evictedThreadParents.set(thread.id, thread.parentId);
1909
- threadMetadata.delete(thread.id);
1910
- }
1911
- while (evictedThreadParents.size > maxClosedThreads) {
1912
- const oldest = evictedThreadParents.keys().next().value;
1913
- if (oldest === undefined) break;
1914
- evictedThreadParents.delete(oldest);
1915
- }
1916
- };
1917
- const pruneClosedThreads = (): void => {
1918
- if (threads.isDisposed) return;
1919
- rememberEvictedThreads(threads.pruneClosed(maxClosedThreads));
1920
- };
1921
-
1922
- const resultBytes = (result: SubagentTaskResult): number => Buffer.byteLength([
1923
- result.task,
1924
- ...result.trace,
1925
- result.stderr,
1926
- result.output,
1927
- result.errorMessage ?? "",
1928
- ].join("\n"), "utf8");
1929
- const cleanupTerminalThread = async (
1930
- threadId: string,
1931
- resource: ThreadResource | undefined,
1932
- metadata: ThreadMetadata | undefined,
1933
- ): Promise<boolean> => {
1934
- const exits = resource
1935
- ? await Promise.all([...resource.handles].map((handle) => waitForConfirmedProcessExit(handle, limits.processExitWaitMs)))
1936
- : [];
1937
- if (!exits.every(Boolean)) {
1938
- for (const handle of resource?.handles ?? []) {
1939
- if (!handle.hasExited) {
1940
- void handle.exited.then(() => queueTerminalCleanup(threadId)).catch(() => {});
1941
- }
1942
- }
1943
- return false;
1944
- }
1945
- const directory = resource?.directory ?? metadata?.session.directory;
1946
- if ((resource?.persistent || metadata?.persistentSession) && directory) {
1947
- await rm(directory, { recursive: true, force: true });
1948
- }
1949
- if (threadResources.get(threadId) === resource) threadResources.delete(threadId);
1950
- return true;
1951
- };
1952
- const queueTerminalCleanup = (threadId: string): Promise<boolean> => {
1953
- const resource = threadResources.get(threadId);
1954
- const metadata = threadMetadata.get(threadId);
1955
- const generation = sessionGeneration;
1956
- const cleanup = terminalCleanupQueue.then(() => generation === sessionGeneration
1957
- ? cleanupTerminalThread(threadId, resource, metadata)
1958
- : false);
1959
- terminalCleanupQueue = cleanup.then(() => undefined, () => undefined);
1960
- return cleanup;
1961
- };
1962
- const waitForTerminalCleanup = async (): Promise<void> => {
1963
- await terminalCleanupQueue;
1964
- };
1965
- const trimSavedResults = (): void => {
1966
- const candidates = threads.listAll()
1967
- .filter((thread) => ["done", "failed", "stopped"].includes(thread.state))
1968
- .sort((left, right) => left.timestamps.createdAt - right.timestamps.createdAt);
1969
- const retainedBytes = (): number => [...savedResults.values()].reduce((total, result) => total + resultBytes(result), 0);
1970
- while ((savedResults.size > limits.threadRetentionRecords || retainedBytes() > limits.threadRetentionBytes) && candidates.length) {
1971
- const candidate = candidates.shift()!;
1972
- savedResults.delete(candidate.id);
1973
- const current = threads.inspect(candidate.id);
1974
- if (current && ["done", "failed", "stopped"].includes(current.state)) {
1975
- threads.close(candidate.id);
1976
- void queueTerminalCleanup(candidate.id).catch(() => {});
1977
- }
1978
- }
1979
- pruneClosedThreads();
1980
- };
1981
- const saveResult = (threadId: string, result: SubagentTaskResult): void => {
1982
- savedResults.delete(threadId);
1983
- savedResults.set(threadId, cloneResult(result));
1984
- trimSavedResults();
1985
- };
1986
-
1987
- const detailsFor = (
1988
- parentId: string,
1989
- mode: SubagentDetails["mode"] = "single",
1990
- scope: AgentScope = "user",
1991
- projectAgentsDir: string | null = null,
1992
- selectedThreadId?: string,
1993
- ): SubagentDetails => {
1994
- const all = threads.listAll().filter((thread) => thread.parentId === parentId);
1995
- const visible = all.filter((thread) => thread.state !== "closed").map((thread) => threadView(thread, threadMetadata));
1996
- const selectedClosed = selectedThreadId
1997
- ? all.find((thread) => thread.id === selectedThreadId && thread.state === "closed")
1998
- : undefined;
1999
- const listed = selectedClosed ? [...visible, threadView(selectedClosed, threadMetadata)] : visible;
2000
- const results = visible.map((thread) => {
2001
- const result = threadResult(thread, savedResults.get(thread.id));
2002
- result.name = threadDisplayName(thread, threadMetadata);
2003
- result.attempt = threadAttempt(thread, threadMetadata);
2004
- return result;
2005
- });
2006
- return {
2007
- ...cloneDetails(mode, scope, projectAgentsDir, results),
2008
- parentId,
2009
- executionNote: persistenceWarning,
2010
- threads: listed,
2011
- activeThreads: visible.filter((thread) => thread.state === "active"),
2012
- doneThreads: visible.filter((thread) => ["done", "failed", "stopped", "orphaned"].includes(thread.state)),
2013
- selectedThreadId,
2014
- };
2015
- };
2016
-
2017
- const threadBoardText = (parentId: string, selectedThreadId?: string): string => {
2018
- const details = detailsFor(parentId, "single", "user", null, selectedThreadId);
2019
- const active = details.activeThreads ?? [];
2020
- const done = details.doneThreads ?? [];
2021
- const row = (thread: ThreadSnapshot): string => `- ${threadDisplayName(thread, threadMetadata)} · ${thread.role} · ${thread.id} · ${thread.state} · ${thread.prompt}`;
2022
- const lines = [
2023
- `parent ${parentId}`,
2024
- `Active (${active.length})`,
2025
- ...(active.length ? active.map(row) : ["- none"]),
2026
- `Done (${done.length})`,
2027
- ...(done.length ? done.map(row) : ["- none"]),
2028
- "Controls: inspect · wait · steer · interrupt · collect · resume · close",
2029
- ];
2030
- if (selectedThreadId) {
2031
- const selected = details.threads?.find((thread) => thread.id === selectedThreadId);
2032
- if (selected) {
2033
- lines.push(`Inspect ${selected.id}: ${selected.state}`);
2034
- lines.push(`Name: ${threadDisplayName(selected, threadMetadata)}`);
2035
- lines.push(`Attempt: ${threadAttempt(selected, threadMetadata)}`);
2036
- lines.push(`Role: ${selected.role}`);
2037
- lines.push(`Model: ${selected.model}`);
2038
- lines.push(`Tools: ${selected.tools.join(", ")}`);
2039
- lines.push(`Trace: ${selected.trace.length} entries`);
2040
- if (selected.result) lines.push(`Handoff: ${selected.result}`);
2041
- if (selected.stopReason) lines.push(`Reason: ${selected.stopReason}`);
2042
- if (selected.evicted) lines.push("Retention: heavy thread data was evicted after close");
2043
- }
2044
- }
2045
- return boundedText(lines.join("\n"), limits.toolOutputBytes, "\n\n[Thread board truncated; inspect a child thread for its bounded detail.]");
2046
- };
2047
-
2048
- const syncThread = (threadId: SubagentThreadId, next: SubagentTaskResult, runtime?: ActiveThreadRuntime): SubagentTaskResult => {
2049
- const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
2050
- if (runtime?.requestedReason && next.status === "cancelled") effective.terminationReason = runtime.requestedReason;
2051
- if (runtime && runtime.sessionGeneration !== sessionGeneration) return effective;
2052
- saveResult(threadId, effective);
2053
- let thread = threads.inspect(threadId);
2054
- if (!thread || threads.isDisposed) return effective;
2055
- if (thread.state === "queued" && next.status === "running") {
2056
- thread = threads.begin(threadId);
2057
- }
2058
- if (thread.state !== "active") return effective;
2059
- if (runtime && next.trace.length < runtime.traceCount) runtime.traceCount = 0;
2060
- const from = runtime?.traceCount ?? 0;
2061
- let retainedTraceBytes = thread.trace.reduce((total, entry) => total + Buffer.byteLength(entry.message ?? "", "utf8"), 0);
2062
- for (const entry of next.trace.slice(from)) {
2063
- const retained = truncateUtf8(entry, limits.traceRetentionBytes === undefined
2064
- ? Buffer.byteLength(entry, "utf8")
2065
- : Math.max(0, limits.traceRetentionBytes - retainedTraceBytes));
2066
- if (retained.text) {
2067
- threads.trace(threadId, { kind: "child", message: retained.text });
2068
- retainedTraceBytes += Buffer.byteLength(retained.text, "utf8");
2069
- }
2070
- }
2071
- if (runtime) runtime.traceCount = next.trace.length;
2072
- const output = lifecycleOutput(effective.output);
2073
- const handoff = output ? { summary: output } : undefined;
2074
- thread = threads.patch(threadId, { usage: threadUsage(effective.usage), result: output, handoff });
2075
- const restartPending = runtime?.restarting === true && ["cancelled", "complete"].includes(next.status);
2076
- if (restartPending) return effective;
2077
- if (effective.status === "complete") {
2078
- threads.complete(threadId, { usage: threadUsage(effective.usage), result: output, handoff });
2079
- } else if (effective.status === "failed") {
2080
- threads.fail(threadId, {
2081
- usage: threadUsage(effective.usage),
2082
- result: output,
2083
- handoff,
2084
- message: effective.errorMessage ?? effective.terminationReason ?? "child failed",
2085
- code: effective.terminationReason,
2086
- });
2087
- } else if (effective.status === "cancelled" || effective.status === "limited") {
2088
- threads.stop(threadId, {
2089
- usage: threadUsage(effective.usage),
2090
- result: output,
2091
- handoff,
2092
- reason: effective.terminationReason ?? (effective.status === "limited" ? "resource_limit" : "interrupted"),
2093
- });
2094
- }
2095
- return effective;
2096
- };
2097
-
2098
- const resolveOwnedThread = (reference: string, parentId: string): ThreadSnapshot | undefined => {
2099
- const registry = threads as any;
2100
- const resolved = typeof registry.resolve === "function" ? registry.resolve(reference, parentId as SubagentThreadId) : undefined;
2101
- if (resolved && resolved.parentId === parentId) return threadView(resolved, threadMetadata);
2102
- const exact = threads.inspect(reference as SubagentThreadId);
2103
- if (exact && exact.parentId === parentId) return threadView(exact, threadMetadata);
2104
- const folded = reference.toLocaleLowerCase();
2105
- return threads.listAll()
2106
- .filter((thread) => thread.parentId === parentId)
2107
- .map((thread) => threadView(thread, threadMetadata))
2108
- .find((thread) => threadDisplayName(thread, threadMetadata).toLocaleLowerCase() === folded);
2109
- };
2110
-
2111
- const terminalThread = (thread: ThreadSnapshot): boolean => ["done", "failed", "stopped", "orphaned", "closed"].includes(thread.state as string);
2112
-
2113
- const waitForThreads = async (ids: readonly string[], timeoutMs: number): Promise<SubagentWaitSummary> => {
2114
- const startedAt = Date.now();
2115
- const targetIds = [...ids];
2116
- const status = (): { completed: string[]; pending: string[] } => {
2117
- const completed: string[] = [];
2118
- const pending: string[] = [];
2119
- for (const id of targetIds) {
2120
- const thread = threads.inspect(id as SubagentThreadId);
2121
- if (thread && terminalThread(threadView(thread, threadMetadata))) completed.push(id);
2122
- else pending.push(id);
2123
- }
2124
- return { completed, pending };
2125
- };
2126
- const initial = status();
2127
- if (!initial.pending.length) {
2128
- return { targetThreadIds: targetIds, completedThreadIds: initial.completed, pendingThreadIds: [], timedOut: false, waitedMs: 0 };
2129
- }
2130
- const registry = threads as any;
2131
- if (typeof registry.waitForTerminal === "function") {
2132
- const result = await registry.waitForTerminal(targetIds as SubagentThreadId[], timeoutMs);
2133
- return {
2134
- targetThreadIds: targetIds,
2135
- completedThreadIds: [...(result.completedThreadIds ?? [])],
2136
- pendingThreadIds: [...(result.pendingThreadIds ?? [])],
2137
- timedOut: result.timedOut === true,
2138
- waitedMs: result.waitedMs ?? Date.now() - startedAt,
2139
- };
2140
- }
2141
- return new Promise((resolve) => {
2142
- let timer: NodeJS.Timeout | undefined;
2143
- let unsubscribe = (): void => {};
2144
- const finish = (timedOut: boolean): void => {
2145
- if (timer) clearTimeout(timer);
2146
- unsubscribe();
2147
- const current = status();
2148
- resolve({
2149
- targetThreadIds: targetIds,
2150
- completedThreadIds: current.completed,
2151
- pendingThreadIds: current.pending,
2152
- timedOut,
2153
- waitedMs: Date.now() - startedAt,
2154
- });
2155
- };
2156
- unsubscribe = threads.subscribe(() => {
2157
- if (!status().pending.length) finish(false);
2158
- });
2159
- timer = setTimeout(() => finish(true), timeoutMs);
2160
- if (!status().pending.length) finish(false);
2161
- });
2162
- };
2163
-
2164
- const resumeThread = (target: ThreadSnapshot, prompt: string | undefined): ThreadSnapshot => {
2165
- const registry = threads as any;
2166
- const previousResult = savedResults.get(target.id);
2167
- savedResults.delete(target.id);
2168
- if (typeof registry.resume === "function") {
2169
- try {
2170
- const resumed = registry.resume(target.id as SubagentThreadId, prompt);
2171
- const metadata = threadMetadata.get(target.id);
2172
- if (metadata) metadata.attempt += 1;
2173
- return threadView(resumed, threadMetadata);
2174
- } catch (error) {
2175
- if (previousResult) saveResult(target.id, previousResult);
2176
- throw error;
2177
- }
2178
- }
2179
- try {
2180
- const snapshots = threads.listAll().filter((thread) => thread.state !== "closed");
2181
- const ids = snapshots.map((thread) => thread.id);
2182
- let idIndex = 0;
2183
- unsubscribePersistence();
2184
- const previous = threads;
2185
- threads = new SubagentThreadRegistry({ createId: () => ids[idIndex++]! });
2186
- attachPersistence();
2187
- let resumed: ThreadSnapshot | undefined;
2188
- for (const snapshot of snapshots) {
2189
- const metadata = threadMetadata.get(snapshot.id) ?? {
2190
- displayName: threadDisplayName(snapshot, threadMetadata),
2191
- attempt: threadAttempt(snapshot, threadMetadata),
2192
- session: threadSession(snapshot, threadMetadata) ?? { id: `killeros-${safeSessionId(snapshot.id)}`, directory: "" },
2193
- persistentSession: Boolean(threadSession(snapshot, threadMetadata)?.directory),
2194
- };
2195
- const nextPrompt = snapshot.id === target.id && prompt ? prompt : snapshot.prompt;
2196
- const created = threads.spawn({
2197
- parentId: snapshot.parentId,
2198
- role: snapshot.role,
2199
- prompt: nextPrompt,
2200
- model: snapshot.model,
2201
- tools: snapshot.tools,
2202
- capabilityBoundary: snapshot.capabilityBoundary,
2203
- displayName: metadata.displayName,
2204
- attempt: snapshot.id === target.id ? metadata.attempt + 1 : metadata.attempt,
2205
- session: metadata.session,
2206
- } as any);
2207
- metadata.attempt = snapshot.id === target.id ? metadata.attempt + 1 : metadata.attempt;
2208
- threadMetadata.set(snapshot.id, metadata);
2209
- if (snapshot.id === target.id) {
2210
- resumed = threadView(created, threadMetadata);
2211
- continue;
2212
- }
2213
- if (snapshot.state === "done") {
2214
- threads.begin(created.id);
2215
- threads.complete(created.id, { result: snapshot.result });
2216
- } else if (snapshot.state === "failed") {
2217
- threads.begin(created.id);
2218
- threads.fail(created.id, { message: snapshot.failure?.message ?? "restored failure" });
2219
- } else if (snapshot.state === "stopped" || snapshot.state === "orphaned") {
2220
- threads.stop(created.id, { reason: snapshot.stopReason ?? "stopped" });
2221
- }
2222
- }
2223
- if (!resumed) throw new Error(`Unknown child thread ${JSON.stringify(target.id)}`);
2224
- if (!previous.isDisposed) previous.dispose();
2225
- return resumed;
2226
- } catch (error) {
2227
- if (previousResult) saveResult(target.id, previousResult);
2228
- throw error;
2229
- }
2230
- };
2231
-
2232
- if (typeof pi.on === "function") {
2233
- pi.on("session_start", (_event, ctx) => {
2234
- sessionGeneration += 1;
2235
- stopActiveRuntimes("session_start");
2236
- threadResources.clear();
2237
- persistenceWarning = undefined;
2238
- unsubscribePersistence();
2239
- threads.dispose();
2240
- threads = new SubagentThreadRegistry();
2241
- threadMetadata.clear();
2242
- savedResults.clear();
2243
- evictedThreadParents.clear();
2244
- activeRuntimes.clear();
2245
- attachPersistence();
2246
- const entries = (ctx as ExtensionContext | undefined)?.sessionManager?.getEntries?.();
2247
- if (Array.isArray(entries)) {
2248
- const extensionContext = ctx as ExtensionContext;
2249
- installRestoredThreads(restoreRecords(
2250
- entries,
2251
- parentThreadId(extensionContext),
2252
- (threadId) => childSessionPath(extensionContext, threadId),
2253
- ));
2254
- }
2255
- });
2256
- pi.on("session_shutdown", async () => {
2257
- sessionGeneration += 1;
2258
- stopActiveRuntimes("session_shutdown");
2259
- await Promise.allSettled([...backgroundBatches]);
2260
- unsubscribePersistence();
2261
- for (const thread of threads.listAll()) {
2262
- if (["done", "failed", "stopped", "orphaned"].includes(thread.state as string)) {
2263
- recordSnapshot(thread, savedResults.get(thread.id));
2264
- }
2265
- }
2266
- threadResources.clear();
2267
- threads.dispose();
2268
- threadMetadata.clear();
2269
- savedResults.clear();
2270
- evictedThreadParents.clear();
2271
- });
2272
- }
2273
-
2274
- const toolDefinition: Parameters<ExtensionAPI["registerTool"]>[0] = {
2275
- name: "subagent",
2276
- label: "Subagents",
2277
- description: `Spawn and manage named child threads. A task-only spawn creates a generic read-only child; agent may name an optional custom role from an approved agents folder or provide an inline { name, description, access, tools } role. On spawn, message aliases task. Parallel tasks with write-capable roles use one shared slot; read-only batches run concurrently up to ${limits.maxReadConcurrency}. Write-capable tasks are serialized in the shared parent worktree. Use action list, inspect, wait, steer, interrupt, collect, resume, and close to manage child handoffs.`,
2278
- promptSnippet: "Delegate bounded work to isolated KillerOS subagents",
2279
- promptGuidelines: [
2280
- "Omit agent for a generic read-only child; use an approved custom role name or inline role when the task needs a specific prompt or write access.",
2281
- "Parallel tasks with write-capable roles use one shared slot because all children share the parent worktree.",
2282
- "Every child can load relevant skills with read and can use web_search, source_check, fetch_content, and get_search_content for external research.",
2283
- "Default model and thinking are inherited. If the user requires one model for the batch, pass model; set thinking only when requested, and use inherit to follow the selected role default or active parent.",
2284
- "Keep completed and stopped threads inspectable until the parent explicitly closes them.",
2285
- ],
2286
- parameters: createSubagentParams(limits),
2287
- prepareArguments(args) {
2288
- return prepareSubagentRequest(args, limits).input;
2289
- },
2290
- executionMode: "parallel",
2291
-
2292
- async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
2293
- const request = normalizeSubagentRequest(rawParams, limits);
2294
- const parentId = parentThreadId(ctx);
2295
- const actionDetails = (selectedThreadId?: string): SubagentDetails => detailsFor(parentId, "single", "user", null, selectedThreadId);
2296
- const actionResult = (text: string, selectedThreadId?: string) => {
2297
- const details = actionDetails(selectedThreadId);
2298
- return {
2299
- content: [{ type: "text" as const, text: boundedText(text, limits.toolOutputBytes, "\n\n[Thread action output truncated.]") }],
2300
- details,
2301
- usage: details.aggregateUsage,
2302
- };
2303
- };
2304
-
2305
- if (request.kind === "list") return actionResult(threadBoardText(parentId));
2306
- if (request.kind === "inspect") {
2307
- const { threadId } = request.input;
2308
- const thread = resolveOwnedThread(threadId, parentId);
2309
- if (!thread) {
2310
- if (evictedThreadParents.get(threadId) === parentId) {
2311
- return actionResult(`Thread ${threadId} was evicted from bounded retention; its heavy data is no longer available.`, threadId);
2312
- }
2313
- throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2314
- }
2315
- return actionResult(threadBoardText(parentId, thread.id), thread.id);
2316
- }
2317
- if (request.kind === "steer") {
2318
- const { threadId, message } = request.input;
2319
- const thread = resolveOwnedThread(threadId, parentId);
2320
- if (!thread) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2321
- const brandedThreadId = thread.id as SubagentThreadId;
2322
- const runtime = activeRuntimes.get(thread.id);
2323
- const pendingCount = runtime ? runtime.steering.length : thread.steering.length;
2324
- if (pendingCount >= MAX_RUNTIME_STEERING_MESSAGES) {
2325
- throw new Error(`Steering queue is full (${MAX_RUNTIME_STEERING_MESSAGES} pending messages); wait for the child restart or interrupt the thread first`);
2326
- }
2327
- const pendingSteering = runtime
2328
- ? [...runtime.steering, message]
2329
- : [...thread.steering.map((entry) => entry.message), message];
2330
- const baseTask = runtime?.task ?? thread.prompt;
2331
- if (steeredTaskWouldExceedLimit(baseTask, pendingSteering, limits.taskCharacters)) {
2332
- throw new Error(`Steering would exceed the ${limits.taskCharacters}-character task limit; shorten the message or wait for the child restart`);
2333
- }
2334
- threads.steer(brandedThreadId, message);
2335
- if (runtime) {
2336
- runtime.steering.push(message);
2337
- runtime.restarting = true;
2338
- runtime.requestedReason = "steer";
2339
- runtime.handle?.stop("steer");
2340
- }
2341
- return actionResult(`Steering queued for ${threadDisplayName(thread, threadMetadata)} (${thread.id}). The child keeps the same thread and handoff record.`, thread.id);
2342
- }
2343
- if (request.kind === "interrupt-one" || request.kind === "interrupt-all") {
2344
- let targets: SubagentThread[];
2345
- if (request.kind === "interrupt-all") {
2346
- targets = threads.listAll().filter((thread) => thread.parentId === parentId && (thread.state === "active" || thread.state === "queued"));
2347
- } else {
2348
- const { threadId } = request.input;
2349
- const target = resolveOwnedThread(threadId, parentId);
2350
- if (!target) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2351
- if (target.state !== "active" && target.state !== "queued") {
2352
- throw new Error(`Cannot interrupt thread ${threadId} from ${target.state}`);
2353
- }
2354
- targets = [target];
2355
- }
2356
- for (const thread of targets) {
2357
- if (thread.parentId !== parentId) continue;
2358
- const runtime = activeRuntimes.get(thread.id);
2359
- if (runtime) {
2360
- runtime.restarting = false;
2361
- runtime.requestedReason = "interrupt";
2362
- runtime.handle?.stop("interrupt");
2363
- runtime.controller.abort();
2364
- } else if (thread.state === "active" || thread.state === "queued") {
2365
- threads.stop(thread.id, { reason: "interrupt" });
2366
- }
2367
- }
2368
- return actionResult(request.kind === "interrupt-all"
2369
- ? "Interrupt requested for all active and queued child threads."
2370
- : `Interrupt requested for ${targets[0]?.id} (${threadDisplayName(targets[0] as ThreadSnapshot, threadMetadata)}).`);
2371
- }
2372
- if (request.kind === "collect") {
2373
- const { threadId } = request.input;
2374
- const thread = resolveOwnedThread(threadId, parentId);
2375
- if (!thread) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2376
- const collected = threads.collect(thread.id as SubagentThreadId);
2377
- return actionResult(`Collected ${threadDisplayName(thread, threadMetadata)} (${thread.id}): ${collected.result ?? collected.failure?.message ?? collected.stopReason ?? "no handoff"}`, thread.id);
2378
- }
2379
- if (request.kind === "wait") {
2380
- const target = request.input.threadId ? resolveOwnedThread(request.input.threadId, parentId) : undefined;
2381
- if (request.input.threadId && !target) throw new Error(`Unknown child thread ${JSON.stringify(request.input.threadId)}`);
2382
- const targets = target
2383
- ? [target]
2384
- : threads.listAll().filter((thread) => thread.parentId === parentId && (thread.state === "queued" || thread.state === "active"));
2385
- const wait = await waitForThreads(targets.map((thread) => thread.id), request.input.timeoutMs);
2386
- const details = actionDetails(target?.id);
2387
- details.wait = wait;
2388
- return {
2389
- content: [{ type: "text" as const, text: boundedText(
2390
- wait.timedOut
2391
- ? `Wait timed out after ${wait.waitedMs}ms. Pending: ${wait.pendingThreadIds.join(", ") || "none"}.`
2392
- : `Wait complete after ${wait.waitedMs}ms. Completed: ${wait.completedThreadIds.join(", ") || "none"}.`,
2393
- limits.toolOutputBytes,
2394
- "\n\n[Thread action output truncated.]",
2395
- ) }],
2396
- details,
2397
- usage: details.aggregateUsage,
2398
- };
2399
- }
2400
- if (request.kind === "close") {
2401
- const { threadId } = request.input;
2402
- const thread = resolveOwnedThread(threadId, parentId);
2403
- if (!thread) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2404
- if (thread.state === "queued" || thread.state === "active") {
2405
- throw new Error(`Cannot close thread ${thread.id} from ${thread.state}`);
2406
- }
2407
- const exitConfirmed = await queueTerminalCleanup(thread.id);
2408
- if (!exitConfirmed) {
2409
- const current = savedResults.get(thread.id);
2410
- if (current) {
2411
- const failed = cloneResult(current);
2412
- failed.status = "failed";
2413
- failed.terminationReason = "process_exit_unconfirmed";
2414
- failed.exitConfirmed = false;
2415
- failed.errorMessage = "Child process exit was not confirmed before close";
2416
- saveResult(thread.id, failed);
2417
- recordSnapshot(thread, failed);
2418
- }
2419
- }
2420
- threads.close(thread.id as SubagentThreadId);
2421
- savedResults.delete(thread.id);
2422
- pruneClosedThreads();
2423
- return actionResult(exitConfirmed
2424
- ? `Closed ${threadDisplayName(thread, threadMetadata)} (${thread.id}). Heavy trace and handoff data were evicted; a tombstone remains inspectable.`
2425
- : `Closed ${threadDisplayName(thread, threadMetadata)} (${thread.id}); process exit was not confirmed, so its session directory was retained.`, thread.id);
2426
- }
2427
-
2428
- let resumeTarget: ThreadSnapshot | undefined;
2429
- let resumePrompt: string | undefined;
2430
- let resumeAgent: AgentRole | undefined;
2431
- const parentTools = activeParentTools(pi);
2432
- const isResume = request.kind === "resume";
2433
- if (isResume) {
2434
- const target = resolveOwnedThread(request.input.threadId, parentId);
2435
- if (!target) throw new Error(`Unknown child thread ${JSON.stringify(request.input.threadId)}`);
2436
- if (!terminalThread(target) || target.state === "closed") {
2437
- throw new Error(`Cannot resume thread ${target.id} from ${target.state}`);
2438
- }
2439
- resumeAgent = threadMetadata.get(target.id)?.agent;
2440
- if (!resumeAgent && target.role === "generic") resumeAgent = genericAgentRole(parentTools);
2441
- if (!resumeAgent && savedResults.get(target.id)?.agentSource === "inline") {
2442
- throw new Error(`Cannot resume inline role ${JSON.stringify(target.role)}; inline roles are scoped to one spawn`);
2443
- }
2444
- resumePrompt = request.input.task;
2445
- resumeTarget = target;
2446
- }
2447
- const spawnRequest = (isResume
2448
- ? {
2449
- kind: "spawn-single",
2450
- input: {
2451
- agent: resumeAgent ?? resumeTarget!.role,
2452
- task: resumePrompt ?? resumeTarget!.prompt,
2453
- model: resumeTarget!.model,
2454
- ...(threadMetadata.get(resumeTarget!.id)?.thinking
2455
- ? { thinking: threadMetadata.get(resumeTarget!.id)!.thinking }
2456
- : {}),
2457
- },
2458
- }
2459
- : request) as Extract<NormalizedSubagentRequest, { kind: "spawn-single" | "spawn-parallel" | "spawn-chain" }>;
2460
- const params = spawnRequest.input;
2461
- const scope: AgentScope = params.agentScope ?? (isResume && resumeAgent?.source === "project" ? "project" : "user");
2462
- const hasParallel = spawnRequest.kind === "spawn-parallel";
2463
- const hasChain = spawnRequest.kind === "spawn-chain";
2464
- const writerConcurrencyOverride = hasParallel ? spawnRequest.input.writerConcurrency : undefined;
2465
-
2466
- const discovery = discoverAgentRoles(ctx.cwd, scope, ctx.isProjectTrusted(), options);
2467
- const roles = new Map(discovery.agents.map((agent) => [agent.name, agent]));
2468
- const rawInputs: TaskInput[] = spawnRequest.kind === "spawn-single"
2469
- ? [{ agent: spawnRequest.input.agent, task: spawnRequest.input.task, ...(spawnRequest.input.name ? { name: spawnRequest.input.name } : {}) }]
2470
- : spawnRequest.kind === "spawn-parallel" ? spawnRequest.input.tasks : spawnRequest.input.chain;
2471
- const rolesForInputs = rawInputs.map((input) => {
2472
- if (isResume && resumeAgent && input.agent === resumeAgent) {
2473
- const role = cloneAgentRole(resumeAgent);
2474
- validateAgentTools(role, parentTools);
2475
- return role;
2476
- }
2477
- if (input.agent === undefined) return genericAgentRole(parentTools);
2478
- if (typeof input.agent !== "string") {
2479
- const role = inlineAgentRole(input.agent);
2480
- validateAgentTools(role, parentTools);
2481
- return role;
2482
- }
2483
- const selected = roles.get(input.agent);
2484
- if (selected) return selected;
2485
- const available = discovery.agents.map((agent) => `${agent.name} (${agent.source})`).join(", ") || "none";
2486
- throw new Error(`Unknown custom subagent ${JSON.stringify(input.agent)}. Available: ${available}`);
2487
- });
2488
- rolesForInputs.forEach((role) => validateAgentTools(role, parentTools));
2489
- const inputs: Array<Omit<TaskInput, "agent"> & { agent: string }> = rawInputs.map((input, index) => ({
2490
- ...input,
2491
- agent: rolesForInputs[index]!.name,
2492
- }));
2493
-
2494
- const projectRoles = [...new Set(rolesForInputs.filter((role) => role.source === "project"))];
2495
- if (projectRoles.length) {
2496
- if (!ctx.hasUI) throw new Error("Project-local subagents require interactive confirmation");
2497
- const approved = await ctx.ui.confirm(
2498
- "Run project-local subagents?",
2499
- `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.`,
2500
- );
2501
- if (!approved) throw new Error("Project-local subagents were not approved");
2502
- }
2503
- const legacyRoles = [...new Set(rolesForInputs.filter((role) => role.source === "legacy"))];
2504
- if (legacyRoles.length) {
2505
- if (!ctx.hasUI) throw new Error("Legacy persisted subagents require interactive confirmation");
2506
- const approved = await ctx.ui.confirm(
2507
- "Resume legacy subagents?",
2508
- `Roles: ${legacyRoles.map((role) => role.name).join(", ")}\nThese saved roles came from an earlier KillerOS release and their original source is unavailable.`,
2509
- );
2510
- if (!approved) throw new Error("Legacy persisted subagents were not approved");
2511
- }
2512
-
2513
- const resolvedModels = rolesForInputs.map((role) => resolveAgentModel(role, ctx, params.model, params.thinking));
2514
-
2515
- const mode: SubagentDetails["mode"] = hasParallel ? "parallel" : hasChain ? "chain" : "single";
2516
- if (inputs.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
2517
- const readIndexes = hasParallel
2518
- ? inputs.map((input, index) => ({ input, index })).filter(({ index }) => rolesForInputs[index]!.access === "read")
2519
- : [];
2520
- const writerIndexes = hasParallel
2521
- ? inputs.map((input, index) => ({ input, index })).filter(({ index }) => rolesForInputs[index]!.access === "write").map(({ index }) => index)
2522
- : [];
2523
- if (writerConcurrencyOverride !== undefined && writerIndexes.length === 0) {
2524
- throw new Error("writerConcurrency requires at least one write-capable role");
2525
- }
2526
- if (writerIndexes.length > 0 && writerConcurrencyOverride !== undefined && writerConcurrencyOverride > 1) {
2527
- throw new Error("writerConcurrency above 1 is not allowed for write-capable tasks because child threads share the parent worktree; use 1");
2528
- }
2529
- const writerConcurrency = writerConcurrencyOverride ?? (writerIndexes.length > 0 ? 1 : limits.maxReadConcurrency);
2530
- const useSharedParallelPool = hasParallel && writerIndexes.length > 0;
2531
- const scheduleNote = hasParallel
2532
- ? writerIndexes.length
2533
- ? `Parallel schedule: all tasks run through a shared pool of up to ${writerConcurrency}${writerConcurrencyOverride === undefined ? " (safe default)" : " (explicit)"}. Write-capable tasks are serialized in the shared parent worktree.`
2534
- : `Parallel schedule: read-only tasks run concurrently up to ${limits.maxReadConcurrency}.`
2535
- : undefined;
2536
- const executionNote = scheduleNote;
2537
-
2538
- const inFlight = threads.listAll().filter((thread) => ["queued", "active"].includes(thread.state)).length;
2539
- if (inFlight + inputs.length > limits.maxTasks) {
2540
- throw new Error(`At most ${limits.maxTasks} child threads may be active at once`);
2541
- }
2542
-
2543
- const existingThreads = threads.listAll().filter((thread) => thread.parentId === parentId).map((thread) => threadView(thread, threadMetadata));
2544
- const allocatedNames = new Set<string>();
2545
- if (isResume) {
2546
- resumeTarget = resumeThread(resumeTarget!, resumePrompt);
2547
- const metadata = threadMetadata.get(resumeTarget.id);
2548
- if (metadata) {
2549
- metadata.agent = cloneAgentRole(rolesForInputs[0]!);
2550
- metadata.thinking = resolvedModels[0]!.thinking;
2551
- }
2552
- }
2553
- const threadRecords = isResume
2554
- ? [resumeTarget!]
2555
- : inputs.map((input, index) => {
2556
- const allocated = [...allocatedNames].map((name) => ({ role: name, displayName: name } as ThreadSnapshot));
2557
- const displayName = input.name ?? defaultThreadName(input.agent, [...existingThreads, ...allocated]);
2558
- validateThreadName(displayName);
2559
- const duplicate = [...existingThreads, ...allocated]
2560
- .some((thread) => threadDisplayName(thread, threadMetadata).toLocaleLowerCase() === displayName.toLocaleLowerCase());
2561
- if (duplicate) throw new Error(`Child display name ${JSON.stringify(displayName)} already exists for this parent`);
2562
- allocatedNames.add(displayName.toLocaleLowerCase());
2563
- const thread = threads.spawn({
2564
- parentId: parentId as SubagentThreadId,
2565
- role: input.agent,
2566
- prompt: input.task,
2567
- model: resolvedModels[index]!.model,
2568
- tools: rolesForInputs[index]!.tools,
2569
- capabilityBoundary: threadCapabilityBoundary(rolesForInputs[index]!),
2570
- displayName,
2571
- attempt: 1,
2572
- session: { id: "killeros-pending", directory: path.join(os.tmpdir(), "killeros-subagent-pending") },
2573
- } as any);
2574
- const session = childSessionPath(ctx, thread.id) ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" };
2575
- threadMetadata.set(thread.id, {
2576
- displayName,
2577
- attempt: 1,
2578
- session,
2579
- persistentSession: Boolean(session.directory),
2580
- agent: cloneAgentRole(rolesForInputs[index]!),
2581
- thinking: resolvedModels[index]!.thinking,
2582
- });
2583
- recordSpawn(thread);
2584
- return thread;
2585
- });
2586
- const results = threadRecords.map((thread, index) => {
2587
- const metadata = threadMetadata.get(thread.id)!;
2588
- return makeQueuedResult(thread.id, inputs[index]!.agent, inputs[index]!.task, hasChain ? index + 1 : undefined, metadata.displayName, metadata.attempt);
2589
- });
2590
- const batchSessionGeneration = sessionGeneration;
2591
- const liveWidgetKey = `killeros-subagents:${results[0]!.id}`;
2592
- const showLiveWidget = options.awaitSpawnCompletion !== true && ctx.hasUI;
2593
- const updateLiveWidget = (currentResults: SubagentTaskResult[]): void => {
2594
- if (!showLiveWidget) return;
2595
- const board = formatThreadBoard({
2596
- title: `Subagents · ${mode} · live`,
2597
- threads: currentResults.map(threadBoardRecord),
2598
- });
2599
- const row = (task: (typeof board.active)[number]): string => `${task.state.label} · ${task.displayName ?? task.agent} · ${task.usage.text}`;
2600
- try {
2601
- ctx.ui.setWidget(liveWidgetKey, [
2602
- board.title,
2603
- `Active (${board.active.length})`,
2604
- ...(board.active.length ? board.active.map(row) : ["None"]),
2605
- `Done (${board.done.length})`,
2606
- ...(board.done.length ? board.done.map(row) : ["None"]),
2607
- `Total · ${formatUsage(aggregateUsage(currentResults))}`,
2608
- ]);
2609
- } catch {
2610
- // Live UI updates must not fail the child batch.
2611
- }
2612
- };
2613
- const clearLiveWidget = (): void => {
2614
- if (!showLiveWidget) return;
2615
- try {
2616
- ctx.ui.setWidget(liveWidgetKey, undefined);
2617
- } catch {
2618
- // The session may close before the child batch settles.
2619
- }
2620
- };
2621
- let updatesOpen = true;
2622
- const emit = (message = `${mode}: ${results.filter((result) => !["queued", "running"].includes(result.status)).length}/${results.length} settled`): void => {
2623
- if (batchSessionGeneration !== sessionGeneration) return;
2624
- const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir, isResume ? resumeTarget?.id : undefined);
2625
- const currentResults = results.map(cloneResult);
2626
- updateLiveWidget(currentResults);
2627
- if (!updatesOpen) return;
2628
- try {
2629
- (onUpdate as ToolUpdate | undefined)?.({
2630
- content: [{ type: "text", text: message }],
2631
- details: { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
2632
- });
2633
- } catch {
2634
- // Host update callbacks are telemetry; failures must not fail the batch.
2635
- }
2636
- };
2637
- const failQueuedTask = (index: number, reason: string, message: string): void => {
2638
- if (batchSessionGeneration !== sessionGeneration) return;
2639
- const threadId = threadRecords[index]!.id;
2640
- const thread = threads.inspect(threadId);
2641
- if (thread?.state === "queued") threads.begin(threadId);
2642
- results[index] = {
2643
- ...results[index]!,
2644
- status: "failed",
2645
- terminationReason: reason,
2646
- errorMessage: message,
2647
- };
2648
- if (threads.inspect(threadId)?.state === "active") threads.fail(threadId, { message, code: reason });
2649
- saveResult(threadId, results[index]!);
2650
- emit();
2651
- };
2652
- const runAt = async (index: number, task: string): Promise<void> => {
2653
- if (batchSessionGeneration !== sessionGeneration) {
2654
- results[index] = { ...results[index]!, status: "cancelled", terminationReason: "session_start" };
2655
- return;
2656
- }
2657
- const threadId = threadRecords[index]!.id;
2658
- const initialThread = threads.inspect(threadId);
2659
- if (signal?.aborted) {
2660
- results[index] = { ...results[index]!, status: "cancelled", terminationReason: "abort" };
2661
- if (initialThread?.state === "queued" || initialThread?.state === "active") threads.stop(threadId, { reason: "abort" });
2662
- saveResult(threadId, results[index]!);
2663
- emit();
2664
- return;
2665
- }
2666
- if (!initialThread) return;
2667
- if (initialThread.state === "closed") {
2668
- results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "disposed" };
2669
- saveResult(threadId, results[index]!);
2670
- emit();
2671
- return;
2672
- }
2673
- if (initialThread.state === "stopped") {
2674
- results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "interrupted" };
2675
- saveResult(threadId, results[index]!);
2676
- emit();
2677
- return;
2678
- }
2679
- if (initialThread.state !== "queued") return;
2680
- const input = inputs[index]!;
2681
- threads.begin(threadId);
2682
- const queuedSteering = initialThread.steering.map((entry) => entry.message);
2683
- if (codePointLength(task) > limits.taskCharacters) {
2684
- failQueuedTask(index, "task_limit", `Expanded task exceeds ${limits.taskCharacters} characters`);
2685
- return;
2686
- }
2687
- if (steeredTaskWouldExceedLimit(task, queuedSteering, limits.taskCharacters)) {
2688
- failQueuedTask(index, "steering_task_limit", `Expanded task plus steering exceeds ${limits.taskCharacters} characters`);
2689
- return;
2690
- }
2691
- const controller = new AbortController();
2692
- const runtime: ActiveThreadRuntime = {
2693
- controller,
2694
- handles: new Set(),
2695
- task,
2696
- steering: [],
2697
- restarting: false,
2698
- traceCount: 0,
2699
- startedAt: Date.now(),
2700
- sessionGeneration: batchSessionGeneration,
2701
- aggregate: isResume ? savedResults.get(threadId) && cloneResult(savedResults.get(threadId)!) : undefined,
2702
- };
2703
- const abortFromParent = (): void => {
2704
- runtime.restarting = false;
2705
- runtime.requestedReason = "abort";
2706
- runtime.handle?.stop("abort");
2707
- controller.abort();
2708
- };
2709
- signal?.addEventListener("abort", abortFromParent, { once: true });
2710
- if (signal?.aborted) abortFromParent();
2711
- activeRuntimes.set(threadId, runtime);
2712
- let sessionDirectory: string;
2713
- let persistentSession = false;
2714
- try {
2715
- const metadata = threadMetadata.get(threadId)!;
2716
- if (metadata.persistentSession && metadata.session.directory) {
2717
- sessionDirectory = metadata.session.directory;
2718
- await mkdir(sessionDirectory, { recursive: true, mode: 0o700 });
2719
- persistentSession = true;
2720
- } else {
2721
- sessionDirectory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-session-"));
2722
- }
2723
- const existingResource = threadResources.get(threadId);
2724
- threadResources.set(threadId, existingResource ?? { directory: sessionDirectory, persistent: persistentSession, handles: new Set() });
2725
- const resource = threadResources.get(threadId)!;
2726
- resource.directory = sessionDirectory;
2727
- resource.persistent = persistentSession || resource.persistent;
2728
- } catch (error) {
2729
- signal?.removeEventListener("abort", abortFromParent);
2730
- if (activeRuntimes.get(threadId) === runtime) activeRuntimes.delete(threadId);
2731
- if (runtime.sessionGeneration !== sessionGeneration) {
2732
- results[index] = { ...results[index]!, status: "cancelled", terminationReason: runtime.requestedReason ?? "session_start" };
2733
- return;
2734
- }
2735
- const message = error instanceof Error ? error.message : String(error);
2736
- results[index] = {
2737
- ...results[index]!,
2738
- status: "failed",
2739
- terminationReason: "session_error",
2740
- errorMessage: message,
2741
- };
2742
- threads.fail(threadId, { message, code: "session_error" });
2743
- saveResult(threadId, results[index]!);
2744
- emit();
2745
- return;
2746
- }
2747
- const currentThread = threads.inspect(threadId);
2748
- if (controller.signal.aborted || threads.isDisposed || currentThread?.state !== "active") {
2749
- signal?.removeEventListener("abort", abortFromParent);
2750
- if (activeRuntimes.get(threadId) === runtime) activeRuntimes.delete(threadId);
2751
- if (!persistentSession) {
2752
- try {
2753
- await rm(sessionDirectory, { recursive: true, force: true });
2754
- } catch {
2755
- // Temporary child session cleanup is best effort before process startup.
2756
- }
2757
- }
2758
- if (runtime.sessionGeneration !== sessionGeneration) {
2759
- results[index] = { ...results[index]!, status: "cancelled", terminationReason: runtime.requestedReason ?? "session_start" };
2760
- return;
2761
- }
2762
- const reason = runtime.requestedReason ?? currentThread?.stopReason ?? (threads.isDisposed ? "session_shutdown" : "interrupted");
2763
- results[index] = { ...results[index]!, status: "cancelled", terminationReason: reason };
2764
- if (!threads.isDisposed && currentThread?.state === "active") threads.stop(threadId, { reason });
2765
- saveResult(threadId, results[index]!);
2766
- emit();
2767
- return;
2768
- }
2769
- const metadata = threadMetadata.get(threadId)!;
2770
- const sessionId = metadata.session.id;
2771
- const agent = rolesForInputs[index]!;
2772
- let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, limits.taskCharacters) : task;
2773
- const stopForBudget = (reason: string, message: string): void => {
2774
- if (runtime.sessionGeneration !== sessionGeneration) return;
2775
- const limited = cloneResult(runtime.aggregate ?? results[index]!);
2776
- limited.status = "limited";
2777
- limited.terminationReason = reason;
2778
- limited.errorMessage = message;
2779
- runtime.aggregate = limited;
2780
- results[index] = cloneResult(limited);
2781
- saveResult(threadId, limited);
2782
- if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2783
- const output = lifecycleOutput(limited.output);
2784
- threads.stop(threadId, {
2785
- usage: threadUsage(limited.usage),
2786
- result: output,
2787
- handoff: output ? { summary: output } : undefined,
2788
- reason,
2789
- });
2790
- }
2791
- emit();
2792
- };
2793
- try {
2794
- while (true) {
2795
- const aggregate = runtime.aggregate;
2796
- const wallTimeMs = limits.wallTimeMs ?? agent.timeoutMs ?? limits.defaultWallTimeMs;
2797
- const remainingWallTimeMs = wallTimeMs === undefined ? undefined : wallTimeMs - (Date.now() - runtime.startedAt);
2798
- const usedTraceBytes = (aggregate?.traceBytes ?? 0) + (aggregate?.traceTruncatedBytes ?? 0);
2799
- const usedStderrBytes = aggregate?.stderrBytes ?? 0;
2800
- const usedOutputBytes = aggregate?.outputBytes ?? 0;
2801
- const usedTokens = aggregate?.usage.totalTokens ?? 0;
2802
- const usedCost = aggregate?.usage.cost.total ?? 0;
2803
- const usedTurns = aggregate?.usage.turns ?? 0;
2804
- const maxTurns = limits.maxTurns ?? limits.defaultMaxTurns;
2805
- const quotaTokens = limits.quotaTokens ?? limits.defaultQuotaTokens;
2806
- if (remainingWallTimeMs !== undefined && remainingWallTimeMs <= 0) {
2807
- stopForBudget("wall_time_limit", `Child thread exceeds ${wallTimeMs} ms`);
2808
- break;
2809
- }
2810
- if (limits.traceBytes !== undefined && usedTraceBytes >= limits.traceBytes) {
2811
- stopForBudget("trace_limit", `Child thread retains more than ${limits.traceBytes} trace bytes`);
2812
- break;
2813
- }
2814
- if (limits.stderrBytes !== undefined && usedStderrBytes >= limits.stderrBytes) {
2815
- stopForBudget("stderr_limit", `Child thread emits more than ${limits.stderrBytes} stderr bytes`);
2816
- break;
2817
- }
2818
- if (limits.taskOutputBytes !== undefined && usedOutputBytes >= limits.taskOutputBytes) {
2819
- stopForBudget("output_limit", `Child thread emits more than ${limits.taskOutputBytes} output bytes`);
2820
- break;
2821
- }
2822
- if (usedTurns >= maxTurns) {
2823
- stopForBudget("turn_limit", `Child thread reaches ${maxTurns} turns`);
2824
- break;
2825
- }
2826
- if (usedTokens >= quotaTokens) {
2827
- stopForBudget("quota_tokens", `Child thread reaches ${quotaTokens} tokens`);
2828
- break;
2829
- }
2830
- if (limits.quotaUsd !== undefined && usedCost >= limits.quotaUsd) {
2831
- stopForBudget("quota_cost", `Child thread exceeds $${limits.quotaUsd}`);
2832
- break;
2833
- }
2834
- runtime.traceCount = 0;
2835
- const next = await runTask({
2836
- cwd: ctx.cwd,
2837
- agent,
2838
- task: currentTask,
2839
- id: results[index]!.id,
2840
- displayName: metadata.displayName,
2841
- attempt: metadata.attempt,
2842
- step: results[index]!.step,
2843
- model: resolvedModels[index]!,
2844
- signal: controller.signal,
2845
- webExtension: options.webExtension,
2846
- projectTrusted: ctx.isProjectTrusted(),
2847
- spawnProcess,
2848
- sessionDirectory,
2849
- sessionId,
2850
- limits: {
2851
- ...limits,
2852
- ...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes - usedTraceBytes }),
2853
- ...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes - usedStderrBytes }),
2854
- ...(limits.taskOutputBytes === undefined ? {} : { taskOutputBytes: limits.taskOutputBytes - usedOutputBytes }),
2855
- maxTurns: maxTurns - usedTurns,
2856
- quotaTokens: quotaTokens - usedTokens,
2857
- ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd - usedCost }),
2858
- },
2859
- timeoutMs: remainingWallTimeMs,
2860
- onHandle: (handle) => {
2861
- runtime.handle = handle;
2862
- runtime.handles.add(handle);
2863
- threadResources.get(threadId)?.handles.add(handle);
2864
- },
2865
- onChange: (changed) => {
2866
- results[index] = syncThread(threadId, changed, runtime);
2867
- emit();
2868
- },
2869
- });
2870
- next.task = task;
2871
- runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
2872
- runtime.aggregate.task = task;
2873
- if (next.status === "cancelled" && runtime.requestedReason !== undefined) {
2874
- runtime.aggregate.terminationReason = runtime.requestedReason;
2875
- }
2876
- results[index] = cloneResult(runtime.aggregate);
2877
- if (runtime.sessionGeneration === sessionGeneration) saveResult(threadId, runtime.aggregate);
2878
- const shouldRestart = runtime.sessionGeneration === sessionGeneration
2879
- && runtime.steering.length > 0
2880
- && !controller.signal.aborted
2881
- && (runtime.restarting || next.status === "complete" || next.status === "cancelled");
2882
- if (!shouldRestart) break;
2883
- const previousHandle = runtime.handle;
2884
- if (previousHandle && !(await waitForConfirmedProcessExit(previousHandle))) {
2885
- if (runtime.sessionGeneration !== sessionGeneration || controller.signal.aborted || threads.isDisposed) {
2886
- const cancelled = cloneResult(runtime.aggregate ?? results[index]!);
2887
- cancelled.status = "cancelled";
2888
- cancelled.terminationReason = runtime.requestedReason ?? (threads.isDisposed ? "session_shutdown" : "abort");
2889
- runtime.aggregate = cancelled;
2890
- results[index] = cloneResult(cancelled);
2891
- if (runtime.sessionGeneration === sessionGeneration) {
2892
- saveResult(threadId, cancelled);
2893
- if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2894
- const output = lifecycleOutput(cancelled.output);
2895
- threads.stop(threadId, {
2896
- usage: threadUsage(cancelled.usage),
2897
- result: output,
2898
- handoff: output ? { summary: output } : undefined,
2899
- reason: cancelled.terminationReason,
2900
- });
2901
- }
2902
- emit();
2903
- }
2904
- break;
2905
- }
2906
- const message = "Child process exit was not confirmed before the steering restart";
2907
- const unconfirmed = cloneResult(runtime.aggregate ?? results[index]!);
2908
- unconfirmed.status = "failed";
2909
- unconfirmed.terminationReason = "process_exit_unconfirmed";
2910
- unconfirmed.errorMessage = message;
2911
- runtime.aggregate = unconfirmed;
2912
- results[index] = cloneResult(unconfirmed);
2913
- if (runtime.sessionGeneration === sessionGeneration) saveResult(threadId, unconfirmed);
2914
- if (runtime.sessionGeneration === sessionGeneration && !threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2915
- const output = lifecycleOutput(unconfirmed.output);
2916
- threads.fail(threadId, {
2917
- usage: threadUsage(unconfirmed.usage),
2918
- result: output,
2919
- handoff: output ? { summary: output } : undefined,
2920
- message,
2921
- code: "process_exit_unconfirmed",
2922
- });
2923
- }
2924
- emit();
2925
- break;
2926
- }
2927
- if (runtime.sessionGeneration !== sessionGeneration || controller.signal.aborted || threads.isDisposed) break;
2928
- const steering = runtime.steering.splice(0);
2929
- runtime.restarting = false;
2930
- runtime.requestedReason = undefined;
2931
- if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2932
- const output = lifecycleOutput(runtime.aggregate.output);
2933
- threads.patch(threadId, {
2934
- usage: threadUsage(runtime.aggregate.usage),
2935
- result: output,
2936
- handoff: output ? { summary: output } : undefined,
2937
- });
2938
- }
2939
- if (steeredTaskWouldExceedLimit(task, steering, limits.taskCharacters)) {
2940
- const failed = cloneResult(runtime.aggregate ?? results[index]!);
2941
- failed.status = "failed";
2942
- failed.terminationReason = "steering_task_limit";
2943
- failed.errorMessage = `Expanded task plus steering exceeds ${limits.taskCharacters} characters`;
2944
- runtime.aggregate = failed;
2945
- results[index] = cloneResult(failed);
2946
- if (runtime.sessionGeneration === sessionGeneration) {
2947
- saveResult(threadId, failed);
2948
- if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2949
- const output = lifecycleOutput(failed.output);
2950
- threads.fail(threadId, {
2951
- usage: threadUsage(failed.usage),
2952
- result: output,
2953
- handoff: output ? { summary: output } : undefined,
2954
- message: failed.errorMessage,
2955
- code: failed.terminationReason,
2956
- });
2957
- }
2958
- emit();
2959
- }
2960
- break;
2961
- }
2962
- currentTask = buildSteeredTask(task, steering, limits.taskCharacters);
2963
- }
2964
- } finally {
2965
- signal?.removeEventListener("abort", abortFromParent);
2966
- if (activeRuntimes.get(threadId) === runtime) activeRuntimes.delete(threadId);
2967
- const handles = [...runtime.handles];
2968
- const exitStates = await Promise.all(handles.map((handle) => waitForConfirmedProcessExit(handle, limits.processExitWaitMs)));
2969
- const allExited = exitStates.every(Boolean);
2970
- if (!allExited) {
2971
- const unconfirmed = cloneResult(runtime.aggregate ?? results[index]!);
2972
- const requestedReason = runtime.requestedReason;
2973
- const strongReasons = new Set(["abort", "interrupt", "session_start", "session_shutdown", "malformed_jsonl", "invalid_usage", "spawn_error"]);
2974
- if (!requestedReason || !strongReasons.has(requestedReason)) {
2975
- unconfirmed.status = "failed";
2976
- unconfirmed.terminationReason = "process_exit_unconfirmed";
2977
- unconfirmed.errorMessage = "Child process exit was not confirmed before cleanup";
2978
- }
2979
- unconfirmed.exitConfirmed = false;
2980
- runtime.aggregate = unconfirmed;
2981
- results[index] = cloneResult(unconfirmed);
2982
- if (runtime.sessionGeneration === sessionGeneration) {
2983
- saveResult(threadId, unconfirmed);
2984
- const current = threads.inspect(threadId);
2985
- if (current?.state === "active") {
2986
- if (unconfirmed.status === "failed") {
2987
- const output = lifecycleOutput(unconfirmed.output);
2988
- threads.fail(threadId, {
2989
- usage: threadUsage(unconfirmed.usage),
2990
- result: output,
2991
- handoff: output ? { summary: output } : undefined,
2992
- message: unconfirmed.errorMessage ?? "Child process exit was not confirmed before cleanup",
2993
- code: unconfirmed.terminationReason,
2994
- });
2995
- } else {
2996
- threads.stop(threadId, { reason: unconfirmed.terminationReason ?? "process_exit_unconfirmed" });
2997
- }
2998
- }
2999
- emit();
3000
- }
3001
- } else if (!persistentSession) {
3002
- try {
3003
- await rm(sessionDirectory, { recursive: true, force: true });
3004
- } catch {
3005
- // Temporary child session cleanup is best effort after process termination.
3006
- }
3007
- }
3008
- if (allExited && runtime.sessionGeneration === sessionGeneration) threadResources.delete(threadId);
3009
- }
3010
- emit();
3011
- };
3012
-
3013
- const settleQueued = (reason: string): void => {
3014
- if (batchSessionGeneration !== sessionGeneration) {
3015
- for (const result of results) {
3016
- if (result.status === "queued") {
3017
- result.status = "cancelled";
3018
- result.terminationReason = "session_start";
3019
- }
3020
- }
3021
- return;
3022
- }
3023
- for (let index = 0; index < results.length; index += 1) {
3024
- const result = results[index]!;
3025
- if (result.status !== "queued") continue;
3026
- const thread = threads.inspect(threadRecords[index]!.id);
3027
- const alreadyStopped = thread?.state === "stopped";
3028
- result.status = signal?.aborted || alreadyStopped || reason === "chain_stopped" ? "cancelled" : "failed";
3029
- result.terminationReason = alreadyStopped
3030
- ? thread.stopReason ?? "interrupted"
3031
- : signal?.aborted ? "abort" : reason;
3032
- if (thread?.state === "queued" || thread?.state === "active") {
3033
- threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
3034
- }
3035
- saveResult(threadRecords[index]!.id, result);
3036
- }
3037
- };
3038
-
3039
- const finishBatch = async () => {
3040
- if (hasChain) {
3041
- let previous = "";
3042
- for (let index = 0; index < inputs.length; index += 1) {
3043
- const task = expandChainTask(inputs[index]!.task, previous, limits.taskCharacters);
3044
- if (task === undefined) {
3045
- failQueuedTask(index, "task_limit", `Expanded task exceeds ${limits.taskCharacters} characters`);
3046
- break;
3047
- }
3048
- await runAt(index, task);
3049
- if (results[index]!.status !== "complete") break;
3050
- previous = results[index]!.output;
3051
- }
3052
- settleQueued("chain_stopped");
3053
- } else if (hasParallel) {
3054
- try {
3055
- if (useSharedParallelPool) {
3056
- const indexes = inputs.map((_, index) => index);
3057
- await mapReadTasks(indexes, writerConcurrency, async (index) => runAt(index, inputs[index]!.task));
3058
- } else {
3059
- await mapReadTasks(readIndexes, limits.maxReadConcurrency, async ({ index }) => runAt(index, inputs[index]!.task));
3060
- for (const index of writerIndexes) await runAt(index, inputs[index]!.task);
3061
- }
3062
- } finally {
3063
- settleQueued("parallel_stopped");
3064
- }
3065
- } else {
3066
- await runAt(0, inputs[0]!.task);
3067
- }
3068
-
3069
- await waitForTerminalCleanup();
3070
- const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir, isResume ? resumeTarget?.id : undefined);
3071
- const currentResults = results.map(cloneResult);
3072
- const details: SubagentDetails = { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
3073
- const toolContent = buildToolContent(mode, details.results, limits.toolOutputBytes);
3074
- return {
3075
- content: [{
3076
- type: "text" as const,
3077
- text: executionNote
3078
- ? boundedText(`${executionNote}\n\n${toolContent}`, limits.toolOutputBytes, "\n\n[Spawn output truncated.]")
3079
- : toolContent,
3080
- }],
3081
- details,
3082
- usage: details.aggregateUsage,
3083
- };
3084
- };
3085
-
3086
- emit(`${mode}: ${results.length} queued`);
3087
- if (options.awaitSpawnCompletion === true) {
3088
- const foregroundBatch = finishBatch();
3089
- backgroundBatches.add(foregroundBatch);
3090
- try {
3091
- return await foregroundBatch;
3092
- } finally {
3093
- backgroundBatches.delete(foregroundBatch);
3094
- }
3095
- }
3096
-
3097
- const queuedBoard = detailsFor(parentId, mode, scope, discovery.projectAgentsDir, isResume ? resumeTarget?.id : undefined);
3098
- const queuedResults = results.map(cloneResult);
3099
- const queuedDetails: SubagentDetails = {
3100
- ...queuedBoard,
3101
- backgroundStarted: true,
3102
- executionNote,
3103
- results: queuedResults,
3104
- aggregateUsage: aggregateUsage(queuedResults),
3105
- };
3106
- const threadList = threadRecords.map((thread) => `${threadDisplayName(threadView(thread, threadMetadata), threadMetadata)} (${thread.id})`).join(", ");
3107
- const reportUndeliveredFollowUp = (outcome: "settled" | "failed"): void => {
3108
- if (!ctx.hasUI) return;
3109
- try {
3110
- ctx.ui.notify(`Subagent batch ${outcome}, but its follow-up could not be delivered. Use list or collect for the saved result.`, "warning");
3111
- } catch {
3112
- // The thread registry still retains the result when the UI is closing.
3113
- }
3114
- };
3115
- updatesOpen = false;
3116
- const backgroundBatch = finishBatch().then((completed) => {
3117
- if (batchSessionGeneration !== sessionGeneration
3118
- || threads.isDisposed
3119
- || signal?.aborted
3120
- || completed.details.results.some((result) => result.terminationReason === "abort")) return;
3121
- try {
3122
- pi.sendMessage({
3123
- customType: "killeros-subagent-settled",
3124
- content: `Subagent batch settled: ${threadList}\n\n${completed.content[0].text}`,
3125
- display: true,
3126
- }, { triggerTurn: true, deliverAs: "followUp" });
3127
- } catch {
3128
- reportUndeliveredFollowUp("settled");
3129
- }
3130
- }).catch((error) => {
3131
- if (batchSessionGeneration !== sessionGeneration || threads.isDisposed || signal?.aborted) return;
3132
- const message = error instanceof Error ? error.message : String(error);
3133
- try {
3134
- pi.sendMessage({
3135
- customType: "killeros-subagent-settled",
3136
- content: `Subagent batch failed: ${threadList}\n\n${message}`,
3137
- display: true,
3138
- }, { triggerTurn: true, deliverAs: "followUp" });
3139
- } catch {
3140
- reportUndeliveredFollowUp("failed");
3141
- }
3142
- }).finally(clearLiveWidget);
3143
- backgroundBatches.add(backgroundBatch);
3144
- void backgroundBatch.finally(() => backgroundBatches.delete(backgroundBatch));
3145
- return {
3146
- content: [{
3147
- type: "text",
3148
- text: boundedText(`${executionNote ? `${executionNote}\n\n` : ""}${isResume ? "Resumed" : "Started"} child threads: ${threadList}. They continue in the background. Live progress appears above the editor while they run; use list, inspect, wait, steer, interrupt, collect, resume, or close for current details.`, limits.toolOutputBytes, "\n\n[Spawn output truncated.]"),
3149
- }],
3150
- details: queuedDetails,
3151
- usage: queuedDetails.aggregateUsage,
3152
- };
3153
- },
3154
-
3155
- renderCall(args, theme) {
3156
- let renderArgs = args;
3157
- try {
3158
- renderArgs = prepareSubagentRequest(args, limits).input;
3159
- } catch {
3160
- // Strict rendering below displays malformed requests as invalid.
3161
- }
3162
- const parsed = tryNormalizeSubagentRequest(renderArgs, limits);
3163
- if (!parsed.ok) {
3164
- return new Text(`${theme.fg("toolTitle", theme.bold("subagent"))}${theme.fg("error", " · invalid request")}`, 0, 0);
3165
- }
3166
- const request = parsed.request;
3167
- if (!request.kind.startsWith("spawn-")) {
3168
- const threadId = "threadId" in request.input ? request.input.threadId : undefined;
3169
- return new Text(`${theme.fg("toolTitle", theme.bold("threads "))}${theme.fg("accent", request.input.action ?? "spawn")}${theme.fg("dim", threadId ? ` · ${threadId}` : "")}`, 0, 0);
3170
- }
3171
- const spawnRequest = request as Extract<NormalizedSubagentRequest, { kind: "spawn-single" | "spawn-parallel" | "spawn-chain" }>;
3172
- const scope = spawnRequest.input.agentScope ?? "user";
3173
- if (spawnRequest.kind === "spawn-parallel") {
3174
- const schedule = spawnRequest.input.writerConcurrency === undefined ? "parallel default" : `shared pool ${spawnRequest.input.writerConcurrency}`;
3175
- return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `parallel ${spawnRequest.input.tasks.length} · ${schedule}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
3176
- }
3177
- 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);
3178
- const agentName = spawnRequest.input.agent === undefined
3179
- ? "generic"
3180
- : typeof spawnRequest.input.agent === "string" ? spawnRequest.input.agent : spawnRequest.input.agent.name;
3181
- return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", agentName)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
3182
- },
3183
-
3184
- renderResult(result, { expanded }, theme) {
3185
- const details = result.details as SubagentDetails | undefined;
3186
- if (!details?.results.length) {
3187
- const first = result.content[0];
3188
- return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
3189
- }
3190
- if (details.backgroundStarted) {
3191
- const lines = [
3192
- theme.fg("toolTitle", theme.bold(`Started in background (${details.results.length})`)),
3193
- ...details.results.map((task) => `${theme.fg("toolTitle", theme.bold(task.name ?? task.agent))}${theme.fg("dim", ` · role ${task.agent} · ${task.id} · attempt ${task.attempt ?? 1}`)}`),
3194
- ];
3195
- if (details.executionNote) lines.push(theme.fg("dim", details.executionNote));
3196
- lines.push(theme.fg("dim", "Live status appears below while child threads run."));
3197
- return new Text(lines.join("\n"), 0, 0);
3198
- }
3199
- const board = formatThreadBoard({
3200
- title: `Subagents · ${details.mode}`,
3201
- threads: details.results.map(threadBoardRecord),
3202
- selectedThreadId: details.selectedThreadId,
3203
- });
3204
- if (!expanded) {
3205
- const lines = [
3206
- theme.fg("toolTitle", theme.bold(`Active (${board.active.length})`)),
3207
- ...board.active.map((task) => `${theme.fg("accent", "✻")} ${theme.fg("toolTitle", theme.bold(task.displayName ?? task.agent))}${theme.fg("dim", ` · role ${task.agent} · ${task.id} · attempt ${task.attempt ?? 1} · ${task.state.label} · ${task.usage.text}`)}`),
3208
- theme.fg("toolTitle", theme.bold(`Done (${board.done.length})`)),
3209
- ...board.done.map((task) => `${theme.fg(task.state.status === "complete" ? "success" : "warning", `${task.state.label}`)} ${theme.fg("toolTitle", theme.bold(task.displayName ?? task.agent))}${theme.fg("dim", ` · role ${task.agent} · ${task.id} · attempt ${task.attempt ?? 1} · ${task.usage.text}`)}`),
3210
- ];
3211
- if (details.executionNote) lines.push(theme.fg("dim", details.executionNote));
3212
- lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
3213
- return new Text(lines.join("\n"), 0, 0);
3214
- }
3215
-
3216
- const container = new Container();
3217
- container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
3218
- container.addChild(new Text(theme.fg("dim", `Active ${board.active.length} · Done ${board.done.length} · Controls: Inspect · Steer · Interrupt · Wait · Collect · Resume · Close`), 0, 0));
3219
- if (details.executionNote) container.addChild(new Text(theme.fg("dim", details.executionNote), 0, 0));
3220
- if (board.selected) {
3221
- const inspection = formatThreadInspection(threadBoardRecord(details.results.find((task) => task.id === board.selected!.id)!));
3222
- container.addChild(new Spacer(1));
3223
- container.addChild(new Text(theme.fg("accent", `Inspect ${inspection.displayName ?? inspection.agent} · ${inspection.state.label} · ${inspection.id} · ${inspection.usage.text}`), 0, 0));
3224
- for (const entry of inspection.trace.entries) container.addChild(new Text(`${theme.fg("muted", "→ ")}${theme.fg("toolOutput", entry)}`, 0, 0));
3225
- }
3226
- for (const task of details.results) {
3227
- container.addChild(new Spacer(1));
3228
- const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);
3229
- container.addChild(new Text(`${status} ${theme.fg("accent", task.name ?? task.agent)}${theme.fg("dim", ` · role ${task.agent} · ${task.id} · attempt ${task.attempt} · ${task.agentSource}`)}`, 0, 0));
3230
- 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));
3231
- container.addChild(new Text(theme.fg("muted", `Task: ${task.task}`), 0, 0));
3232
- for (const entry of task.trace) container.addChild(new Text(`${theme.fg("muted", "→ ")}${theme.fg("toolOutput", entry)}`, 0, 0));
3233
- if (task.traceTruncatedBytes || task.stderrTruncatedBytes || task.outputTruncatedBytes) {
3234
- container.addChild(new Text(theme.fg("warning", `Truncated · trace ${task.traceTruncatedBytes} B · stderr ${task.stderrTruncatedBytes} B · output ${task.outputTruncatedBytes} B`), 0, 0));
3235
- }
3236
- if (task.output) container.addChild(new Markdown(task.output, 0, 0, getMarkdownTheme()));
3237
- else if (task.errorMessage || task.stderr) container.addChild(new Text(theme.fg("error", task.errorMessage || task.stderr), 0, 0));
3238
- }
3239
- container.addChild(new Spacer(1));
3240
- container.addChild(new Text(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)}`), 0, 0));
3241
- return container;
3242
- },
3243
- };
3244
- pi.registerTool(toolDefinition);
3245
- return {
3246
- async execute(controlRequest: SubagentControlRequest, ctx: ExtensionContext): Promise<SubagentControlResult> {
3247
- const result = await toolDefinition.execute("subagent-control", controlRequest, undefined, undefined, ctx);
3248
- const first = result.content[0];
3249
- const details = result.details as SubagentDetails;
3250
- return {
3251
- text: first?.type === "text" ? first.text : "",
3252
- details,
3253
- usage: (result.usage as SubagentUsage | undefined) ?? details.aggregateUsage,
3254
- };
3255
- },
3256
- };
3257
- }