@ferris1225/pi-subagents 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +475 -457
- package/agents/cleaner.md +11 -21
- package/agents/{explore.md → explorer.md} +3 -3
- package/agents/reviewer.md +47 -41
- package/agents/worker.md +3 -3
- package/package.json +1 -1
- package/src/config.ts +299 -299
- package/src/dispatch.ts +8 -25
- package/src/monitor.ts +1 -1
- package/src/prompt.ts +75 -58
- package/src/setup.ts +438 -438
- package/src/thread-lifecycle.ts +1 -1
package/src/config.ts
CHANGED
|
@@ -1,299 +1,299 @@
|
|
|
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.
|
|
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.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
16
|
-
import { dirname, join } from "node:path";
|
|
17
|
-
import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
|
|
19
|
-
/** Full catalog of agents shipped with the package (selectable in /subagents-setup). */
|
|
20
|
-
export const BUILTIN_AGENT_NAMES = ["
|
|
21
|
-
|
|
22
|
-
/** Agents enabled out of the box on a fresh install. Explicit configured lists are preserved. */
|
|
23
|
-
export const DEFAULT_ENABLED_AGENTS: readonly string[] = ["
|
|
24
|
-
|
|
25
|
-
export const AGENT_SCOPE_VALUES = ["user", "project", "both"] as const;
|
|
26
|
-
export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
|
|
27
|
-
|
|
28
|
-
/** Thinking levels accepted by pi's `--thinking` option. */
|
|
29
|
-
export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
30
|
-
export type ThinkingLevel = (typeof THINKING_LEVEL_VALUES)[number];
|
|
31
|
-
export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "high";
|
|
32
|
-
|
|
33
|
-
/** How many lines of a sub-agent result the completion message may carry. Default: 80. */
|
|
34
|
-
export const DEFAULT_MAX_RESULT_LINES = 80;
|
|
35
|
-
/** Upper bound accepted for maxResultLines (defensive clamp). */
|
|
36
|
-
export const MAX_RESULT_LINES_LIMIT = 2000;
|
|
37
|
-
|
|
38
|
-
const CONFIG_FILE_NAME = "pi-subagents.json";
|
|
39
|
-
|
|
40
|
-
/** How many sub-agent processes may run at once, and how many tasks one parallel `subagent` call may contain. Default: 4. */
|
|
41
|
-
export const DEFAULT_MAX_CONCURRENCY = 4;
|
|
42
|
-
/** Upper bound accepted for maxConcurrency (defensive clamp). */
|
|
43
|
-
export const MAX_CONCURRENCY_LIMIT = 16;
|
|
44
|
-
/**
|
|
45
|
-
* How many automatic worker→reviewer fix rounds run when a reviewer returns
|
|
46
|
-
* REVIEW_FAIL before waking the main agent. 0 disables the auto-fix loop
|
|
47
|
-
* (the main agent is woken to dispatch fixes itself). Default: 2.
|
|
48
|
-
*/
|
|
49
|
-
export const DEFAULT_MAX_FIX_ROUNDS = 2;
|
|
50
|
-
/** Upper bound accepted for maxFixRounds (defensive clamp). 0 disables the loop. */
|
|
51
|
-
export const MAX_FIX_ROUNDS_LIMIT = 5;
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Default idle timeout in seconds: a sub-agent whose stdout (JSON event stream)
|
|
55
|
-
* goes silent for this long is terminated; a selected model then hands the
|
|
56
|
-
* retained session to current main. 0 disables the watchdog. Default: 90.
|
|
57
|
-
*/
|
|
58
|
-
export const DEFAULT_IDLE_TIMEOUT_SEC = 90;
|
|
59
|
-
/** Upper bound accepted for idleTimeoutSec (defensive clamp). 0 disables. */
|
|
60
|
-
export const IDLE_TIMEOUT_SEC_LIMIT = 600;
|
|
61
|
-
|
|
62
|
-
export interface SubagentsConfig {
|
|
63
|
-
/** Agent names that are discoverable and injected. Fresh-install default:
|
|
64
|
-
enabledAgents: string[];
|
|
65
|
-
/** Per-agent model override, keyed by agent name, as "provider/model-id". */
|
|
66
|
-
agentModels: Record<string, string>;
|
|
67
|
-
/** Optional per-agent thinking preference. Runtime clamps it to the effective model's supported levels. */
|
|
68
|
-
agentThinkingLevels: Record<string, ThinkingLevel>;
|
|
69
|
-
/**
|
|
70
|
-
* When a review passes (REVIEW_PASS verdict), deliver it without waking the
|
|
71
|
-
* main agent. Disabled by default so passing reviews still resume orchestration.
|
|
72
|
-
*/
|
|
73
|
-
notifyOnReviewPass: boolean;
|
|
74
|
-
/**
|
|
75
|
-
* Max lines of a sub-agent result carried in the completion message. Longer
|
|
76
|
-
* results are truncated; the full text is written to a temp file whose path
|
|
77
|
-
* is included in the message. Default: 80.
|
|
78
|
-
*/
|
|
79
|
-
maxResultLines: number;
|
|
80
|
-
/** Whether to inject the delegation directive into the parent system prompt. Default: true. */
|
|
81
|
-
proactiveInjection: boolean;
|
|
82
|
-
/** Which agent directories to discover from. Default: "user". */
|
|
83
|
-
agentScope: AgentScope;
|
|
84
|
-
/** Max sub-agent processes running at once (extra work queues) and the max tasks
|
|
85
|
-
* one parallel `subagent` call may contain. Default: 4. */
|
|
86
|
-
maxConcurrency: number;
|
|
87
|
-
/**
|
|
88
|
-
* Auto-fix rounds when a reviewer returns REVIEW_FAIL: the extension dispatches
|
|
89
|
-
* a worker (briefed with the review's concrete findings) then a reviewer
|
|
90
|
-
* re-review, repeating up to this many times before waking the main agent with
|
|
91
|
-
* the full chain. 0 disables it (the main agent handles fixes itself).
|
|
92
|
-
* Default: 2.
|
|
93
|
-
*/
|
|
94
|
-
maxFixRounds: number;
|
|
95
|
-
/**
|
|
96
|
-
* Idle timeout in seconds: a sub-agent whose stdout (JSON event stream) goes
|
|
97
|
-
* silent for this long is terminated; a configured agent model then hands
|
|
98
|
-
* off to the current main model. 0 disables the idle watchdog. Default: 90.
|
|
99
|
-
*/
|
|
100
|
-
idleTimeoutSec: number;
|
|
101
|
-
/**
|
|
102
|
-
* One-time feature announcements already shown to the user. Persisted so
|
|
103
|
-
* the notice never nags again.
|
|
104
|
-
*/
|
|
105
|
-
announcedFeatures: string[];
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export const DEFAULT_CONFIG: SubagentsConfig = {
|
|
109
|
-
enabledAgents: [...DEFAULT_ENABLED_AGENTS],
|
|
110
|
-
agentModels: {},
|
|
111
|
-
agentThinkingLevels: {},
|
|
112
|
-
notifyOnReviewPass: false,
|
|
113
|
-
maxResultLines: DEFAULT_MAX_RESULT_LINES,
|
|
114
|
-
proactiveInjection: true,
|
|
115
|
-
agentScope: "user",
|
|
116
|
-
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
|
117
|
-
maxFixRounds: DEFAULT_MAX_FIX_ROUNDS,
|
|
118
|
-
idleTimeoutSec: DEFAULT_IDLE_TIMEOUT_SEC,
|
|
119
|
-
announcedFeatures: [],
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
export function getConfigPath(agentDir: string = getAgentDir()): string {
|
|
123
|
-
return join(agentDir, CONFIG_FILE_NAME);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
127
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function isAgentScope(value: unknown): value is AgentScope {
|
|
131
|
-
return typeof value === "string" && (AGENT_SCOPE_VALUES as readonly string[]).includes(value);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function isModelReference(value: unknown): value is string {
|
|
135
|
-
if (typeof value !== "string") return false;
|
|
136
|
-
const normalized = value.trim();
|
|
137
|
-
const slash = normalized.indexOf("/");
|
|
138
|
-
return slash > 0 && slash < normalized.length - 1 && !/\s/u.test(normalized);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Clamp a raw value to a positive integer within [1, upper]; undefined when invalid. */
|
|
142
|
-
function clampCount(value: unknown, upper: number): number | undefined {
|
|
143
|
-
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
144
|
-
return Math.max(1, Math.min(upper, Math.round(value)));
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Merge a raw parsed JSON value over the defaults, dropping invalid fields.
|
|
149
|
-
* Exported for tests.
|
|
150
|
-
*/
|
|
151
|
-
export function normalizeConfig(raw: unknown): SubagentsConfig {
|
|
152
|
-
const config = defaultConfig();
|
|
153
|
-
if (!isRecord(raw)) return config;
|
|
154
|
-
|
|
155
|
-
if (Array.isArray(raw.enabledAgents)) {
|
|
156
|
-
const names = raw.enabledAgents.filter(
|
|
157
|
-
(name): name is string => typeof name === "string" && name.trim().length > 0,
|
|
158
|
-
);
|
|
159
|
-
// An explicitly empty array is honored (disables all agents); otherwise keep valid names.
|
|
160
|
-
config.enabledAgents = [...new Set(names.map((name) => name.trim()))];
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (isRecord(raw.agentModels)) {
|
|
164
|
-
for (const [key, value] of Object.entries(raw.agentModels)) {
|
|
165
|
-
if (isModelReference(value)) config.agentModels[key.trim()] = value.trim();
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (isRecord(raw.agentThinkingLevels)) {
|
|
170
|
-
for (const [key, value] of Object.entries(raw.agentThinkingLevels)) {
|
|
171
|
-
if (
|
|
172
|
-
typeof value === "string" &&
|
|
173
|
-
(THINKING_LEVEL_VALUES as readonly string[]).includes(value)
|
|
174
|
-
) {
|
|
175
|
-
config.agentThinkingLevels[key.trim()] = value as ThinkingLevel;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (typeof raw.notifyOnReviewPass === "boolean") {
|
|
181
|
-
config.notifyOnReviewPass = raw.notifyOnReviewPass;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const maxResultLines = clampCount(raw.maxResultLines, MAX_RESULT_LINES_LIMIT);
|
|
185
|
-
if (maxResultLines !== undefined) config.maxResultLines = maxResultLines;
|
|
186
|
-
|
|
187
|
-
if (typeof raw.proactiveInjection === "boolean") {
|
|
188
|
-
config.proactiveInjection = raw.proactiveInjection;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (isAgentScope(raw.agentScope)) {
|
|
192
|
-
config.agentScope = raw.agentScope;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const maxConcurrency = clampCount(raw.maxConcurrency, MAX_CONCURRENCY_LIMIT);
|
|
196
|
-
if (maxConcurrency !== undefined) config.maxConcurrency = maxConcurrency;
|
|
197
|
-
|
|
198
|
-
// 0 disables the auto-fix loop (main agent handles fixes itself).
|
|
199
|
-
if (typeof raw.maxFixRounds === "number" && Number.isFinite(raw.maxFixRounds)) {
|
|
200
|
-
config.maxFixRounds = Math.max(0, Math.min(MAX_FIX_ROUNDS_LIMIT, Math.round(raw.maxFixRounds)));
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// 0 disables the idle watchdog; otherwise clamp to [0, upper].
|
|
204
|
-
if (typeof raw.idleTimeoutSec === "number" && Number.isFinite(raw.idleTimeoutSec)) {
|
|
205
|
-
config.idleTimeoutSec = Math.max(0, Math.min(IDLE_TIMEOUT_SEC_LIMIT, Math.round(raw.idleTimeoutSec)));
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (Array.isArray(raw.announcedFeatures)) {
|
|
209
|
-
config.announcedFeatures = raw.announcedFeatures.filter(
|
|
210
|
-
(feature): feature is string => typeof feature === "string" && feature.trim().length > 0,
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return config;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function defaultConfig(): SubagentsConfig {
|
|
218
|
-
return {
|
|
219
|
-
...DEFAULT_CONFIG,
|
|
220
|
-
enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
|
|
221
|
-
agentModels: {},
|
|
222
|
-
agentThinkingLevels: {},
|
|
223
|
-
announcedFeatures: [],
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Load config. A missing file is a normal state and yields the defaults (not an error).
|
|
229
|
-
* A corrupt file also falls back to defaults rather than throwing, so startup never breaks.
|
|
230
|
-
* A file from an older version (missing newer keys or holding extra keys) is
|
|
231
|
-
* normalized and persisted back, so the on-disk config stays current.
|
|
232
|
-
*/
|
|
233
|
-
export async function loadConfig(configPath: string = getConfigPath()): Promise<SubagentsConfig> {
|
|
234
|
-
let text: string;
|
|
235
|
-
try {
|
|
236
|
-
text = await readFile(configPath, "utf8");
|
|
237
|
-
} catch {
|
|
238
|
-
// Missing or unreadable: fall back to defaults but do not crash startup.
|
|
239
|
-
return defaultConfig();
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
let parsed: unknown;
|
|
243
|
-
try {
|
|
244
|
-
parsed = JSON.parse(text);
|
|
245
|
-
} catch {
|
|
246
|
-
return defaultConfig();
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const config = normalizeConfig(parsed);
|
|
250
|
-
|
|
251
|
-
// Schema upgrade: persist the normalized shape when the file gained fields
|
|
252
|
-
// (new version) or dropped invalid ones.
|
|
253
|
-
if (JSON.stringify(config) !== JSON.stringify(parsed)) {
|
|
254
|
-
try {
|
|
255
|
-
await saveConfig(config, configPath);
|
|
256
|
-
} catch {
|
|
257
|
-
// Non-fatal: keep the in-memory config for this run.
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return config;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Synchronous load for the extension's init-time decisions (e.g. the recursion
|
|
266
|
-
* guard). Runs before any async context is available; never migrates or saves.
|
|
267
|
-
*/
|
|
268
|
-
export function loadConfigSync(configPath: string = getConfigPath()): SubagentsConfig {
|
|
269
|
-
try {
|
|
270
|
-
return normalizeConfig(JSON.parse(readFileSync(configPath, "utf8")));
|
|
271
|
-
} catch {
|
|
272
|
-
return defaultConfig();
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Save config atomically (temp file + rename) serialized through pi's per-file
|
|
278
|
-
* mutation queue so concurrent writers cannot interleave.
|
|
279
|
-
*/
|
|
280
|
-
export async function saveConfig(
|
|
281
|
-
config: SubagentsConfig,
|
|
282
|
-
configPath: string = getConfigPath(),
|
|
283
|
-
): Promise<void> {
|
|
284
|
-
const normalized = normalizeConfig(config);
|
|
285
|
-
await mkdir(dirname(configPath), { recursive: true });
|
|
286
|
-
await withFileMutationQueue(configPath, async () => {
|
|
287
|
-
const temporaryPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
|
|
288
|
-
try {
|
|
289
|
-
await writeFile(temporaryPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
|
290
|
-
await rename(temporaryPath, configPath);
|
|
291
|
-
} finally {
|
|
292
|
-
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
293
|
-
}
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
export function errorMessage(error: unknown): string {
|
|
298
|
-
return error instanceof Error ? error.message : String(error);
|
|
299
|
-
}
|
|
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.
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
/** Full catalog of agents shipped with the package (selectable in /subagents-setup). */
|
|
20
|
+
export const BUILTIN_AGENT_NAMES = ["explorer", "worker", "cleaner", "reviewer"] as const;
|
|
21
|
+
|
|
22
|
+
/** Agents enabled out of the box on a fresh install. Explicit configured lists are preserved. */
|
|
23
|
+
export const DEFAULT_ENABLED_AGENTS: readonly string[] = ["explorer", "worker", "cleaner", "reviewer"];
|
|
24
|
+
|
|
25
|
+
export const AGENT_SCOPE_VALUES = ["user", "project", "both"] as const;
|
|
26
|
+
export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
|
|
27
|
+
|
|
28
|
+
/** Thinking levels accepted by pi's `--thinking` option. */
|
|
29
|
+
export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
30
|
+
export type ThinkingLevel = (typeof THINKING_LEVEL_VALUES)[number];
|
|
31
|
+
export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "high";
|
|
32
|
+
|
|
33
|
+
/** How many lines of a sub-agent result the completion message may carry. Default: 80. */
|
|
34
|
+
export const DEFAULT_MAX_RESULT_LINES = 80;
|
|
35
|
+
/** Upper bound accepted for maxResultLines (defensive clamp). */
|
|
36
|
+
export const MAX_RESULT_LINES_LIMIT = 2000;
|
|
37
|
+
|
|
38
|
+
const CONFIG_FILE_NAME = "pi-subagents.json";
|
|
39
|
+
|
|
40
|
+
/** How many sub-agent processes may run at once, and how many tasks one parallel `subagent` call may contain. Default: 4. */
|
|
41
|
+
export const DEFAULT_MAX_CONCURRENCY = 4;
|
|
42
|
+
/** Upper bound accepted for maxConcurrency (defensive clamp). */
|
|
43
|
+
export const MAX_CONCURRENCY_LIMIT = 16;
|
|
44
|
+
/**
|
|
45
|
+
* How many automatic worker→reviewer fix rounds run when a reviewer returns
|
|
46
|
+
* REVIEW_FAIL before waking the main agent. 0 disables the auto-fix loop
|
|
47
|
+
* (the main agent is woken to dispatch fixes itself). Default: 2.
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_MAX_FIX_ROUNDS = 2;
|
|
50
|
+
/** Upper bound accepted for maxFixRounds (defensive clamp). 0 disables the loop. */
|
|
51
|
+
export const MAX_FIX_ROUNDS_LIMIT = 5;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Default idle timeout in seconds: a sub-agent whose stdout (JSON event stream)
|
|
55
|
+
* goes silent for this long is terminated; a selected model then hands the
|
|
56
|
+
* retained session to current main. 0 disables the watchdog. Default: 90.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_IDLE_TIMEOUT_SEC = 90;
|
|
59
|
+
/** Upper bound accepted for idleTimeoutSec (defensive clamp). 0 disables. */
|
|
60
|
+
export const IDLE_TIMEOUT_SEC_LIMIT = 600;
|
|
61
|
+
|
|
62
|
+
export interface SubagentsConfig {
|
|
63
|
+
/** Agent names that are discoverable and injected. Fresh-install default: explorer, worker, cleaner, reviewer. */
|
|
64
|
+
enabledAgents: string[];
|
|
65
|
+
/** Per-agent model override, keyed by agent name, as "provider/model-id". */
|
|
66
|
+
agentModels: Record<string, string>;
|
|
67
|
+
/** Optional per-agent thinking preference. Runtime clamps it to the effective model's supported levels. */
|
|
68
|
+
agentThinkingLevels: Record<string, ThinkingLevel>;
|
|
69
|
+
/**
|
|
70
|
+
* When a review passes (REVIEW_PASS verdict), deliver it without waking the
|
|
71
|
+
* main agent. Disabled by default so passing reviews still resume orchestration.
|
|
72
|
+
*/
|
|
73
|
+
notifyOnReviewPass: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Max lines of a sub-agent result carried in the completion message. Longer
|
|
76
|
+
* results are truncated; the full text is written to a temp file whose path
|
|
77
|
+
* is included in the message. Default: 80.
|
|
78
|
+
*/
|
|
79
|
+
maxResultLines: number;
|
|
80
|
+
/** Whether to inject the delegation directive into the parent system prompt. Default: true. */
|
|
81
|
+
proactiveInjection: boolean;
|
|
82
|
+
/** Which agent directories to discover from. Default: "user". */
|
|
83
|
+
agentScope: AgentScope;
|
|
84
|
+
/** Max sub-agent processes running at once (extra work queues) and the max tasks
|
|
85
|
+
* one parallel `subagent` call may contain. Default: 4. */
|
|
86
|
+
maxConcurrency: number;
|
|
87
|
+
/**
|
|
88
|
+
* Auto-fix rounds when a reviewer returns REVIEW_FAIL: the extension dispatches
|
|
89
|
+
* a worker (briefed with the review's concrete findings) then a reviewer
|
|
90
|
+
* re-review, repeating up to this many times before waking the main agent with
|
|
91
|
+
* the full chain. 0 disables it (the main agent handles fixes itself).
|
|
92
|
+
* Default: 2.
|
|
93
|
+
*/
|
|
94
|
+
maxFixRounds: number;
|
|
95
|
+
/**
|
|
96
|
+
* Idle timeout in seconds: a sub-agent whose stdout (JSON event stream) goes
|
|
97
|
+
* silent for this long is terminated; a configured agent model then hands
|
|
98
|
+
* off to the current main model. 0 disables the idle watchdog. Default: 90.
|
|
99
|
+
*/
|
|
100
|
+
idleTimeoutSec: number;
|
|
101
|
+
/**
|
|
102
|
+
* One-time feature announcements already shown to the user. Persisted so
|
|
103
|
+
* the notice never nags again.
|
|
104
|
+
*/
|
|
105
|
+
announcedFeatures: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const DEFAULT_CONFIG: SubagentsConfig = {
|
|
109
|
+
enabledAgents: [...DEFAULT_ENABLED_AGENTS],
|
|
110
|
+
agentModels: {},
|
|
111
|
+
agentThinkingLevels: {},
|
|
112
|
+
notifyOnReviewPass: false,
|
|
113
|
+
maxResultLines: DEFAULT_MAX_RESULT_LINES,
|
|
114
|
+
proactiveInjection: true,
|
|
115
|
+
agentScope: "user",
|
|
116
|
+
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
|
117
|
+
maxFixRounds: DEFAULT_MAX_FIX_ROUNDS,
|
|
118
|
+
idleTimeoutSec: DEFAULT_IDLE_TIMEOUT_SEC,
|
|
119
|
+
announcedFeatures: [],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export function getConfigPath(agentDir: string = getAgentDir()): string {
|
|
123
|
+
return join(agentDir, CONFIG_FILE_NAME);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
127
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isAgentScope(value: unknown): value is AgentScope {
|
|
131
|
+
return typeof value === "string" && (AGENT_SCOPE_VALUES as readonly string[]).includes(value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isModelReference(value: unknown): value is string {
|
|
135
|
+
if (typeof value !== "string") return false;
|
|
136
|
+
const normalized = value.trim();
|
|
137
|
+
const slash = normalized.indexOf("/");
|
|
138
|
+
return slash > 0 && slash < normalized.length - 1 && !/\s/u.test(normalized);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Clamp a raw value to a positive integer within [1, upper]; undefined when invalid. */
|
|
142
|
+
function clampCount(value: unknown, upper: number): number | undefined {
|
|
143
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
144
|
+
return Math.max(1, Math.min(upper, Math.round(value)));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Merge a raw parsed JSON value over the defaults, dropping invalid fields.
|
|
149
|
+
* Exported for tests.
|
|
150
|
+
*/
|
|
151
|
+
export function normalizeConfig(raw: unknown): SubagentsConfig {
|
|
152
|
+
const config = defaultConfig();
|
|
153
|
+
if (!isRecord(raw)) return config;
|
|
154
|
+
|
|
155
|
+
if (Array.isArray(raw.enabledAgents)) {
|
|
156
|
+
const names = raw.enabledAgents.filter(
|
|
157
|
+
(name): name is string => typeof name === "string" && name.trim().length > 0,
|
|
158
|
+
);
|
|
159
|
+
// An explicitly empty array is honored (disables all agents); otherwise keep valid names.
|
|
160
|
+
config.enabledAgents = [...new Set(names.map((name) => name.trim()))];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (isRecord(raw.agentModels)) {
|
|
164
|
+
for (const [key, value] of Object.entries(raw.agentModels)) {
|
|
165
|
+
if (isModelReference(value)) config.agentModels[key.trim()] = value.trim();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (isRecord(raw.agentThinkingLevels)) {
|
|
170
|
+
for (const [key, value] of Object.entries(raw.agentThinkingLevels)) {
|
|
171
|
+
if (
|
|
172
|
+
typeof value === "string" &&
|
|
173
|
+
(THINKING_LEVEL_VALUES as readonly string[]).includes(value)
|
|
174
|
+
) {
|
|
175
|
+
config.agentThinkingLevels[key.trim()] = value as ThinkingLevel;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (typeof raw.notifyOnReviewPass === "boolean") {
|
|
181
|
+
config.notifyOnReviewPass = raw.notifyOnReviewPass;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const maxResultLines = clampCount(raw.maxResultLines, MAX_RESULT_LINES_LIMIT);
|
|
185
|
+
if (maxResultLines !== undefined) config.maxResultLines = maxResultLines;
|
|
186
|
+
|
|
187
|
+
if (typeof raw.proactiveInjection === "boolean") {
|
|
188
|
+
config.proactiveInjection = raw.proactiveInjection;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (isAgentScope(raw.agentScope)) {
|
|
192
|
+
config.agentScope = raw.agentScope;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const maxConcurrency = clampCount(raw.maxConcurrency, MAX_CONCURRENCY_LIMIT);
|
|
196
|
+
if (maxConcurrency !== undefined) config.maxConcurrency = maxConcurrency;
|
|
197
|
+
|
|
198
|
+
// 0 disables the auto-fix loop (main agent handles fixes itself).
|
|
199
|
+
if (typeof raw.maxFixRounds === "number" && Number.isFinite(raw.maxFixRounds)) {
|
|
200
|
+
config.maxFixRounds = Math.max(0, Math.min(MAX_FIX_ROUNDS_LIMIT, Math.round(raw.maxFixRounds)));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 0 disables the idle watchdog; otherwise clamp to [0, upper].
|
|
204
|
+
if (typeof raw.idleTimeoutSec === "number" && Number.isFinite(raw.idleTimeoutSec)) {
|
|
205
|
+
config.idleTimeoutSec = Math.max(0, Math.min(IDLE_TIMEOUT_SEC_LIMIT, Math.round(raw.idleTimeoutSec)));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (Array.isArray(raw.announcedFeatures)) {
|
|
209
|
+
config.announcedFeatures = raw.announcedFeatures.filter(
|
|
210
|
+
(feature): feature is string => typeof feature === "string" && feature.trim().length > 0,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return config;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function defaultConfig(): SubagentsConfig {
|
|
218
|
+
return {
|
|
219
|
+
...DEFAULT_CONFIG,
|
|
220
|
+
enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
|
|
221
|
+
agentModels: {},
|
|
222
|
+
agentThinkingLevels: {},
|
|
223
|
+
announcedFeatures: [],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Load config. A missing file is a normal state and yields the defaults (not an error).
|
|
229
|
+
* A corrupt file also falls back to defaults rather than throwing, so startup never breaks.
|
|
230
|
+
* A file from an older version (missing newer keys or holding extra keys) is
|
|
231
|
+
* normalized and persisted back, so the on-disk config stays current.
|
|
232
|
+
*/
|
|
233
|
+
export async function loadConfig(configPath: string = getConfigPath()): Promise<SubagentsConfig> {
|
|
234
|
+
let text: string;
|
|
235
|
+
try {
|
|
236
|
+
text = await readFile(configPath, "utf8");
|
|
237
|
+
} catch {
|
|
238
|
+
// Missing or unreadable: fall back to defaults but do not crash startup.
|
|
239
|
+
return defaultConfig();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let parsed: unknown;
|
|
243
|
+
try {
|
|
244
|
+
parsed = JSON.parse(text);
|
|
245
|
+
} catch {
|
|
246
|
+
return defaultConfig();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const config = normalizeConfig(parsed);
|
|
250
|
+
|
|
251
|
+
// Schema upgrade: persist the normalized shape when the file gained fields
|
|
252
|
+
// (new version) or dropped invalid ones.
|
|
253
|
+
if (JSON.stringify(config) !== JSON.stringify(parsed)) {
|
|
254
|
+
try {
|
|
255
|
+
await saveConfig(config, configPath);
|
|
256
|
+
} catch {
|
|
257
|
+
// Non-fatal: keep the in-memory config for this run.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return config;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Synchronous load for the extension's init-time decisions (e.g. the recursion
|
|
266
|
+
* guard). Runs before any async context is available; never migrates or saves.
|
|
267
|
+
*/
|
|
268
|
+
export function loadConfigSync(configPath: string = getConfigPath()): SubagentsConfig {
|
|
269
|
+
try {
|
|
270
|
+
return normalizeConfig(JSON.parse(readFileSync(configPath, "utf8")));
|
|
271
|
+
} catch {
|
|
272
|
+
return defaultConfig();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Save config atomically (temp file + rename) serialized through pi's per-file
|
|
278
|
+
* mutation queue so concurrent writers cannot interleave.
|
|
279
|
+
*/
|
|
280
|
+
export async function saveConfig(
|
|
281
|
+
config: SubagentsConfig,
|
|
282
|
+
configPath: string = getConfigPath(),
|
|
283
|
+
): Promise<void> {
|
|
284
|
+
const normalized = normalizeConfig(config);
|
|
285
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
286
|
+
await withFileMutationQueue(configPath, async () => {
|
|
287
|
+
const temporaryPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
|
|
288
|
+
try {
|
|
289
|
+
await writeFile(temporaryPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
|
290
|
+
await rename(temporaryPath, configPath);
|
|
291
|
+
} finally {
|
|
292
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function errorMessage(error: unknown): string {
|
|
298
|
+
return error instanceof Error ? error.message : String(error);
|
|
299
|
+
}
|
package/src/dispatch.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `subagent` tool: dispatches
|
|
2
|
+
* The `subagent` tool: dispatches explorer/worker/cleaner/reviewer agents as isolated pi
|
|
3
3
|
* child processes, single or parallel. Owns the public dispatch contract,
|
|
4
4
|
* per-run status tracking, the auto-fix chain (REVIEW_FAIL → worker → re-review),
|
|
5
5
|
* and completion delivery. Stable thread generations live in thread-lifecycle.ts.
|
|
@@ -134,32 +134,15 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
134
134
|
name: "subagent",
|
|
135
135
|
label: "Subagent",
|
|
136
136
|
description: [
|
|
137
|
-
"
|
|
138
|
-
"Built-
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
142
|
-
"Use subagent_control to steer, retarget, park, resume, or fork
|
|
143
|
-
"It starts agents in the background and immediately returns control to the main window; completion messages automatically wake the main agent to continue.",
|
|
144
|
-
"Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output).",
|
|
145
|
-
"Results arrive as wake-up messages automatically — you do NOT need to wait. If you must get a result in-turn, subagent_wait is a non-blocking lookup by default (pass timeoutMs to block).",
|
|
137
|
+
"Dispatch enabled specialized agents as isolated leaf Pi child processes, singly or in parallel.",
|
|
138
|
+
"Built-ins: explorer for broad read-only reconnaissance; worker for implementation; cleaner only for explicitly authorized cleanup/removal/simplification edits; reviewer for generic read-only assessments and pre-commit gates.",
|
|
139
|
+
"Work starts in the background; completion automatically resumes the main agent and is already shown to the user, so do not poll or restate it. Give each child a self-contained brief because it has no conversation memory.",
|
|
140
|
+
"Single tasks default to shared; parallel workers default to detached Git worktrees. Only write-capable agents can use worktree isolation, and failures never fall back silently to shared.",
|
|
141
|
+
"A selected-model or provider failure continues the retained session on the current main model; ordinary tool/task failures do not.",
|
|
142
|
+
"Use subagent_control to steer, retarget, park, resume, or fork by stable run id.",
|
|
146
143
|
].join(" "),
|
|
147
144
|
promptSnippet:
|
|
148
|
-
"
|
|
149
|
-
promptGuidelines: [
|
|
150
|
-
"Delegate only when an isolated context genuinely pays: broad exploration, a self-contained implementation, explicit evidence-first cleanup, or a review gate. Handle simple lookups and one-line edits inline with direct tools — never spawn a sub-agent for them.",
|
|
151
|
-
"Use subagent with agent 'explore' for broad or open-ended code search before large changes; a targeted 'where is X' is a direct grep/read.",
|
|
152
|
-
"Treat explore output as a retrieval index, not authority: re-read load-bearing files before editing or deciding deletion, security, compatibility, persistence, or dynamic reachability. The cheapest model can cost more through rework on complex dynamic, concurrent, migration, or security-sensitive code; choose a stronger model or specialist there.",
|
|
153
|
-
"Use subagent with agent 'worker' for a self-contained implementation task worth a separate context; it plans internally.",
|
|
154
|
-
"When cleaner is enabled, use subagent with agent 'cleaner' only for explicit cleanup intent in any language (for example dead code, redundancy, simplification, or over-engineering) or a requested periodic cleanup pass. Audit/find/inspect/report means read-only ranked evidence; apply only for explicit remove/clean/simplify/refactor wording. Generic code review goes to reviewer. Never trigger cleaner from PR count or as a pre-commit gate; send non-trivial cleaner edits to reviewer.",
|
|
155
|
-
"Use subagent with agent 'reviewer' for the fresh read-only gate before reporting non-trivial work done or committing, including after cleaner edits.",
|
|
156
|
-
"subagent launches work in the background and ends the current turn; when a result arrives, the main agent is automatically resumed with it.",
|
|
157
|
-
"Run independent tasks in parallel by passing a tasks array to subagent; parallel worker items default to isolation: worktree so their edits are integrated independently. Pass isolation: shared only when workers intentionally need the caller's live uncommitted tree.",
|
|
158
|
-
"Use isolation: worktree only for worker, cleaner, or another write-capable agent and only inside a Git repository with a committed HEAD; parallel worker tasks default to worktree, while cleaner must opt in. Setup or integration failures never silently fall back to shared.",
|
|
159
|
-
"NEVER sleep or poll, and do NOT call subagent_wait to hold the turn — subagent ends the turn immediately and the result arrives as a message that wakes you automatically (even mid-turn). Ending your turn is the default and the only correct way to wait.",
|
|
160
|
-
"If you must keep the turn for a result, call subagent_wait with an explicit timeoutMs (non-blocking by default) — never bash sleep/timeout to wait for a sub-agent.",
|
|
161
|
-
"When a sub-agent result arrives it is already shown to the user — do NOT restate, paraphrase, or summarize it; reply with only your own conclusion or next action (often just one line), since duplicating the result wastes tokens for nothing.",
|
|
162
|
-
],
|
|
145
|
+
"Dispatch isolated background agents: explorer (recon), worker (implementation), cleaner (authorized cleanup), reviewer (read-only assessment/gate); results resume automatically. Use direct tools for trivial work.",
|
|
163
146
|
parameters: SubagentParams,
|
|
164
147
|
|
|
165
148
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|