killeros 1.4.3 → 1.4.5

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,16 @@
2
2
 
3
3
  All notable changes to KillerOS are documented here.
4
4
 
5
+ ## [1.4.5] - 2026-08-01
6
+
7
+ ### Fixed
8
+
9
+ - Removed the child-budget extension, its read-tool budget, and the default 250,000-token/$5 quota.
10
+ - 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.
11
+ - Removed forced early-report prompt text so roles can finish their assigned work naturally.
12
+ - Treat model stop reason `length` as a completed child process instead of inventing a KillerOS `limited` result.
13
+ - Documented the child lifecycle contract: children complete naturally; explicit user interruptions, configured guards, and real child-process failures remain visible.
14
+
5
15
  ## [1.4.3] - 2026-08-01
6
16
 
7
17
  ### 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.5
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 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`, 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 quota. The parent still limits each request to eight 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.5`, 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.5",
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",
@@ -3,13 +3,6 @@ import { statSync } from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
5
  export const SUBAGENT_PROCESS_LIMITS = {
6
- wallTimeMs: 600_000,
7
- jsonlLineBytes: 32 * 1024 * 1024,
8
- traceBytes: 2 * 1024 * 1024,
9
- stderrBytes: 64 * 1024,
10
- outputBytes: 50 * 1024,
11
- quotaTokens: 250_000,
12
- quotaUsd: 5,
13
6
  killGraceMs: 5_000,
14
7
  } as const;
15
8
 
@@ -75,13 +68,13 @@ export interface SubagentProcessOptions {
75
68
  }
76
69
 
77
70
  export interface SubagentProcessLimits {
78
- wallTimeMs: number;
79
- jsonlLineBytes: number;
80
- traceBytes: number;
81
- stderrBytes: number;
82
- outputBytes: number;
83
- quotaTokens: number;
84
- quotaUsd: number;
71
+ wallTimeMs?: number;
72
+ jsonlLineBytes?: number;
73
+ traceBytes?: number;
74
+ stderrBytes?: number;
75
+ outputBytes?: number;
76
+ quotaTokens?: number;
77
+ quotaUsd?: number;
85
78
  killGraceMs: number;
86
79
  }
87
80
 
@@ -178,11 +171,11 @@ function traceMessage(message: any): string[] {
178
171
  return entries;
179
172
  }
180
173
 
181
- function appendTrace(result: SubagentProcessResult, entries: string[], maxBytes: number): boolean {
174
+ function appendTrace(result: SubagentProcessResult, entries: string[], maxBytes: number | undefined): boolean {
182
175
  let truncated = false;
183
176
  for (const entry of entries) {
184
177
  const entryBytes = Buffer.byteLength(entry, "utf8");
185
- const retained = truncateUtf8(entry, Math.max(0, maxBytes - result.traceBytes));
178
+ const retained = truncateUtf8(entry, maxBytes === undefined ? entryBytes : Math.max(0, maxBytes - result.traceBytes));
186
179
  if (retained.text) result.trace.push(retained.text);
187
180
  result.traceBytes += Buffer.byteLength(retained.text, "utf8");
188
181
  result.traceTruncatedBytes += entryBytes - Buffer.byteLength(retained.text, "utf8");
@@ -199,11 +192,11 @@ function normalizeLimits(overrides: Partial<SubagentProcessLimits> | undefined):
199
192
  const limits = { ...SUBAGENT_PROCESS_LIMITS, ...overrides };
200
193
  for (const name of ["wallTimeMs", "jsonlLineBytes", "traceBytes", "stderrBytes", "outputBytes", "killGraceMs"] as const) {
201
194
  const value = limits[name];
202
- if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
195
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) throw new RangeError(`${name} must be a positive safe integer`);
203
196
  }
204
197
  for (const name of ["quotaTokens", "quotaUsd"] as const) {
205
198
  const value = limits[name];
206
- if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be a positive finite number`);
199
+ if (value !== undefined && (!Number.isFinite(value) || value <= 0)) throw new RangeError(`${name} must be a positive finite number`);
207
200
  }
208
201
  return limits;
209
202
  }
@@ -270,7 +263,7 @@ function terminateProcess(child: SubagentProcessChild, force: boolean): void {
270
263
 
271
264
  /**
272
265
  * 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.
266
+ * model, tools, prompt, and extension flags. Resource limits are opt-in.
274
267
  */
275
268
  export function runSubagentProcess(options: SubagentProcessOptions): SubagentProcessHandle {
276
269
  const args = [...options.args];
@@ -331,9 +324,12 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
331
324
  if (requestedStatus) {
332
325
  state.status = requestedStatus;
333
326
  state.terminationReason = requestedReason;
334
- } else if (code !== 0 || state.errorMessage || state.stopReason && state.stopReason !== "stop" && state.stopReason !== "toolUse") {
335
- state.status = "failed";
327
+ } else if (code !== 0 || state.errorMessage || state.stopReason === "error" || state.stopReason === "aborted") {
328
+ state.status = state.stopReason === "aborted" ? "cancelled" : "failed";
336
329
  state.terminationReason ??= code === null ? "process_closed" : `exit_${code}`;
330
+ } else if (state.stopReason !== undefined && !["stop", "toolUse", "length"].includes(state.stopReason)) {
331
+ state.status = "failed";
332
+ state.terminationReason = state.stopReason;
337
333
  } else if (state.usage.turns === 0) {
338
334
  state.status = "failed";
339
335
  state.terminationReason = "missing_assistant_message";
@@ -382,9 +378,9 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
382
378
  state.usage.turns += 1;
383
379
  addUsage(state.usage, { ...message.usage, turns: 0 });
384
380
  state.toolCallCount += toolCallCount(message);
385
- if (state.usage.totalTokens > limits.quotaTokens) {
381
+ if (limits.quotaTokens !== undefined && state.usage.totalTokens > limits.quotaTokens) {
386
382
  requestTermination("limited", "quota_tokens", `Child token usage exceeds ${limits.quotaTokens}`);
387
- } else if (state.usage.cost.total > limits.quotaUsd) {
383
+ } else if (limits.quotaUsd !== undefined && state.usage.cost.total > limits.quotaUsd) {
388
384
  requestTermination("limited", "quota_cost", `Child cost exceeds $${limits.quotaUsd}`);
389
385
  }
390
386
  if (appendTrace(state, traceMessage(message), limits.traceBytes)) {
@@ -392,13 +388,13 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
392
388
  }
393
389
  const output = textContent(message);
394
390
  if (output) {
395
- const capped = truncateUtf8(output, limits.outputBytes);
391
+ const capped = truncateUtf8(output, limits.outputBytes ?? Buffer.byteLength(output, "utf8"));
396
392
  state.output = capped.text;
397
393
  state.outputTruncatedBytes = capped.omittedBytes;
398
394
  outputBytesSeen += Buffer.byteLength(output, "utf8");
399
395
  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`);
396
+ state.outputTruncatedBytes = Math.max(state.outputTruncatedBytes, limits.outputBytes === undefined ? 0 : outputBytesSeen - limits.outputBytes);
397
+ if (limits.outputBytes !== undefined && outputBytesSeen > limits.outputBytes) requestTermination("limited", "output_limit", `Child output exceeds ${limits.outputBytes} bytes`);
402
398
  }
403
399
  if (typeof message.model === "string") state.model = message.provider ? `${message.provider}/${message.model}` : message.model;
404
400
  if (typeof message.stopReason === "string") {
@@ -407,7 +403,6 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
407
403
  else state.terminationReason = message.stopReason;
408
404
  }
409
405
  if (typeof message.errorMessage === "string") state.errorMessage = message.errorMessage;
410
- if (message.stopReason === "length") requestTermination("limited", "model_output_limit");
411
406
  publish();
412
407
  } else if (event?.type === "tool_result_end" && event.message) {
413
408
  const name = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
@@ -419,12 +414,14 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
419
414
  };
420
415
  const appendStdout = (fragment: Buffer): boolean => {
421
416
  const nextBytes = stdoutLineBytes + fragment.length;
422
- if (nextBytes > limits.jsonlLineBytes) {
417
+ if (limits.jsonlLineBytes !== undefined && nextBytes > limits.jsonlLineBytes) {
423
418
  requestTermination("limited", "jsonl_line_limit", `Child JSONL line exceeds ${limits.jsonlLineBytes} bytes`);
424
419
  return false;
425
420
  }
426
421
  if (nextBytes > stdoutLine.length) {
427
- const nextCapacity = Math.min(limits.jsonlLineBytes, Math.max(nextBytes, stdoutLine.length * 2, 4_096));
422
+ const nextCapacity = limits.jsonlLineBytes === undefined
423
+ ? Math.max(nextBytes, stdoutLine.length * 2, 4_096)
424
+ : Math.min(limits.jsonlLineBytes, Math.max(nextBytes, stdoutLine.length * 2, 4_096));
428
425
  const expanded = Buffer.allocUnsafe(nextCapacity);
429
426
  stdoutLine.copy(expanded, 0, 0, stdoutLineBytes);
430
427
  stdoutLine = expanded;
@@ -461,14 +458,16 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
461
458
  });
462
459
  child.stderr.on("data", (chunk: Buffer | string) => {
463
460
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
464
- const retained = buffer.subarray(0, Math.max(0, limits.stderrBytes - stderr.length));
461
+ const retained = limits.stderrBytes === undefined
462
+ ? buffer
463
+ : buffer.subarray(0, Math.max(0, limits.stderrBytes - stderr.length));
465
464
  if (retained.length) stderr = Buffer.concat([stderr, retained]);
466
465
  state.stderrTruncatedBytes += buffer.length - retained.length;
467
466
  if (buffer.length > retained.length) requestTermination("limited", "stderr_limit", `Child stderr exceeds ${limits.stderrBytes} bytes`);
468
467
  });
469
468
  child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
470
469
  child.once("close", finish);
471
- timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
470
+ if (limits.wallTimeMs !== undefined) timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
472
471
  options.signal?.addEventListener("abort", abortHandler, { once: true });
473
472
  if (options.signal?.aborted) abortHandler();
474
473
  } 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,7 +15,6 @@ 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";
@@ -23,17 +22,7 @@ import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadB
23
22
  export const SUBAGENT_LIMITS = {
24
23
  maxTasks: 8,
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,
37
26
  roleFileBytes: 64 * 1024,
38
27
  taskCharacters: 20_000,
39
28
  killGraceMs: 5_000,
@@ -44,16 +33,9 @@ const READ_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOLS]);
44
33
  const WRITE_TOOLS = new Set(["bash", "edit", "write"]);
45
34
  const KNOWN_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
46
35
  const SUBAGENT_WEB_EXTENSION = "npm:pi-web-access";
47
- const SUBAGENT_BUDGET_EXTENSION = fileURLToPath(new URL("./subagent-budget.ts", import.meta.url));
48
36
  const INHERIT_SETTING = "inherit";
49
37
  const MAX_RUNTIME_STEERING_MESSAGES = 20;
50
38
  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
39
 
58
40
  type ThinkingLevel = ModelThinkingLevel;
59
41
  export type AgentAccess = "read" | "write";
@@ -68,7 +50,7 @@ export interface AgentRole {
68
50
  tools: string[];
69
51
  model?: string;
70
52
  thinking?: string;
71
- timeoutMs: number;
53
+ timeoutMs?: number;
72
54
  prompt: string;
73
55
  source: AgentSource;
74
56
  filePath: string;
@@ -160,7 +142,15 @@ interface SpawnedProcess {
160
142
  once(event: "close", listener: (code: number | null) => void): this;
161
143
  }
162
144
 
163
- type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number };
145
+ type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number } & {
146
+ wallTimeMs?: number;
147
+ jsonlLineBytes?: number;
148
+ traceBytes?: number;
149
+ stderrBytes?: number;
150
+ taskOutputBytes?: number;
151
+ quotaTokens?: number;
152
+ quotaUsd?: number;
153
+ };
164
154
 
165
155
  export interface SubagentRuntimeOptions {
166
156
  bundledAgentsDir?: string;
@@ -243,14 +233,15 @@ function optionalPositiveInteger(
243
233
  frontmatter: Record<string, unknown>,
244
234
  filePath: string,
245
235
  field: string,
246
- fallback: number,
247
- maximum: number,
248
- ): number {
236
+ fallback?: number,
237
+ maximum?: number,
238
+ ): number | undefined {
249
239
  const value = frontmatter[field];
250
240
  if (value === undefined || value === "") return fallback;
251
241
  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}`);
242
+ if (!Number.isInteger(parsed) || parsed <= 0 || maximum !== undefined && parsed > maximum) {
243
+ const bound = maximum === undefined ? "" : " no greater than " + maximum;
244
+ throw new AgentConfigurationError(filePath, field, `must be a positive integer${bound}`);
254
245
  }
255
246
  return parsed;
256
247
  }
@@ -307,7 +298,7 @@ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentL
307
298
  tools,
308
299
  model: typeof modelValue === "string" ? modelValue.trim() : undefined,
309
300
  thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
310
- timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", limits.defaultTimeoutMs, limits.maxTimeoutMs),
301
+ timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs"),
311
302
  prompt,
312
303
  source,
313
304
  filePath,
@@ -520,14 +511,14 @@ function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
520
511
  };
521
512
  }
522
513
 
523
- function mergeTaskResults(previous: SubagentTaskResult | undefined, next: SubagentTaskResult, maxTraceBytes: number): SubagentTaskResult {
514
+ function mergeTaskResults(previous: SubagentTaskResult | undefined, next: SubagentTaskResult, maxTraceBytes?: number): SubagentTaskResult {
524
515
  if (!previous) return cloneResult(next);
525
516
  const merged = cloneResult(next);
526
517
  const trace: string[] = [];
527
518
  let traceBytes = 0;
528
519
  let traceTruncatedBytes = previous.traceTruncatedBytes + next.traceTruncatedBytes;
529
520
  for (const entry of [...previous.trace, ...next.trace]) {
530
- const retained = truncateUtf8(entry, Math.max(0, maxTraceBytes - traceBytes));
521
+ const retained = truncateUtf8(entry, maxTraceBytes === undefined ? Buffer.byteLength(entry, "utf8") : Math.max(0, maxTraceBytes - traceBytes));
531
522
  if (retained.text) trace.push(retained.text);
532
523
  const retainedBytes = Buffer.byteLength(retained.text, "utf8");
533
524
  traceBytes += retainedBytes;
@@ -558,7 +549,7 @@ function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectA
558
549
  async function writeRolePrompt(agent: AgentRole): Promise<{ directory: string; filePath: string }> {
559
550
  const directory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-"));
560
551
  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 });
552
+ await writeFile(filePath, agent.prompt, { encoding: "utf8", mode: 0o600 });
562
553
  return { directory, filePath };
563
554
  }
564
555
 
@@ -577,7 +568,6 @@ interface RunTaskOptions {
577
568
  timeoutMs?: number;
578
569
  onChange: (result: SubagentTaskResult) => void;
579
570
  onHandle?: (handle: SubagentProcessHandle) => void;
580
- toolBudget?: ChildToolBudget;
581
571
  }
582
572
 
583
573
  function applyProcessResult(
@@ -644,7 +634,6 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
644
634
  "--no-session",
645
635
  "--no-extensions",
646
636
  "--extension", options.webExtension ?? SUBAGENT_WEB_EXTENSION,
647
- "--extension", SUBAGENT_BUDGET_EXTENSION,
648
637
  "--no-prompt-templates",
649
638
  options.projectTrusted ? "--approve" : "--no-approve",
650
639
  "--model", options.model.model,
@@ -659,18 +648,15 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
659
648
  signal: options.signal,
660
649
  spawnProcess: options.spawnProcess,
661
650
  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,
651
+ ...(options.timeoutMs === undefined ? {} : { wallTimeMs: options.timeoutMs }),
652
+ ...(limits.jsonlLineBytes === undefined ? {} : { jsonlLineBytes: limits.jsonlLineBytes }),
653
+ ...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes }),
654
+ ...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes }),
655
+ ...(limits.taskOutputBytes === undefined ? {} : { outputBytes: limits.taskOutputBytes }),
656
+ ...(limits.quotaTokens === undefined ? {} : { quotaTokens: limits.quotaTokens }),
657
+ ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd }),
669
658
  killGraceMs: limits.killGraceMs,
670
659
  },
671
- environment: options.toolBudget ? {
672
- [CHILD_TOOL_BUDGET_ENV]: JSON.stringify(options.toolBudget),
673
- } : undefined,
674
660
  onUpdate: (next) => applyProcessResult(result, next, startedAt, options.onChange),
675
661
  });
676
662
  options.onHandle?.(handle);
@@ -980,7 +966,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
980
966
  const from = runtime?.traceCount ?? 0;
981
967
  let retainedTraceBytes = thread.trace.reduce((total, entry) => total + Buffer.byteLength(entry.message ?? "", "utf8"), 0);
982
968
  for (const entry of next.trace.slice(from)) {
983
- const retained = truncateUtf8(entry, Math.max(0, limits.traceBytes - retainedTraceBytes));
969
+ const retained = truncateUtf8(entry, limits.traceBytes === undefined
970
+ ? Buffer.byteLength(entry, "utf8")
971
+ : Math.max(0, limits.traceBytes - retainedTraceBytes));
984
972
  if (retained.text) {
985
973
  threads.trace(threadId, { kind: "child", message: retained.text });
986
974
  retainedTraceBytes += Buffer.byteLength(retained.text, "utf8");
@@ -1235,9 +1223,6 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1235
1223
  const runtime: ActiveThreadRuntime = { controller, steering: [], restarting: false, traceCount: 0, startedAt: Date.now() };
1236
1224
  activeRuntimes.set(threadId, runtime);
1237
1225
  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
1226
  const queuedSteering = initialThread.steering.map((entry) => entry.message);
1242
1227
  let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, undefined, limits.taskCharacters) : task;
1243
1228
  const stopForBudget = (reason: string, message: string): void => {
@@ -1261,49 +1246,38 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1261
1246
  try {
1262
1247
  while (true) {
1263
1248
  const aggregate = runtime.aggregate;
1264
- const remainingWallTimeMs = agent.timeoutMs - (Date.now() - runtime.startedAt);
1249
+ const wallTimeMs = agent.timeoutMs ?? limits.wallTimeMs;
1250
+ const remainingWallTimeMs = wallTimeMs === undefined ? undefined : wallTimeMs - (Date.now() - runtime.startedAt);
1265
1251
  const usedTraceBytes = aggregate?.traceBytes ?? 0;
1266
1252
  const usedStderrBytes = aggregate?.stderrBytes ?? 0;
1267
1253
  const usedOutputBytes = aggregate?.outputBytes ?? 0;
1268
- const usedToolCalls = aggregate?.toolCallCount ?? 0;
1269
1254
  const usedTokens = aggregate?.usage.totalTokens ?? 0;
1270
1255
  const usedCost = aggregate?.usage.cost.total ?? 0;
1271
- if (remainingWallTimeMs <= 0) {
1272
- stopForBudget("wall_time_limit", `Child thread exceeds ${agent.timeoutMs} ms`);
1256
+ if (remainingWallTimeMs !== undefined && remainingWallTimeMs <= 0) {
1257
+ stopForBudget("wall_time_limit", `Child thread exceeds ${wallTimeMs} ms`);
1273
1258
  break;
1274
1259
  }
1275
- if (usedTraceBytes >= limits.traceBytes || aggregate?.traceTruncatedBytes) {
1260
+ if (limits.traceBytes !== undefined && (usedTraceBytes >= limits.traceBytes || aggregate?.traceTruncatedBytes)) {
1276
1261
  stopForBudget("trace_limit", `Child thread retains more than ${limits.traceBytes} trace bytes`);
1277
1262
  break;
1278
1263
  }
1279
- if (usedStderrBytes >= limits.stderrBytes || aggregate?.stderrTruncatedBytes) {
1264
+ if (limits.stderrBytes !== undefined && (usedStderrBytes >= limits.stderrBytes || aggregate?.stderrTruncatedBytes)) {
1280
1265
  stopForBudget("stderr_limit", `Child thread emits more than ${limits.stderrBytes} stderr bytes`);
1281
1266
  break;
1282
1267
  }
1283
- if (usedOutputBytes >= limits.taskOutputBytes || aggregate?.outputTruncatedBytes) {
1268
+ if (limits.taskOutputBytes !== undefined && (usedOutputBytes >= limits.taskOutputBytes || aggregate?.outputTruncatedBytes)) {
1284
1269
  stopForBudget("output_limit", `Child thread emits more than ${limits.taskOutputBytes} output bytes`);
1285
1270
  break;
1286
1271
  }
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) {
1272
+ if (limits.quotaTokens !== undefined && usedTokens >= limits.quotaTokens) {
1292
1273
  stopForBudget("quota_tokens", `Child thread exceeds ${limits.quotaTokens} tokens`);
1293
1274
  break;
1294
1275
  }
1295
- if (usedCost >= limits.quotaUsd) {
1276
+ if (limits.quotaUsd !== undefined && usedCost >= limits.quotaUsd) {
1296
1277
  stopForBudget("quota_cost", `Child thread exceeds $${limits.quotaUsd}`);
1297
1278
  break;
1298
1279
  }
1299
1280
  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
1281
  const next = await runTask({
1308
1282
  cwd: ctx.cwd,
1309
1283
  agent: roles.get(input.agent)!,
@@ -1317,15 +1291,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1317
1291
  spawnProcess,
1318
1292
  limits: {
1319
1293
  ...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,
1294
+ ...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes - usedTraceBytes }),
1295
+ ...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes - usedStderrBytes }),
1296
+ ...(limits.taskOutputBytes === undefined ? {} : { taskOutputBytes: limits.taskOutputBytes - usedOutputBytes }),
1297
+ ...(limits.quotaTokens === undefined ? {} : { quotaTokens: limits.quotaTokens - usedTokens }),
1298
+ ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd - usedCost }),
1325
1299
  },
1326
1300
  timeoutMs: remainingWallTimeMs,
1327
1301
  onHandle: (handle) => { runtime.handle = handle; },
1328
- toolBudget,
1329
1302
  onChange: (changed) => {
1330
1303
  results[index] = syncThread(threadId, changed, runtime);
1331
1304
  emit();
@@ -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
- }