@d3ara1n/pi-subagent 0.10.1 → 0.10.2

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/README.md CHANGED
@@ -30,12 +30,12 @@ This means:
30
30
 
31
31
  ## Built-in Roles
32
32
 
33
- | Role | Model Role | Tools | Can Delegate To | Description |
34
- |------|-----------|-------|-----------------|-------------|
35
- | `explorer` | fast | read, find, grep | — | Fast code search (read-only, no bash) |
36
- | `reviewer` | heavy | read, bash, grep, find | — | Deep code review (read-only, bash for git/log) |
37
- | `worker` | default | read, bash, edit, write, grep, find, delegate | explorer, researcher | Implementation — the only role that can modify files |
38
- | `researcher` | fast | web_search, fetch_content, read, bash, delegate | explorer | Web research + GitHub repo analysis |
33
+ | Role | Model Role | Timeout | Tools | Can Delegate To | Description |
34
+ |------|-----------|---------|-------|-----------------|-------------|
35
+ | `explorer` | fast | 900s | read, find, grep | — | Fast code search (read-only, no bash) |
36
+ | `reviewer` | heavy | 3600s | read, bash, grep, find | — | Deep code review (read-only, bash for git/log) |
37
+ | `worker` | default | 2400s | read, bash, edit, write, grep, find, delegate | explorer, researcher | Implementation — the only role that can modify files |
38
+ | `researcher` | fast | 2400s | web_search, fetch_content, read, bash, delegate | explorer | Web research + GitHub repo analysis |
39
39
 
40
40
  **Nested delegation**: `worker` and `researcher` can spawn their own subagents. This keeps the main model's context clean — a worker can explore unfamiliar code via an `explorer` subagent without returning intermediate results to the main model.
41
41
 
@@ -76,7 +76,6 @@ Edit `~/.pi/agent/settings.json`:
76
76
  ```json
77
77
  {
78
78
  "subagent": {
79
- "timeout": 1500,
80
79
  "maxConcurrency": 4,
81
80
  "maxDepth": 3,
82
81
  "maxTurns": 0,
@@ -92,9 +91,11 @@ Edit `~/.pi/agent/settings.json`:
92
91
  }
93
92
  ```
94
93
 
95
- All fields are optional. Defaults: `timeout: 1500` (seconds; 25 min; active time — the clock pauses while the child is inside a nested `delegate` call, so delegate-capable roles need no extra headroom), `maxConcurrency: 4`, `maxDepth: 3`, `maxTurns: 0` (unlimited), `maxCost: 0` (unlimited), `history.enabled: true`, `summary.role: "utility"`, `summary.enabled: true`.
94
+ All fields are optional. Defaults: `maxConcurrency: 4`, `maxDepth: 3`, `maxTurns: 0` (unlimited), `maxCost: 0` (unlimited), `history.enabled: true`, `summary.role: "utility"`, `summary.enabled: true`.
96
95
 
97
- All numeric limits accept `0` for unlimited: `timeout`, `maxConcurrency`, `maxDepth`, `maxTurns`, and `maxCost`. Negative values are normalized to `0`; non-numeric or non-finite values fall back to their defaults. `maxConcurrency: 0` runs delegates without queuing, and `maxDepth: 0` permits unrestricted nesting.
96
+ Timeouts are defined per role. Built-in defaults are `explorer: 900`, `reviewer: 3600`, `worker: 2400`, and `researcher: 2400` seconds. The timeout is active time the clock pauses while the child is inside a nested `delegate` call, so delegate-capable roles need no extra headroom.
97
+
98
+ All numeric limits accept `0` for unlimited: `maxConcurrency`, `maxDepth`, `maxTurns`, `maxCost`, and per-role `timeout`. Negative values are normalized to `0`; non-numeric or non-finite values fall back to their defaults. `maxConcurrency: 0` runs delegates without queuing, and `maxDepth: 0` permits unrestricted nesting.
98
99
 
99
100
  ### Agent Overrides
100
101
 
@@ -131,7 +132,7 @@ Override, disable, or add subagent roles via `agentOverrides`. Built-in and cust
131
132
 
132
133
  **Required fields for custom roles:** `role`, `description`, `examples`, `decisionTrigger`, `tools`, `systemPrompt`.
133
134
 
134
- **Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `timeout` (per-role active-time timeout in seconds; unset uses the global setting, `0` is unlimited, negative values normalize to `0`), `maxTurns` / `maxCost` (per-role budget overrides; unset uses the global setting, `0` is unlimited, negative values normalize to `0`), `fallbackRole` (backup pi-model-roles role on provider errors).
135
+ **Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `timeout` (per-role active-time timeout in seconds; unset or `0` is unlimited, negative values normalize to `0`), `maxTurns` / `maxCost` (per-role budget overrides; unset uses the top-level `maxTurns` / `maxCost` setting, `0` is unlimited, negative values normalize to `0`), `fallbackRole` (backup pi-model-roles role on provider errors).
135
136
 
136
137
  Invalid custom roles (missing required fields) are silently skipped with an error notification at session start.
137
138
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "type": "module",
5
5
  "description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
6
6
  "main": "src/index.ts",
@@ -47,7 +47,6 @@ describe("loadSubagentConfig", () => {
47
47
  const { agentDir } = makeRoot();
48
48
  writeSettings(agentDir, {
49
49
  subagent: {
50
- timeout: 0,
51
50
  maxConcurrency: -1,
52
51
  maxDepth: -2,
53
52
  maxTurns: 0,
@@ -56,7 +55,6 @@ describe("loadSubagentConfig", () => {
56
55
  });
57
56
 
58
57
  const config = loadSubagentConfig();
59
- assert.equal(config.timeout, 0);
60
58
  assert.equal(config.maxConcurrency, 0);
61
59
  assert.equal(config.maxDepth, 0);
62
60
  assert.equal(config.maxTurns, 0);
@@ -69,11 +67,10 @@ describe("loadSubagentConfig", () => {
69
67
  // would serialize it as null. Exercise both non-finite and wrong-type inputs.
70
68
  writeSettingsText(
71
69
  agentDir,
72
- '{"subagent":{"timeout":1e999,"maxConcurrency":-1e999,"maxDepth":{},"maxTurns":false,"maxCost":"NaN"}}',
70
+ '{"subagent":{"maxConcurrency":-1e999,"maxDepth":{},"maxTurns":false,"maxCost":"NaN"}}',
73
71
  );
74
72
 
75
73
  const config = loadSubagentConfig();
76
- assert.equal(config.timeout, DEFAULT_CONFIG.timeout);
77
74
  assert.equal(config.maxConcurrency, DEFAULT_CONFIG.maxConcurrency);
78
75
  assert.equal(config.maxDepth, DEFAULT_CONFIG.maxDepth);
79
76
  assert.equal(config.maxTurns, DEFAULT_CONFIG.maxTurns);
@@ -96,7 +93,6 @@ describe("loadSubagentConfig", () => {
96
93
  const { agentDir, projectDir } = makeRoot();
97
94
  writeSettings(agentDir, {
98
95
  subagent: {
99
- timeout: 999,
100
96
  maxConcurrency: 8,
101
97
  maxDepth: 7,
102
98
  maxTurns: 6,
@@ -107,11 +103,10 @@ describe("loadSubagentConfig", () => {
107
103
  },
108
104
  });
109
105
  writeSettings(path.join(projectDir, ".pi"), {
110
- subagent: { timeout: 12, summary: { role: "project-summary" } },
106
+ subagent: { summary: { role: "project-summary" } },
111
107
  });
112
108
 
113
109
  const config = loadSubagentConfig(projectDir);
114
- assert.equal(config.timeout, 12);
115
110
  assert.equal(config.maxConcurrency, DEFAULT_CONFIG.maxConcurrency);
116
111
  assert.equal(config.maxDepth, DEFAULT_CONFIG.maxDepth);
117
112
  assert.equal(config.maxTurns, DEFAULT_CONFIG.maxTurns);
package/src/config.ts CHANGED
@@ -46,7 +46,6 @@ export function loadSubagentConfig(cwd?: string): SubagentConfig {
46
46
  const rawSummary = raw?.summary;
47
47
  const rawHistory = raw?.history;
48
48
  return {
49
- timeout: normalizeNonNegativeNumber(raw.timeout, DEFAULT_CONFIG.timeout),
50
49
  maxConcurrency: normalizeNonNegativeInteger(raw.maxConcurrency, DEFAULT_CONFIG.maxConcurrency),
51
50
  maxDepth: normalizeNonNegativeInteger(raw.maxDepth, DEFAULT_CONFIG.maxDepth),
52
51
  maxTurns: normalizeNonNegativeInteger(raw.maxTurns, DEFAULT_CONFIG.maxTurns),
package/src/index.ts CHANGED
@@ -23,8 +23,6 @@ import {
23
23
  AsyncSemaphore,
24
24
  isProviderError,
25
25
  effectiveTimeout,
26
- normalizeNonNegativeInteger,
27
- normalizeNonNegativeNumber,
28
26
  } from "./utils.ts";
29
27
  import { persistSubagentHistory } from "./history.ts";
30
28
  import { compressOutput, generateSummary } from "./output.ts";
@@ -296,6 +294,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
296
294
  };
297
295
  }
298
296
 
297
+ const rethrowToToolRuntime = Symbol("pi-subagent-rethrow");
299
298
  try {
300
299
  // Resolve model AFTER acquiring so the queued period stays zero-cost
301
300
  let rolesApi: ModelRolesAPI;
@@ -336,12 +335,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
336
335
  const startTime = Date.now();
337
336
  // Total active-time budget for this run (ms). The clock pauses while the
338
337
  // child delegates, so this caps *active* time, not wall time.
339
- const timeoutBudgetMs = effectiveTimeout(roleDef, config.timeout) * 1000;
340
- const maxTurns = normalizeNonNegativeInteger(
341
- roleDef.maxTurns ?? config.maxTurns,
342
- config.maxTurns,
343
- );
344
- const maxCost = normalizeNonNegativeNumber(roleDef.maxCost ?? config.maxCost, config.maxCost);
338
+ const timeoutBudgetMs = effectiveTimeout(roleDef) * 1000;
339
+ const maxTurns = roleDef.maxTurns ?? config.maxTurns;
340
+ const maxCost = roleDef.maxCost ?? config.maxCost;
345
341
 
346
342
  // Throttled progress: coalesces bursty thinking/tool events so the TUI
347
343
  // repaints at most ~every PROGRESS_THROTTLE_MS, always keeping the latest state.
@@ -526,11 +522,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
526
522
  if (result.exitCode !== 0 || result.errorMessage) {
527
523
  const failedText = `Subagent (${params.role}) failed: ${result.errorMessage || result.stderr || "unknown error"}\n\nPartial output:\n${result.output}`;
528
524
  emitFinal([result], failedText);
529
- return {
530
- content: [{ type: "text", text: failedText }],
531
- details: { mode: "single", results: [result] },
532
- isError: true,
533
- };
525
+ const err = new Error(failedText) as Error & { [rethrowToToolRuntime]?: true };
526
+ err[rethrowToToolRuntime] = true;
527
+ throw err;
534
528
  }
535
529
 
536
530
  // Build concise output for the main model with usage info
@@ -550,13 +544,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
550
544
  details: { mode: "single", results: [result] },
551
545
  };
552
546
  } catch (err: any) {
547
+ if (err?.[rethrowToToolRuntime]) throw err;
553
548
  const errorText = `Subagent (${params.role}) error: ${err.message || err}`;
554
549
  emitFinal([], errorText);
555
- return {
556
- content: [{ type: "text", text: errorText }],
557
- details: { mode: "single", results: [] },
558
- isError: true,
559
- };
550
+ throw new Error(errorText);
560
551
  } finally {
561
552
  // Cancel any trailing throttled onUpdate regardless of how we exited
562
553
  // (success / fallback / budget / error). A stale "still running" progress
@@ -592,7 +583,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
592
583
  try {
593
584
  const cfg = loadSubagentConfig(ctx.cwd);
594
585
  lines.push(
595
- `[\u2713] config: timeout=${cfg.timeout || "∞"}s concurrency=${cfg.maxConcurrency || "∞"} depth=${cfg.maxDepth || "∞"} turns=${cfg.maxTurns || "∞"} cost=$${cfg.maxCost || "∞"} summary=${cfg.summary.enabled ? cfg.summary.role : "off"} history=${cfg.history.enabled}`,
586
+ `[\u2713] config: concurrency=${cfg.maxConcurrency || "∞"} depth=${cfg.maxDepth || "∞"} turns=${cfg.maxTurns || "∞"} cost=$${cfg.maxCost || "∞"} summary=${cfg.summary.enabled ? cfg.summary.role : "off"} history=${cfg.history.enabled}`,
596
587
  );
597
588
  } catch {
598
589
  lines.push("[\u2717] config: failed to load");
package/src/roles.ts CHANGED
@@ -12,6 +12,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
12
12
  explorer: {
13
13
  role: "fast",
14
14
  fallbackRole: "default",
15
+ timeout: 900,
15
16
  description:
16
17
  "READ-ONLY codebase exploration — locate files, grep symbols, trace imports, explain structures. Tools: read, find, grep. NO bash, NO edits, NO web access.",
17
18
  examples: ["Find where auth middleware is implemented", "Map the routing structure"],
@@ -31,6 +32,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
31
32
  reviewer: {
32
33
  role: "heavy",
33
34
  fallbackRole: "default",
35
+ timeout: 3600,
34
36
  description:
35
37
  "READ-ONLY code review & analysis — audit code, assess architecture, review diffs. Tools: read, bash, grep, find. Has bash (git diff/log, test runs). NO edits, NO web access.",
36
38
  examples: [
@@ -52,6 +54,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
52
54
  },
53
55
  worker: {
54
56
  role: "default",
57
+ timeout: 2400,
55
58
  description:
56
59
  "the ONLY role that can MODIFY files — edit, write, refactor, fix, implement. Tools: read, bash, edit, write, grep, find, delegate. Can delegate to explorer/researcher.",
57
60
  examples: ["Rename all snake_case fields to camelCase", "Add input validation to POST /login"],
@@ -77,6 +80,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
77
80
  researcher: {
78
81
  role: "fast",
79
82
  fallbackRole: "default",
83
+ timeout: 2400,
80
84
  description:
81
85
  "the ONLY role with WEB ACCESS — search docs, fetch pages, analyze GitHub repos. Tools: web_search, fetch_content, read, bash, delegate. Can clone repos & delegate to explorer.",
82
86
  examples: ["Find the React 19 migration guide", "Check GitHub issue #1234 for context"],
package/src/types.ts CHANGED
@@ -4,8 +4,6 @@
4
4
 
5
5
  /** Configuration for the subagent extension. */
6
6
  export interface SubagentConfig {
7
- /** Per-subagent active-time timeout in seconds. `0` means unlimited; negative values are normalized to `0`. The clock pauses while the child is inside a nested `delegate` call, so no widening is needed for delegate-capable roles. */
8
- timeout: number;
9
7
  /** Max concurrent subagents. `0` means unlimited; negative values are normalized to `0`. Extras queue with a TUI hint when this is positive. */
10
8
  maxConcurrency: number;
11
9
  /** Max subagent nesting depth (the top-level session is depth 0). `0` means unlimited; negative values are normalized to `0`. */
@@ -35,7 +33,6 @@ export interface SubagentSummaryConfig {
35
33
  }
36
34
 
37
35
  export const DEFAULT_CONFIG: SubagentConfig = {
38
- timeout: 1500,
39
36
  maxConcurrency: 4,
40
37
  maxDepth: 3,
41
38
  maxTurns: 0,
@@ -61,7 +58,7 @@ export interface SubagentRole {
61
58
  tools: string[];
62
59
  /** If this role has `delegate`, restrict which roles it may spawn. undefined = no restriction. */
63
60
  subagentRoles?: string[];
64
- /** Per-role active-time timeout override in seconds. `0` means unlimited; negative values are normalized to `0`. Falls back to config.timeout when unset. */
61
+ /** Per-role active-time timeout in seconds. `0` or unset means unlimited; negative values are normalized to `0`. */
65
62
  timeout?: number;
66
63
  /** Max assistant turns before the run is killed. `0` means unlimited; negative values are normalized to `0`. */
67
64
  maxTurns?: number;
package/src/utils.test.ts CHANGED
@@ -248,17 +248,18 @@ describe("effectiveTimeout", () => {
248
248
  timeout,
249
249
  }) as unknown as SubagentRole;
250
250
 
251
- test("non-delegate role uses base timeout", () => {
252
- assert.equal(effectiveTimeout(role(["read", "grep"]), 600), 600);
251
+ test("role without timeout is unlimited", () => {
252
+ assert.equal(effectiveTimeout(role(["read", "grep"])), 0);
253
253
  });
254
- test("delegate role uses base timeout (no widening — active-time clock pauses for nested delegate)", () => {
255
- assert.equal(effectiveTimeout(role(["read", "delegate"]), 600), 600);
254
+ test("delegate-capable role without timeout is also unlimited", () => {
255
+ assert.equal(effectiveTimeout(role(["read", "delegate"])), 0);
256
256
  });
257
- test("explicit roleDef.timeout is always honored (no widening)", () => {
258
- assert.equal(effectiveTimeout(role(["read", "delegate"], 300), 600), 300);
257
+ test("explicit role timeout is honored", () => {
258
+ assert.equal(effectiveTimeout(role(["read", "delegate"], 300)), 300);
259
259
  });
260
- test("explicit timeout on non-delegate also honored", () => {
261
- assert.equal(effectiveTimeout(role(["read"]), 600), 600);
260
+ test("negative and non-finite values normalize to unlimited", () => {
261
+ assert.equal(effectiveTimeout(role(["read"], -1)), 0);
262
+ assert.equal(effectiveTimeout(role(["read"], Number.POSITIVE_INFINITY)), 0);
262
263
  });
263
264
  });
264
265
 
package/src/utils.ts CHANGED
@@ -291,13 +291,10 @@ export class AsyncSemaphore {
291
291
 
292
292
  /**
293
293
  * Effective per-role timeout in SECONDS (convert to ms at the spawn boundary).
294
- * No widening for delegate-capable roles: the parent's active-time clock
295
- * pauses while the child is inside a nested `delegate` call, so the base
296
- * budget is already enough. An explicit roleDef.timeout always wins.
297
- * Non-finite values fall back to the base timeout; negative values become 0 (unlimited).
294
+ * `0` or unset means unlimited; non-finite and negative values normalize to 0.
298
295
  */
299
- export function effectiveTimeout(roleDef: SubagentRole, baseTimeoutSec: number): number {
300
- return normalizeNonNegativeNumber(roleDef.timeout, normalizeNonNegativeNumber(baseTimeoutSec, 0));
296
+ export function effectiveTimeout(roleDef: SubagentRole): number {
297
+ return normalizeNonNegativeNumber(roleDef.timeout, 0);
301
298
  }
302
299
 
303
300
  // ── Output truncation ────────────────────────────────────────