@oh-my-pi/pi-coding-agent 4.3.0 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +5 -5
  3. package/src/cli/update-cli.ts +2 -2
  4. package/src/config.ts +5 -5
  5. package/src/core/auth-storage.ts +6 -1
  6. package/src/core/custom-commands/loader.ts +3 -1
  7. package/src/core/custom-tools/loader.ts +1 -18
  8. package/src/core/extensions/loader.ts +5 -21
  9. package/src/core/hooks/loader.ts +1 -18
  10. package/src/core/keybindings.ts +3 -1
  11. package/src/core/logger.ts +1 -2
  12. package/src/core/prompt-templates.ts +5 -4
  13. package/src/core/sdk.ts +5 -3
  14. package/src/core/skills.ts +5 -4
  15. package/src/core/tools/exa/mcp-client.ts +2 -2
  16. package/src/core/tools/task/agents.ts +5 -64
  17. package/src/core/tools/task/commands.ts +7 -33
  18. package/src/core/tools/task/discovery.ts +4 -66
  19. package/src/core/tools/task/executor.ts +32 -3
  20. package/src/core/tools/task/index.ts +11 -2
  21. package/src/core/tools/task/render.ts +25 -15
  22. package/src/core/tools/task/types.ts +3 -0
  23. package/src/core/tools/task/worker-protocol.ts +2 -1
  24. package/src/core/tools/task/worker.ts +2 -1
  25. package/src/core/tools/web-scrapers/huggingface.ts +1 -1
  26. package/src/core/tools/web-scrapers/readthedocs.ts +1 -1
  27. package/src/core/tools/web-scrapers/types.ts +1 -1
  28. package/src/core/tools/web-search/auth.ts +5 -3
  29. package/src/discovery/codex.ts +3 -1
  30. package/src/discovery/helpers.ts +124 -3
  31. package/src/migrations.ts +11 -9
  32. package/src/modes/interactive/components/extensions/state-manager.ts +19 -18
  33. package/src/prompts/agents/frontmatter.md +1 -0
  34. package/src/prompts/agents/reviewer.md +32 -4
  35. package/src/prompts/tools/task.md +3 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [4.3.1] - 2026-01-11
6
+
7
+ ### Changed
8
+
9
+ - Expanded system prompt with defensive reasoning guidance and assumption checks
10
+ - Allowed agent frontmatter to override subagent thinking level, clamped to model capabilities
11
+
12
+ ### Fixed
13
+
14
+ - Ensured reviewer agents use structured output schemas and include reported findings in task outputs
15
+
5
16
  ## [4.3.0] - 2026-01-11
6
17
 
7
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "4.3.0",
3
+ "version": "4.3.1",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,10 +39,10 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "4.3.0",
43
- "@oh-my-pi/pi-agent-core": "4.3.0",
44
- "@oh-my-pi/pi-git-tool": "4.3.0",
45
- "@oh-my-pi/pi-tui": "4.3.0",
42
+ "@oh-my-pi/pi-ai": "4.3.1",
43
+ "@oh-my-pi/pi-agent-core": "4.3.1",
44
+ "@oh-my-pi/pi-git-tool": "4.3.1",
45
+ "@oh-my-pi/pi-tui": "4.3.1",
46
46
  "@openai/agents": "^0.3.7",
47
47
  "@sinclair/typebox": "^0.34.46",
48
48
  "ajv": "^8.17.1",
@@ -144,8 +144,8 @@ async function updateViaBun(): Promise<void> {
144
144
  try {
145
145
  execSync(`bun update -g ${PACKAGE}`, { stdio: "inherit" });
146
146
  console.log(chalk.green(`\n${theme.status.success} Update complete`));
147
- } catch {
148
- throw new Error("bun update failed");
147
+ } catch (error) {
148
+ throw new Error("bun update failed", { cause: error });
149
149
  }
150
150
  }
151
151
 
package/src/config.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
-
5
4
  // Embed package.json at build time for config
6
5
  import packageJson from "../package.json" with { type: "json" };
6
+ import { logger } from "./core/logger";
7
7
 
8
8
  // =============================================================================
9
9
  // App Config (from embedded package.json)
@@ -244,8 +244,8 @@ export function readConfigFile<T = unknown>(
244
244
  content: JSON.parse(content) as T,
245
245
  };
246
246
  }
247
- } catch {
248
- // Continue to next file on parse error
247
+ } catch (error) {
248
+ logger.warn("Failed to parse config file", { path: filePath, error: String(error) });
249
249
  }
250
250
  }
251
251
 
@@ -275,8 +275,8 @@ export function readAllConfigFiles<T = unknown>(
275
275
  content: JSON.parse(content) as T,
276
276
  });
277
277
  }
278
- } catch {
279
- // Skip files that fail to parse
278
+ } catch (error) {
279
+ logger.warn("Failed to parse config file", { path: filePath, error: String(error) });
280
280
  }
281
281
  }
282
282
 
@@ -939,7 +939,12 @@ export class AuthStorage {
939
939
 
940
940
  this.recordSessionCredential(provider, sessionId, "oauth", selection.index);
941
941
  return result.apiKey;
942
- } catch {
942
+ } catch (error) {
943
+ logger.warn("OAuth token refresh failed, removing credential", {
944
+ provider,
945
+ index: selection.index,
946
+ error: String(error),
947
+ });
943
948
  this.removeCredentialAt(provider, selection.index);
944
949
  if (this.getCredentialsForProvider(provider).some((credential) => credential.type === "oauth")) {
945
950
  return this.getApiKey(provider, sessionId, options);
@@ -11,6 +11,7 @@ import * as typebox from "@sinclair/typebox";
11
11
  import { getAgentDir, getConfigDirs } from "../../config";
12
12
  import * as piCodingAgent from "../../index";
13
13
  import { execCommand } from "../exec";
14
+ import { logger } from "../logger";
14
15
  import { createReviewCommand } from "./bundled/review";
15
16
  import { createWorktreeCommand } from "./bundled/wt";
16
17
  import type {
@@ -110,7 +111,8 @@ export function discoverCustomCommands(options: DiscoverCustomCommandsOptions =
110
111
  let entries: Dirent[];
111
112
  try {
112
113
  entries = readdirSync(commandsDir, { withFileTypes: true });
113
- } catch {
114
+ } catch (error) {
115
+ logger.warn("Failed to read custom commands directory", { path: commandsDir, error: String(error) });
114
116
  continue;
115
117
  }
116
118
  for (const entry of entries) {
@@ -5,11 +5,11 @@
5
5
  * to avoid import resolution issues with custom tools loaded from user directories.
6
6
  */
7
7
 
8
- import * as os from "node:os";
9
8
  import * as path from "node:path";
10
9
  import * as typebox from "@sinclair/typebox";
11
10
  import { toolCapability } from "../../capability/tool";
12
11
  import { type CustomTool, loadCapability } from "../../discovery";
12
+ import { expandPath } from "../../discovery/helpers";
13
13
  import * as piCodingAgent from "../../index";
14
14
  import { theme } from "../../modes/interactive/theme/theme";
15
15
  import type { ExecOptions } from "../exec";
@@ -19,23 +19,6 @@ import { logger } from "../logger";
19
19
  import { getAllPluginToolPaths } from "../plugins/loader";
20
20
  import type { CustomToolAPI, CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool } from "./types";
21
21
 
22
- const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
23
-
24
- function normalizeUnicodeSpaces(str: string): string {
25
- return str.replace(UNICODE_SPACES, " ");
26
- }
27
-
28
- function expandPath(p: string): string {
29
- const normalized = normalizeUnicodeSpaces(p);
30
- if (normalized.startsWith("~/")) {
31
- return path.join(os.homedir(), normalized.slice(2));
32
- }
33
- if (normalized.startsWith("~")) {
34
- return path.join(os.homedir(), normalized.slice(1));
35
- }
36
- return normalized;
37
- }
38
-
39
22
  /**
40
23
  * Resolve tool path.
41
24
  * - Absolute paths used as-is
@@ -3,13 +3,12 @@
3
3
  */
4
4
 
5
5
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
6
- import { homedir } from "node:os";
7
6
  import * as path from "node:path";
8
7
  import type { KeyId } from "@oh-my-pi/pi-tui";
9
8
  import * as TypeBox from "@sinclair/typebox";
10
9
  import { type ExtensionModule, extensionModuleCapability } from "../../capability/extension-module";
11
10
  import { loadCapability } from "../../discovery";
12
- import { getExtensionNameFromPath } from "../../discovery/helpers";
11
+ import { expandPath, getExtensionNameFromPath } from "../../discovery/helpers";
13
12
  import * as piCodingAgent from "../../index";
14
13
  import { createEventBus, type EventBus } from "../event-bus";
15
14
  import type { ExecOptions } from "../exec";
@@ -27,23 +26,6 @@ import type {
27
26
  ToolDefinition,
28
27
  } from "./types";
29
28
 
30
- const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
31
-
32
- function normalizeUnicodeSpaces(str: string): string {
33
- return str.replace(UNICODE_SPACES, " ");
34
- }
35
-
36
- function expandPath(p: string): string {
37
- const normalized = normalizeUnicodeSpaces(p);
38
- if (normalized.startsWith("~/")) {
39
- return path.join(homedir(), normalized.slice(2));
40
- }
41
- if (normalized.startsWith("~")) {
42
- return path.join(homedir(), normalized.slice(1));
43
- }
44
- return normalized;
45
- }
46
-
47
29
  function resolvePath(extPath: string, cwd: string): string {
48
30
  const expanded = expandPath(extPath);
49
31
  if (path.isAbsolute(expanded)) {
@@ -291,7 +273,8 @@ function readExtensionManifest(packageJsonPath: string): ExtensionManifest | nul
291
273
  return manifest;
292
274
  }
293
275
  return null;
294
- } catch {
276
+ } catch (error) {
277
+ logger.warn("Failed to read extension manifest", { path: packageJsonPath, error: String(error) });
295
278
  return null;
296
279
  }
297
280
  }
@@ -370,7 +353,8 @@ function discoverExtensionsInDir(dir: string): string[] {
370
353
  }
371
354
  }
372
355
  }
373
- } catch {
356
+ } catch (error) {
357
+ logger.warn("Failed to discover extensions in directory", { path: dir, error: String(error) });
374
358
  return [];
375
359
  }
376
360
 
@@ -2,12 +2,12 @@
2
2
  * Hook loader - loads TypeScript hook modules using native Bun import.
3
3
  */
4
4
 
5
- import * as os from "node:os";
6
5
  import * as path from "node:path";
7
6
  import * as typebox from "@sinclair/typebox";
8
7
  import { hookCapability } from "../../capability/hook";
9
8
  import type { Hook } from "../../discovery";
10
9
  import { loadCapability } from "../../discovery";
10
+ import { expandPath } from "../../discovery/helpers";
11
11
  import * as piCodingAgent from "../../index";
12
12
  import { logger } from "../logger";
13
13
  import type { HookMessage } from "../messages";
@@ -84,23 +84,6 @@ export interface LoadHooksResult {
84
84
  errors: Array<{ path: string; error: string }>;
85
85
  }
86
86
 
87
- const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
88
-
89
- function normalizeUnicodeSpaces(str: string): string {
90
- return str.replace(UNICODE_SPACES, " ");
91
- }
92
-
93
- function expandPath(p: string): string {
94
- const normalized = normalizeUnicodeSpaces(p);
95
- if (normalized.startsWith("~/")) {
96
- return path.join(os.homedir(), normalized.slice(2));
97
- }
98
- if (normalized.startsWith("~")) {
99
- return path.join(os.homedir(), normalized.slice(1));
100
- }
101
- return normalized;
102
- }
103
-
104
87
  /**
105
88
  * Resolve hook path.
106
89
  * - Absolute paths used as-is
@@ -10,6 +10,7 @@ import {
10
10
  setEditorKeybindings,
11
11
  } from "@oh-my-pi/pi-tui";
12
12
  import { getAgentDir } from "../config";
13
+ import { logger } from "./logger";
13
14
 
14
15
  /**
15
16
  * Application-level actions (coding agent specific).
@@ -136,7 +137,8 @@ export class KeybindingsManager {
136
137
  if (!existsSync(path)) return {};
137
138
  try {
138
139
  return JSON.parse(readFileSync(path, "utf-8"));
139
- } catch {
140
+ } catch (error) {
141
+ logger.warn("Failed to parse keybindings config", { path, error: String(error) });
140
142
  return {};
141
143
  }
142
144
  }
@@ -10,11 +10,10 @@ import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import winston from "winston";
12
12
  import DailyRotateFile from "winston-daily-rotate-file";
13
- import { CONFIG_DIR_NAME } from "../config";
14
13
 
15
14
  /** Get the logs directory (~/.omp/logs/) */
16
15
  function getLogsDir(): string {
17
- return join(homedir(), CONFIG_DIR_NAME, "logs");
16
+ return join(homedir(), ".omp", "logs");
18
17
  }
19
18
 
20
19
  /** Ensure logs directory exists */
@@ -1,6 +1,7 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import Handlebars from "handlebars";
3
3
  import { CONFIG_DIR_NAME, getPromptsDir } from "../config";
4
+ import { logger } from "./logger";
4
5
 
5
6
  /**
6
7
  * Represents a prompt template loaded from a markdown file
@@ -448,12 +449,12 @@ async function loadTemplatesFromDir(
448
449
  source: sourceStr,
449
450
  });
450
451
  }
451
- } catch (_error) {
452
- // Silently skip files that can't be read
452
+ } catch (error) {
453
+ logger.warn("Failed to load prompt template", { path: fullPath, error: String(error) });
453
454
  }
454
455
  }
455
- } catch (_error) {
456
- // Silently skip directories that can't be read
456
+ } catch (error) {
457
+ logger.warn("Failed to scan prompt templates directory", { dir, error: String(error) });
457
458
  }
458
459
 
459
460
  return templates;
package/src/core/sdk.ts CHANGED
@@ -28,7 +28,7 @@
28
28
 
29
29
  import { join } from "node:path";
30
30
  import { Agent, type AgentEvent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
31
- import type { Message, Model } from "@oh-my-pi/pi-ai";
31
+ import { type Message, type Model, supportsXhigh } from "@oh-my-pi/pi-ai";
32
32
  import type { Component } from "@oh-my-pi/pi-tui";
33
33
  import chalk from "chalk";
34
34
  // Import discovery to register all providers on startup
@@ -631,6 +631,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
631
631
  // Clamp to model capabilities
632
632
  if (!model || !model.reasoning) {
633
633
  thinkingLevel = "off";
634
+ } else if (thinkingLevel === "xhigh" && !supportsXhigh(model)) {
635
+ thinkingLevel = "high";
634
636
  }
635
637
 
636
638
  let skills: Skill[];
@@ -1021,8 +1023,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1021
1023
  });
1022
1024
  lspServers = result.servers;
1023
1025
  time("warmupLspServers");
1024
- } catch {
1025
- // Ignore warmup errors
1026
+ } catch (error) {
1027
+ logger.warn("LSP server warmup failed", { cwd, error: String(error) });
1026
1028
  }
1027
1029
  }
1028
1030
 
@@ -7,6 +7,7 @@ import type { SourceMeta } from "../capability/types";
7
7
  import type { Skill as CapabilitySkill, SkillFrontmatter as ImportedSkillFrontmatter } from "../discovery";
8
8
  import { loadCapability } from "../discovery";
9
9
  import { parseFrontmatter } from "../discovery/helpers";
10
+ import { logger } from "./logger";
10
11
  import type { SkillsSettings } from "./settings-manager";
11
12
 
12
13
  // Re-export SkillFrontmatter for backward compatibility
@@ -67,8 +68,8 @@ export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkills
67
68
  source: options.source,
68
69
  });
69
70
  }
70
- } catch {
71
- // Skip invalid skills
71
+ } catch (error) {
72
+ logger.warn("Failed to load skill", { path: skillFile, error: String(error) });
72
73
  }
73
74
  }
74
75
 
@@ -131,8 +132,8 @@ function scanDirectoryForSkills(dir: string): LoadSkillsResult {
131
132
  source: "custom",
132
133
  });
133
134
  }
134
- } catch {
135
- // Skip invalid skills
135
+ } catch (error) {
136
+ logger.warn("Failed to load skill", { path: skillFile, error: String(error) });
136
137
  }
137
138
  }
138
139
 
@@ -292,8 +292,8 @@ export async function fetchMCPToolSchema(
292
292
  mcpSchemaCache.set(cacheKey, tool);
293
293
  return tool;
294
294
  }
295
- } catch {
296
- // Fall through to return null
295
+ } catch (error) {
296
+ logger.warn("Failed to fetch MCP tool schema", { mcpToolName, isWebsetsTool, error: String(error) });
297
297
  }
298
298
  return null;
299
299
  }
@@ -4,6 +4,7 @@
4
4
  * Agents are embedded at build time via Bun's import with { type: "text" }.
5
5
  */
6
6
 
7
+ import { parseAgentFields, parseFrontmatter } from "../../../discovery/helpers";
7
8
  import exploreMd from "../../../prompts/agents/explore.md" with { type: "text" };
8
9
  // Embed agent markdown files at build time
9
10
  import agentFrontmatterTemplate from "../../../prompts/agents/frontmatter.md" with { type: "text" };
@@ -18,6 +19,7 @@ interface AgentFrontmatter {
18
19
  description: string;
19
20
  spawns?: string;
20
21
  model?: string;
22
+ thinkingLevel?: string;
21
23
  }
22
24
 
23
25
  interface EmbeddedAgentDef {
@@ -71,80 +73,19 @@ const EMBEDDED_AGENTS: { name: string; content: string }[] = EMBEDDED_AGENT_DEFS
71
73
  content: buildAgentContent(def),
72
74
  }));
73
75
 
74
- /**
75
- * Parse YAML frontmatter from markdown content.
76
- */
77
- function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
78
- const frontmatter: Record<string, string> = {};
79
- const normalized = content.replace(/\r\n/g, "\n");
80
-
81
- if (!normalized.startsWith("---")) {
82
- return { frontmatter, body: normalized };
83
- }
84
-
85
- const endIndex = normalized.indexOf("\n---", 3);
86
- if (endIndex === -1) {
87
- return { frontmatter, body: normalized };
88
- }
89
-
90
- const frontmatterBlock = normalized.slice(4, endIndex);
91
- const body = normalized.slice(endIndex + 4).trim();
92
-
93
- for (const line of frontmatterBlock.split("\n")) {
94
- const match = line.match(/^([\w-]+):\s*(.*)$/);
95
- if (match) {
96
- let value = match[2].trim();
97
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
98
- value = value.slice(1, -1);
99
- }
100
- frontmatter[match[1]] = value;
101
- }
102
- }
103
-
104
- return { frontmatter, body };
105
- }
106
-
107
76
  /**
108
77
  * Parse an agent from embedded content.
109
78
  */
110
79
  function parseAgent(fileName: string, content: string, source: AgentSource): AgentDefinition | null {
111
80
  const { frontmatter, body } = parseFrontmatter(content);
81
+ const fields = parseAgentFields(frontmatter);
112
82
 
113
- if (!frontmatter.name || !frontmatter.description) {
83
+ if (!fields) {
114
84
  return null;
115
85
  }
116
86
 
117
- const tools = frontmatter.tools
118
- ?.split(",")
119
- .map((t) => t.trim())
120
- .filter(Boolean);
121
-
122
- // Parse spawns field
123
- let spawns: string[] | "*" | undefined;
124
- if (frontmatter.spawns !== undefined) {
125
- const spawnsRaw = frontmatter.spawns.trim();
126
- if (spawnsRaw === "*") {
127
- spawns = "*";
128
- } else if (spawnsRaw) {
129
- spawns = spawnsRaw
130
- .split(",")
131
- .map((s) => s.trim())
132
- .filter(Boolean);
133
- if (spawns.length === 0) spawns = undefined;
134
- }
135
- }
136
-
137
- // Backward compat: infer spawns: "*" when tools includes "task"
138
- if (spawns === undefined && tools?.includes("task")) {
139
- spawns = "*";
140
- }
141
-
142
87
  return {
143
- name: frontmatter.name,
144
- description: frontmatter.description,
145
- tools: tools && tools.length > 0 ? tools : undefined,
146
- spawns,
147
- model: frontmatter.model,
88
+ ...fields,
148
89
  systemPrompt: body,
149
90
  source,
150
91
  filePath: `embedded:${fileName}`,
@@ -7,6 +7,7 @@
7
7
  import * as path from "node:path";
8
8
  import { type SlashCommand, slashCommandCapability } from "../../../capability/slash-command";
9
9
  import { loadCapability } from "../../../discovery";
10
+ import { parseFrontmatter } from "../../../discovery/helpers";
10
11
 
11
12
  // Embed command markdown files at build time
12
13
  import initMd from "../../../prompts/agents/init.md" with { type: "text" };
@@ -27,37 +28,10 @@ export interface WorkflowCommand {
27
28
  filePath: string;
28
29
  }
29
30
 
30
- /**
31
- * Parse YAML frontmatter from markdown content.
32
- */
33
- function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
34
- const frontmatter: Record<string, string> = {};
35
- const normalized = content.replace(/\r\n/g, "\n");
36
-
37
- if (!normalized.startsWith("---")) {
38
- return { frontmatter, body: normalized };
39
- }
40
-
41
- const endIndex = normalized.indexOf("\n---", 3);
42
- if (endIndex === -1) {
43
- return { frontmatter, body: normalized };
44
- }
45
-
46
- const frontmatterBlock = normalized.slice(4, endIndex);
47
- const body = normalized.slice(endIndex + 4).trim();
48
-
49
- for (const line of frontmatterBlock.split("\n")) {
50
- const match = line.match(/^([\w-]+):\s*(.*)$/);
51
- if (match) {
52
- let value = match[2].trim();
53
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
54
- value = value.slice(1, -1);
55
- }
56
- frontmatter[match[1]] = value;
57
- }
58
- }
59
-
60
- return { frontmatter, body };
31
+ /** Extract string value from frontmatter field */
32
+ function getString(frontmatter: Record<string, unknown>, key: string): string {
33
+ const value = frontmatter[key];
34
+ return typeof value === "string" ? value : "";
61
35
  }
62
36
 
63
37
  /** Cache for bundled commands */
@@ -79,7 +53,7 @@ export function loadBundledCommands(): WorkflowCommand[] {
79
53
 
80
54
  commands.push({
81
55
  name: cmdName,
82
- description: frontmatter.description || "",
56
+ description: getString(frontmatter, "description"),
83
57
  instructions: body,
84
58
  source: "bundled",
85
59
  filePath: `embedded:${name}`,
@@ -115,7 +89,7 @@ export async function discoverCommands(cwd: string): Promise<WorkflowCommand[]>
115
89
 
116
90
  commands.push({
117
91
  name: cmd.name,
118
- description: frontmatter.description || "",
92
+ description: getString(frontmatter, "description"),
119
93
  instructions: body,
120
94
  source,
121
95
  filePath: cmd.path,
@@ -15,6 +15,7 @@
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import { findAllNearestProjectConfigDirs, getConfigDirs } from "../../../config";
18
+ import { parseAgentFields, parseFrontmatter } from "../../../discovery/helpers";
18
19
  import { loadBundledAgents } from "./agents";
19
20
  import type { AgentDefinition, AgentSource } from "./types";
20
21
 
@@ -24,40 +25,6 @@ export interface DiscoveryResult {
24
25
  projectAgentsDir: string | null;
25
26
  }
26
27
 
27
- /**
28
- * Parse YAML frontmatter from markdown content.
29
- */
30
- function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
31
- const frontmatter: Record<string, string> = {};
32
- const normalized = content.replace(/\r\n/g, "\n");
33
-
34
- if (!normalized.startsWith("---")) {
35
- return { frontmatter, body: normalized };
36
- }
37
-
38
- const endIndex = normalized.indexOf("\n---", 3);
39
- if (endIndex === -1) {
40
- return { frontmatter, body: normalized };
41
- }
42
-
43
- const frontmatterBlock = normalized.slice(4, endIndex);
44
- const body = normalized.slice(endIndex + 4).trim();
45
-
46
- for (const line of frontmatterBlock.split("\n")) {
47
- const match = line.match(/^([\w-]+):\s*(.*)$/);
48
- if (match) {
49
- let value = match[2].trim();
50
- // Strip quotes
51
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
52
- value = value.slice(1, -1);
53
- }
54
- frontmatter[match[1]] = value;
55
- }
56
- }
57
-
58
- return { frontmatter, body };
59
- }
60
-
61
28
  /**
62
29
  * Load agents from a directory.
63
30
  */
@@ -95,43 +62,14 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentDefinition[]
95
62
  }
96
63
 
97
64
  const { frontmatter, body } = parseFrontmatter(content);
65
+ const fields = parseAgentFields(frontmatter);
98
66
 
99
- // Require name and description
100
- if (!frontmatter.name || !frontmatter.description) {
67
+ if (!fields) {
101
68
  continue;
102
69
  }
103
70
 
104
- const tools = frontmatter.tools
105
- ?.split(",")
106
- .map((t) => t.trim())
107
- .filter(Boolean);
108
-
109
- // Parse spawns field
110
- let spawns: string[] | "*" | undefined;
111
- if (frontmatter.spawns !== undefined) {
112
- const spawnsRaw = frontmatter.spawns.trim();
113
- if (spawnsRaw === "*") {
114
- spawns = "*";
115
- } else if (spawnsRaw) {
116
- spawns = spawnsRaw
117
- .split(",")
118
- .map((s) => s.trim())
119
- .filter(Boolean);
120
- if (spawns.length === 0) spawns = undefined;
121
- }
122
- }
123
-
124
- // Backward compat: infer spawns: "*" when tools includes "task"
125
- if (spawns === undefined && tools?.includes("task")) {
126
- spawns = "*";
127
- }
128
-
129
71
  agents.push({
130
- name: frontmatter.name,
131
- description: frontmatter.description,
132
- tools: tools && tools.length > 0 ? tools : undefined,
133
- spawns,
134
- model: frontmatter.model,
72
+ ...fields,
135
73
  systemPrompt: body,
136
74
  source,
137
75
  filePath,