killeros 1.4.2 → 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,24 @@
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
+
15
+ ## [1.4.3] - 2026-08-01
16
+
17
+ ### Fixed
18
+
19
+ - Added child-runtime tool budgets for read-only roles, with a soft finalization nudge and hard blocking for read and web tools after 32 calls.
20
+ - Added bounded child report instructions and kept read-tool budgets cumulative across steering restarts.
21
+ - Lowered the default child quota to 250,000 tokens and $5, and exposed child tool-call counts in results.
22
+
5
23
  ## [1.4.2] - 2026-08-01
6
24
 
7
25
  ### Added
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.1
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`, 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` before work and to use web research when external evidence is needed. 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, one million tokens, and $10 per child. 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, task count, and concurrency. 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.2`, 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.2",
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": [
@@ -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: 1_000_000,
12
- quotaUsd: 10,
13
6
  killGraceMs: 5_000,
14
7
  } as const;
15
8
 
@@ -43,6 +36,7 @@ export interface SubagentProcessResult {
43
36
  output: string;
44
37
  outputBytes: number;
45
38
  outputTruncatedBytes: number;
39
+ toolCallCount: number;
46
40
  usage: SubagentProcessUsage;
47
41
  model?: string;
48
42
  stopReason?: string;
@@ -67,19 +61,20 @@ export interface SubagentProcessOptions {
67
61
  cwd: string;
68
62
  signal?: AbortSignal;
69
63
  limits?: Partial<SubagentProcessLimits>;
64
+ environment?: NodeJS.ProcessEnv;
70
65
  onUpdate?: (result: Readonly<SubagentProcessResult>) => void;
71
66
  /** Test or embed hook. It receives the exact Pi arguments supplied above. */
72
- spawnProcess?: (args: string[], cwd: string) => SubagentProcessChild;
67
+ spawnProcess?: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SubagentProcessChild;
73
68
  }
74
69
 
75
70
  export interface SubagentProcessLimits {
76
- wallTimeMs: number;
77
- jsonlLineBytes: number;
78
- traceBytes: number;
79
- stderrBytes: number;
80
- outputBytes: number;
81
- quotaTokens: number;
82
- quotaUsd: number;
71
+ wallTimeMs?: number;
72
+ jsonlLineBytes?: number;
73
+ traceBytes?: number;
74
+ stderrBytes?: number;
75
+ outputBytes?: number;
76
+ quotaTokens?: number;
77
+ quotaUsd?: number;
83
78
  killGraceMs: number;
84
79
  }
85
80
 
@@ -161,6 +156,11 @@ function textContent(message: any): string {
161
156
  .join("\n");
162
157
  }
163
158
 
159
+ function toolCallCount(message: any): number {
160
+ if (!Array.isArray(message?.content)) return 0;
161
+ return message.content.filter((part: any) => part?.type === "toolCall").length;
162
+ }
163
+
164
164
  function traceMessage(message: any): string[] {
165
165
  if (!Array.isArray(message?.content)) return [];
166
166
  const entries: string[] = [];
@@ -171,11 +171,11 @@ function traceMessage(message: any): string[] {
171
171
  return entries;
172
172
  }
173
173
 
174
- function appendTrace(result: SubagentProcessResult, entries: string[], maxBytes: number): boolean {
174
+ function appendTrace(result: SubagentProcessResult, entries: string[], maxBytes: number | undefined): boolean {
175
175
  let truncated = false;
176
176
  for (const entry of entries) {
177
177
  const entryBytes = Buffer.byteLength(entry, "utf8");
178
- const retained = truncateUtf8(entry, Math.max(0, maxBytes - result.traceBytes));
178
+ const retained = truncateUtf8(entry, maxBytes === undefined ? entryBytes : Math.max(0, maxBytes - result.traceBytes));
179
179
  if (retained.text) result.trace.push(retained.text);
180
180
  result.traceBytes += Buffer.byteLength(retained.text, "utf8");
181
181
  result.traceTruncatedBytes += entryBytes - Buffer.byteLength(retained.text, "utf8");
@@ -192,11 +192,11 @@ function normalizeLimits(overrides: Partial<SubagentProcessLimits> | undefined):
192
192
  const limits = { ...SUBAGENT_PROCESS_LIMITS, ...overrides };
193
193
  for (const name of ["wallTimeMs", "jsonlLineBytes", "traceBytes", "stderrBytes", "outputBytes", "killGraceMs"] as const) {
194
194
  const value = limits[name];
195
- 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`);
196
196
  }
197
197
  for (const name of ["quotaTokens", "quotaUsd"] as const) {
198
198
  const value = limits[name];
199
- 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`);
200
200
  }
201
201
  return limits;
202
202
  }
@@ -224,12 +224,12 @@ export function subagentProcessEnvironment(environment: NodeJS.ProcessEnv = proc
224
224
  return childEnvironment;
225
225
  }
226
226
 
227
- function defaultSpawnProcess(args: string[], cwd: string): SubagentProcessChild {
227
+ function defaultSpawnProcess(args: string[], cwd: string, environment?: NodeJS.ProcessEnv): SubagentProcessChild {
228
228
  const invocation = getPiInvocation(args);
229
229
  return spawn(invocation.command, invocation.args, {
230
230
  cwd,
231
231
  detached: process.platform !== "win32",
232
- env: subagentProcessEnvironment(),
232
+ env: subagentProcessEnvironment({ ...process.env, ...environment }),
233
233
  shell: false,
234
234
  stdio: ["ignore", "pipe", "pipe"],
235
235
  windowsHide: true,
@@ -263,7 +263,7 @@ function terminateProcess(child: SubagentProcessChild, force: boolean): void {
263
263
 
264
264
  /**
265
265
  * Run one isolated Pi JSON process. The caller owns all Pi arguments, including
266
- * 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.
267
267
  */
268
268
  export function runSubagentProcess(options: SubagentProcessOptions): SubagentProcessHandle {
269
269
  const args = [...options.args];
@@ -283,6 +283,7 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
283
283
  output: "",
284
284
  outputBytes: 0,
285
285
  outputTruncatedBytes: 0,
286
+ toolCallCount: 0,
286
287
  usage: emptyUsage(),
287
288
  exitCode: null,
288
289
  durationMs: 0,
@@ -323,9 +324,12 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
323
324
  if (requestedStatus) {
324
325
  state.status = requestedStatus;
325
326
  state.terminationReason = requestedReason;
326
- } else if (code !== 0 || state.errorMessage || state.stopReason && state.stopReason !== "stop" && state.stopReason !== "toolUse") {
327
- state.status = "failed";
327
+ } else if (code !== 0 || state.errorMessage || state.stopReason === "error" || state.stopReason === "aborted") {
328
+ state.status = state.stopReason === "aborted" ? "cancelled" : "failed";
328
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;
329
333
  } else if (state.usage.turns === 0) {
330
334
  state.status = "failed";
331
335
  state.terminationReason = "missing_assistant_message";
@@ -373,9 +377,10 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
373
377
  }
374
378
  state.usage.turns += 1;
375
379
  addUsage(state.usage, { ...message.usage, turns: 0 });
376
- if (state.usage.totalTokens > limits.quotaTokens) {
380
+ state.toolCallCount += toolCallCount(message);
381
+ if (limits.quotaTokens !== undefined && state.usage.totalTokens > limits.quotaTokens) {
377
382
  requestTermination("limited", "quota_tokens", `Child token usage exceeds ${limits.quotaTokens}`);
378
- } else if (state.usage.cost.total > limits.quotaUsd) {
383
+ } else if (limits.quotaUsd !== undefined && state.usage.cost.total > limits.quotaUsd) {
379
384
  requestTermination("limited", "quota_cost", `Child cost exceeds $${limits.quotaUsd}`);
380
385
  }
381
386
  if (appendTrace(state, traceMessage(message), limits.traceBytes)) {
@@ -383,13 +388,13 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
383
388
  }
384
389
  const output = textContent(message);
385
390
  if (output) {
386
- const capped = truncateUtf8(output, limits.outputBytes);
391
+ const capped = truncateUtf8(output, limits.outputBytes ?? Buffer.byteLength(output, "utf8"));
387
392
  state.output = capped.text;
388
393
  state.outputTruncatedBytes = capped.omittedBytes;
389
394
  outputBytesSeen += Buffer.byteLength(output, "utf8");
390
395
  state.outputBytes = outputBytesSeen;
391
- state.outputTruncatedBytes = Math.max(state.outputTruncatedBytes, outputBytesSeen - limits.outputBytes);
392
- 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`);
393
398
  }
394
399
  if (typeof message.model === "string") state.model = message.provider ? `${message.provider}/${message.model}` : message.model;
395
400
  if (typeof message.stopReason === "string") {
@@ -398,7 +403,6 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
398
403
  else state.terminationReason = message.stopReason;
399
404
  }
400
405
  if (typeof message.errorMessage === "string") state.errorMessage = message.errorMessage;
401
- if (message.stopReason === "length") requestTermination("limited", "model_output_limit");
402
406
  publish();
403
407
  } else if (event?.type === "tool_result_end" && event.message) {
404
408
  const name = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
@@ -410,12 +414,14 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
410
414
  };
411
415
  const appendStdout = (fragment: Buffer): boolean => {
412
416
  const nextBytes = stdoutLineBytes + fragment.length;
413
- if (nextBytes > limits.jsonlLineBytes) {
417
+ if (limits.jsonlLineBytes !== undefined && nextBytes > limits.jsonlLineBytes) {
414
418
  requestTermination("limited", "jsonl_line_limit", `Child JSONL line exceeds ${limits.jsonlLineBytes} bytes`);
415
419
  return false;
416
420
  }
417
421
  if (nextBytes > stdoutLine.length) {
418
- 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));
419
425
  const expanded = Buffer.allocUnsafe(nextCapacity);
420
426
  stdoutLine.copy(expanded, 0, 0, stdoutLineBytes);
421
427
  stdoutLine = expanded;
@@ -431,7 +437,9 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
431
437
  abortHandler();
432
438
  } else {
433
439
  try {
434
- child = (options.spawnProcess ?? defaultSpawnProcess)(args, options.cwd);
440
+ child = options.spawnProcess
441
+ ? options.spawnProcess(args, options.cwd, options.environment)
442
+ : defaultSpawnProcess(args, options.cwd, options.environment);
435
443
  child.stdout.on("data", (chunk: Buffer | string) => {
436
444
  if (requestedStatus) return;
437
445
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
@@ -450,14 +458,16 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
450
458
  });
451
459
  child.stderr.on("data", (chunk: Buffer | string) => {
452
460
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
453
- 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));
454
464
  if (retained.length) stderr = Buffer.concat([stderr, retained]);
455
465
  state.stderrTruncatedBytes += buffer.length - retained.length;
456
466
  if (buffer.length > retained.length) requestTermination("limited", "stderr_limit", `Child stderr exceeds ${limits.stderrBytes} bytes`);
457
467
  });
458
468
  child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
459
469
  child.once("close", finish);
460
- timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
470
+ if (limits.wallTimeMs !== undefined) timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
461
471
  options.signal?.addEventListener("abort", abortHandler, { once: true });
462
472
  if (options.signal?.aborted) abortHandler();
463
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
@@ -22,15 +22,7 @@ import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadB
22
22
  export const SUBAGENT_LIMITS = {
23
23
  maxTasks: 8,
24
24
  maxReadConcurrency: 4,
25
- defaultTimeoutMs: 300_000,
26
- maxTimeoutMs: 600_000,
27
- jsonlLineBytes: 32 * 1024 * 1024,
28
- traceBytes: 2 * 1024 * 1024,
29
- stderrBytes: 64 * 1024,
30
- taskOutputBytes: 50 * 1024,
31
25
  toolOutputBytes: 50 * 1024,
32
- quotaTokens: 1_000_000,
33
- quotaUsd: 10,
34
26
  roleFileBytes: 64 * 1024,
35
27
  taskCharacters: 20_000,
36
28
  killGraceMs: 5_000,
@@ -58,7 +50,7 @@ export interface AgentRole {
58
50
  tools: string[];
59
51
  model?: string;
60
52
  thinking?: string;
61
- timeoutMs: number;
53
+ timeoutMs?: number;
62
54
  prompt: string;
63
55
  source: AgentSource;
64
56
  filePath: string;
@@ -105,6 +97,7 @@ export interface SubagentTaskResult {
105
97
  output: string;
106
98
  outputBytes: number;
107
99
  outputTruncatedBytes: number;
100
+ toolCallCount: number;
108
101
  usage: SubagentUsage;
109
102
  durationMs: number;
110
103
  exitCode: number | null;
@@ -149,13 +142,21 @@ interface SpawnedProcess {
149
142
  once(event: "close", listener: (code: number | null) => void): this;
150
143
  }
151
144
 
152
- 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
+ };
153
154
 
154
155
  export interface SubagentRuntimeOptions {
155
156
  bundledAgentsDir?: string;
156
157
  userAgentsDir?: string;
157
158
  webExtension?: string;
158
- spawnProcess?: (args: string[], cwd: string) => SpawnedProcess;
159
+ spawnProcess?: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SpawnedProcess;
159
160
  limits?: Partial<SubagentLimits>;
160
161
  }
161
162
 
@@ -232,14 +233,15 @@ function optionalPositiveInteger(
232
233
  frontmatter: Record<string, unknown>,
233
234
  filePath: string,
234
235
  field: string,
235
- fallback: number,
236
- maximum: number,
237
- ): number {
236
+ fallback?: number,
237
+ maximum?: number,
238
+ ): number | undefined {
238
239
  const value = frontmatter[field];
239
240
  if (value === undefined || value === "") return fallback;
240
241
  const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
241
- if (!Number.isInteger(parsed) || parsed <= 0 || parsed > maximum) {
242
- 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}`);
243
245
  }
244
246
  return parsed;
245
247
  }
@@ -296,7 +298,7 @@ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentL
296
298
  tools,
297
299
  model: typeof modelValue === "string" ? modelValue.trim() : undefined,
298
300
  thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
299
- timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", limits.defaultTimeoutMs, limits.maxTimeoutMs),
301
+ timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs"),
300
302
  prompt,
301
303
  source,
302
304
  filePath,
@@ -492,6 +494,7 @@ function makeQueuedResult(id: string, agent: string, task: string, step?: number
492
494
  output: "",
493
495
  outputBytes: 0,
494
496
  outputTruncatedBytes: 0,
497
+ toolCallCount: 0,
495
498
  usage: emptyUsage(),
496
499
  durationMs: 0,
497
500
  exitCode: null,
@@ -508,14 +511,14 @@ function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
508
511
  };
509
512
  }
510
513
 
511
- function mergeTaskResults(previous: SubagentTaskResult | undefined, next: SubagentTaskResult, maxTraceBytes: number): SubagentTaskResult {
514
+ function mergeTaskResults(previous: SubagentTaskResult | undefined, next: SubagentTaskResult, maxTraceBytes?: number): SubagentTaskResult {
512
515
  if (!previous) return cloneResult(next);
513
516
  const merged = cloneResult(next);
514
517
  const trace: string[] = [];
515
518
  let traceBytes = 0;
516
519
  let traceTruncatedBytes = previous.traceTruncatedBytes + next.traceTruncatedBytes;
517
520
  for (const entry of [...previous.trace, ...next.trace]) {
518
- 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));
519
522
  if (retained.text) trace.push(retained.text);
520
523
  const retainedBytes = Buffer.byteLength(retained.text, "utf8");
521
524
  traceBytes += retainedBytes;
@@ -530,6 +533,7 @@ function mergeTaskResults(previous: SubagentTaskResult | undefined, next: Subage
530
533
  merged.output = next.output || previous.output;
531
534
  merged.outputBytes = previous.outputBytes + next.outputBytes;
532
535
  merged.outputTruncatedBytes = previous.outputTruncatedBytes + next.outputTruncatedBytes;
536
+ merged.toolCallCount = previous.toolCallCount + next.toolCallCount;
533
537
  merged.usage = emptyUsage();
534
538
  addUsage(merged.usage, previous.usage);
535
539
  addUsage(merged.usage, next.usage);
@@ -557,7 +561,7 @@ interface RunTaskOptions {
557
561
  step?: number;
558
562
  model: ResolvedModel;
559
563
  signal?: AbortSignal;
560
- spawnProcess: (args: string[], cwd: string) => SpawnedProcess;
564
+ spawnProcess: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SpawnedProcess;
561
565
  webExtension?: string;
562
566
  projectTrusted: boolean;
563
567
  limits: SubagentLimits;
@@ -582,6 +586,7 @@ function applyProcessResult(
582
586
  target.output = source.output;
583
587
  target.outputBytes = source.outputBytes;
584
588
  target.outputTruncatedBytes = source.outputTruncatedBytes;
589
+ target.toolCallCount = source.toolCallCount;
585
590
  target.usage = { ...source.usage, cost: { ...source.usage.cost } };
586
591
  target.model = source.model ?? target.model;
587
592
  target.terminationReason = source.terminationReason;
@@ -643,13 +648,13 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
643
648
  signal: options.signal,
644
649
  spawnProcess: options.spawnProcess,
645
650
  limits: {
646
- wallTimeMs: options.timeoutMs ?? agent.timeoutMs,
647
- jsonlLineBytes: limits.jsonlLineBytes,
648
- traceBytes: limits.traceBytes,
649
- stderrBytes: limits.stderrBytes,
650
- outputBytes: limits.taskOutputBytes,
651
- quotaTokens: limits.quotaTokens,
652
- 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 }),
653
658
  killGraceMs: limits.killGraceMs,
654
659
  },
655
660
  onUpdate: (next) => applyProcessResult(result, next, startedAt, options.onChange),
@@ -861,6 +866,7 @@ function threadResult(thread: SubagentThread, source?: SubagentTaskResult): Suba
861
866
  turns: thread.usage.turns,
862
867
  },
863
868
  output: thread.result ?? "",
869
+ toolCallCount: 0,
864
870
  terminationReason: thread.stopReason,
865
871
  };
866
872
  }
@@ -960,7 +966,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
960
966
  const from = runtime?.traceCount ?? 0;
961
967
  let retainedTraceBytes = thread.trace.reduce((total, entry) => total + Buffer.byteLength(entry.message ?? "", "utf8"), 0);
962
968
  for (const entry of next.trace.slice(from)) {
963
- 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));
964
972
  if (retained.text) {
965
973
  threads.trace(threadId, { kind: "child", message: retained.text });
966
974
  retainedTraceBytes += Buffer.byteLength(retained.text, "utf8");
@@ -1238,33 +1246,34 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1238
1246
  try {
1239
1247
  while (true) {
1240
1248
  const aggregate = runtime.aggregate;
1241
- 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);
1242
1251
  const usedTraceBytes = aggregate?.traceBytes ?? 0;
1243
1252
  const usedStderrBytes = aggregate?.stderrBytes ?? 0;
1244
1253
  const usedOutputBytes = aggregate?.outputBytes ?? 0;
1245
1254
  const usedTokens = aggregate?.usage.totalTokens ?? 0;
1246
1255
  const usedCost = aggregate?.usage.cost.total ?? 0;
1247
- if (remainingWallTimeMs <= 0) {
1248
- 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`);
1249
1258
  break;
1250
1259
  }
1251
- if (usedTraceBytes >= limits.traceBytes || aggregate?.traceTruncatedBytes) {
1260
+ if (limits.traceBytes !== undefined && (usedTraceBytes >= limits.traceBytes || aggregate?.traceTruncatedBytes)) {
1252
1261
  stopForBudget("trace_limit", `Child thread retains more than ${limits.traceBytes} trace bytes`);
1253
1262
  break;
1254
1263
  }
1255
- if (usedStderrBytes >= limits.stderrBytes || aggregate?.stderrTruncatedBytes) {
1264
+ if (limits.stderrBytes !== undefined && (usedStderrBytes >= limits.stderrBytes || aggregate?.stderrTruncatedBytes)) {
1256
1265
  stopForBudget("stderr_limit", `Child thread emits more than ${limits.stderrBytes} stderr bytes`);
1257
1266
  break;
1258
1267
  }
1259
- if (usedOutputBytes >= limits.taskOutputBytes || aggregate?.outputTruncatedBytes) {
1268
+ if (limits.taskOutputBytes !== undefined && (usedOutputBytes >= limits.taskOutputBytes || aggregate?.outputTruncatedBytes)) {
1260
1269
  stopForBudget("output_limit", `Child thread emits more than ${limits.taskOutputBytes} output bytes`);
1261
1270
  break;
1262
1271
  }
1263
- if (usedTokens >= limits.quotaTokens) {
1272
+ if (limits.quotaTokens !== undefined && usedTokens >= limits.quotaTokens) {
1264
1273
  stopForBudget("quota_tokens", `Child thread exceeds ${limits.quotaTokens} tokens`);
1265
1274
  break;
1266
1275
  }
1267
- if (usedCost >= limits.quotaUsd) {
1276
+ if (limits.quotaUsd !== undefined && usedCost >= limits.quotaUsd) {
1268
1277
  stopForBudget("quota_cost", `Child thread exceeds $${limits.quotaUsd}`);
1269
1278
  break;
1270
1279
  }
@@ -1282,11 +1291,11 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1282
1291
  spawnProcess,
1283
1292
  limits: {
1284
1293
  ...limits,
1285
- traceBytes: limits.traceBytes - usedTraceBytes,
1286
- stderrBytes: limits.stderrBytes - usedStderrBytes,
1287
- taskOutputBytes: limits.taskOutputBytes - usedOutputBytes,
1288
- quotaTokens: limits.quotaTokens - usedTokens,
1289
- 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 }),
1290
1299
  },
1291
1300
  timeoutMs: remainingWallTimeMs,
1292
1301
  onHandle: (handle) => { runtime.handle = handle; },