@ferris1225/pi-subagents 4.1.7 → 4.1.9

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/agents/worker.md CHANGED
@@ -7,38 +7,20 @@ thinking: high
7
7
  # balance quality against cost. No `tools` field => inherits all tools (full capability).
8
8
  ---
9
9
 
10
- 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.
10
+ You are a worker agent with full capabilities 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.
11
11
 
12
- ## Standard operating procedure
13
- Work in phases. Do not skip planning or verification.
12
+ ## Procedure
13
+ 1. **Context.** Read the brief fully. Read referenced files before editing, and referenced images (screenshots, mockups, designs) too — the model receives them as attachments when it supports vision. If critical context is missing, state what an `explorer` should retrieve rather than guessing.
14
+ 2. **Plan.** Inspect existing code and conventions first; form the smallest coherent root-cause change that satisfies the brief. Prefer the design that deletes complexity rather than rearranges it — if a simpler structure (fewer branches, modes, or layers) achieves the goal, take it, and decompose rather than push a file toward ~1000 lines. For a large task, note files to touch, order, and risks before editing. No unrelated refactors or standalone documentation work unless the brief asks.
15
+ 3. **Implement.** Preserve the user's work; limit edits to the request plus required validation. Follow the project's error handling, naming, and style. Synchronize existing README/docs, examples, and comments directly affected by your change; do not defer obvious drift to another role.
16
+ 4. **Verify.** 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 a pre-existing failure, with the exact error.
17
+ 5. **Handoff.** Return only the concrete outcome. Do not repeat the task brief, plan, root-cause investigation, or tool chronology. Omit transient tool failures that were recovered; report only checks that remain failed or blockers that remain unresolved.
14
18
 
15
- ### Phase 1 — Context
16
- Read the brief fully. If it references files, read them before editing. If it references images (screenshots, mockups, designs), `read` them too — the model receives them as attachments when it supports vision. If critical context is clearly missing, state what an `explorer` should retrieve rather than guessing.
17
-
18
- ### Phase 2Plan
19
- 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 start broad/standalone documentation work unless the brief asks.
20
-
21
- ### Phase 3 — Implement
22
- 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. Synchronize any existing README/docs, examples, API comments, docstrings, and explanatory comments directly affected by your change; do not defer obvious drift to another role.
23
-
24
- ### Phase 4 — Verify
25
- 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.
26
-
27
- ### Phase 5 — Handoff
28
- Return only the concrete outcome so the caller can verify it and, if needed, hand it to a `reviewer`. Do not repeat the task brief, plan, root-cause investigation, or tool chronology. Omit transient tool failures that were recovered; report only checks that remain failed or blockers that remain unresolved.
29
-
30
- ## Release boundary
31
- Never commit, push, publish, tag, release, or bump a package version. The parent workflow owns the independent review gate, any conditional final documentation sync, and every release action—even when repository instructions normally automate release after green checks.
32
-
33
- ## Collaboration
34
- - You cannot dispatch sub-agents (children are leaf processes with no `subagent` tool). When the
35
- brief lacks context that needs broad code discovery, state concretely what an `explorer` should
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.
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.
19
+ ## Boundaries
20
+ - Never commit, push, publish, tag, release, or bump a package version. The parent workflow owns the independent review gate, the conditional final documentation sync, and every release action even when repository instructions normally automate release after green checks.
21
+ - Children are leaf processes: you cannot dispatch sub-agents. When the brief needs broad discovery, state what an `explorer` should retrieve; do not guess.
22
+ - In an auto-fix round, apply the reviewer's fix instructions: implement each when it is sound; when it is wrong, out of scope, or a sounder fix exists, ship your fix and push back in your report cite the finding, refute the instruction's reasoning, and describe what you shipped instead. A deviation without reasoning will be re-opened.
23
+ - Do not ask the caller to duplicate downstream roles, and never treat your own verification as the final gate.
42
24
 
43
25
  ## Output format
44
26
  ## Completed
@@ -48,9 +30,8 @@ What was done, in a few lines.
48
30
  ## Verification
49
31
  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.
50
32
  ## Notes (only when material)
51
- Unresolved blockers, rejected requirements, or decisions the caller must know. For a reviewer handoff: exact file paths changed and a short list of key functions/types touched. Omit the section when there is nothing actionable to add.
33
+ Unresolved blockers, rejected requirements, or decisions the caller must know. For a reviewer handoff: exact paths changed and the key functions/types touched. Omit the section when there is nothing actionable to add.
52
34
 
53
35
  Keep the final response comfortably below the 80-line delivery cap unless the result genuinely requires more.
54
36
 
55
- ## Quality standards
56
- Root-cause fixes over patches. No unrelated churn. Honest verification — an unrun check is never a passed check.
37
+ Root-cause fixes over patches; no unrelated churn; an unrun check is never a passed check.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "4.1.7",
3
+ "version": "4.1.9",
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),
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",
68
32
  );
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,
76
- );
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,16 @@ 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);
43
+ if (!runtime.restoredNotified && runtime.restoredRunIds.length > 0) {
44
+ runtime.restoredNotified = true;
45
+ const ids = runtime.restoredRunIds.map((id) => `#${id}`).join(", ");
46
+ ctx.ui.notify(
47
+ `pi-subagents: restored ${runtime.restoredRunIds.length} resumable thread${runtime.restoredRunIds.length === 1 ? "" : "s"} from the previous session (${ids}). subagent_status lists them; subagent_control resume continues one.`,
48
+ "info",
49
+ );
50
+ }
87
51
  if (ctx.mode !== "tui") return;
88
52
  installActiveRunsWidget(ctx);
89
- await announceNewFeatures(ctx, runtime);
90
53
  });
91
54
  }
package/src/background.ts CHANGED
@@ -27,10 +27,21 @@ 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. Only manually
33
+ * dispatched top-level generations hold slots; runtime-initiated managed
34
+ * continuations (gate reviews, auto-fix rounds, documentation sync) suspend
35
+ * their task's slot so they never starve manual dispatches. */
36
+ export const MAX_CONCURRENT_SUBAGENTS = 4;
37
+
30
38
  export class BackgroundTaskQueue {
31
39
  private concurrency: number;
32
40
  private readonly pending: PendingTask[] = [];
33
41
  private readonly active = new Set<AbortController>();
42
+ /** Active tasks that no longer count toward the concurrency limit. They keep
43
+ * every other guarantee: abortable, awaited by waitForTask/waitForIdle. */
44
+ private readonly suspended = new Set<AbortController>();
34
45
  private readonly completions = new WeakMap<AbortController, Promise<void>>();
35
46
  private readonly idleWaiters = new Set<() => void>();
36
47
  private stopped = false;
@@ -39,16 +50,6 @@ export class BackgroundTaskQueue {
39
50
  this.concurrency = Math.max(1, concurrency);
40
51
  }
41
52
 
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
53
  enqueue(task: BackgroundTask, onCancelled?: () => void, onError?: (error: unknown) => void | Promise<void>): AbortController {
53
54
  const controller = new AbortController();
54
55
  let complete!: () => void;
@@ -76,6 +77,16 @@ export class BackgroundTaskQueue {
76
77
  return this.completions.get(controller) ?? Promise.resolve();
77
78
  }
78
79
 
80
+ /** Stop counting a running task toward the concurrency limit. Its body keeps
81
+ * running under the same abort signal; completion still releases everything
82
+ * waitForTask/waitForIdle promise. Frees a slot for queued work immediately. */
83
+ suspend(controller: AbortController | undefined): void {
84
+ if (!controller || this.stopped) return;
85
+ if (!this.active.delete(controller)) return;
86
+ this.suspended.add(controller);
87
+ this.drain();
88
+ }
89
+
79
90
  /** Cancel one queued/running task. Queued entries are removed immediately;
80
91
  * active entries resolve waitForTask only after their body and error handler
81
92
  * have quiesced and the concurrency slot has been released. */
@@ -94,7 +105,7 @@ export class BackgroundTaskQueue {
94
105
 
95
106
  /** Resolve once no queued or running task remains. */
96
107
  waitForIdle(): Promise<void> {
97
- if (this.pending.length === 0 && this.active.size === 0) return Promise.resolve();
108
+ if (this.pending.length === 0 && this.active.size === 0 && this.suspended.size === 0) return Promise.resolve();
98
109
  return new Promise<void>((resolve) => this.idleWaiters.add(resolve));
99
110
  }
100
111
 
@@ -109,6 +120,7 @@ export class BackgroundTaskQueue {
109
120
  entry.complete();
110
121
  }
111
122
  for (const controller of this.active) controller.abort();
123
+ for (const controller of this.suspended) controller.abort();
112
124
  this.resolveIdleWaiters();
113
125
  }
114
126
 
@@ -150,6 +162,7 @@ export class BackgroundTaskQueue {
150
162
  })
151
163
  .finally(() => {
152
164
  this.active.delete(entry.controller);
165
+ this.suspended.delete(entry.controller);
153
166
  entry.complete();
154
167
  this.drain();
155
168
  this.resolveIdleWaiters();
@@ -158,7 +171,7 @@ export class BackgroundTaskQueue {
158
171
  }
159
172
 
160
173
  private resolveIdleWaiters(): void {
161
- if (this.pending.length > 0 || this.active.size > 0) return;
174
+ if (this.pending.length > 0 || this.active.size > 0 || this.suspended.size > 0) return;
162
175
  for (const resolve of this.idleWaiters) resolve();
163
176
  this.idleWaiters.clear();
164
177
  }
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.