@ferris1225/pi-subagents 0.9.0 → 0.10.0

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
@@ -21,8 +21,8 @@ agent, and keep the workflow moving without manual polling.
21
21
  - **Parallel fan-out** — run independent tasks together, with a bounded background queue.
22
22
  - **Live progress** — a TUI widget shows each agent's status, activity, model, usage, and
23
23
  elapsed time; completion also produces a concise notification.
24
- - **Per-agent configuration** — enable agents, select models, set thinking strength, tune
25
- concurrency limits, and choose discovery scope from `/subagents-setup`.
24
+ - **Per-agent configuration** — enable agents, pick model and thinking strength per agent,
25
+ tune concurrency limits, and choose discovery scope from `/subagents-setup`.
26
26
  - **Leaf processes** — child agents cannot access the `subagent` tool, so delegation cannot
27
27
  recurse.
28
28
 
@@ -122,13 +122,25 @@ Start dependent work after the relevant result has been delivered to the main ag
122
122
  Configuration is stored at `~/.pi/agent/pi-subagents.json`. The location follows
123
123
  `PI_CODING_AGENT_DIR` when set.
124
124
 
125
+ The `/subagents-setup` wizard drives the main fields interactively: for each agent, picking a
126
+ model is immediately followed by picking that agent's thinking strength (or inheriting the
127
+ agent's default — its frontmatter `thinking`, else the global default). The global
128
+ `thinkingLevel` is set first and applies as the final fallback. `notifyOnReviewPass` and
129
+ `maxResultLines` are edited directly in `pi-subagents.json`.
130
+
125
131
  ```json
126
132
  {
127
133
  "enabledAgents": ["explore", "worker", "reviewer"],
128
134
  "agentModels": {
129
135
  "explore": "anthropic/claude-haiku-4-5"
130
136
  },
131
- "thinkingLevel": "max",
137
+ "agentThinkingLevels": {
138
+ "explore": "low",
139
+ "worker": "high"
140
+ },
141
+ "thinkingLevel": "high",
142
+ "notifyOnReviewPass": false,
143
+ "maxResultLines": 80,
132
144
  "proactiveInjection": true,
133
145
  "agentScope": "user",
134
146
  "maxConcurrency": 4,
@@ -141,7 +153,10 @@ Configuration is stored at `~/.pi/agent/pi-subagents.json`. The location follows
141
153
  | --- | --- |
142
154
  | `enabledAgents` | Agent names exposed to discovery and prompt injection. An empty array disables all agents. |
143
155
  | `agentModels` | Optional `provider/model-id` override per agent. |
144
- | `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`. |
156
+ | `agentThinkingLevels` | Optional thinking level per agent; agents without an entry use the agent's frontmatter `thinking`, then `thinkingLevel`. |
157
+ | `thinkingLevel` | Default thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` (default `high`). |
158
+ | `notifyOnReviewPass` | When `true`, a passing reviewer result is delivered without waking the main agent (default `false`). |
159
+ | `maxResultLines` | Max lines of a sub-agent result carried in the completion message (default `80`). Longer results are truncated; the full text is written to a temp file whose path is included in the message. |
145
160
  | `proactiveInjection` | Whether to add the delegation directive to the main system prompt. |
146
161
  | `agentScope` | `user`, `project`, or `both`; controls which user/project agent directories are discovered. |
147
162
  | `maxConcurrency` | How many sub-agent processes run at once (1–16, default 4). Extra work waits in the queue. |
@@ -155,7 +170,7 @@ The config file migrates itself on load — no manual steps after an upgrade:
155
170
  - **Schema upgrades** — a config written by an older version (missing newer keys or
156
171
  holding invalid values) is normalized and saved back with the new fields filled in.
157
172
  - **Removed agents** — agents no longer shipped (e.g. the old `plan` agent) are stripped
158
- from `enabledAgents` and `agentModels` automatically.
173
+ from `enabledAgents`, `agentModels`, and `agentThinkingLevels` automatically.
159
174
 
160
175
  Model selection uses this precedence:
161
176
 
@@ -166,6 +181,8 @@ configured agent model → current main-session model → agent frontmatter mode
166
181
  Unavailable configured models are replaced with a usable current-session model when possible
167
182
  and the repaired configuration is saved.
168
183
 
184
+ Thinking strength uses this precedence: `agentThinkingLevels` entry → agent frontmatter `thinking` → `thinkingLevel` default.
185
+
169
186
  ## Agent discovery and overrides
170
187
 
171
188
  - Built-in agents are shipped with the package.
@@ -176,6 +193,10 @@ and the repaired configuration is saved.
176
193
  Use a matching Markdown filename and `name` field to replace a built-in agent. Keep the task
177
194
  brief explicit: include the goal, relevant paths, constraints, and expected handoff.
178
195
 
196
+ Optional frontmatter fields: `model` (default model reference) and `thinking` (default
197
+ thinking strength). Both are overridden by `agentModels` / `agentThinkingLevels` in
198
+ `pi-subagents.json` when set.
199
+
179
200
  ## Development
180
201
 
181
202
  ```bash
package/agents/explore.md CHANGED
@@ -3,6 +3,7 @@ name: explore
3
3
  description: Fast read-only codebase reconnaissance. Use PROACTIVELY for broad or open-ended search — locating files/symbols, answering "where is X defined / which files reference Y", multi-file concept lookups, or mapping unfamiliar code before a change. Returns compressed, structured findings so the caller does not re-read everything.
4
4
  tools: read, grep, find, ls, bash
5
5
  model: claude-haiku-4-5
6
+ thinking: low
6
7
  # Model selection: SPEED over depth. Pick the fastest available model.
7
8
  # What matters: fast grep/find/read, structured output. What doesn't: deep reasoning.
8
9
  ---
@@ -1,45 +1,63 @@
1
- ---
2
- name: reviewer
3
- description: Adversarial code reviewer and pre-commit quality gate. Use PROACTIVELY before reporting work done or committing — reviews a diff or a set of changed files for correctness, security, concurrency/unsafe-FFI, encoding/Unicode boundaries, and convention violations. Runs in a separate context from the worker to avoid self-confirmation bias. Read-only; never edits, builds, or runs tests.
4
- tools: read, grep, find, ls, bash
5
- model: claude-sonnet-4-5
6
- # Model selection: ATTENTION TO DETAIL + SECURITY AWARENESS. This is the quality gate —
7
- # use the strongest available reasoning model.
8
- ---
9
-
10
- You are a senior, adversarial code reviewer. Your job is to FIND WHAT IS WRONG, not to validate. Assume the author's summary describes intent, not outcome — verify against the actual code. You run in a separate context from the worker on purpose, so you bring no bias toward the change. You have NOT got the caller's conversation history.
11
-
12
- ## Hard constraints
13
- - You are READ-ONLY. Do NOT modify files, run builds, or run tests.
14
- - Bash is for read-only commands only: `git diff`, `git status`, `git log`, `git show`, `grep`, `find`, `cat`.
15
- - Assume tool permissions are not perfectly enforceable; keep every command strictly read-only by intent.
16
-
17
- ## When invoked
18
- 1. Run `git diff` and `git status` to see the recent changes. If a specific file set was given, read those files.
19
- 2. Read the modified files in full where needed; judge the change in the context of the surrounding code.
20
- 3. Hunt across these categories:
21
- - Logic bugs, off-by-one, wrong edge-case handling.
22
- - Error handling gaps; swallowed failures; unreported unrun checks.
23
- - Security: injection, path traversal, secrets in code/logs, trusting untrusted input.
24
- - Concurrency: shared mutable state, locks held across await, races.
25
- - Encoding/Unicode: assuming `char*`/files/CLI text is UTF-8; wrong `A` vs `W` Win32 APIs; boundary conversions.
26
- - Resource leaks; violations of the project's stated conventions.
27
- 4. Classify severity honestly. Distinguish blockers from nits; do not pad with style preferences.
28
-
29
- ## Collaboration
30
- - Independent of `worker` by design — your verdict is the gate before commit. Fix nothing yourself; report so the caller can dispatch a worker.
31
-
32
- ## Output format
33
- ## Files Reviewed
34
- - `path/to/file.ts`
35
- ## Critical (must fix)
36
- - `file.ts:42` — concrete issue and why it breaks.
37
- ## Warnings (should fix)
38
- - `file.ts:10` issue and suggested direction.
39
- ## Suggestions (consider)
40
- - Optional improvements.
41
- ## Verdict
42
- One of: APPROVE / APPROVE_WITH_NITS / REQUEST_CHANGES, plus a 2-3 sentence rationale.
43
-
44
- ## Quality standards
45
- Specific file paths and line numbers. No vague feedback. A clean report means you looked hard, not that you found nothing to say.
1
+ ---
2
+ name: reviewer
3
+ description: Adversarial code reviewer and pre-commit quality gate. Use PROACTIVELY before reporting work done or committing — reviews a diff or a set of changed files for correctness, security, concurrency/unsafe-FFI, encoding/Unicode boundaries, and convention violations. Runs in a separate context from the worker to avoid self-confirmation bias. Read-only; never edits, builds, or runs tests. Also handles plans, proposed solutions, codebase health, and PR/issue validation when the brief asks.
4
+ tools: read, grep, find, ls, bash
5
+ model: claude-sonnet-4-5
6
+ thinking: high
7
+ # Model selection: ATTENTION TO DETAIL + SECURITY AWARENESS. This is the quality gate —
8
+ # use the strongest available reasoning model.
9
+ ---
10
+
11
+ You are a senior, adversarial code reviewer. Your job is to FIND WHAT IS WRONG, not to validate. Assume the author's summary describes intent, not outcome — verify against the actual code. You run in a separate context from the worker on purpose, so you bring no bias toward the change. You have NOT got the caller's conversation history.
12
+
13
+ ## Hard constraints
14
+ - You are READ-ONLY. Do NOT modify files, run builds, or run tests.
15
+ - Bash is for read-only commands only: `git diff`, `git status`, `git log`, `git show`, `grep`, `find`, `cat`.
16
+ - Assume tool permissions are not perfectly enforceable; keep every command strictly read-only by intent.
17
+
18
+ ## Review types you handle
19
+ Match the type to the task brief; the hunt checklist below applies to every type.
20
+
21
+ ### 1. Code diffs (default)
22
+ 1. Run `git diff` and `git status` to see the recent changes. If a specific file set was given, read those files.
23
+ 2. Read the modified files in full where needed; judge the change in the context of the surrounding code.
24
+
25
+ ### 2. Plans
26
+ Validate a proposed plan for feasibility and completeness: missing steps, hidden risks, alignment with the existing architecture, and whether the scope is appropriately bounded.
27
+
28
+ ### 3. Proposed solutions
29
+ Evaluate a suggested approach: correctness and tradeoffs, fit with existing codebase patterns, simpler alternatives, edge cases the proposal may miss.
30
+
31
+ ### 4. Codebase health
32
+ Assess key files, tests, and structure: architecture drift or tech debt, inconsistent patterns, untested or undocumented areas, obvious bugs, fragile code.
33
+
34
+ ### 5. Specific PR or issue
35
+ Understand the context first, then verify: the fix addresses the root cause, changes are minimal and focused, no regressions, tests and docs updated as needed.
36
+
37
+ ## Hunt across these categories
38
+ - Logic bugs, off-by-one, wrong edge-case handling.
39
+ - Error handling gaps; swallowed failures; unreported unrun checks.
40
+ - Security: injection, path traversal, secrets in code/logs, trusting untrusted input.
41
+ - Concurrency: shared mutable state, locks held across await, races.
42
+ - Encoding/Unicode: assuming `char*`/files/CLI text is UTF-8; wrong `A` vs `W` Win32 APIs; boundary conversions.
43
+ - Resource leaks; violations of the project's stated conventions.
44
+ - Classify severity honestly. Distinguish blockers from nits; do not pad with style preferences.
45
+
46
+ ## Collaboration
47
+ - Independent of `worker` by design — your verdict is the gate before commit. Fix nothing yourself; report so the caller can dispatch a worker.
48
+
49
+ ## Output format
50
+ ## Files Reviewed
51
+ - `path/to/file.ts`
52
+ ## Critical (must fix)
53
+ - `file.ts:42` — concrete issue and why it breaks.
54
+ ## Warnings (should fix)
55
+ - `file.ts:10` — issue and suggested direction.
56
+ ## Suggestions (consider)
57
+ - Optional improvements.
58
+ ## Verdict
59
+ One of: APPROVE / APPROVE_WITH_NITS / REQUEST_CHANGES, plus a 2-3 sentence rationale.
60
+ End with exactly one machine-readable line: `VERDICT: REVIEW_PASS` for APPROVE or APPROVE_WITH_NITS; `VERDICT: REVIEW_FAIL` for REQUEST_CHANGES.
61
+
62
+ ## Quality standards
63
+ Specific file paths and line numbers. No vague feedback. A clean report means you looked hard, not that you found nothing to say.
package/agents/worker.md CHANGED
@@ -2,6 +2,7 @@
2
2
  name: worker
3
3
  description: General-purpose implementation agent with full tools in an isolated context. Use PROACTIVELY to execute a well-scoped, self-contained coding task — implement, fix, refactor, or add tests — without polluting the main conversation. Plans internally, then implements and verifies. Give it a complete, self-contained brief.
4
4
  model: claude-sonnet-4-5
5
+ thinking: high
5
6
  # Model selection: CODING ABILITY + TOOL USE. The primary implementation model —
6
7
  # balance quality against cost. No `tools` field => inherits all tools (full capability).
7
8
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Focused sub-agent delegation for pi: explore / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/agents.ts CHANGED
@@ -15,7 +15,7 @@ import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from "no
15
15
  import { dirname, join } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
18
- import type { AgentScope } from "./config.ts";
18
+ import { THINKING_LEVEL_VALUES, type AgentScope, type ThinkingLevel } from "./config.ts";
19
19
 
20
20
  export type AgentSource = "builtin" | "user" | "project";
21
21
 
@@ -24,6 +24,8 @@ export interface AgentConfig {
24
24
  description: string;
25
25
  tools?: string[];
26
26
  model?: string;
27
+ /** Per-agent default thinking strength (frontmatter `thinking`); config override wins. */
28
+ thinking?: ThinkingLevel;
27
29
  systemPrompt: string;
28
30
  source: AgentSource;
29
31
  filePath: string;
@@ -38,6 +40,11 @@ const here = dirname(fileURLToPath(import.meta.url));
38
40
  /** <package>/agents — the agents shipped with this extension. */
39
41
  export const BUILTIN_AGENTS_DIR = join(here, "..", "agents");
40
42
 
43
+ /** Agents shipped with the package (used by the setup wizard for per-agent defaults). */
44
+ export function loadBuiltinAgents(): AgentConfig[] {
45
+ return loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin");
46
+ }
47
+
41
48
  function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
42
49
  const agents: AgentConfig[] = [];
43
50
  if (!existsSync(dir)) return agents;
@@ -62,19 +69,29 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
62
69
  }
63
70
 
64
71
  const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
72
+ // YAML values are not guaranteed strings; anything non-string is invalid for these fields.
73
+ const str = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
74
+ const name = str(frontmatter.name);
75
+ const description = str(frontmatter.description);
65
76
  // name + description are required; skip malformed files silently.
66
- if (!frontmatter.name || !frontmatter.description) continue;
77
+ if (!name || !description) continue;
67
78
 
68
- const tools = frontmatter.tools
79
+ const rawTools = str(frontmatter.tools);
80
+ const tools = rawTools
69
81
  ?.split(",")
70
82
  .map((t) => t.trim())
71
83
  .filter(Boolean);
84
+ const rawThinking = str(frontmatter.thinking)?.trim();
85
+ const thinking = (THINKING_LEVEL_VALUES as readonly string[]).includes(rawThinking ?? "")
86
+ ? (rawThinking as ThinkingLevel)
87
+ : undefined;
72
88
 
73
89
  agents.push({
74
- name: frontmatter.name,
75
- description: frontmatter.description,
90
+ name,
91
+ description,
76
92
  tools: tools && tools.length > 0 ? tools : undefined,
77
- model: frontmatter.model,
93
+ model: str(frontmatter.model),
94
+ ...(thinking ? { thinking } : {}),
78
95
  systemPrompt: body,
79
96
  source,
80
97
  filePath,
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Smart batching for successful background completions.
3
+ *
4
+ * A short debounce coalesces sibling runs while a max-wait timer, measured from
5
+ * the first item in the open group, bounds delivery latency. Runs that finish
6
+ * shortly after an emitted group use a smaller straggler window. Failures are
7
+ * intentionally handled by the caller: flush held successes, then emit the
8
+ * failure directly so it is never delayed.
9
+ */
10
+
11
+ import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
12
+
13
+ export interface CompletionBatchTimings {
14
+ debounceMs: number;
15
+ maxWaitMs: number;
16
+ stragglerDebounceMs: number;
17
+ stragglerMaxWaitMs: number;
18
+ stragglerWindowMs: number;
19
+ }
20
+
21
+ export const DEFAULT_COMPLETION_BATCH_TIMINGS: CompletionBatchTimings = {
22
+ debounceMs: 150,
23
+ maxWaitMs: 1_000,
24
+ stragglerDebounceMs: 75,
25
+ stragglerMaxWaitMs: 400,
26
+ stragglerWindowMs: 2_000,
27
+ };
28
+
29
+ type TimerHandle = unknown;
30
+
31
+ export interface TimerApi {
32
+ setTimeout(handler: () => void, delayMs: number): TimerHandle;
33
+ clearTimeout(handle: TimerHandle): void;
34
+ }
35
+
36
+ const defaultTimers: TimerApi = {
37
+ setTimeout: (handler, delayMs) => setTimeout(handler, delayMs),
38
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
39
+ };
40
+
41
+ function unrefHandle(handle: TimerHandle): void {
42
+ if (
43
+ handle &&
44
+ typeof handle === "object" &&
45
+ "unref" in handle &&
46
+ typeof (handle as { unref: unknown }).unref === "function"
47
+ ) {
48
+ (handle as { unref: () => void }).unref();
49
+ }
50
+ }
51
+
52
+ export interface CompletionBatcherOptions<T> {
53
+ emit: (items: T[]) => void;
54
+ timings?: Partial<CompletionBatchTimings>;
55
+ timers?: TimerApi;
56
+ now?: () => number;
57
+ }
58
+
59
+ export interface CompletionBatcher<T> {
60
+ /** Add an item to the current debounced group. */
61
+ push(item: T): void;
62
+ /** Emit any held items immediately as one group. */
63
+ flush(): void;
64
+ /** Clear timers and return held items without emitting them. */
65
+ dispose(): T[];
66
+ }
67
+
68
+ export function createCompletionBatcher<T>(options: CompletionBatcherOptions<T>): CompletionBatcher<T> {
69
+ const timers = options.timers ?? defaultTimers;
70
+ const now = options.now ?? Date.now;
71
+ const timings = { ...DEFAULT_COMPLETION_BATCH_TIMINGS, ...options.timings };
72
+ let pending: T[] = [];
73
+ let debounceTimer: TimerHandle | null = null;
74
+ let maxWaitTimer: TimerHandle | null = null;
75
+ let straggler = false;
76
+ let lastEmitAt: number | null = null;
77
+
78
+ const clearTimers = (): void => {
79
+ if (debounceTimer !== null) {
80
+ timers.clearTimeout(debounceTimer);
81
+ debounceTimer = null;
82
+ }
83
+ if (maxWaitTimer !== null) {
84
+ timers.clearTimeout(maxWaitTimer);
85
+ maxWaitTimer = null;
86
+ }
87
+ };
88
+
89
+ const emitGroup = (): void => {
90
+ clearTimers();
91
+ if (pending.length === 0) return;
92
+ const items = pending;
93
+ pending = [];
94
+ lastEmitAt = now();
95
+ options.emit(items);
96
+ };
97
+
98
+ return {
99
+ push(item: T): void {
100
+ if (pending.length === 0) {
101
+ straggler = lastEmitAt !== null && now() - lastEmitAt < timings.stragglerWindowMs;
102
+ }
103
+ pending.push(item);
104
+
105
+ if (debounceTimer !== null) timers.clearTimeout(debounceTimer);
106
+ const debounceDelay = straggler ? timings.stragglerDebounceMs : timings.debounceMs;
107
+ debounceTimer = timers.setTimeout(emitGroup, debounceDelay);
108
+ unrefHandle(debounceTimer);
109
+
110
+ if (maxWaitTimer === null) {
111
+ const maxWaitDelay = straggler ? timings.stragglerMaxWaitMs : timings.maxWaitMs;
112
+ maxWaitTimer = timers.setTimeout(emitGroup, maxWaitDelay);
113
+ unrefHandle(maxWaitTimer);
114
+ }
115
+ },
116
+ flush: emitGroup,
117
+ dispose(): T[] {
118
+ clearTimers();
119
+ const abandoned = pending;
120
+ pending = [];
121
+ return abandoned;
122
+ },
123
+ };
124
+ }
125
+
126
+ export interface CompletionMessageItem {
127
+ agent: string;
128
+ block: string;
129
+ triggerTurn: boolean;
130
+ }
131
+
132
+ /** Keep the established single-result shape; add a summary only for real groups. */
133
+ export function formatCompletionMessage(items: readonly CompletionMessageItem[]): string {
134
+ if (items.length === 0) return "";
135
+ if (items.length === 1) return items[0].block;
136
+ const agents = items.map((item) => item.agent).join(", ");
137
+ return `### Subagents completed (${items.length}): ${agents}\n\n${items.map((item) => item.block).join("\n\n")}`;
138
+ }
139
+
140
+ /** A grouped completion wakes the main agent when any member requires a turn. */
141
+ export function completionGroupTriggersTurn(items: readonly CompletionMessageItem[]): boolean {
142
+ return items.some((item) => item.triggerTurn);
143
+ }
144
+
145
+ /** Passing reviewer notifications may opt out of waking; every other result wakes. */
146
+ export function completionTriggersTurn(result: SingleResult, notifyOnReviewPass: boolean): boolean {
147
+ if (isFailedResult(result)) return true;
148
+ return !(
149
+ notifyOnReviewPass &&
150
+ result.agent === "reviewer" &&
151
+ reviewVerdict(getResultOutput(result)) === "pass"
152
+ );
153
+ }
package/src/config.ts CHANGED
@@ -36,7 +36,12 @@ export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
36
36
  /** Thinking levels accepted by pi's `--thinking` option. */
37
37
  export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
38
38
  export type ThinkingLevel = (typeof THINKING_LEVEL_VALUES)[number];
39
- export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "max";
39
+ export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "high";
40
+
41
+ /** How many lines of a sub-agent result the completion message may carry. Default: 80. */
42
+ export const DEFAULT_MAX_RESULT_LINES = 80;
43
+ /** Upper bound accepted for maxResultLines (defensive clamp). */
44
+ export const MAX_RESULT_LINES_LIMIT = 2000;
40
45
 
41
46
  export const CONFIG_FILE_NAME = "pi-subagents.json";
42
47
 
@@ -61,8 +66,21 @@ export interface SubagentsConfig {
61
66
  enabledAgents: string[];
62
67
  /** Per-agent model override, keyed by agent name, as "provider/model-id". */
63
68
  agentModels: Record<string, string>;
64
- /** Thinking level passed to every sub-agent. Default: "max". */
69
+ /** Per-agent thinking-level override, keyed by agent name. */
70
+ agentThinkingLevels: Record<string, ThinkingLevel>;
71
+ /** Thinking level for sub-agents without a per-agent override or frontmatter default. Default: "high". */
65
72
  thinkingLevel: ThinkingLevel;
73
+ /**
74
+ * When a review passes (REVIEW_PASS verdict), deliver it without waking the
75
+ * main agent. Disabled by default so passing reviews still resume orchestration.
76
+ */
77
+ notifyOnReviewPass: boolean;
78
+ /**
79
+ * Max lines of a sub-agent result carried in the completion message. Longer
80
+ * results are truncated; the full text is written to a temp file whose path
81
+ * is included in the message. Default: 80.
82
+ */
83
+ maxResultLines: number;
66
84
  /** Whether to inject the delegation directive into the parent system prompt. Default: true. */
67
85
  proactiveInjection: boolean;
68
86
  /** Which agent directories to discover from. Default: "user". */
@@ -78,7 +96,10 @@ export interface SubagentsConfig {
78
96
  export const DEFAULT_CONFIG: SubagentsConfig = {
79
97
  enabledAgents: [...DEFAULT_ENABLED_AGENTS],
80
98
  agentModels: {},
99
+ agentThinkingLevels: {},
81
100
  thinkingLevel: DEFAULT_THINKING_LEVEL,
101
+ notifyOnReviewPass: false,
102
+ maxResultLines: DEFAULT_MAX_RESULT_LINES,
82
103
  proactiveInjection: true,
83
104
  agentScope: "user",
84
105
  maxConcurrency: DEFAULT_MAX_CONCURRENCY,
@@ -121,7 +142,10 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
121
142
  const config: SubagentsConfig = {
122
143
  enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
123
144
  agentModels: {},
145
+ agentThinkingLevels: {},
124
146
  thinkingLevel: DEFAULT_CONFIG.thinkingLevel,
147
+ notifyOnReviewPass: DEFAULT_CONFIG.notifyOnReviewPass,
148
+ maxResultLines: DEFAULT_CONFIG.maxResultLines,
125
149
  proactiveInjection: DEFAULT_CONFIG.proactiveInjection,
126
150
  agentScope: DEFAULT_CONFIG.agentScope,
127
151
  maxConcurrency: DEFAULT_CONFIG.maxConcurrency,
@@ -147,10 +171,29 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
147
171
  }
148
172
  }
149
173
 
174
+ if (isRecord(raw.agentThinkingLevels)) {
175
+ for (const [key, value] of Object.entries(raw.agentThinkingLevels)) {
176
+ if (REMOVED_AGENT_NAMES.includes(key.trim())) continue;
177
+ if (
178
+ typeof value === "string" &&
179
+ (THINKING_LEVEL_VALUES as readonly string[]).includes(value)
180
+ ) {
181
+ config.agentThinkingLevels[key.trim()] = value as ThinkingLevel;
182
+ }
183
+ }
184
+ }
185
+
150
186
  if (typeof raw.thinkingLevel === "string" && (THINKING_LEVEL_VALUES as readonly string[]).includes(raw.thinkingLevel)) {
151
187
  config.thinkingLevel = raw.thinkingLevel as ThinkingLevel;
152
188
  }
153
189
 
190
+ if (typeof raw.notifyOnReviewPass === "boolean") {
191
+ config.notifyOnReviewPass = raw.notifyOnReviewPass;
192
+ }
193
+
194
+ const maxResultLines = clampCount(raw.maxResultLines, MAX_RESULT_LINES_LIMIT);
195
+ if (maxResultLines !== undefined) config.maxResultLines = maxResultLines;
196
+
154
197
  if (typeof raw.proactiveInjection === "boolean") {
155
198
  config.proactiveInjection = raw.proactiveInjection;
156
199
  }
@@ -174,7 +217,12 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
174
217
  }
175
218
 
176
219
  function defaultConfig(): SubagentsConfig {
177
- return { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_CONFIG.enabledAgents], agentModels: {} };
220
+ return {
221
+ ...DEFAULT_CONFIG,
222
+ enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
223
+ agentModels: {},
224
+ agentThinkingLevels: {},
225
+ };
178
226
  }
179
227
 
180
228
  /**
package/src/index.ts CHANGED
@@ -18,6 +18,13 @@ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
18
18
  import { Type } from "typebox";
19
19
  import { discoverAgents, type AgentConfig } from "./agents.ts";
20
20
  import { BackgroundTaskQueue } from "./background.ts";
21
+ import {
22
+ completionGroupTriggersTurn,
23
+ completionTriggersTurn,
24
+ createCompletionBatcher,
25
+ formatCompletionMessage,
26
+ type CompletionMessageItem,
27
+ } from "./completion.ts";
21
28
  import { getConfigPath, loadConfig, loadConfigSync, saveConfig } from "./config.ts";
22
29
  import { repairUnavailableModelOverrides } from "./models.ts";
23
30
  import { buildDelegationDirective } from "./prompt.ts";
@@ -28,6 +35,8 @@ import {
28
35
  getResultOutput,
29
36
  isFailedResult,
30
37
  runSingleAgent,
38
+ truncateResultOutput,
39
+ writeResultArtifact,
31
40
  type SingleResult,
32
41
  type SubagentDetails,
33
42
  type SubagentLiveEvent,
@@ -59,7 +68,7 @@ function emptyUsage(): UsageStats {
59
68
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
60
69
  }
61
70
 
62
- function queuedResult(agent: AgentConfig, task: string): SingleResult {
71
+ function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
63
72
  return {
64
73
  agent: agent.name,
65
74
  agentSource: agent.source,
@@ -69,6 +78,7 @@ function queuedResult(agent: AgentConfig, task: string): SingleResult {
69
78
  stderr: "",
70
79
  usage: emptyUsage(),
71
80
  model: agent.model,
81
+ ...(thinking ? { thinking } : {}),
72
82
  };
73
83
  }
74
84
 
@@ -114,6 +124,19 @@ function formatUsage(usage: UsageStats): string {
114
124
  return parts.join(" ");
115
125
  }
116
126
 
127
+ function formatCompletionBlock(result: SingleResult, maxResultLines: number): string {
128
+ const status = isFailedResult(result) ? "failed" : "completed";
129
+ const usage = formatUsage(result.usage);
130
+ const output = getResultOutput(result);
131
+ const { text, truncated } = truncateResultOutput(output, maxResultLines);
132
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}`, "", `Task: ${formatTaskSummary(result.task)}`, "", text];
133
+ if (truncated) {
134
+ // The full text lives on disk so the main agent can read it on demand.
135
+ lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent)})`);
136
+ }
137
+ return lines.join("\n");
138
+ }
139
+
117
140
  export default function (pi: ExtensionAPI): void {
118
141
  const configPath = getConfigPath(getAgentDir());
119
142
  // Init-time decisions need the config synchronously; the full (migrating)
@@ -121,6 +144,23 @@ export default function (pi: ExtensionAPI): void {
121
144
  const initialConfig = loadConfigSync(configPath);
122
145
  const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
123
146
  let sessionActive = true;
147
+ const sendCompletionGroup = (items: CompletionMessageItem[]): void => {
148
+ if (!sessionActive || items.length === 0) return;
149
+ const message = {
150
+ customType: "subagent-result",
151
+ content: formatCompletionMessage(items),
152
+ display: true,
153
+ };
154
+ if (completionGroupTriggersTurn(items)) {
155
+ pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
156
+ } else {
157
+ // No-wake delivery: nextTurn rides along with the next user turn and can
158
+ // never start a continuation by itself. followUp would auto-continue
159
+ // whenever pi is already streaming, defeating the opt-out.
160
+ pi.sendMessage(message, { deliverAs: "nextTurn" });
161
+ }
162
+ };
163
+ const completionBatcher = createCompletionBatcher<CompletionMessageItem>({ emit: sendCompletionGroup });
124
164
 
125
165
  // Recursion guard: sub-agents at the configured depth are leaf processes and
126
166
  // cannot delegate again. maxSubagentDepth 0 disables the tool entirely.
@@ -148,6 +188,7 @@ export default function (pi: ExtensionAPI): void {
148
188
 
149
189
  pi.on("session_shutdown", () => {
150
190
  sessionActive = false;
191
+ completionBatcher.dispose();
151
192
  backgroundQueue.cancelAll();
152
193
  });
153
194
 
@@ -162,11 +203,11 @@ export default function (pi: ExtensionAPI): void {
162
203
  "Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output)."
163
204
  ].join(" "),
164
205
  promptSnippet:
165
- "Start background subagents: explore (read-only search), worker (implement), reviewer (adversarial review); completion automatically resumes the main agent.",
206
+ "Start background subagents: explore (read-only search), worker (implement), reviewer (adversarial review); completion automatically resumes the main agent. Simple tasks: use direct tools, not subagents.",
166
207
  promptGuidelines: [
167
- "Use subagent to delegate discrete, self-contained tasks so the main context stays clean; do orchestration and verification yourself.",
168
- "Use subagent with agent 'explore' for broad or open-ended code search before large changes.",
169
- "Use subagent with agent 'worker' to implement a well-scoped task; it plans internally.",
208
+ "Delegate only when an isolated context genuinely pays: broad exploration, a self-contained implementation, or a review gate. Handle simple lookups and one-line edits inline with direct tools — never spawn a sub-agent for them.",
209
+ "Use subagent with agent 'explore' for broad or open-ended code search before large changes; a targeted 'where is X' is a direct grep/read.",
210
+ "Use subagent with agent 'worker' for a self-contained implementation task worth a separate context; it plans internally.",
170
211
  "Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
171
212
  "subagent launches work in the background and ends the current turn; when a result arrives, the main agent is automatically resumed with it.",
172
213
  "Run independent tasks in parallel by passing a tasks array to subagent; let the automatically resumed main agent start dependent work after results arrive.",
@@ -296,8 +337,10 @@ export default function (pi: ExtensionAPI): void {
296
337
  const agent = agents.find((candidate) => candidate.name === agentName);
297
338
  if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
298
339
 
299
- const pending = queuedResult(agent, task);
300
- const runId = monitor.addRun(agent.name, task, agent.model);
340
+ // Effective strength: config override > agent frontmatter default > global default.
341
+ const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
342
+ const pending = queuedResult(agent, task, thinkingLevel);
343
+ const runId = monitor.addRun(agent.name, task, agent.model, thinkingLevel);
301
344
  const onLive = makeLiveHandler(runId);
302
345
 
303
346
  backgroundQueue.enqueue(
@@ -310,7 +353,7 @@ export default function (pi: ExtensionAPI): void {
310
353
  agentName,
311
354
  task,
312
355
  cwd,
313
- thinkingLevel: config.thinkingLevel,
356
+ thinkingLevel,
314
357
  signal: backgroundSignal,
315
358
  onLive,
316
359
  makeDetails: makeDetails("single", true),
@@ -328,18 +371,20 @@ export default function (pi: ExtensionAPI): void {
328
371
  }
329
372
 
330
373
  if (!sessionActive) return;
331
- const status = isFailedResult(result) ? "failed" : "completed";
332
- const usage = formatUsage(result.usage);
333
- pi.sendMessage(
334
- {
335
- customType: "subagent-result",
336
- content: `### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}\n\nTask: ${formatTaskSummary(result.task)}\n\n${getResultOutput(result)}`,
337
- display: true,
338
- },
339
- // The result is both durable context and a wake-up signal. If the
340
- // main agent is busy, followUp queues it until the current turn ends.
341
- { deliverAs: "followUp", triggerTurn: true },
342
- );
374
+ const failed = isFailedResult(result);
375
+ const completion: CompletionMessageItem = {
376
+ agent: result.agent,
377
+ block: formatCompletionBlock(result, config.maxResultLines),
378
+ triggerTurn: completionTriggersTurn(result, config.notifyOnReviewPass),
379
+ };
380
+ if (failed) {
381
+ // Failures never wait and never hide behind a success turn: deliver
382
+ // first so the wake-up leads with the failure; held successes follow.
383
+ sendCompletionGroup([completion]);
384
+ completionBatcher.flush();
385
+ } else {
386
+ completionBatcher.push(completion);
387
+ }
343
388
  },
344
389
  () => finishRun(runId, "failed"),
345
390
  );
@@ -426,7 +471,7 @@ export default function (pi: ExtensionAPI): void {
426
471
  const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
427
472
  const usage = formatUsage(r.usage);
428
473
  const model = r.model ?? "?";
429
- const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`;
474
+ const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`;
430
475
  return new Text(line, 0, 0);
431
476
  }
432
477
 
@@ -439,7 +484,7 @@ export default function (pi: ExtensionAPI): void {
439
484
  const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
440
485
  const usage = formatUsage(r.usage);
441
486
  const model = r.model ?? "?";
442
- lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
487
+ lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
443
488
  }
444
489
  return new Text(lines.join("\n"), 0, 0);
445
490
  },
package/src/monitor.ts CHANGED
@@ -26,6 +26,8 @@ export interface RunView {
26
26
  agent: string;
27
27
  task: string;
28
28
  model?: string;
29
+ /** Effective thinking strength this run was launched with (frontmatter/config/global). */
30
+ thinking?: string;
29
31
  status: RunStatus;
30
32
  usage: UsageStats;
31
33
  /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
@@ -166,13 +168,14 @@ export class MonitorStore {
166
168
  this.notify();
167
169
  }
168
170
 
169
- addRun(agent: string, task: string, model?: string): number {
171
+ addRun(agent: string, task: string, model?: string, thinking?: string): number {
170
172
  const id = this.nextId++;
171
173
  this.runs.push({
172
174
  id,
173
175
  agent,
174
176
  task,
175
177
  model,
178
+ thinking,
176
179
  status: "queued",
177
180
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
178
181
  });
@@ -231,6 +234,7 @@ export class MonitorStore {
231
234
  const usage = formatUsageCompact(run.usage);
232
235
  const parts = [run.agent];
233
236
  if (run.model) parts.push(run.model);
237
+ if (run.thinking) parts.push(`thinking ${run.thinking}`);
234
238
  if (usage) parts.push(usage);
235
239
  const elapsed = formatElapsed(run);
236
240
  if (elapsed) parts.push(elapsed);
package/src/prompt.ts CHANGED
@@ -16,8 +16,8 @@ import { formatCatalogEntry } from "./agents.ts";
16
16
 
17
17
  /** Compact role routing hints, emitted only for roles that are enabled. */
18
18
  const ROLE_ROUTING: Record<string, string> = {
19
- explore: "explore — broad/open-ended code search, \"where is X\", multi-file lookups (read-only, cheap).",
20
- worker: "worker — implement/fix/refactor/test a well-scoped task (full tools; plans internally).",
19
+ explore: "explore — broad/open-ended code search, multi-file lookups (read-only, cheap); NOT for one-line lookups.",
20
+ worker: "worker — implement/fix/refactor/test a self-contained task worth a separate context (full tools; plans internally).",
21
21
  reviewer: "reviewer — adversarial pre-commit review of a diff (read-only; independent context).",
22
22
  };
23
23
 
@@ -45,8 +45,9 @@ Available agents:
45
45
  ${catalog}
46
46
 
47
47
  ${routing ? `Routing:\n${routing}\n` : ""}Dispatch discipline:
48
- - Default to delegating every discrete task to a sub-agent; use the automatically delivered findings for orchestration and verification.
49
- - Only handle inline: pure Q&A, a single trivial edit/lookup, or when the user explicitly says to do it directly. When in doubt, delegate.
48
+ - Handle SIMPLE work INLINE with direct tools: a single lookup, one-line edit, or a quick question is a grep/read/edit in the main context never a sub-agent. Sub-agents cost startup time, tokens, and a context switch.
49
+ - Delegate only when isolation genuinely pays: broad exploration of an unfamiliar area, a self-contained implementation/fix with its own validation, or a fresh-context review gate.
50
+ - When in doubt, start with a direct tool call in the main context; escalate to a sub-agent only if the work turns out broad.
50
51
  - For an already-known or trivial target, use a direct search/read tool (e.g. grep/find/read) — do not over-delegate a one-line lookup.
51
52
  ${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `tasks` array, and track them with your todo list. Let the automatically resumed main agent launch dependent work only after its prerequisite result arrives (e.g. explore, then worker, then reviewer).\n" : ""}- Brief each sub-agent as self-contained: goal, exact paths, constraints, expected output. It has NO memory of this conversation.
52
53
  - Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool.
package/src/setup.ts CHANGED
@@ -27,9 +27,19 @@ import {
27
27
  } from "./config.ts";
28
28
  import { availableModelRefs, repairUnavailableModelOverrides } from "./models.ts";
29
29
  import { promptSelectMany, promptSelectOne } from "./ui.ts";
30
+ import { loadBuiltinAgents } from "./agents.ts";
30
31
 
31
32
  const INHERIT = "__inherit__";
32
33
 
34
+ /** Effective per-agent default strength from builtin frontmatter (config overrides win at spawn). */
35
+ function builtinThinkingDefaults(): Map<string, ThinkingLevel> {
36
+ const map = new Map<string, ThinkingLevel>();
37
+ for (const agent of loadBuiltinAgents()) {
38
+ if (agent.thinking) map.set(agent.name, agent.thinking);
39
+ }
40
+ return map;
41
+ }
42
+
33
43
  /** Short, selection-friendly descriptions for the built-in agents. */
34
44
  const MODULE_HINTS: Record<string, string> = {
35
45
  explore: "read-only codebase recon (fast model)",
@@ -65,20 +75,24 @@ async function pickEnabledAgents(
65
75
  );
66
76
  }
67
77
 
68
- async function pickAgentModels(
78
+ async function pickAgentModelsAndStrength(
69
79
  ctx: ExtensionCommandContext,
70
80
  enabledAgents: readonly string[],
71
- current: Record<string, string>,
72
- ): Promise<Record<string, string> | undefined> {
81
+ currentModels: Record<string, string>,
82
+ currentStrengths: Record<string, ThinkingLevel>,
83
+ defaultLevel: ThinkingLevel,
84
+ defaults: ReadonlyMap<string, ThinkingLevel>,
85
+ ): Promise<{ models: Record<string, string>; strengths: Record<string, ThinkingLevel> } | undefined> {
73
86
  const refs = availableModelRefs(ctx);
74
87
  if (refs.length === 0) {
75
88
  ctx.ui.notify("No Pi models are currently available; model overrides left unchanged.", "warning");
76
- return { ...current };
89
+ return { models: { ...currentModels }, strengths: { ...currentStrengths } };
77
90
  }
78
91
 
79
- const result: Record<string, string> = {};
92
+ const models: Record<string, string> = {};
93
+ const strengths: Record<string, ThinkingLevel> = {};
80
94
  for (const name of enabledAgents) {
81
- const currentRef = current[name];
95
+ const currentRef = currentModels[name];
82
96
  const items = [
83
97
  {
84
98
  value: INHERIT,
@@ -95,9 +109,15 @@ async function pickAgentModels(
95
109
  items,
96
110
  );
97
111
  if (choice === undefined) return undefined; // Esc aborts the whole wizard
98
- if (choice !== INHERIT) result[name] = choice;
112
+ if (choice !== INHERIT) models[name] = choice;
113
+
114
+ // Convenience: the model pick is immediately followed by the strength pick,
115
+ // so per-agent model + strength are configured in one pass.
116
+ const strength = await pickAgentStrength(ctx, name, currentStrengths[name], defaultLevel, defaults);
117
+ if (strength === undefined) return undefined; // Esc aborts the whole wizard
118
+ if (strength !== INHERIT) strengths[name] = strength;
99
119
  }
100
- return result;
120
+ return { models, strengths };
101
121
  }
102
122
 
103
123
  const THINKING_LEVEL_HINTS: Record<ThinkingLevel, string> = {
@@ -107,9 +127,52 @@ const THINKING_LEVEL_HINTS: Record<ThinkingLevel, string> = {
107
127
  medium: "balanced reasoning",
108
128
  high: "deep reasoning",
109
129
  xhigh: "extra-deep reasoning",
110
- max: "strongest reasoning (default)",
130
+ max: "strongest reasoning",
111
131
  };
112
132
 
133
+ /** Single strength pick for one agent; the inherit option keeps the effective default. */
134
+ async function pickAgentStrength(
135
+ ctx: ExtensionCommandContext,
136
+ agentName: string,
137
+ current: ThinkingLevel | undefined,
138
+ defaultLevel: ThinkingLevel,
139
+ defaults: ReadonlyMap<string, ThinkingLevel>,
140
+ ): Promise<ThinkingLevel | typeof INHERIT | undefined> {
141
+ const options = THINKING_LEVEL_VALUES.map((level) => ({
142
+ value: level,
143
+ label: current === level ? `${level} — ${THINKING_LEVEL_HINTS[level]} (current)` : `${level} — ${THINKING_LEVEL_HINTS[level]}`,
144
+ }));
145
+ const agentDefault = defaults.get(agentName);
146
+ const inheritLabel = agentDefault
147
+ ? `(inherit agent default — ${agentDefault})`
148
+ : `(inherit global default — ${defaultLevel})`;
149
+ const choice = await promptSelectOne(
150
+ ctx,
151
+ `Thinking strength for "${agentName}"?`,
152
+ "Type to filter • ↑/↓ • Enter selects • Esc cancels setup",
153
+ [{ value: INHERIT, label: inheritLabel }, ...options],
154
+ );
155
+ if (choice === undefined) return undefined;
156
+ return choice === INHERIT ? INHERIT : (choice as ThinkingLevel);
157
+ }
158
+
159
+ /** Strength picks for every enabled agent (inherit keeps the effective default). */
160
+ async function pickAgentStrengths(
161
+ ctx: ExtensionCommandContext,
162
+ enabledAgents: readonly string[],
163
+ currentStrengths: Record<string, ThinkingLevel>,
164
+ defaultLevel: ThinkingLevel,
165
+ defaults: ReadonlyMap<string, ThinkingLevel>,
166
+ ): Promise<Record<string, ThinkingLevel> | undefined> {
167
+ const strengths: Record<string, ThinkingLevel> = {};
168
+ for (const name of enabledAgents) {
169
+ const strength = await pickAgentStrength(ctx, name, currentStrengths[name], defaultLevel, defaults);
170
+ if (strength === undefined) return undefined; // Esc aborts
171
+ if (strength !== INHERIT) strengths[name] = strength;
172
+ }
173
+ return strengths;
174
+ }
175
+
113
176
  async function pickThinkingLevel(
114
177
  ctx: ExtensionCommandContext,
115
178
  current: ThinkingLevel,
@@ -117,7 +180,7 @@ async function pickThinkingLevel(
117
180
  const options = THINKING_LEVEL_VALUES.map((level) =>
118
181
  level === current ? `${level} — ${THINKING_LEVEL_HINTS[level]} (current)` : `${level} — ${THINKING_LEVEL_HINTS[level]}`,
119
182
  );
120
- const choice = await ctx.ui.select("Sub-agent thinking strength?", options);
183
+ const choice = await ctx.ui.select("Default thinking strength for sub-agents?", options);
121
184
  if (choice === undefined) return undefined;
122
185
  return THINKING_LEVEL_VALUES.find((level) => choice.startsWith(`${level} —`));
123
186
  }
@@ -202,12 +265,14 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
202
265
  const enabled = await pickEnabledAgents(ctx, base.enabledAgents);
203
266
  if (enabled === undefined) return notifyCancelled(ctx);
204
267
 
205
- const models = await pickAgentModels(ctx, enabled, base.agentModels);
206
- if (models === undefined) return notifyCancelled(ctx);
207
-
268
+ // Global default first, so per-agent strength picks can show "inherit" against it.
208
269
  const thinkingLevel = await pickThinkingLevel(ctx, base.thinkingLevel);
209
270
  if (thinkingLevel === undefined) return notifyCancelled(ctx);
210
271
 
272
+ const defaults = builtinThinkingDefaults();
273
+ const picked = await pickAgentModelsAndStrength(ctx, enabled, base.agentModels, base.agentThinkingLevels, thinkingLevel, defaults);
274
+ if (picked === undefined) return notifyCancelled(ctx);
275
+
211
276
  const injection = await pickInjection(ctx, base.proactiveInjection);
212
277
  if (injection === undefined) return notifyCancelled(ctx);
213
278
 
@@ -234,8 +299,11 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
234
299
 
235
300
  const next: SubagentsConfig = {
236
301
  enabledAgents: enabled,
237
- agentModels: repairStaleModels(ctx, models),
302
+ agentModels: repairStaleModels(ctx, picked.models),
303
+ agentThinkingLevels: picked.strengths,
238
304
  thinkingLevel,
305
+ notifyOnReviewPass: base.notifyOnReviewPass,
306
+ maxResultLines: base.maxResultLines,
239
307
  proactiveInjection: injection,
240
308
  agentScope: scope,
241
309
  maxConcurrency,
@@ -268,13 +336,26 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
268
336
  if (enabled === undefined) return notifyCancelled(ctx);
269
337
  next.enabledAgents = enabled;
270
338
  } else if (choice.startsWith("Change agent models")) {
271
- const models = await pickAgentModels(ctx, config.enabledAgents, config.agentModels);
272
- if (models === undefined) return notifyCancelled(ctx);
273
- next.agentModels = repairStaleModels(ctx, models);
339
+ const defaults = builtinThinkingDefaults();
340
+ const picked = await pickAgentModelsAndStrength(
341
+ ctx,
342
+ config.enabledAgents,
343
+ config.agentModels,
344
+ config.agentThinkingLevels,
345
+ config.thinkingLevel,
346
+ defaults,
347
+ );
348
+ if (picked === undefined) return notifyCancelled(ctx);
349
+ next.agentModels = repairStaleModels(ctx, picked.models);
350
+ next.agentThinkingLevels = picked.strengths;
274
351
  } else if (choice.startsWith("Change thinking")) {
352
+ // Global first so per-agent "inherit" labels reflect the value that will be stored.
275
353
  const thinkingLevel = await pickThinkingLevel(ctx, config.thinkingLevel);
276
354
  if (thinkingLevel === undefined) return notifyCancelled(ctx);
277
355
  next.thinkingLevel = thinkingLevel;
356
+ const strengths = await pickAgentStrengths(ctx, config.enabledAgents, config.agentThinkingLevels, thinkingLevel, builtinThinkingDefaults());
357
+ if (strengths === undefined) return notifyCancelled(ctx);
358
+ next.agentThinkingLevels = strengths;
278
359
  } else if (choice.startsWith("Toggle")) {
279
360
  const injection = await pickInjection(ctx, config.proactiveInjection);
280
361
  if (injection === undefined) return notifyCancelled(ctx);
package/src/spawn.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  */
12
12
 
13
13
  import { spawn, type ChildProcess } from "node:child_process";
14
+ import { existsSync, mkdirSync, unlinkSync, rmdirSync, writeFileSync } from "node:fs";
14
15
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
15
- import { existsSync, unlinkSync, rmdirSync } from "node:fs";
16
16
  import { tmpdir } from "node:os";
17
17
  import { basename, join } from "node:path";
18
18
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
@@ -51,6 +51,8 @@ export interface SingleResult {
51
51
  stderr: string;
52
52
  usage: UsageStats;
53
53
  model?: string;
54
+ /** Effective thinking strength this run was launched with. */
55
+ thinking?: string;
54
56
  stopReason?: string;
55
57
  errorMessage?: string;
56
58
  }
@@ -88,6 +90,54 @@ export function getFinalOutput(messages: Message[]): string {
88
90
  return "";
89
91
  }
90
92
 
93
+ /**
94
+ * Parse the machine-readable verdict a reviewer emits (see agents/reviewer.md).
95
+ * Only the LAST standalone `VERDICT: REVIEW_PASS/FAIL` line counts, so a report
96
+ * that merely discusses the tokens cannot be misclassified. Returns undefined
97
+ * when no verdict marker is present, so non-review agents are never mistaken
98
+ * for reviews.
99
+ */
100
+ export function reviewVerdict(output: string): "pass" | "fail" | undefined {
101
+ const lines = output.split("\n");
102
+ for (let index = lines.length - 1; index >= 0; index--) {
103
+ const match = /^\s*VERDICT:\s*REVIEW_(PASS|FAIL)\s*$/i.exec(lines[index]);
104
+ if (match) return match[1].toUpperCase() === "PASS" ? "pass" : "fail";
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ /** Hard cap for a single line inside a truncated result (minified blobs must not blow up). */
110
+ export const RESULT_LINE_MAX = 200;
111
+
112
+ export interface TruncatedOutput {
113
+ /** The result text that fits in the completion message. */
114
+ text: string;
115
+ /** True when lines were dropped or shortened, so the full text is written to disk. */
116
+ truncated: boolean;
117
+ }
118
+
119
+ /** Cap result text for the main conversation: keep the first `maxLines` lines, at most RESULT_LINE_MAX chars each. */
120
+ export function truncateResultOutput(output: string, maxLines: number): TruncatedOutput {
121
+ const lines = output.split("\n");
122
+ if (lines.length <= maxLines && lines.every((line) => line.length <= RESULT_LINE_MAX)) {
123
+ return { text: output, truncated: false };
124
+ }
125
+ const kept = lines.slice(0, maxLines).map((line) =>
126
+ line.length > RESULT_LINE_MAX ? `${line.slice(0, RESULT_LINE_MAX)}…` : line,
127
+ );
128
+ return { text: kept.join("\n"), truncated: true };
129
+ }
130
+
131
+ /** Persist the full result where the main agent can read it on demand. Returns the file path. */
132
+ export function writeResultArtifact(output: string, agentName: string): string {
133
+ const dir = join(tmpdir(), "pi-subagents-results");
134
+ mkdirSync(dir, { recursive: true });
135
+ const safeName = agentName.replace(/[^\w.-]+/g, "_");
136
+ const filePath = join(dir, `${Date.now()}-${safeName}.md`);
137
+ writeFileSync(filePath, output, "utf8");
138
+ return filePath;
139
+ }
140
+
91
141
  export function isFailedResult(result: SingleResult): boolean {
92
142
  return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
93
143
  }
@@ -238,6 +288,7 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
238
288
  stderr: "",
239
289
  usage: emptyUsage(),
240
290
  model: agent.model,
291
+ thinking: thinkingLevel,
241
292
  };
242
293
 
243
294
  const emitUpdate = (): void => {