@ferris1225/pi-subagents 4.1.6 → 4.1.8

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
@@ -119,7 +119,10 @@ The runtime deliberately keeps delegation conservative:
119
119
  - `worker` and `cleaner` remain distinct write-capable entry roles. Each updates
120
120
  existing README/docs/examples/comments directly affected by its change. After
121
121
  success, one enabled independent `reviewer` gate runs, with the existing bounded
122
- worker ↔ reviewer fix loop for `REVIEW_FAIL`.
122
+ worker ↔ reviewer fix loop for `REVIEW_FAIL`. Every gate finding carries a
123
+ concrete fix instruction; the worker implements those instructions or ships a
124
+ sounder fix with an explicit pushback, and re-review judges the resulting code —
125
+ only open findings and defects the fix itself introduced can continue the loop.
123
126
  - When `documenter` is enabled, every managed reviewer gate is asked for a
124
127
  standalone `DOCUMENTATION: CLEAN` or `DOCUMENTATION: NEEDED` line. Only
125
128
  `REVIEW_PASS` can authorize the final sync: NEEDED includes
@@ -204,10 +207,10 @@ subagent({
204
207
  });
205
208
  ```
206
209
 
207
- Independent tasks run up to `maxConcurrency` (default `4`). One parallel call may
208
- contain at most that many tasks and is rejected if it exceeds the limit. Accepted
209
- background work from separate calls waits in the shared queue when all slots are
210
- busy.
210
+ Independent tasks run up to a fixed limit of `4` concurrent sub-agent processes.
211
+ One parallel call may contain at most that many tasks and is rejected if it
212
+ exceeds the limit. Accepted background work from separate calls waits in the
213
+ shared queue when all slots are busy.
211
214
 
212
215
  ### Run an independent quality gate
213
216
 
@@ -225,20 +228,26 @@ immediately, while NEEDED (or a missing marker on that passing gate) runs one
225
228
  final docs sync. A failure uses the bounded loop:
226
229
 
227
230
  ```text
228
- reviewer → worker fixes every open finding reviewer checks again
231
+ reviewer → worker applies each fix instruction (or rebuts with a sounder fix)
232
+ → reviewer re-reviews the result → …
229
233
  REVIEW_PASS ─┬─ CLEAN → deliver
230
234
  └─ NEEDED/missing → documenter
231
235
  ```
232
236
 
233
237
  Each step gets a fresh model context. The chain shares the same code state and
234
238
  passes every full reviewer and worker report forward; it does not reuse one
235
- context window. Internal children bypass top-level lifecycle policy, so they
239
+ context window. Re-review converges instead of re-auditing: it rules on every
240
+ previous finding once, judges the code as it now stands (a sound worker fix
241
+ counts even when it deviates from the instruction), and adds new findings only
242
+ for defects the fix round's own edits introduced or exposed — issues unrelated
243
+ to those edits belong to a fresh gate, not to the loop. Internal children bypass top-level lifecycle policy, so they
236
244
  cannot recursively start another chain. Gate reviewers keep documentation drift
237
245
  out of the code verdict while `documenter` is enabled by recording it under
238
246
  `## Documentation notes`; with documenter disabled, drift is a normal finding.
239
247
 
240
- `maxFixRounds` limits worker fix attempts only. The post-writer review gate still
241
- runs when it is `0`, and only a terminal `REVIEW_PASS` can decide whether docs
248
+ The loop is fixed at two worker fix rounds (each fix is re-reviewed); disabling
249
+ the `worker` agent is the way to turn fixes off. The post-writer review gate still
250
+ runs regardless, and only a terminal `REVIEW_PASS` can decide whether docs
242
251
  sync is needed. Generic audits and read-only reviews are advisory: they omit
243
252
  `VERDICT` and documentation machine markers, remain read-only, and never trigger
244
253
  edits.
@@ -495,8 +504,8 @@ subagent({
495
504
 
496
505
  ## Configuration
497
506
 
498
- The wizard covers enabled agents, per-agent models and thinking, concurrency,
499
- auto-fix rounds, and the idle timeout:
507
+ The wizard covers enabled agents, per-agent models and thinking, and the idle
508
+ timeout:
500
509
 
501
510
  ```text
502
511
  /subagents-setup
@@ -526,8 +535,6 @@ Configuration is stored at `~/.pi/agent/pi-subagents.json` and follows
526
535
  "maxResultLines": 80,
527
536
  "proactiveInjection": true,
528
537
  "agentScope": "user",
529
- "maxConcurrency": 4,
530
- "maxFixRounds": 2,
531
538
  "idleTimeoutSec": 90
532
539
  }
533
540
  ```
@@ -541,16 +548,15 @@ Configuration is stored at `~/.pi/agent/pi-subagents.json` and follows
541
548
  | `maxResultLines` | Lines kept in a completion message before the full result moves to a temporary artifact. Default `80`. |
542
549
  | `proactiveInjection` | Teach the main model when and how to delegate. Default `true`. |
543
550
  | `agentScope` | Discover `user`, `project`, or `both` agent directories. Default `user`. |
544
- | `maxConcurrency` | Running process limit and maximum tasks in one parallel call, from `1` to `16`. Default `4`. |
545
- | `maxFixRounds` | Maximum worker fixes after `REVIEW_FAIL`; each fix is re-reviewed. After terminal `REVIEW_PASS`, docs sync runs only for `DOCUMENTATION: NEEDED` or a missing marker. `0` disables fixes but not the post-writer gate or conditional/reviewer-disabled docs behavior. Default `2`. |
546
551
  | `idleTimeoutSec` | Seconds without RPC output before termination. `0` disables the watchdog. Default `90`. |
547
552
 
548
- Invalid values fall back safely. Older configs are normalized automatically. The
549
- former built-in name `explore` migrates to `explorer`, and pre-cleaner non-empty
550
- agent lists receive `cleaner` once. Existing non-empty configs also receive
551
- `documenter` once, inserted before `reviewer`, with any configured `explorer`
552
- model and thinking copied across. Fresh installs do not enable `documenter`
553
- until the user selects it. Later deliberate disables are respected.
553
+ Invalid values fall back safely. Keys from older versions (including the former
554
+ `maxConcurrency` and `maxFixRounds` tuning options, now fixed at `4` concurrent
555
+ processes and `2` fix rounds) are dropped automatically and the normalized
556
+ shape is saved back. At session start, per-agent model overrides that Pi no
557
+ longer reports as available are removed with a one-time notice; those agents
558
+ fall back to the current main model until you re-pick them in
559
+ `/subagents-setup`.
554
560
 
555
561
  ## Custom and overridden agents
556
562
 
@@ -43,7 +43,7 @@ You are a senior, adversarial code reviewer. Find genuine defects and risks rath
43
43
  - Return only the review result. Do not repeat the task brief, summarize the implementation, narrate inspection/tool chronology, or explain a root cause when no finding depends on it. Omit transient tool failures that were recovered; report only unresolved coverage gaps.
44
44
  - Stay independent of `worker`, `cleaner`, and `documenter`; fix nothing yourself. When a final documenter is available, documentation drift is not a code-gate finding: record it in a short `## Documentation notes` section and carry it forward on re-review. When documenter is disabled, drift is a normal gate finding.
45
45
  - Every gate (never an advisory review) must classify documentation on its own standalone machine line. Emit `DOCUMENTATION: NEEDED` and include `## Documentation notes` when a sync is needed; otherwise emit `DOCUMENTATION: CLEAN`. Runtime treats a missing marker conservatively as NEEDED. Do not emit this marker for advisory output.
46
- - In a gate, every code/test finding enters auto-fix, with no severity tiers. A direct REVIEW_PASS is final for code: CLEAN delivers directly, while NEEDED/missing runs one conditional documentation sync without reopening the gate. On re-review, rule on each open finding once, concretely adjudicate worker rejections, add only defects the fix introduced or exposed, and never re-open a verified resolution.
46
+ - In a gate, every code/test finding enters auto-fix, with no severity tiers, and every gate finding must end with a concrete fix instruction — what to change, where, and how to verify the fix — because a worker implements exactly those instructions unless it can justify a sounder fix and push back. A direct REVIEW_PASS is final for code: CLEAN delivers directly, while NEEDED/missing runs one conditional documentation sync without reopening the gate. On re-review, judge the code as it now stands: a finding is resolved when the pending diff fixes it soundly, whether or not the worker followed your instruction. Rule on each open finding once, concretely adjudicate worker pushback, add only defects the fix introduced or exposed — never issues unrelated to this round's edits — and never re-open a verified resolution.
47
47
  - Advisory findings never enter auto-fix; the caller decides whether to authorize later implementation or cleanup.
48
48
 
49
49
  ## Output
@@ -64,7 +64,7 @@ For a gate review:
64
64
  ## Files Reviewed
65
65
  - path/to/file.ts
66
66
  ## Findings
67
- - file.ts:42 — concrete issue and why it breaks
67
+ - file.ts:42 — concrete issue and why it breaks — Fix: the change to make and how to verify it
68
68
  (Write "None" when no finding remains.)
69
69
  ## Documentation notes
70
70
  - exact stale surface and required correction
package/agents/worker.md CHANGED
@@ -34,6 +34,10 @@ Never commit, push, publish, tag, release, or bump a package version. The parent
34
34
  - You cannot dispatch sub-agents (children are leaf processes with no `subagent` tool). When the
35
35
  brief lacks context that needs broad code discovery, state concretely what an `explorer` should
36
36
  retrieve for the caller — do not guess.
37
+ - In an auto-fix round, apply your own judgment to the reviewer's fix instructions: implement each
38
+ instruction when it is sound; when it is wrong, out of scope, or a sounder fix exists, implement
39
+ your fix and push back in your report — cite the finding, refute the instruction's reasoning,
40
+ and describe what you shipped instead. A deviation without reasoning will be re-opened.
37
41
  - The parent runtime automatically runs one enabled `reviewer` gate after a successful top-level worker and preserves the bounded worker/reviewer fix loop. A final `documenter` runs only when that terminal review reports documentation drift or omits its marker (or when reviewer is disabled). Report a complete handoff, but do not ask the caller to duplicate downstream roles. Never treat your own verification as the final gate.
38
42
 
39
43
  ## Output format
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "4.1.6",
3
+ "version": "4.1.8",
4
4
  "description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,82 +1,37 @@
1
- /** Session-start recovery and one-time feature announcements. */
1
+ /** Session-start recovery, stale-config migration, and widget installation. */
2
2
 
3
- import { stat } from "node:fs/promises";
4
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import {
6
- CLEANER_AUTO_ENABLED_FEATURE,
7
- CLEANER_INHERITED_FEATURE,
8
- DOCUMENTER_AUTO_ENABLED_FEATURE,
9
- DOCUMENTER_INHERITED_FEATURE,
10
- loadConfig,
11
- saveConfig,
12
- } from "./config.ts";
4
+ import { loadConfig, saveConfig } from "./config.ts";
5
+ import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
13
6
  import { announceRecoveryRecords } from "./recovery.ts";
14
7
  import type { SubagentRuntime } from "./runtime.ts";
15
8
  import { pruneResultArtifacts } from "./spawn.ts";
16
9
  import { installActiveRunsWidget } from "./widget.ts";
17
10
 
18
- const ANNOUNCEMENTS: Array<{
19
- key: string;
20
- condition: (config: Awaited<ReturnType<typeof loadConfig>>) => boolean;
21
- message: (config: Awaited<ReturnType<typeof loadConfig>>) => string;
22
- }> = [
23
- {
24
- // Fires once after the load-time upgrade injected cleaner into an older
25
- // config (the injection stamp only exists in that case). The extra
26
- // enabledAgents check keeps the notice silent when the user already
27
- // disabled cleaner (e.g. via full setup) before it could fire.
28
- key: "cleanerAutoEnabledNotice",
29
- condition: (config) =>
30
- config.announcedFeatures.includes(CLEANER_AUTO_ENABLED_FEATURE) &&
31
- config.enabledAgents.includes("cleaner"),
32
- // The inheritance clause matches reality: its stamp is only set when the
33
- // upgrade actually copied reviewer model/thinking settings.
34
- message: (config) =>
35
- config.announcedFeatures.includes(CLEANER_INHERITED_FEATURE)
36
- ? "pi-subagents: the built-in cleaner agent was enabled by default and inherited your reviewer model/thinking settings. Run /subagents-setup to adjust or disable it."
37
- : "pi-subagents: the built-in cleaner agent was enabled by default. Run /subagents-setup to adjust or disable it.",
38
- },
39
- {
40
- key: "documenterAutoEnabledNotice",
41
- condition: (config) =>
42
- config.announcedFeatures.includes(DOCUMENTER_AUTO_ENABLED_FEATURE) &&
43
- config.enabledAgents.includes("documenter"),
44
- message: (config) =>
45
- config.announcedFeatures.includes(DOCUMENTER_INHERITED_FEATURE)
46
- ? "pi-subagents: the new documenter agent was enabled for your existing config and inherited your explorer model/thinking settings. It synchronizes comments and README/docs before commit; run /subagents-setup to adjust or disable it."
47
- : "pi-subagents: the new documenter agent was enabled for your existing config. It synchronizes comments and README/docs before commit; run /subagents-setup to adjust or disable it.",
48
- },
49
- ];
50
-
51
- async function announceNewFeatures(
52
- ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } },
11
+ /**
12
+ * One-time-per-stale-override migration: keep agent model selections Pi still
13
+ * reports as available, drop the rest back to dynamic main-model routing, and
14
+ * tell the user what was removed. Saving the cleaned config is what makes it
15
+ * one-time — the dropped refs no longer exist to re-trigger the notice.
16
+ */
17
+ async function migrateUnavailableAgentModels(
18
+ ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } } & Parameters<typeof availableModelsInScope>[0],
53
19
  runtime: SubagentRuntime,
54
20
  ): Promise<void> {
55
21
  try {
56
- let configExists = true;
57
- try {
58
- await stat(runtime.configPath);
59
- } catch {
60
- configExists = false;
61
- }
62
- if (!configExists) return;
63
-
64
22
  const config = await loadConfig(runtime.configPath);
65
- const pending = ANNOUNCEMENTS.filter(
66
- (announcement) =>
67
- announcement.condition(config) && !config.announcedFeatures.includes(announcement.key),
68
- );
69
- if (pending.length === 0) return;
70
- await saveConfig(
71
- {
72
- ...config,
73
- announcedFeatures: [...config.announcedFeatures, ...pending.map((announcement) => announcement.key)],
74
- },
75
- runtime.configPath,
23
+ const overrides = Object.entries(config.agentModels);
24
+ if (overrides.length === 0) return;
25
+ const { kept, dropped } = filterUnavailableModelOverrides(config.agentModels, availableModelsInScope(ctx));
26
+ if (dropped.length === 0) return;
27
+ await saveConfig({ ...config, agentModels: kept }, runtime.configPath);
28
+ const list = dropped.map(({ agent, ref }) => `${agent}: ${ref}`).join(", ");
29
+ ctx.ui.notify(
30
+ `pi-subagents: removed stale agent model overrides that are no longer available (${list}). Those agents now follow the current main model; run /subagents-setup to re-pick.`,
31
+ "warning",
76
32
  );
77
- for (const announcement of pending) ctx.ui.notify(announcement.message(config), "info");
78
33
  } catch {
79
- /* announcement failures are non-fatal */
34
+ /* migration failures are non-fatal */
80
35
  }
81
36
  }
82
37
 
@@ -84,8 +39,8 @@ export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime
84
39
  pi.on("session_start", async (_event, ctx) => {
85
40
  pruneResultArtifacts();
86
41
  await announceRecoveryRecords(runtime.configPath, ctx);
42
+ await migrateUnavailableAgentModels(ctx, runtime);
87
43
  if (ctx.mode !== "tui") return;
88
44
  installActiveRunsWidget(ctx);
89
- await announceNewFeatures(ctx, runtime);
90
45
  });
91
46
  }
package/src/background.ts CHANGED
@@ -27,6 +27,11 @@ interface PendingTask {
27
27
  onError?: (error: unknown) => void | Promise<void>;
28
28
  }
29
29
 
30
+ /** How many sub-agent processes may run at once, and how many tasks one
31
+ * parallel `subagent` call may contain. Fixed by design: the queue sheds load
32
+ * by waiting, so the knob bought nothing worth its maintenance. */
33
+ export const MAX_CONCURRENT_SUBAGENTS = 4;
34
+
30
35
  export class BackgroundTaskQueue {
31
36
  private concurrency: number;
32
37
  private readonly pending: PendingTask[] = [];
@@ -39,16 +44,6 @@ export class BackgroundTaskQueue {
39
44
  this.concurrency = Math.max(1, concurrency);
40
45
  }
41
46
 
42
- /**
43
- * Update the concurrency limit (e.g. after a config change). Raising it
44
- * immediately starts more queued work; lowering it takes effect as running
45
- * tasks finish — already-running tasks are never interrupted.
46
- */
47
- setConcurrency(concurrency: number): void {
48
- this.concurrency = Math.max(1, concurrency);
49
- this.drain();
50
- }
51
-
52
47
  enqueue(task: BackgroundTask, onCancelled?: () => void, onError?: (error: unknown) => void | Promise<void>): AbortController {
53
48
  const controller = new AbortController();
54
49
  let complete!: () => void;
package/src/config.ts CHANGED
@@ -4,57 +4,23 @@
4
4
  * Config lives at <agentDir>/pi-subagents.json (agentDir defaults to ~/.pi/agent
5
5
  * and honors PI_CODING_AGENT_DIR). Parsing is defensive: invalid fields fall back
6
6
  * to defaults instead of throwing, so a hand-edited or partially-written file can
7
- * never break the extension at runtime.
8
- *
9
- * Schema upgrades happen transparently on load: a config written by an older
10
- * version (missing newer keys or containing invalid
11
- * values) is normalized and persisted back with the new fields filled in.
7
+ * never break the extension at runtime. Unknown keys from older versions are
8
+ * dropped and the normalized shape persisted back on load.
12
9
  */
13
10
 
14
11
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
15
- import { readFileSync } from "node:fs";
16
12
  import { dirname, join } from "node:path";
17
13
  import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
18
14
 
19
15
  /** Full catalog of agents shipped with the package (selectable in /subagents-setup). */
20
16
  export const BUILTIN_AGENT_NAMES = ["explorer", "worker", "cleaner", "documenter", "reviewer"] as const;
21
17
 
22
- /** Agents enabled out of the box on a fresh install. Documenter remains an
23
- * explicit setup choice; existing non-empty configs receive it via migration. */
18
+ /** Agents enabled out of the box on a fresh install. */
24
19
  export const DEFAULT_ENABLED_AGENTS: readonly string[] = ["explorer", "worker", "cleaner", "reviewer"];
25
20
 
26
21
  export const AGENT_SCOPE_VALUES = ["user", "project", "both"] as const;
27
22
  export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
28
23
 
29
- const LEGACY_EXPLORER_NAME = "explore";
30
- const EXPLORER_NAME = "explorer";
31
- const CLEANER_NAME = "cleaner";
32
- const DOCUMENTER_NAME = "documenter";
33
- const REVIEWER_NAME = "reviewer";
34
-
35
- /**
36
- * Stamps recorded in `announcedFeatures` by the one-time upgrade that defaults
37
- * cleaner on for configs written before it shipped. The first marks a config as
38
- * processed, so a later deliberate disable is not undone; the second records
39
- * that cleaner was actually injected, so session start can tell the user once;
40
- * the third records that reviewer model/thinking settings were actually copied,
41
- * so that notice never claims an inheritance that did not happen.
42
- */
43
- export const CLEANER_DEFAULTED_FEATURE = "cleanerDefaulted";
44
- export const CLEANER_AUTO_ENABLED_FEATURE = "cleanerAutoEnabled";
45
- export const CLEANER_INHERITED_FEATURE = "cleanerInheritedReviewer";
46
-
47
- /** One-time upgrade stamps for the pre-commit documenter role. Existing
48
- * non-empty configs gain it before reviewer and inherit explorer routing; fresh
49
- * installs keep it off until setup explicitly enables it. */
50
- export const DOCUMENTER_DEFAULTED_FEATURE = "documenterDefaulted";
51
- export const DOCUMENTER_AUTO_ENABLED_FEATURE = "documenterAutoEnabled";
52
- export const DOCUMENTER_INHERITED_FEATURE = "documenterInheritedExplorer";
53
-
54
- function migrateAgentName(name: string): string {
55
- return name === LEGACY_EXPLORER_NAME ? EXPLORER_NAME : name;
56
- }
57
-
58
24
  /** Thinking levels accepted by pi's `--thinking` option. */
59
25
  export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
60
26
  export type ThinkingLevel = (typeof THINKING_LEVEL_VALUES)[number];
@@ -67,20 +33,6 @@ export const MAX_RESULT_LINES_LIMIT = 2000;
67
33
 
68
34
  const CONFIG_FILE_NAME = "pi-subagents.json";
69
35
 
70
- /** How many sub-agent processes may run at once, and how many tasks one parallel `subagent` call may contain. Default: 4. */
71
- export const DEFAULT_MAX_CONCURRENCY = 4;
72
- /** Upper bound accepted for maxConcurrency (defensive clamp). */
73
- export const MAX_CONCURRENCY_LIMIT = 16;
74
- /**
75
- * Maximum worker fixes after REVIEW_FAIL. Each fix is followed by a reviewer
76
- * re-review; this cap does not suppress the post-writer review gate or its
77
- * conditional/reviewer-disabled documentation fallback. 0 disables fixes.
78
- * Default: 2.
79
- */
80
- export const DEFAULT_MAX_FIX_ROUNDS = 2;
81
- /** Upper bound accepted for maxFixRounds (defensive clamp). 0 disables the loop. */
82
- export const MAX_FIX_ROUNDS_LIMIT = 5;
83
-
84
36
  /**
85
37
  * Default idle timeout in seconds: a sub-agent whose stdout (JSON event stream)
86
38
  * goes silent for this long is terminated; a selected model then hands the
@@ -112,27 +64,12 @@ export interface SubagentsConfig {
112
64
  proactiveInjection: boolean;
113
65
  /** Which agent directories to discover from. Default: "user". */
114
66
  agentScope: AgentScope;
115
- /** Max sub-agent processes running at once (extra work queues) and the max tasks
116
- * one parallel `subagent` call may contain. Default: 4. */
117
- maxConcurrency: number;
118
- /**
119
- * Maximum worker fixes after REVIEW_FAIL. Every fix receives the full review,
120
- * then a re-review runs; any documentation sync selected after the terminal
121
- * healthy review does not consume this budget. 0 disables fixes. Default: 2.
122
- */
123
- maxFixRounds: number;
124
67
  /**
125
68
  * Idle timeout in seconds: a sub-agent whose stdout (JSON event stream) goes
126
69
  * silent for this long is terminated; a configured agent model then hands
127
70
  * off to the current main model. 0 disables the idle watchdog. Default: 90.
128
71
  */
129
72
  idleTimeoutSec: number;
130
- /**
131
- * One-time feature announcements already shown to the user, plus schema
132
- * upgrade stamps (e.g. the cleaner default-enable upgrade). Persisted so
133
- * notices and migrations never repeat.
134
- */
135
- announcedFeatures: string[];
136
73
  }
137
74
 
138
75
  export const DEFAULT_CONFIG: SubagentsConfig = {
@@ -143,10 +80,7 @@ export const DEFAULT_CONFIG: SubagentsConfig = {
143
80
  maxResultLines: DEFAULT_MAX_RESULT_LINES,
144
81
  proactiveInjection: true,
145
82
  agentScope: "user",
146
- maxConcurrency: DEFAULT_MAX_CONCURRENCY,
147
- maxFixRounds: DEFAULT_MAX_FIX_ROUNDS,
148
83
  idleTimeoutSec: DEFAULT_IDLE_TIMEOUT_SEC,
149
- announcedFeatures: [],
150
84
  };
151
85
 
152
86
  export function getConfigPath(agentDir: string = getAgentDir()): string {
@@ -186,48 +120,30 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
186
120
  const names = raw.enabledAgents.filter(
187
121
  (name): name is string => typeof name === "string" && name.trim().length > 0,
188
122
  );
189
- // An explicitly empty array is honored. Rename the former built-in key and
190
- // deduplicate when a config already contains both spellings.
191
- config.enabledAgents = [...new Set(names.map((name) => migrateAgentName(name.trim())))];
123
+ // An explicitly empty array is honored; duplicates collapse.
124
+ config.enabledAgents = [...new Set(names.map((name) => name.trim()))];
192
125
  }
193
126
 
194
127
  if (isRecord(raw.agentModels)) {
195
- const entries = Object.entries(raw.agentModels);
196
- // A valid explicit new key wins regardless of JSON property order.
197
- for (const [rawKey, value] of entries) {
128
+ for (const [rawKey, value] of Object.entries(raw.agentModels)) {
198
129
  const key = rawKey.trim();
199
- if (key !== LEGACY_EXPLORER_NAME && isModelReference(value)) {
130
+ if (key !== "" && isModelReference(value)) {
200
131
  config.agentModels[key] = value.trim();
201
132
  }
202
133
  }
203
- if (!Object.hasOwn(config.agentModels, EXPLORER_NAME)) {
204
- const legacy = entries.find(([key, value]) =>
205
- key.trim() === LEGACY_EXPLORER_NAME && isModelReference(value)
206
- );
207
- if (legacy && isModelReference(legacy[1])) config.agentModels[EXPLORER_NAME] = legacy[1].trim();
208
- }
209
134
  }
210
135
 
211
136
  if (isRecord(raw.agentThinkingLevels)) {
212
- const entries = Object.entries(raw.agentThinkingLevels);
213
- for (const [rawKey, value] of entries) {
137
+ for (const [rawKey, value] of Object.entries(raw.agentThinkingLevels)) {
214
138
  const key = rawKey.trim();
215
139
  if (
216
- key !== LEGACY_EXPLORER_NAME &&
140
+ key !== "" &&
217
141
  typeof value === "string" &&
218
142
  (THINKING_LEVEL_VALUES as readonly string[]).includes(value)
219
143
  ) {
220
144
  config.agentThinkingLevels[key] = value as ThinkingLevel;
221
145
  }
222
146
  }
223
- if (!Object.hasOwn(config.agentThinkingLevels, EXPLORER_NAME)) {
224
- const legacy = entries.find(([key, value]) =>
225
- key.trim() === LEGACY_EXPLORER_NAME &&
226
- typeof value === "string" &&
227
- (THINKING_LEVEL_VALUES as readonly string[]).includes(value)
228
- );
229
- if (legacy) config.agentThinkingLevels[EXPLORER_NAME] = legacy[1] as ThinkingLevel;
230
- }
231
147
  }
232
148
 
233
149
  if (typeof raw.notifyOnReviewPass === "boolean") {
@@ -245,75 +161,11 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
245
161
  config.agentScope = raw.agentScope;
246
162
  }
247
163
 
248
- const maxConcurrency = clampCount(raw.maxConcurrency, MAX_CONCURRENCY_LIMIT);
249
- if (maxConcurrency !== undefined) config.maxConcurrency = maxConcurrency;
250
-
251
- // 0 disables worker fixes, not the independent post-writer review gate or
252
- // conditional/reviewer-disabled documentation fallback.
253
- if (typeof raw.maxFixRounds === "number" && Number.isFinite(raw.maxFixRounds)) {
254
- config.maxFixRounds = Math.max(0, Math.min(MAX_FIX_ROUNDS_LIMIT, Math.round(raw.maxFixRounds)));
255
- }
256
-
257
164
  // 0 disables the idle watchdog; otherwise clamp to [0, upper].
258
165
  if (typeof raw.idleTimeoutSec === "number" && Number.isFinite(raw.idleTimeoutSec)) {
259
166
  config.idleTimeoutSec = Math.max(0, Math.min(IDLE_TIMEOUT_SEC_LIMIT, Math.round(raw.idleTimeoutSec)));
260
167
  }
261
168
 
262
- if (Array.isArray(raw.announcedFeatures)) {
263
- config.announcedFeatures = raw.announcedFeatures.filter(
264
- (feature): feature is string => typeof feature === "string" && feature.trim().length > 0,
265
- );
266
- }
267
-
268
- // One-time upgrade for configs written before cleaner shipped: a non-empty
269
- // explicit enabledAgents list gets cleaner defaulted on (inserted before
270
- // reviewer, matching the fresh-install order) and inherits the reviewer's
271
- // configured model and thinking level — its closest peer. The stamps make
272
- // the upgrade idempotent and keep a later deliberate disable from being undone.
273
- if (!config.announcedFeatures.includes(CLEANER_DEFAULTED_FEATURE)) {
274
- config.announcedFeatures.push(CLEANER_DEFAULTED_FEATURE);
275
- if (config.enabledAgents.length > 0 && !config.enabledAgents.includes(CLEANER_NAME)) {
276
- const reviewerIndex = config.enabledAgents.indexOf(REVIEWER_NAME);
277
- config.enabledAgents.splice(reviewerIndex === -1 ? config.enabledAgents.length : reviewerIndex, 0, CLEANER_NAME);
278
- config.announcedFeatures.push(CLEANER_AUTO_ENABLED_FEATURE);
279
- let inherited = false;
280
- if (!config.agentModels[CLEANER_NAME] && config.agentModels[REVIEWER_NAME]) {
281
- config.agentModels[CLEANER_NAME] = config.agentModels[REVIEWER_NAME];
282
- inherited = true;
283
- }
284
- if (!config.agentThinkingLevels[CLEANER_NAME] && config.agentThinkingLevels[REVIEWER_NAME]) {
285
- config.agentThinkingLevels[CLEANER_NAME] = config.agentThinkingLevels[REVIEWER_NAME];
286
- inherited = true;
287
- }
288
- // An old explicit list may have no reviewer overrides to copy; only the
289
- // copied case is stamped so the one-time notice stays accurate.
290
- if (inherited) config.announcedFeatures.push(CLEANER_INHERITED_FEATURE);
291
- }
292
- }
293
-
294
- if (!config.announcedFeatures.includes(DOCUMENTER_DEFAULTED_FEATURE)) {
295
- config.announcedFeatures.push(DOCUMENTER_DEFAULTED_FEATURE);
296
- if (config.enabledAgents.length > 0 && !config.enabledAgents.includes(DOCUMENTER_NAME)) {
297
- const reviewerIndex = config.enabledAgents.indexOf(REVIEWER_NAME);
298
- config.enabledAgents.splice(
299
- reviewerIndex === -1 ? config.enabledAgents.length : reviewerIndex,
300
- 0,
301
- DOCUMENTER_NAME,
302
- );
303
- config.announcedFeatures.push(DOCUMENTER_AUTO_ENABLED_FEATURE);
304
- let inherited = false;
305
- if (!config.agentModels[DOCUMENTER_NAME] && config.agentModels[EXPLORER_NAME]) {
306
- config.agentModels[DOCUMENTER_NAME] = config.agentModels[EXPLORER_NAME];
307
- inherited = true;
308
- }
309
- if (!config.agentThinkingLevels[DOCUMENTER_NAME] && config.agentThinkingLevels[EXPLORER_NAME]) {
310
- config.agentThinkingLevels[DOCUMENTER_NAME] = config.agentThinkingLevels[EXPLORER_NAME];
311
- inherited = true;
312
- }
313
- if (inherited) config.announcedFeatures.push(DOCUMENTER_INHERITED_FEATURE);
314
- }
315
- }
316
-
317
169
  return config;
318
170
  }
319
171
 
@@ -323,7 +175,6 @@ function defaultConfig(): SubagentsConfig {
323
175
  enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
324
176
  agentModels: {},
325
177
  agentThinkingLevels: {},
326
- announcedFeatures: [],
327
178
  };
328
179
  }
329
180
 
@@ -364,18 +215,6 @@ export async function loadConfig(configPath: string = getConfigPath()): Promise<
364
215
  return config;
365
216
  }
366
217
 
367
- /**
368
- * Synchronous load for the extension's init-time decisions (e.g. the recursion
369
- * guard). Runs before any async context is available; never migrates or saves.
370
- */
371
- export function loadConfigSync(configPath: string = getConfigPath()): SubagentsConfig {
372
- try {
373
- return normalizeConfig(JSON.parse(readFileSync(configPath, "utf8")));
374
- } catch {
375
- return defaultConfig();
376
- }
377
- }
378
-
379
218
  /**
380
219
  * Save config atomically (temp file + rename) serialized through pi's per-file
381
220
  * mutation queue so concurrent writers cannot interleave.
package/src/dispatch.ts CHANGED
@@ -15,6 +15,7 @@ import { realpath } from "node:fs/promises";
15
15
  import { resolve } from "node:path";
16
16
  import { Type } from "typebox";
17
17
  import { discoverAgents, resolveAgentTools, type AgentConfig } from "./agents.ts";
18
+ import { MAX_CONCURRENT_SUBAGENTS } from "./background.ts";
18
19
  import { loadConfig } from "./config.ts";
19
20
  import { formatUsage, queuedResult } from "./format.ts";
20
21
  import {
@@ -23,6 +24,7 @@ import {
23
24
  buildFixTaskBrief,
24
25
  buildReReviewBrief,
25
26
  documentationDisposition,
27
+ MAX_FIX_ROUNDS,
26
28
  type ChainStep,
27
29
  type ManagedWorkflowOutcome,
28
30
  } from "./fixloop.ts";
@@ -193,8 +195,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
193
195
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
194
196
  monitor.beginTurn();
195
197
  const config = await loadConfig(runtime.configPath);
196
- // Pick up concurrency changes from /subagents-setup without a restart.
197
- runtime.backgroundQueue.setConcurrency(config.maxConcurrency);
198
198
 
199
199
  // Finished runs leave the active monitor immediately. Their final findings
200
200
  // are sent as a custom message that starts a follow-up turn.
@@ -493,19 +493,19 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
493
493
  ): Promise<{ lastReview?: SingleResult; lastWorker?: SingleResult }> => {
494
494
  let lastReviewer = triggeringReviewer;
495
495
  const outcome: { lastReview?: SingleResult; lastWorker?: SingleResult } = {};
496
- for (let round = 1; round <= request.config.maxFixRounds; round++) {
496
+ for (let round = 1; round <= MAX_FIX_ROUNDS; round++) {
497
497
  if (!canContinue()) break;
498
- const fixRelation = `fix ${round}/${request.config.maxFixRounds}`;
498
+ const fixRelation = `fix ${round}/${MAX_FIX_ROUNDS}`;
499
499
  const workerResult = await launchStep(
500
500
  "worker",
501
- buildFixTaskBrief(lastReviewer, round, request.config.maxFixRounds),
501
+ buildFixTaskBrief(lastReviewer, round, MAX_FIX_ROUNDS),
502
502
  `fix round ${round}`,
503
503
  { timelineRelation: fixRelation, childRelation: fixRelation },
504
504
  );
505
505
  if (isFailedResult(workerResult) || !canContinue()) break;
506
506
  outcome.lastWorker = workerResult;
507
507
 
508
- const reReviewRelation = `re-review ${round}/${request.config.maxFixRounds}`;
508
+ const reReviewRelation = `re-review ${round}/${MAX_FIX_ROUNDS}`;
509
509
  const reviewResult = await launchStep(
510
510
  "reviewer",
511
511
  buildReReviewBrief(lastReviewer, round, workerResult, {
@@ -586,8 +586,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
586
586
  !isFailedResult(gateReview) &&
587
587
  canContinue() &&
588
588
  reviewVerdict(getResultOutput(gateReview)) === "fail" &&
589
- enabled("worker") &&
590
- request.config.maxFixRounds > 0
589
+ enabled("worker")
591
590
  ) {
592
591
  fixOutcome = await runFixRounds(gateReview);
593
592
  }
@@ -620,12 +619,12 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
620
619
  // Sub-agents intentionally detach from the foreground turn. This makes the
621
620
  // editor available immediately; completion messages later wake the main agent.
622
621
  if (params.tasks && params.tasks.length > 0) {
623
- if (params.tasks.length > config.maxConcurrency) {
622
+ if (params.tasks.length > MAX_CONCURRENT_SUBAGENTS) {
624
623
  return {
625
624
  content: [
626
625
  {
627
626
  type: "text",
628
- text: `Too many parallel tasks (${params.tasks.length}). Max is ${config.maxConcurrency} (configurable via /subagents-setup).`,
627
+ text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_CONCURRENT_SUBAGENTS}.`,
629
628
  },
630
629
  ],
631
630
  details: makeDetails("parallel", true)([]),
package/src/fixloop.ts CHANGED
@@ -3,6 +3,10 @@
3
3
  *
4
4
  * Successful top-level worker/cleaner runs continue through an independent
5
5
  * code review gate; bounded worker → reviewer fix rounds close its findings.
6
+ * Gate findings carry concrete fix instructions that the worker implements
7
+ * unless it can justify a sounder fix and push back; re-review adjudicates on
8
+ * the resulting code (open findings plus fix-introduced defects only) so the
9
+ * rounds converge instead of re-auditing from scratch.
6
10
  * Reviewers classify documentation drift explicitly, so the low-cost final
7
11
  * documenter runs only when needed (or conservatively when an older/custom
8
12
  * reviewer omits the marker). Direct passing/failing gates use the same policy.
@@ -14,7 +18,13 @@
14
18
  import { isWriteCapableAgent, type AgentConfig } from "./agents.ts";
15
19
  import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
16
20
  import { formatUsageCompact, sumUsage } from "./monitor.ts";
17
- import type { SubagentsConfig } from "./config.ts";
21
+
22
+ /**
23
+ * Worker fixes allowed after REVIEW_FAIL. Each fix is followed by a reviewer
24
+ * re-review; this cap does not suppress the post-writer review gate or its
25
+ * conditional/reviewer-disabled documentation fallback.
26
+ */
27
+ export const MAX_FIX_ROUNDS = 2;
18
28
 
19
29
  /**
20
30
  * Whether a completed result should trigger the auto-fix loop instead of being
@@ -23,8 +33,7 @@ import type { SubagentsConfig } from "./config.ts";
23
33
  * normally. Loop-internal re-review results never reach this path (they are
24
34
  * awaited inside the loop, not delivered through the completion flow).
25
35
  */
26
- export function shouldTriggerFixLoop(result: SingleResult, config: SubagentsConfig): boolean {
27
- if (config.maxFixRounds <= 0) return false;
36
+ export function shouldTriggerFixLoop(result: SingleResult): boolean {
28
37
  if (result.agent !== "reviewer") return false;
29
38
  if (isFailedResult(result)) return false;
30
39
  // A dispatch crash (spawn infra, delivery API, ...) is never a real review
@@ -93,8 +102,8 @@ export function canStartManagedWorkflow(
93
102
  if (isWriteCapableAgent(agent)) return true;
94
103
  if (agent.name === "reviewer") {
95
104
  // Hold a stable diff snapshot against every discoverable writer even when
96
- // this review is advisory or maxFixRounds=0. Classification happens only
97
- // after the read-only child returns, too late to acquire the lane safely.
105
+ // this review is advisory. Classification happens only after the read-only
106
+ // child returns, too late to acquire the lane safely.
98
107
  return availability.writer;
99
108
  }
100
109
  return false;
@@ -104,7 +113,6 @@ export function canStartManagedWorkflow(
104
113
  * machine verdict is advisory and cannot start any write-capable child. */
105
114
  export function getManagedWorkflowPlan(
106
115
  result: SingleResult,
107
- config: SubagentsConfig,
108
116
  availability: WorkflowAgentAvailability,
109
117
  ): ManagedWorkflowPlan | undefined {
110
118
  if (result.parked || result.dispatchFailed || isFailedResult(result)) return undefined;
@@ -131,7 +139,7 @@ export function getManagedWorkflowPlan(
131
139
  ) {
132
140
  return { kind: "review-pass-sync", initialRelation: "pre-documentation review" };
133
141
  }
134
- if (verdict === "fail" && availability.worker && shouldTriggerFixLoop(result, config)) {
142
+ if (verdict === "fail" && availability.worker && shouldTriggerFixLoop(result)) {
135
143
  return { kind: "auto-fix", initialRelation: "initial review" };
136
144
  }
137
145
  return undefined;
@@ -139,8 +147,9 @@ export function getManagedWorkflowPlan(
139
147
 
140
148
  /**
141
149
  * Build the worker task brief for one fix round from a reviewer's findings.
142
- * The worker gets the full review text so it can address concrete file:line
143
- * issues, with instructions to fix every reported finding and self-verify.
150
+ * The worker gets the full review text findings plus their fix instructions
151
+ * and closes every finding either by implementing the instruction or by
152
+ * shipping a sounder fix with an explicit per-finding pushback.
144
153
  */
145
154
  export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, maxRounds: number): string {
146
155
  const review = getResultOutput(reviewerResult);
@@ -153,13 +162,15 @@ export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, m
153
162
  review,
154
163
  `---`,
155
164
  ``,
156
- `Fix EVERY finding in the reviewer's findings list — there is no severity triage; all of them get fixed.`,
157
- `If a finding is factually wrong or clearly out of scope, say so explicitly instead of fixing it.`,
165
+ `Each finding in the reviewer's report carries a fix instruction. Close EVERY finding — there is no severity triage; all of them get fixed.`,
166
+ `You own the fix: when an instruction is factually wrong, clearly out of scope, or a sounder fix exists, implement YOUR fix instead,`,
167
+ `then push back explicitly per finding — cite it, refute the instruction's reasoning, and state what you shipped instead.`,
168
+ `A pushback without a working alternative or concrete reasoning will be re-opened.`,
158
169
  `Do NOT refactor unrelated code beyond what the findings require.`,
159
170
  `Synchronize any existing README/docs/examples/comments directly affected by your fixes; do not broaden into standalone documentation maintenance.`,
160
171
  `Do NOT commit, push, publish, tag, or release; do not bump versions. The parent chain still owns re-review and any conditional final documentation sync.`,
161
172
  `After editing, run the project's format/build/tests when they exist and report`,
162
- `exactly what you changed (paths + short rationale) so a reviewer can verify.`,
173
+ `exactly what you changed (paths + short rationale) plus any pushback, so a reviewer can verify.`,
163
174
  remaining > 0
164
175
  ? `A reviewer will re-review your changes automatically after you finish.`
165
176
  : `This is the last auto-fix round; the workflow conditionally runs any needed final documentation sync and then delivers.`,
@@ -304,6 +315,8 @@ export function buildFinalReviewBrief(
304
315
  ``,
305
316
  `Run \`git status\` and \`git diff\` and inspect the actual pending code; the report is context, not proof.`,
306
317
  `Remain read-only. Verify correctness, regressions, and tests.`,
318
+ `Attach a concrete fix instruction to EVERY gate finding: what to change, where, and how to verify the fix.`,
319
+ `A worker will implement your instructions unless it can justify a sounder fix and push back, so make each instruction specific enough to act on.`,
307
320
  ...(options.documenterPending
308
321
  ? [
309
322
  `A conditional documentation sync is available AFTER this gate, so documentation drift is not a code-gate finding.`,
@@ -321,10 +334,11 @@ export function buildFinalReviewBrief(
321
334
 
322
335
  /**
323
336
  * The re-review brief handed to the reviewer after a worker fix round. Includes
324
- * the prior review and worker report so the reviewer can adjudicate rejections
337
+ * the prior review and worker report so the reviewer can adjudicate pushback
325
338
  * instead of restating findings. The convergence contract keeps rounds from
326
- * ping-ponging: rule on the open findings once, add only defects this round's
327
- * edits introduced, never re-open a verified resolution.
339
+ * ping-ponging: judge the resulting code (not instruction obedience), rule on
340
+ * the open findings once, add only defects this round's edits introduced,
341
+ * never re-open a verified resolution.
328
342
  */
329
343
  export function buildReReviewBrief(
330
344
  reviewerResult: SingleResult,
@@ -342,16 +356,17 @@ export function buildReReviewBrief(
342
356
  review,
343
357
  `---`,
344
358
  ``,
345
- `The worker's report (what it changed, plus any finding it rejected as factually wrong or out of scope):`,
359
+ `The worker's report (what it changed, plus any pushback where it replaced your fix instruction with its own fix):`,
346
360
  `---`,
347
361
  workerReport,
348
362
  `---`,
349
363
  ``,
350
- `Rule on EVERY previous finding: resolved, or still open. A finding the worker rejected must be`,
351
- `adjudicated ONCE accept the rejection unless you can concretely refute the worker's reasoning;`,
352
- `never simply restate the finding for another round.`,
353
- `Run \`git diff\` to see what changed, then add NEW findings only when they are defects this round's`,
354
- `edits introduced or exposed (or a load-bearing issue the earlier review genuinely missed).`,
364
+ `Rule on EVERY previous finding: resolved, or still open. Judge the code as it now stands — a finding is`,
365
+ `resolved when the pending diff fixes it soundly, whether or not the worker followed your fix instruction.`,
366
+ `A finding the worker pushed back on must be adjudicated ONCE — accept the worker's fix unless you can`,
367
+ `concretely refute its reasoning; never simply restate the finding for another round.`,
368
+ `Run \`git diff\` to see what changed, then add NEW findings only for defects this round's edits introduced or exposed.`,
369
+ `Re-review never opens findings unrelated to this round's edits; issues the earlier review missed belong to a fresh gate.`,
355
370
  `Do NOT re-open a finding you verified as resolved.`,
356
371
  ...(options.documenterPending
357
372
  ? [
package/src/index.ts CHANGED
@@ -86,7 +86,7 @@ export default function (pi: ExtensionAPI): void {
86
86
  enabledNames: config.enabledAgents,
87
87
  projectTrusted: ctx.isProjectTrusted?.() === true,
88
88
  });
89
- const directive = buildDelegationDirective(agents, { maxFixRounds: config.maxFixRounds });
89
+ const directive = buildDelegationDirective(agents);
90
90
  if (!directive) return undefined;
91
91
  return { systemPrompt: `${event.systemPrompt}\n${directive}` };
92
92
  });
package/src/models.ts CHANGED
@@ -86,6 +86,22 @@ export function findModelByRef(
86
86
  return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
87
87
  }
88
88
 
89
+ /** Split persisted agent model overrides into the ones Pi still reports as
90
+ * available and the stale ones. Stale refs are dropped at session start (with
91
+ * a user notice) so the config never carries models that can no longer run. */
92
+ export function filterUnavailableModelOverrides(
93
+ agentModels: Record<string, string>,
94
+ models: readonly Model<Api>[],
95
+ ): { kept: Record<string, string>; dropped: Array<{ agent: string; ref: string }> } {
96
+ const kept: Record<string, string> = {};
97
+ const dropped: Array<{ agent: string; ref: string }> = [];
98
+ for (const [agent, ref] of Object.entries(agentModels)) {
99
+ if (findModelByRef(models, ref)) kept[agent] = ref;
100
+ else dropped.push({ agent, ref });
101
+ }
102
+ return { kept, dropped };
103
+ }
104
+
89
105
  /**
90
106
  * Resolve one agent's runtime route:
91
107
  *
package/src/prompt.ts CHANGED
@@ -13,7 +13,6 @@ function bullets(lines: readonly string[]): string {
13
13
 
14
14
  export function buildDelegationDirective(
15
15
  agents: AgentConfig[],
16
- options: { maxFixRounds?: number } = {},
17
16
  ): string {
18
17
  if (agents.length === 0) return "";
19
18
 
@@ -24,7 +23,7 @@ export function buildDelegationDirective(
24
23
  const hasDocumenter = agents.some((agent) => agent.name === "documenter");
25
24
  const hasReviewer = agents.some((agent) => agent.name === "reviewer");
26
25
  const hasMultiple = agents.length > 1;
27
- const autoFixEnabled = hasWorker && (options.maxFixRounds ?? 1) > 0;
26
+ const autoFixEnabled = hasWorker;
28
27
  const codeWriterNames = [
29
28
  ...(hasWorker ? ["worker"] : []),
30
29
  ...(hasCleaner ? ["cleaner"] : []),
@@ -101,10 +100,10 @@ export function buildDelegationDirective(
101
100
  ? [
102
101
  ...(hasDocumenter
103
102
  ? [
104
- `A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps bounded worker/reviewer auto-fix, with docs considered only after its terminal REVIEW_PASS." : "cannot start fixes while worker/fix rounds are disabled."}`,
103
+ `A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps bounded worker/reviewer auto-fix, with docs considered only after its terminal REVIEW_PASS." : "cannot start fixes while worker is disabled."}`,
105
104
  ]
106
105
  : []),
107
- "Resolve every gate finding; do not bypass the configured auto-fix/re-review cap. A reviewer report without a standalone VERDICT is advisory and cannot trigger writes.",
106
+ "Resolve every gate finding; do not bypass the auto-fix/re-review cap. A reviewer report without a standalone VERDICT is advisory and cannot trigger writes.",
108
107
  "Use multi-model cross-review only when explicitly requested or for genuinely high-risk security, unsafe/FFI, persistence-migration, or concurrency changes.",
109
108
  ]
110
109
  : []),
package/src/runtime.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { rmSync } from "node:fs";
13
- import { BackgroundTaskQueue } from "./background.ts";
13
+ import { BackgroundTaskQueue, MAX_CONCURRENT_SUBAGENTS } from "./background.ts";
14
14
  import {
15
15
  completionGroupTriggersTurn,
16
16
  createCompletionBatcher,
@@ -19,7 +19,7 @@ import {
19
19
  type CompletionBatcher,
20
20
  type CompletionMessageItem,
21
21
  } from "./completion.ts";
22
- import { loadConfigSync, type ThinkingLevel } from "./config.ts";
22
+ import { type ThinkingLevel } from "./config.ts";
23
23
  import { isRunActiveStatus, monitor } from "./monitor.ts";
24
24
  import {
25
25
  persistRecoveryRecords,
@@ -124,10 +124,7 @@ export interface SubagentRuntime {
124
124
  }
125
125
 
126
126
  export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
127
- // Init-time decisions need the config synchronously; the full (migrating)
128
- // async load runs per tool call.
129
- const initialConfig = loadConfigSync(configPath);
130
- const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
127
+ const backgroundQueue = new BackgroundTaskQueue(MAX_CONCURRENT_SUBAGENTS);
131
128
 
132
129
  const runtime: SubagentRuntime = {
133
130
  configPath,
package/src/setup.ts CHANGED
@@ -13,13 +13,9 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
13
  import {
14
14
  AGENT_SCOPE_VALUES,
15
15
  BUILTIN_AGENT_NAMES,
16
- CLEANER_DEFAULTED_FEATURE,
17
- DOCUMENTER_DEFAULTED_FEATURE,
18
16
  DEFAULT_CONFIG,
19
17
  DEFAULT_ENABLED_AGENTS,
20
18
  DEFAULT_IDLE_TIMEOUT_SEC,
21
- DEFAULT_MAX_CONCURRENCY,
22
- DEFAULT_MAX_FIX_ROUNDS,
23
19
  DEFAULT_THINKING_LEVEL,
24
20
  type AgentScope,
25
21
  type SubagentsConfig,
@@ -243,8 +239,6 @@ async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Pr
243
239
  return choice.startsWith("On");
244
240
  }
245
241
 
246
- const CONCURRENCY_STEPS = [1, 2, 3, 4, 6, 8, 12, 16];
247
- const FIX_ROUNDS_STEPS = [0, 1, 2, 3, 5];
248
242
  const IDLE_TIMEOUT_STEPS = [0, 30, 60, 90, 120, 180, 300, 600];
249
243
 
250
244
  async function pickCount(
@@ -299,22 +293,6 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
299
293
  if (injection === undefined) return false;
300
294
  const scope = await pickScope(ctx, base.agentScope);
301
295
  if (scope === undefined) return false;
302
- const maxConcurrency = await pickCount(
303
- ctx,
304
- "Max sub-agents running at once?",
305
- CONCURRENCY_STEPS,
306
- base.maxConcurrency,
307
- DEFAULT_MAX_CONCURRENCY,
308
- );
309
- if (maxConcurrency === undefined) return false;
310
- const maxFixRounds = await pickCount(
311
- ctx,
312
- "Reviewer worker-fix rounds? (0 = no automatic fixes)",
313
- FIX_ROUNDS_STEPS,
314
- base.maxFixRounds,
315
- DEFAULT_MAX_FIX_ROUNDS,
316
- );
317
- if (maxFixRounds === undefined) return false;
318
296
  const idleTimeoutSec = await pickCount(
319
297
  ctx,
320
298
  "Idle timeout in seconds? (0 = disabled)",
@@ -333,16 +311,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
333
311
  maxResultLines: base.maxResultLines,
334
312
  proactiveInjection: injection,
335
313
  agentScope: scope,
336
- maxConcurrency,
337
- maxFixRounds,
338
314
  idleTimeoutSec,
339
- // Full setup is an explicit decision point: mark role-enable migrations as
340
- // processed so the user's saved selection is kept as-is.
341
- announcedFeatures: [...new Set([
342
- ...base.announcedFeatures,
343
- CLEANER_DEFAULTED_FEATURE,
344
- DOCUMENTER_DEFAULTED_FEATURE,
345
- ])],
346
315
  };
347
316
  await saveConfig(next, configPath);
348
317
  ctx.ui.notify(`pi-subagents configured with Auto thinking. Saved to ${configPath}`, "info");
@@ -357,8 +326,6 @@ async function updateRuntimeSetting(
357
326
  const choice = await ctx.ui.select("Runtime setting", [
358
327
  "Proactive injection",
359
328
  "Agent scope",
360
- "Max concurrency",
361
- "Reviewer worker-fix rounds",
362
329
  "Idle timeout",
363
330
  ]);
364
331
  if (choice === undefined) return undefined;
@@ -371,14 +338,6 @@ async function updateRuntimeSetting(
371
338
  const value = await pickScope(ctx, config.agentScope);
372
339
  if (value === undefined) continue;
373
340
  next.agentScope = value;
374
- } else if (choice.startsWith("Max concurrency")) {
375
- const value = await pickCount(ctx, "Max sub-agents running at once?", CONCURRENCY_STEPS, config.maxConcurrency, DEFAULT_MAX_CONCURRENCY);
376
- if (value === undefined) continue;
377
- next.maxConcurrency = value;
378
- } else if (choice.startsWith("Reviewer")) {
379
- const value = await pickCount(ctx, "Reviewer worker-fix rounds?", FIX_ROUNDS_STEPS, config.maxFixRounds, DEFAULT_MAX_FIX_ROUNDS);
380
- if (value === undefined) continue;
381
- next.maxFixRounds = value;
382
341
  } else {
383
342
  const value = await pickCount(ctx, "Idle timeout in seconds?", IDLE_TIMEOUT_STEPS, config.idleTimeoutSec, DEFAULT_IDLE_TIMEOUT_SEC);
384
343
  if (value === undefined) continue;
package/src/spawn.ts CHANGED
@@ -272,7 +272,7 @@ export function isRetryableStartupFailure(result: SingleResult, durationMs: numb
272
272
  }
273
273
 
274
274
  export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
275
- return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or temporarily lower maxConcurrency in /subagents-setup.`;
275
+ return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or dispatch fewer sub-agents at once.`;
276
276
  }
277
277
 
278
278
  export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
@@ -720,7 +720,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
720
720
  if (!ownsResumeReservation(thread, reservation)) {
721
721
  throw new Error(`Run #${runId} changed while resume configuration was loading.`);
722
722
  }
723
- runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
724
723
  const currentAgents = discoverAgents(currentCtx.cwd, {
725
724
  scope: currentConfig.agentScope,
726
725
  enabledNames: currentConfig.enabledAgents,
@@ -888,7 +887,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
888
887
  if (!ownsFork()) throw new Error(`Run #${runId} changed while its retained session was being forked.`);
889
888
  const currentConfig = await loadConfig(runtime.configPath);
890
889
  if (!ownsFork()) throw new Error(`Run #${runId} changed while fork configuration was loading.`);
891
- runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
892
890
  const currentAgents = discoverAgents(currentCtx.cwd, {
893
891
  scope: currentConfig.agentScope,
894
892
  enabledNames: currentConfig.enabledAgents,
@@ -971,7 +969,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
971
969
  let activeIdleTimeoutMs = runConfig.idleTimeoutSec * 1000;
972
970
  try {
973
971
  const startConfig = await loadConfig(runtime.configPath);
974
- runtime.backgroundQueue.setConcurrency(startConfig.maxConcurrency);
975
972
  const resolvedStart = resolveDispatchModelRoute(agent, startConfig, runCtx);
976
973
  activeRoute = isolation === "worktree"
977
974
  ? { ...resolvedStart, agent: withWorktreeSystemPrompt(resolvedStart.agent) }
@@ -1064,7 +1061,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
1064
1061
 
1065
1062
  if (thread.retireOnSettle) runtime.retireThreadSession(thread);
1066
1063
  let workflowOutcome: ManagedWorkflowOutcome | undefined;
1067
- const workflowPlan = getManagedWorkflowPlan(result, runConfig, workflowAvailability);
1064
+ const workflowPlan = getManagedWorkflowPlan(result, workflowAvailability);
1068
1065
  if (workflowPlan && runtime.sessionActive) {
1069
1066
  thread.state = "running";
1070
1067
  // The stable parent row now represents workflow ownership, not whichever