Files
oh-my-pi/packages/coding-agent/src/task/discovery.ts
T
roboomp 285d384ca6 fix(task): scan OMP extension agents/ dirs in discoverAgents
discoverAgents only walked .omp/agents and Claude marketplace plugin
roots, so agents shipped by OMP npm plugins (omp plugin install ...) and
--extension/extensions: settings roots silently disappeared while their
sibling skills/, hooks/, tools/ subdirectories were already discovered.

Route the same listOmpExtensionRoots scan used by discovery/omp-plugins.ts
through discoverAgents and append <root>/agents to the ordered scan list,
project scope before user. listOmpExtensionRoots already filters Claude
marketplace installs by realpath so they continue to flow only through
the claude-plugins provider.

Fixes #3920
2026-06-30 21:11:55 +00:00

142 lines
5.3 KiB
TypeScript

/**
* Agent discovery from filesystem.
*
* Discovers agent definitions from OMP-native task-agent roots:
* - ~/.omp/agent/agents/*.md (user-level)
* - .omp/agents/*.md (project-level)
* - <ext>/agents/*.md for every OMP extension package wired through
* `listOmpExtensionRoots` (CLI `--extension` roots, `extensions:` in
* settings, and enabled npm/link plugins under `<plugins>/node_modules/`).
* Mirrors the same sub-discovery convention applied to `skills/`,
* `hooks/`, `tools/`, etc. by `discovery/omp-plugins.ts`.
*
* Claude Code marketplace plugin agents are discovered separately via the
* claude-plugins provider. Direct cross-harness roots such as .claude/agents
* are intentionally skipped because their frontmatter schema is not the OMP
* task-agent contract.
*
* Agent files use markdown with YAML frontmatter.
*/
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import { isProviderEnabled } from "../capability";
import { findAllNearestProjectConfigDirs, getConfigDirs } from "../config";
import { listClaudePluginRoots } from "../discovery/helpers";
import { listOmpExtensionRoots } from "../discovery/omp-extension-roots";
import { loadBundledAgents, parseAgent } from "./agents";
import type { AgentDefinition, AgentSource } from "./types";
const TASK_AGENT_CONFIG_SOURCE = ".omp";
/** Result of agent discovery */
export interface DiscoveryResult {
agents: AgentDefinition[];
projectAgentsDir: string | null;
}
/**
* Load agents from a directory.
*/
async function loadAgentsFromDir(dir: string, source: AgentSource): Promise<AgentDefinition[]> {
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
const files = entries
.filter(entry => (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".md"))
.sort((a, b) => a.name.localeCompare(b.name))
.map(file => {
const filePath = path.join(dir, file.name);
return fs
.readFile(filePath, "utf-8")
.then(content => parseAgent(filePath, content, source, "warn"))
.catch(error => {
logger.warn("Failed to read agent file", { filePath, error });
return null;
});
});
return (await Promise.all(files)).filter(Boolean) as AgentDefinition[];
}
/**
* Discover agents from filesystem and merge with bundled agents.
* Precedence (highest wins): project `.omp/agents`, user `.omp/agents`,
* OMP extension-package agents (project scope before user), Claude
* marketplace plugin agents (project scope before user), then bundled.
* @param cwd - Current working directory for project agent discovery
*/
export async function discoverAgents(cwd: string, home: string = os.homedir()): Promise<DiscoveryResult> {
const resolvedCwd = path.resolve(cwd);
const userDirs = getConfigDirs("agents", { project: false })
.filter(entry => entry.source === TASK_AGENT_CONFIG_SOURCE)
.map(entry => ({
...entry,
path: path.resolve(entry.path),
}));
const projectDirs = findAllNearestProjectConfigDirs("agents", resolvedCwd)
.filter(entry => entry.source === TASK_AGENT_CONFIG_SOURCE)
.map(entry => ({
...entry,
path: path.resolve(entry.path),
}));
const orderedDirs: Array<{ dir: string; source: AgentSource }> = [];
const project = projectDirs[0];
if (project) orderedDirs.push({ dir: project.path, source: "project" });
const user = userDirs[0];
if (user) orderedDirs.push({ dir: user.path, source: "user" });
// OMP extension-package agents/ dirs (CLI roots + `extensions:` settings +
// enabled npm/link plugins). `listOmpExtensionRoots` already excludes Claude
// marketplace installs by realpath so we never double-scan them here.
const extensionRoots = await listOmpExtensionRoots({ cwd: resolvedCwd, home, repoRoot: null });
const sortedExtensionRoots = [...extensionRoots].sort((a, b) => {
if (a.level === b.level) return 0;
return a.level === "project" ? -1 : 1;
});
for (const root of sortedExtensionRoots) {
orderedDirs.push({ dir: path.join(root.path, "agents"), source: root.level });
}
// Load agents from Claude Code marketplace plugins (respects disabledProviders)
const { roots: pluginRoots } = isProviderEnabled("claude-plugins")
? await listClaudePluginRoots(home, resolvedCwd)
: { roots: [] };
const sortedPluginRoots = [...pluginRoots].sort((a, b) => {
if (a.scope === b.scope) return 0;
return a.scope === "project" ? -1 : 1;
});
for (const plugin of sortedPluginRoots) {
const agentsDir = path.join(plugin.path, "agents");
orderedDirs.push({ dir: agentsDir, source: plugin.scope === "project" ? "project" : "user" });
}
const seen = new Set<string>();
const loadedAgents = (await Promise.all(orderedDirs.map(({ dir, source }) => loadAgentsFromDir(dir, source))))
.flat()
.filter(agent => {
if (seen.has(agent.name)) return false;
seen.add(agent.name);
return true;
});
const bundledAgents = loadBundledAgents().filter(agent => {
if (seen.has(agent.name)) return false;
seen.add(agent.name);
return true;
});
const projectAgentsDir = projectDirs.length > 0 ? projectDirs[0].path : null;
return { agents: [...loadedAgents, ...bundledAgents], projectAgentsDir };
}
/**
* Get an agent by name from discovered agents.
*/
export function getAgent(agents: AgentDefinition[], name: string): AgentDefinition | undefined {
return agents.find(a => a.name === name);
}