killeros 1.4.3 → 1.4.6
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 +19 -0
- package/README.md +7 -7
- package/agents/debugger.md +0 -1
- package/agents/documenter.md +0 -1
- package/agents/planner.md +0 -1
- package/agents/reviewer.md +1 -2
- package/agents/scout.md +2 -3
- package/agents/security.md +1 -2
- package/agents/tester.md +0 -1
- package/agents/worker.md +0 -1
- package/package.json +1 -2
- package/subagent-process.ts +136 -44
- package/subagent-ui.ts +26 -9
- package/subagents.ts +129 -118
- package/subagent-budget.ts +0 -65
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to KillerOS are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.4.6] - 2026-08-01
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Made the registered task schema use the same ten-task limit as runtime validation.
|
|
10
|
+
- Kept one isolated Pi session ID and session directory across steering restarts so a child retains its conversation.
|
|
11
|
+
- Bound retained trace, stderr, and returned text, and spooled large JSONL lines to temporary storage without stopping the child or reporting a retention cutoff as `limited`.
|
|
12
|
+
- Kept explicit embedding resource guards and user stops visible as terminal states.
|
|
13
|
+
|
|
14
|
+
## [1.4.5] - 2026-08-01
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Removed the child-budget extension, its read-tool budget, and the default 250,000-token/$5 quota.
|
|
19
|
+
- Removed default child wall-time, JSONL-line, trace, stderr, returned-output, and model-output-length stops; role `timeoutMs` and other child guards are now opt-in.
|
|
20
|
+
- Removed forced early-report prompt text so roles can finish their assigned work naturally.
|
|
21
|
+
- Treat model stop reason `length` as a completed child process instead of inventing a KillerOS `limited` result.
|
|
22
|
+
- Documented the child lifecycle contract: children complete naturally; explicit user interruptions, configured guards, and real child-process failures remain visible.
|
|
23
|
+
|
|
5
24
|
## [1.4.3] - 2026-08-01
|
|
6
25
|
|
|
7
26
|
### Fixed
|
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
|
33
33
|
Pin an install to a release:
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
|
|
36
|
+
pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.4.6
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Add `-l` to either command for a project-only install. Restart Pi after installing.
|
|
@@ -47,7 +47,7 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
|
|
|
47
47
|
- 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
|
|
48
48
|
- `/variants` selector and direct reasoning-level arguments
|
|
49
49
|
- Codex-style `/goal` for durable long-running objectives with pause, resume, edit, clear, automatic continuation, and explicit completion
|
|
50
|
-
- Pi-native `subagent` tool with named, inspectable child threads, Markdown roles, explicit read/write boundaries, parent controls,
|
|
50
|
+
- Pi-native `subagent` tool with named, inspectable child threads, Markdown roles, explicit read/write boundaries, parent controls, natural completion, and cancellation propagation
|
|
51
51
|
- Claude Code-style `/init` that scans the repository and generates a concise root `AGENTS.md` without setup questions
|
|
52
52
|
- `question` tool with filtering, proposal previews, keyboard selection, custom answers, history, cancellation, and resize-safe rendering
|
|
53
53
|
- Mid-prompt slash completion with current Pi `0.82.1` commands, extensions, prompts, and skills
|
|
@@ -89,7 +89,7 @@ KillerOS ships `planner`, `reviewer`, `scout`, and `security` as read-only roles
|
|
|
89
89
|
| `scout` | read | Map unfamiliar code and return an evidence trail |
|
|
90
90
|
| `security` | read | Audit trust boundaries and report concrete security findings |
|
|
91
91
|
| `tester` | write | Add focused coverage and run deterministic verification |
|
|
92
|
-
| `worker` | write | Execute
|
|
92
|
+
| `worker` | write | Execute the assigned repository change |
|
|
93
93
|
|
|
94
94
|
1. Bundled: `<killeros>/agents/*.md`
|
|
95
95
|
2. Personal: `~/.pi/agent/agents/*.md`
|
|
@@ -103,7 +103,7 @@ The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `
|
|
|
103
103
|
{"agent":"reviewer","task":"Review the change","model":"provider/model","thinking":"high"}
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
Use the separate `model` and `thinking` fields for new configuration. The older `provider/model:thinking` model form remains accepted. Children run as
|
|
106
|
+
Use the separate `model` and `thinking` fields for new configuration. The older `provider/model:thinking` model form remains accepted. Children run as isolated `pi --mode json -p` processes with a private `--session-dir` and `--session-id`, plus explicit local tools and `web_search`, `source_check`, `fetch_content`, and `get_search_content`. Steering restarts the same child session, so the child keeps its prior conversation. Each child explicitly loads `npm:pi-web-access`, discovers available skills, and keeps arbitrary extensions and prompt templates disabled; project-local skills load only when the parent project is trusted. Every bundled role is instructed to load the most relevant `SKILL.md` and report useful evidence. Children have no default token, dollar, turn, tool-call, research, wall-time, JSONL-line, trace, stderr, or returned-output execution quota. KillerOS bounds retained trace, stderr, and returned text and spills a large JSONL line to temporary storage; retention never stops a child or marks it `limited`. The parent limits each request to ten tasks and four parallel readers and bounds role files, task input, and combined parent output. An embedding caller may opt into named child resource guards. Esc cancellation terminates active children and escalates after five seconds.
|
|
107
107
|
|
|
108
108
|
### Thread lifecycle
|
|
109
109
|
|
|
@@ -113,7 +113,7 @@ Threads move through `queued`, `active`, `done`, `failed`, `stopped`, and `close
|
|
|
113
113
|
|
|
114
114
|
The parent can inspect a thread’s prompt, role, model, tools, trace, usage, and handoff; steer an active thread with one bounded follow-up; interrupt one child or all active children; collect a concise handoff into parent context; and close a finished or stopped thread. An interrupt preserves the partial trace, states the reason, and reports the handoff as partial rather than successful.
|
|
115
115
|
|
|
116
|
-
A child completes naturally when it returns a final answer.
|
|
116
|
+
A child completes naturally when it returns a final answer. The default path has no per-child execution quota. Explicit embedding options can add wall-time, output, trace, stderr, JSONL, token, or cost guards; those guards report their cause and return partial work clearly. The parent still bounds task count, reader concurrency, role files, task input, and combined parent output. Explicit user interruptions and real child-process failures remain visible. Esc cancellation terminates active children and escalates after five seconds.
|
|
117
117
|
|
|
118
118
|
The replacement lifecycle has nine phases:
|
|
119
119
|
|
|
@@ -123,7 +123,7 @@ The replacement lifecycle has nine phases:
|
|
|
123
123
|
4. **Steer:** append a bounded parent follow-up to an active thread.
|
|
124
124
|
5. **Interrupt:** stop one or all active children while preserving partial work.
|
|
125
125
|
6. **Collect:** return a concise handoff while retaining the expanded trace.
|
|
126
|
-
7. **
|
|
126
|
+
7. **Guard:** honor only explicitly configured child resource guards; do not impose a routine turn stop.
|
|
127
127
|
8. **Close:** remove a finished or stopped thread from the workspace without deleting its result record.
|
|
128
128
|
9. **Prove:** test identity, visibility, controls, natural completion, guards, partial handoffs, and closure.
|
|
129
129
|
|
|
@@ -163,7 +163,7 @@ The package manifest lists Pi’s built-in modules as peer dependencies, so npm
|
|
|
163
163
|
|
|
164
164
|
The [`pi-package`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) keyword makes a published npm release visible in Pi’s package catalog.
|
|
165
165
|
|
|
166
|
-
For
|
|
166
|
+
For release `1.4.6`, publish after the validation checks pass:
|
|
167
167
|
|
|
168
168
|
```bash
|
|
169
169
|
npm login
|
package/agents/debugger.md
CHANGED
package/agents/documenter.md
CHANGED
package/agents/planner.md
CHANGED
package/agents/reviewer.md
CHANGED
|
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, web_search, source_check, fetch_content, get_search
|
|
|
6
6
|
# Replace inherit with provider/model to pin this role; set thinking separately when needed.
|
|
7
7
|
model: inherit
|
|
8
8
|
thinking: inherit
|
|
9
|
-
timeoutMs: 300000
|
|
10
9
|
---
|
|
11
10
|
|
|
12
11
|
# Role
|
|
@@ -39,7 +38,7 @@ If no concrete issue is found, report the reviewed scope, protections checked, a
|
|
|
39
38
|
|
|
40
39
|
## Boundaries
|
|
41
40
|
|
|
42
|
-
Do not edit files or fix findings yourself. Do not report stylistic preferences as defects. Do not claim a vulnerability, regression, or test gap without repository evidence.
|
|
41
|
+
Do not edit files or fix findings yourself. Do not report stylistic preferences as defects. Do not claim a vulnerability, regression, or test gap without repository evidence. Finish the assigned review when the evidence resolves its scope, report the findings or clean result, and do not chase unrelated work.
|
|
43
42
|
|
|
44
43
|
## Skills and web research
|
|
45
44
|
|
package/agents/scout.md
CHANGED
|
@@ -6,12 +6,11 @@ tools: read, grep, find, ls, web_search, source_check, fetch_content, get_search
|
|
|
6
6
|
# Replace inherit with provider/model to pin this role; set thinking separately when needed.
|
|
7
7
|
model: inherit
|
|
8
8
|
thinking: inherit
|
|
9
|
-
timeoutMs: 300000
|
|
10
9
|
---
|
|
11
10
|
|
|
12
11
|
# Role
|
|
13
12
|
|
|
14
|
-
You are the `scout` role, a
|
|
13
|
+
You are the `scout` role, a focused repository investigator. Your job is to make an unfamiliar codebase legible to the parent agent, not to become an unrequested implementer or produce an exhaustive directory tour.
|
|
15
14
|
|
|
16
15
|
## Mission
|
|
17
16
|
|
|
@@ -23,7 +22,7 @@ Begin with the user’s actual question and define what evidence would answer it
|
|
|
23
22
|
2. **Trace.** Follow the real path from input or command to state, side effect, and output. Follow callers and callees only when the current evidence requires it.
|
|
24
23
|
3. **Compare.** Check neighboring implementations, overrides, fixtures, generated files, and documentation when they could change the conclusion.
|
|
25
24
|
4. **Verify.** Record exact paths, symbols, commands, and conventions. Distinguish observed facts from inferences and unresolved questions.
|
|
26
|
-
5. **
|
|
25
|
+
5. **Finish.** Once evidence closes the parent’s question, report the findings and stop. Do not chase unrelated files, generic architecture advice, or speculative fixes.
|
|
27
26
|
|
|
28
27
|
## Report
|
|
29
28
|
|
package/agents/security.md
CHANGED
|
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, web_search, source_check, fetch_content, get_search
|
|
|
6
6
|
# Replace inherit with provider/model to pin this role; set thinking separately when needed.
|
|
7
7
|
model: inherit
|
|
8
8
|
thinking: inherit
|
|
9
|
-
timeoutMs: 300000
|
|
10
9
|
---
|
|
11
10
|
|
|
12
11
|
# Role
|
|
@@ -35,7 +34,7 @@ Relevant lenses may include:
|
|
|
35
34
|
|
|
36
35
|
## Finding standard
|
|
37
36
|
|
|
38
|
-
Every confirmed finding must name the severity, precondition or trigger, affected path or symbol, evidence, security impact, and smallest safe correction. Separate questions and blind spots from findings. If no concrete issue is found, state the scope, lenses applied, controls verified, and meaningful limitations.
|
|
37
|
+
Every confirmed finding must name the severity, precondition or trigger, affected path or symbol, evidence, security impact, and smallest safe correction. Separate questions and blind spots from findings. If no concrete issue is found, state the scope, lenses applied, controls verified, and meaningful limitations. Finish the assigned review when the evidence resolves its scope, report the findings or clean result, and do not chase unrelated work.
|
|
39
38
|
|
|
40
39
|
## Skills and web research
|
|
41
40
|
|
package/agents/tester.md
CHANGED
package/agents/worker.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "killeros",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.6",
|
|
4
4
|
"description": "A production-hardened TUI and workflow extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
"subagents.ts",
|
|
25
25
|
"subagent-lifecycle.ts",
|
|
26
26
|
"subagent-process.ts",
|
|
27
|
-
"subagent-budget.ts",
|
|
28
27
|
"subagent-ui.ts",
|
|
29
28
|
"agents/*.md",
|
|
30
29
|
"themes/killeros.json",
|
package/subagent-process.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { statSync } from "node:fs";
|
|
2
|
+
import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
|
|
5
6
|
export const SUBAGENT_PROCESS_LIMITS = {
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
killGraceMs: 5_000,
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export const SUBAGENT_PROCESS_RETENTION = {
|
|
11
|
+
jsonlMemoryBytes: 1 * 1024 * 1024,
|
|
8
12
|
traceBytes: 2 * 1024 * 1024,
|
|
9
13
|
stderrBytes: 64 * 1024,
|
|
10
|
-
outputBytes:
|
|
11
|
-
quotaTokens: 250_000,
|
|
12
|
-
quotaUsd: 5,
|
|
13
|
-
killGraceMs: 5_000,
|
|
14
|
+
outputBytes: 1 * 1024 * 1024,
|
|
14
15
|
} as const;
|
|
15
16
|
|
|
16
17
|
export type SubagentProcessStatus = "running" | "complete" | "failed" | "cancelled" | "limited";
|
|
@@ -63,11 +64,12 @@ export interface SubagentProcessChild {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
export interface SubagentProcessOptions {
|
|
66
|
-
/** Exact Pi arguments. Include `--mode json` and `--no-session
|
|
67
|
+
/** Exact Pi arguments. Include `--mode json` and either `--no-session` or an isolated session id and directory. */
|
|
67
68
|
args: readonly string[];
|
|
68
69
|
cwd: string;
|
|
69
70
|
signal?: AbortSignal;
|
|
70
71
|
limits?: Partial<SubagentProcessLimits>;
|
|
72
|
+
retention?: Partial<SubagentProcessRetention>;
|
|
71
73
|
environment?: NodeJS.ProcessEnv;
|
|
72
74
|
onUpdate?: (result: Readonly<SubagentProcessResult>) => void;
|
|
73
75
|
/** Test or embed hook. It receives the exact Pi arguments supplied above. */
|
|
@@ -75,14 +77,21 @@ export interface SubagentProcessOptions {
|
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
export interface SubagentProcessLimits {
|
|
78
|
-
wallTimeMs
|
|
79
|
-
jsonlLineBytes
|
|
80
|
+
wallTimeMs?: number;
|
|
81
|
+
jsonlLineBytes?: number;
|
|
82
|
+
traceBytes?: number;
|
|
83
|
+
stderrBytes?: number;
|
|
84
|
+
outputBytes?: number;
|
|
85
|
+
quotaTokens?: number;
|
|
86
|
+
quotaUsd?: number;
|
|
87
|
+
killGraceMs: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface SubagentProcessRetention {
|
|
91
|
+
jsonlMemoryBytes: number;
|
|
80
92
|
traceBytes: number;
|
|
81
93
|
stderrBytes: number;
|
|
82
94
|
outputBytes: number;
|
|
83
|
-
quotaTokens: number;
|
|
84
|
-
quotaUsd: number;
|
|
85
|
-
killGraceMs: number;
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
export interface SubagentProcessHandle {
|
|
@@ -178,11 +187,11 @@ function traceMessage(message: any): string[] {
|
|
|
178
187
|
return entries;
|
|
179
188
|
}
|
|
180
189
|
|
|
181
|
-
function appendTrace(result: SubagentProcessResult, entries: string[], maxBytes: number): boolean {
|
|
190
|
+
function appendTrace(result: SubagentProcessResult, entries: string[], maxBytes: number | undefined): boolean {
|
|
182
191
|
let truncated = false;
|
|
183
192
|
for (const entry of entries) {
|
|
184
193
|
const entryBytes = Buffer.byteLength(entry, "utf8");
|
|
185
|
-
const retained = truncateUtf8(entry, Math.max(0, maxBytes - result.traceBytes));
|
|
194
|
+
const retained = truncateUtf8(entry, maxBytes === undefined ? entryBytes : Math.max(0, maxBytes - result.traceBytes));
|
|
186
195
|
if (retained.text) result.trace.push(retained.text);
|
|
187
196
|
result.traceBytes += Buffer.byteLength(retained.text, "utf8");
|
|
188
197
|
result.traceTruncatedBytes += entryBytes - Buffer.byteLength(retained.text, "utf8");
|
|
@@ -195,19 +204,35 @@ function hasJsonMode(args: readonly string[]): boolean {
|
|
|
195
204
|
return args.some((arg, index) => arg === "--mode=json" || arg === "--mode" && args[index + 1] === "json");
|
|
196
205
|
}
|
|
197
206
|
|
|
207
|
+
function hasIsolatedSession(args: readonly string[]): boolean {
|
|
208
|
+
const sessionId = args.indexOf("--session-id");
|
|
209
|
+
const sessionDir = args.indexOf("--session-dir");
|
|
210
|
+
return sessionId >= 0 && typeof args[sessionId + 1] === "string"
|
|
211
|
+
&& sessionDir >= 0 && typeof args[sessionDir + 1] === "string";
|
|
212
|
+
}
|
|
213
|
+
|
|
198
214
|
function normalizeLimits(overrides: Partial<SubagentProcessLimits> | undefined): SubagentProcessLimits {
|
|
199
215
|
const limits = { ...SUBAGENT_PROCESS_LIMITS, ...overrides };
|
|
200
216
|
for (const name of ["wallTimeMs", "jsonlLineBytes", "traceBytes", "stderrBytes", "outputBytes", "killGraceMs"] as const) {
|
|
201
217
|
const value = limits[name];
|
|
202
|
-
if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
|
|
218
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) throw new RangeError(`${name} must be a positive safe integer`);
|
|
203
219
|
}
|
|
204
220
|
for (const name of ["quotaTokens", "quotaUsd"] as const) {
|
|
205
221
|
const value = limits[name];
|
|
206
|
-
if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be a positive finite number`);
|
|
222
|
+
if (value !== undefined && (!Number.isFinite(value) || value <= 0)) throw new RangeError(`${name} must be a positive finite number`);
|
|
207
223
|
}
|
|
208
224
|
return limits;
|
|
209
225
|
}
|
|
210
226
|
|
|
227
|
+
function normalizeRetention(overrides: Partial<SubagentProcessRetention> | undefined): SubagentProcessRetention {
|
|
228
|
+
const retention = { ...SUBAGENT_PROCESS_RETENTION, ...overrides };
|
|
229
|
+
for (const name of ["jsonlMemoryBytes", "traceBytes", "stderrBytes", "outputBytes"] as const) {
|
|
230
|
+
const value = retention[name];
|
|
231
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
|
|
232
|
+
}
|
|
233
|
+
return retention;
|
|
234
|
+
}
|
|
235
|
+
|
|
211
236
|
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
212
237
|
const currentScript = process.argv[1];
|
|
213
238
|
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
@@ -270,13 +295,16 @@ function terminateProcess(child: SubagentProcessChild, force: boolean): void {
|
|
|
270
295
|
|
|
271
296
|
/**
|
|
272
297
|
* Run one isolated Pi JSON process. The caller owns all Pi arguments, including
|
|
273
|
-
* model, tools, prompt, and extension flags.
|
|
298
|
+
* model, tools, prompt, and extension flags. Resource limits are opt-in.
|
|
274
299
|
*/
|
|
275
300
|
export function runSubagentProcess(options: SubagentProcessOptions): SubagentProcessHandle {
|
|
276
301
|
const args = [...options.args];
|
|
277
302
|
if (!hasJsonMode(args)) throw new Error("Subagent Pi arguments must include --mode json");
|
|
278
|
-
if (!args.includes("--no-session")
|
|
303
|
+
if (!args.includes("--no-session") && !hasIsolatedSession(args)) {
|
|
304
|
+
throw new Error("Subagent Pi arguments must include --no-session or an isolated --session-id and --session-dir");
|
|
305
|
+
}
|
|
279
306
|
const limits = normalizeLimits(options.limits);
|
|
307
|
+
const retention = normalizeRetention(options.retention);
|
|
280
308
|
const startedAt = Date.now();
|
|
281
309
|
const state: SubagentProcessResult = {
|
|
282
310
|
status: "running",
|
|
@@ -302,6 +330,8 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
302
330
|
let requestedReason: string | undefined;
|
|
303
331
|
let stdoutLine = Buffer.alloc(0);
|
|
304
332
|
let stdoutLineBytes = 0;
|
|
333
|
+
let stdoutLineSpoolDirectory: string | undefined;
|
|
334
|
+
let stdoutLineSpoolDescriptor: number | undefined;
|
|
305
335
|
let stderr = Buffer.alloc(0);
|
|
306
336
|
let outputBytesSeen = 0;
|
|
307
337
|
let forceTimer: NodeJS.Timeout | undefined;
|
|
@@ -310,16 +340,53 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
310
340
|
let resolveResult!: (result: SubagentProcessResult) => void;
|
|
311
341
|
const result = new Promise<SubagentProcessResult>((resolve) => { resolveResult = resolve; });
|
|
312
342
|
|
|
343
|
+
const clearStdoutLine = (): void => {
|
|
344
|
+
if (stdoutLineSpoolDescriptor !== undefined) {
|
|
345
|
+
try {
|
|
346
|
+
closeSync(stdoutLineSpoolDescriptor);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
state.errorMessage ??= `Could not close child JSONL spool: ${error instanceof Error ? error.message : String(error)}`;
|
|
349
|
+
}
|
|
350
|
+
stdoutLineSpoolDescriptor = undefined;
|
|
351
|
+
}
|
|
352
|
+
if (stdoutLineSpoolDirectory) {
|
|
353
|
+
try {
|
|
354
|
+
rmSync(stdoutLineSpoolDirectory, { recursive: true, force: true });
|
|
355
|
+
} catch (error) {
|
|
356
|
+
state.errorMessage ??= `Could not remove child JSONL spool: ${error instanceof Error ? error.message : String(error)}`;
|
|
357
|
+
}
|
|
358
|
+
stdoutLineSpoolDirectory = undefined;
|
|
359
|
+
}
|
|
360
|
+
stdoutLine = Buffer.alloc(0);
|
|
361
|
+
stdoutLineBytes = 0;
|
|
362
|
+
};
|
|
363
|
+
const readStdoutLine = (): string => {
|
|
364
|
+
if (stdoutLineSpoolDirectory) {
|
|
365
|
+
const filePath = path.join(stdoutLineSpoolDirectory, "line.jsonl");
|
|
366
|
+
try {
|
|
367
|
+
if (stdoutLineSpoolDescriptor !== undefined) {
|
|
368
|
+
closeSync(stdoutLineSpoolDescriptor);
|
|
369
|
+
stdoutLineSpoolDescriptor = undefined;
|
|
370
|
+
}
|
|
371
|
+
return readFileSync(filePath, "utf8");
|
|
372
|
+
} catch (error) {
|
|
373
|
+
state.errorMessage ??= `Could not read child JSONL spool: ${error instanceof Error ? error.message : String(error)}`;
|
|
374
|
+
return "";
|
|
375
|
+
} finally {
|
|
376
|
+
clearStdoutLine();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const line = stdoutLine.toString("utf8", 0, stdoutLineBytes);
|
|
380
|
+
clearStdoutLine();
|
|
381
|
+
return line;
|
|
382
|
+
};
|
|
383
|
+
|
|
313
384
|
const publish = (): void => options.onUpdate?.(cloneResult(state));
|
|
314
385
|
const finish = (code: number | null): void => {
|
|
315
386
|
if (closed || finishing) return;
|
|
316
387
|
finishing = true;
|
|
317
|
-
if (stdoutLineBytes && !requestedStatus)
|
|
318
|
-
|
|
319
|
-
stdoutLine = Buffer.alloc(0);
|
|
320
|
-
stdoutLineBytes = 0;
|
|
321
|
-
processLine(finalLine);
|
|
322
|
-
}
|
|
388
|
+
if (stdoutLineBytes && !requestedStatus) processLine(readStdoutLine());
|
|
389
|
+
else clearStdoutLine();
|
|
323
390
|
closed = true;
|
|
324
391
|
if (forceTimer) clearTimeout(forceTimer);
|
|
325
392
|
if (settleTimer) clearTimeout(settleTimer);
|
|
@@ -331,9 +398,12 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
331
398
|
if (requestedStatus) {
|
|
332
399
|
state.status = requestedStatus;
|
|
333
400
|
state.terminationReason = requestedReason;
|
|
334
|
-
} else if (code !== 0 || state.errorMessage || state.stopReason
|
|
335
|
-
state.status = "failed";
|
|
401
|
+
} else if (code !== 0 || state.errorMessage || state.stopReason === "error" || state.stopReason === "aborted") {
|
|
402
|
+
state.status = state.stopReason === "aborted" ? "cancelled" : "failed";
|
|
336
403
|
state.terminationReason ??= code === null ? "process_closed" : `exit_${code}`;
|
|
404
|
+
} else if (state.stopReason !== undefined && !["stop", "toolUse", "length"].includes(state.stopReason)) {
|
|
405
|
+
state.status = "failed";
|
|
406
|
+
state.terminationReason = state.stopReason;
|
|
337
407
|
} else if (state.usage.turns === 0) {
|
|
338
408
|
state.status = "failed";
|
|
339
409
|
state.terminationReason = "missing_assistant_message";
|
|
@@ -382,23 +452,26 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
382
452
|
state.usage.turns += 1;
|
|
383
453
|
addUsage(state.usage, { ...message.usage, turns: 0 });
|
|
384
454
|
state.toolCallCount += toolCallCount(message);
|
|
385
|
-
if (state.usage.totalTokens > limits.quotaTokens) {
|
|
455
|
+
if (limits.quotaTokens !== undefined && state.usage.totalTokens > limits.quotaTokens) {
|
|
386
456
|
requestTermination("limited", "quota_tokens", `Child token usage exceeds ${limits.quotaTokens}`);
|
|
387
|
-
} else if (state.usage.cost.total > limits.quotaUsd) {
|
|
457
|
+
} else if (limits.quotaUsd !== undefined && state.usage.cost.total > limits.quotaUsd) {
|
|
388
458
|
requestTermination("limited", "quota_cost", `Child cost exceeds $${limits.quotaUsd}`);
|
|
389
459
|
}
|
|
390
|
-
|
|
460
|
+
const traceTruncatedBefore = state.traceTruncatedBytes;
|
|
461
|
+
appendTrace(state, traceMessage(message), Math.min(retention.traceBytes, limits.traceBytes ?? retention.traceBytes));
|
|
462
|
+
if (limits.traceBytes !== undefined && state.traceTruncatedBytes > traceTruncatedBefore && state.traceBytes >= limits.traceBytes) {
|
|
391
463
|
requestTermination("limited", "trace_limit", `Retained child trace exceeds ${limits.traceBytes} bytes`);
|
|
392
464
|
}
|
|
393
465
|
const output = textContent(message);
|
|
394
466
|
if (output) {
|
|
395
|
-
const
|
|
467
|
+
const outputLimit = Math.min(retention.outputBytes, limits.outputBytes ?? retention.outputBytes);
|
|
468
|
+
const capped = truncateUtf8(output, outputLimit);
|
|
396
469
|
state.output = capped.text;
|
|
397
470
|
state.outputTruncatedBytes = capped.omittedBytes;
|
|
398
471
|
outputBytesSeen += Buffer.byteLength(output, "utf8");
|
|
399
472
|
state.outputBytes = outputBytesSeen;
|
|
400
|
-
state.outputTruncatedBytes = Math.max(state.outputTruncatedBytes, outputBytesSeen - limits.outputBytes);
|
|
401
|
-
if (outputBytesSeen > limits.outputBytes) requestTermination("limited", "output_limit", `Child output exceeds ${limits.outputBytes} bytes`);
|
|
473
|
+
state.outputTruncatedBytes = Math.max(state.outputTruncatedBytes, outputBytesSeen - (limits.outputBytes ?? retention.outputBytes));
|
|
474
|
+
if (limits.outputBytes !== undefined && outputBytesSeen > limits.outputBytes) requestTermination("limited", "output_limit", `Child output exceeds ${limits.outputBytes} bytes`);
|
|
402
475
|
}
|
|
403
476
|
if (typeof message.model === "string") state.model = message.provider ? `${message.provider}/${message.model}` : message.model;
|
|
404
477
|
if (typeof message.stopReason === "string") {
|
|
@@ -407,11 +480,12 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
407
480
|
else state.terminationReason = message.stopReason;
|
|
408
481
|
}
|
|
409
482
|
if (typeof message.errorMessage === "string") state.errorMessage = message.errorMessage;
|
|
410
|
-
if (message.stopReason === "length") requestTermination("limited", "model_output_limit");
|
|
411
483
|
publish();
|
|
412
484
|
} else if (event?.type === "tool_result_end" && event.message) {
|
|
413
485
|
const name = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
|
|
414
|
-
|
|
486
|
+
const traceTruncatedBefore = state.traceTruncatedBytes;
|
|
487
|
+
appendTrace(state, [`${name} result${event.message.isError ? " (error)" : ""}`], Math.min(retention.traceBytes, limits.traceBytes ?? retention.traceBytes));
|
|
488
|
+
if (limits.traceBytes !== undefined && state.traceTruncatedBytes > traceTruncatedBefore && state.traceBytes >= limits.traceBytes) {
|
|
415
489
|
requestTermination("limited", "trace_limit", `Retained child trace exceeds ${limits.traceBytes} bytes`);
|
|
416
490
|
}
|
|
417
491
|
publish();
|
|
@@ -419,12 +493,30 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
419
493
|
};
|
|
420
494
|
const appendStdout = (fragment: Buffer): boolean => {
|
|
421
495
|
const nextBytes = stdoutLineBytes + fragment.length;
|
|
422
|
-
if (nextBytes > limits.jsonlLineBytes) {
|
|
496
|
+
if (limits.jsonlLineBytes !== undefined && nextBytes > limits.jsonlLineBytes) {
|
|
423
497
|
requestTermination("limited", "jsonl_line_limit", `Child JSONL line exceeds ${limits.jsonlLineBytes} bytes`);
|
|
424
498
|
return false;
|
|
425
499
|
}
|
|
500
|
+
if (nextBytes > retention.jsonlMemoryBytes) {
|
|
501
|
+
try {
|
|
502
|
+
if (stdoutLineSpoolDescriptor === undefined) {
|
|
503
|
+
stdoutLineSpoolDirectory = mkdtempSync(path.join(os.tmpdir(), "killeros-jsonl-"));
|
|
504
|
+
stdoutLineSpoolDescriptor = openSync(path.join(stdoutLineSpoolDirectory, "line.jsonl"), "w");
|
|
505
|
+
if (stdoutLineBytes) writeSync(stdoutLineSpoolDescriptor, stdoutLine);
|
|
506
|
+
stdoutLine = Buffer.alloc(0);
|
|
507
|
+
}
|
|
508
|
+
if (fragment.length) writeSync(stdoutLineSpoolDescriptor, fragment);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
requestTermination("failed", "jsonl_spool_error", `Could not spool child JSONL: ${error instanceof Error ? error.message : String(error)}`);
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
stdoutLineBytes = nextBytes;
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
426
516
|
if (nextBytes > stdoutLine.length) {
|
|
427
|
-
const nextCapacity =
|
|
517
|
+
const nextCapacity = limits.jsonlLineBytes === undefined
|
|
518
|
+
? Math.max(nextBytes, stdoutLine.length * 2, 4_096)
|
|
519
|
+
: Math.min(limits.jsonlLineBytes, Math.max(nextBytes, stdoutLine.length * 2, 4_096));
|
|
428
520
|
const expanded = Buffer.allocUnsafe(nextCapacity);
|
|
429
521
|
stdoutLine.copy(expanded, 0, 0, stdoutLineBytes);
|
|
430
522
|
stdoutLine = expanded;
|
|
@@ -452,23 +544,23 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
452
544
|
const end = newline < 0 ? buffer.length : newline;
|
|
453
545
|
if (!appendStdout(buffer.subarray(offset, end))) return;
|
|
454
546
|
if (newline < 0) return;
|
|
455
|
-
|
|
456
|
-
stdoutLine = Buffer.alloc(0);
|
|
457
|
-
stdoutLineBytes = 0;
|
|
458
|
-
processLine(line);
|
|
547
|
+
processLine(readStdoutLine());
|
|
459
548
|
offset = newline + 1;
|
|
460
549
|
}
|
|
461
550
|
});
|
|
462
551
|
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
463
552
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
464
|
-
const
|
|
553
|
+
const stderrLimit = Math.min(retention.stderrBytes, limits.stderrBytes ?? retention.stderrBytes);
|
|
554
|
+
const retained = buffer.subarray(0, Math.max(0, stderrLimit - stderr.length));
|
|
465
555
|
if (retained.length) stderr = Buffer.concat([stderr, retained]);
|
|
466
556
|
state.stderrTruncatedBytes += buffer.length - retained.length;
|
|
467
|
-
if (
|
|
557
|
+
if (limits.stderrBytes !== undefined && stderr.length + state.stderrTruncatedBytes > limits.stderrBytes) {
|
|
558
|
+
requestTermination("limited", "stderr_limit", `Child stderr exceeds ${limits.stderrBytes} bytes`);
|
|
559
|
+
}
|
|
468
560
|
});
|
|
469
561
|
child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
|
|
470
562
|
child.once("close", finish);
|
|
471
|
-
timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
|
|
563
|
+
if (limits.wallTimeMs !== undefined) timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
|
|
472
564
|
options.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
473
565
|
if (options.signal?.aborted) abortHandler();
|
|
474
566
|
} catch (error) {
|
package/subagent-ui.ts
CHANGED
|
@@ -120,14 +120,31 @@ function toRecord(item: ThreadListItem): ThreadRecord {
|
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
function threadStatus(thread: Pick<ThreadRecord, "status" | "terminationReason">): ThreadStatus {
|
|
124
124
|
const reason = thread.terminationReason;
|
|
125
|
-
if (thread.status === "
|
|
126
|
-
if (thread.status === "
|
|
127
|
-
if (thread.status === "
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
if (thread.status === "failed" || reason === "error" || reason === "spawn_error" || reason === "process_closed" || reason === "missing_assistant_message" || reason === "malformed_jsonl" || reason === "invalid_usage" || reason?.startsWith("exit_")) return "failed";
|
|
126
|
+
if (thread.status === "cancelled" || reason === "abort" || reason === "interrupt") return "cancelled";
|
|
127
|
+
if (thread.status === "complete" || reason === "completed") return "complete";
|
|
128
|
+
return thread.status;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function threadReason(thread: Pick<ThreadRecord, "terminationReason" | "errorMessage">, status: ThreadStatus): string | undefined {
|
|
132
|
+
if (status === "cancelled" && thread.terminationReason === "interrupt") return "Interrupted by user.";
|
|
133
|
+
if (status === "cancelled" && thread.terminationReason === "abort") return "Cancelled by parent.";
|
|
134
|
+
if (status === "failed" && thread.errorMessage && thread.terminationReason) return `${thread.terminationReason}: ${thread.errorMessage}`;
|
|
135
|
+
if (status === "failed") return thread.errorMessage ?? thread.terminationReason ?? "Child process failed.";
|
|
136
|
+
return thread.terminationReason;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function formatThreadState(thread: Pick<ThreadRecord, "status" | "terminationReason" | "errorMessage">): ThreadStateView {
|
|
140
|
+
const status = threadStatus(thread);
|
|
141
|
+
const reason = threadReason(thread, status);
|
|
142
|
+
if (status === "complete") return { status, label: "Complete" };
|
|
143
|
+
if (status === "failed") return { status, label: "Failed", reason, partialWork: "Failed before completion. Any saved output is partial work." };
|
|
144
|
+
if (status === "cancelled") return { status, label: "Stopped", reason, partialWork: "Stopped before completion. Any saved output is partial work." };
|
|
145
|
+
if (status === "limited") return { status, label: "Limited", reason, partialWork: "Stopped at a limit before completion. Any saved output is partial work." };
|
|
146
|
+
if (status === "running") return { status, label: "Running", reason };
|
|
147
|
+
return { status, label: "Queued", reason };
|
|
131
148
|
}
|
|
132
149
|
|
|
133
150
|
export function formatThreadUsage(usage: ThreadUsage): ThreadUsageView {
|
|
@@ -148,9 +165,9 @@ export function formatThreadTrace(thread: Pick<ThreadRecord, "trace" | "traceTru
|
|
|
148
165
|
};
|
|
149
166
|
}
|
|
150
167
|
|
|
151
|
-
export function formatThreadHandoff(thread: Pick<ThreadRecord, "status" | "handoff" | "output">): ThreadHandoffView {
|
|
168
|
+
export function formatThreadHandoff(thread: Pick<ThreadRecord, "status" | "terminationReason" | "handoff" | "output">): ThreadHandoffView {
|
|
152
169
|
const text = thread.handoff ?? thread.output ?? "No handoff yet.";
|
|
153
|
-
return { label: "Handoff", text, isPartial: thread
|
|
170
|
+
return { label: "Handoff", text, isPartial: threadStatus(thread) !== "complete" };
|
|
154
171
|
}
|
|
155
172
|
|
|
156
173
|
export function formatThreadControls(status: ThreadStatus): readonly ThreadBoardControl[] {
|
package/subagents.ts
CHANGED
|
@@ -15,25 +15,17 @@ import {
|
|
|
15
15
|
} from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
17
17
|
import { Type } from "typebox";
|
|
18
|
-
import { CHILD_TOOL_BUDGET_ENV, type ChildToolBudget } from "./subagent-budget.ts";
|
|
19
18
|
import { SubagentThreadRegistry, type SubagentThread, type SubagentThreadId, type SubagentThreadState } from "./subagent-lifecycle.ts";
|
|
20
19
|
import { runSubagentProcess, type SubagentProcessHandle, type SubagentProcessResult } from "./subagent-process.ts";
|
|
21
20
|
import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadBoardRecord } from "./subagent-ui.ts";
|
|
22
21
|
|
|
23
22
|
export const SUBAGENT_LIMITS = {
|
|
24
|
-
maxTasks:
|
|
23
|
+
maxTasks: 10,
|
|
25
24
|
maxReadConcurrency: 4,
|
|
26
|
-
defaultTimeoutMs: 300_000,
|
|
27
|
-
maxTimeoutMs: 600_000,
|
|
28
|
-
jsonlLineBytes: 32 * 1024 * 1024,
|
|
29
|
-
traceBytes: 2 * 1024 * 1024,
|
|
30
|
-
stderrBytes: 64 * 1024,
|
|
31
|
-
taskOutputBytes: 50 * 1024,
|
|
32
25
|
toolOutputBytes: 50 * 1024,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
readToolBudgetHard: 32,
|
|
26
|
+
traceRetentionBytes: 8 * 1024 * 1024,
|
|
27
|
+
stderrRetentionBytes: 1 * 1024 * 1024,
|
|
28
|
+
taskOutputRetentionBytes: 1 * 1024 * 1024,
|
|
37
29
|
roleFileBytes: 64 * 1024,
|
|
38
30
|
taskCharacters: 20_000,
|
|
39
31
|
killGraceMs: 5_000,
|
|
@@ -44,16 +36,9 @@ const READ_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOLS]);
|
|
|
44
36
|
const WRITE_TOOLS = new Set(["bash", "edit", "write"]);
|
|
45
37
|
const KNOWN_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
|
|
46
38
|
const SUBAGENT_WEB_EXTENSION = "npm:pi-web-access";
|
|
47
|
-
const SUBAGENT_BUDGET_EXTENSION = fileURLToPath(new URL("./subagent-budget.ts", import.meta.url));
|
|
48
39
|
const INHERIT_SETTING = "inherit";
|
|
49
40
|
const MAX_RUNTIME_STEERING_MESSAGES = 20;
|
|
50
41
|
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");
|
|
57
42
|
|
|
58
43
|
type ThinkingLevel = ModelThinkingLevel;
|
|
59
44
|
export type AgentAccess = "read" | "write";
|
|
@@ -68,7 +53,7 @@ export interface AgentRole {
|
|
|
68
53
|
tools: string[];
|
|
69
54
|
model?: string;
|
|
70
55
|
thinking?: string;
|
|
71
|
-
timeoutMs
|
|
56
|
+
timeoutMs?: number;
|
|
72
57
|
prompt: string;
|
|
73
58
|
source: AgentSource;
|
|
74
59
|
filePath: string;
|
|
@@ -160,7 +145,15 @@ interface SpawnedProcess {
|
|
|
160
145
|
once(event: "close", listener: (code: number | null) => void): this;
|
|
161
146
|
}
|
|
162
147
|
|
|
163
|
-
type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number }
|
|
148
|
+
type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number } & {
|
|
149
|
+
wallTimeMs?: number;
|
|
150
|
+
jsonlLineBytes?: number;
|
|
151
|
+
traceBytes?: number;
|
|
152
|
+
stderrBytes?: number;
|
|
153
|
+
taskOutputBytes?: number;
|
|
154
|
+
quotaTokens?: number;
|
|
155
|
+
quotaUsd?: number;
|
|
156
|
+
};
|
|
164
157
|
|
|
165
158
|
export interface SubagentRuntimeOptions {
|
|
166
159
|
bundledAgentsDir?: string;
|
|
@@ -243,14 +236,15 @@ function optionalPositiveInteger(
|
|
|
243
236
|
frontmatter: Record<string, unknown>,
|
|
244
237
|
filePath: string,
|
|
245
238
|
field: string,
|
|
246
|
-
fallback
|
|
247
|
-
maximum
|
|
248
|
-
): number {
|
|
239
|
+
fallback?: number,
|
|
240
|
+
maximum?: number,
|
|
241
|
+
): number | undefined {
|
|
249
242
|
const value = frontmatter[field];
|
|
250
243
|
if (value === undefined || value === "") return fallback;
|
|
251
244
|
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
252
|
-
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > maximum) {
|
|
253
|
-
|
|
245
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || maximum !== undefined && parsed > maximum) {
|
|
246
|
+
const bound = maximum === undefined ? "" : " no greater than " + maximum;
|
|
247
|
+
throw new AgentConfigurationError(filePath, field, `must be a positive integer${bound}`);
|
|
254
248
|
}
|
|
255
249
|
return parsed;
|
|
256
250
|
}
|
|
@@ -307,7 +301,7 @@ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentL
|
|
|
307
301
|
tools,
|
|
308
302
|
model: typeof modelValue === "string" ? modelValue.trim() : undefined,
|
|
309
303
|
thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
|
|
310
|
-
timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs"
|
|
304
|
+
timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs"),
|
|
311
305
|
prompt,
|
|
312
306
|
source,
|
|
313
307
|
filePath,
|
|
@@ -520,14 +514,19 @@ function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
|
|
|
520
514
|
};
|
|
521
515
|
}
|
|
522
516
|
|
|
523
|
-
function mergeTaskResults(
|
|
517
|
+
function mergeTaskResults(
|
|
518
|
+
previous: SubagentTaskResult | undefined,
|
|
519
|
+
next: SubagentTaskResult,
|
|
520
|
+
maxTraceBytes?: number,
|
|
521
|
+
maxStderrBytes?: number,
|
|
522
|
+
): SubagentTaskResult {
|
|
524
523
|
if (!previous) return cloneResult(next);
|
|
525
524
|
const merged = cloneResult(next);
|
|
526
525
|
const trace: string[] = [];
|
|
527
526
|
let traceBytes = 0;
|
|
528
527
|
let traceTruncatedBytes = previous.traceTruncatedBytes + next.traceTruncatedBytes;
|
|
529
528
|
for (const entry of [...previous.trace, ...next.trace]) {
|
|
530
|
-
const retained = truncateUtf8(entry, Math.max(0, maxTraceBytes - traceBytes));
|
|
529
|
+
const retained = truncateUtf8(entry, maxTraceBytes === undefined ? Buffer.byteLength(entry, "utf8") : Math.max(0, maxTraceBytes - traceBytes));
|
|
531
530
|
if (retained.text) trace.push(retained.text);
|
|
532
531
|
const retainedBytes = Buffer.byteLength(retained.text, "utf8");
|
|
533
532
|
traceBytes += retainedBytes;
|
|
@@ -536,9 +535,11 @@ function mergeTaskResults(previous: SubagentTaskResult | undefined, next: Subage
|
|
|
536
535
|
merged.trace = trace;
|
|
537
536
|
merged.traceBytes = traceBytes;
|
|
538
537
|
merged.traceTruncatedBytes = traceTruncatedBytes;
|
|
539
|
-
|
|
538
|
+
const stderr = [previous.stderr, next.stderr].filter(Boolean).join("\n");
|
|
539
|
+
const retainedStderr = truncateUtf8(stderr, maxStderrBytes === undefined ? Buffer.byteLength(stderr, "utf8") : maxStderrBytes);
|
|
540
|
+
merged.stderr = retainedStderr.text;
|
|
540
541
|
merged.stderrBytes = previous.stderrBytes + next.stderrBytes;
|
|
541
|
-
merged.stderrTruncatedBytes = previous.stderrTruncatedBytes + next.stderrTruncatedBytes;
|
|
542
|
+
merged.stderrTruncatedBytes = previous.stderrTruncatedBytes + next.stderrTruncatedBytes + retainedStderr.omittedBytes;
|
|
542
543
|
merged.output = next.output || previous.output;
|
|
543
544
|
merged.outputBytes = previous.outputBytes + next.outputBytes;
|
|
544
545
|
merged.outputTruncatedBytes = previous.outputTruncatedBytes + next.outputTruncatedBytes;
|
|
@@ -558,7 +559,7 @@ function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectA
|
|
|
558
559
|
async function writeRolePrompt(agent: AgentRole): Promise<{ directory: string; filePath: string }> {
|
|
559
560
|
const directory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-"));
|
|
560
561
|
const filePath = path.join(directory, `${agent.name.replace(/[^A-Za-z0-9_.-]/gu, "_")}.md`);
|
|
561
|
-
await writeFile(filePath,
|
|
562
|
+
await writeFile(filePath, agent.prompt, { encoding: "utf8", mode: 0o600 });
|
|
562
563
|
return { directory, filePath };
|
|
563
564
|
}
|
|
564
565
|
|
|
@@ -574,10 +575,11 @@ interface RunTaskOptions {
|
|
|
574
575
|
webExtension?: string;
|
|
575
576
|
projectTrusted: boolean;
|
|
576
577
|
limits: SubagentLimits;
|
|
578
|
+
sessionDirectory: string;
|
|
579
|
+
sessionId: string;
|
|
577
580
|
timeoutMs?: number;
|
|
578
581
|
onChange: (result: SubagentTaskResult) => void;
|
|
579
582
|
onHandle?: (handle: SubagentProcessHandle) => void;
|
|
580
|
-
toolBudget?: ChildToolBudget;
|
|
581
583
|
}
|
|
582
584
|
|
|
583
585
|
function applyProcessResult(
|
|
@@ -641,10 +643,10 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
|
641
643
|
const args = [
|
|
642
644
|
"--mode", "json",
|
|
643
645
|
"-p",
|
|
644
|
-
"--
|
|
646
|
+
"--session-dir", options.sessionDirectory,
|
|
647
|
+
"--session-id", options.sessionId,
|
|
645
648
|
"--no-extensions",
|
|
646
649
|
"--extension", options.webExtension ?? SUBAGENT_WEB_EXTENSION,
|
|
647
|
-
"--extension", SUBAGENT_BUDGET_EXTENSION,
|
|
648
650
|
"--no-prompt-templates",
|
|
649
651
|
options.projectTrusted ? "--approve" : "--no-approve",
|
|
650
652
|
"--model", options.model.model,
|
|
@@ -659,18 +661,20 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
|
659
661
|
signal: options.signal,
|
|
660
662
|
spawnProcess: options.spawnProcess,
|
|
661
663
|
limits: {
|
|
662
|
-
wallTimeMs: options.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,
|
|
664
|
+
...(options.timeoutMs === undefined ? {} : { wallTimeMs: options.timeoutMs }),
|
|
665
|
+
...(limits.jsonlLineBytes === undefined ? {} : { jsonlLineBytes: limits.jsonlLineBytes }),
|
|
666
|
+
...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes }),
|
|
667
|
+
...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes }),
|
|
668
|
+
...(limits.taskOutputBytes === undefined ? {} : { outputBytes: limits.taskOutputBytes }),
|
|
669
|
+
...(limits.quotaTokens === undefined ? {} : { quotaTokens: limits.quotaTokens }),
|
|
670
|
+
...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd }),
|
|
669
671
|
killGraceMs: limits.killGraceMs,
|
|
670
672
|
},
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
673
|
+
retention: {
|
|
674
|
+
traceBytes: limits.traceRetentionBytes,
|
|
675
|
+
stderrBytes: limits.stderrRetentionBytes,
|
|
676
|
+
outputBytes: limits.taskOutputRetentionBytes,
|
|
677
|
+
},
|
|
674
678
|
onUpdate: (next) => applyProcessResult(result, next, startedAt, options.onChange),
|
|
675
679
|
});
|
|
676
680
|
options.onHandle?.(handle);
|
|
@@ -708,35 +712,35 @@ async function mapReadTasks<T>(items: T[], concurrency: number, run: (item: T, i
|
|
|
708
712
|
await Promise.all(workers);
|
|
709
713
|
}
|
|
710
714
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
})
|
|
715
|
-
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
})
|
|
739
|
-
}
|
|
715
|
+
function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "taskCharacters">) {
|
|
716
|
+
const taskSchema = Type.Object({
|
|
717
|
+
agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
|
|
718
|
+
task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Bounded task for the role" }),
|
|
719
|
+
});
|
|
720
|
+
const chainTaskSchema = Type.Object({
|
|
721
|
+
agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
|
|
722
|
+
task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task with optional {previous} handoff placeholder" }),
|
|
723
|
+
});
|
|
724
|
+
return Type.Object({
|
|
725
|
+
action: Type.Optional(StringEnum(["spawn", "list", "inspect", "steer", "interrupt", "collect", "close"] as const, {
|
|
726
|
+
default: "spawn",
|
|
727
|
+
description: "Thread lifecycle action",
|
|
728
|
+
})),
|
|
729
|
+
threadId: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Stable child thread ID" })),
|
|
730
|
+
message: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000, description: "Bounded steering message" })),
|
|
731
|
+
all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
|
|
732
|
+
agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
|
|
733
|
+
task: Type.Optional(Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task for single mode" })),
|
|
734
|
+
tasks: Type.Optional(Type.Array(taskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Parallel role tasks" })),
|
|
735
|
+
chain: Type.Optional(Type.Array(chainTaskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
|
|
736
|
+
model: Type.Optional(Type.String({ minLength: 1, maxLength: 256, description: "Model for every task as provider/model; inherit uses each role setting or the active parent" })),
|
|
737
|
+
thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
|
|
738
|
+
agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
|
|
739
|
+
default: "user",
|
|
740
|
+
description: "Role sources: user includes bundled and personal; project includes bundled and trusted project; both includes all",
|
|
741
|
+
})),
|
|
742
|
+
});
|
|
743
|
+
}
|
|
740
744
|
|
|
741
745
|
type TaskInput = { agent: string; task: string };
|
|
742
746
|
|
|
@@ -753,16 +757,12 @@ function clipCharacters(text: string, maxCharacters: number, fromEnd = false): s
|
|
|
753
757
|
return (fromEnd ? characters.slice(-maxCharacters) : characters.slice(0, maxCharacters)).join("");
|
|
754
758
|
}
|
|
755
759
|
|
|
756
|
-
function buildSteeredTask(task: string, steering: readonly string[],
|
|
760
|
+
function buildSteeredTask(task: string, steering: readonly string[], maxCharacters: number): string {
|
|
757
761
|
const steeringLabel = "\n\nParent steering:\n";
|
|
758
762
|
const steeringText = clipCharacters(steering.join("\n"), Math.max(0, maxCharacters - [...steeringLabel].length), true);
|
|
759
|
-
const
|
|
760
|
-
const required = [...steeringLabel, ...steeringText, ...previousLabel].length;
|
|
763
|
+
const required = [...steeringLabel, ...steeringText].length;
|
|
761
764
|
const taskText = clipCharacters(task, Math.max(0, maxCharacters - required));
|
|
762
|
-
|
|
763
|
-
? clipCharacters(previousOutput, Math.max(0, maxCharacters - [...taskText, ...steeringLabel, ...steeringText, ...previousLabel].length))
|
|
764
|
-
: "";
|
|
765
|
-
return `${taskText}${previousText ? `${previousLabel}${previousText}` : ""}${steeringLabel}${steeringText}`;
|
|
765
|
+
return `${taskText}${steeringLabel}${steeringText}`;
|
|
766
766
|
}
|
|
767
767
|
|
|
768
768
|
function formatUsage(usage: SubagentUsage): string {
|
|
@@ -967,7 +967,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
967
967
|
};
|
|
968
968
|
|
|
969
969
|
const syncThread = (threadId: SubagentThreadId, next: SubagentTaskResult, runtime?: ActiveThreadRuntime): SubagentTaskResult => {
|
|
970
|
-
const effective = mergeTaskResults(runtime?.aggregate, next, limits.
|
|
970
|
+
const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
|
|
971
971
|
if (runtime?.requestedReason && next.status === "cancelled") effective.terminationReason = runtime.requestedReason;
|
|
972
972
|
savedResults.set(threadId, cloneResult(effective));
|
|
973
973
|
let thread = threads.inspect(threadId);
|
|
@@ -980,7 +980,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
980
980
|
const from = runtime?.traceCount ?? 0;
|
|
981
981
|
let retainedTraceBytes = thread.trace.reduce((total, entry) => total + Buffer.byteLength(entry.message ?? "", "utf8"), 0);
|
|
982
982
|
for (const entry of next.trace.slice(from)) {
|
|
983
|
-
const retained = truncateUtf8(entry,
|
|
983
|
+
const retained = truncateUtf8(entry, limits.traceRetentionBytes === undefined
|
|
984
|
+
? Buffer.byteLength(entry, "utf8")
|
|
985
|
+
: Math.max(0, limits.traceRetentionBytes - retainedTraceBytes));
|
|
984
986
|
if (retained.text) {
|
|
985
987
|
threads.trace(threadId, { kind: "child", message: retained.text });
|
|
986
988
|
retainedTraceBytes += Buffer.byteLength(retained.text, "utf8");
|
|
@@ -1027,7 +1029,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1027
1029
|
pi.registerTool({
|
|
1028
1030
|
name: "subagent",
|
|
1029
1031
|
label: "Subagents",
|
|
1030
|
-
description: "Spawn and manage named child threads. Children finish naturally;
|
|
1032
|
+
description: "Spawn and manage named child threads. Children finish naturally; explicit execution guards, task count, and concurrency are the hard edges. Retention only bounds stored detail. Use action list, inspect, steer, interrupt, collect, and close to manage active and completed handoffs.",
|
|
1031
1033
|
promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
|
|
1032
1034
|
promptGuidelines: [
|
|
1033
1035
|
"Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
|
|
@@ -1036,7 +1038,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1036
1038
|
"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
1039
|
"Keep completed and stopped threads inspectable until the parent explicitly closes them.",
|
|
1038
1040
|
],
|
|
1039
|
-
parameters:
|
|
1041
|
+
parameters: createSubagentParams(limits),
|
|
1040
1042
|
executionMode: "parallel",
|
|
1041
1043
|
|
|
1042
1044
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -1226,6 +1228,23 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1226
1228
|
emit();
|
|
1227
1229
|
return;
|
|
1228
1230
|
}
|
|
1231
|
+
let sessionDirectory: string;
|
|
1232
|
+
try {
|
|
1233
|
+
sessionDirectory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-session-"));
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1236
|
+
results[index] = {
|
|
1237
|
+
...results[index]!,
|
|
1238
|
+
status: "failed",
|
|
1239
|
+
terminationReason: "session_error",
|
|
1240
|
+
errorMessage: message,
|
|
1241
|
+
};
|
|
1242
|
+
threads.fail(threadId, { message, code: "session_error" });
|
|
1243
|
+
savedResults.set(threadId, cloneResult(results[index]!));
|
|
1244
|
+
emit();
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const sessionId = `killeros-${threadId.replace(/[^A-Za-z0-9_.-]/gu, "_")}`;
|
|
1229
1248
|
const controller = new AbortController();
|
|
1230
1249
|
const abortParent = (): void => controller.abort();
|
|
1231
1250
|
if (signal) {
|
|
@@ -1235,11 +1254,8 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1235
1254
|
const runtime: ActiveThreadRuntime = { controller, steering: [], restarting: false, traceCount: 0, startedAt: Date.now() };
|
|
1236
1255
|
activeRuntimes.set(threadId, runtime);
|
|
1237
1256
|
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
1257
|
const queuedSteering = initialThread.steering.map((entry) => entry.message);
|
|
1242
|
-
let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering,
|
|
1258
|
+
let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, limits.taskCharacters) : task;
|
|
1243
1259
|
const stopForBudget = (reason: string, message: string): void => {
|
|
1244
1260
|
const limited = cloneResult(runtime.aggregate ?? results[index]!);
|
|
1245
1261
|
limited.status = "limited";
|
|
@@ -1261,49 +1277,38 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1261
1277
|
try {
|
|
1262
1278
|
while (true) {
|
|
1263
1279
|
const aggregate = runtime.aggregate;
|
|
1264
|
-
const
|
|
1265
|
-
const
|
|
1280
|
+
const wallTimeMs = agent.timeoutMs ?? limits.wallTimeMs;
|
|
1281
|
+
const remainingWallTimeMs = wallTimeMs === undefined ? undefined : wallTimeMs - (Date.now() - runtime.startedAt);
|
|
1282
|
+
const usedTraceBytes = (aggregate?.traceBytes ?? 0) + (aggregate?.traceTruncatedBytes ?? 0);
|
|
1266
1283
|
const usedStderrBytes = aggregate?.stderrBytes ?? 0;
|
|
1267
1284
|
const usedOutputBytes = aggregate?.outputBytes ?? 0;
|
|
1268
|
-
const usedToolCalls = aggregate?.toolCallCount ?? 0;
|
|
1269
1285
|
const usedTokens = aggregate?.usage.totalTokens ?? 0;
|
|
1270
1286
|
const usedCost = aggregate?.usage.cost.total ?? 0;
|
|
1271
|
-
if (remainingWallTimeMs <= 0) {
|
|
1272
|
-
stopForBudget("wall_time_limit", `Child thread exceeds ${
|
|
1287
|
+
if (remainingWallTimeMs !== undefined && remainingWallTimeMs <= 0) {
|
|
1288
|
+
stopForBudget("wall_time_limit", `Child thread exceeds ${wallTimeMs} ms`);
|
|
1273
1289
|
break;
|
|
1274
1290
|
}
|
|
1275
|
-
if (usedTraceBytes >= limits.traceBytes
|
|
1291
|
+
if (limits.traceBytes !== undefined && usedTraceBytes >= limits.traceBytes) {
|
|
1276
1292
|
stopForBudget("trace_limit", `Child thread retains more than ${limits.traceBytes} trace bytes`);
|
|
1277
1293
|
break;
|
|
1278
1294
|
}
|
|
1279
|
-
if (usedStderrBytes >= limits.stderrBytes
|
|
1295
|
+
if (limits.stderrBytes !== undefined && usedStderrBytes >= limits.stderrBytes) {
|
|
1280
1296
|
stopForBudget("stderr_limit", `Child thread emits more than ${limits.stderrBytes} stderr bytes`);
|
|
1281
1297
|
break;
|
|
1282
1298
|
}
|
|
1283
|
-
if (usedOutputBytes >= limits.taskOutputBytes
|
|
1299
|
+
if (limits.taskOutputBytes !== undefined && usedOutputBytes >= limits.taskOutputBytes) {
|
|
1284
1300
|
stopForBudget("output_limit", `Child thread emits more than ${limits.taskOutputBytes} output bytes`);
|
|
1285
1301
|
break;
|
|
1286
1302
|
}
|
|
1287
|
-
if (
|
|
1288
|
-
stopForBudget("tool_call_limit", `Child thread exceeds ${baseToolBudget.hard} tool calls`);
|
|
1289
|
-
break;
|
|
1290
|
-
}
|
|
1291
|
-
if (usedTokens >= limits.quotaTokens) {
|
|
1303
|
+
if (limits.quotaTokens !== undefined && usedTokens >= limits.quotaTokens) {
|
|
1292
1304
|
stopForBudget("quota_tokens", `Child thread exceeds ${limits.quotaTokens} tokens`);
|
|
1293
1305
|
break;
|
|
1294
1306
|
}
|
|
1295
|
-
if (usedCost >= limits.quotaUsd) {
|
|
1307
|
+
if (limits.quotaUsd !== undefined && usedCost >= limits.quotaUsd) {
|
|
1296
1308
|
stopForBudget("quota_cost", `Child thread exceeds $${limits.quotaUsd}`);
|
|
1297
1309
|
break;
|
|
1298
1310
|
}
|
|
1299
1311
|
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
1312
|
const next = await runTask({
|
|
1308
1313
|
cwd: ctx.cwd,
|
|
1309
1314
|
agent: roles.get(input.agent)!,
|
|
@@ -1315,24 +1320,25 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1315
1320
|
webExtension: options.webExtension,
|
|
1316
1321
|
projectTrusted: ctx.isProjectTrusted(),
|
|
1317
1322
|
spawnProcess,
|
|
1323
|
+
sessionDirectory,
|
|
1324
|
+
sessionId,
|
|
1318
1325
|
limits: {
|
|
1319
1326
|
...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,
|
|
1327
|
+
...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes - usedTraceBytes }),
|
|
1328
|
+
...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes - usedStderrBytes }),
|
|
1329
|
+
...(limits.taskOutputBytes === undefined ? {} : { taskOutputBytes: limits.taskOutputBytes - usedOutputBytes }),
|
|
1330
|
+
...(limits.quotaTokens === undefined ? {} : { quotaTokens: limits.quotaTokens - usedTokens }),
|
|
1331
|
+
...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd - usedCost }),
|
|
1325
1332
|
},
|
|
1326
1333
|
timeoutMs: remainingWallTimeMs,
|
|
1327
1334
|
onHandle: (handle) => { runtime.handle = handle; },
|
|
1328
|
-
toolBudget,
|
|
1329
1335
|
onChange: (changed) => {
|
|
1330
1336
|
results[index] = syncThread(threadId, changed, runtime);
|
|
1331
1337
|
emit();
|
|
1332
1338
|
},
|
|
1333
1339
|
});
|
|
1334
1340
|
next.task = task;
|
|
1335
|
-
runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.
|
|
1341
|
+
runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
|
|
1336
1342
|
runtime.aggregate.task = task;
|
|
1337
1343
|
results[index] = cloneResult(runtime.aggregate);
|
|
1338
1344
|
savedResults.set(threadId, cloneResult(runtime.aggregate));
|
|
@@ -1348,11 +1354,16 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1348
1354
|
handoff: runtime.aggregate.output ? { summary: runtime.aggregate.output } : undefined,
|
|
1349
1355
|
});
|
|
1350
1356
|
}
|
|
1351
|
-
currentTask = buildSteeredTask(task, steering,
|
|
1357
|
+
currentTask = buildSteeredTask(task, steering, limits.taskCharacters);
|
|
1352
1358
|
}
|
|
1353
1359
|
} finally {
|
|
1354
1360
|
activeRuntimes.delete(threadId);
|
|
1355
1361
|
signal?.removeEventListener("abort", abortParent);
|
|
1362
|
+
try {
|
|
1363
|
+
await rm(sessionDirectory, { recursive: true, force: true });
|
|
1364
|
+
} catch {
|
|
1365
|
+
// Temporary child session cleanup is best effort after process termination.
|
|
1366
|
+
}
|
|
1356
1367
|
}
|
|
1357
1368
|
emit();
|
|
1358
1369
|
};
|
package/subagent-budget.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ToolCallEventResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
export const CHILD_TOOL_BUDGET_ENV = "PI_KILLEROS_TOOL_BUDGET";
|
|
4
|
-
|
|
5
|
-
export interface ChildToolBudget {
|
|
6
|
-
soft?: number;
|
|
7
|
-
hard: number;
|
|
8
|
-
block: "*" | string[];
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function parseChildToolBudget(value: string | undefined): ChildToolBudget | undefined {
|
|
12
|
-
if (!value?.trim()) return undefined;
|
|
13
|
-
try {
|
|
14
|
-
const parsed = JSON.parse(value) as Record<string, unknown>;
|
|
15
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
16
|
-
const hard = parsed.hard;
|
|
17
|
-
const soft = parsed.soft;
|
|
18
|
-
const block = parsed.block;
|
|
19
|
-
if (typeof hard !== "number" || !Number.isSafeInteger(hard) || hard < 1) return undefined;
|
|
20
|
-
if (soft !== undefined && (typeof soft !== "number" || !Number.isSafeInteger(soft) || soft < 1 || soft > hard)) return undefined;
|
|
21
|
-
if (block !== "*" && (!Array.isArray(block) || block.length === 0 || block.some((tool) => typeof tool !== "string" || !tool.trim()))) return undefined;
|
|
22
|
-
return {
|
|
23
|
-
hard,
|
|
24
|
-
...(soft === undefined ? {} : { soft }),
|
|
25
|
-
block: block === "*" ? "*" : [...new Set((block as string[]).map((tool) => tool.trim()))],
|
|
26
|
-
};
|
|
27
|
-
} catch {
|
|
28
|
-
return undefined;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function softNudge(budget: ChildToolBudget, calls: number): string {
|
|
33
|
-
return `Tool budget soft limit reached after ${calls} tool call${calls === 1 ? "" : "s"} (soft ${budget.soft}, hard ${budget.hard}). Stop starting new browsing/search work and finalize from the context you already have.`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function shouldBlock(budget: ChildToolBudget, toolName: string, calls: number): boolean {
|
|
37
|
-
return calls > budget.hard && (budget.block === "*" || budget.block.includes(toolName));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export default function registerSubagentBudget(pi: ExtensionAPI): void {
|
|
41
|
-
const budget = parseChildToolBudget(process.env[CHILD_TOOL_BUDGET_ENV]);
|
|
42
|
-
if (!budget) return;
|
|
43
|
-
let calls = 0;
|
|
44
|
-
let nudged = false;
|
|
45
|
-
const sendUserMessage = (pi as unknown as {
|
|
46
|
-
sendUserMessage?: (content: string, options: { deliverAs: "steer" }) => unknown;
|
|
47
|
-
}).sendUserMessage;
|
|
48
|
-
|
|
49
|
-
pi.on("tool_call", (event): ToolCallEventResult | void => {
|
|
50
|
-
calls += 1;
|
|
51
|
-
if (!nudged && budget.soft !== undefined && calls >= budget.soft) {
|
|
52
|
-
nudged = true;
|
|
53
|
-
try {
|
|
54
|
-
sendUserMessage?.(softNudge(budget, calls), { deliverAs: "steer" });
|
|
55
|
-
} catch {
|
|
56
|
-
// The hard block below remains authoritative if steering is unavailable.
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
if (!shouldBlock(budget, event.toolName, calls)) return undefined;
|
|
60
|
-
return {
|
|
61
|
-
block: true,
|
|
62
|
-
reason: `Tool budget hard limit reached after ${calls} tool calls (hard ${budget.hard}). Finalize from the context you already have.`,
|
|
63
|
-
};
|
|
64
|
-
});
|
|
65
|
-
}
|