@ferris1225/pi-subagents 4.2.13 → 4.3.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/src/config.ts CHANGED
@@ -13,14 +13,27 @@ import { dirname, join } from "node:path";
13
13
  import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
14
14
 
15
15
  /** Full catalog of agents shipped with the package (selectable in /subagents-setup). */
16
- export const BUILTIN_AGENT_NAMES = ["explorer", "executor"] as const;
16
+ export const BUILTIN_AGENT_NAMES = ["scout", "artisan", "steward"] as const;
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;
17
22
 
18
23
  /** Built-in agent names this package no longer ships. Loading an older config
19
24
  * prunes them from every record so the setup wizard, dispatch catalog, and
20
25
  * model-routing table never surface dead roles. Custom names stay untouched —
21
26
  * except one that reuses a removed built-in name, which this cleanup cannot
22
27
  * distinguish and deliberately treats as retired. */
23
- export const REMOVED_BUILTIN_AGENT_NAMES = ["worker", "cleaner", "documenter", "synthesizer", "reviewer"] as const;
28
+ export const REMOVED_BUILTIN_AGENT_NAMES = [
29
+ "worker",
30
+ "cleaner",
31
+ "documenter",
32
+ "synthesizer",
33
+ "reviewer",
34
+ "explorer",
35
+ "executor",
36
+ ] as const;
24
37
 
25
38
  /** Agents enabled out of the box on a fresh install. */
26
39
  export const DEFAULT_ENABLED_AGENTS: readonly string[] = [...BUILTIN_AGENT_NAMES];
@@ -31,7 +44,49 @@ export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
31
44
  /** Thinking levels accepted by pi's `--thinking` option. */
32
45
  export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
33
46
  export type ThinkingLevel = (typeof THINKING_LEVEL_VALUES)[number];
34
- export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "high";
47
+ export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium";
48
+
49
+ /** Role-owned default reasoning strength. A `/subagents-setup` override wins;
50
+ * there is no per-call or frontmatter thinking. */
51
+ export function roleThinkingLevel(agentName: string): ThinkingLevel {
52
+ switch (agentName) {
53
+ case "scout":
54
+ return "low";
55
+ case "artisan":
56
+ return "high";
57
+ case "steward":
58
+ return "medium";
59
+ default:
60
+ return DEFAULT_THINKING_LEVEL;
61
+ }
62
+ }
63
+
64
+ /** Short responsibility line shown next to each built-in in setup lists. */
65
+ export interface AgentProfile {
66
+ /** A few words for picker rows. */
67
+ summary: string;
68
+ /** What this role owns, for first-run copy and the configure step. */
69
+ remark: string;
70
+ }
71
+
72
+ export const AGENT_PROFILES: Record<(typeof BUILTIN_AGENT_NAMES)[number], AgentProfile> = {
73
+ scout: {
74
+ summary: "read-only recon",
75
+ remark: "Maps unfamiliar code. Returns exact paths and leads — never proof. Broad search, symbols, dependencies.",
76
+ },
77
+ artisan: {
78
+ summary: "implement / fix",
79
+ remark: "Writes the change. One deliverable: implement, fix, refactor, or test. Confirms a named defect before editing.",
80
+ },
81
+ steward: {
82
+ summary: "tidy when needed",
83
+ remark: "Use only when that work exists: evidence-first cleanup, docs/comment sync, or merging fan-out results.",
84
+ },
85
+ };
86
+
87
+ export function agentProfile(name: string): AgentProfile | undefined {
88
+ return (AGENT_PROFILES as Record<string, AgentProfile | undefined>)[name];
89
+ }
35
90
 
36
91
  /** How many lines of a sub-agent result the completion message may carry.
37
92
  * Default: 40 — wide fan-outs multiply completion blocks, so deliveries stay
@@ -61,7 +116,8 @@ export interface SubagentsConfig {
61
116
  knownAgents: string[];
62
117
  /** Per-agent model override, keyed by agent name, as "provider/model-id". */
63
118
  agentModels: Record<string, string>;
64
- /** Optional per-agent thinking preference. Runtime clamps it to the effective model's supported levels. */
119
+ /** Optional per-agent thinking override from `/subagents-setup`. Missing =
120
+ * the role default from `roleThinkingLevel`. */
65
121
  agentThinkingLevels: Record<string, ThinkingLevel>;
66
122
  /**
67
123
  * Max lines of a sub-agent result carried in the completion message. Longer
@@ -77,6 +133,8 @@ export interface SubagentsConfig {
77
133
  * off to the current main model. 0 disables the idle watchdog. Default: 90.
78
134
  */
79
135
  idleTimeoutSec: number;
136
+ /** One-shot session notice after a catalog migration; announcements clears it. */
137
+ pendingSetupNotice?: string;
80
138
  }
81
139
 
82
140
  export const DEFAULT_CONFIG: SubagentsConfig = {
@@ -89,6 +147,15 @@ export const DEFAULT_CONFIG: SubagentsConfig = {
89
147
  idleTimeoutSec: DEFAULT_IDLE_TIMEOUT_SEC,
90
148
  };
91
149
 
150
+ export const FIRST_RUN_SETUP_HINT =
151
+ "Run /subagents-setup: pick a model for scout, artisan, and steward. " +
152
+ "scout maps code (leads, not proof). artisan implements, fixes, refactors, or tests. " +
153
+ "steward tidies or merges only when that work exists. All three stay on. " +
154
+ "Each role has a thinking default you can change in setup.";
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.";
158
+
92
159
  export function getConfigPath(agentDir: string = getAgentDir()): string {
93
160
  return join(agentDir, CONFIG_FILE_NAME);
94
161
  }
@@ -178,6 +245,10 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
178
245
  config.idleTimeoutSec = Math.max(0, Math.min(IDLE_TIMEOUT_SEC_LIMIT, Math.round(raw.idleTimeoutSec)));
179
246
  }
180
247
 
248
+ if (typeof raw.pendingSetupNotice === "string" && raw.pendingSetupNotice.trim()) {
249
+ config.pendingSetupNotice = raw.pendingSetupNotice.trim();
250
+ }
251
+
181
252
  return config;
182
253
  }
183
254
 
@@ -190,6 +261,23 @@ function defaultConfig(): SubagentsConfig {
190
261
  };
191
262
  }
192
263
 
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
+
193
281
  /**
194
282
  * Drop every removed built-in role from an already-normalized config: enabled
195
283
  * and known lists, plus per-agent model and thinking routes. The schema-upgrade
@@ -215,38 +303,82 @@ function pruneRemovedBuiltins(config: SubagentsConfig): SubagentsConfig {
215
303
 
216
304
  /**
217
305
  * 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.
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.
221
308
  */
222
- function adoptNewBuiltins(config: SubagentsConfig): SubagentsConfig {
309
+ function adoptNewBuiltins(config: SubagentsConfig): { config: SubagentsConfig; fresh: string[] } {
223
310
  const known = new Set(config.knownAgents);
224
311
  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
- }
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);
233
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 };
234
361
  return {
235
- ...config,
236
- enabledAgents: [...config.enabledAgents, ...fresh],
237
- knownAgents: [...known, ...fresh],
238
- agentModels,
239
- agentThinkingLevels,
362
+ changed: true,
363
+ config: {
364
+ ...config,
365
+ enabledAgents: enabled.names,
366
+ knownAgents: known.names,
367
+ agentModels: models.record,
368
+ agentThinkingLevels: thinking.record,
369
+ },
240
370
  };
241
371
  }
242
372
 
373
+ // --- end v4 catalog migration ---
374
+
243
375
  /**
244
376
  * Load config. A missing file is a normal state and yields the defaults (not an error).
245
377
  * A corrupt file also falls back to defaults rather than throwing, so startup never breaks.
246
378
  * A file from an older version (missing newer keys or holding extra keys) is
247
379
  * 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.
380
+ * agents the file has never seen are adopted. Retired built-in names are
381
+ * renamed or pruned. Artisan and steward stay enabled.
250
382
  */
251
383
  export async function loadConfig(configPath: string = getConfigPath()): Promise<SubagentsConfig> {
252
384
  let text: string;
@@ -264,10 +396,12 @@ export async function loadConfig(configPath: string = getConfigPath()): Promise<
264
396
  return defaultConfig();
265
397
  }
266
398
 
267
- // Adopt newly shipped roles first so the prune below works on the final
268
- // catalog, then drop roles this package stopped shipping and persist the
269
- // cleaned shape back to disk.
270
- const config = pruneRemovedBuiltins(adoptNewBuiltins(normalizeConfig(parsed)));
399
+ const renamed = migrateRetiredBuiltinNames(normalizeConfig(parsed));
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
+ }
271
405
 
272
406
  // Schema upgrade: persist the normalized shape when the file gained fields
273
407
  // (new version) or dropped invalid ones.
package/src/dispatch.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The `subagent` tool: dispatches the enabled agents (explorer, executor,
2
+ * The `subagent` tool: dispatches the enabled agents (scout, artisan, steward,
3
3
  * plus custom roles) as isolated pi
4
4
  * child processes, single or parallel. Owns the public dispatch contract and
5
5
  * per-run status tracking. Stable thread generations, final integration, and
@@ -12,7 +12,7 @@ import { Text } from "@earendil-works/pi-tui";
12
12
  import { join, resolve } from "node:path";
13
13
  import { Type } from "typebox";
14
14
  import { discoverAgents, isWriteCapableAgent, resolveAgentTools, type AgentConfig } from "./agents.ts";
15
- import { loadConfig, THINKING_LEVEL_VALUES, type ThinkingLevel } from "./config.ts";
15
+ import { loadConfig } from "./config.ts";
16
16
  import { formatCompletionBlock, formatUsage, queuedResult } from "./format.ts";
17
17
  import {
18
18
  formatTaskSummary,
@@ -51,19 +51,12 @@ export { isWorktreeCapableAgent, runInManagedRepositoryLane } from "./thread-lif
51
51
  const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
52
52
 
53
53
  const ISOLATION_DESCRIPTION =
54
- "Filesystem isolation: shared uses the caller's working tree; worktree creates a detached temporary Git worktree (write-capable agents, including executor, only)";
54
+ "Filesystem isolation: shared uses the caller's working tree; worktree creates a detached temporary Git worktree (write-capable agents, including artisan and steward, only)";
55
55
 
56
56
  const IsolationSchema = Type.Optional(
57
57
  StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
58
58
  );
59
59
 
60
- const ThinkingSchema = Type.Optional(
61
- StringEnum(THINKING_LEVEL_VALUES, {
62
- description:
63
- "Optional reasoning strength for this task; omit to keep the agent's own level",
64
- }),
65
- );
66
-
67
60
  const WaitSchema = Type.Optional(
68
61
  Type.Boolean({
69
62
  description:
@@ -79,7 +72,6 @@ const TaskItem = Type.Object({
79
72
  }),
80
73
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
81
74
  isolation: IsolationSchema,
82
- thinking: ThinkingSchema,
83
75
  });
84
76
 
85
77
  const SubagentParams = Type.Object({
@@ -90,14 +82,13 @@ const SubagentParams = Type.Object({
90
82
  tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
91
83
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
92
84
  isolation: IsolationSchema,
93
- thinking: ThinkingSchema,
94
85
  wait: WaitSchema,
95
86
  });
96
87
 
97
88
  /** Roles that default to worktree isolation in parallel dispatches even when
98
89
  * the live catalog cannot be consulted (render-only call sites). Custom
99
90
  * write-capable agents join them via isWriteCapableAgent on the execute path. */
100
- const WORKTREE_DEFAULT_AGENTS = new Set(["executor"]);
91
+ const WORKTREE_DEFAULT_AGENTS = new Set(["artisan", "steward"]);
101
92
 
102
93
  /** Resolve the default isolation for a dispatch. Precedence: an explicit
103
94
  * per-call request, then the role's own frontmatter declaration (`worktree`
@@ -390,7 +381,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
390
381
  "A configured child-model failure continues the retained session on the current main model.",
391
382
  ].join(" "),
392
383
  promptSnippet:
393
- "Dispatch isolated background agents for recon, implementation, cleanup, docs sync, or result merging; never blocks your turn, and completions wake you automatically.",
384
+ "Dispatch isolated background agents for recon or implementation, and for cleanup, docs sync, or result merging only when that work exists; never blocks your turn, and completions wake you automatically.",
394
385
  parameters: SubagentParams,
395
386
 
396
387
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -481,7 +472,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
481
472
  catalogAgent ? isWriteCapableAgent(catalogAgent) : undefined,
482
473
  catalogAgent?.isolation,
483
474
  ),
484
- { thinking: item.thinking as ThinkingLevel | undefined },
485
475
  ));
486
476
  }
487
477
  const startedRuns = results.filter((result) => result.exitCode === -1);
@@ -544,7 +534,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
544
534
  singleCatalogAgent ? isWriteCapableAgent(singleCatalogAgent) : undefined,
545
535
  singleCatalogAgent?.isolation,
546
536
  ),
547
- { thinking: params.thinking as ThinkingLevel | undefined },
548
537
  );
549
538
  if (result.exitCode !== -1) {
550
539
  throw new Error(getResultOutput(result));
package/src/index.ts CHANGED
@@ -72,7 +72,7 @@ export default function (pi: ExtensionAPI): void {
72
72
  registerLookupTools(pi, runtime);
73
73
 
74
74
  pi.registerCommand("subagents-setup", {
75
- description: "Configure pi-subagents: agents, selected models, capability-aware thinking, and runtime settings",
75
+ description: "Configure pi-subagents: agents, models, and per-role thinking",
76
76
  handler: async (_args, ctx) => {
77
77
  await runSetup(ctx, configPath);
78
78
  },
package/src/monitor.ts CHANGED
@@ -52,7 +52,7 @@ export interface RunView {
52
52
  model?: string;
53
53
  /** Selected model ref when the run handed off to current main. */
54
54
  modelFallbackFrom?: string;
55
- /** Effective thinking strength this run was launched with (frontmatter/config/global). */
55
+ /** Effective thinking strength this run was launched with (setup override or role default). */
56
56
  thinking?: string;
57
57
  isolation?: IsolationMode;
58
58
  integrationStatus?: RunIntegrationStatus;
@@ -187,7 +187,7 @@ function tailGraphemes(segments: string[], maxWidth: number): string {
187
187
  * One-line task preview, capped by `maxWidth` display columns (default 80).
188
188
  * `keysOnly` (default): extracted key fragments (paths, quoted phrases,
189
189
  * symbols) are shown bare — the agent name is already displayed next to the
190
- * task line, so templated prose ("explorer: trace how ...") adds nothing.
190
+ * task line, so templated prose ("scout: trace how ...") adds nothing.
191
191
  * `keysOnly: false` keeps the prose as `head…tail` (used for completion
192
192
  * messages, where the Task line is the reader's only context).
193
193
  * Grapheme-safe — CJK, ZWJ emoji and combining sequences are never split.
package/src/prompt.ts CHANGED
@@ -19,20 +19,26 @@ export function buildDelegationDirective(
19
19
  if (agents.length === 0) return "";
20
20
 
21
21
  const catalog = agents.map(formatCatalogEntry).join("\n");
22
- const hasExplorer = agents.some((agent) => agent.name === "explorer");
23
- const hasExecutor = agents.some((agent) => agent.name === "executor");
22
+ const hasScout = agents.some((agent) => agent.name === "scout");
23
+ const hasArtisan = agents.some((agent) => agent.name === "artisan");
24
+ const hasSteward = agents.some((agent) => agent.name === "steward");
24
25
 
25
26
  const dispatchRules = [
26
- `Delegate aggressively: child contexts are cheap, yours is scarce. A unit is delegable when it can proceed independently and return a compact result${hasExecutor ? "; when both hold, default it to `executor`" : ""}.`,
27
+ `Delegate aggressively: child contexts are cheap, yours is scarce. A unit is delegable when it can proceed independently and return a compact result${hasArtisan ? "; when the unit is a code change, default it to \`artisan\`" : ""}.`,
27
28
  "Keep inline what fails either test — a lookup, a single focused edit, an answer already in context, or a single artifact you must absorb yourself (one issue, one spec), where delegation saves search, not that read. Cluster related questions into one brief instead of firing many small dispatches; a child loses context between runs.",
28
- ...(hasExplorer
29
+ ...(hasScout
29
30
  ? [
30
- "`explorer`: split a broad question into parallel explorers with disjoint scopes. Its findings are leads, never proof — re-read the cited line ranges before acting on them (a child you brief re-verifies).",
31
+ "`scout`: split a broad question into parallel scouts with disjoint scopes. Its findings are leads, never proof — re-read the cited line ranges before acting on them (a child you brief re-verifies).",
31
32
  ]
32
33
  : []),
33
- ...(hasExecutor
34
+ ...(hasArtisan
34
35
  ? [
35
- "`executor`: brief it as the edit authorization. For cleanup, name the scope (uncommitted diff, Git range, directory) — every safe proven cut applies without per-item approval; finding no safe cut is a valid result. After a wide fan-out, pass the result-artifact paths to one executor and read its merged brief instead of every result yourself.",
36
+ "`artisan`: brief it as the edit authorization for implement, fix, refactor, or test. Not cleanup, docs sync, or merging results.",
37
+ ]
38
+ : []),
39
+ ...(hasSteward
40
+ ? [
41
+ "`steward`: dispatch only when the work is cleanup, documentation sync, or merging named result artifacts — do not invent a tidy pass. For cleanup, name the scope (uncommitted diff, Git range, directory). After a wide fan-out, pass the result-artifact paths to one steward and read its brief instead of every result yourself.",
36
42
  ]
37
43
  : []),
38
44
  "A discovered defect is not a change: re-read the current code and confirm it is not a false positive before you edit or brief a writer to edit.",