@ferris1225/pi-subagents 4.3.0 → 4.3.2
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/CHANGELOG.md +25 -0
- package/README.md +91 -131
- package/agents/artisan.md +11 -39
- package/agents/scout.md +11 -28
- package/agents/steward.md +11 -43
- package/package.json +1 -1
- package/src/agents.ts +31 -18
- package/src/announcements.ts +9 -23
- package/src/background.ts +56 -9
- package/src/completion.ts +0 -6
- package/src/config.ts +11 -180
- package/src/dispatch.ts +48 -52
- package/src/durable.ts +6 -53
- package/src/index.ts +6 -9
- package/src/prompt.ts +101 -46
- package/src/recovery.ts +35 -10
- package/src/rpc-run.ts +59 -1
- package/src/runtime.ts +74 -17
- package/src/setup.ts +5 -19
- package/src/spawn.ts +6 -4
- package/src/thread-lifecycle.ts +119 -75
- package/src/tools.ts +4 -20
package/src/agents.ts
CHANGED
|
@@ -39,7 +39,17 @@ const SHELL_TOOL_NAMES = new Set(["bash", "powershell"]);
|
|
|
39
39
|
* Only used to break a tie when the parent has both enabled — a parent running a
|
|
40
40
|
* single shell is followed as configured, whatever it is. */
|
|
41
41
|
const NATIVE_SHELL_TOOL = process.platform === "win32" ? "powershell" : "bash";
|
|
42
|
-
const
|
|
42
|
+
const READ_ONLY_TOOL_NAMES = new Set([
|
|
43
|
+
"read",
|
|
44
|
+
"grep",
|
|
45
|
+
"find",
|
|
46
|
+
"ls",
|
|
47
|
+
"anchor_grep",
|
|
48
|
+
"web_search",
|
|
49
|
+
"fetch_content",
|
|
50
|
+
"resolve-library-id",
|
|
51
|
+
"query-docs",
|
|
52
|
+
]);
|
|
43
53
|
export const SUBAGENT_TOOL_NAMES = [
|
|
44
54
|
"subagent",
|
|
45
55
|
"subagent_control",
|
|
@@ -48,21 +58,26 @@ export const SUBAGENT_TOOL_NAMES = [
|
|
|
48
58
|
const SUBAGENT_TOOL_NAME_SET = new Set<string>(SUBAGENT_TOOL_NAMES);
|
|
49
59
|
|
|
50
60
|
/** Resolve every child against the parent's live tool selection. Roles without
|
|
51
|
-
* an allowlist inherit the complete active set. Explicit lists
|
|
52
|
-
* declared
|
|
53
|
-
* SDK tools. pi-subagents controls
|
|
61
|
+
* an allowlist inherit the complete active set. Explicit lists are strict: they
|
|
62
|
+
* keep only declared tools that are active in the parent, with shell adaptation.
|
|
63
|
+
* Active extension/SDK tools are never added implicitly. pi-subagents controls
|
|
64
|
+
* are always removed so children stay leaves.
|
|
54
65
|
*
|
|
55
66
|
* A declared shell is one slot, so it resolves to one shell: the parent's, and
|
|
56
67
|
* the host-native one when the parent runs both. A child never inherits a shell
|
|
57
|
-
* the parent does not have
|
|
58
|
-
* `defaultTools` setting, so naming a shell the user disabled would hand it a
|
|
59
|
-
* terminal they deliberately turned off. */
|
|
68
|
+
* the parent does not have. */
|
|
60
69
|
export function resolveAgentTools(
|
|
61
70
|
agent: AgentConfig,
|
|
62
71
|
activeToolNames: readonly string[],
|
|
63
72
|
): AgentConfig {
|
|
64
73
|
const active = [...new Set(activeToolNames)].filter((tool) => !SUBAGENT_TOOL_NAME_SET.has(tool));
|
|
65
|
-
if (!agent.tools)
|
|
74
|
+
if (!agent.tools) {
|
|
75
|
+
return { ...agent, tools: agent.name === "scout" ? active.filter((tool) => READ_ONLY_TOOL_NAMES.has(tool)) : active };
|
|
76
|
+
}
|
|
77
|
+
const declaredTools = agent.name === "scout"
|
|
78
|
+
? agent.tools.filter((tool) => READ_ONLY_TOOL_NAMES.has(tool))
|
|
79
|
+
: agent.tools;
|
|
80
|
+
const activeSet = new Set(active);
|
|
66
81
|
|
|
67
82
|
const parentShellTools = active.filter((tool) => SHELL_TOOL_NAMES.has(tool));
|
|
68
83
|
const activeShellTools = parentShellTools.length > 1 && parentShellTools.includes(NATIVE_SHELL_TOOL)
|
|
@@ -70,32 +85,30 @@ export function resolveAgentTools(
|
|
|
70
85
|
: parentShellTools;
|
|
71
86
|
const tools: string[] = [];
|
|
72
87
|
let shellAdapted = false;
|
|
73
|
-
for (const tool of
|
|
88
|
+
for (const tool of declaredTools) {
|
|
89
|
+
if (SUBAGENT_TOOL_NAME_SET.has(tool)) continue;
|
|
74
90
|
if (SHELL_TOOL_NAMES.has(tool)) {
|
|
75
91
|
if (!shellAdapted) tools.push(...activeShellTools);
|
|
76
92
|
shellAdapted = true;
|
|
77
|
-
} else if (
|
|
93
|
+
} else if (activeSet.has(tool) && !tools.includes(tool)) {
|
|
78
94
|
tools.push(tool);
|
|
79
95
|
}
|
|
80
96
|
}
|
|
81
|
-
for (const tool of active) {
|
|
82
|
-
if (PI_BUILTIN_TOOL_NAMES.has(tool) || tools.includes(tool)) continue;
|
|
83
|
-
tools.push(tool);
|
|
84
|
-
}
|
|
85
97
|
return { ...agent, tools };
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
/** Filesystem-write capability used by worktree admission and repository-lane
|
|
89
|
-
* safety.
|
|
90
|
-
*
|
|
91
|
-
*
|
|
101
|
+
* safety. Scout is a hard read-only boundary even with a project override.
|
|
102
|
+
* Omitted allowlists inherit arbitrary active tools and are therefore mutable.
|
|
103
|
+
* For explicit allowlists, only the canonical retrieval tools are proven
|
|
104
|
+
* read-only; shells and unknown custom tools are conservatively mutable. */
|
|
92
105
|
export function isWriteCapableAgent(
|
|
93
106
|
agent: Pick<AgentConfig, "name" | "tools">,
|
|
94
107
|
): boolean {
|
|
95
108
|
if (agent.name === "scout") return false;
|
|
96
109
|
if (agent.name === "artisan" || agent.name === "steward") return true;
|
|
97
110
|
if (!agent.tools) return true;
|
|
98
|
-
return agent.tools.
|
|
111
|
+
return agent.tools.some((tool) => !READ_ONLY_TOOL_NAMES.has(tool));
|
|
99
112
|
}
|
|
100
113
|
|
|
101
114
|
const here = dirname(fileURLToPath(import.meta.url));
|
package/src/announcements.ts
CHANGED
|
@@ -1,21 +1,16 @@
|
|
|
1
|
-
/** Session-start recovery, stale-
|
|
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
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
|
-
|
|
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,7 +27,7 @@ async function migrateUnavailableAgentModels(
|
|
|
32
27
|
"warning",
|
|
33
28
|
);
|
|
34
29
|
} catch {
|
|
35
|
-
/*
|
|
30
|
+
/* stale-model cleanup is non-fatal */
|
|
36
31
|
}
|
|
37
32
|
}
|
|
38
33
|
|
|
@@ -41,20 +36,11 @@ export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime
|
|
|
41
36
|
if (!existsSync(runtime.configPath)) {
|
|
42
37
|
ctx.ui.notify(`pi-subagents: ${FIRST_RUN_SETUP_HINT}`, "info");
|
|
43
38
|
}
|
|
39
|
+
await relocateRecoveryManifest(runtime.configPath);
|
|
44
40
|
await announceRecoveryRecords(runtime.configPath, ctx);
|
|
45
|
-
await
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (config.pendingSetupNotice) {
|
|
49
|
-
ctx.ui.notify(`pi-subagents: ${config.pendingSetupNotice}`, "info");
|
|
50
|
-
const { pendingSetupNotice: _cleared, ...rest } = config;
|
|
51
|
-
await saveConfig(rest, runtime.configPath);
|
|
52
|
-
}
|
|
53
|
-
} catch {
|
|
54
|
-
/* notice is non-fatal */
|
|
55
|
-
}
|
|
56
|
-
// Restore starts at extension load and session_start fires right behind
|
|
57
|
-
// 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.
|
|
58
44
|
await runtime.durableRestore;
|
|
59
45
|
if (!runtime.restoredNotified && runtime.restoredRunIds.length > 0) {
|
|
60
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:
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
@@ -15,26 +15,6 @@ import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-ag
|
|
|
15
15
|
/** Full catalog of agents shipped with the package (selectable in /subagents-setup). */
|
|
16
16
|
export const BUILTIN_AGENT_NAMES = ["scout", "artisan", "steward"] as const;
|
|
17
17
|
|
|
18
|
-
/** Every shipped role stays enabled. The setup wizard and load-time adopt both
|
|
19
|
-
* force the full team on so a stale allow-list cannot hide scout, artisan, or
|
|
20
|
-
* steward. */
|
|
21
|
-
export const REQUIRED_ENABLED_AGENTS = [...BUILTIN_AGENT_NAMES] as const;
|
|
22
|
-
|
|
23
|
-
/** Built-in agent names this package no longer ships. Loading an older config
|
|
24
|
-
* prunes them from every record so the setup wizard, dispatch catalog, and
|
|
25
|
-
* model-routing table never surface dead roles. Custom names stay untouched —
|
|
26
|
-
* except one that reuses a removed built-in name, which this cleanup cannot
|
|
27
|
-
* distinguish and deliberately treats as retired. */
|
|
28
|
-
export const REMOVED_BUILTIN_AGENT_NAMES = [
|
|
29
|
-
"worker",
|
|
30
|
-
"cleaner",
|
|
31
|
-
"documenter",
|
|
32
|
-
"synthesizer",
|
|
33
|
-
"reviewer",
|
|
34
|
-
"explorer",
|
|
35
|
-
"executor",
|
|
36
|
-
] as const;
|
|
37
|
-
|
|
38
18
|
/** Agents enabled out of the box on a fresh install. */
|
|
39
19
|
export const DEFAULT_ENABLED_AGENTS: readonly string[] = [...BUILTIN_AGENT_NAMES];
|
|
40
20
|
|
|
@@ -72,15 +52,15 @@ export interface AgentProfile {
|
|
|
72
52
|
export const AGENT_PROFILES: Record<(typeof BUILTIN_AGENT_NAMES)[number], AgentProfile> = {
|
|
73
53
|
scout: {
|
|
74
54
|
summary: "read-only recon",
|
|
75
|
-
remark: "
|
|
55
|
+
remark: "Broad or unknown reconnaissance. Returns decisive citations as leads, never proof.",
|
|
76
56
|
},
|
|
77
57
|
artisan: {
|
|
78
58
|
summary: "implement / fix",
|
|
79
|
-
remark: "
|
|
59
|
+
remark: "Owns implementation, code refactors, and directly affected tests/docs; a disproved defect means zero edits.",
|
|
80
60
|
},
|
|
81
61
|
steward: {
|
|
82
|
-
summary: "
|
|
83
|
-
remark: "
|
|
62
|
+
summary: "pre-commit finish",
|
|
63
|
+
remark: "Cleans a completed broad or multi-writer diff and synchronizes cross-cutting docs/comments without changing behavior.",
|
|
84
64
|
},
|
|
85
65
|
};
|
|
86
66
|
|
|
@@ -109,11 +89,6 @@ export const IDLE_TIMEOUT_SEC_LIMIT = 600;
|
|
|
109
89
|
export interface SubagentsConfig {
|
|
110
90
|
/** Agent names that are discoverable and injected. Fresh-install default: every built-in agent. */
|
|
111
91
|
enabledAgents: string[];
|
|
112
|
-
/** Built-in names this config has already surfaced. A shipped agent outside
|
|
113
|
-
* this set is new in an upgrade: loadConfig enables it instead of leaving it
|
|
114
|
-
* dark behind a stale allow-list. Bookkeeping only — maintained automatically,
|
|
115
|
-
* and it is what keeps an explicit disable from being undone. */
|
|
116
|
-
knownAgents: string[];
|
|
117
92
|
/** Per-agent model override, keyed by agent name, as "provider/model-id". */
|
|
118
93
|
agentModels: Record<string, string>;
|
|
119
94
|
/** Optional per-agent thinking override from `/subagents-setup`. Missing =
|
|
@@ -133,13 +108,10 @@ export interface SubagentsConfig {
|
|
|
133
108
|
* off to the current main model. 0 disables the idle watchdog. Default: 90.
|
|
134
109
|
*/
|
|
135
110
|
idleTimeoutSec: number;
|
|
136
|
-
/** One-shot session notice after a catalog migration; announcements clears it. */
|
|
137
|
-
pendingSetupNotice?: string;
|
|
138
111
|
}
|
|
139
112
|
|
|
140
113
|
export const DEFAULT_CONFIG: SubagentsConfig = {
|
|
141
114
|
enabledAgents: [...DEFAULT_ENABLED_AGENTS],
|
|
142
|
-
knownAgents: [...BUILTIN_AGENT_NAMES],
|
|
143
115
|
agentModels: {},
|
|
144
116
|
agentThinkingLevels: {},
|
|
145
117
|
maxResultLines: DEFAULT_MAX_RESULT_LINES,
|
|
@@ -148,13 +120,10 @@ export const DEFAULT_CONFIG: SubagentsConfig = {
|
|
|
148
120
|
};
|
|
149
121
|
|
|
150
122
|
export const FIRST_RUN_SETUP_HINT =
|
|
151
|
-
"Run /subagents-setup
|
|
152
|
-
"
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
|
|
156
|
-
export const TEAM_SETUP_NOTICE =
|
|
157
|
-
"The team is now scout, artisan, and steward. Run /subagents-setup to pick a model for each; thinking has a role default you can change there.";
|
|
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.";
|
|
158
127
|
|
|
159
128
|
export function getConfigPath(agentDir: string = getAgentDir()): string {
|
|
160
129
|
return join(agentDir, CONFIG_FILE_NAME);
|
|
@@ -197,20 +166,6 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
|
|
|
197
166
|
config.enabledAgents = [...new Set(names.map((name) => name.trim()))];
|
|
198
167
|
}
|
|
199
168
|
|
|
200
|
-
// Known-agent bookkeeping starts empty for a parsed record (not the fresh
|
|
201
|
-
// default) so loadConfig can still tell which shipped agents this config
|
|
202
|
-
// has never seen. Every enabled name was necessarily surfaced.
|
|
203
|
-
config.knownAgents = [];
|
|
204
|
-
if (Array.isArray(raw.knownAgents)) {
|
|
205
|
-
const names = raw.knownAgents.filter(
|
|
206
|
-
(name): name is string => typeof name === "string" && name.trim().length > 0,
|
|
207
|
-
);
|
|
208
|
-
config.knownAgents = [...new Set(names.map((name) => name.trim()))];
|
|
209
|
-
}
|
|
210
|
-
for (const name of config.enabledAgents) {
|
|
211
|
-
if (!config.knownAgents.includes(name)) config.knownAgents.push(name);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
169
|
if (isRecord(raw.agentModels)) {
|
|
215
170
|
for (const [rawKey, value] of Object.entries(raw.agentModels)) {
|
|
216
171
|
const key = rawKey.trim();
|
|
@@ -245,10 +200,6 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
|
|
|
245
200
|
config.idleTimeoutSec = Math.max(0, Math.min(IDLE_TIMEOUT_SEC_LIMIT, Math.round(raw.idleTimeoutSec)));
|
|
246
201
|
}
|
|
247
202
|
|
|
248
|
-
if (typeof raw.pendingSetupNotice === "string" && raw.pendingSetupNotice.trim()) {
|
|
249
|
-
config.pendingSetupNotice = raw.pendingSetupNotice.trim();
|
|
250
|
-
}
|
|
251
|
-
|
|
252
203
|
return config;
|
|
253
204
|
}
|
|
254
205
|
|
|
@@ -261,124 +212,10 @@ function defaultConfig(): SubagentsConfig {
|
|
|
261
212
|
};
|
|
262
213
|
}
|
|
263
214
|
|
|
264
|
-
export function withRequiredAgents(enabled: readonly string[]): string[] {
|
|
265
|
-
const next = [...enabled];
|
|
266
|
-
for (const name of REQUIRED_ENABLED_AGENTS) {
|
|
267
|
-
if (!next.includes(name)) next.push(name);
|
|
268
|
-
}
|
|
269
|
-
return next;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function forceRequiredAgents(config: SubagentsConfig): SubagentsConfig {
|
|
273
|
-
const enabledAgents = withRequiredAgents(config.enabledAgents);
|
|
274
|
-
const knownAgents = [...config.knownAgents];
|
|
275
|
-
for (const name of REQUIRED_ENABLED_AGENTS) {
|
|
276
|
-
if (!knownAgents.includes(name)) knownAgents.push(name);
|
|
277
|
-
}
|
|
278
|
-
return { ...config, enabledAgents, knownAgents };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Drop every removed built-in role from an already-normalized config: enabled
|
|
283
|
-
* and known lists, plus per-agent model and thinking routes. The schema-upgrade
|
|
284
|
-
* persistence in loadConfig writes the pruned shape back to disk.
|
|
285
|
-
*/
|
|
286
|
-
function pruneRemovedBuiltins(config: SubagentsConfig): SubagentsConfig {
|
|
287
|
-
const removed = new Set<string>(REMOVED_BUILTIN_AGENT_NAMES);
|
|
288
|
-
const filter = (names: readonly string[]): string[] => names.filter((name) => !removed.has(name));
|
|
289
|
-
const agentModels = { ...config.agentModels };
|
|
290
|
-
const agentThinkingLevels = { ...config.agentThinkingLevels };
|
|
291
|
-
for (const name of REMOVED_BUILTIN_AGENT_NAMES) {
|
|
292
|
-
delete agentModels[name];
|
|
293
|
-
delete agentThinkingLevels[name];
|
|
294
|
-
}
|
|
295
|
-
return {
|
|
296
|
-
...config,
|
|
297
|
-
enabledAgents: filter(config.enabledAgents),
|
|
298
|
-
knownAgents: filter(config.knownAgents),
|
|
299
|
-
agentModels,
|
|
300
|
-
agentThinkingLevels,
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* A shipped agent the config has never recorded is new in this release; the
|
|
306
|
-
* stale allow-list must not keep it dark. Enable it. Model and thinking stay
|
|
307
|
-
* unset so the role default and current main model apply until setup.
|
|
308
|
-
*/
|
|
309
|
-
function adoptNewBuiltins(config: SubagentsConfig): { config: SubagentsConfig; fresh: string[] } {
|
|
310
|
-
const known = new Set(config.knownAgents);
|
|
311
|
-
const fresh = BUILTIN_AGENT_NAMES.filter((name) => !known.has(name));
|
|
312
|
-
if (fresh.length === 0) return { config, fresh };
|
|
313
|
-
return {
|
|
314
|
-
config: {
|
|
315
|
-
...config,
|
|
316
|
-
enabledAgents: [...config.enabledAgents, ...fresh],
|
|
317
|
-
knownAgents: [...known, ...fresh],
|
|
318
|
-
},
|
|
319
|
-
fresh,
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// --- v4 → current catalog migration. Delete this block in the next major. ---
|
|
324
|
-
|
|
325
|
-
const RENAMED_BUILTIN_AGENTS: Record<string, string> = {
|
|
326
|
-
explorer: "scout",
|
|
327
|
-
executor: "artisan",
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
function renameNameList(names: readonly string[]): { names: string[]; changed: boolean } {
|
|
331
|
-
let changed = false;
|
|
332
|
-
const out: string[] = [];
|
|
333
|
-
for (const name of names) {
|
|
334
|
-
const mapped = RENAMED_BUILTIN_AGENTS[name] ?? name;
|
|
335
|
-
if (mapped !== name) changed = true;
|
|
336
|
-
if (!out.includes(mapped)) out.push(mapped);
|
|
337
|
-
}
|
|
338
|
-
return { names: out, changed };
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function renameKeyedRecord<T>(record: Record<string, T>): { record: Record<string, T>; changed: boolean } {
|
|
342
|
-
let changed = false;
|
|
343
|
-
const out: Record<string, T> = {};
|
|
344
|
-
for (const [key, value] of Object.entries(record)) {
|
|
345
|
-
const mapped = RENAMED_BUILTIN_AGENTS[key] ?? key;
|
|
346
|
-
if (mapped !== key) changed = true;
|
|
347
|
-
if (!(mapped in out)) out[mapped] = value;
|
|
348
|
-
}
|
|
349
|
-
return { record: out, changed };
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/** Map retired built-in names onto the current catalog and keep their models
|
|
353
|
-
* and thinking overrides. Isolated so the next major can delete it. */
|
|
354
|
-
function migrateRetiredBuiltinNames(config: SubagentsConfig): { config: SubagentsConfig; changed: boolean } {
|
|
355
|
-
const enabled = renameNameList(config.enabledAgents);
|
|
356
|
-
const known = renameNameList(config.knownAgents);
|
|
357
|
-
const models = renameKeyedRecord(config.agentModels);
|
|
358
|
-
const thinking = renameKeyedRecord(config.agentThinkingLevels);
|
|
359
|
-
const changed = enabled.changed || known.changed || models.changed || thinking.changed;
|
|
360
|
-
if (!changed) return { config, changed: false };
|
|
361
|
-
return {
|
|
362
|
-
changed: true,
|
|
363
|
-
config: {
|
|
364
|
-
...config,
|
|
365
|
-
enabledAgents: enabled.names,
|
|
366
|
-
knownAgents: known.names,
|
|
367
|
-
agentModels: models.record,
|
|
368
|
-
agentThinkingLevels: thinking.record,
|
|
369
|
-
},
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// --- end v4 catalog migration ---
|
|
374
|
-
|
|
375
215
|
/**
|
|
376
216
|
* Load config. A missing file is a normal state and yields the defaults (not an error).
|
|
377
217
|
* A corrupt file also falls back to defaults rather than throwing, so startup never breaks.
|
|
378
|
-
*
|
|
379
|
-
* normalized and persisted back, so the on-disk config stays current. Built-in
|
|
380
|
-
* agents the file has never seen are adopted. Retired built-in names are
|
|
381
|
-
* renamed or pruned. Artisan and steward stay enabled.
|
|
218
|
+
* Valid fields are normalized and unknown fields are omitted when the config is saved.
|
|
382
219
|
*/
|
|
383
220
|
export async function loadConfig(configPath: string = getConfigPath()): Promise<SubagentsConfig> {
|
|
384
221
|
let text: string;
|
|
@@ -396,15 +233,9 @@ export async function loadConfig(configPath: string = getConfigPath()): Promise<
|
|
|
396
233
|
return defaultConfig();
|
|
397
234
|
}
|
|
398
235
|
|
|
399
|
-
const
|
|
400
|
-
const adopted = adoptNewBuiltins(renamed.config);
|
|
401
|
-
let config = forceRequiredAgents(pruneRemovedBuiltins(adopted.config));
|
|
402
|
-
if ((renamed.changed || adopted.fresh.length > 0) && !config.pendingSetupNotice) {
|
|
403
|
-
config = { ...config, pendingSetupNotice: TEAM_SETUP_NOTICE };
|
|
404
|
-
}
|
|
236
|
+
const config = normalizeConfig(parsed);
|
|
405
237
|
|
|
406
|
-
//
|
|
407
|
-
// (new version) or dropped invalid ones.
|
|
238
|
+
// Persist the canonical shape when invalid or unknown fields were omitted.
|
|
408
239
|
if (JSON.stringify(config) !== JSON.stringify(parsed)) {
|
|
409
240
|
try {
|
|
410
241
|
await saveConfig(config, configPath);
|