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 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
- pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.4.3
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, bounded resources, and cancellation propagation
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 a bounded repository change |
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 ephemeral `pi --mode json -p --no-session` processes with explicit local tools plus `web_search`, `source_check`, `fetch_content`, and `get_search_content`. Each child explicitly loads `npm:pi-web-access` and the KillerOS child-budget hook, 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`, work in bounded passes, and report after its first useful evidence. KillerOS allows at most eight tasks, four parallel readers, ten minutes, a 32 MiB JSONL line, 2 MiB retained trace, 64 KiB stderr, 50 KiB returned output, 250,000 tokens, and $5 per child. Read-only children receive a 24-call soft and 32-call hard tool budget that blocks read and web tools after the hard limit; final reports remain allowed. Esc cancellation terminates active children and escalates after five seconds.
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. Routine turn caps do not end useful work. Named resource guards still stop unsafe growth: wall time, output bytes, retained trace, stderr, quota, read-tool calls, task count, and concurrency. Read-tool budgets are enforced inside the child process, so the model receives a finalization nudge and a blocked-tool result instead of being killed while it is still researching. A guard reports its cause and returns any partial work clearly. Esc cancellation terminates active children and escalates after five seconds.
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. **Bound:** apply named resource guards instead of a routine turn stop.
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 the current unreleased `1.4.3`, publish after the validation checks pass:
166
+ For release `1.4.6`, publish after the validation checks pass:
167
167
 
168
168
  ```bash
169
169
  npm login
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, edit, write, bash, web_search, source_check, fetch_
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
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, edit, write, bash, web_search, source_check, fetch_
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
package/agents/planner.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
@@ -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 fast and bounded 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.
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. **Stop.** Once the parent’s question is closed by evidence, stop. Do not pad the report with unrelated files, generic architecture advice, or speculative fixes.
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
 
@@ -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
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, edit, write, bash, web_search, source_check, fetch_
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
package/agents/worker.md CHANGED
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, edit, write, bash, web_search, source_check, fetch_
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "1.4.3",
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",
@@ -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
- wallTimeMs: 600_000,
7
- jsonlLineBytes: 32 * 1024 * 1024,
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: 50 * 1024,
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: number;
79
- jsonlLineBytes: number;
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. This runner never applies a turn cap.
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")) throw new Error("Subagent Pi arguments must include --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
- const finalLine = stdoutLine.toString("utf8", 0, stdoutLineBytes);
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 && state.stopReason !== "stop" && state.stopReason !== "toolUse") {
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
- if (appendTrace(state, traceMessage(message), limits.traceBytes)) {
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 capped = truncateUtf8(output, limits.outputBytes);
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
- if (appendTrace(state, [`${name} result${event.message.isError ? " (error)" : ""}`], limits.traceBytes)) {
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 = Math.min(limits.jsonlLineBytes, Math.max(nextBytes, stdoutLine.length * 2, 4_096));
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
- const line = stdoutLine.toString("utf8", 0, stdoutLineBytes);
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 retained = buffer.subarray(0, Math.max(0, limits.stderrBytes - stderr.length));
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 (buffer.length > retained.length) requestTermination("limited", "stderr_limit", `Child stderr exceeds ${limits.stderrBytes} bytes`);
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
- export function formatThreadState(thread: Pick<ThreadRecord, "status" | "terminationReason">): ThreadStateView {
123
+ function threadStatus(thread: Pick<ThreadRecord, "status" | "terminationReason">): ThreadStatus {
124
124
  const reason = thread.terminationReason;
125
- if (thread.status === "complete") return { status: thread.status, label: "Complete" };
126
- if (thread.status === "failed") return { status: thread.status, label: "Failed", reason, partialWork: "Failed before completion. Any saved output is partial work." };
127
- if (thread.status === "cancelled") return { status: thread.status, label: "Stopped", reason, partialWork: "Stopped before completion. Any saved output is partial work." };
128
- if (thread.status === "limited") return { status: thread.status, label: "Limited", reason, partialWork: "Stopped at a limit before completion. Any saved output is partial work." };
129
- if (thread.status === "running") return { status: thread.status, label: "Running", reason };
130
- return { status: thread.status, label: "Queued", reason };
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.status !== "complete" };
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: 8,
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
- quotaTokens: 250_000,
34
- quotaUsd: 5,
35
- readToolBudgetSoft: 24,
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: number;
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: number,
247
- maximum: number,
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
- throw new AgentConfigurationError(filePath, field, `must be a positive integer no greater than ${maximum}`);
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", limits.defaultTimeoutMs, limits.maxTimeoutMs),
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(previous: SubagentTaskResult | undefined, next: SubagentTaskResult, maxTraceBytes: number): SubagentTaskResult {
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
- merged.stderr = [previous.stderr, next.stderr].filter(Boolean).join("\n");
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, `${agent.prompt}\n\n${CHILD_REPORT_PROTOCOL}`, { encoding: "utf8", mode: 0o600 });
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
- "--no-session",
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 ?? agent.timeoutMs,
663
- jsonlLineBytes: limits.jsonlLineBytes,
664
- traceBytes: limits.traceBytes,
665
- stderrBytes: limits.stderrBytes,
666
- outputBytes: limits.taskOutputBytes,
667
- quotaTokens: limits.quotaTokens,
668
- quotaUsd: limits.quotaUsd,
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
- environment: options.toolBudget ? {
672
- [CHILD_TOOL_BUDGET_ENV]: JSON.stringify(options.toolBudget),
673
- } : undefined,
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
- const TaskSchema = Type.Object({
712
- agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
713
- task: Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Bounded task for the role" }),
714
- });
715
-
716
- const ChainTaskSchema = Type.Object({
717
- agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
718
- task: Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task with optional {previous} handoff placeholder" }),
719
- });
720
-
721
- const SubagentParams = Type.Object({
722
- action: Type.Optional(StringEnum(["spawn", "list", "inspect", "steer", "interrupt", "collect", "close"] as const, {
723
- default: "spawn",
724
- description: "Thread lifecycle action",
725
- })),
726
- threadId: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Stable child thread ID" })),
727
- message: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000, description: "Bounded steering message" })),
728
- all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
729
- agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
730
- task: Type.Optional(Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task for single mode" })),
731
- tasks: Type.Optional(Type.Array(TaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Parallel role tasks" })),
732
- chain: Type.Optional(Type.Array(ChainTaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
733
- 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" })),
734
- thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
735
- agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
736
- default: "user",
737
- description: "Role sources: user includes bundled and personal; project includes bundled and trusted project; both includes all",
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[], previousOutput: string | undefined, maxCharacters: number): 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 previousLabel = previousOutput ? "\n\nPrevious child handoff:\n" : "";
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
- const previousText = previousOutput
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.traceBytes);
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, Math.max(0, limits.traceBytes - retainedTraceBytes));
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; time, output, trace, stderr, quota, task count, and concurrency are the hard edges. Use action list, inspect, steer, interrupt, collect, and close to manage active and completed handoffs.",
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: SubagentParams,
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, undefined, limits.taskCharacters) : task;
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 remainingWallTimeMs = agent.timeoutMs - (Date.now() - runtime.startedAt);
1265
- const usedTraceBytes = aggregate?.traceBytes ?? 0;
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 ${agent.timeoutMs} ms`);
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 || aggregate?.traceTruncatedBytes) {
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 || aggregate?.stderrTruncatedBytes) {
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 || aggregate?.outputTruncatedBytes) {
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 (baseToolBudget && usedToolCalls >= baseToolBudget.hard) {
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.traceBytes);
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, runtime.aggregate.output, limits.taskCharacters);
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
  };
@@ -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
- }