killeros 1.4.1 → 1.4.3
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 +20 -0
- package/README.md +27 -5
- package/agents/debugger.md +0 -1
- package/agents/documenter.md +0 -1
- package/agents/planner.md +0 -1
- package/agents/reviewer.md +0 -1
- package/agents/scout.md +0 -1
- package/agents/security.md +0 -1
- package/agents/tester.md +0 -1
- package/agents/worker.md +0 -1
- package/package.json +5 -1
- package/subagent-budget.ts +65 -0
- package/subagent-lifecycle.ts +506 -0
- package/subagent-process.ts +485 -0
- package/subagent-ui.ts +210 -0
- package/subagents.ts +649 -269
package/subagents.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
2
|
import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
4
3
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
4
|
import os from "node:os";
|
|
@@ -16,12 +15,14 @@ import {
|
|
|
16
15
|
} from "@earendil-works/pi-coding-agent";
|
|
17
16
|
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
18
17
|
import { Type } from "typebox";
|
|
18
|
+
import { CHILD_TOOL_BUDGET_ENV, type ChildToolBudget } from "./subagent-budget.ts";
|
|
19
|
+
import { SubagentThreadRegistry, type SubagentThread, type SubagentThreadId, type SubagentThreadState } from "./subagent-lifecycle.ts";
|
|
20
|
+
import { runSubagentProcess, type SubagentProcessHandle, type SubagentProcessResult } from "./subagent-process.ts";
|
|
21
|
+
import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadBoardRecord } from "./subagent-ui.ts";
|
|
19
22
|
|
|
20
23
|
export const SUBAGENT_LIMITS = {
|
|
21
24
|
maxTasks: 8,
|
|
22
25
|
maxReadConcurrency: 4,
|
|
23
|
-
defaultTurns: 8,
|
|
24
|
-
maxTurns: 12,
|
|
25
26
|
defaultTimeoutMs: 300_000,
|
|
26
27
|
maxTimeoutMs: 600_000,
|
|
27
28
|
jsonlLineBytes: 32 * 1024 * 1024,
|
|
@@ -29,6 +30,10 @@ export const SUBAGENT_LIMITS = {
|
|
|
29
30
|
stderrBytes: 64 * 1024,
|
|
30
31
|
taskOutputBytes: 50 * 1024,
|
|
31
32
|
toolOutputBytes: 50 * 1024,
|
|
33
|
+
quotaTokens: 250_000,
|
|
34
|
+
quotaUsd: 5,
|
|
35
|
+
readToolBudgetSoft: 24,
|
|
36
|
+
readToolBudgetHard: 32,
|
|
32
37
|
roleFileBytes: 64 * 1024,
|
|
33
38
|
taskCharacters: 20_000,
|
|
34
39
|
killGraceMs: 5_000,
|
|
@@ -39,8 +44,16 @@ const READ_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOLS]);
|
|
|
39
44
|
const WRITE_TOOLS = new Set(["bash", "edit", "write"]);
|
|
40
45
|
const KNOWN_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
|
|
41
46
|
const SUBAGENT_WEB_EXTENSION = "npm:pi-web-access";
|
|
47
|
+
const SUBAGENT_BUDGET_EXTENSION = fileURLToPath(new URL("./subagent-budget.ts", import.meta.url));
|
|
42
48
|
const INHERIT_SETTING = "inherit";
|
|
43
|
-
const
|
|
49
|
+
const MAX_RUNTIME_STEERING_MESSAGES = 20;
|
|
50
|
+
const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "thinking", "timeoutMs"]);
|
|
51
|
+
const CHILD_REPORT_PROTOCOL = [
|
|
52
|
+
"## Child report protocol",
|
|
53
|
+
"Work in bounded passes. After the first useful evidence, write a concise report with findings, exact files, checks run, and remaining work.",
|
|
54
|
+
"Do not keep opening files or searching after you have enough evidence to answer the task.",
|
|
55
|
+
"If a tool budget notice or blocked-tool message appears, stop research and report from the context you have. A partial report is better than no report.",
|
|
56
|
+
].join("\n");
|
|
44
57
|
|
|
45
58
|
type ThinkingLevel = ModelThinkingLevel;
|
|
46
59
|
export type AgentAccess = "read" | "write";
|
|
@@ -55,7 +68,6 @@ export interface AgentRole {
|
|
|
55
68
|
tools: string[];
|
|
56
69
|
model?: string;
|
|
57
70
|
thinking?: string;
|
|
58
|
-
maxTurns: number;
|
|
59
71
|
timeoutMs: number;
|
|
60
72
|
prompt: string;
|
|
61
73
|
source: AgentSource;
|
|
@@ -98,9 +110,12 @@ export interface SubagentTaskResult {
|
|
|
98
110
|
traceBytes: number;
|
|
99
111
|
traceTruncatedBytes: number;
|
|
100
112
|
stderr: string;
|
|
113
|
+
stderrBytes: number;
|
|
101
114
|
stderrTruncatedBytes: number;
|
|
102
115
|
output: string;
|
|
116
|
+
outputBytes: number;
|
|
103
117
|
outputTruncatedBytes: number;
|
|
118
|
+
toolCallCount: number;
|
|
104
119
|
usage: SubagentUsage;
|
|
105
120
|
durationMs: number;
|
|
106
121
|
exitCode: number | null;
|
|
@@ -115,6 +130,11 @@ export interface SubagentDetails {
|
|
|
115
130
|
projectAgentsDir: string | null;
|
|
116
131
|
results: SubagentTaskResult[];
|
|
117
132
|
aggregateUsage: SubagentUsage;
|
|
133
|
+
parentId?: string;
|
|
134
|
+
threads?: SubagentThread[];
|
|
135
|
+
activeThreads?: SubagentThread[];
|
|
136
|
+
doneThreads?: SubagentThread[];
|
|
137
|
+
selectedThreadId?: string;
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
interface ModelContext {
|
|
@@ -146,8 +166,7 @@ export interface SubagentRuntimeOptions {
|
|
|
146
166
|
bundledAgentsDir?: string;
|
|
147
167
|
userAgentsDir?: string;
|
|
148
168
|
webExtension?: string;
|
|
149
|
-
spawnProcess?: (args: string[], cwd: string) => SpawnedProcess;
|
|
150
|
-
createTaskId?: (index: number) => string;
|
|
169
|
+
spawnProcess?: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SpawnedProcess;
|
|
151
170
|
limits?: Partial<SubagentLimits>;
|
|
152
171
|
}
|
|
153
172
|
|
|
@@ -191,6 +210,14 @@ function aggregateUsage(results: SubagentTaskResult[]): SubagentUsage {
|
|
|
191
210
|
return total;
|
|
192
211
|
}
|
|
193
212
|
|
|
213
|
+
function boundedText(text: string, maxBytes: number, marker: string): string {
|
|
214
|
+
const capped = truncateUtf8(text, maxBytes);
|
|
215
|
+
if (!capped.omittedBytes) return text;
|
|
216
|
+
const markerBytes = Buffer.byteLength(marker, "utf8");
|
|
217
|
+
if (markerBytes >= maxBytes) return truncateUtf8(text, maxBytes).text;
|
|
218
|
+
return `${truncateUtf8(text, maxBytes - markerBytes).text}${marker}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
194
221
|
function readBoundedFile(filePath: string, maxBytes: number): string {
|
|
195
222
|
let descriptor: number | undefined;
|
|
196
223
|
try {
|
|
@@ -280,7 +307,6 @@ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentL
|
|
|
280
307
|
tools,
|
|
281
308
|
model: typeof modelValue === "string" ? modelValue.trim() : undefined,
|
|
282
309
|
thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
|
|
283
|
-
maxTurns: optionalPositiveInteger(frontmatter, filePath, "maxTurns", limits.defaultTurns, limits.maxTurns),
|
|
284
310
|
timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", limits.defaultTimeoutMs, limits.maxTimeoutMs),
|
|
285
311
|
prompt,
|
|
286
312
|
source,
|
|
@@ -452,31 +478,6 @@ function defaultSpawnProcess(args: string[], cwd: string): SpawnedProcess {
|
|
|
452
478
|
}) as unknown as SpawnedProcess;
|
|
453
479
|
}
|
|
454
480
|
|
|
455
|
-
function terminateProcess(child: SpawnedProcess, force: boolean): void {
|
|
456
|
-
if (process.platform === "win32" && force && child.pid) {
|
|
457
|
-
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
458
|
-
shell: false,
|
|
459
|
-
stdio: "ignore",
|
|
460
|
-
windowsHide: true,
|
|
461
|
-
});
|
|
462
|
-
killer.unref();
|
|
463
|
-
return;
|
|
464
|
-
}
|
|
465
|
-
if (process.platform !== "win32" && child.pid) {
|
|
466
|
-
try {
|
|
467
|
-
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
468
|
-
return;
|
|
469
|
-
} catch {
|
|
470
|
-
// Fall back to the direct child when process-group signaling is unavailable.
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
try {
|
|
474
|
-
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
475
|
-
} catch {
|
|
476
|
-
// The process may already have exited.
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
481
|
function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBytes: number } {
|
|
481
482
|
const bytes = Buffer.from(text, "utf8");
|
|
482
483
|
if (bytes.length <= maxBytes) return { text, omittedBytes: 0 };
|
|
@@ -485,34 +486,6 @@ function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBy
|
|
|
485
486
|
return { text: truncated, omittedBytes: bytes.length - Buffer.byteLength(truncated, "utf8") };
|
|
486
487
|
}
|
|
487
488
|
|
|
488
|
-
function textContent(message: any): string {
|
|
489
|
-
if (!Array.isArray(message?.content)) return "";
|
|
490
|
-
return message.content.filter((part: any) => part?.type === "text" && typeof part.text === "string").map((part: any) => part.text).join("\n");
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
function traceMessage(message: any): string[] {
|
|
494
|
-
if (!Array.isArray(message?.content)) return [];
|
|
495
|
-
const entries: string[] = [];
|
|
496
|
-
for (const part of message.content) {
|
|
497
|
-
if (part?.type !== "toolCall" || typeof part.name !== "string") continue;
|
|
498
|
-
const args = truncateUtf8(JSON.stringify(part.arguments ?? {}), 2_000).text;
|
|
499
|
-
entries.push(`${part.name} ${args}`);
|
|
500
|
-
}
|
|
501
|
-
return entries;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function appendTrace(result: SubagentTaskResult, entries: string[], maxBytes: number): void {
|
|
505
|
-
for (const entry of entries) {
|
|
506
|
-
const entryBytes = Buffer.byteLength(entry, "utf8");
|
|
507
|
-
const remaining = Math.max(0, maxBytes - result.traceBytes);
|
|
508
|
-
const retained = truncateUtf8(entry, remaining).text;
|
|
509
|
-
const retainedBytes = Buffer.byteLength(retained, "utf8");
|
|
510
|
-
if (retained) result.trace.push(retained);
|
|
511
|
-
result.traceBytes += retainedBytes;
|
|
512
|
-
result.traceTruncatedBytes += entryBytes - retainedBytes;
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
489
|
function makeQueuedResult(id: string, agent: string, task: string, step?: number): SubagentTaskResult {
|
|
517
490
|
return {
|
|
518
491
|
id,
|
|
@@ -525,9 +498,12 @@ function makeQueuedResult(id: string, agent: string, task: string, step?: number
|
|
|
525
498
|
traceBytes: 0,
|
|
526
499
|
traceTruncatedBytes: 0,
|
|
527
500
|
stderr: "",
|
|
501
|
+
stderrBytes: 0,
|
|
528
502
|
stderrTruncatedBytes: 0,
|
|
529
503
|
output: "",
|
|
504
|
+
outputBytes: 0,
|
|
530
505
|
outputTruncatedBytes: 0,
|
|
506
|
+
toolCallCount: 0,
|
|
531
507
|
usage: emptyUsage(),
|
|
532
508
|
durationMs: 0,
|
|
533
509
|
exitCode: null,
|
|
@@ -544,6 +520,36 @@ function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
|
|
|
544
520
|
};
|
|
545
521
|
}
|
|
546
522
|
|
|
523
|
+
function mergeTaskResults(previous: SubagentTaskResult | undefined, next: SubagentTaskResult, maxTraceBytes: number): SubagentTaskResult {
|
|
524
|
+
if (!previous) return cloneResult(next);
|
|
525
|
+
const merged = cloneResult(next);
|
|
526
|
+
const trace: string[] = [];
|
|
527
|
+
let traceBytes = 0;
|
|
528
|
+
let traceTruncatedBytes = previous.traceTruncatedBytes + next.traceTruncatedBytes;
|
|
529
|
+
for (const entry of [...previous.trace, ...next.trace]) {
|
|
530
|
+
const retained = truncateUtf8(entry, Math.max(0, maxTraceBytes - traceBytes));
|
|
531
|
+
if (retained.text) trace.push(retained.text);
|
|
532
|
+
const retainedBytes = Buffer.byteLength(retained.text, "utf8");
|
|
533
|
+
traceBytes += retainedBytes;
|
|
534
|
+
traceTruncatedBytes += retained.omittedBytes;
|
|
535
|
+
}
|
|
536
|
+
merged.trace = trace;
|
|
537
|
+
merged.traceBytes = traceBytes;
|
|
538
|
+
merged.traceTruncatedBytes = traceTruncatedBytes;
|
|
539
|
+
merged.stderr = [previous.stderr, next.stderr].filter(Boolean).join("\n");
|
|
540
|
+
merged.stderrBytes = previous.stderrBytes + next.stderrBytes;
|
|
541
|
+
merged.stderrTruncatedBytes = previous.stderrTruncatedBytes + next.stderrTruncatedBytes;
|
|
542
|
+
merged.output = next.output || previous.output;
|
|
543
|
+
merged.outputBytes = previous.outputBytes + next.outputBytes;
|
|
544
|
+
merged.outputTruncatedBytes = previous.outputTruncatedBytes + next.outputTruncatedBytes;
|
|
545
|
+
merged.toolCallCount = previous.toolCallCount + next.toolCallCount;
|
|
546
|
+
merged.usage = emptyUsage();
|
|
547
|
+
addUsage(merged.usage, previous.usage);
|
|
548
|
+
addUsage(merged.usage, next.usage);
|
|
549
|
+
merged.durationMs = previous.durationMs + next.durationMs;
|
|
550
|
+
return merged;
|
|
551
|
+
}
|
|
552
|
+
|
|
547
553
|
function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectAgentsDir: string | null, results: SubagentTaskResult[]): SubagentDetails {
|
|
548
554
|
const cloned = results.map(cloneResult);
|
|
549
555
|
return { mode, agentScope: scope, projectAgentsDir, results: cloned, aggregateUsage: aggregateUsage(cloned) };
|
|
@@ -552,7 +558,7 @@ function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectA
|
|
|
552
558
|
async function writeRolePrompt(agent: AgentRole): Promise<{ directory: string; filePath: string }> {
|
|
553
559
|
const directory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-"));
|
|
554
560
|
const filePath = path.join(directory, `${agent.name.replace(/[^A-Za-z0-9_.-]/gu, "_")}.md`);
|
|
555
|
-
await writeFile(filePath, agent.prompt
|
|
561
|
+
await writeFile(filePath, `${agent.prompt}\n\n${CHILD_REPORT_PROTOCOL}`, { encoding: "utf8", mode: 0o600 });
|
|
556
562
|
return { directory, filePath };
|
|
557
563
|
}
|
|
558
564
|
|
|
@@ -564,11 +570,40 @@ interface RunTaskOptions {
|
|
|
564
570
|
step?: number;
|
|
565
571
|
model: ResolvedModel;
|
|
566
572
|
signal?: AbortSignal;
|
|
567
|
-
spawnProcess: (args: string[], cwd: string) => SpawnedProcess;
|
|
573
|
+
spawnProcess: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SpawnedProcess;
|
|
568
574
|
webExtension?: string;
|
|
569
575
|
projectTrusted: boolean;
|
|
570
576
|
limits: SubagentLimits;
|
|
577
|
+
timeoutMs?: number;
|
|
571
578
|
onChange: (result: SubagentTaskResult) => void;
|
|
579
|
+
onHandle?: (handle: SubagentProcessHandle) => void;
|
|
580
|
+
toolBudget?: ChildToolBudget;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function applyProcessResult(
|
|
584
|
+
target: SubagentTaskResult,
|
|
585
|
+
source: Readonly<SubagentProcessResult>,
|
|
586
|
+
startedAt: number,
|
|
587
|
+
onChange: (result: SubagentTaskResult) => void,
|
|
588
|
+
): void {
|
|
589
|
+
target.status = source.status;
|
|
590
|
+
target.trace = [...source.trace];
|
|
591
|
+
target.traceBytes = source.traceBytes;
|
|
592
|
+
target.traceTruncatedBytes = source.traceTruncatedBytes;
|
|
593
|
+
target.stderr = source.stderr;
|
|
594
|
+
target.stderrBytes = source.stderrBytes;
|
|
595
|
+
target.stderrTruncatedBytes = source.stderrTruncatedBytes;
|
|
596
|
+
target.output = source.output;
|
|
597
|
+
target.outputBytes = source.outputBytes;
|
|
598
|
+
target.outputTruncatedBytes = source.outputTruncatedBytes;
|
|
599
|
+
target.toolCallCount = source.toolCallCount;
|
|
600
|
+
target.usage = { ...source.usage, cost: { ...source.usage.cost } };
|
|
601
|
+
target.model = source.model ?? target.model;
|
|
602
|
+
target.terminationReason = source.terminationReason;
|
|
603
|
+
target.errorMessage = source.errorMessage;
|
|
604
|
+
target.exitCode = source.exitCode;
|
|
605
|
+
target.durationMs = source.durationMs || Date.now() - startedAt;
|
|
606
|
+
onChange(target);
|
|
572
607
|
}
|
|
573
608
|
|
|
574
609
|
async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
@@ -593,7 +628,6 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
|
593
628
|
}
|
|
594
629
|
|
|
595
630
|
let promptDirectory: string | undefined;
|
|
596
|
-
let child: SpawnedProcess | undefined;
|
|
597
631
|
try {
|
|
598
632
|
const prompt = await writeRolePrompt(agent);
|
|
599
633
|
promptDirectory = prompt.directory;
|
|
@@ -610,6 +644,7 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
|
610
644
|
"--no-session",
|
|
611
645
|
"--no-extensions",
|
|
612
646
|
"--extension", options.webExtension ?? SUBAGENT_WEB_EXTENSION,
|
|
647
|
+
"--extension", SUBAGENT_BUDGET_EXTENSION,
|
|
613
648
|
"--no-prompt-templates",
|
|
614
649
|
options.projectTrusted ? "--approve" : "--no-approve",
|
|
615
650
|
"--model", options.model.model,
|
|
@@ -618,175 +653,29 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
|
618
653
|
"--append-system-prompt", prompt.filePath,
|
|
619
654
|
`Task: ${options.task}`,
|
|
620
655
|
];
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
if (closed) return;
|
|
641
|
-
terminateProcess(child!, true);
|
|
642
|
-
settleTimer = setTimeout(() => {
|
|
643
|
-
if (!closed) finish(null);
|
|
644
|
-
}, 1_000);
|
|
645
|
-
}, limits.killGraceMs);
|
|
646
|
-
};
|
|
647
|
-
|
|
648
|
-
const processLine = (line: string): void => {
|
|
649
|
-
if (!line.trim() || requestedStatus) return;
|
|
650
|
-
let event: any;
|
|
651
|
-
try {
|
|
652
|
-
event = JSON.parse(line);
|
|
653
|
-
} catch (error) {
|
|
654
|
-
malformedError = error instanceof Error ? error.message : String(error);
|
|
655
|
-
requestTermination("failed", "malformed_jsonl", `Malformed child JSONL: ${malformedError}`);
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
if (event?.type === "message_end" && event.message?.role === "assistant") {
|
|
659
|
-
const message = event.message;
|
|
660
|
-
result.usage.turns += 1;
|
|
661
|
-
addUsage(result.usage, { ...message.usage, turns: 0 });
|
|
662
|
-
appendTrace(result, traceMessage(message), limits.traceBytes);
|
|
663
|
-
const output = textContent(message);
|
|
664
|
-
if (output) {
|
|
665
|
-
const capped = truncateUtf8(output, limits.taskOutputBytes);
|
|
666
|
-
result.output = capped.text;
|
|
667
|
-
result.outputTruncatedBytes = capped.omittedBytes;
|
|
668
|
-
}
|
|
669
|
-
if (typeof message.model === "string") result.model = message.provider ? `${message.provider}/${message.model}` : message.model;
|
|
670
|
-
const stopReason = typeof message.stopReason === "string" ? message.stopReason : undefined;
|
|
671
|
-
if (stopReason === "stop" || stopReason === "toolUse") {
|
|
672
|
-
result.terminationReason = undefined;
|
|
673
|
-
result.errorMessage = undefined;
|
|
674
|
-
} else if (stopReason) {
|
|
675
|
-
result.terminationReason = stopReason;
|
|
676
|
-
}
|
|
677
|
-
if (typeof message.errorMessage === "string") result.errorMessage = message.errorMessage;
|
|
678
|
-
if (stopReason === "length") requestTermination("limited", "model_output_limit");
|
|
679
|
-
if (result.usage.turns > agent.maxTurns || result.usage.turns >= agent.maxTurns && stopReason === "toolUse") {
|
|
680
|
-
requestTermination("limited", "turn_limit");
|
|
681
|
-
}
|
|
682
|
-
options.onChange(result);
|
|
683
|
-
} else if (event?.type === "agent_end" && event.willRetry === true) {
|
|
684
|
-
if (result.usage.turns >= agent.maxTurns) {
|
|
685
|
-
requestTermination("limited", "turn_limit");
|
|
686
|
-
options.onChange(result);
|
|
687
|
-
}
|
|
688
|
-
} else if (event?.type === "tool_result_end" && event.message) {
|
|
689
|
-
const toolName = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
|
|
690
|
-
appendTrace(result, [`${toolName} result${event.message.isError ? " (error)" : ""}`], limits.traceBytes);
|
|
691
|
-
options.onChange(result);
|
|
692
|
-
}
|
|
693
|
-
};
|
|
694
|
-
|
|
695
|
-
const appendStdoutLine = (fragment: Buffer): boolean => {
|
|
696
|
-
const nextBytes = stdoutLineBytes + fragment.length;
|
|
697
|
-
if (nextBytes > limits.jsonlLineBytes) {
|
|
698
|
-
requestTermination("limited", "jsonl_line_limit", `Child JSONL line exceeds ${limits.jsonlLineBytes} bytes`);
|
|
699
|
-
return false;
|
|
700
|
-
}
|
|
701
|
-
if (nextBytes > stdoutLineBuffer.length) {
|
|
702
|
-
const nextCapacity = Math.min(limits.jsonlLineBytes, Math.max(nextBytes, stdoutLineBuffer.length * 2, 4_096));
|
|
703
|
-
const expanded = Buffer.allocUnsafe(nextCapacity);
|
|
704
|
-
stdoutLineBuffer.copy(expanded, 0, 0, stdoutLineBytes);
|
|
705
|
-
stdoutLineBuffer = expanded;
|
|
706
|
-
}
|
|
707
|
-
fragment.copy(stdoutLineBuffer, stdoutLineBytes);
|
|
708
|
-
stdoutLineBytes = nextBytes;
|
|
709
|
-
return true;
|
|
710
|
-
};
|
|
711
|
-
|
|
712
|
-
const processStdoutLine = (): void => {
|
|
713
|
-
const line = stdoutLineBuffer.toString("utf8", 0, stdoutLineBytes);
|
|
714
|
-
stdoutLineBuffer = Buffer.alloc(0);
|
|
715
|
-
stdoutLineBytes = 0;
|
|
716
|
-
processLine(line);
|
|
717
|
-
};
|
|
718
|
-
|
|
719
|
-
let finish!: (code: number | null) => void;
|
|
720
|
-
const closedPromise = new Promise<void>((resolve) => {
|
|
721
|
-
finish = (code: number | null): void => {
|
|
722
|
-
if (closed) return;
|
|
723
|
-
closed = true;
|
|
724
|
-
if (forceTimer) clearTimeout(forceTimer);
|
|
725
|
-
if (settleTimer) clearTimeout(settleTimer);
|
|
726
|
-
if (stdoutLineBytes > 0 && !requestedStatus) processStdoutLine();
|
|
727
|
-
result.exitCode = code;
|
|
728
|
-
if (requestedStatus) {
|
|
729
|
-
result.status = requestedStatus;
|
|
730
|
-
result.terminationReason = requestedReason;
|
|
731
|
-
} else if (result.terminationReason === "length") {
|
|
732
|
-
result.status = "limited";
|
|
733
|
-
result.terminationReason = "model_output_limit";
|
|
734
|
-
} else if (code !== 0 || result.errorMessage || result.terminationReason) {
|
|
735
|
-
result.status = "failed";
|
|
736
|
-
result.terminationReason ??= code === null ? "process_closed" : `exit_${code}`;
|
|
737
|
-
} else if (result.usage.turns === 0) {
|
|
738
|
-
result.status = "failed";
|
|
739
|
-
result.terminationReason = "missing_assistant_message";
|
|
740
|
-
result.errorMessage = "Child exited without an assistant response";
|
|
741
|
-
} else {
|
|
742
|
-
result.status = "complete";
|
|
743
|
-
result.terminationReason = "completed";
|
|
744
|
-
}
|
|
745
|
-
result.durationMs = Date.now() - startedAt;
|
|
746
|
-
options.onChange(result);
|
|
747
|
-
resolve();
|
|
748
|
-
};
|
|
749
|
-
});
|
|
750
|
-
|
|
751
|
-
child.stdout.on("data", (chunk: Buffer | string) => {
|
|
752
|
-
if (requestedStatus) return;
|
|
753
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
754
|
-
let offset = 0;
|
|
755
|
-
while (offset < buffer.length && !requestedStatus) {
|
|
756
|
-
const newline = buffer.indexOf(0x0a, offset);
|
|
757
|
-
const end = newline < 0 ? buffer.length : newline;
|
|
758
|
-
if (!appendStdoutLine(buffer.subarray(offset, end))) return;
|
|
759
|
-
if (newline < 0) return;
|
|
760
|
-
processStdoutLine();
|
|
761
|
-
offset = newline + 1;
|
|
762
|
-
}
|
|
656
|
+
const handle = runSubagentProcess({
|
|
657
|
+
args,
|
|
658
|
+
cwd: options.cwd,
|
|
659
|
+
signal: options.signal,
|
|
660
|
+
spawnProcess: options.spawnProcess,
|
|
661
|
+
limits: {
|
|
662
|
+
wallTimeMs: options.timeoutMs ?? agent.timeoutMs,
|
|
663
|
+
jsonlLineBytes: limits.jsonlLineBytes,
|
|
664
|
+
traceBytes: limits.traceBytes,
|
|
665
|
+
stderrBytes: limits.stderrBytes,
|
|
666
|
+
outputBytes: limits.taskOutputBytes,
|
|
667
|
+
quotaTokens: limits.quotaTokens,
|
|
668
|
+
quotaUsd: limits.quotaUsd,
|
|
669
|
+
killGraceMs: limits.killGraceMs,
|
|
670
|
+
},
|
|
671
|
+
environment: options.toolBudget ? {
|
|
672
|
+
[CHILD_TOOL_BUDGET_ENV]: JSON.stringify(options.toolBudget),
|
|
673
|
+
} : undefined,
|
|
674
|
+
onUpdate: (next) => applyProcessResult(result, next, startedAt, options.onChange),
|
|
763
675
|
});
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
const remaining = Math.max(0, limits.stderrBytes - rawStderrBytes);
|
|
768
|
-
rawStderrBytes += buffer.length;
|
|
769
|
-
if (remaining > 0) result.stderr += buffer.subarray(0, remaining).toString("utf8");
|
|
770
|
-
result.stderrTruncatedBytes = Math.max(0, rawStderrBytes - limits.stderrBytes);
|
|
771
|
-
if (rawStderrBytes > limits.stderrBytes) requestTermination("limited", "stderr_limit");
|
|
772
|
-
});
|
|
773
|
-
child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
|
|
774
|
-
child.once("close", finish);
|
|
775
|
-
|
|
776
|
-
const timeoutTimer = setTimeout(() => requestTermination("limited", "timeout"), agent.timeoutMs);
|
|
777
|
-
const abortHandler = (): void => requestTermination("cancelled", "abort");
|
|
778
|
-
if (options.signal?.aborted) abortHandler();
|
|
779
|
-
else options.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
780
|
-
|
|
781
|
-
try {
|
|
782
|
-
await closedPromise;
|
|
783
|
-
} finally {
|
|
784
|
-
clearTimeout(timeoutTimer);
|
|
785
|
-
options.signal?.removeEventListener("abort", abortHandler);
|
|
786
|
-
}
|
|
787
|
-
if (!result.output && result.stderr && (result.status as SubagentStatus) !== "complete") {
|
|
788
|
-
result.errorMessage ??= result.stderr.trim();
|
|
789
|
-
}
|
|
676
|
+
options.onHandle?.(handle);
|
|
677
|
+
const final = await handle.result;
|
|
678
|
+
applyProcessResult(result, final, startedAt, options.onChange);
|
|
790
679
|
return result;
|
|
791
680
|
} catch (error) {
|
|
792
681
|
result.status = options.signal?.aborted ? "cancelled" : "failed";
|
|
@@ -830,6 +719,13 @@ const ChainTaskSchema = Type.Object({
|
|
|
830
719
|
});
|
|
831
720
|
|
|
832
721
|
const SubagentParams = Type.Object({
|
|
722
|
+
action: Type.Optional(StringEnum(["spawn", "list", "inspect", "steer", "interrupt", "collect", "close"] as const, {
|
|
723
|
+
default: "spawn",
|
|
724
|
+
description: "Thread lifecycle action",
|
|
725
|
+
})),
|
|
726
|
+
threadId: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Stable child thread ID" })),
|
|
727
|
+
message: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000, description: "Bounded steering message" })),
|
|
728
|
+
all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
|
|
833
729
|
agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
|
|
834
730
|
task: Type.Optional(Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task for single mode" })),
|
|
835
731
|
tasks: Type.Optional(Type.Array(TaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Parallel role tasks" })),
|
|
@@ -851,16 +747,30 @@ function requestedAgents(params: { agent?: string; tasks?: TaskInput[]; chain?:
|
|
|
851
747
|
return (params.tasks ?? params.chain ?? []).map((task) => task.agent);
|
|
852
748
|
}
|
|
853
749
|
|
|
750
|
+
function clipCharacters(text: string, maxCharacters: number, fromEnd = false): string {
|
|
751
|
+
const characters = [...text];
|
|
752
|
+
if (characters.length <= maxCharacters) return text;
|
|
753
|
+
return (fromEnd ? characters.slice(-maxCharacters) : characters.slice(0, maxCharacters)).join("");
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function buildSteeredTask(task: string, steering: readonly string[], previousOutput: string | undefined, maxCharacters: number): string {
|
|
757
|
+
const steeringLabel = "\n\nParent steering:\n";
|
|
758
|
+
const steeringText = clipCharacters(steering.join("\n"), Math.max(0, maxCharacters - [...steeringLabel].length), true);
|
|
759
|
+
const previousLabel = previousOutput ? "\n\nPrevious child handoff:\n" : "";
|
|
760
|
+
const required = [...steeringLabel, ...steeringText, ...previousLabel].length;
|
|
761
|
+
const taskText = clipCharacters(task, Math.max(0, maxCharacters - required));
|
|
762
|
+
const previousText = previousOutput
|
|
763
|
+
? clipCharacters(previousOutput, Math.max(0, maxCharacters - [...taskText, ...steeringLabel, ...steeringText, ...previousLabel].length))
|
|
764
|
+
: "";
|
|
765
|
+
return `${taskText}${previousText ? `${previousLabel}${previousText}` : ""}${steeringLabel}${steeringText}`;
|
|
766
|
+
}
|
|
767
|
+
|
|
854
768
|
function formatUsage(usage: SubagentUsage): string {
|
|
855
769
|
const parts = [`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`, `${usage.totalTokens} tokens`];
|
|
856
770
|
if (usage.cost.total) parts.push(`$${usage.cost.total.toFixed(4)}`);
|
|
857
771
|
return parts.join(" · ");
|
|
858
772
|
}
|
|
859
773
|
|
|
860
|
-
function taskSummary(result: SubagentTaskResult): string {
|
|
861
|
-
return `${result.id} · ${result.agent} · ${result.status} · ${formatUsage(result.usage)}`;
|
|
862
|
-
}
|
|
863
|
-
|
|
864
774
|
function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskResult[], maxBytes: number): string {
|
|
865
775
|
const sections = results.map((result) => {
|
|
866
776
|
const heading = `### ${result.id} · ${result.agent} · ${result.status}`;
|
|
@@ -872,8 +782,7 @@ function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskRe
|
|
|
872
782
|
const complete = results.filter((result) => result.status === "complete").length;
|
|
873
783
|
const text = `${mode}: ${complete}/${results.length} complete · ${formatUsage(aggregateUsage(results))}\n\n${sections.join("\n\n---\n\n")}`;
|
|
874
784
|
const marker = "\n\n[Combined subagent output truncated to 50 KiB; inspect the expanded tool result for bounded per-task details.]";
|
|
875
|
-
|
|
876
|
-
return capped.omittedBytes ? `${capped.text}${marker}` : text;
|
|
785
|
+
return boundedText(text, maxBytes, marker);
|
|
877
786
|
}
|
|
878
787
|
|
|
879
788
|
function statusColor(status: SubagentStatus): "accent" | "success" | "error" | "warning" | "muted" {
|
|
@@ -892,25 +801,322 @@ function statusIcon(status: SubagentStatus): string {
|
|
|
892
801
|
return "!";
|
|
893
802
|
}
|
|
894
803
|
|
|
804
|
+
interface ActiveThreadRuntime {
|
|
805
|
+
controller: AbortController;
|
|
806
|
+
handle?: SubagentProcessHandle;
|
|
807
|
+
steering: string[];
|
|
808
|
+
restarting: boolean;
|
|
809
|
+
traceCount: number;
|
|
810
|
+
startedAt: number;
|
|
811
|
+
aggregate?: SubagentTaskResult;
|
|
812
|
+
requestedReason?: string;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function parentThreadId(ctx: ExtensionContext): string {
|
|
816
|
+
try {
|
|
817
|
+
const id = ctx.sessionManager?.getSessionId?.();
|
|
818
|
+
if (id) return `main:${id}`;
|
|
819
|
+
} catch {
|
|
820
|
+
// Test and RPC contexts may not expose a session manager.
|
|
821
|
+
}
|
|
822
|
+
return "main";
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function threadCapabilityBoundary(agent: AgentRole): {
|
|
826
|
+
filesystem: "read" | "write";
|
|
827
|
+
network: "none" | "read";
|
|
828
|
+
process: "none" | "limited";
|
|
829
|
+
childThreads: false;
|
|
830
|
+
} {
|
|
831
|
+
return {
|
|
832
|
+
filesystem: agent.access,
|
|
833
|
+
network: agent.tools.some((tool) => WEB_TOOLS.has(tool)) ? "read" : "none",
|
|
834
|
+
process: agent.tools.includes("bash") ? "limited" : "none",
|
|
835
|
+
childThreads: false,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function threadUsage(usage: SubagentUsage): SubagentThread["usage"] {
|
|
840
|
+
return {
|
|
841
|
+
inputTokens: usage.input,
|
|
842
|
+
outputTokens: usage.output,
|
|
843
|
+
cacheReadTokens: usage.cacheRead,
|
|
844
|
+
cacheWriteTokens: usage.cacheWrite,
|
|
845
|
+
totalTokens: usage.totalTokens,
|
|
846
|
+
costUsd: usage.cost.total,
|
|
847
|
+
turns: usage.turns,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function legacyStatus(state: SubagentThreadState): SubagentStatus {
|
|
852
|
+
if (state === "active") return "running";
|
|
853
|
+
if (state === "done" || state === "closed") return "complete";
|
|
854
|
+
if (state === "failed") return "failed";
|
|
855
|
+
if (state === "stopped") return "cancelled";
|
|
856
|
+
return "queued";
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function threadResult(thread: SubagentThread, source?: SubagentTaskResult): SubagentTaskResult {
|
|
860
|
+
if (source) {
|
|
861
|
+
const result = cloneResult(source);
|
|
862
|
+
if (thread.state === "queued") result.status = "queued";
|
|
863
|
+
else if (thread.state === "active") result.status = "running";
|
|
864
|
+
return result;
|
|
865
|
+
}
|
|
866
|
+
return {
|
|
867
|
+
...makeQueuedResult(thread.id, thread.role, thread.prompt),
|
|
868
|
+
status: legacyStatus(thread.state),
|
|
869
|
+
agentSource: "unknown",
|
|
870
|
+
model: thread.model,
|
|
871
|
+
tools: [...thread.tools],
|
|
872
|
+
trace: thread.trace.map((event) => event.message ?? event.kind),
|
|
873
|
+
usage: {
|
|
874
|
+
input: thread.usage.inputTokens,
|
|
875
|
+
output: thread.usage.outputTokens,
|
|
876
|
+
cacheRead: thread.usage.cacheReadTokens,
|
|
877
|
+
cacheWrite: thread.usage.cacheWriteTokens,
|
|
878
|
+
totalTokens: thread.usage.totalTokens,
|
|
879
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: thread.usage.costUsd },
|
|
880
|
+
turns: thread.usage.turns,
|
|
881
|
+
},
|
|
882
|
+
output: thread.result ?? "",
|
|
883
|
+
toolCallCount: 0,
|
|
884
|
+
terminationReason: thread.stopReason,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function threadBoardRecord(result: SubagentTaskResult): ThreadBoardRecord {
|
|
889
|
+
return {
|
|
890
|
+
id: result.id,
|
|
891
|
+
agent: result.agent,
|
|
892
|
+
task: result.task,
|
|
893
|
+
status: result.status,
|
|
894
|
+
usage: {
|
|
895
|
+
input: result.usage.input,
|
|
896
|
+
output: result.usage.output,
|
|
897
|
+
cacheRead: result.usage.cacheRead,
|
|
898
|
+
cacheWrite: result.usage.cacheWrite,
|
|
899
|
+
totalTokens: result.usage.totalTokens,
|
|
900
|
+
turns: result.usage.turns,
|
|
901
|
+
cost: result.usage.cost.total,
|
|
902
|
+
},
|
|
903
|
+
trace: result.trace,
|
|
904
|
+
traceTruncatedBytes: result.traceTruncatedBytes,
|
|
905
|
+
handoff: result.output,
|
|
906
|
+
output: result.output,
|
|
907
|
+
terminationReason: result.terminationReason,
|
|
908
|
+
errorMessage: result.errorMessage,
|
|
909
|
+
durationMs: result.durationMs,
|
|
910
|
+
step: result.step,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
895
914
|
export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}): void {
|
|
896
915
|
const limits = { ...SUBAGENT_LIMITS, ...options.limits };
|
|
897
916
|
const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
|
|
917
|
+
const threads = new SubagentThreadRegistry();
|
|
918
|
+
const activeRuntimes = new Map<string, ActiveThreadRuntime>();
|
|
919
|
+
const savedResults = new Map<string, SubagentTaskResult>();
|
|
920
|
+
|
|
921
|
+
const detailsFor = (
|
|
922
|
+
parentId: string,
|
|
923
|
+
mode: SubagentDetails["mode"] = "single",
|
|
924
|
+
scope: AgentScope = "user",
|
|
925
|
+
projectAgentsDir: string | null = null,
|
|
926
|
+
selectedThreadId?: string,
|
|
927
|
+
): SubagentDetails => {
|
|
928
|
+
const all = threads.listAll().filter((thread) => thread.parentId === parentId);
|
|
929
|
+
const visible = all.filter((thread) => thread.state !== "closed");
|
|
930
|
+
const results = visible.map((thread) => threadResult(thread, savedResults.get(thread.id)));
|
|
931
|
+
return {
|
|
932
|
+
...cloneDetails(mode, scope, projectAgentsDir, results),
|
|
933
|
+
parentId,
|
|
934
|
+
threads: all,
|
|
935
|
+
activeThreads: all.filter((thread) => thread.state === "active"),
|
|
936
|
+
doneThreads: all.filter((thread) => ["done", "failed", "stopped"].includes(thread.state)),
|
|
937
|
+
selectedThreadId,
|
|
938
|
+
};
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
const threadBoardText = (parentId: string, selectedThreadId?: string): string => {
|
|
942
|
+
const details = detailsFor(parentId, "single", "user", null, selectedThreadId);
|
|
943
|
+
const active = details.activeThreads ?? [];
|
|
944
|
+
const done = details.doneThreads ?? [];
|
|
945
|
+
const row = (thread: SubagentThread): string => `- ${thread.id} · ${thread.role} · ${thread.state} · ${thread.prompt}`;
|
|
946
|
+
const lines = [
|
|
947
|
+
`parent ${parentId}`,
|
|
948
|
+
`Active (${active.length})`,
|
|
949
|
+
...(active.length ? active.map(row) : ["- none"]),
|
|
950
|
+
`Done (${done.length})`,
|
|
951
|
+
...(done.length ? done.map(row) : ["- none"]),
|
|
952
|
+
"Controls: inspect · steer · interrupt · collect · close",
|
|
953
|
+
];
|
|
954
|
+
if (selectedThreadId) {
|
|
955
|
+
const selected = details.threads?.find((thread) => thread.id === selectedThreadId);
|
|
956
|
+
if (selected) {
|
|
957
|
+
lines.push(`Inspect ${selected.id}: ${selected.state}`);
|
|
958
|
+
lines.push(`Role: ${selected.role}`);
|
|
959
|
+
lines.push(`Model: ${selected.model}`);
|
|
960
|
+
lines.push(`Tools: ${selected.tools.join(", ")}`);
|
|
961
|
+
lines.push(`Trace: ${selected.trace.length} entries`);
|
|
962
|
+
if (selected.result) lines.push(`Handoff: ${selected.result}`);
|
|
963
|
+
if (selected.stopReason) lines.push(`Reason: ${selected.stopReason}`);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
return boundedText(lines.join("\n"), limits.toolOutputBytes, "\n\n[Thread board truncated; inspect a child thread for its bounded detail.]");
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
const syncThread = (threadId: SubagentThreadId, next: SubagentTaskResult, runtime?: ActiveThreadRuntime): SubagentTaskResult => {
|
|
970
|
+
const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceBytes);
|
|
971
|
+
if (runtime?.requestedReason && next.status === "cancelled") effective.terminationReason = runtime.requestedReason;
|
|
972
|
+
savedResults.set(threadId, cloneResult(effective));
|
|
973
|
+
let thread = threads.inspect(threadId);
|
|
974
|
+
if (!thread || threads.isDisposed) return effective;
|
|
975
|
+
if (thread.state === "queued" && next.status === "running") {
|
|
976
|
+
thread = threads.begin(threadId);
|
|
977
|
+
}
|
|
978
|
+
if (thread.state !== "active") return effective;
|
|
979
|
+
if (runtime && next.trace.length < runtime.traceCount) runtime.traceCount = 0;
|
|
980
|
+
const from = runtime?.traceCount ?? 0;
|
|
981
|
+
let retainedTraceBytes = thread.trace.reduce((total, entry) => total + Buffer.byteLength(entry.message ?? "", "utf8"), 0);
|
|
982
|
+
for (const entry of next.trace.slice(from)) {
|
|
983
|
+
const retained = truncateUtf8(entry, Math.max(0, limits.traceBytes - retainedTraceBytes));
|
|
984
|
+
if (retained.text) {
|
|
985
|
+
threads.trace(threadId, { kind: "child", message: retained.text });
|
|
986
|
+
retainedTraceBytes += Buffer.byteLength(retained.text, "utf8");
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
if (runtime) runtime.traceCount = next.trace.length;
|
|
990
|
+
const handoff = effective.output ? { summary: effective.output } : undefined;
|
|
991
|
+
thread = threads.patch(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
|
|
992
|
+
const restartPending = runtime?.restarting === true && ["cancelled", "complete"].includes(next.status);
|
|
993
|
+
if (restartPending) return effective;
|
|
994
|
+
if (effective.status === "complete") {
|
|
995
|
+
threads.complete(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
|
|
996
|
+
} else if (effective.status === "failed") {
|
|
997
|
+
threads.fail(threadId, {
|
|
998
|
+
usage: threadUsage(effective.usage),
|
|
999
|
+
result: effective.output || undefined,
|
|
1000
|
+
handoff,
|
|
1001
|
+
message: effective.errorMessage ?? effective.terminationReason ?? "child failed",
|
|
1002
|
+
code: effective.terminationReason,
|
|
1003
|
+
});
|
|
1004
|
+
} else if (effective.status === "cancelled" || effective.status === "limited") {
|
|
1005
|
+
threads.stop(threadId, {
|
|
1006
|
+
usage: threadUsage(effective.usage),
|
|
1007
|
+
result: effective.output || undefined,
|
|
1008
|
+
handoff,
|
|
1009
|
+
reason: effective.terminationReason ?? (effective.status === "limited" ? "resource_limit" : "interrupted"),
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
return effective;
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
if (typeof pi.on === "function") {
|
|
1016
|
+
pi.on("session_shutdown", () => {
|
|
1017
|
+
for (const runtime of activeRuntimes.values()) {
|
|
1018
|
+
runtime.restarting = false;
|
|
1019
|
+
runtime.requestedReason = "session_shutdown";
|
|
1020
|
+
runtime.handle?.stop("session_shutdown");
|
|
1021
|
+
runtime.controller.abort();
|
|
1022
|
+
}
|
|
1023
|
+
threads.dispose();
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
898
1026
|
|
|
899
1027
|
pi.registerTool({
|
|
900
1028
|
name: "subagent",
|
|
901
1029
|
label: "Subagents",
|
|
902
|
-
description: "
|
|
1030
|
+
description: "Spawn and manage named child threads. Children finish naturally; time, output, trace, stderr, quota, task count, and concurrency are the hard edges. Use action list, inspect, steer, interrupt, collect, and close to manage active and completed handoffs.",
|
|
903
1031
|
promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
|
|
904
1032
|
promptGuidelines: [
|
|
905
1033
|
"Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
|
|
906
1034
|
"Do not request multiple write-capable subagents in one parallel batch.",
|
|
907
1035
|
"Every child can load relevant skills with read and can use web_search, source_check, fetch_content, and get_search_content for external research.",
|
|
908
1036
|
"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.",
|
|
1037
|
+
"Keep completed and stopped threads inspectable until the parent explicitly closes them.",
|
|
909
1038
|
],
|
|
910
1039
|
parameters: SubagentParams,
|
|
911
|
-
executionMode: "
|
|
1040
|
+
executionMode: "parallel",
|
|
912
1041
|
|
|
913
1042
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1043
|
+
const action = params.action ?? "spawn";
|
|
1044
|
+
const parentId = parentThreadId(ctx);
|
|
1045
|
+
const actionDetails = (selectedThreadId?: string): SubagentDetails => detailsFor(parentId, "single", params.agentScope ?? "user", null, selectedThreadId);
|
|
1046
|
+
const actionResult = (text: string, selectedThreadId?: string) => {
|
|
1047
|
+
const details = actionDetails(selectedThreadId);
|
|
1048
|
+
return {
|
|
1049
|
+
content: [{ type: "text" as const, text: boundedText(text, limits.toolOutputBytes, "\n\n[Thread action output truncated.]") }],
|
|
1050
|
+
details,
|
|
1051
|
+
usage: details.aggregateUsage,
|
|
1052
|
+
};
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
if (action === "list") return actionResult(threadBoardText(parentId));
|
|
1056
|
+
if (action === "inspect") {
|
|
1057
|
+
if (!params.threadId) throw new Error("inspect requires threadId");
|
|
1058
|
+
const thread = threads.inspect(params.threadId as SubagentThreadId);
|
|
1059
|
+
if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
|
|
1060
|
+
return actionResult(threadBoardText(parentId, params.threadId), params.threadId);
|
|
1061
|
+
}
|
|
1062
|
+
if (action === "steer") {
|
|
1063
|
+
if (!params.threadId || !params.message) throw new Error("steer requires threadId and message");
|
|
1064
|
+
const threadId = params.threadId as SubagentThreadId;
|
|
1065
|
+
const thread = threads.inspect(threadId);
|
|
1066
|
+
if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
|
|
1067
|
+
threads.steer(threadId, params.message);
|
|
1068
|
+
const runtime = activeRuntimes.get(params.threadId);
|
|
1069
|
+
if (runtime) {
|
|
1070
|
+
runtime.steering.push(params.message);
|
|
1071
|
+
if (runtime.steering.length > MAX_RUNTIME_STEERING_MESSAGES) runtime.steering.splice(0, runtime.steering.length - MAX_RUNTIME_STEERING_MESSAGES);
|
|
1072
|
+
runtime.restarting = true;
|
|
1073
|
+
runtime.requestedReason = "steer";
|
|
1074
|
+
runtime.handle?.stop("steer");
|
|
1075
|
+
}
|
|
1076
|
+
return actionResult(`Steering queued for ${params.threadId}. The child keeps the same thread and handoff record.`, params.threadId);
|
|
1077
|
+
}
|
|
1078
|
+
if (action === "interrupt") {
|
|
1079
|
+
if (!params.all && !params.threadId) throw new Error("interrupt requires threadId or all=true");
|
|
1080
|
+
let targets: SubagentThread[];
|
|
1081
|
+
if (params.all) {
|
|
1082
|
+
targets = threads.listActive().filter((thread) => thread.parentId === parentId);
|
|
1083
|
+
} else {
|
|
1084
|
+
const target = threads.inspect(params.threadId as SubagentThreadId);
|
|
1085
|
+
if (!target || target.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
|
|
1086
|
+
if (target.state !== "active" && target.state !== "queued") {
|
|
1087
|
+
throw new Error(`Cannot interrupt thread ${params.threadId} from ${target.state}`);
|
|
1088
|
+
}
|
|
1089
|
+
targets = [target];
|
|
1090
|
+
}
|
|
1091
|
+
for (const thread of targets) {
|
|
1092
|
+
if (thread.parentId !== parentId) continue;
|
|
1093
|
+
const runtime = activeRuntimes.get(thread.id);
|
|
1094
|
+
if (runtime) {
|
|
1095
|
+
runtime.restarting = false;
|
|
1096
|
+
runtime.requestedReason = "interrupt";
|
|
1097
|
+
runtime.handle?.stop("interrupt");
|
|
1098
|
+
runtime.controller.abort();
|
|
1099
|
+
} else if (thread.state === "active" || thread.state === "queued") {
|
|
1100
|
+
threads.stop(thread.id, { reason: "interrupt" });
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return actionResult(params.all ? "Interrupt requested for all active child threads." : `Interrupt requested for ${params.threadId}.`);
|
|
1104
|
+
}
|
|
1105
|
+
if (action === "collect") {
|
|
1106
|
+
if (!params.threadId) throw new Error("collect requires threadId");
|
|
1107
|
+
const thread = threads.inspect(params.threadId as SubagentThreadId);
|
|
1108
|
+
if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
|
|
1109
|
+
const collected = threads.collect(params.threadId as SubagentThreadId);
|
|
1110
|
+
return actionResult(`Collected ${params.threadId}: ${collected.result ?? collected.failure?.message ?? collected.stopReason ?? "no handoff"}`, params.threadId);
|
|
1111
|
+
}
|
|
1112
|
+
if (action === "close") {
|
|
1113
|
+
if (!params.threadId) throw new Error("close requires threadId");
|
|
1114
|
+
const thread = threads.inspect(params.threadId as SubagentThreadId);
|
|
1115
|
+
if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
|
|
1116
|
+
threads.close(params.threadId as SubagentThreadId);
|
|
1117
|
+
return actionResult(`Closed ${params.threadId}. Its result record remains inspectable.`, params.threadId);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
914
1120
|
const scope: AgentScope = params.agentScope ?? "user";
|
|
915
1121
|
const hasSingleFields = params.agent !== undefined || params.task !== undefined;
|
|
916
1122
|
const hasParallel = params.tasks !== undefined;
|
|
@@ -958,22 +1164,56 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
958
1164
|
if (writers.length > 1) throw new Error("Parallel batches may contain at most one write-capable subagent; writers are serialized");
|
|
959
1165
|
}
|
|
960
1166
|
|
|
961
|
-
const
|
|
962
|
-
|
|
963
|
-
|
|
1167
|
+
const inFlight = threads.listAll().filter((thread) => ["queued", "active"].includes(thread.state)).length;
|
|
1168
|
+
if (inFlight + inputs.length > limits.maxTasks) {
|
|
1169
|
+
throw new Error(`At most ${limits.maxTasks} child threads may be active at once`);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const threadRecords = inputs.map((input, index) => threads.spawn({
|
|
1173
|
+
parentId: parentId as SubagentThreadId,
|
|
1174
|
+
role: input.agent,
|
|
1175
|
+
prompt: input.task,
|
|
1176
|
+
model: resolvedModels.get(input.agent)!.model,
|
|
1177
|
+
tools: roles.get(input.agent)!.tools,
|
|
1178
|
+
capabilityBoundary: threadCapabilityBoundary(roles.get(input.agent)!),
|
|
1179
|
+
}));
|
|
1180
|
+
const results = threadRecords.map((thread, index) => {
|
|
1181
|
+
return makeQueuedResult(thread.id, inputs[index]!.agent, inputs[index]!.task, hasChain ? index + 1 : undefined);
|
|
1182
|
+
});
|
|
964
1183
|
const emit = (message = `${mode}: ${results.filter((result) => !["queued", "running"].includes(result.status)).length}/${results.length} settled`): void => {
|
|
1184
|
+
const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir);
|
|
1185
|
+
const currentResults = results.map(cloneResult);
|
|
965
1186
|
(onUpdate as ToolUpdate | undefined)?.({
|
|
966
1187
|
content: [{ type: "text", text: message }],
|
|
967
|
-
details:
|
|
1188
|
+
details: { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
|
|
968
1189
|
});
|
|
969
1190
|
};
|
|
970
1191
|
const runAt = async (index: number, task: string): Promise<void> => {
|
|
1192
|
+
const threadId = threadRecords[index]!.id;
|
|
1193
|
+
const initialThread = threads.inspect(threadId);
|
|
971
1194
|
if (signal?.aborted) {
|
|
972
1195
|
results[index] = { ...results[index]!, status: "cancelled", terminationReason: "abort" };
|
|
1196
|
+
if (initialThread?.state === "queued" || initialThread?.state === "active") threads.stop(threadId, { reason: "abort" });
|
|
1197
|
+
savedResults.set(threadId, cloneResult(results[index]!));
|
|
1198
|
+
emit();
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
if (!initialThread) return;
|
|
1202
|
+
if (initialThread.state === "closed") {
|
|
1203
|
+
results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "disposed" };
|
|
1204
|
+
savedResults.set(threadId, cloneResult(results[index]!));
|
|
973
1205
|
emit();
|
|
974
1206
|
return;
|
|
975
1207
|
}
|
|
1208
|
+
if (initialThread.state === "stopped") {
|
|
1209
|
+
results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "interrupted" };
|
|
1210
|
+
savedResults.set(threadId, cloneResult(results[index]!));
|
|
1211
|
+
emit();
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
if (initialThread.state !== "queued") return;
|
|
976
1215
|
const input = inputs[index]!;
|
|
1216
|
+
threads.begin(threadId);
|
|
977
1217
|
if ([...task].length > limits.taskCharacters) {
|
|
978
1218
|
results[index] = {
|
|
979
1219
|
...results[index]!,
|
|
@@ -981,26 +1221,140 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
981
1221
|
terminationReason: "task_limit",
|
|
982
1222
|
errorMessage: `Expanded task exceeds ${limits.taskCharacters} characters`,
|
|
983
1223
|
};
|
|
1224
|
+
threads.fail(threadId, { message: results[index]!.errorMessage ?? "Expanded task exceeds the task limit", code: "task_limit" });
|
|
1225
|
+
savedResults.set(threadId, cloneResult(results[index]!));
|
|
984
1226
|
emit();
|
|
985
1227
|
return;
|
|
986
1228
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1229
|
+
const controller = new AbortController();
|
|
1230
|
+
const abortParent = (): void => controller.abort();
|
|
1231
|
+
if (signal) {
|
|
1232
|
+
if (signal.aborted) controller.abort();
|
|
1233
|
+
else signal.addEventListener("abort", abortParent, { once: true });
|
|
1234
|
+
}
|
|
1235
|
+
const runtime: ActiveThreadRuntime = { controller, steering: [], restarting: false, traceCount: 0, startedAt: Date.now() };
|
|
1236
|
+
activeRuntimes.set(threadId, runtime);
|
|
1237
|
+
const agent = roles.get(input.agent)!;
|
|
1238
|
+
const baseToolBudget: ChildToolBudget | undefined = agent.access === "read"
|
|
1239
|
+
? { soft: limits.readToolBudgetSoft, hard: limits.readToolBudgetHard, block: [...READ_TOOLS] }
|
|
1240
|
+
: undefined;
|
|
1241
|
+
const queuedSteering = initialThread.steering.map((entry) => entry.message);
|
|
1242
|
+
let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, undefined, limits.taskCharacters) : task;
|
|
1243
|
+
const stopForBudget = (reason: string, message: string): void => {
|
|
1244
|
+
const limited = cloneResult(runtime.aggregate ?? results[index]!);
|
|
1245
|
+
limited.status = "limited";
|
|
1246
|
+
limited.terminationReason = reason;
|
|
1247
|
+
limited.errorMessage = message;
|
|
1248
|
+
runtime.aggregate = limited;
|
|
1249
|
+
results[index] = cloneResult(limited);
|
|
1250
|
+
savedResults.set(threadId, cloneResult(limited));
|
|
1251
|
+
if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
|
|
1252
|
+
threads.stop(threadId, {
|
|
1253
|
+
usage: threadUsage(limited.usage),
|
|
1254
|
+
result: limited.output || undefined,
|
|
1255
|
+
handoff: limited.output ? { summary: limited.output } : undefined,
|
|
1256
|
+
reason,
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
emit();
|
|
1260
|
+
};
|
|
1261
|
+
try {
|
|
1262
|
+
while (true) {
|
|
1263
|
+
const aggregate = runtime.aggregate;
|
|
1264
|
+
const remainingWallTimeMs = agent.timeoutMs - (Date.now() - runtime.startedAt);
|
|
1265
|
+
const usedTraceBytes = aggregate?.traceBytes ?? 0;
|
|
1266
|
+
const usedStderrBytes = aggregate?.stderrBytes ?? 0;
|
|
1267
|
+
const usedOutputBytes = aggregate?.outputBytes ?? 0;
|
|
1268
|
+
const usedToolCalls = aggregate?.toolCallCount ?? 0;
|
|
1269
|
+
const usedTokens = aggregate?.usage.totalTokens ?? 0;
|
|
1270
|
+
const usedCost = aggregate?.usage.cost.total ?? 0;
|
|
1271
|
+
if (remainingWallTimeMs <= 0) {
|
|
1272
|
+
stopForBudget("wall_time_limit", `Child thread exceeds ${agent.timeoutMs} ms`);
|
|
1273
|
+
break;
|
|
1274
|
+
}
|
|
1275
|
+
if (usedTraceBytes >= limits.traceBytes || aggregate?.traceTruncatedBytes) {
|
|
1276
|
+
stopForBudget("trace_limit", `Child thread retains more than ${limits.traceBytes} trace bytes`);
|
|
1277
|
+
break;
|
|
1278
|
+
}
|
|
1279
|
+
if (usedStderrBytes >= limits.stderrBytes || aggregate?.stderrTruncatedBytes) {
|
|
1280
|
+
stopForBudget("stderr_limit", `Child thread emits more than ${limits.stderrBytes} stderr bytes`);
|
|
1281
|
+
break;
|
|
1282
|
+
}
|
|
1283
|
+
if (usedOutputBytes >= limits.taskOutputBytes || aggregate?.outputTruncatedBytes) {
|
|
1284
|
+
stopForBudget("output_limit", `Child thread emits more than ${limits.taskOutputBytes} output bytes`);
|
|
1285
|
+
break;
|
|
1286
|
+
}
|
|
1287
|
+
if (baseToolBudget && usedToolCalls >= baseToolBudget.hard) {
|
|
1288
|
+
stopForBudget("tool_call_limit", `Child thread exceeds ${baseToolBudget.hard} tool calls`);
|
|
1289
|
+
break;
|
|
1290
|
+
}
|
|
1291
|
+
if (usedTokens >= limits.quotaTokens) {
|
|
1292
|
+
stopForBudget("quota_tokens", `Child thread exceeds ${limits.quotaTokens} tokens`);
|
|
1293
|
+
break;
|
|
1294
|
+
}
|
|
1295
|
+
if (usedCost >= limits.quotaUsd) {
|
|
1296
|
+
stopForBudget("quota_cost", `Child thread exceeds $${limits.quotaUsd}`);
|
|
1297
|
+
break;
|
|
1298
|
+
}
|
|
1299
|
+
runtime.traceCount = 0;
|
|
1300
|
+
const toolBudget = baseToolBudget
|
|
1301
|
+
? {
|
|
1302
|
+
...baseToolBudget,
|
|
1303
|
+
soft: Math.max(1, (baseToolBudget.soft ?? baseToolBudget.hard) - usedToolCalls),
|
|
1304
|
+
hard: baseToolBudget.hard - usedToolCalls,
|
|
1305
|
+
}
|
|
1306
|
+
: undefined;
|
|
1307
|
+
const next = await runTask({
|
|
1308
|
+
cwd: ctx.cwd,
|
|
1309
|
+
agent: roles.get(input.agent)!,
|
|
1310
|
+
task: currentTask,
|
|
1311
|
+
id: results[index]!.id,
|
|
1312
|
+
step: results[index]!.step,
|
|
1313
|
+
model: resolvedModels.get(input.agent)!,
|
|
1314
|
+
signal: controller.signal,
|
|
1315
|
+
webExtension: options.webExtension,
|
|
1316
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
1317
|
+
spawnProcess,
|
|
1318
|
+
limits: {
|
|
1319
|
+
...limits,
|
|
1320
|
+
traceBytes: limits.traceBytes - usedTraceBytes,
|
|
1321
|
+
stderrBytes: limits.stderrBytes - usedStderrBytes,
|
|
1322
|
+
taskOutputBytes: limits.taskOutputBytes - usedOutputBytes,
|
|
1323
|
+
quotaTokens: limits.quotaTokens - usedTokens,
|
|
1324
|
+
quotaUsd: limits.quotaUsd - usedCost,
|
|
1325
|
+
},
|
|
1326
|
+
timeoutMs: remainingWallTimeMs,
|
|
1327
|
+
onHandle: (handle) => { runtime.handle = handle; },
|
|
1328
|
+
toolBudget,
|
|
1329
|
+
onChange: (changed) => {
|
|
1330
|
+
results[index] = syncThread(threadId, changed, runtime);
|
|
1331
|
+
emit();
|
|
1332
|
+
},
|
|
1333
|
+
});
|
|
1334
|
+
next.task = task;
|
|
1335
|
+
runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceBytes);
|
|
1336
|
+
runtime.aggregate.task = task;
|
|
1337
|
+
results[index] = cloneResult(runtime.aggregate);
|
|
1338
|
+
savedResults.set(threadId, cloneResult(runtime.aggregate));
|
|
1339
|
+
const shouldRestart = runtime.steering.length > 0 && !controller.signal.aborted && (runtime.restarting || next.status === "complete" || next.status === "cancelled");
|
|
1340
|
+
if (!shouldRestart) break;
|
|
1341
|
+
const steering = runtime.steering.splice(0);
|
|
1342
|
+
runtime.restarting = false;
|
|
1343
|
+
runtime.requestedReason = undefined;
|
|
1344
|
+
if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
|
|
1345
|
+
threads.patch(threadId, {
|
|
1346
|
+
usage: threadUsage(runtime.aggregate.usage),
|
|
1347
|
+
result: runtime.aggregate.output || undefined,
|
|
1348
|
+
handoff: runtime.aggregate.output ? { summary: runtime.aggregate.output } : undefined,
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
currentTask = buildSteeredTask(task, steering, runtime.aggregate.output, limits.taskCharacters);
|
|
1352
|
+
}
|
|
1353
|
+
} finally {
|
|
1354
|
+
activeRuntimes.delete(threadId);
|
|
1355
|
+
signal?.removeEventListener("abort", abortParent);
|
|
1356
|
+
}
|
|
1357
|
+
emit();
|
|
1004
1358
|
};
|
|
1005
1359
|
|
|
1006
1360
|
emit(`${mode}: ${results.length} queued`);
|
|
@@ -1012,10 +1366,19 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1012
1366
|
if (results[index]!.status !== "complete") break;
|
|
1013
1367
|
previous = results[index]!.output;
|
|
1014
1368
|
}
|
|
1015
|
-
for (
|
|
1369
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
1370
|
+
const result = results[index]!;
|
|
1016
1371
|
if (result.status === "queued") {
|
|
1017
|
-
|
|
1018
|
-
|
|
1372
|
+
const thread = threads.inspect(threadRecords[index]!.id);
|
|
1373
|
+
const alreadyStopped = thread?.state === "stopped";
|
|
1374
|
+
result.status = signal?.aborted || alreadyStopped ? "cancelled" : "failed";
|
|
1375
|
+
result.terminationReason = alreadyStopped
|
|
1376
|
+
? thread.stopReason ?? "interrupted"
|
|
1377
|
+
: signal?.aborted ? "abort" : "chain_stopped";
|
|
1378
|
+
if (thread?.state === "queued" || thread?.state === "active") {
|
|
1379
|
+
threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
|
|
1380
|
+
}
|
|
1381
|
+
savedResults.set(threadRecords[index]!.id, cloneResult(result));
|
|
1019
1382
|
}
|
|
1020
1383
|
}
|
|
1021
1384
|
} else if (hasParallel) {
|
|
@@ -1027,7 +1390,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1027
1390
|
await runAt(0, inputs[0]!.task);
|
|
1028
1391
|
}
|
|
1029
1392
|
|
|
1030
|
-
const
|
|
1393
|
+
const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir);
|
|
1394
|
+
const currentResults = results.map(cloneResult);
|
|
1395
|
+
const details: SubagentDetails = { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
|
|
1031
1396
|
return {
|
|
1032
1397
|
content: [{ type: "text", text: buildToolContent(mode, details.results, limits.toolOutputBytes) }],
|
|
1033
1398
|
details,
|
|
@@ -1037,6 +1402,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1037
1402
|
|
|
1038
1403
|
renderCall(args, theme) {
|
|
1039
1404
|
const scope = args.agentScope ?? "user";
|
|
1405
|
+
if (args.action && args.action !== "spawn") return new Text(`${theme.fg("toolTitle", theme.bold("threads "))}${theme.fg("accent", args.action)}${theme.fg("dim", args.threadId ? ` · ${args.threadId}` : "")}`, 0, 0);
|
|
1040
1406
|
if (args.tasks?.length) return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `parallel ${args.tasks.length}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
1041
1407
|
if (args.chain?.length) return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `chain ${args.chain.length}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
1042
1408
|
return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "…")}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
@@ -1048,17 +1414,31 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1048
1414
|
const first = result.content[0];
|
|
1049
1415
|
return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
|
|
1050
1416
|
}
|
|
1417
|
+
const board = formatThreadBoard({
|
|
1418
|
+
title: `Subagents · ${details.mode}`,
|
|
1419
|
+
threads: details.results.map(threadBoardRecord),
|
|
1420
|
+
selectedThreadId: details.selectedThreadId,
|
|
1421
|
+
});
|
|
1051
1422
|
if (!expanded) {
|
|
1052
|
-
const lines =
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1423
|
+
const lines = [
|
|
1424
|
+
theme.fg("toolTitle", theme.bold(`Active (${board.active.length})`)),
|
|
1425
|
+
...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}`)}`),
|
|
1426
|
+
theme.fg("toolTitle", theme.bold(`Done (${board.done.length})`)),
|
|
1427
|
+
...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}`)}`),
|
|
1428
|
+
];
|
|
1056
1429
|
lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
|
|
1057
1430
|
return new Text(lines.join("\n"), 0, 0);
|
|
1058
1431
|
}
|
|
1059
1432
|
|
|
1060
1433
|
const container = new Container();
|
|
1061
1434
|
container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
|
|
1435
|
+
container.addChild(new Text(theme.fg("dim", `Active ${board.active.length} · Done ${board.done.length} · Controls: Inspect · Steer · Interrupt · Collect · Close`), 0, 0));
|
|
1436
|
+
if (board.selected) {
|
|
1437
|
+
const inspection = formatThreadInspection(threadBoardRecord(details.results.find((task) => task.id === board.selected!.id)!));
|
|
1438
|
+
container.addChild(new Spacer(1));
|
|
1439
|
+
container.addChild(new Text(theme.fg("accent", `Inspect ${inspection.id} · ${inspection.state.label} · ${inspection.usage.text}`), 0, 0));
|
|
1440
|
+
for (const entry of inspection.trace.entries) container.addChild(new Text(`${theme.fg("muted", "→ ")}${theme.fg("toolOutput", entry)}`, 0, 0));
|
|
1441
|
+
}
|
|
1062
1442
|
for (const task of details.results) {
|
|
1063
1443
|
container.addChild(new Spacer(1));
|
|
1064
1444
|
const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);
|