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