@ferris1225/pi-subagents 0.12.0 → 0.14.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
@@ -68,48 +68,48 @@ here first.
68
68
  <summary><code>agents/explore.md</code> — reconnaissance</summary>
69
69
 
70
70
  ```markdown
71
- ---
72
- name: explore
73
- 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.
74
- tools: read, grep, find, ls, bash
75
- model: claude-haiku-4-5
76
- thinking: low
77
- # Model selection: SPEED over depth. Pick the fastest available model.
78
- # What matters: fast grep/find/read, structured output. What doesn't: deep reasoning.
79
- ---
80
-
81
- You are an explore agent: a fast, read-only reconnaissance specialist. You investigate a codebase and return compressed, structured findings that another agent can act on WITHOUT re-reading the files you explored. You have NOT got the caller's conversation history — the task brief is your only input.
82
-
83
- ## Hard constraints
84
- - You are READ-ONLY. Never create, edit, or delete files; never run mutating commands.
85
- - Bash is for read-only inspection only: `grep`, `find`, `ls`, `cat`, `git log/show/diff/status`. No installs, builds, or state changes.
86
- - Assume tool permissions are not perfectly enforceable; keep every command strictly read-only by intent.
87
-
88
- ## When invoked
89
- 1. Orient with `grep`/`find` to locate the relevant code fast. Prefer bare identifiers as patterns; scope by path and exclude noisy dirs (node_modules, dist, generated).
90
- 2. Read KEY SECTIONS, not whole files. After 1-2 greps, read the top match instead of running more greps.
91
- 3. Identify the types, interfaces, and key function signatures involved; note how files depend on each other.
92
- 4. Record exact paths and line ranges so the caller can jump straight in.
93
-
94
- ## Thoroughness (infer from the task, default medium)
95
- - Quick: targeted lookups, key files only.
96
- - Medium: follow imports and callers, read critical sections.
97
- - Thorough: trace dependencies across modules; check tests and types.
98
-
99
- ## Collaboration
100
- - Your output feeds `worker` (or the main agent directly). Hand off compressed context: exact locations + the minimum code needed to proceed. Flag anything ambiguous so the caller can decide.
101
-
102
- ## Output format
103
- ## Files Retrieved
104
- 1. `path/to/file.ts` (lines 10-50) — what lives here and why it matters
105
- ## Key Code
106
- Critical types / interfaces / signatures as short code blocks.
107
- ## Architecture
108
- A brief explanation of how the pieces connect.
109
- ## Start Here
110
- Which file to look at first, and why.
111
-
112
- ## Quality standards
71
+ ---
72
+ name: explore
73
+ 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.
74
+ tools: read, grep, find, ls, bash
75
+ model: claude-haiku-4-5
76
+ thinking: low
77
+ # Model selection: SPEED over depth. Pick the fastest available model.
78
+ # What matters: fast grep/find/read, structured output. What doesn't: deep reasoning.
79
+ ---
80
+
81
+ You are an explore agent: a fast, read-only reconnaissance specialist. You investigate a codebase and return compressed, structured findings that another agent can act on WITHOUT re-reading the files you explored. You have NOT got the caller's conversation history — the task brief is your only input.
82
+
83
+ ## Hard constraints
84
+ - You are READ-ONLY. Never create, edit, or delete files; never run mutating commands.
85
+ - Bash is for read-only inspection only: `grep`, `find`, `ls`, `cat`, `git log/show/diff/status`. No installs, builds, or state changes.
86
+ - Assume tool permissions are not perfectly enforceable; keep every command strictly read-only by intent.
87
+
88
+ ## When invoked
89
+ 1. Orient with `grep`/`find` to locate the relevant code fast. Prefer bare identifiers as patterns; scope by path and exclude noisy dirs (node_modules, dist, generated).
90
+ 2. Read KEY SECTIONS, not whole files. After 1-2 greps, read the top match instead of running more greps.
91
+ 3. Identify the types, interfaces, and key function signatures involved; note how files depend on each other.
92
+ 4. Record exact paths and line ranges so the caller can jump straight in.
93
+
94
+ ## Thoroughness (infer from the task, default medium)
95
+ - Quick: targeted lookups, key files only.
96
+ - Medium: follow imports and callers, read critical sections.
97
+ - Thorough: trace dependencies across modules; check tests and types.
98
+
99
+ ## Collaboration
100
+ - Your output feeds `worker` (or the main agent directly). Hand off compressed context: exact locations + the minimum code needed to proceed. Flag anything ambiguous so the caller can decide.
101
+
102
+ ## Output format
103
+ ## Files Retrieved
104
+ 1. `path/to/file.ts` (lines 10-50) — what lives here and why it matters
105
+ ## Key Code
106
+ Critical types / interfaces / signatures as short code blocks.
107
+ ## Architecture
108
+ A brief explanation of how the pieces connect.
109
+ ## Start Here
110
+ Which file to look at first, and why.
111
+
112
+ ## Quality standards
113
113
  Terse and factual. Exact paths and line numbers. Compress — do not narrate your search process or pad with prose.
114
114
  ```
115
115
 
@@ -119,52 +119,52 @@ Terse and factual. Exact paths and line numbers. Compress — do not narrate you
119
119
  <summary><code>agents/worker.md</code> — implementation</summary>
120
120
 
121
121
  ```markdown
122
- ---
123
- name: worker
124
- 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.
125
- model: claude-sonnet-4-5
126
- thinking: high
127
- # Model selection: CODING ABILITY + TOOL USE. The primary implementation model —
128
- # balance quality against cost. No `tools` field => inherits all tools (full capability).
129
- ---
130
-
131
- You are a worker agent with full capabilities, operating in an isolated context window. You own a delegated, self-contained task end to end so the main conversation stays clean. You have NOT got the caller's conversation history — the task brief is your source of truth.
132
-
133
- ## Standard operating procedure
134
- Work in phases. Do not skip planning or verification.
135
-
136
- ### Phase 1 — Context
137
- Read the brief fully. If it references files, read them before editing. If critical context is clearly missing, state what an `explore` should retrieve rather than guessing.
138
-
139
- ### Phase 2 — Plan
140
- Inspect existing code and conventions first. Form the smallest coherent root-cause change that satisfies the brief. For a large task, write a short internal plan (files to touch, order, risks) before editing. Do not refactor unrelated code or create docs unless the brief asks.
141
-
142
- ### Phase 3 — Implement
143
- Make the change. Preserve the user's work; limit edits to the request plus required validation. Follow the project's existing error handling, naming, and style.
144
-
145
- ### Phase 4 — Verify
146
- Run the project's format/build/tests when they exist (e.g. `tsc --noEmit`, the test runner). NEVER report an unrun check as passed — report it as unavailable or as a pre-existing failure, with the exact error.
147
-
148
- ### Phase 5 — Handoff
149
- Summarize concretely so the caller can verify and, if needed, hand to a `reviewer`.
150
-
151
- ## Collaboration
152
- - You cannot dispatch sub-agents (children are leaf processes with no `subagent` tool). When the
153
- brief lacks context that needs broad code discovery, state concretely what an `explore` should
154
- retrieve for the caller — do not guess.
155
- - Recommend a `reviewer` pass before the caller reports work done or commits, especially for non-trivial diffs.
156
-
157
- ## Output format
158
- ## Completed
159
- What was done, in a few lines.
160
- ## Files Changed
161
- - `path/to/file.ts` — what changed.
162
- ## Verification
163
- Which checks you ACTUALLY ran and their result (e.g. `tsc --noEmit` clean; `vitest` 12 passed). State explicitly anything you could not run and why.
164
- ## Notes (if any)
165
- Follow-ups, decisions made, blockers. For a reviewer handoff: exact file paths changed and a short list of key functions/types touched.
166
-
167
- ## Quality standards
122
+ ---
123
+ name: worker
124
+ 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.
125
+ model: claude-sonnet-4-5
126
+ thinking: high
127
+ # Model selection: CODING ABILITY + TOOL USE. The primary implementation model —
128
+ # balance quality against cost. No `tools` field => inherits all tools (full capability).
129
+ ---
130
+
131
+ You are a worker agent with full capabilities, operating in an isolated context window. You own a delegated, self-contained task end to end so the main conversation stays clean. You have NOT got the caller's conversation history — the task brief is your source of truth.
132
+
133
+ ## Standard operating procedure
134
+ Work in phases. Do not skip planning or verification.
135
+
136
+ ### Phase 1 — Context
137
+ Read the brief fully. If it references files, read them before editing. If critical context is clearly missing, state what an `explore` should retrieve rather than guessing.
138
+
139
+ ### Phase 2 — Plan
140
+ Inspect existing code and conventions first. Form the smallest coherent root-cause change that satisfies the brief. For a large task, write a short internal plan (files to touch, order, risks) before editing. Do not refactor unrelated code or create docs unless the brief asks.
141
+
142
+ ### Phase 3 — Implement
143
+ Make the change. Preserve the user's work; limit edits to the request plus required validation. Follow the project's existing error handling, naming, and style.
144
+
145
+ ### Phase 4 — Verify
146
+ Run the project's format/build/tests when they exist (e.g. `tsc --noEmit`, the test runner). NEVER report an unrun check as passed — report it as unavailable or as a pre-existing failure, with the exact error.
147
+
148
+ ### Phase 5 — Handoff
149
+ Summarize concretely so the caller can verify and, if needed, hand to a `reviewer`.
150
+
151
+ ## Collaboration
152
+ - You cannot dispatch sub-agents (children are leaf processes with no `subagent` tool). When the
153
+ brief lacks context that needs broad code discovery, state concretely what an `explore` should
154
+ retrieve for the caller — do not guess.
155
+ - Recommend a `reviewer` pass before the caller reports work done or commits, especially for non-trivial diffs.
156
+
157
+ ## Output format
158
+ ## Completed
159
+ What was done, in a few lines.
160
+ ## Files Changed
161
+ - `path/to/file.ts` — what changed.
162
+ ## Verification
163
+ Which checks you ACTUALLY ran and their result (e.g. `tsc --noEmit` clean; `vitest` 12 passed). State explicitly anything you could not run and why.
164
+ ## Notes (if any)
165
+ Follow-ups, decisions made, blockers. For a reviewer handoff: exact file paths changed and a short list of key functions/types touched.
166
+
167
+ ## Quality standards
168
168
  Root-cause fixes over patches. No unrelated churn. Honest verification — an unrun check is never a passed check.
169
169
  ```
170
170
 
@@ -257,9 +257,9 @@ main agent
257
257
  1. The main agent calls `subagent` with a self-contained brief.
258
258
  2. The tool returns immediately and ends that foreground tool turn, leaving the editor ready
259
259
  for input.
260
- 3. The child process works independently. By default up to four queued runs execute at
261
- once and a single parallel request may contain up to eight tasks; both limits are
262
- configurable (`maxConcurrency` / `maxParallelTasks`).
260
+ 3. The child process works independently. By default up to four sub-agents run at once —
261
+ and one parallel call accepts at most four tasks; extra runs queue up to `maxConcurrency`
262
+ (configurable via `/subagents-setup` or `pi-subagents.json`).
263
263
  4. On completion or failure, the extension sends a durable result message to the main
264
264
  session. That message automatically wakes the main agent, or waits until its current turn
265
265
  finishes.
@@ -331,8 +331,6 @@ agent's default — its frontmatter `thinking`, else the global default). The gl
331
331
  "proactiveInjection": true,
332
332
  "agentScope": "user",
333
333
  "maxConcurrency": 4,
334
- "maxParallelTasks": 8,
335
- "maxSubagentDepth": 1,
336
334
  "maxFixRounds": 2
337
335
  }
338
336
  ```
@@ -347,9 +345,7 @@ agent's default — its frontmatter `thinking`, else the global default). The gl
347
345
  | `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. |
348
346
  | `proactiveInjection` | Whether to add the delegation directive to the main system prompt. |
349
347
  | `agentScope` | `user`, `project`, or `both`; controls which user/project agent directories are discovered. |
350
- | `maxConcurrency` | How many sub-agent processes run at once (1–16, default 4). Extra work waits in the queue. |
351
- | `maxParallelTasks` | Maximum tasks accepted by one parallel `subagent` call (1–32, default 8). |
352
- | `maxSubagentDepth` | Depth at which the `subagent` tool is no longer registered (default 1: the main session delegates, children are leaf processes). `0` disables the tool entirely. Read once at extension load. |
348
+ | `maxConcurrency` | Max sub-agent processes running at once (1–16, default 4), and the max tasks one parallel `subagent` call accepts. Extra work waits in the queue. |
353
349
  | `maxFixRounds` | Auto-fix rounds when a reviewer returns `REVIEW_FAIL`: the extension dispatches a `worker` (briefed with the review's concrete findings) then a `reviewer` re-review, repeating up to this many times before waking the main agent with the full chain. `0` disables it (the main agent handles fixes itself). Default 2. The reviewer stays read-only and in its own context; the loop is orchestrated by the extension, not by the reviewer. |
354
350
 
355
351
  ### Configuration migration
@@ -360,6 +356,11 @@ The config file migrates itself on load — no manual steps after an upgrade:
360
356
  holding invalid values) is normalized and saved back with the new fields filled in.
361
357
  - **Removed agents** — agents no longer shipped (e.g. the old `plan` agent) are stripped
362
358
  from `enabledAgents`, `agentModels`, and `agentThinkingLevels` automatically.
359
+ - **Merged limits** — the pre-0.13 `maxParallelTasks` key is folded into `maxConcurrency`
360
+ (the larger of the two wins) and dropped on the next save.
361
+ - **Removed keys** — `maxSubagentDepth` (0.14) is dropped on load: sub-agent children are
362
+ always leaf processes (the `subagent` tool is excluded from their toolset, with a depth
363
+ marker as defense in depth). To disable delegation entirely, use `"enabledAgents": []`.
363
364
 
364
365
  Model selection uses this precedence:
365
366
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.12.0",
3
+ "version": "0.14.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/config.ts CHANGED
@@ -45,21 +45,10 @@ export const MAX_RESULT_LINES_LIMIT = 2000;
45
45
 
46
46
  export const CONFIG_FILE_NAME = "pi-subagents.json";
47
47
 
48
- /** How many sub-agent processes may run at once. Default: 4. */
48
+ /** How many sub-agent processes may run at once, and how many tasks one parallel `subagent` call may contain. Default: 4. */
49
49
  export const DEFAULT_MAX_CONCURRENCY = 4;
50
50
  /** Upper bound accepted for maxConcurrency (defensive clamp). */
51
51
  export const MAX_CONCURRENCY_LIMIT = 16;
52
- /** How many tasks a single parallel `subagent` call may contain. Default: 8. */
53
- export const DEFAULT_MAX_PARALLEL_TASKS = 8;
54
- /** Upper bound accepted for maxParallelTasks (defensive clamp). */
55
- export const MAX_PARALLEL_TASKS_LIMIT = 32;
56
- /**
57
- * Depth at which the subagent tool stops being available. 1 = the main session
58
- * delegates and child processes are leaves; 0 disables the tool entirely.
59
- */
60
- export const DEFAULT_MAX_SUBAGENT_DEPTH = 1;
61
- /** Upper bound accepted for maxSubagentDepth (defensive clamp). */
62
- export const MAX_SUBAGENT_DEPTH_LIMIT = 4;
63
52
  /**
64
53
  * How many automatic worker→reviewer fix rounds run when a reviewer returns
65
54
  * REVIEW_FAIL before waking the main agent. 0 disables the auto-fix loop
@@ -93,12 +82,9 @@ export interface SubagentsConfig {
93
82
  proactiveInjection: boolean;
94
83
  /** Which agent directories to discover from. Default: "user". */
95
84
  agentScope: AgentScope;
96
- /** Max sub-agent processes running at once (extra work queues). Default: 4. */
85
+ /** Max sub-agent processes running at once (extra work queues) and the max tasks
86
+ * one parallel `subagent` call may contain. Default: 4. */
97
87
  maxConcurrency: number;
98
- /** Max tasks accepted by one parallel `subagent` call. Default: 8. */
99
- maxParallelTasks: number;
100
- /** Depth at which the subagent tool is no longer registered. Default: 1. */
101
- maxSubagentDepth: number;
102
88
  /**
103
89
  * Auto-fix rounds when a reviewer returns REVIEW_FAIL: the extension dispatches
104
90
  * a worker (briefed with the review's concrete findings) then a reviewer
@@ -119,8 +105,6 @@ export const DEFAULT_CONFIG: SubagentsConfig = {
119
105
  proactiveInjection: true,
120
106
  agentScope: "user",
121
107
  maxConcurrency: DEFAULT_MAX_CONCURRENCY,
122
- maxParallelTasks: DEFAULT_MAX_PARALLEL_TASKS,
123
- maxSubagentDepth: DEFAULT_MAX_SUBAGENT_DEPTH,
124
108
  maxFixRounds: DEFAULT_MAX_FIX_ROUNDS,
125
109
  };
126
110
 
@@ -166,8 +150,6 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
166
150
  proactiveInjection: DEFAULT_CONFIG.proactiveInjection,
167
151
  agentScope: DEFAULT_CONFIG.agentScope,
168
152
  maxConcurrency: DEFAULT_CONFIG.maxConcurrency,
169
- maxParallelTasks: DEFAULT_CONFIG.maxParallelTasks,
170
- maxSubagentDepth: DEFAULT_CONFIG.maxSubagentDepth,
171
153
  maxFixRounds: DEFAULT_CONFIG.maxFixRounds,
172
154
  };
173
155
 
@@ -223,12 +205,12 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
223
205
  const maxConcurrency = clampCount(raw.maxConcurrency, MAX_CONCURRENCY_LIMIT);
224
206
  if (maxConcurrency !== undefined) config.maxConcurrency = maxConcurrency;
225
207
 
226
- const maxParallelTasks = clampCount(raw.maxParallelTasks, MAX_PARALLEL_TASKS_LIMIT);
227
- if (maxParallelTasks !== undefined) config.maxParallelTasks = maxParallelTasks;
228
-
229
- // 0 is meaningful here (disables the tool), so clamp to [0, limit] instead.
230
- if (typeof raw.maxSubagentDepth === "number" && Number.isFinite(raw.maxSubagentDepth)) {
231
- config.maxSubagentDepth = Math.max(0, Math.min(MAX_SUBAGENT_DEPTH_LIMIT, Math.round(raw.maxSubagentDepth)));
208
+ // Schema migration: maxParallelTasks (pre-0.13) merged into maxConcurrency.
209
+ // Take the larger of the two so an upgraded config never loses capacity it
210
+ // was explicitly given; the old key is dropped on the persisted save.
211
+ const legacyParallelTasks = clampCount(raw.maxParallelTasks, MAX_CONCURRENCY_LIMIT);
212
+ if (legacyParallelTasks !== undefined && legacyParallelTasks > config.maxConcurrency) {
213
+ config.maxConcurrency = legacyParallelTasks;
232
214
  }
233
215
 
234
216
  // 0 disables the auto-fix loop (main agent handles fixes itself).
package/src/index.ts CHANGED
@@ -167,17 +167,15 @@ export default function (pi: ExtensionAPI): void {
167
167
  };
168
168
  const completionBatcher = createCompletionBatcher<CompletionMessageItem>({ emit: sendCompletionGroup });
169
169
 
170
- // Recursion guard: sub-agents at the configured depth are leaf processes and
171
- // cannot delegate again. maxSubagentDepth 0 disables the tool entirely.
172
- if (currentSubagentDepth() >= initialConfig.maxSubagentDepth) {
173
- const reason =
174
- initialConfig.maxSubagentDepth === 0
175
- ? "disabled by maxSubagentDepth 0 in pi-subagents.json"
176
- : "disabled in nested sub-agent processes";
170
+ // Recursion guard: sub-agent children are leaf processes. The `subagent` tool is
171
+ // excluded from their toolset at spawn (--exclude-tools); this check is defense
172
+ // in depth so a child can never expose the tool back to its model, even if
173
+ // another extension ignores the depth marker.
174
+ if (currentSubagentDepth() >= 1) {
177
175
  pi.registerCommand("subagents-setup", {
178
- description: `Configure pi-subagents (${reason})`,
176
+ description: "Configure pi-subagents (unavailable in nested sub-agent processes)",
179
177
  handler: async (_args, ctx) => {
180
- ctx.ui.notify(`pi-subagents setup is unavailable here (${reason}).`, "warning");
178
+ ctx.ui.notify("pi-subagents setup is unavailable in nested sub-agent processes.", "warning");
181
179
  },
182
180
  });
183
181
  return;
@@ -245,22 +243,34 @@ export default function (pi: ExtensionAPI): void {
245
243
 
246
244
  // Finished runs leave the widget immediately. Their final findings are sent
247
245
  // back as a custom message that automatically starts a follow-up turn.
248
- const finishRun = (runId: number, status: "done" | "failed"): void => {
246
+ const finishRun = (
247
+ runId: number,
248
+ status: "done" | "failed",
249
+ opts?: { silent?: boolean; retain?: boolean },
250
+ ): void => {
249
251
  monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
250
- const run = monitor.removeRun(runId);
252
+ const run = opts?.retain ? monitor.findRun(runId) : monitor.removeRun(runId);
251
253
  if (!run) return; // already finished — stay idempotent
252
- if (!sessionActive) return;
254
+ if (opts?.silent || !sessionActive) return;
253
255
  const icon = status === "done" ? "✓" : "✗";
254
256
  ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
255
257
  };
256
258
 
257
259
  // Live sub-agent activity → concise one-line status ("thinking",
258
- // "read src/index.ts", ...), never a raw args blob.
259
- const makeLiveHandler = (runId: number) => (e: SubagentLiveEvent): void => {
260
+ // "read src/index.ts", ...), never a raw args blob. Reviewer runs started
261
+ // by the main agent defer finishing so the queue task can decide between
262
+ // delivering the review and starting an auto-fix chain: a triggered chain
263
+ // keeps the parent row in the widget (annotated) until it completes and
264
+ // suppresses the premature "done" notification.
265
+ const makeLiveHandler = (runId: number, deferFinish = false) => (e: SubagentLiveEvent): void => {
260
266
  switch (e.kind) {
261
267
  case "status":
262
- if (e.status === "done" || e.status === "failed") finishRun(runId, e.status);
263
- else monitor.setStatus(runId, e.status);
268
+ if (e.status === "done" || e.status === "failed") {
269
+ // Deferred runs only update the widget; the queue task finishes
270
+ // them once it knows whether an auto-fix chain will follow.
271
+ if (deferFinish) monitor.setStatus(runId, e.status);
272
+ else finishRun(runId, e.status);
273
+ } else monitor.setStatus(runId, e.status);
264
274
  break;
265
275
  case "usage":
266
276
  monitor.setUsage(runId, e.usage, e.model);
@@ -388,8 +398,10 @@ export default function (pi: ExtensionAPI): void {
388
398
  * findings) → reviewer re-review, up to maxFixRounds times. The main agent is
389
399
  * not woken mid-loop; the full chain is delivered as one group at the end.
390
400
  * Failures short-circuit: a crashed worker skips its re-review and delivers.
401
+ * The triggering reviewer's run stays visible in the widget (annotated) until
402
+ * the chain resolves, so the ↳ rows have an obvious parent.
391
403
  */
392
- const startFixLoop = (initialReviewerResult: SingleResult, parentGroupId: string): void => {
404
+ const startFixLoop = (initialReviewerResult: SingleResult, parentGroupId: string, parentRunId: number): void => {
393
405
  backgroundQueue.enqueue(
394
406
  async (signal) => {
395
407
  const chain: SingleResult[] = [initialReviewerResult];
@@ -410,12 +422,18 @@ export default function (pi: ExtensionAPI): void {
410
422
  });
411
423
  chain.push(reviewResult);
412
424
  lastReviewer = reviewResult;
413
- if (!sessionActive) break;
425
+ // A crashed re-review must stop the chain like a crashed worker: its
426
+ // output (if any) is not a verdict, and feeding it to the next fix
427
+ // round would brief the worker from garbage.
428
+ if (!sessionActive || isFailedResult(reviewResult)) break;
414
429
  if (reviewVerdict(getResultOutput(reviewResult)) === "pass") break;
415
430
  }
431
+ // The chain is done (success, exhaustion, or abort): drop the retained
432
+ // parent row, then deliver the whole chain as one group. The loop's
433
+ // outcome always wakes the main agent (a passing chain reports
434
+ // success, a stuck one needs a human).
435
+ monitor.removeRun(parentRunId);
416
436
  if (!sessionActive) return;
417
- // Deliver the whole chain as one group; the loop's outcome always wakes
418
- // the main agent (a passing chain reports success, a stuck one needs a human).
419
437
  const items: CompletionMessageItem[] = chain.map((r) => ({
420
438
  agent: r.agent,
421
439
  block: formatCompletionBlock(r, config.maxResultLines),
@@ -425,7 +443,9 @@ export default function (pi: ExtensionAPI): void {
425
443
  completionBatcher.flush();
426
444
  },
427
445
  () => {
428
- // Cancelled: each in-flight run was already finished by its launchInLoop path.
446
+ // Cancelled before delivery: clean up the retained parent row (each
447
+ // in-flight chain run was already finished by its launchInLoop path).
448
+ monitor.removeRun(parentRunId);
429
449
  },
430
450
  );
431
451
  };
@@ -438,7 +458,9 @@ export default function (pi: ExtensionAPI): void {
438
458
  const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
439
459
  const pending = queuedResult(agent, task, thinkingLevel);
440
460
  const runId = monitor.addRun(agent.name, task, agent.model, thinkingLevel);
441
- const onLive = makeLiveHandler(runId);
461
+ // Only a main-agent-dispatched reviewer can trigger an auto-fix chain, so
462
+ // only its finish is deferred to the queue task (see startFixLoop).
463
+ const onLive = makeLiveHandler(runId, agent.name === "reviewer");
442
464
 
443
465
  backgroundQueue.enqueue(
444
466
  async (backgroundSignal) => {
@@ -475,12 +497,22 @@ export default function (pi: ExtensionAPI): void {
475
497
  // triggers a worker→reviewer chain (up to maxFixRounds) without waking
476
498
  // the main agent. Loop-internal re-reviews never reach here (they are
477
499
  // awaited inside launchInLoop); the initial review is delivered with
478
- // the chain at the end.
500
+ // the chain at the end. While the chain runs, the triggering review
501
+ // stays in the widget (annotated) so the chain rows have an obvious
502
+ // parent; no premature "done" notification is shown.
479
503
  if (shouldTriggerFixLoop(result, config)) {
480
- startFixLoop(result, `fix-${runId}`);
504
+ // The session is known active here (checked above), so the chain
505
+ // always starts: keep the triggering review in the widget
506
+ // (annotated) without a premature "done" notification, and let
507
+ // startFixLoop deliver the whole chain and drop the parent row.
508
+ finishRun(runId, "done", { silent: true, retain: true });
509
+ monitor.setAnnotation(runId, "auto-fix chain running");
510
+ startFixLoop(result, `fix-${runId}`, runId);
481
511
  return;
482
512
  }
483
513
  const failed = isFailedResult(result);
514
+ finishRun(runId, failed ? "failed" : "done");
515
+ if (!sessionActive) return;
484
516
  const completion: CompletionMessageItem = {
485
517
  agent: result.agent,
486
518
  block: formatCompletionBlock(result, config.maxResultLines),
@@ -504,12 +536,12 @@ export default function (pi: ExtensionAPI): void {
504
536
  // Sub-agents intentionally detach from the foreground turn. This makes the
505
537
  // editor available immediately; completion messages later wake the main agent.
506
538
  if (params.tasks && params.tasks.length > 0) {
507
- if (params.tasks.length > config.maxParallelTasks) {
539
+ if (params.tasks.length > config.maxConcurrency) {
508
540
  return {
509
541
  content: [
510
542
  {
511
543
  type: "text",
512
- text: `Too many parallel tasks (${params.tasks.length}). Max is ${config.maxParallelTasks} (configurable via /subagents-setup).`,
544
+ text: `Too many parallel tasks (${params.tasks.length}). Max is ${config.maxConcurrency} (configurable via /subagents-setup).`,
513
545
  },
514
546
  ],
515
547
  details: makeDetails("parallel", true)([]),
@@ -630,7 +662,8 @@ export default function (pi: ExtensionAPI): void {
630
662
  // Chain-internal runs (auto-fix worker/reviewer) indent under their
631
663
  // parent reviewer; summarize() already carries the relationLabel.
632
664
  const head = r.groupId ? theme.fg("dim", " ↳ ") : " ";
633
- lines.push(truncateToWidth(`${head}${icon} ${monitor.summarize(r)} · ${label}`, width, ""));
665
+ const note = r.annotation ? theme.fg("dim", ` · ${r.annotation}`) : "";
666
+ lines.push(truncateToWidth(`${head}${icon} ${monitor.summarize(r)} · ${label}${note}`, width, ""));
634
667
  if (r.status === "queued" || r.status === "running") {
635
668
  lines.push(truncateToWidth(theme.fg("dim", ` task: ${formatTaskSummary(r.task)}`), width, ""));
636
669
  }
package/src/monitor.ts CHANGED
@@ -40,6 +40,8 @@ export interface RunView {
40
40
  groupId?: string;
41
41
  /** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
42
42
  relationLabel?: string;
43
+ /** Free-form note shown in the widget next to the status label (e.g. "auto-fix chain running"). */
44
+ annotation?: string;
43
45
  }
44
46
 
45
47
  /** Optional chain metadata for runs spawned by an auto-fix loop. */
@@ -199,8 +201,11 @@ export class MonitorStore {
199
201
  const run = this.find(id);
200
202
  if (!run) return;
201
203
  run.status = status;
202
- if (status === "running" && run.startedAt === undefined) {
203
- run.startedAt = Date.now();
204
+ if (status === "running") {
205
+ if (run.startedAt === undefined) run.startedAt = Date.now();
206
+ // A model-fallback retry after a failed attempt restarts the clock; a
207
+ // stale endedAt would freeze the elapsed display at the first attempt.
208
+ if (run.endedAt !== undefined) run.endedAt = undefined;
204
209
  } else if ((status === "done" || status === "failed") && run.endedAt === undefined) {
205
210
  run.endedAt = Date.now();
206
211
  }
@@ -222,6 +227,19 @@ export class MonitorStore {
222
227
  this.notify();
223
228
  }
224
229
 
230
+ /** Set a widget note on the run (e.g. that its auto-fix chain is still running). */
231
+ setAnnotation(id: number, text: string): void {
232
+ const run = this.find(id);
233
+ if (!run) return;
234
+ run.annotation = text;
235
+ this.notify();
236
+ }
237
+
238
+ /** Look up a run by id without removing it. */
239
+ findRun(id: number): RunView | undefined {
240
+ return this.find(id);
241
+ }
242
+
225
243
  /** Remove a run (finished runs leave the widget). Returns the removed run. */
226
244
  removeRun(id: number): RunView | undefined {
227
245
  const index = this.runs.findIndex((r) => r.id === id);
package/src/setup.ts CHANGED
@@ -16,7 +16,6 @@ import {
16
16
  DEFAULT_ENABLED_AGENTS,
17
17
  DEFAULT_MAX_CONCURRENCY,
18
18
  DEFAULT_MAX_FIX_ROUNDS,
19
- DEFAULT_MAX_PARALLEL_TASKS,
20
19
  THINKING_LEVEL_VALUES,
21
20
  type AgentScope,
22
21
  type SubagentsConfig,
@@ -196,7 +195,6 @@ async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Pr
196
195
 
197
196
  /** Preset steps offered for the two numeric limits (selection-only wizard). */
198
197
  const CONCURRENCY_STEPS = [1, 2, 3, 4, 6, 8, 12, 16];
199
- const PARALLEL_TASK_STEPS = [2, 4, 6, 8, 12, 16, 24, 32];
200
198
  /** Preset rounds offered for the auto-fix loop (0 disables it). */
201
199
  const FIX_ROUNDS_STEPS = [0, 1, 2, 3, 5];
202
200
 
@@ -284,22 +282,13 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
284
282
 
285
283
  const maxConcurrency = await pickCount(
286
284
  ctx,
287
- "Max sub-agents running at once? (extra work queues)",
285
+ "Max sub-agents running at once (and per parallel call)? (extra work queues)",
288
286
  CONCURRENCY_STEPS,
289
287
  base.maxConcurrency,
290
288
  DEFAULT_MAX_CONCURRENCY,
291
289
  );
292
290
  if (maxConcurrency === undefined) return notifyCancelled(ctx);
293
291
 
294
- const maxParallelTasks = await pickCount(
295
- ctx,
296
- "Max tasks in one parallel subagent call?",
297
- PARALLEL_TASK_STEPS,
298
- base.maxParallelTasks,
299
- DEFAULT_MAX_PARALLEL_TASKS,
300
- );
301
- if (maxParallelTasks === undefined) return notifyCancelled(ctx);
302
-
303
292
  const maxFixRounds = await pickCount(
304
293
  ctx,
305
294
  "Auto-fix rounds when a reviewer returns REQUEST_CHANGES? (0 = main agent handles fixes)",
@@ -319,8 +308,6 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
319
308
  proactiveInjection: injection,
320
309
  agentScope: scope,
321
310
  maxConcurrency,
322
- maxParallelTasks,
323
- maxSubagentDepth: base.maxSubagentDepth,
324
311
  maxFixRounds,
325
312
  };
326
313
  await saveConfig(next, configPath);
@@ -335,7 +322,6 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
335
322
  "Toggle proactive injection",
336
323
  "Change agent scope",
337
324
  "Change max concurrent sub-agents",
338
- "Change max parallel tasks",
339
325
  "Change max fix rounds",
340
326
  "Full re-setup",
341
327
  ]);
@@ -381,23 +367,13 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
381
367
  } else if (choice.startsWith("Change max concurrent")) {
382
368
  const maxConcurrency = await pickCount(
383
369
  ctx,
384
- "Max sub-agents running at once? (extra work queues)",
370
+ "Max sub-agents running at once (and per parallel call)? (extra work queues)",
385
371
  CONCURRENCY_STEPS,
386
372
  config.maxConcurrency,
387
373
  DEFAULT_MAX_CONCURRENCY,
388
374
  );
389
375
  if (maxConcurrency === undefined) return notifyCancelled(ctx);
390
376
  next.maxConcurrency = maxConcurrency;
391
- } else if (choice.startsWith("Change max parallel")) {
392
- const maxParallelTasks = await pickCount(
393
- ctx,
394
- "Max tasks in one parallel subagent call?",
395
- PARALLEL_TASK_STEPS,
396
- config.maxParallelTasks,
397
- DEFAULT_MAX_PARALLEL_TASKS,
398
- );
399
- if (maxParallelTasks === undefined) return notifyCancelled(ctx);
400
- next.maxParallelTasks = maxParallelTasks;
401
377
  } else if (choice.startsWith("Change max fix")) {
402
378
  const maxFixRounds = await pickCount(
403
379
  ctx,
package/src/spawn.ts CHANGED
@@ -15,15 +15,15 @@ import { existsSync, mkdirSync, unlinkSync, rmdirSync, writeFileSync } from "nod
15
15
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
16
16
  import { tmpdir } from "node:os";
17
17
  import { basename, join } from "node:path";
18
+ import { StringDecoder } from "node:string_decoder";
18
19
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
19
20
  import type { Message } from "@earendil-works/pi-ai";
20
21
  import type { AgentConfig, AgentSource } from "./agents.ts";
21
22
  import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
22
23
 
23
24
  /**
24
- * Limits are configurable: see maxConcurrency / maxParallelTasks /
25
- * maxSubagentDepth in config.ts (defaults 4 / 8 / 1, via /subagents-setup
26
- * or pi-subagents.json).
25
+ * Limits are configurable: see maxConcurrency in config.ts (default 4, via
26
+ * /subagents-setup or pi-subagents.json).
27
27
  */
28
28
  /** Default thinking level for sub-agents. pi clamps it to the resolved model's support. */
29
29
  export const SUBAGENT_THINKING_LEVEL: ThinkingLevel = DEFAULT_THINKING_LEVEL;
@@ -135,7 +135,9 @@ export function writeResultArtifact(output: string, agentName: string): string {
135
135
  const dir = join(tmpdir(), "pi-subagents-results");
136
136
  mkdirSync(dir, { recursive: true });
137
137
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
138
- const filePath = join(dir, `${Date.now()}-${safeName}.md`);
138
+ // A random suffix keeps same-millisecond writes from clobbering each other.
139
+ const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
140
+ const filePath = join(dir, `${unique}-${safeName}.md`);
139
141
  writeFileSync(filePath, output, "utf8");
140
142
  return filePath;
141
143
  }
@@ -458,8 +460,13 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
458
460
  proc.stdin?.on("error", () => undefined);
459
461
  proc.stdin?.end(`Task: ${task}`);
460
462
 
463
+ // Decode stdout through a StringDecoder so multi-byte UTF-8 characters
464
+ // (CJK, emoji) split across chunk boundaries never produce U+FFFD
465
+ // replacement characters — a corrupted JSON line would drop the whole
466
+ // message (including a reviewer's verdict line) from parsing.
467
+ const stdoutDecoder = new StringDecoder("utf8");
461
468
  proc.stdout.on("data", (data) => {
462
- buffer += data.toString();
469
+ buffer += stdoutDecoder.write(data);
463
470
  const lines = buffer.split("\n");
464
471
  buffer = lines.pop() || "";
465
472
  for (const line of lines) processLine(line);
@@ -470,6 +477,9 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
470
477
  });
471
478
 
472
479
  proc.on("close", (code) => {
480
+ // Flush any bytes still held by the decoder (a trailing incomplete
481
+ // multi-byte sequence) before processing the final buffer.
482
+ buffer += stdoutDecoder.end();
473
483
  if (buffer.trim()) processLine(buffer);
474
484
  // A null exit code means the process was terminated by a signal and
475
485
  // must be reported as failure, never as a false clean completion.