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