@ferris1225/pi-subagents 4.2.8 → 4.2.13

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/config.ts CHANGED
@@ -1,308 +1,308 @@
1
- /**
2
- * Configuration load/save for pi-subagents.
3
- *
4
- * Config lives at <agentDir>/pi-subagents.json (agentDir defaults to ~/.pi/agent
5
- * and honors PI_CODING_AGENT_DIR). Parsing is defensive: invalid fields fall back
6
- * to defaults instead of throwing, so a hand-edited or partially-written file can
7
- * never break the extension at runtime. Unknown keys from older versions are
8
- * dropped and the normalized shape persisted back on load.
9
- */
10
-
11
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
12
- import { dirname, join } from "node:path";
13
- import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
14
-
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;
24
-
25
- /** Agents enabled out of the box on a fresh install. */
26
- export const DEFAULT_ENABLED_AGENTS: readonly string[] = [...BUILTIN_AGENT_NAMES];
27
-
28
- export const AGENT_SCOPE_VALUES = ["user", "project", "both"] as const;
29
- export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
30
-
31
- /** Thinking levels accepted by pi's `--thinking` option. */
32
- export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
33
- export type ThinkingLevel = (typeof THINKING_LEVEL_VALUES)[number];
34
- export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "high";
35
-
36
- /** How many lines of a sub-agent result the completion message may carry.
37
- * Default: 40 — wide fan-outs multiply completion blocks, so deliveries stay
38
- * compact and the full text lives in the on-disk result artifact. */
39
- export const DEFAULT_MAX_RESULT_LINES = 40;
40
- /** Upper bound accepted for maxResultLines (defensive clamp). */
41
- export const MAX_RESULT_LINES_LIMIT = 2000;
42
-
43
- const CONFIG_FILE_NAME = "pi-subagents.json";
44
-
45
- /**
46
- * Default idle timeout in seconds: a sub-agent whose stdout (JSON event stream)
47
- * goes silent for this long is terminated; a selected model then hands the
48
- * retained session to current main. 0 disables the watchdog. Default: 90.
49
- */
50
- export const DEFAULT_IDLE_TIMEOUT_SEC = 90;
51
- /** Upper bound accepted for idleTimeoutSec (defensive clamp). 0 disables. */
52
- export const IDLE_TIMEOUT_SEC_LIMIT = 600;
53
-
54
- export interface SubagentsConfig {
55
- /** Agent names that are discoverable and injected. Fresh-install default: every built-in agent. */
56
- 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
- /** Per-agent model override, keyed by agent name, as "provider/model-id". */
63
- agentModels: Record<string, string>;
64
- /** Optional per-agent thinking preference. Runtime clamps it to the effective model's supported levels. */
65
- agentThinkingLevels: Record<string, ThinkingLevel>;
66
- /**
67
- * Max lines of a sub-agent result carried in the completion message. Longer
68
- * results are truncated; the full text is written to a temp file whose path
69
- * is included in the message. Default: 80.
70
- */
71
- maxResultLines: number;
72
- /** Which agent directories to discover from. Default: "user". */
73
- agentScope: AgentScope;
74
- /**
75
- * Idle timeout in seconds: a sub-agent whose stdout (JSON event stream) goes
76
- * silent for this long is terminated; a configured agent model then hands
77
- * off to the current main model. 0 disables the idle watchdog. Default: 90.
78
- */
79
- idleTimeoutSec: number;
80
- }
81
-
82
- export const DEFAULT_CONFIG: SubagentsConfig = {
83
- enabledAgents: [...DEFAULT_ENABLED_AGENTS],
84
- knownAgents: [...BUILTIN_AGENT_NAMES],
85
- agentModels: {},
86
- agentThinkingLevels: {},
87
- maxResultLines: DEFAULT_MAX_RESULT_LINES,
88
- agentScope: "user",
89
- idleTimeoutSec: DEFAULT_IDLE_TIMEOUT_SEC,
90
- };
91
-
92
- export function getConfigPath(agentDir: string = getAgentDir()): string {
93
- return join(agentDir, CONFIG_FILE_NAME);
94
- }
95
-
96
- function isRecord(value: unknown): value is Record<string, unknown> {
97
- return typeof value === "object" && value !== null && !Array.isArray(value);
98
- }
99
-
100
- function isAgentScope(value: unknown): value is AgentScope {
101
- return typeof value === "string" && (AGENT_SCOPE_VALUES as readonly string[]).includes(value);
102
- }
103
-
104
- function isModelReference(value: unknown): value is string {
105
- if (typeof value !== "string") return false;
106
- const normalized = value.trim();
107
- const slash = normalized.indexOf("/");
108
- return slash > 0 && slash < normalized.length - 1 && !/\s/u.test(normalized);
109
- }
110
-
111
- /** Clamp a raw value to a positive integer within [1, upper]; undefined when invalid. */
112
- function clampCount(value: unknown, upper: number): number | undefined {
113
- if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
114
- return Math.max(1, Math.min(upper, Math.round(value)));
115
- }
116
-
117
- /**
118
- * Merge a raw parsed JSON value over the defaults, dropping invalid fields.
119
- * Exported for tests.
120
- */
121
- export function normalizeConfig(raw: unknown): SubagentsConfig {
122
- const config = defaultConfig();
123
- if (!isRecord(raw)) return config;
124
-
125
- if (Array.isArray(raw.enabledAgents)) {
126
- const names = raw.enabledAgents.filter(
127
- (name): name is string => typeof name === "string" && name.trim().length > 0,
128
- );
129
- // An explicitly empty array is honored; duplicates collapse.
130
- config.enabledAgents = [...new Set(names.map((name) => name.trim()))];
131
- }
132
-
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
- if (isRecord(raw.agentModels)) {
148
- for (const [rawKey, value] of Object.entries(raw.agentModels)) {
149
- const key = rawKey.trim();
150
- if (key !== "" && isModelReference(value)) {
151
- config.agentModels[key] = value.trim();
152
- }
153
- }
154
- }
155
-
156
- if (isRecord(raw.agentThinkingLevels)) {
157
- for (const [rawKey, value] of Object.entries(raw.agentThinkingLevels)) {
158
- const key = rawKey.trim();
159
- if (
160
- key !== "" &&
161
- typeof value === "string" &&
162
- (THINKING_LEVEL_VALUES as readonly string[]).includes(value)
163
- ) {
164
- config.agentThinkingLevels[key] = value as ThinkingLevel;
165
- }
166
- }
167
- }
168
-
169
- const maxResultLines = clampCount(raw.maxResultLines, MAX_RESULT_LINES_LIMIT);
170
- if (maxResultLines !== undefined) config.maxResultLines = maxResultLines;
171
-
172
- if (isAgentScope(raw.agentScope)) {
173
- config.agentScope = raw.agentScope;
174
- }
175
-
176
- // 0 disables the idle watchdog; otherwise clamp to [0, upper].
177
- if (typeof raw.idleTimeoutSec === "number" && Number.isFinite(raw.idleTimeoutSec)) {
178
- config.idleTimeoutSec = Math.max(0, Math.min(IDLE_TIMEOUT_SEC_LIMIT, Math.round(raw.idleTimeoutSec)));
179
- }
180
-
181
- return config;
182
- }
183
-
184
- function defaultConfig(): SubagentsConfig {
185
- return {
186
- ...DEFAULT_CONFIG,
187
- enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
188
- agentModels: {},
189
- agentThinkingLevels: {},
190
- };
191
- }
192
-
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
- /**
244
- * Load config. A missing file is a normal state and yields the defaults (not an error).
245
- * 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.
250
- */
251
- export async function loadConfig(configPath: string = getConfigPath()): Promise<SubagentsConfig> {
252
- let text: string;
253
- try {
254
- text = await readFile(configPath, "utf8");
255
- } catch {
256
- // Missing or unreadable: fall back to defaults but do not crash startup.
257
- return defaultConfig();
258
- }
259
-
260
- let parsed: unknown;
261
- try {
262
- parsed = JSON.parse(text);
263
- } catch {
264
- return defaultConfig();
265
- }
266
-
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)));
271
-
272
- // Schema upgrade: persist the normalized shape when the file gained fields
273
- // (new version) or dropped invalid ones.
274
- if (JSON.stringify(config) !== JSON.stringify(parsed)) {
275
- try {
276
- await saveConfig(config, configPath);
277
- } catch {
278
- // Non-fatal: keep the in-memory config for this run.
279
- }
280
- }
281
-
282
- return config;
283
- }
284
-
285
- /**
286
- * Save config atomically (temp file + rename) serialized through pi's per-file
287
- * mutation queue so concurrent writers cannot interleave.
288
- */
289
- export async function saveConfig(
290
- config: SubagentsConfig,
291
- configPath: string = getConfigPath(),
292
- ): Promise<void> {
293
- const normalized = normalizeConfig(config);
294
- await mkdir(dirname(configPath), { recursive: true });
295
- await withFileMutationQueue(configPath, async () => {
296
- const temporaryPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
297
- try {
298
- await writeFile(temporaryPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
299
- await rename(temporaryPath, configPath);
300
- } finally {
301
- await rm(temporaryPath, { force: true }).catch(() => undefined);
302
- }
303
- });
304
- }
305
-
306
- export function errorMessage(error: unknown): string {
307
- return error instanceof Error ? error.message : String(error);
308
- }
1
+ /**
2
+ * Configuration load/save for pi-subagents.
3
+ *
4
+ * Config lives at <agentDir>/pi-subagents.json (agentDir defaults to ~/.pi/agent
5
+ * and honors PI_CODING_AGENT_DIR). Parsing is defensive: invalid fields fall back
6
+ * to defaults instead of throwing, so a hand-edited or partially-written file can
7
+ * never break the extension at runtime. Unknown keys from older versions are
8
+ * dropped and the normalized shape persisted back on load.
9
+ */
10
+
11
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
12
+ import { dirname, join } from "node:path";
13
+ import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
14
+
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;
24
+
25
+ /** Agents enabled out of the box on a fresh install. */
26
+ export const DEFAULT_ENABLED_AGENTS: readonly string[] = [...BUILTIN_AGENT_NAMES];
27
+
28
+ export const AGENT_SCOPE_VALUES = ["user", "project", "both"] as const;
29
+ export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
30
+
31
+ /** Thinking levels accepted by pi's `--thinking` option. */
32
+ export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
33
+ export type ThinkingLevel = (typeof THINKING_LEVEL_VALUES)[number];
34
+ export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "high";
35
+
36
+ /** How many lines of a sub-agent result the completion message may carry.
37
+ * Default: 40 — wide fan-outs multiply completion blocks, so deliveries stay
38
+ * compact and the full text lives in the on-disk result artifact. */
39
+ export const DEFAULT_MAX_RESULT_LINES = 40;
40
+ /** Upper bound accepted for maxResultLines (defensive clamp). */
41
+ export const MAX_RESULT_LINES_LIMIT = 2000;
42
+
43
+ const CONFIG_FILE_NAME = "pi-subagents.json";
44
+
45
+ /**
46
+ * Default idle timeout in seconds: a sub-agent whose stdout (JSON event stream)
47
+ * goes silent for this long is terminated; a selected model then hands the
48
+ * retained session to current main. 0 disables the watchdog. Default: 90.
49
+ */
50
+ export const DEFAULT_IDLE_TIMEOUT_SEC = 90;
51
+ /** Upper bound accepted for idleTimeoutSec (defensive clamp). 0 disables. */
52
+ export const IDLE_TIMEOUT_SEC_LIMIT = 600;
53
+
54
+ export interface SubagentsConfig {
55
+ /** Agent names that are discoverable and injected. Fresh-install default: every built-in agent. */
56
+ 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
+ /** Per-agent model override, keyed by agent name, as "provider/model-id". */
63
+ agentModels: Record<string, string>;
64
+ /** Optional per-agent thinking preference. Runtime clamps it to the effective model's supported levels. */
65
+ agentThinkingLevels: Record<string, ThinkingLevel>;
66
+ /**
67
+ * Max lines of a sub-agent result carried in the completion message. Longer
68
+ * results are truncated; the full text is written to a temp file whose path
69
+ * is included in the message. Default: 80.
70
+ */
71
+ maxResultLines: number;
72
+ /** Which agent directories to discover from. Default: "user". */
73
+ agentScope: AgentScope;
74
+ /**
75
+ * Idle timeout in seconds: a sub-agent whose stdout (JSON event stream) goes
76
+ * silent for this long is terminated; a configured agent model then hands
77
+ * off to the current main model. 0 disables the idle watchdog. Default: 90.
78
+ */
79
+ idleTimeoutSec: number;
80
+ }
81
+
82
+ export const DEFAULT_CONFIG: SubagentsConfig = {
83
+ enabledAgents: [...DEFAULT_ENABLED_AGENTS],
84
+ knownAgents: [...BUILTIN_AGENT_NAMES],
85
+ agentModels: {},
86
+ agentThinkingLevels: {},
87
+ maxResultLines: DEFAULT_MAX_RESULT_LINES,
88
+ agentScope: "user",
89
+ idleTimeoutSec: DEFAULT_IDLE_TIMEOUT_SEC,
90
+ };
91
+
92
+ export function getConfigPath(agentDir: string = getAgentDir()): string {
93
+ return join(agentDir, CONFIG_FILE_NAME);
94
+ }
95
+
96
+ function isRecord(value: unknown): value is Record<string, unknown> {
97
+ return typeof value === "object" && value !== null && !Array.isArray(value);
98
+ }
99
+
100
+ function isAgentScope(value: unknown): value is AgentScope {
101
+ return typeof value === "string" && (AGENT_SCOPE_VALUES as readonly string[]).includes(value);
102
+ }
103
+
104
+ function isModelReference(value: unknown): value is string {
105
+ if (typeof value !== "string") return false;
106
+ const normalized = value.trim();
107
+ const slash = normalized.indexOf("/");
108
+ return slash > 0 && slash < normalized.length - 1 && !/\s/u.test(normalized);
109
+ }
110
+
111
+ /** Clamp a raw value to a positive integer within [1, upper]; undefined when invalid. */
112
+ function clampCount(value: unknown, upper: number): number | undefined {
113
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
114
+ return Math.max(1, Math.min(upper, Math.round(value)));
115
+ }
116
+
117
+ /**
118
+ * Merge a raw parsed JSON value over the defaults, dropping invalid fields.
119
+ * Exported for tests.
120
+ */
121
+ export function normalizeConfig(raw: unknown): SubagentsConfig {
122
+ const config = defaultConfig();
123
+ if (!isRecord(raw)) return config;
124
+
125
+ if (Array.isArray(raw.enabledAgents)) {
126
+ const names = raw.enabledAgents.filter(
127
+ (name): name is string => typeof name === "string" && name.trim().length > 0,
128
+ );
129
+ // An explicitly empty array is honored; duplicates collapse.
130
+ config.enabledAgents = [...new Set(names.map((name) => name.trim()))];
131
+ }
132
+
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
+ if (isRecord(raw.agentModels)) {
148
+ for (const [rawKey, value] of Object.entries(raw.agentModels)) {
149
+ const key = rawKey.trim();
150
+ if (key !== "" && isModelReference(value)) {
151
+ config.agentModels[key] = value.trim();
152
+ }
153
+ }
154
+ }
155
+
156
+ if (isRecord(raw.agentThinkingLevels)) {
157
+ for (const [rawKey, value] of Object.entries(raw.agentThinkingLevels)) {
158
+ const key = rawKey.trim();
159
+ if (
160
+ key !== "" &&
161
+ typeof value === "string" &&
162
+ (THINKING_LEVEL_VALUES as readonly string[]).includes(value)
163
+ ) {
164
+ config.agentThinkingLevels[key] = value as ThinkingLevel;
165
+ }
166
+ }
167
+ }
168
+
169
+ const maxResultLines = clampCount(raw.maxResultLines, MAX_RESULT_LINES_LIMIT);
170
+ if (maxResultLines !== undefined) config.maxResultLines = maxResultLines;
171
+
172
+ if (isAgentScope(raw.agentScope)) {
173
+ config.agentScope = raw.agentScope;
174
+ }
175
+
176
+ // 0 disables the idle watchdog; otherwise clamp to [0, upper].
177
+ if (typeof raw.idleTimeoutSec === "number" && Number.isFinite(raw.idleTimeoutSec)) {
178
+ config.idleTimeoutSec = Math.max(0, Math.min(IDLE_TIMEOUT_SEC_LIMIT, Math.round(raw.idleTimeoutSec)));
179
+ }
180
+
181
+ return config;
182
+ }
183
+
184
+ function defaultConfig(): SubagentsConfig {
185
+ return {
186
+ ...DEFAULT_CONFIG,
187
+ enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
188
+ agentModels: {},
189
+ agentThinkingLevels: {},
190
+ };
191
+ }
192
+
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
+ /**
244
+ * Load config. A missing file is a normal state and yields the defaults (not an error).
245
+ * 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.
250
+ */
251
+ export async function loadConfig(configPath: string = getConfigPath()): Promise<SubagentsConfig> {
252
+ let text: string;
253
+ try {
254
+ text = await readFile(configPath, "utf8");
255
+ } catch {
256
+ // Missing or unreadable: fall back to defaults but do not crash startup.
257
+ return defaultConfig();
258
+ }
259
+
260
+ let parsed: unknown;
261
+ try {
262
+ parsed = JSON.parse(text);
263
+ } catch {
264
+ return defaultConfig();
265
+ }
266
+
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)));
271
+
272
+ // Schema upgrade: persist the normalized shape when the file gained fields
273
+ // (new version) or dropped invalid ones.
274
+ if (JSON.stringify(config) !== JSON.stringify(parsed)) {
275
+ try {
276
+ await saveConfig(config, configPath);
277
+ } catch {
278
+ // Non-fatal: keep the in-memory config for this run.
279
+ }
280
+ }
281
+
282
+ return config;
283
+ }
284
+
285
+ /**
286
+ * Save config atomically (temp file + rename) serialized through pi's per-file
287
+ * mutation queue so concurrent writers cannot interleave.
288
+ */
289
+ export async function saveConfig(
290
+ config: SubagentsConfig,
291
+ configPath: string = getConfigPath(),
292
+ ): Promise<void> {
293
+ const normalized = normalizeConfig(config);
294
+ await mkdir(dirname(configPath), { recursive: true });
295
+ await withFileMutationQueue(configPath, async () => {
296
+ const temporaryPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
297
+ try {
298
+ await writeFile(temporaryPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
299
+ await rename(temporaryPath, configPath);
300
+ } finally {
301
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
302
+ }
303
+ });
304
+ }
305
+
306
+ export function errorMessage(error: unknown): string {
307
+ return error instanceof Error ? error.message : String(error);
308
+ }
package/src/dispatch.ts CHANGED
@@ -258,16 +258,22 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
258
258
  // refreshes the fallback context, config, and agent catalog it resolves.
259
259
  const environmentRef: { current: DispatchEnvironment | undefined } = { current: undefined };
260
260
 
261
- // Finished runs leave the active monitor immediately. Their final findings
262
- // are sent as a custom message that starts a follow-up turn.
261
+ // Terminal rows stay in the monitor until the next beginTurn so the footer
262
+ // can count them beside siblings that are still live. The widget ignores
263
+ // them. A second finishRun for the same endedAt is a no-op; a resume
264
+ // clears endedAt, so the next settlement notifies again.
265
+ const publishedEndedAt = new Map<number, number>();
263
266
  const finishRun = (
264
267
  runId: number,
265
268
  status: "done" | "failed",
266
269
  opts?: { silent?: boolean },
267
270
  ): void => {
271
+ const run = monitor.findRun(runId);
272
+ if (!run) return;
268
273
  monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
269
- const run = monitor.removeRun(runId);
270
- if (!run) return; // already finished stay idempotent
274
+ const endedAt = monitor.findRun(runId)?.endedAt;
275
+ if (endedAt !== undefined && publishedEndedAt.get(runId) === endedAt) return;
276
+ if (endedAt !== undefined) publishedEndedAt.set(runId, endedAt);
271
277
  if (opts?.silent || !runtime.sessionActive) return;
272
278
  const icon = status === "done" ? "✓" : "✗";
273
279
  environmentRef.current?.ctx.ui.notify(`${icon} #${run.id} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");