killeros 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/Killeros.ts +2 -0
- package/README.md +19 -6
- package/agents/planner.md +10 -0
- package/agents/reviewer.md +10 -0
- package/agents/scout.md +10 -0
- package/agents/worker.md +10 -0
- package/package.json +4 -1
- package/subagents.ts +1027 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to KillerOS are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.4.0] - 2026-08-01
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Pi-native `subagent` tool with isolated JSONL child processes, single/parallel/chain modes, streamed TUI status, aggregate usage, and abort propagation.
|
|
10
|
+
- Bundled Markdown roles for read-only scouting, planning, and review plus one serialized write-capable worker.
|
|
11
|
+
- Strict role, tool, model, trust, precedence, concurrency, turn, timeout, trace, stderr, and output enforcement.
|
|
12
|
+
|
|
5
13
|
## [1.3.0] - 2026-07-31
|
|
6
14
|
|
|
7
15
|
### Added
|
package/Killeros.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type TUI,
|
|
32
32
|
} from "@earendil-works/pi-tui";
|
|
33
33
|
import { Type } from "typebox";
|
|
34
|
+
import { registerSubagentTool } from "./subagents.ts";
|
|
34
35
|
|
|
35
36
|
const COMMAND_BLUE_RGB = "120;169;255";
|
|
36
37
|
const FOOTER_REFRESH_INTERVAL_MS = 1_000;
|
|
@@ -2541,6 +2542,7 @@ export default function Killeros(pi: ExtensionAPI): void {
|
|
|
2541
2542
|
registerGoal(pi, goalRuntime, initState);
|
|
2542
2543
|
registerPersonalInstructions(pi, initState);
|
|
2543
2544
|
registerQuestionTool(pi);
|
|
2545
|
+
registerSubagentTool(pi);
|
|
2544
2546
|
registerAliases(pi);
|
|
2545
2547
|
registerSlashAutocomplete(pi);
|
|
2546
2548
|
registerFooter(pi, goalRuntime);
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# KillerOS
|
|
2
2
|
|
|
3
|
-
A production-hardened Pi extension that combines a custom TUI, repository initialization, long-running goals, reasoning controls, interactive questions, command aliases, and concise-response guidance.
|
|
3
|
+
A production-hardened Pi extension that combines a custom TUI, isolated subagents, repository initialization, long-running goals, reasoning controls, interactive questions, command aliases, and concise-response guidance.
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
@@ -29,7 +29,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
|
29
29
|
Pin an install to a release:
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.
|
|
32
|
+
pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.4.0
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
Add `-l` to either command for a project-only install. Restart Pi after installing.
|
|
@@ -43,6 +43,7 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
|
|
|
43
43
|
- Responsive footer with polished model/provider identity, plain-language context, and active goal state remaining; reasoning, Git branch, elapsed time, cost, and path cut down by available width
|
|
44
44
|
- `/variants` selector and direct reasoning-level arguments
|
|
45
45
|
- Codex-style `/goal` for durable long-running objectives with pause, resume, edit, clear, automatic continuation, and explicit completion
|
|
46
|
+
- Pi-native `subagent` tool with isolated child processes, Markdown roles, explicit read/write boundaries, parallel readers, one serialized writer, bounded output, and cancellation propagation
|
|
46
47
|
- Claude Code-style `/init` that scans the repository and generates a concise root `AGENTS.md` without setup questions
|
|
47
48
|
- `question` tool with filtering, proposal previews, keyboard selection, custom answers, history, cancellation, and resize-safe rendering
|
|
48
49
|
- Mid-prompt slash completion with current Pi `0.82.1` commands, extensions, prompts, and skills
|
|
@@ -71,6 +72,18 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
|
|
|
71
72
|
|
|
72
73
|
Supported reasoning levels are `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. KillerOS limits choices to levels supported by the current model.
|
|
73
74
|
|
|
75
|
+
## Subagents
|
|
76
|
+
|
|
77
|
+
KillerOS ships `scout`, `planner`, and `reviewer` as read-only roles and `worker` as the only write-capable default. Each invocation rediscovers Markdown roles with this precedence:
|
|
78
|
+
|
|
79
|
+
1. Bundled: `<killeros>/agents/*.md`
|
|
80
|
+
2. Personal: `~/.pi/agent/agents/*.md`
|
|
81
|
+
3. Trusted project: `<repo>/.pi/agents/*.md`
|
|
82
|
+
|
|
83
|
+
The default `agentScope: "user"` uses bundled and personal roles. Use `"project"` or `"both"` to opt into trusted project roles; a selected project override requires interactive confirmation. Role frontmatter requires `name`, `description`, `access`, and an explicit `tools` list. Optional fields are `model`, `maxTurns`, and `timeoutMs`.
|
|
84
|
+
|
|
85
|
+
The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `chain` whose task text may include `{previous}`. Children run as ephemeral `pi --mode json -p --no-session` processes with extension, skill, and prompt-template discovery disabled. KillerOS allows at most eight tasks, four parallel readers, one serialized writer, 12 turns, ten minutes, 2 MiB retained trace, 64 KiB stderr, and 50 KiB returned output per task. Esc cancellation terminates active children and escalates after five seconds.
|
|
86
|
+
|
|
74
87
|
## Configuration
|
|
75
88
|
|
|
76
89
|
KillerOS activates its packaged `killeros` theme when a TUI session starts. Tool-call backgrounds stay neutral across pending, successful, and failed states; restrained text and icons preserve status visibility.
|
|
@@ -85,9 +98,9 @@ Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supp
|
|
|
85
98
|
|
|
86
99
|
| Mode | Behavior |
|
|
87
100
|
|---|---|
|
|
88
|
-
| TUI | All features are available |
|
|
89
|
-
| RPC | Goal set/view/pause/resume/clear and concise prompt guidance work; TUI components, `/goal edit`, and `/init` are disabled |
|
|
90
|
-
| Print/JSON | Concise prompt guidance works; interactive questions, `/goal`,
|
|
101
|
+
| TUI | All features are available, including confirmation for trusted project subagents |
|
|
102
|
+
| RPC | Goal set/view/pause/resume/clear, subagents, and concise prompt guidance work; TUI components, `/goal edit`, and `/init` are disabled |
|
|
103
|
+
| Print/JSON | Concise prompt guidance works; interactive questions, `/goal`, `/init`, and project-role confirmation fail explicitly |
|
|
91
104
|
|
|
92
105
|
## Validation
|
|
93
106
|
|
|
@@ -118,7 +131,7 @@ git push origin main --follow-tags
|
|
|
118
131
|
|
|
119
132
|
## Security
|
|
120
133
|
|
|
121
|
-
Pi extensions run with your user permissions. Review the source before installing it globally. KillerOS executes lifecycle hook commands only for projects Pi marks as trusted; review `.pi/killeros-hooks.json` before enabling project trust.
|
|
134
|
+
Pi extensions and write-capable subagents run with your user permissions. Review the source before installing it globally. KillerOS executes lifecycle hook commands and reads project agent roles only for projects Pi marks as trusted; review `.pi/killeros-hooks.json` and `.pi/agents/*.md` before enabling project trust.
|
|
122
135
|
|
|
123
136
|
## License
|
|
124
137
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: planner
|
|
3
|
+
description: Produce a bounded implementation plan from verified repository evidence
|
|
4
|
+
access: read
|
|
5
|
+
tools: read, grep, find, ls
|
|
6
|
+
maxTurns: 8
|
|
7
|
+
timeoutMs: 300000
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Analyze the requested change and produce the smallest implementation plan that satisfies it. Verify relevant architecture, contracts, and tests in the repository. Name files and ordered validation steps. Surface unresolved decisions instead of silently assuming them. Do not modify files.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reviewer
|
|
3
|
+
description: Review code for proven correctness, security, and regression risks without modifying files
|
|
4
|
+
access: read
|
|
5
|
+
tools: read, grep, find, ls
|
|
6
|
+
maxTurns: 8
|
|
7
|
+
timeoutMs: 300000
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Review the requested scope using repository evidence. Report only issues with a concrete trigger, resulting harm, and smallest safe correction. Check existing protections and tests before making a claim. Include exact paths and locations. Do not edit files and do not report stylistic preferences as defects.
|
package/agents/scout.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: scout
|
|
3
|
+
description: Map a codebase, locate relevant files, and return evidence without modifying the checkout
|
|
4
|
+
access: read
|
|
5
|
+
tools: read, grep, find, ls
|
|
6
|
+
maxTurns: 8
|
|
7
|
+
timeoutMs: 300000
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Explore the requested area efficiently. Identify the smallest relevant set of files, trace important control and data flow, and return concise findings with exact paths and symbols. Do not edit files or propose speculative changes unsupported by repository evidence.
|
package/agents/worker.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: worker
|
|
3
|
+
description: Implement one bounded repository change after requirements and evidence are clear
|
|
4
|
+
access: write
|
|
5
|
+
tools: read, grep, find, ls, edit, write, bash
|
|
6
|
+
maxTurns: 8
|
|
7
|
+
timeoutMs: 300000
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Implement only the requested bounded change. Re-check relevant files before editing, preserve unrelated work, and match existing conventions. Use the smallest safe edits and run focused verification required by the change. Report modified paths, checks, and any failures with their recovery action.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "killeros",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "A production-hardened TUI and workflow extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"Killeros.ts",
|
|
24
|
+
"subagents.ts",
|
|
25
|
+
"agents/*.md",
|
|
24
26
|
"themes/killeros.json",
|
|
25
27
|
"README.md",
|
|
26
28
|
"CHANGELOG.md"
|
|
@@ -41,6 +43,7 @@
|
|
|
41
43
|
]
|
|
42
44
|
},
|
|
43
45
|
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-ai": "*",
|
|
44
47
|
"@earendil-works/pi-coding-agent": "*",
|
|
45
48
|
"@earendil-works/pi-tui": "*",
|
|
46
49
|
"typebox": "*"
|
package/subagents.ts
ADDED
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { StringDecoder } from "node:string_decoder";
|
|
9
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
10
|
+
import {
|
|
11
|
+
CONFIG_DIR_NAME,
|
|
12
|
+
getAgentDir,
|
|
13
|
+
getMarkdownTheme,
|
|
14
|
+
parseFrontmatter,
|
|
15
|
+
type ExtensionAPI,
|
|
16
|
+
type ExtensionContext,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
19
|
+
import { Type } from "typebox";
|
|
20
|
+
|
|
21
|
+
export const SUBAGENT_LIMITS = {
|
|
22
|
+
maxTasks: 8,
|
|
23
|
+
maxReadConcurrency: 4,
|
|
24
|
+
defaultTurns: 8,
|
|
25
|
+
maxTurns: 12,
|
|
26
|
+
defaultTimeoutMs: 300_000,
|
|
27
|
+
maxTimeoutMs: 600_000,
|
|
28
|
+
traceBytes: 2 * 1024 * 1024,
|
|
29
|
+
stderrBytes: 64 * 1024,
|
|
30
|
+
taskOutputBytes: 50 * 1024,
|
|
31
|
+
toolOutputBytes: 50 * 1024,
|
|
32
|
+
roleFileBytes: 64 * 1024,
|
|
33
|
+
taskCharacters: 20_000,
|
|
34
|
+
killGraceMs: 5_000,
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
const READ_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
38
|
+
const WRITE_TOOLS = new Set(["bash", "edit", "write"]);
|
|
39
|
+
const KNOWN_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
|
|
40
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
41
|
+
const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "maxTurns", "timeoutMs"]);
|
|
42
|
+
|
|
43
|
+
type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
44
|
+
export type AgentAccess = "read" | "write";
|
|
45
|
+
export type AgentSource = "bundled" | "personal" | "project";
|
|
46
|
+
export type AgentScope = "user" | "project" | "both";
|
|
47
|
+
export type SubagentStatus = "queued" | "running" | "complete" | "failed" | "cancelled" | "limited";
|
|
48
|
+
|
|
49
|
+
export interface AgentRole {
|
|
50
|
+
name: string;
|
|
51
|
+
description: string;
|
|
52
|
+
access: AgentAccess;
|
|
53
|
+
tools: string[];
|
|
54
|
+
model?: string;
|
|
55
|
+
maxTurns: number;
|
|
56
|
+
timeoutMs: number;
|
|
57
|
+
prompt: string;
|
|
58
|
+
source: AgentSource;
|
|
59
|
+
filePath: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AgentDiscoveryResult {
|
|
63
|
+
agents: AgentRole[];
|
|
64
|
+
projectAgentsDir: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SubagentUsage {
|
|
68
|
+
input: number;
|
|
69
|
+
output: number;
|
|
70
|
+
cacheRead: number;
|
|
71
|
+
cacheWrite: number;
|
|
72
|
+
totalTokens: number;
|
|
73
|
+
cost: {
|
|
74
|
+
input: number;
|
|
75
|
+
output: number;
|
|
76
|
+
cacheRead: number;
|
|
77
|
+
cacheWrite: number;
|
|
78
|
+
total: number;
|
|
79
|
+
};
|
|
80
|
+
turns: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SubagentTaskResult {
|
|
84
|
+
id: string;
|
|
85
|
+
agent: string;
|
|
86
|
+
agentSource: AgentSource | "unknown";
|
|
87
|
+
sourcePath?: string;
|
|
88
|
+
task: string;
|
|
89
|
+
access?: AgentAccess;
|
|
90
|
+
status: SubagentStatus;
|
|
91
|
+
model?: string;
|
|
92
|
+
thinking?: ThinkingLevel;
|
|
93
|
+
tools: string[];
|
|
94
|
+
trace: string[];
|
|
95
|
+
traceBytes: number;
|
|
96
|
+
traceTruncatedBytes: number;
|
|
97
|
+
stderr: string;
|
|
98
|
+
stderrTruncatedBytes: number;
|
|
99
|
+
output: string;
|
|
100
|
+
outputTruncatedBytes: number;
|
|
101
|
+
usage: SubagentUsage;
|
|
102
|
+
durationMs: number;
|
|
103
|
+
exitCode: number | null;
|
|
104
|
+
terminationReason?: string;
|
|
105
|
+
errorMessage?: string;
|
|
106
|
+
step?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface SubagentDetails {
|
|
110
|
+
mode: "single" | "parallel" | "chain";
|
|
111
|
+
agentScope: AgentScope;
|
|
112
|
+
projectAgentsDir: string | null;
|
|
113
|
+
results: SubagentTaskResult[];
|
|
114
|
+
aggregateUsage: SubagentUsage;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface ModelLike {
|
|
118
|
+
provider: string;
|
|
119
|
+
id: string;
|
|
120
|
+
reasoning?: boolean;
|
|
121
|
+
thinkingLevelMap?: Partial<Record<ThinkingLevel, unknown>>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface ModelContext {
|
|
125
|
+
model?: ModelLike;
|
|
126
|
+
thinkingLevel?: ThinkingLevel;
|
|
127
|
+
modelRegistry: {
|
|
128
|
+
getAvailable(): ModelLike[];
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface ResolvedModel {
|
|
133
|
+
model: string;
|
|
134
|
+
thinking: ThinkingLevel;
|
|
135
|
+
definition: ModelLike;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface SpawnedProcess {
|
|
139
|
+
stdout: NodeJS.ReadableStream;
|
|
140
|
+
stderr: NodeJS.ReadableStream;
|
|
141
|
+
pid?: number;
|
|
142
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
143
|
+
on(event: "error", listener: (error: Error) => void): this;
|
|
144
|
+
once(event: "close", listener: (code: number | null) => void): this;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number };
|
|
148
|
+
|
|
149
|
+
export interface SubagentRuntimeOptions {
|
|
150
|
+
bundledAgentsDir?: string;
|
|
151
|
+
userAgentsDir?: string;
|
|
152
|
+
spawnProcess?: (args: string[], cwd: string) => SpawnedProcess;
|
|
153
|
+
createTaskId?: (index: number) => string;
|
|
154
|
+
limits?: Partial<SubagentLimits>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
class AgentConfigurationError extends Error {
|
|
158
|
+
constructor(filePath: string, field: string, message: string) {
|
|
159
|
+
super(`${filePath} [${field}]: ${message}`);
|
|
160
|
+
this.name = "AgentConfigurationError";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function emptyUsage(): SubagentUsage {
|
|
165
|
+
return {
|
|
166
|
+
input: 0,
|
|
167
|
+
output: 0,
|
|
168
|
+
cacheRead: 0,
|
|
169
|
+
cacheWrite: 0,
|
|
170
|
+
totalTokens: 0,
|
|
171
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
172
|
+
turns: 0,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function addUsage(target: SubagentUsage, source: Partial<SubagentUsage> | undefined): void {
|
|
177
|
+
if (!source) return;
|
|
178
|
+
target.input += source.input ?? 0;
|
|
179
|
+
target.output += source.output ?? 0;
|
|
180
|
+
target.cacheRead += source.cacheRead ?? 0;
|
|
181
|
+
target.cacheWrite += source.cacheWrite ?? 0;
|
|
182
|
+
target.totalTokens += source.totalTokens ?? 0;
|
|
183
|
+
target.cost.input += source.cost?.input ?? 0;
|
|
184
|
+
target.cost.output += source.cost?.output ?? 0;
|
|
185
|
+
target.cost.cacheRead += source.cost?.cacheRead ?? 0;
|
|
186
|
+
target.cost.cacheWrite += source.cost?.cacheWrite ?? 0;
|
|
187
|
+
target.cost.total += source.cost?.total ?? 0;
|
|
188
|
+
target.turns += source.turns ?? 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function aggregateUsage(results: SubagentTaskResult[]): SubagentUsage {
|
|
192
|
+
const total = emptyUsage();
|
|
193
|
+
for (const result of results) addUsage(total, result.usage);
|
|
194
|
+
return total;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function readBoundedFile(filePath: string, maxBytes: number): string {
|
|
198
|
+
let descriptor: number | undefined;
|
|
199
|
+
try {
|
|
200
|
+
descriptor = openSync(filePath, "r");
|
|
201
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
202
|
+
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
203
|
+
if (bytesRead > maxBytes) throw new AgentConfigurationError(filePath, "file", `exceeds ${maxBytes} bytes`);
|
|
204
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
205
|
+
} finally {
|
|
206
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function requiredString(frontmatter: Record<string, unknown>, filePath: string, field: string): string {
|
|
211
|
+
const value = frontmatter[field];
|
|
212
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
213
|
+
throw new AgentConfigurationError(filePath, field, "must be a non-empty string");
|
|
214
|
+
}
|
|
215
|
+
return value.trim();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function optionalPositiveInteger(
|
|
219
|
+
frontmatter: Record<string, unknown>,
|
|
220
|
+
filePath: string,
|
|
221
|
+
field: string,
|
|
222
|
+
fallback: number,
|
|
223
|
+
maximum: number,
|
|
224
|
+
): number {
|
|
225
|
+
const value = frontmatter[field];
|
|
226
|
+
if (value === undefined || value === "") return fallback;
|
|
227
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
228
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > maximum) {
|
|
229
|
+
throw new AgentConfigurationError(filePath, field, `must be a positive integer no greater than ${maximum}`);
|
|
230
|
+
}
|
|
231
|
+
return parsed;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentLimits): AgentRole {
|
|
235
|
+
const content = readBoundedFile(filePath, limits.roleFileBytes);
|
|
236
|
+
let parsed: { frontmatter: Record<string, unknown>; body: string };
|
|
237
|
+
try {
|
|
238
|
+
parsed = parseFrontmatter<Record<string, unknown>>(content);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
throw new AgentConfigurationError(filePath, "frontmatter", error instanceof Error ? error.message : String(error));
|
|
241
|
+
}
|
|
242
|
+
const frontmatter = parsed.frontmatter;
|
|
243
|
+
for (const field of Object.keys(frontmatter)) {
|
|
244
|
+
if (!ROLE_FIELDS.has(field)) throw new AgentConfigurationError(filePath, field, "unknown role field");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const name = requiredString(frontmatter, filePath, "name");
|
|
248
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(name)) {
|
|
249
|
+
throw new AgentConfigurationError(filePath, "name", "must use 1-64 letters, numbers, dots, underscores, or hyphens");
|
|
250
|
+
}
|
|
251
|
+
const description = requiredString(frontmatter, filePath, "description");
|
|
252
|
+
if (description.length > 500) throw new AgentConfigurationError(filePath, "description", "must not exceed 500 characters");
|
|
253
|
+
|
|
254
|
+
const accessValue = requiredString(frontmatter, filePath, "access");
|
|
255
|
+
if (accessValue !== "read" && accessValue !== "write") {
|
|
256
|
+
throw new AgentConfigurationError(filePath, "access", 'must be "read" or "write"');
|
|
257
|
+
}
|
|
258
|
+
const toolsValue = requiredString(frontmatter, filePath, "tools");
|
|
259
|
+
const tools = [...new Set(toolsValue.split(",").map((tool) => tool.trim()).filter(Boolean))];
|
|
260
|
+
if (tools.length === 0) throw new AgentConfigurationError(filePath, "tools", "must contain at least one tool");
|
|
261
|
+
for (const tool of tools) {
|
|
262
|
+
if (!KNOWN_TOOLS.has(tool)) throw new AgentConfigurationError(filePath, "tools", `unknown child tool ${JSON.stringify(tool)}`);
|
|
263
|
+
if (accessValue === "read" && WRITE_TOOLS.has(tool)) {
|
|
264
|
+
throw new AgentConfigurationError(filePath, "tools", `read-only roles cannot use ${tool}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const prompt = parsed.body.trim();
|
|
269
|
+
if (!prompt) throw new AgentConfigurationError(filePath, "prompt", "Markdown body must be non-empty");
|
|
270
|
+
const modelValue = frontmatter.model;
|
|
271
|
+
if (modelValue !== undefined && (typeof modelValue !== "string" || !modelValue.trim())) {
|
|
272
|
+
throw new AgentConfigurationError(filePath, "model", "must be a non-empty string when provided");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
name,
|
|
277
|
+
description,
|
|
278
|
+
access: accessValue,
|
|
279
|
+
tools,
|
|
280
|
+
model: typeof modelValue === "string" ? modelValue.trim() : undefined,
|
|
281
|
+
maxTurns: optionalPositiveInteger(frontmatter, filePath, "maxTurns", limits.defaultTurns, limits.maxTurns),
|
|
282
|
+
timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", limits.defaultTimeoutMs, limits.maxTimeoutMs),
|
|
283
|
+
prompt,
|
|
284
|
+
source,
|
|
285
|
+
filePath,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function loadAgentDirectory(dir: string, source: AgentSource, limits: SubagentLimits): AgentRole[] {
|
|
290
|
+
let entries;
|
|
291
|
+
try {
|
|
292
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
295
|
+
throw new Error(`Could not read ${source} agent directory ${dir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
296
|
+
}
|
|
297
|
+
const agents: AgentRole[] = [];
|
|
298
|
+
const names = new Set<string>();
|
|
299
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
300
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
301
|
+
const agent = parseAgentFile(path.join(dir, entry.name), source, limits);
|
|
302
|
+
if (names.has(agent.name)) throw new AgentConfigurationError(agent.filePath, "name", `duplicate ${source} role ${JSON.stringify(agent.name)}`);
|
|
303
|
+
names.add(agent.name);
|
|
304
|
+
agents.push(agent);
|
|
305
|
+
}
|
|
306
|
+
return agents;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isDirectory(candidate: string): boolean {
|
|
310
|
+
try {
|
|
311
|
+
return statSync(candidate).isDirectory();
|
|
312
|
+
} catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function findProjectAgentsDir(cwd: string): string | null {
|
|
318
|
+
let current = path.resolve(cwd);
|
|
319
|
+
while (true) {
|
|
320
|
+
const candidate = path.join(current, CONFIG_DIR_NAME, "agents");
|
|
321
|
+
if (isDirectory(candidate)) return candidate;
|
|
322
|
+
const parent = path.dirname(current);
|
|
323
|
+
if (parent === current) return null;
|
|
324
|
+
current = parent;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function discoverAgentRoles(
|
|
329
|
+
cwd: string,
|
|
330
|
+
scope: AgentScope,
|
|
331
|
+
projectTrusted: boolean,
|
|
332
|
+
options: Pick<SubagentRuntimeOptions, "bundledAgentsDir" | "userAgentsDir" | "limits"> = {},
|
|
333
|
+
): AgentDiscoveryResult {
|
|
334
|
+
const limits = { ...SUBAGENT_LIMITS, ...options.limits };
|
|
335
|
+
const bundledDir = options.bundledAgentsDir ?? fileURLToPath(new URL("./agents/", import.meta.url));
|
|
336
|
+
const userDir = options.userAgentsDir ?? path.join(getAgentDir(), "agents");
|
|
337
|
+
const wantsProject = scope === "project" || scope === "both";
|
|
338
|
+
if (wantsProject && !projectTrusted) throw new Error("Project agents require a trusted project");
|
|
339
|
+
const projectAgentsDir = wantsProject ? findProjectAgentsDir(cwd) : null;
|
|
340
|
+
|
|
341
|
+
const layers: Array<{ dir: string; source: AgentSource }> = [{ dir: bundledDir, source: "bundled" }];
|
|
342
|
+
if (scope === "user" || scope === "both") layers.push({ dir: userDir, source: "personal" });
|
|
343
|
+
if (wantsProject && projectAgentsDir) layers.push({ dir: projectAgentsDir, source: "project" });
|
|
344
|
+
|
|
345
|
+
const byName = new Map<string, AgentRole>();
|
|
346
|
+
for (const layer of layers) {
|
|
347
|
+
for (const agent of loadAgentDirectory(layer.dir, layer.source, limits)) byName.set(agent.name, agent);
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
agents: [...byName.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
|
351
|
+
projectAgentsDir,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function splitModelAndThinking(value: string, filePath: string): { model: string; thinking?: ThinkingLevel } {
|
|
356
|
+
const colon = value.lastIndexOf(":");
|
|
357
|
+
if (colon < 0) return { model: value };
|
|
358
|
+
const suffix = value.slice(colon + 1);
|
|
359
|
+
if (!(THINKING_LEVELS as readonly string[]).includes(suffix)) {
|
|
360
|
+
throw new AgentConfigurationError(filePath, "model", `unknown thinking level ${JSON.stringify(suffix)}`);
|
|
361
|
+
}
|
|
362
|
+
const model = value.slice(0, colon);
|
|
363
|
+
if (!model) throw new AgentConfigurationError(filePath, "model", "model identifier is missing");
|
|
364
|
+
return { model, thinking: suffix as ThinkingLevel };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function modelSupportsThinking(model: ModelLike, thinking: ThinkingLevel): boolean {
|
|
368
|
+
if (thinking === "off") return true;
|
|
369
|
+
if (model.reasoning === false) return false;
|
|
370
|
+
if (model.thinkingLevelMap?.[thinking] === null) return false;
|
|
371
|
+
if ((thinking === "xhigh" || thinking === "max") && model.thinkingLevelMap?.[thinking] === undefined) return false;
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function resolveAgentModel(agent: AgentRole, ctx: ModelContext): ResolvedModel {
|
|
376
|
+
const inheritedThinking = ctx.thinkingLevel ?? "off";
|
|
377
|
+
let definition: ModelLike | undefined;
|
|
378
|
+
let thinking = inheritedThinking;
|
|
379
|
+
|
|
380
|
+
if (agent.model) {
|
|
381
|
+
const requested = splitModelAndThinking(agent.model, agent.filePath);
|
|
382
|
+
thinking = requested.thinking ?? inheritedThinking;
|
|
383
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
384
|
+
const slash = requested.model.indexOf("/");
|
|
385
|
+
if (slash >= 1 && slash < requested.model.length - 1) {
|
|
386
|
+
definition = available.find((model) => model.provider === requested.model.slice(0, slash) && model.id === requested.model.slice(slash + 1));
|
|
387
|
+
if (!definition) throw new AgentConfigurationError(agent.filePath, "model", `unavailable model ${JSON.stringify(requested.model)}`);
|
|
388
|
+
} else {
|
|
389
|
+
const matches = available.filter((model) => model.id === requested.model);
|
|
390
|
+
if (matches.length === 0) throw new AgentConfigurationError(agent.filePath, "model", `unavailable model ${JSON.stringify(requested.model)}`);
|
|
391
|
+
if (matches.length > 1) throw new AgentConfigurationError(agent.filePath, "model", `ambiguous model ${JSON.stringify(requested.model)}; use provider/model`);
|
|
392
|
+
definition = matches[0];
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
definition = ctx.model;
|
|
396
|
+
if (!definition) throw new AgentConfigurationError(agent.filePath, "model", "no active parent model is available to inherit");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!modelSupportsThinking(definition, thinking)) {
|
|
400
|
+
throw new AgentConfigurationError(agent.filePath, "model", `${definition.provider}/${definition.id} does not support thinking level ${thinking}`);
|
|
401
|
+
}
|
|
402
|
+
return { model: `${definition.provider}/${definition.id}`, thinking, definition };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
406
|
+
const currentScript = process.argv[1];
|
|
407
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
408
|
+
if (currentScript && !isBunVirtualScript) {
|
|
409
|
+
try {
|
|
410
|
+
if (statSync(currentScript).isFile()) return { command: process.execPath, args: [currentScript, ...args] };
|
|
411
|
+
} catch {
|
|
412
|
+
// Fall through to the installed pi command.
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const executable = path.basename(process.execPath).toLocaleLowerCase();
|
|
416
|
+
return /^(node|bun)(\.exe)?$/u.test(executable)
|
|
417
|
+
? { command: "pi", args }
|
|
418
|
+
: { command: process.execPath, args };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function childProcessEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
422
|
+
const childEnvironment = { ...environment };
|
|
423
|
+
delete childEnvironment.PI_SESSION_FILE;
|
|
424
|
+
delete childEnvironment.PI_SESSION_ID;
|
|
425
|
+
return childEnvironment;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function defaultSpawnProcess(args: string[], cwd: string): SpawnedProcess {
|
|
429
|
+
const invocation = getPiInvocation(args);
|
|
430
|
+
return spawn(invocation.command, invocation.args, {
|
|
431
|
+
cwd,
|
|
432
|
+
detached: process.platform !== "win32",
|
|
433
|
+
env: childProcessEnvironment(),
|
|
434
|
+
shell: false,
|
|
435
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
436
|
+
windowsHide: true,
|
|
437
|
+
}) as unknown as SpawnedProcess;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function terminateProcess(child: SpawnedProcess, force: boolean): void {
|
|
441
|
+
if (process.platform === "win32" && force && child.pid) {
|
|
442
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
443
|
+
shell: false,
|
|
444
|
+
stdio: "ignore",
|
|
445
|
+
windowsHide: true,
|
|
446
|
+
});
|
|
447
|
+
killer.unref();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (process.platform !== "win32" && child.pid) {
|
|
451
|
+
try {
|
|
452
|
+
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
453
|
+
return;
|
|
454
|
+
} catch {
|
|
455
|
+
// Fall back to the direct child when process-group signaling is unavailable.
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
460
|
+
} catch {
|
|
461
|
+
// The process may already have exited.
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBytes: number } {
|
|
466
|
+
const bytes = Buffer.from(text, "utf8");
|
|
467
|
+
if (bytes.length <= maxBytes) return { text, omittedBytes: 0 };
|
|
468
|
+
let truncated = bytes.subarray(0, maxBytes).toString("utf8");
|
|
469
|
+
if (truncated.endsWith("�")) truncated = truncated.slice(0, -1);
|
|
470
|
+
return { text: truncated, omittedBytes: bytes.length - Buffer.byteLength(truncated, "utf8") };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function textContent(message: any): string {
|
|
474
|
+
if (!Array.isArray(message?.content)) return "";
|
|
475
|
+
return message.content.filter((part: any) => part?.type === "text" && typeof part.text === "string").map((part: any) => part.text).join("\n");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function traceMessage(message: any): string[] {
|
|
479
|
+
if (!Array.isArray(message?.content)) return [];
|
|
480
|
+
const entries: string[] = [];
|
|
481
|
+
for (const part of message.content) {
|
|
482
|
+
if (part?.type !== "toolCall" || typeof part.name !== "string") continue;
|
|
483
|
+
const args = truncateUtf8(JSON.stringify(part.arguments ?? {}), 2_000).text;
|
|
484
|
+
entries.push(`${part.name} ${args}`);
|
|
485
|
+
}
|
|
486
|
+
return entries;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function makeQueuedResult(id: string, agent: string, task: string, step?: number): SubagentTaskResult {
|
|
490
|
+
return {
|
|
491
|
+
id,
|
|
492
|
+
agent,
|
|
493
|
+
agentSource: "unknown",
|
|
494
|
+
task,
|
|
495
|
+
status: "queued",
|
|
496
|
+
tools: [],
|
|
497
|
+
trace: [],
|
|
498
|
+
traceBytes: 0,
|
|
499
|
+
traceTruncatedBytes: 0,
|
|
500
|
+
stderr: "",
|
|
501
|
+
stderrTruncatedBytes: 0,
|
|
502
|
+
output: "",
|
|
503
|
+
outputTruncatedBytes: 0,
|
|
504
|
+
usage: emptyUsage(),
|
|
505
|
+
durationMs: 0,
|
|
506
|
+
exitCode: null,
|
|
507
|
+
step,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
|
|
512
|
+
return {
|
|
513
|
+
...result,
|
|
514
|
+
tools: [...result.tools],
|
|
515
|
+
trace: [...result.trace],
|
|
516
|
+
usage: { ...result.usage, cost: { ...result.usage.cost } },
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectAgentsDir: string | null, results: SubagentTaskResult[]): SubagentDetails {
|
|
521
|
+
const cloned = results.map(cloneResult);
|
|
522
|
+
return { mode, agentScope: scope, projectAgentsDir, results: cloned, aggregateUsage: aggregateUsage(cloned) };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function writeRolePrompt(agent: AgentRole): Promise<{ directory: string; filePath: string }> {
|
|
526
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-"));
|
|
527
|
+
const filePath = path.join(directory, `${agent.name.replace(/[^A-Za-z0-9_.-]/gu, "_")}.md`);
|
|
528
|
+
await writeFile(filePath, agent.prompt, { encoding: "utf8", mode: 0o600 });
|
|
529
|
+
return { directory, filePath };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
interface RunTaskOptions {
|
|
533
|
+
cwd: string;
|
|
534
|
+
agent: AgentRole;
|
|
535
|
+
task: string;
|
|
536
|
+
id: string;
|
|
537
|
+
step?: number;
|
|
538
|
+
model: ResolvedModel;
|
|
539
|
+
signal?: AbortSignal;
|
|
540
|
+
spawnProcess: (args: string[], cwd: string) => SpawnedProcess;
|
|
541
|
+
limits: SubagentLimits;
|
|
542
|
+
onChange: (result: SubagentTaskResult) => void;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
546
|
+
const { agent, limits } = options;
|
|
547
|
+
const result = makeQueuedResult(options.id, agent.name, options.task, options.step);
|
|
548
|
+
result.agentSource = agent.source;
|
|
549
|
+
result.sourcePath = agent.filePath;
|
|
550
|
+
result.access = agent.access;
|
|
551
|
+
result.tools = [...agent.tools];
|
|
552
|
+
result.model = options.model.model;
|
|
553
|
+
result.thinking = options.model.thinking;
|
|
554
|
+
result.status = "running";
|
|
555
|
+
const startedAt = Date.now();
|
|
556
|
+
options.onChange(result);
|
|
557
|
+
|
|
558
|
+
if (options.signal?.aborted) {
|
|
559
|
+
result.status = "cancelled";
|
|
560
|
+
result.terminationReason = "abort";
|
|
561
|
+
result.durationMs = Date.now() - startedAt;
|
|
562
|
+
options.onChange(result);
|
|
563
|
+
return result;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
let promptDirectory: string | undefined;
|
|
567
|
+
let child: SpawnedProcess | undefined;
|
|
568
|
+
try {
|
|
569
|
+
const prompt = await writeRolePrompt(agent);
|
|
570
|
+
promptDirectory = prompt.directory;
|
|
571
|
+
if (options.signal?.aborted) {
|
|
572
|
+
result.status = "cancelled";
|
|
573
|
+
result.terminationReason = "abort";
|
|
574
|
+
result.durationMs = Date.now() - startedAt;
|
|
575
|
+
options.onChange(result);
|
|
576
|
+
return result;
|
|
577
|
+
}
|
|
578
|
+
const args = [
|
|
579
|
+
"--mode", "json",
|
|
580
|
+
"-p",
|
|
581
|
+
"--no-session",
|
|
582
|
+
"--no-extensions",
|
|
583
|
+
"--no-skills",
|
|
584
|
+
"--no-prompt-templates",
|
|
585
|
+
"--model", options.model.model,
|
|
586
|
+
"--thinking", options.model.thinking,
|
|
587
|
+
"--tools", agent.tools.join(","),
|
|
588
|
+
"--append-system-prompt", prompt.filePath,
|
|
589
|
+
`Task: ${options.task}`,
|
|
590
|
+
];
|
|
591
|
+
child = options.spawnProcess(args, options.cwd);
|
|
592
|
+
|
|
593
|
+
const decoder = new StringDecoder("utf8");
|
|
594
|
+
let lineBuffer = "";
|
|
595
|
+
let rawTraceBytes = 0;
|
|
596
|
+
let rawStderrBytes = 0;
|
|
597
|
+
let requestedStatus: SubagentStatus | undefined;
|
|
598
|
+
let requestedReason: string | undefined;
|
|
599
|
+
let closed = false;
|
|
600
|
+
let malformedError: string | undefined;
|
|
601
|
+
let forceTimer: NodeJS.Timeout | undefined;
|
|
602
|
+
let settleTimer: NodeJS.Timeout | undefined;
|
|
603
|
+
|
|
604
|
+
const requestTermination = (status: "failed" | "cancelled" | "limited", reason: string, errorMessage?: string): void => {
|
|
605
|
+
if (requestedStatus) return;
|
|
606
|
+
requestedStatus = status;
|
|
607
|
+
requestedReason = reason;
|
|
608
|
+
if (errorMessage) result.errorMessage = errorMessage;
|
|
609
|
+
terminateProcess(child!, false);
|
|
610
|
+
forceTimer = setTimeout(() => {
|
|
611
|
+
if (closed) return;
|
|
612
|
+
terminateProcess(child!, true);
|
|
613
|
+
settleTimer = setTimeout(() => {
|
|
614
|
+
if (!closed) finish(null);
|
|
615
|
+
}, 1_000);
|
|
616
|
+
settleTimer.unref?.();
|
|
617
|
+
}, limits.killGraceMs);
|
|
618
|
+
forceTimer.unref?.();
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const processLine = (line: string): void => {
|
|
622
|
+
if (!line.trim() || requestedStatus) return;
|
|
623
|
+
let event: any;
|
|
624
|
+
try {
|
|
625
|
+
event = JSON.parse(line);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
malformedError = error instanceof Error ? error.message : String(error);
|
|
628
|
+
requestTermination("failed", "malformed_jsonl", `Malformed child JSONL: ${malformedError}`);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (event?.type === "message_end" && event.message?.role === "assistant") {
|
|
632
|
+
const message = event.message;
|
|
633
|
+
result.usage.turns += 1;
|
|
634
|
+
addUsage(result.usage, { ...message.usage, turns: 0 });
|
|
635
|
+
result.trace.push(...traceMessage(message));
|
|
636
|
+
const output = textContent(message);
|
|
637
|
+
if (output) {
|
|
638
|
+
const capped = truncateUtf8(output, limits.taskOutputBytes);
|
|
639
|
+
result.output = capped.text;
|
|
640
|
+
result.outputTruncatedBytes = capped.omittedBytes;
|
|
641
|
+
}
|
|
642
|
+
if (typeof message.model === "string") result.model = message.provider ? `${message.provider}/${message.model}` : message.model;
|
|
643
|
+
const stopReason = typeof message.stopReason === "string" ? message.stopReason : undefined;
|
|
644
|
+
if (stopReason === "stop" || stopReason === "toolUse") {
|
|
645
|
+
result.terminationReason = undefined;
|
|
646
|
+
result.errorMessage = undefined;
|
|
647
|
+
} else if (stopReason) {
|
|
648
|
+
result.terminationReason = stopReason;
|
|
649
|
+
}
|
|
650
|
+
if (typeof message.errorMessage === "string") result.errorMessage = message.errorMessage;
|
|
651
|
+
if (stopReason === "length") requestTermination("limited", "model_output_limit");
|
|
652
|
+
if (result.usage.turns > agent.maxTurns || result.usage.turns >= agent.maxTurns && stopReason === "toolUse") {
|
|
653
|
+
requestTermination("limited", "turn_limit");
|
|
654
|
+
}
|
|
655
|
+
options.onChange(result);
|
|
656
|
+
} else if (event?.type === "agent_end" && event.willRetry === true) {
|
|
657
|
+
if (result.usage.turns >= agent.maxTurns) {
|
|
658
|
+
requestTermination("limited", "turn_limit");
|
|
659
|
+
options.onChange(result);
|
|
660
|
+
}
|
|
661
|
+
} else if (event?.type === "tool_result_end" && event.message) {
|
|
662
|
+
const toolName = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
|
|
663
|
+
result.trace.push(`${toolName} result${event.message.isError ? " (error)" : ""}`);
|
|
664
|
+
options.onChange(result);
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
let finish!: (code: number | null) => void;
|
|
669
|
+
const closedPromise = new Promise<void>((resolve) => {
|
|
670
|
+
finish = (code: number | null): void => {
|
|
671
|
+
if (closed) return;
|
|
672
|
+
closed = true;
|
|
673
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
674
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
675
|
+
const tail = decoder.end();
|
|
676
|
+
if (tail) lineBuffer += tail;
|
|
677
|
+
if (lineBuffer.trim() && !requestedStatus) processLine(lineBuffer);
|
|
678
|
+
result.exitCode = code;
|
|
679
|
+
if (requestedStatus) {
|
|
680
|
+
result.status = requestedStatus;
|
|
681
|
+
result.terminationReason = requestedReason;
|
|
682
|
+
} else if (result.terminationReason === "length") {
|
|
683
|
+
result.status = "limited";
|
|
684
|
+
result.terminationReason = "model_output_limit";
|
|
685
|
+
} else if (code !== 0 || result.errorMessage || result.terminationReason) {
|
|
686
|
+
result.status = "failed";
|
|
687
|
+
result.terminationReason ??= code === null ? "process_closed" : `exit_${code}`;
|
|
688
|
+
} else if (result.usage.turns === 0) {
|
|
689
|
+
result.status = "failed";
|
|
690
|
+
result.terminationReason = "missing_assistant_message";
|
|
691
|
+
result.errorMessage = "Child exited without an assistant response";
|
|
692
|
+
} else {
|
|
693
|
+
result.status = "complete";
|
|
694
|
+
result.terminationReason = "completed";
|
|
695
|
+
}
|
|
696
|
+
result.durationMs = Date.now() - startedAt;
|
|
697
|
+
options.onChange(result);
|
|
698
|
+
resolve();
|
|
699
|
+
};
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
child.stdout.on("data", (chunk: Buffer | string) => {
|
|
703
|
+
if (requestedStatus) return;
|
|
704
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
705
|
+
const remaining = Math.max(0, limits.traceBytes - rawTraceBytes);
|
|
706
|
+
rawTraceBytes += buffer.length;
|
|
707
|
+
const accepted = buffer.subarray(0, remaining);
|
|
708
|
+
if (accepted.length) {
|
|
709
|
+
lineBuffer += decoder.write(accepted);
|
|
710
|
+
const lines = lineBuffer.split(/\r?\n/u);
|
|
711
|
+
lineBuffer = lines.pop() ?? "";
|
|
712
|
+
for (const line of lines) processLine(line);
|
|
713
|
+
}
|
|
714
|
+
result.traceBytes = Math.min(rawTraceBytes, limits.traceBytes);
|
|
715
|
+
result.traceTruncatedBytes = Math.max(0, rawTraceBytes - limits.traceBytes);
|
|
716
|
+
if (rawTraceBytes > limits.traceBytes) requestTermination("limited", "trace_limit");
|
|
717
|
+
});
|
|
718
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
719
|
+
if (requestedStatus) return;
|
|
720
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
721
|
+
const remaining = Math.max(0, limits.stderrBytes - rawStderrBytes);
|
|
722
|
+
rawStderrBytes += buffer.length;
|
|
723
|
+
if (remaining > 0) result.stderr += buffer.subarray(0, remaining).toString("utf8");
|
|
724
|
+
result.stderrTruncatedBytes = Math.max(0, rawStderrBytes - limits.stderrBytes);
|
|
725
|
+
if (rawStderrBytes > limits.stderrBytes) requestTermination("limited", "stderr_limit");
|
|
726
|
+
});
|
|
727
|
+
child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
|
|
728
|
+
child.once("close", finish);
|
|
729
|
+
|
|
730
|
+
const timeoutTimer = setTimeout(() => requestTermination("limited", "timeout"), agent.timeoutMs);
|
|
731
|
+
timeoutTimer.unref?.();
|
|
732
|
+
const abortHandler = (): void => requestTermination("cancelled", "abort");
|
|
733
|
+
if (options.signal?.aborted) abortHandler();
|
|
734
|
+
else options.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
735
|
+
|
|
736
|
+
try {
|
|
737
|
+
await closedPromise;
|
|
738
|
+
} finally {
|
|
739
|
+
clearTimeout(timeoutTimer);
|
|
740
|
+
options.signal?.removeEventListener("abort", abortHandler);
|
|
741
|
+
}
|
|
742
|
+
if (!result.output && result.stderr && (result.status as SubagentStatus) !== "complete") {
|
|
743
|
+
result.errorMessage ??= result.stderr.trim();
|
|
744
|
+
}
|
|
745
|
+
return result;
|
|
746
|
+
} catch (error) {
|
|
747
|
+
result.status = options.signal?.aborted ? "cancelled" : "failed";
|
|
748
|
+
result.terminationReason = options.signal?.aborted ? "abort" : "spawn_error";
|
|
749
|
+
result.errorMessage = error instanceof Error ? error.message : String(error);
|
|
750
|
+
result.durationMs = Date.now() - startedAt;
|
|
751
|
+
options.onChange(result);
|
|
752
|
+
return result;
|
|
753
|
+
} finally {
|
|
754
|
+
if (promptDirectory) {
|
|
755
|
+
try {
|
|
756
|
+
await rm(promptDirectory, { recursive: true, force: true });
|
|
757
|
+
} catch {
|
|
758
|
+
// Temporary prompt cleanup is best effort after child termination.
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function mapReadTasks<T>(items: T[], concurrency: number, run: (item: T, index: number) => Promise<void>): Promise<void> {
|
|
765
|
+
let next = 0;
|
|
766
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
767
|
+
while (true) {
|
|
768
|
+
const index = next;
|
|
769
|
+
next += 1;
|
|
770
|
+
if (index >= items.length) return;
|
|
771
|
+
await run(items[index]!, index);
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
await Promise.all(workers);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const TaskSchema = Type.Object({
|
|
778
|
+
agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
|
|
779
|
+
task: Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Bounded task for the role" }),
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
const ChainTaskSchema = Type.Object({
|
|
783
|
+
agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
|
|
784
|
+
task: Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task with optional {previous} handoff placeholder" }),
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const SubagentParams = Type.Object({
|
|
788
|
+
agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
|
|
789
|
+
task: Type.Optional(Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task for single mode" })),
|
|
790
|
+
tasks: Type.Optional(Type.Array(TaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Parallel role tasks" })),
|
|
791
|
+
chain: Type.Optional(Type.Array(ChainTaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
|
|
792
|
+
agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
|
|
793
|
+
default: "user",
|
|
794
|
+
description: "Role sources: user includes bundled and personal; project includes bundled and trusted project; both includes all",
|
|
795
|
+
})),
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
type TaskInput = { agent: string; task: string };
|
|
799
|
+
|
|
800
|
+
type ToolUpdate = (partial: { content: Array<{ type: "text"; text: string }>; details: SubagentDetails }) => void;
|
|
801
|
+
|
|
802
|
+
function requestedAgents(params: { agent?: string; tasks?: TaskInput[]; chain?: TaskInput[] }): string[] {
|
|
803
|
+
if (params.agent) return [params.agent];
|
|
804
|
+
return (params.tasks ?? params.chain ?? []).map((task) => task.agent);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function formatUsage(usage: SubagentUsage): string {
|
|
808
|
+
const parts = [`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`, `${usage.totalTokens} tokens`];
|
|
809
|
+
if (usage.cost.total) parts.push(`$${usage.cost.total.toFixed(4)}`);
|
|
810
|
+
return parts.join(" · ");
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function taskSummary(result: SubagentTaskResult): string {
|
|
814
|
+
return `${result.id} · ${result.agent} · ${result.status} · ${formatUsage(result.usage)}`;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskResult[], maxBytes: number): string {
|
|
818
|
+
const sections = results.map((result) => {
|
|
819
|
+
const heading = `### ${result.id} · ${result.agent} · ${result.status}`;
|
|
820
|
+
const reason = result.terminationReason && result.terminationReason !== "completed" ? `\nReason: ${result.terminationReason}` : "";
|
|
821
|
+
const body = result.output || result.errorMessage || result.stderr.trim() || "(no output)";
|
|
822
|
+
const truncation = result.outputTruncatedBytes ? `\n\n[Task output truncated: ${result.outputTruncatedBytes} bytes omitted; bounded detail is available when expanded.]` : "";
|
|
823
|
+
return `${heading}${reason}\n\n${body}${truncation}`;
|
|
824
|
+
});
|
|
825
|
+
const complete = results.filter((result) => result.status === "complete").length;
|
|
826
|
+
const text = `${mode}: ${complete}/${results.length} complete · ${formatUsage(aggregateUsage(results))}\n\n${sections.join("\n\n---\n\n")}`;
|
|
827
|
+
const marker = "\n\n[Combined subagent output truncated to 50 KiB; inspect the expanded tool result for bounded per-task details.]";
|
|
828
|
+
const capped = truncateUtf8(text, Math.max(0, maxBytes - Buffer.byteLength(marker)));
|
|
829
|
+
return capped.omittedBytes ? `${capped.text}${marker}` : text;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function statusColor(status: SubagentStatus): "accent" | "success" | "error" | "warning" | "muted" {
|
|
833
|
+
if (status === "running") return "accent";
|
|
834
|
+
if (status === "complete") return "success";
|
|
835
|
+
if (status === "failed") return "error";
|
|
836
|
+
if (status === "limited" || status === "cancelled") return "warning";
|
|
837
|
+
return "muted";
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function statusIcon(status: SubagentStatus): string {
|
|
841
|
+
if (status === "running") return "✻";
|
|
842
|
+
if (status === "complete") return "✓";
|
|
843
|
+
if (status === "failed") return "✗";
|
|
844
|
+
if (status === "queued") return "○";
|
|
845
|
+
return "!";
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}): void {
|
|
849
|
+
const limits = { ...SUBAGENT_LIMITS, ...options.limits };
|
|
850
|
+
const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
|
|
851
|
+
|
|
852
|
+
pi.registerTool({
|
|
853
|
+
name: "subagent",
|
|
854
|
+
label: "Subagents",
|
|
855
|
+
description: "Delegate one task, up to eight parallel tasks, or a sequential chain to isolated Pi child roles. Bundled and personal roles are available by default; trusted project roles require project/both scope and confirmation. Children have explicit tools, no extension/skill/template discovery, at most 12 turns, ten minutes, 2 MiB trace, 64 KiB stderr, and 50 KiB returned output per task.",
|
|
856
|
+
promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
|
|
857
|
+
promptGuidelines: [
|
|
858
|
+
"Use subagent for clearly separable specialist work; prefer read-only scout, planner, or reviewer roles before the sequential writer.",
|
|
859
|
+
"Do not request multiple write-capable subagents in one parallel batch.",
|
|
860
|
+
],
|
|
861
|
+
parameters: SubagentParams,
|
|
862
|
+
executionMode: "sequential",
|
|
863
|
+
|
|
864
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
865
|
+
const scope: AgentScope = params.agentScope ?? "user";
|
|
866
|
+
const hasSingleFields = params.agent !== undefined || params.task !== undefined;
|
|
867
|
+
const hasParallel = params.tasks !== undefined;
|
|
868
|
+
const hasChain = params.chain !== undefined;
|
|
869
|
+
const hasSingle = hasSingleFields && Boolean(params.agent && params.task);
|
|
870
|
+
if (Number(hasSingleFields) + Number(hasParallel) + Number(hasChain) !== 1
|
|
871
|
+
|| hasSingleFields && !hasSingle
|
|
872
|
+
|| hasParallel && params.tasks!.length === 0
|
|
873
|
+
|| hasChain && params.chain!.length === 0) {
|
|
874
|
+
throw new Error("Provide exactly one subagent mode: agent + task, tasks, or chain");
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const discovery = discoverAgentRoles(ctx.cwd, scope, ctx.isProjectTrusted(), options);
|
|
878
|
+
const roles = new Map(discovery.agents.map((agent) => [agent.name, agent]));
|
|
879
|
+
const requested = requestedAgents(params);
|
|
880
|
+
for (const name of requested) {
|
|
881
|
+
if (!roles.has(name)) {
|
|
882
|
+
const available = discovery.agents.map((agent) => `${agent.name} (${agent.source})`).join(", ") || "none";
|
|
883
|
+
throw new Error(`Unknown subagent ${JSON.stringify(name)}. Available: ${available}`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const projectRoles = [...new Set(requested.map((name) => roles.get(name)!).filter((role) => role.source === "project"))];
|
|
888
|
+
if (projectRoles.length) {
|
|
889
|
+
if (!ctx.hasUI) throw new Error("Project-local subagents require interactive confirmation");
|
|
890
|
+
const approved = await ctx.ui.confirm(
|
|
891
|
+
"Run project-local subagents?",
|
|
892
|
+
`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.`,
|
|
893
|
+
);
|
|
894
|
+
if (!approved) throw new Error("Project-local subagents were not approved");
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const resolvedModels = new Map<string, ResolvedModel>();
|
|
898
|
+
for (const name of new Set(requested)) resolvedModels.set(name, resolveAgentModel(roles.get(name)!, ctx));
|
|
899
|
+
|
|
900
|
+
const mode: SubagentDetails["mode"] = hasParallel ? "parallel" : hasChain ? "chain" : "single";
|
|
901
|
+
const inputs: TaskInput[] = hasSingle
|
|
902
|
+
? [{ agent: params.agent!, task: params.task! }]
|
|
903
|
+
: hasParallel ? params.tasks! : params.chain!;
|
|
904
|
+
if (inputs.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
|
|
905
|
+
if (hasParallel) {
|
|
906
|
+
const writers = inputs.filter((input) => roles.get(input.agent)!.access === "write");
|
|
907
|
+
if (writers.length > 1) throw new Error("Parallel batches may contain at most one write-capable subagent; writers are serialized");
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const invocationPrefix = randomUUID().slice(0, 8);
|
|
911
|
+
const createTaskId = options.createTaskId ?? ((index: number) => `${invocationPrefix}-${index + 1}`);
|
|
912
|
+
const results = inputs.map((input, index) => makeQueuedResult(createTaskId(index), input.agent, input.task, hasChain ? index + 1 : undefined));
|
|
913
|
+
const emit = (message = `${mode}: ${results.filter((result) => !["queued", "running"].includes(result.status)).length}/${results.length} settled`): void => {
|
|
914
|
+
(onUpdate as ToolUpdate | undefined)?.({
|
|
915
|
+
content: [{ type: "text", text: message }],
|
|
916
|
+
details: cloneDetails(mode, scope, discovery.projectAgentsDir, results),
|
|
917
|
+
});
|
|
918
|
+
};
|
|
919
|
+
const runAt = async (index: number, task: string): Promise<void> => {
|
|
920
|
+
if (signal?.aborted) {
|
|
921
|
+
results[index] = { ...results[index]!, status: "cancelled", terminationReason: "abort" };
|
|
922
|
+
emit();
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
const input = inputs[index]!;
|
|
926
|
+
if ([...task].length > limits.taskCharacters) {
|
|
927
|
+
results[index] = {
|
|
928
|
+
...results[index]!,
|
|
929
|
+
status: "failed",
|
|
930
|
+
terminationReason: "task_limit",
|
|
931
|
+
errorMessage: `Expanded task exceeds ${limits.taskCharacters} characters`,
|
|
932
|
+
};
|
|
933
|
+
emit();
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
results[index] = await runTask({
|
|
937
|
+
cwd: ctx.cwd,
|
|
938
|
+
agent: roles.get(input.agent)!,
|
|
939
|
+
task,
|
|
940
|
+
id: results[index]!.id,
|
|
941
|
+
step: results[index]!.step,
|
|
942
|
+
model: resolvedModels.get(input.agent)!,
|
|
943
|
+
signal,
|
|
944
|
+
spawnProcess,
|
|
945
|
+
limits,
|
|
946
|
+
onChange: (next) => {
|
|
947
|
+
results[index] = cloneResult(next);
|
|
948
|
+
emit();
|
|
949
|
+
},
|
|
950
|
+
});
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
emit(`${mode}: ${results.length} queued`);
|
|
954
|
+
if (hasChain) {
|
|
955
|
+
let previous = "";
|
|
956
|
+
for (let index = 0; index < inputs.length; index += 1) {
|
|
957
|
+
const task = inputs[index]!.task.replaceAll("{previous}", previous);
|
|
958
|
+
await runAt(index, task);
|
|
959
|
+
if (results[index]!.status !== "complete") break;
|
|
960
|
+
previous = results[index]!.output;
|
|
961
|
+
}
|
|
962
|
+
for (const result of results) {
|
|
963
|
+
if (result.status === "queued") {
|
|
964
|
+
result.status = signal?.aborted ? "cancelled" : "failed";
|
|
965
|
+
result.terminationReason = signal?.aborted ? "abort" : "chain_stopped";
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
} else if (hasParallel) {
|
|
969
|
+
const readIndexes = inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "read");
|
|
970
|
+
const writerIndex = inputs.findIndex((input) => roles.get(input.agent)!.access === "write");
|
|
971
|
+
await mapReadTasks(readIndexes, limits.maxReadConcurrency, async ({ index }) => runAt(index, inputs[index]!.task));
|
|
972
|
+
if (writerIndex >= 0) await runAt(writerIndex, inputs[writerIndex]!.task);
|
|
973
|
+
} else {
|
|
974
|
+
await runAt(0, inputs[0]!.task);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const details = cloneDetails(mode, scope, discovery.projectAgentsDir, results);
|
|
978
|
+
return {
|
|
979
|
+
content: [{ type: "text", text: buildToolContent(mode, details.results, limits.toolOutputBytes) }],
|
|
980
|
+
details,
|
|
981
|
+
usage: details.aggregateUsage,
|
|
982
|
+
};
|
|
983
|
+
},
|
|
984
|
+
|
|
985
|
+
renderCall(args, theme) {
|
|
986
|
+
const scope = args.agentScope ?? "user";
|
|
987
|
+
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);
|
|
988
|
+
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);
|
|
989
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "…")}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
990
|
+
},
|
|
991
|
+
|
|
992
|
+
renderResult(result, { expanded }, theme) {
|
|
993
|
+
const details = result.details as SubagentDetails | undefined;
|
|
994
|
+
if (!details?.results.length) {
|
|
995
|
+
const first = result.content[0];
|
|
996
|
+
return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
|
|
997
|
+
}
|
|
998
|
+
if (!expanded) {
|
|
999
|
+
const lines = details.results.map((task) => {
|
|
1000
|
+
const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);
|
|
1001
|
+
return `${status} ${theme.fg("toolTitle", theme.bold(task.agent))}${theme.fg("dim", ` · ${task.id} · ${formatUsage(task.usage)}`)}`;
|
|
1002
|
+
});
|
|
1003
|
+
lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
|
|
1004
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const container = new Container();
|
|
1008
|
+
container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
|
|
1009
|
+
for (const task of details.results) {
|
|
1010
|
+
container.addChild(new Spacer(1));
|
|
1011
|
+
const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);
|
|
1012
|
+
container.addChild(new Text(`${status} ${theme.fg("accent", task.agent)}${theme.fg("dim", ` · ${task.id} · ${task.agentSource}`)}`, 0, 0));
|
|
1013
|
+
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));
|
|
1014
|
+
container.addChild(new Text(theme.fg("muted", `Task: ${task.task}`), 0, 0));
|
|
1015
|
+
for (const entry of task.trace) container.addChild(new Text(`${theme.fg("muted", "→ ")}${theme.fg("toolOutput", entry)}`, 0, 0));
|
|
1016
|
+
if (task.traceTruncatedBytes || task.stderrTruncatedBytes || task.outputTruncatedBytes) {
|
|
1017
|
+
container.addChild(new Text(theme.fg("warning", `Truncated · trace ${task.traceTruncatedBytes} B · stderr ${task.stderrTruncatedBytes} B · output ${task.outputTruncatedBytes} B`), 0, 0));
|
|
1018
|
+
}
|
|
1019
|
+
if (task.output) container.addChild(new Markdown(task.output, 0, 0, getMarkdownTheme()));
|
|
1020
|
+
else if (task.errorMessage || task.stderr) container.addChild(new Text(theme.fg("error", task.errorMessage || task.stderr), 0, 0));
|
|
1021
|
+
}
|
|
1022
|
+
container.addChild(new Spacer(1));
|
|
1023
|
+
container.addChild(new Text(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)}`), 0, 0));
|
|
1024
|
+
return container;
|
|
1025
|
+
},
|
|
1026
|
+
});
|
|
1027
|
+
}
|