killeros 1.5.2 → 1.5.4

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