@ferris1225/pi-subagents 4.2.13 → 4.3.1

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/src/agents.ts CHANGED
@@ -15,7 +15,7 @@ import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from "no
15
15
  import { dirname, join } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
18
- import { THINKING_LEVEL_VALUES, type AgentScope, type ThinkingLevel } from "./config.ts";
18
+ import { type AgentScope } from "./config.ts";
19
19
  import type { IsolationMode } from "./worktree.ts";
20
20
 
21
21
  export type AgentSource = "builtin" | "user" | "project";
@@ -26,8 +26,6 @@ export interface AgentConfig {
26
26
  tools?: string[];
27
27
  /** Model ref this run was routed to; filled in by dispatch, never declared by the agent file. */
28
28
  model?: string;
29
- /** Per-agent default thinking strength (frontmatter `thinking`); config override wins. */
30
- thinking?: ThinkingLevel;
31
29
  /** Role-declared default isolation (frontmatter `isolation`); an explicit
32
30
  * per-call request wins, and `worktree` applies to write-capable roles only. */
33
31
  isolation?: IsolationMode;
@@ -41,7 +39,7 @@ const SHELL_TOOL_NAMES = new Set(["bash", "powershell"]);
41
39
  * Only used to break a tie when the parent has both enabled — a parent running a
42
40
  * single shell is followed as configured, whatever it is. */
43
41
  const NATIVE_SHELL_TOOL = process.platform === "win32" ? "powershell" : "bash";
44
- const PI_BUILTIN_TOOL_NAMES = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
42
+ const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "find", "ls"]);
45
43
  export const SUBAGENT_TOOL_NAMES = [
46
44
  "subagent",
47
45
  "subagent_control",
@@ -50,21 +48,26 @@ export const SUBAGENT_TOOL_NAMES = [
50
48
  const SUBAGENT_TOOL_NAME_SET = new Set<string>(SUBAGENT_TOOL_NAMES);
51
49
 
52
50
  /** Resolve every child against the parent's live tool selection. Roles without
53
- * an allowlist inherit the complete active set. Explicit lists keep only their
54
- * declared Pi built-ins, adapt an existing shell slot, and gain active extension/
55
- * SDK tools. pi-subagents controls are always removed so children stay leaves.
51
+ * an allowlist inherit the complete active set. Explicit lists are strict: they
52
+ * keep only declared tools that are active in the parent, with shell adaptation.
53
+ * Active extension/SDK tools are never added implicitly. pi-subagents controls
54
+ * are always removed so children stay leaves.
56
55
  *
57
56
  * A declared shell is one slot, so it resolves to one shell: the parent's, and
58
57
  * the host-native one when the parent runs both. A child never inherits a shell
59
- * the parent does not have — Pi's `--tools` allowlist overrides the child's own
60
- * `defaultTools` setting, so naming a shell the user disabled would hand it a
61
- * terminal they deliberately turned off. */
58
+ * the parent does not have. */
62
59
  export function resolveAgentTools(
63
60
  agent: AgentConfig,
64
61
  activeToolNames: readonly string[],
65
62
  ): AgentConfig {
66
63
  const active = [...new Set(activeToolNames)].filter((tool) => !SUBAGENT_TOOL_NAME_SET.has(tool));
67
- if (!agent.tools) return { ...agent, tools: active };
64
+ if (!agent.tools) {
65
+ return { ...agent, tools: agent.name === "scout" ? active.filter((tool) => READ_ONLY_TOOL_NAMES.has(tool)) : active };
66
+ }
67
+ const declaredTools = agent.name === "scout"
68
+ ? agent.tools.filter((tool) => READ_ONLY_TOOL_NAMES.has(tool))
69
+ : agent.tools;
70
+ const activeSet = new Set(active);
68
71
 
69
72
  const parentShellTools = active.filter((tool) => SHELL_TOOL_NAMES.has(tool));
70
73
  const activeShellTools = parentShellTools.length > 1 && parentShellTools.includes(NATIVE_SHELL_TOOL)
@@ -72,32 +75,30 @@ export function resolveAgentTools(
72
75
  : parentShellTools;
73
76
  const tools: string[] = [];
74
77
  let shellAdapted = false;
75
- for (const tool of agent.tools) {
78
+ for (const tool of declaredTools) {
79
+ if (SUBAGENT_TOOL_NAME_SET.has(tool)) continue;
76
80
  if (SHELL_TOOL_NAMES.has(tool)) {
77
81
  if (!shellAdapted) tools.push(...activeShellTools);
78
82
  shellAdapted = true;
79
- } else if (PI_BUILTIN_TOOL_NAMES.has(tool) && !tools.includes(tool)) {
83
+ } else if (activeSet.has(tool) && !tools.includes(tool)) {
80
84
  tools.push(tool);
81
85
  }
82
86
  }
83
- for (const tool of active) {
84
- if (PI_BUILTIN_TOOL_NAMES.has(tool) || tools.includes(tool)) continue;
85
- tools.push(tool);
86
- }
87
87
  return { ...agent, tools };
88
88
  }
89
89
 
90
90
  /** Filesystem-write capability used by worktree admission and repository-lane
91
- * safety. Built-in read-only role names remain read-only even when overridden;
92
- * an omitted tool list inherits the parent's active set, so it counts as
93
- * write-capable unless the parent itself is read-only. */
91
+ * safety. Scout is a hard read-only boundary even with a project override.
92
+ * Omitted allowlists inherit arbitrary active tools and are therefore mutable.
93
+ * For explicit allowlists, only the canonical retrieval tools are proven
94
+ * read-only; shells and unknown custom tools are conservatively mutable. */
94
95
  export function isWriteCapableAgent(
95
96
  agent: Pick<AgentConfig, "name" | "tools">,
96
97
  ): boolean {
97
- if (agent.name === "explorer") return false;
98
- if (agent.name === "executor") return true;
98
+ if (agent.name === "scout") return false;
99
+ if (agent.name === "artisan" || agent.name === "steward") return true;
99
100
  if (!agent.tools) return true;
100
- return agent.tools.includes("edit") || agent.tools.includes("write");
101
+ return agent.tools.some((tool) => !READ_ONLY_TOOL_NAMES.has(tool));
101
102
  }
102
103
 
103
104
  const here = dirname(fileURLToPath(import.meta.url));
@@ -145,10 +146,6 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
145
146
  ?.split(",")
146
147
  .map((t) => t.trim())
147
148
  .filter(Boolean);
148
- const rawThinking = str(frontmatter.thinking)?.trim();
149
- const thinking = (THINKING_LEVEL_VALUES as readonly string[]).includes(rawThinking ?? "")
150
- ? (rawThinking as ThinkingLevel)
151
- : undefined;
152
149
  const rawIsolation = str(frontmatter.isolation)?.trim();
153
150
  const isolation = rawIsolation === "worktree" || rawIsolation === "shared"
154
151
  ? (rawIsolation as IsolationMode)
@@ -158,7 +155,6 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
158
155
  name,
159
156
  description,
160
157
  tools: tools && tools.length > 0 ? tools : undefined,
161
- ...(thinking ? { thinking } : {}),
162
158
  ...(isolation ? { isolation } : {}),
163
159
  systemPrompt: body,
164
160
  source,
@@ -1,21 +1,16 @@
1
- /** Session-start recovery, stale-config migration, and progress-surface installation. */
1
+ /** Session-start recovery, stale-model cleanup, and progress-surface installation. */
2
2
 
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { existsSync } from "node:fs";
5
- import { loadConfig, saveConfig } from "./config.ts";
5
+ import { FIRST_RUN_SETUP_HINT, loadConfig, saveConfig } from "./config.ts";
6
6
  import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
7
- import { announceRecoveryRecords } from "./recovery.ts";
7
+ import { announceRecoveryRecords, relocateRecoveryManifest } from "./recovery.ts";
8
8
  import type { SubagentRuntime } from "./runtime.ts";
9
9
  import { installActiveRunsStatus } from "./status.ts";
10
10
  import { installActiveRunsWidget } from "./widget.ts";
11
11
 
12
- /**
13
- * One-time-per-stale-override migration: keep agent model selections Pi still
14
- * reports as available, drop the rest back to dynamic main-model routing, and
15
- * tell the user what was removed. Saving the cleaned config is what makes it
16
- * one-time — the dropped refs no longer exist to re-trigger the notice.
17
- */
18
- async function migrateUnavailableAgentModels(
12
+ /** Drop unavailable model overrides back to dynamic main-model routing. */
13
+ async function removeUnavailableAgentModels(
19
14
  ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } } & Parameters<typeof availableModelsInScope>[0],
20
15
  runtime: SubagentRuntime,
21
16
  ): Promise<void> {
@@ -32,22 +27,20 @@ async function migrateUnavailableAgentModels(
32
27
  "warning",
33
28
  );
34
29
  } catch {
35
- /* migration failures are non-fatal */
30
+ /* stale-model cleanup is non-fatal */
36
31
  }
37
32
  }
38
33
 
39
34
  export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime): void {
40
35
  pi.on("session_start", async (_event, ctx) => {
41
36
  if (!existsSync(runtime.configPath)) {
42
- ctx.ui.notify(
43
- "pi-subagents: no configuration yet — run /subagents-setup to pick agents, models, and thinking strengths. Defaults (all built-in agents on the main model) apply until then.",
44
- "info",
45
- );
37
+ ctx.ui.notify(`pi-subagents: ${FIRST_RUN_SETUP_HINT}`, "info");
46
38
  }
39
+ await relocateRecoveryManifest(runtime.configPath);
47
40
  await announceRecoveryRecords(runtime.configPath, ctx);
48
- await migrateUnavailableAgentModels(ctx, runtime);
49
- // Restore starts at extension load and session_start fires right behind
50
- // it, so without this the notice reports whatever the race left behind.
41
+ await removeUnavailableAgentModels(ctx, runtime);
42
+ // Bootstrap also runs on session_start; wait so this notice sees restored
43
+ // threads instead of racing an empty list.
51
44
  await runtime.durableRestore;
52
45
  if (!runtime.restoredNotified && runtime.restoredRunIds.length > 0) {
53
46
  runtime.restoredNotified = true;
package/src/background.ts CHANGED
@@ -14,6 +14,7 @@ import { cpus } from "node:os";
14
14
  export type BackgroundTask = (signal: AbortSignal, controller: AbortController) => Promise<void>;
15
15
 
16
16
  interface PendingTask {
17
+ kind: "task";
17
18
  task: BackgroundTask;
18
19
  controller: AbortController;
19
20
  complete: () => void;
@@ -29,6 +30,14 @@ interface PendingTask {
29
30
  onError?: (error: unknown) => void | Promise<void>;
30
31
  }
31
32
 
33
+ interface PendingAcquire {
34
+ kind: "acquire";
35
+ controller: AbortController;
36
+ resolve: (acquired: boolean) => void;
37
+ }
38
+
39
+ type PendingEntry = PendingTask | PendingAcquire;
40
+
32
41
  /** How many sub-agent processes may run at once, derived from the host instead
33
42
  * of being fixed: children wait on model I/O far more than on CPU, so the pool
34
43
  * scales with cores while the bounds keep tiny machines usable and huge ones
@@ -40,7 +49,7 @@ export function resolveSubagentConcurrency(cpuCount: number = cpus().length): nu
40
49
 
41
50
  export class BackgroundTaskQueue {
42
51
  private concurrency: number;
43
- private readonly pending: PendingTask[] = [];
52
+ private readonly pending: PendingEntry[] = [];
44
53
  private readonly active = new Set<AbortController>();
45
54
  /** Active tasks that no longer count toward the concurrency limit. They keep
46
55
  * every other guarantee: abortable, awaited by waitForTask/waitForIdle. */
@@ -67,7 +76,7 @@ export class BackgroundTaskQueue {
67
76
  return controller;
68
77
  }
69
78
 
70
- this.pending.push({ task, controller, complete, onCancelled, onError });
79
+ this.pending.push({ kind: "task", task, controller, complete, onCancelled, onError });
71
80
  this.drain();
72
81
  return controller;
73
82
  }
@@ -88,7 +97,7 @@ export class BackgroundTaskQueue {
88
97
 
89
98
  /** Tasks still waiting for a free slot (never started). */
90
99
  get pendingCount(): number {
91
- return this.pending.length;
100
+ return this.pending.filter((entry) => entry.kind === "task").length;
92
101
  }
93
102
 
94
103
  /** Tasks currently holding a slot. Suspended tasks (lane waits, managed
@@ -114,6 +123,19 @@ export class BackgroundTaskQueue {
114
123
  this.drain();
115
124
  }
116
125
 
126
+ /** Reacquire a released process slot before a suspended task starts another
127
+ * child process. Reacquisitions share FIFO order with not-yet-started tasks,
128
+ * so lane waiters cannot bypass work already queued for the pool. */
129
+ acquire(controller: AbortController | undefined): Promise<boolean> {
130
+ if (!controller || this.stopped || controller.signal.aborted) return Promise.resolve(false);
131
+ if (this.active.has(controller)) return Promise.resolve(true);
132
+ if (!this.suspended.has(controller)) return Promise.resolve(false);
133
+ return new Promise<boolean>((resolve) => {
134
+ this.pending.push({ kind: "acquire", controller, resolve });
135
+ this.drain();
136
+ });
137
+ }
138
+
117
139
  /** Cancel one queued/running task. Queued entries are removed immediately;
118
140
  * active entries resolve waitForTask only after their body and error handler
119
141
  * have quiesced and the concurrency slot has been released. */
@@ -123,8 +145,13 @@ export class BackgroundTaskQueue {
123
145
  const index = this.pending.findIndex((entry) => entry.controller === controller);
124
146
  if (index !== -1) {
125
147
  const [entry] = this.pending.splice(index, 1);
126
- this.runCancelled(entry.onCancelled);
127
- entry.complete();
148
+ if (entry.kind === "task") {
149
+ this.runCancelled(entry.onCancelled);
150
+ entry.complete();
151
+ } else {
152
+ this.suspended.delete(controller);
153
+ entry.resolve(false);
154
+ }
128
155
  this.drain();
129
156
  this.resolveIdleWaiters();
130
157
  }
@@ -143,8 +170,13 @@ export class BackgroundTaskQueue {
143
170
 
144
171
  for (const entry of this.pending.splice(0)) {
145
172
  entry.controller.abort();
146
- this.runCancelled(entry.onCancelled);
147
- entry.complete();
173
+ if (entry.kind === "task") {
174
+ this.runCancelled(entry.onCancelled);
175
+ entry.complete();
176
+ } else {
177
+ this.suspended.delete(entry.controller);
178
+ entry.resolve(false);
179
+ }
148
180
  }
149
181
  for (const controller of this.active) controller.abort();
150
182
  for (const controller of this.suspended) controller.abort();
@@ -170,8 +202,23 @@ export class BackgroundTaskQueue {
170
202
  return;
171
203
  }
172
204
  if (entry.controller.signal.aborted) {
173
- this.runCancelled(entry.onCancelled);
174
- entry.complete();
205
+ if (entry.kind === "task") {
206
+ this.runCancelled(entry.onCancelled);
207
+ entry.complete();
208
+ } else {
209
+ this.suspended.delete(entry.controller);
210
+ entry.resolve(false);
211
+ }
212
+ continue;
213
+ }
214
+
215
+ if (entry.kind === "acquire") {
216
+ if (!this.suspended.delete(entry.controller)) {
217
+ entry.resolve(false);
218
+ continue;
219
+ }
220
+ this.active.add(entry.controller);
221
+ entry.resolve(true);
175
222
  continue;
176
223
  }
177
224
 
package/src/completion.ts CHANGED
@@ -98,7 +98,6 @@ export function createCompletionBatcher<T>(options: CompletionBatcherOptions<T>)
98
98
  export interface CompletionMessageItem {
99
99
  agent: string;
100
100
  block: string;
101
- triggerTurn: boolean;
102
101
  /** Final usage of the underlying run (or chain); aggregated into the group totals. */
103
102
  usage?: UsageStats;
104
103
  }
@@ -115,11 +114,6 @@ export function formatCompletionMessage(items: readonly CompletionMessageItem[])
115
114
  return `### Subagents completed (${items.length}): ${agents}\n\n${items.map((item) => item.block).join("\n\n")}${footer}`;
116
115
  }
117
116
 
118
- /** A grouped completion wakes the main agent when any member requires a turn. */
119
- export function completionGroupTriggersTurn(items: readonly CompletionMessageItem[]): boolean {
120
- return items.some((item) => item.triggerTurn);
121
- }
122
-
123
117
  /** Minimal shape of an active run, for the "others still running" footer. Kept
124
118
  * decoupled from the monitor's RunView so this stays a pure, easily tested
125
119
  * formatter; the caller maps its live runs into this shape. */
package/src/config.ts CHANGED
@@ -13,14 +13,7 @@ import { dirname, join } from "node:path";
13
13
  import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
14
14
 
15
15
  /** Full catalog of agents shipped with the package (selectable in /subagents-setup). */
16
- export const BUILTIN_AGENT_NAMES = ["explorer", "executor"] as const;
17
-
18
- /** Built-in agent names this package no longer ships. Loading an older config
19
- * prunes them from every record so the setup wizard, dispatch catalog, and
20
- * model-routing table never surface dead roles. Custom names stay untouched —
21
- * except one that reuses a removed built-in name, which this cleanup cannot
22
- * distinguish and deliberately treats as retired. */
23
- export const REMOVED_BUILTIN_AGENT_NAMES = ["worker", "cleaner", "documenter", "synthesizer", "reviewer"] as const;
16
+ export const BUILTIN_AGENT_NAMES = ["scout", "artisan", "steward"] as const;
24
17
 
25
18
  /** Agents enabled out of the box on a fresh install. */
26
19
  export const DEFAULT_ENABLED_AGENTS: readonly string[] = [...BUILTIN_AGENT_NAMES];
@@ -31,7 +24,49 @@ export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
31
24
  /** Thinking levels accepted by pi's `--thinking` option. */
32
25
  export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
33
26
  export type ThinkingLevel = (typeof THINKING_LEVEL_VALUES)[number];
34
- export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "high";
27
+ export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium";
28
+
29
+ /** Role-owned default reasoning strength. A `/subagents-setup` override wins;
30
+ * there is no per-call or frontmatter thinking. */
31
+ export function roleThinkingLevel(agentName: string): ThinkingLevel {
32
+ switch (agentName) {
33
+ case "scout":
34
+ return "low";
35
+ case "artisan":
36
+ return "high";
37
+ case "steward":
38
+ return "medium";
39
+ default:
40
+ return DEFAULT_THINKING_LEVEL;
41
+ }
42
+ }
43
+
44
+ /** Short responsibility line shown next to each built-in in setup lists. */
45
+ export interface AgentProfile {
46
+ /** A few words for picker rows. */
47
+ summary: string;
48
+ /** What this role owns, for first-run copy and the configure step. */
49
+ remark: string;
50
+ }
51
+
52
+ export const AGENT_PROFILES: Record<(typeof BUILTIN_AGENT_NAMES)[number], AgentProfile> = {
53
+ scout: {
54
+ summary: "read-only recon",
55
+ remark: "Broad or unknown reconnaissance. Returns decisive citations as leads, never proof.",
56
+ },
57
+ artisan: {
58
+ summary: "implement / fix",
59
+ remark: "Owns implementation, code refactors, and directly affected tests/docs; a disproved defect means zero edits.",
60
+ },
61
+ steward: {
62
+ summary: "pre-commit finish",
63
+ remark: "Cleans a completed broad or multi-writer diff and synchronizes cross-cutting docs/comments without changing behavior.",
64
+ },
65
+ };
66
+
67
+ export function agentProfile(name: string): AgentProfile | undefined {
68
+ return (AGENT_PROFILES as Record<string, AgentProfile | undefined>)[name];
69
+ }
35
70
 
36
71
  /** How many lines of a sub-agent result the completion message may carry.
37
72
  * Default: 40 — wide fan-outs multiply completion blocks, so deliveries stay
@@ -54,14 +89,10 @@ export const IDLE_TIMEOUT_SEC_LIMIT = 600;
54
89
  export interface SubagentsConfig {
55
90
  /** Agent names that are discoverable and injected. Fresh-install default: every built-in agent. */
56
91
  enabledAgents: string[];
57
- /** Built-in names this config has already surfaced. A shipped agent outside
58
- * this set is new in an upgrade: loadConfig enables it instead of leaving it
59
- * dark behind a stale allow-list. Bookkeeping only — maintained automatically,
60
- * and it is what keeps an explicit disable from being undone. */
61
- knownAgents: string[];
62
92
  /** Per-agent model override, keyed by agent name, as "provider/model-id". */
63
93
  agentModels: Record<string, string>;
64
- /** Optional per-agent thinking preference. Runtime clamps it to the effective model's supported levels. */
94
+ /** Optional per-agent thinking override from `/subagents-setup`. Missing =
95
+ * the role default from `roleThinkingLevel`. */
65
96
  agentThinkingLevels: Record<string, ThinkingLevel>;
66
97
  /**
67
98
  * Max lines of a sub-agent result carried in the completion message. Longer
@@ -81,7 +112,6 @@ export interface SubagentsConfig {
81
112
 
82
113
  export const DEFAULT_CONFIG: SubagentsConfig = {
83
114
  enabledAgents: [...DEFAULT_ENABLED_AGENTS],
84
- knownAgents: [...BUILTIN_AGENT_NAMES],
85
115
  agentModels: {},
86
116
  agentThinkingLevels: {},
87
117
  maxResultLines: DEFAULT_MAX_RESULT_LINES,
@@ -89,6 +119,12 @@ export const DEFAULT_CONFIG: SubagentsConfig = {
89
119
  idleTimeoutSec: DEFAULT_IDLE_TIMEOUT_SEC,
90
120
  };
91
121
 
122
+ export const FIRST_RUN_SETUP_HINT =
123
+ "Run /subagents-setup to choose enabled roles and pick their models. " +
124
+ "Keep orchestration on the strongest main model; prefer an efficient model for scout and steward. " +
125
+ "Scout handles clustered broad reconnaissance, artisan implements with affected tests/docs, " +
126
+ "and steward finishes completed broad or multi-writer changes before commit.";
127
+
92
128
  export function getConfigPath(agentDir: string = getAgentDir()): string {
93
129
  return join(agentDir, CONFIG_FILE_NAME);
94
130
  }
@@ -130,20 +166,6 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
130
166
  config.enabledAgents = [...new Set(names.map((name) => name.trim()))];
131
167
  }
132
168
 
133
- // Known-agent bookkeeping starts empty for a parsed record (not the fresh
134
- // default) so loadConfig can still tell which shipped agents this config
135
- // has never seen. Every enabled name was necessarily surfaced.
136
- config.knownAgents = [];
137
- if (Array.isArray(raw.knownAgents)) {
138
- const names = raw.knownAgents.filter(
139
- (name): name is string => typeof name === "string" && name.trim().length > 0,
140
- );
141
- config.knownAgents = [...new Set(names.map((name) => name.trim()))];
142
- }
143
- for (const name of config.enabledAgents) {
144
- if (!config.knownAgents.includes(name)) config.knownAgents.push(name);
145
- }
146
-
147
169
  if (isRecord(raw.agentModels)) {
148
170
  for (const [rawKey, value] of Object.entries(raw.agentModels)) {
149
171
  const key = rawKey.trim();
@@ -190,63 +212,10 @@ function defaultConfig(): SubagentsConfig {
190
212
  };
191
213
  }
192
214
 
193
- /**
194
- * Drop every removed built-in role from an already-normalized config: enabled
195
- * and known lists, plus per-agent model and thinking routes. The schema-upgrade
196
- * persistence in loadConfig writes the pruned shape back to disk.
197
- */
198
- function pruneRemovedBuiltins(config: SubagentsConfig): SubagentsConfig {
199
- const removed = new Set<string>(REMOVED_BUILTIN_AGENT_NAMES);
200
- const filter = (names: readonly string[]): string[] => names.filter((name) => !removed.has(name));
201
- const agentModels = { ...config.agentModels };
202
- const agentThinkingLevels = { ...config.agentThinkingLevels };
203
- for (const name of REMOVED_BUILTIN_AGENT_NAMES) {
204
- delete agentModels[name];
205
- delete agentThinkingLevels[name];
206
- }
207
- return {
208
- ...config,
209
- enabledAgents: filter(config.enabledAgents),
210
- knownAgents: filter(config.knownAgents),
211
- agentModels,
212
- agentThinkingLevels,
213
- };
214
- }
215
-
216
- /**
217
- * A shipped agent the config has never recorded is new in this release; the
218
- * stale allow-list must not keep it dark. Enable it and adopt explorer's
219
- * configured model and thinking level, so an upgrade surfaces the new role on
220
- * the fast light-task lane instead of silently spending the main model.
221
- */
222
- function adoptNewBuiltins(config: SubagentsConfig): SubagentsConfig {
223
- const known = new Set(config.knownAgents);
224
- const fresh = BUILTIN_AGENT_NAMES.filter((name) => !known.has(name));
225
- if (fresh.length === 0) return config;
226
- const agentModels = { ...config.agentModels };
227
- const agentThinkingLevels = { ...config.agentThinkingLevels };
228
- for (const name of fresh) {
229
- if (!agentModels[name] && config.agentModels.explorer) agentModels[name] = config.agentModels.explorer;
230
- if (!agentThinkingLevels[name] && config.agentThinkingLevels.explorer) {
231
- agentThinkingLevels[name] = config.agentThinkingLevels.explorer;
232
- }
233
- }
234
- return {
235
- ...config,
236
- enabledAgents: [...config.enabledAgents, ...fresh],
237
- knownAgents: [...known, ...fresh],
238
- agentModels,
239
- agentThinkingLevels,
240
- };
241
- }
242
-
243
215
  /**
244
216
  * Load config. A missing file is a normal state and yields the defaults (not an error).
245
217
  * A corrupt file also falls back to defaults rather than throwing, so startup never breaks.
246
- * A file from an older version (missing newer keys or holding extra keys) is
247
- * normalized and persisted back, so the on-disk config stays current. Built-in
248
- * agents the file has never seen are adopted: enabled with explorer's route.
249
- * Built-in roles this package retired are pruned from every record.
218
+ * Valid fields are normalized and unknown fields are omitted when the config is saved.
250
219
  */
251
220
  export async function loadConfig(configPath: string = getConfigPath()): Promise<SubagentsConfig> {
252
221
  let text: string;
@@ -264,13 +233,9 @@ export async function loadConfig(configPath: string = getConfigPath()): Promise<
264
233
  return defaultConfig();
265
234
  }
266
235
 
267
- // Adopt newly shipped roles first so the prune below works on the final
268
- // catalog, then drop roles this package stopped shipping and persist the
269
- // cleaned shape back to disk.
270
- const config = pruneRemovedBuiltins(adoptNewBuiltins(normalizeConfig(parsed)));
236
+ const config = normalizeConfig(parsed);
271
237
 
272
- // Schema upgrade: persist the normalized shape when the file gained fields
273
- // (new version) or dropped invalid ones.
238
+ // Persist the canonical shape when invalid or unknown fields were omitted.
274
239
  if (JSON.stringify(config) !== JSON.stringify(parsed)) {
275
240
  try {
276
241
  await saveConfig(config, configPath);