@tintinweb/pi-subagents 0.2.7 → 0.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/index.ts CHANGED
@@ -18,10 +18,11 @@ import { Text } from "@mariozechner/pi-tui";
18
18
  import { Type } from "@sinclair/typebox";
19
19
  import { AgentManager } from "./agent-manager.js";
20
20
  import { steerAgent, getAgentConversation, getDefaultMaxTurns, setDefaultMaxTurns, getGraceTurns, setGraceTurns } from "./agent-runner.js";
21
- import { SUBAGENT_TYPES, type SubagentType, type ThinkingLevel, type CustomAgentConfig, type JoinMode, type AgentRecord } from "./types.js";
21
+ import { DEFAULT_AGENT_NAMES, type SubagentType, type ThinkingLevel, type AgentConfig, type JoinMode, type AgentRecord } from "./types.js";
22
22
  import { GroupJoinManager } from "./group-join.js";
23
- import { getAvailableTypes, getCustomAgentNames, getCustomAgentConfig, isValidType, registerCustomAgents, BUILTIN_TOOL_NAMES } from "./agent-types.js";
23
+ import { getAvailableTypes, getAllTypes, getDefaultAgentNames, getUserAgentNames, getAgentConfig, resolveType, registerAgents, BUILTIN_TOOL_NAMES } from "./agent-types.js";
24
24
  import { loadCustomAgents } from "./custom-agents.js";
25
+ import { resolveModel, type ModelRegistry } from "./model-resolver.js";
25
26
  import {
26
27
  AgentWidget,
27
28
  SPINNER,
@@ -119,82 +120,25 @@ function buildDetails(
119
120
  };
120
121
  }
121
122
 
122
- /** Resolve system prompt overrides from a custom agent config. */
123
- function resolveCustomPrompt(config: CustomAgentConfig | undefined): {
123
+ /** Resolve system prompt overrides from an agent config. */
124
+ function resolveCustomPrompt(config: AgentConfig | undefined): {
124
125
  systemPromptOverride?: string;
125
126
  systemPromptAppend?: string;
126
127
  } {
127
128
  if (!config?.systemPrompt) return {};
129
+ // Default agents use their systemPrompt via buildAgentPrompt in agent-runner,
130
+ // not via override/append. Only non-default agents use this path.
131
+ if (config.isDefault) return {};
128
132
  if (config.promptMode === "append") return { systemPromptAppend: config.systemPrompt };
129
133
  return { systemPromptOverride: config.systemPrompt };
130
134
  }
131
135
 
132
- /**
133
- * Resolve a model string to a Model instance.
134
- * Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
135
- * Returns the Model on success, or an error message string on failure.
136
- */
137
- function resolveModel(
138
- input: string,
139
- registry: { find(provider: string, modelId: string): any; getAll(): any[]; getAvailable?(): any[] },
140
- ): any | string {
141
- // 1. Exact match: "provider/modelId"
142
- const slashIdx = input.indexOf("/");
143
- if (slashIdx !== -1) {
144
- const provider = input.slice(0, slashIdx);
145
- const modelId = input.slice(slashIdx + 1);
146
- const found = registry.find(provider, modelId);
147
- if (found) return found;
148
- }
149
-
150
- // 2. Fuzzy match against available models (those with auth configured)
151
- const all = (registry.getAvailable?.() ?? registry.getAll()) as { id: string; name: string; provider: string }[];
152
- const query = input.toLowerCase();
153
-
154
- // Score each model: prefer exact id match > id contains > name contains > provider+id contains
155
- let bestMatch: typeof all[number] | undefined;
156
- let bestScore = 0;
157
-
158
- for (const m of all) {
159
- const id = m.id.toLowerCase();
160
- const name = m.name.toLowerCase();
161
- const full = `${m.provider}/${m.id}`.toLowerCase();
162
-
163
- let score = 0;
164
- if (id === query || full === query) {
165
- score = 100; // exact
166
- } else if (id.includes(query) || full.includes(query)) {
167
- score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
168
- } else if (name.includes(query)) {
169
- score = 40 + (query.length / name.length) * 20;
170
- } else if (query.split(/[\s\-/]+/).every(part => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))) {
171
- score = 20; // all parts present somewhere
172
- }
173
-
174
- if (score > bestScore) {
175
- bestScore = score;
176
- bestMatch = m;
177
- }
178
- }
179
-
180
- if (bestMatch && bestScore >= 20) {
181
- const found = registry.find(bestMatch.provider, bestMatch.id);
182
- if (found) return found;
183
- }
184
-
185
- // 3. No match — list available models
186
- const modelList = all
187
- .map(m => ` ${m.provider}/${m.id}`)
188
- .sort()
189
- .join("\n");
190
- return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
191
- }
192
136
 
193
137
  export default function (pi: ExtensionAPI) {
194
- /** Reload custom agents from .pi/agents/*.md (called on init and each Agent invocation). */
138
+ /** Reload agents from .pi/agents/*.md and merge with defaults (called on init and each Agent invocation). */
195
139
  const reloadCustomAgents = () => {
196
- const agents = loadCustomAgents(process.cwd());
197
- registerCustomAgents(agents);
140
+ const userAgents = loadCustomAgents(process.cwd());
141
+ registerAgents(userAgents);
198
142
  };
199
143
 
200
144
  // Initial load
@@ -352,31 +296,39 @@ export default function (pi: ExtensionAPI) {
352
296
  widget.onTurnStart();
353
297
  });
354
298
 
355
- // Build type description text (static built-in + dynamic custom note)
356
- const builtinDescs = [
357
- "- general-purpose: Full tool access for complex multi-step tasks.",
358
- "- Explore: Fast codebase exploration (read-only, defaults to haiku).",
359
- "- Plan: Software architect for implementation planning (read-only).",
360
- "- statusline-setup: Configuration editor (read + edit only).",
361
- "- claude-code-guide: Documentation and help queries (read-only).",
362
- ];
363
-
364
- /** Build the full type list text, including any currently loaded custom agents. */
299
+ /** Build the full type list text dynamically from the unified registry. */
365
300
  const buildTypeListText = () => {
366
- const names = getCustomAgentNames();
367
- const customDescs = names.map((name) => {
368
- const cfg = getCustomAgentConfig(name);
301
+ const defaultNames = getDefaultAgentNames();
302
+ const userNames = getUserAgentNames();
303
+
304
+ const defaultDescs = defaultNames.map((name) => {
305
+ const cfg = getAgentConfig(name);
306
+ const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
307
+ return `- ${name}: ${cfg?.description ?? name}${modelSuffix}`;
308
+ });
309
+
310
+ const customDescs = userNames.map((name) => {
311
+ const cfg = getAgentConfig(name);
369
312
  return `- ${name}: ${cfg?.description ?? name}`;
370
313
  });
314
+
371
315
  return [
372
- "Built-in types:",
373
- ...builtinDescs,
374
- ...(customDescs.length > 0 ? ["", "Custom types:", ...customDescs] : []),
316
+ "Default agents:",
317
+ ...defaultDescs,
318
+ ...(customDescs.length > 0 ? ["", "Custom agents:", ...customDescs] : []),
375
319
  "",
376
- "Custom agents can be defined in .pi/agents/<name>.md (project) or ~/.pi/agent/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones.",
320
+ "Custom agents can be defined in .pi/agents/<name>.md (project) or ~/.pi/agent/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.",
377
321
  ].join("\n");
378
322
  };
379
323
 
324
+ /** Derive a short model label from a model string. */
325
+ function getModelLabelFromConfig(model: string): string {
326
+ // Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
327
+ const name = model.includes("/") ? model.split("/").pop()! : model;
328
+ // Strip trailing date suffix (e.g. "claude-haiku-4-5-20251001" → "claude-haiku-4-5")
329
+ return name.replace(/-\d{8}$/, "");
330
+ }
331
+
380
332
  const typeListText = buildTypeListText();
381
333
 
382
334
  // ---- Agent tool ----
@@ -413,12 +365,12 @@ Guidelines:
413
365
  description: "A short (3-5 word) description of the task (shown in UI).",
414
366
  }),
415
367
  subagent_type: Type.String({
416
- description: `The type of specialized agent to use. Built-in: ${SUBAGENT_TYPES.join(", ")}. Custom agents from .pi/agents/*.md (project) or ~/.pi/agent/agents/*.md (global) are also available.`,
368
+ description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ~/.pi/agent/agents/*.md (global) are also available.`,
417
369
  }),
418
370
  model: Type.Optional(
419
371
  Type.String({
420
372
  description:
421
- 'Optional model to use. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). If omitted, Explore defaults to haiku; others inherit from parent.',
373
+ 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
422
374
  }),
423
375
  ),
424
376
  thinking: Type.Optional(
@@ -556,26 +508,27 @@ Guidelines:
556
508
  // Reload custom agents so new .pi/agents/*.md files are picked up without restart
557
509
  reloadCustomAgents();
558
510
 
559
- const subagentType = params.subagent_type as SubagentType;
560
-
561
- // Validate subagent type
562
- if (!isValidType(subagentType)) {
563
- return textResult(`Unknown agent type: "${params.subagent_type}". Valid types: ${getAvailableTypes().join(", ")}`);
564
- }
511
+ const rawType = params.subagent_type as SubagentType;
512
+ const resolved = resolveType(rawType);
513
+ const subagentType = resolved ?? "general-purpose";
514
+ const fellBack = resolved === undefined;
565
515
 
566
516
  const displayName = getDisplayName(subagentType);
567
517
 
568
- // Get custom agent config (if any)
569
- const customConfig = getCustomAgentConfig(subagentType);
518
+ // Get agent config (if any)
519
+ const customConfig = getAgentConfig(subagentType);
570
520
 
571
521
  // Resolve model if specified (supports exact "provider/modelId" or fuzzy match)
572
522
  let model = ctx.model;
573
- if (params.model) {
574
- const resolved = resolveModel(params.model, ctx.modelRegistry);
523
+ const modelInput = params.model ?? customConfig?.model;
524
+ if (modelInput) {
525
+ const resolved = resolveModel(modelInput, ctx.modelRegistry);
575
526
  if (typeof resolved === "string") {
576
- return textResult(resolved);
527
+ if (params.model) return textResult(resolved); // user-specified: error
528
+ // config-specified: silent fallback to parent
529
+ } else {
530
+ model = resolved;
577
531
  }
578
- model = resolved;
579
532
  }
580
533
 
581
534
  // Resolve thinking: explicit param > custom config > undefined
@@ -745,13 +698,17 @@ Guidelines:
745
698
 
746
699
  const details = buildDetails(detailBase, record, { tokens: tokenText });
747
700
 
701
+ const fallbackNote = fellBack
702
+ ? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
703
+ : "";
704
+
748
705
  if (record.status === "error") {
749
- return textResult(`Agent failed: ${record.error}`, details);
706
+ return textResult(`${fallbackNote}Agent failed: ${record.error}`, details);
750
707
  }
751
708
 
752
709
  const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
753
710
  return textResult(
754
- `Agent completed in ${formatMs(durationMs)} (${record.toolUses} tool uses)${getStatusNote(record.status)}.\n\n` +
711
+ `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${record.toolUses} tool uses)${getStatusNote(record.status)}.\n\n` +
755
712
  (record.result ?? "No output."),
756
713
  details,
757
714
  );
@@ -875,33 +832,20 @@ Guidelines:
875
832
  return undefined;
876
833
  }
877
834
 
878
- /** Model label for display: built-in types have known defaults, custom agents show their config. */
879
- const BUILTIN_MODEL_LABELS: Record<string, string> = {
880
- "general-purpose": "inherit",
881
- "Explore": "haiku",
882
- "Plan": "inherit",
883
- "statusline-setup": "inherit",
884
- "claude-code-guide": "inherit",
885
- };
886
-
887
- function getModelLabel(type: string): string {
888
- const builtin = BUILTIN_MODEL_LABELS[type];
889
- if (builtin) return builtin;
890
- const custom = getCustomAgentConfig(type);
891
- if (custom?.model) {
892
- // Show short form: "anthropic/claude-haiku-4-5-20251001" → "haiku"
893
- const id = custom.model.toLowerCase();
894
- if (id.includes("haiku")) return "haiku";
895
- if (id.includes("sonnet")) return "sonnet";
896
- if (id.includes("opus")) return "opus";
897
- return custom.model;
835
+ function getModelLabel(type: string, registry?: ModelRegistry): string {
836
+ const cfg = getAgentConfig(type);
837
+ if (!cfg?.model) return "inherit";
838
+ // If registry provided, check if the model actually resolves
839
+ if (registry) {
840
+ const resolved = resolveModel(cfg.model, registry);
841
+ if (typeof resolved === "string") return "inherit"; // model not available
898
842
  }
899
- return "inherit";
843
+ return getModelLabelFromConfig(cfg.model);
900
844
  }
901
845
 
902
846
  async function showAgentsMenu(ctx: ExtensionCommandContext) {
903
847
  reloadCustomAgents();
904
- const customNames = getCustomAgentNames();
848
+ const allNames = getAllTypes();
905
849
 
906
850
  // Build select options
907
851
  const options: string[] = [];
@@ -914,32 +858,24 @@ Guidelines:
914
858
  options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`);
915
859
  }
916
860
 
917
- // Custom agents submenu (only if there are custom agents)
918
- if (customNames.length > 0) {
919
- options.push(`Custom agents (${customNames.length})`);
861
+ // Agent types list
862
+ if (allNames.length > 0) {
863
+ options.push(`Agent types (${allNames.length})`);
920
864
  }
921
865
 
922
866
  // Actions
923
867
  options.push("Create new agent");
924
868
  options.push("Settings");
925
869
 
926
- // Show built-in types below the select as informational text (like Claude does)
927
- const maxBuiltin = Math.max(...SUBAGENT_TYPES.map(t => t.length));
928
- const builtinLines = SUBAGENT_TYPES.map(t => {
929
- const model = BUILTIN_MODEL_LABELS[t] ?? "inherit";
930
- return ` ${t.padEnd(maxBuiltin)} · ${model}`;
931
- });
932
-
933
- const noAgentsMsg = customNames.length === 0 && agents.length === 0
870
+ const noAgentsMsg = allNames.length === 0 && agents.length === 0
934
871
  ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
935
872
  "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
936
873
  "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
937
874
  : "";
938
875
 
939
- ctx.ui.notify(
940
- `${noAgentsMsg}Built-in (always available):\n${builtinLines.join("\n")}`,
941
- "info",
942
- );
876
+ if (noAgentsMsg) {
877
+ ctx.ui.notify(noAgentsMsg, "info");
878
+ }
943
879
 
944
880
  const choice = await ctx.ui.select("Agents", options);
945
881
  if (!choice) return;
@@ -947,8 +883,8 @@ Guidelines:
947
883
  if (choice.startsWith("Running agents (")) {
948
884
  await showRunningAgents(ctx);
949
885
  await showAgentsMenu(ctx);
950
- } else if (choice.startsWith("Custom agents (")) {
951
- await showCustomAgentsList(ctx);
886
+ } else if (choice.startsWith("Agent types (")) {
887
+ await showAllAgentsList(ctx);
952
888
  await showAgentsMenu(ctx);
953
889
  } else if (choice === "Create new agent") {
954
890
  await showCreateWizard(ctx);
@@ -958,32 +894,53 @@ Guidelines:
958
894
  }
959
895
  }
960
896
 
961
- async function showCustomAgentsList(ctx: ExtensionCommandContext) {
962
- const customNames = getCustomAgentNames();
963
- if (customNames.length === 0) {
964
- ctx.ui.notify("No custom agents.", "info");
897
+ async function showAllAgentsList(ctx: ExtensionCommandContext) {
898
+ const allNames = getAllTypes();
899
+ if (allNames.length === 0) {
900
+ ctx.ui.notify("No agents.", "info");
965
901
  return;
966
902
  }
967
903
 
968
- // Compute max width of "name · model" for alignment
969
- const entries = customNames.map(name => {
970
- const cfg = getCustomAgentConfig(name);
971
- const model = getModelLabel(name);
972
- const prefix = `${name} · ${model}`;
973
- return { prefix, desc: cfg?.description ?? name };
904
+ // Source indicators: defaults unmarked, custom agents get (project) or ◦ (global)
905
+ // Disabled agents get prefix
906
+ const sourceIndicator = (cfg: AgentConfig | undefined) => {
907
+ const disabled = cfg?.enabled === false;
908
+ if (cfg?.source === "project") return disabled ? "✕• " : "• ";
909
+ if (cfg?.source === "global") return disabled ? "✕◦ " : "◦ ";
910
+ if (disabled) return "✕ ";
911
+ return " ";
912
+ };
913
+
914
+ const entries = allNames.map(name => {
915
+ const cfg = getAgentConfig(name);
916
+ const disabled = cfg?.enabled === false;
917
+ const model = getModelLabel(name, ctx.modelRegistry);
918
+ const indicator = sourceIndicator(cfg);
919
+ const prefix = `${indicator}${name} · ${model}`;
920
+ const desc = disabled ? "(disabled)" : (cfg?.description ?? name);
921
+ return { name, prefix, desc };
974
922
  });
975
923
  const maxPrefix = Math.max(...entries.map(e => e.prefix.length));
976
924
 
925
+ const hasCustom = allNames.some(n => { const c = getAgentConfig(n); return c && !c.isDefault && c.enabled !== false; });
926
+ const hasDisabled = allNames.some(n => getAgentConfig(n)?.enabled === false);
927
+ const legendParts: string[] = [];
928
+ if (hasCustom) legendParts.push("• = project ◦ = global");
929
+ if (hasDisabled) legendParts.push("✕ = disabled");
930
+ const legend = legendParts.length ? "\n" + legendParts.join(" ") : "";
931
+
977
932
  const options = entries.map(({ prefix, desc }) =>
978
933
  `${prefix.padEnd(maxPrefix)} — ${desc}`,
979
934
  );
935
+ if (legend) options.push(legend);
980
936
 
981
- const choice = await ctx.ui.select("Custom agents", options);
937
+ const choice = await ctx.ui.select("Agent types", options);
982
938
  if (!choice) return;
983
939
 
984
- const agentName = choice.split(" · ")[0];
985
- if (getCustomAgentConfig(agentName)) {
940
+ const agentName = choice.split(" · ")[0].replace(/^[•◦✕\s]+/, "").trim();
941
+ if (getAgentConfig(agentName)) {
986
942
  await showAgentDetail(ctx, agentName);
943
+ await showAllAgentsList(ctx);
987
944
  }
988
945
  }
989
946
 
@@ -1005,16 +962,37 @@ Guidelines:
1005
962
  }
1006
963
 
1007
964
  async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
1008
- const file = findAgentFile(name);
1009
- if (!file) {
1010
- ctx.ui.notify(`Agent file not found for "${name}".`, "warning");
965
+ const cfg = getAgentConfig(name);
966
+ if (!cfg) {
967
+ ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
1011
968
  return;
1012
969
  }
1013
970
 
1014
- const choice = await ctx.ui.select(name, ["Edit", "Delete", "Back"]);
971
+ const file = findAgentFile(name);
972
+ const isDefault = cfg.isDefault === true;
973
+ const disabled = cfg.enabled === false;
974
+
975
+ let menuOptions: string[];
976
+ if (disabled && file) {
977
+ // Disabled agent with a file — offer Enable
978
+ menuOptions = isDefault
979
+ ? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
980
+ : ["Enable", "Edit", "Delete", "Back"];
981
+ } else if (isDefault && !file) {
982
+ // Default agent with no .md override
983
+ menuOptions = ["Eject (export as .md)", "Disable", "Back"];
984
+ } else if (isDefault && file) {
985
+ // Default agent with .md override (ejected)
986
+ menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"];
987
+ } else {
988
+ // User-defined agent
989
+ menuOptions = ["Edit", "Disable", "Delete", "Back"];
990
+ }
991
+
992
+ const choice = await ctx.ui.select(name, menuOptions);
1015
993
  if (!choice || choice === "Back") return;
1016
994
 
1017
- if (choice === "Edit") {
995
+ if (choice === "Edit" && file) {
1018
996
  const content = readFileSync(file.path, "utf-8");
1019
997
  const edited = await ctx.ui.editor(`Edit ${name}`, content);
1020
998
  if (edited !== undefined && edited !== content) {
@@ -1024,12 +1002,125 @@ Guidelines:
1024
1002
  ctx.ui.notify(`Updated ${file.path}`, "info");
1025
1003
  }
1026
1004
  } else if (choice === "Delete") {
1027
- const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`);
1005
+ if (file) {
1006
+ const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`);
1007
+ if (confirmed) {
1008
+ unlinkSync(file.path);
1009
+ reloadCustomAgents();
1010
+ ctx.ui.notify(`Deleted ${file.path}`, "info");
1011
+ }
1012
+ }
1013
+ } else if (choice === "Reset to default" && file) {
1014
+ const confirmed = await ctx.ui.confirm("Reset to default", `Delete override ${file.path} and restore embedded default?`);
1028
1015
  if (confirmed) {
1029
1016
  unlinkSync(file.path);
1030
1017
  reloadCustomAgents();
1031
- ctx.ui.notify(`Deleted ${file.path}`, "info");
1018
+ ctx.ui.notify(`Restored default ${name}`, "info");
1032
1019
  }
1020
+ } else if (choice.startsWith("Eject")) {
1021
+ await ejectAgent(ctx, name, cfg);
1022
+ } else if (choice === "Disable") {
1023
+ await disableAgent(ctx, name);
1024
+ } else if (choice === "Enable") {
1025
+ await enableAgent(ctx, name);
1026
+ }
1027
+ }
1028
+
1029
+ /** Eject a default agent: write its embedded config as a .md file. */
1030
+ async function ejectAgent(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig) {
1031
+ const location = await ctx.ui.select("Choose location", [
1032
+ "Project (.pi/agents/)",
1033
+ "Personal (~/.pi/agent/agents/)",
1034
+ ]);
1035
+ if (!location) return;
1036
+
1037
+ const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
1038
+ mkdirSync(targetDir, { recursive: true });
1039
+
1040
+ const targetPath = join(targetDir, `${name}.md`);
1041
+ if (existsSync(targetPath)) {
1042
+ const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
1043
+ if (!overwrite) return;
1044
+ }
1045
+
1046
+ // Build the .md file content
1047
+ const fmFields: string[] = [];
1048
+ fmFields.push(`description: ${cfg.description}`);
1049
+ if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`);
1050
+ fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`);
1051
+ if (cfg.model) fmFields.push(`model: ${cfg.model}`);
1052
+ if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`);
1053
+ if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
1054
+ fmFields.push(`prompt_mode: ${cfg.promptMode}`);
1055
+ if (cfg.extensions === false) fmFields.push("extensions: false");
1056
+ else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
1057
+ if (cfg.skills === false) fmFields.push("skills: false");
1058
+ else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
1059
+ if (cfg.inheritContext) fmFields.push("inherit_context: true");
1060
+ if (cfg.runInBackground) fmFields.push("run_in_background: true");
1061
+ if (cfg.isolated) fmFields.push("isolated: true");
1062
+
1063
+ const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
1064
+
1065
+ const { writeFileSync } = await import("node:fs");
1066
+ writeFileSync(targetPath, content, "utf-8");
1067
+ reloadCustomAgents();
1068
+ ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
1069
+ }
1070
+
1071
+ /** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
1072
+ async function disableAgent(ctx: ExtensionCommandContext, name: string) {
1073
+ const file = findAgentFile(name);
1074
+ if (file) {
1075
+ // Existing file — set enabled: false in frontmatter (idempotent)
1076
+ const content = readFileSync(file.path, "utf-8");
1077
+ if (content.includes("\nenabled: false\n")) {
1078
+ ctx.ui.notify(`${name} is already disabled.`, "info");
1079
+ return;
1080
+ }
1081
+ const updated = content.replace(/^---\n/, "---\nenabled: false\n");
1082
+ const { writeFileSync } = await import("node:fs");
1083
+ writeFileSync(file.path, updated, "utf-8");
1084
+ reloadCustomAgents();
1085
+ ctx.ui.notify(`Disabled ${name} (${file.path})`, "info");
1086
+ return;
1087
+ }
1088
+
1089
+ // No file (built-in default) — create a stub
1090
+ const location = await ctx.ui.select("Choose location", [
1091
+ "Project (.pi/agents/)",
1092
+ "Personal (~/.pi/agent/agents/)",
1093
+ ]);
1094
+ if (!location) return;
1095
+
1096
+ const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
1097
+ mkdirSync(targetDir, { recursive: true });
1098
+
1099
+ const targetPath = join(targetDir, `${name}.md`);
1100
+ const { writeFileSync } = await import("node:fs");
1101
+ writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8");
1102
+ reloadCustomAgents();
1103
+ ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
1104
+ }
1105
+
1106
+ /** Enable a disabled agent by removing enabled: false from its frontmatter. */
1107
+ async function enableAgent(ctx: ExtensionCommandContext, name: string) {
1108
+ const file = findAgentFile(name);
1109
+ if (!file) return;
1110
+
1111
+ const content = readFileSync(file.path, "utf-8");
1112
+ const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
1113
+ const { writeFileSync } = await import("node:fs");
1114
+
1115
+ // If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
1116
+ if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
1117
+ unlinkSync(file.path);
1118
+ reloadCustomAgents();
1119
+ ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
1120
+ } else {
1121
+ writeFileSync(file.path, updated, "utf-8");
1122
+ reloadCustomAgents();
1123
+ ctx.ui.notify(`Enabled ${name} (${file.path})`, "info");
1033
1124
  }
1034
1125
  }
1035
1126
 
@@ -1062,15 +1153,7 @@ Guidelines:
1062
1153
  const name = await ctx.ui.input("Agent name (filename, no spaces)");
1063
1154
  if (!name) return;
1064
1155
 
1065
- // Validate name
1066
- if (isValidType(name) && !getCustomAgentConfig(name)) {
1067
- ctx.ui.notify(`"${name}" conflicts with a built-in agent type.`, "warning");
1068
- return;
1069
- }
1070
-
1071
- if (!mkdirSync(targetDir, { recursive: true }) && !existsSync(targetDir)) {
1072
- mkdirSync(targetDir, { recursive: true });
1073
- }
1156
+ mkdirSync(targetDir, { recursive: true });
1074
1157
 
1075
1158
  const targetPath = join(targetDir, `${name}.md`);
1076
1159
  if (existsSync(targetPath)) {
@@ -1139,11 +1222,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
1139
1222
  const name = await ctx.ui.input("Agent name (filename, no spaces)");
1140
1223
  if (!name) return;
1141
1224
 
1142
- if (isValidType(name) && !getCustomAgentConfig(name)) {
1143
- ctx.ui.notify(`"${name}" conflicts with a built-in agent type.`, "warning");
1144
- return;
1145
- }
1146
-
1147
1225
  // 2. Description
1148
1226
  const description = await ctx.ui.input("Description (one line)");
1149
1227
  if (!description) return;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Model resolution: exact match ("provider/modelId") with fuzzy fallback.
3
+ */
4
+
5
+ export interface ModelEntry {
6
+ id: string;
7
+ name: string;
8
+ provider: string;
9
+ }
10
+
11
+ export interface ModelRegistry {
12
+ find(provider: string, modelId: string): any;
13
+ getAll(): any[];
14
+ getAvailable?(): any[];
15
+ }
16
+
17
+ /**
18
+ * Resolve a model string to a Model instance.
19
+ * Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
20
+ * Returns the Model on success, or an error message string on failure.
21
+ */
22
+ export function resolveModel(
23
+ input: string,
24
+ registry: ModelRegistry,
25
+ ): any | string {
26
+ // Available models (those with auth configured)
27
+ const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
28
+ const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase()));
29
+
30
+ // 1. Exact match: "provider/modelId" — only if available (has auth)
31
+ const slashIdx = input.indexOf("/");
32
+ if (slashIdx !== -1) {
33
+ const provider = input.slice(0, slashIdx);
34
+ const modelId = input.slice(slashIdx + 1);
35
+ if (availableSet.has(input.toLowerCase())) {
36
+ const found = registry.find(provider, modelId);
37
+ if (found) return found;
38
+ }
39
+ }
40
+
41
+ // 2. Fuzzy match against available models
42
+ const query = input.toLowerCase();
43
+
44
+ // Score each model: prefer exact id match > id contains > name contains > provider+id contains
45
+ let bestMatch: ModelEntry | undefined;
46
+ let bestScore = 0;
47
+
48
+ for (const m of all) {
49
+ const id = m.id.toLowerCase();
50
+ const name = m.name.toLowerCase();
51
+ const full = `${m.provider}/${m.id}`.toLowerCase();
52
+
53
+ let score = 0;
54
+ if (id === query || full === query) {
55
+ score = 100; // exact
56
+ } else if (id.includes(query) || full.includes(query)) {
57
+ score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
58
+ } else if (name.includes(query)) {
59
+ score = 40 + (query.length / name.length) * 20;
60
+ } else if (query.split(/[\s\-/]+/).every(part => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))) {
61
+ score = 20; // all parts present somewhere
62
+ }
63
+
64
+ if (score > bestScore) {
65
+ bestScore = score;
66
+ bestMatch = m;
67
+ }
68
+ }
69
+
70
+ if (bestMatch && bestScore >= 20) {
71
+ const found = registry.find(bestMatch.provider, bestMatch.id);
72
+ if (found) return found;
73
+ }
74
+
75
+ // 3. No match — list available models
76
+ const modelList = all
77
+ .map(m => ` ${m.provider}/${m.id}`)
78
+ .sort()
79
+ .join("\n");
80
+ return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
81
+ }