- Add playwright as builtin skill with MCP server configuration - Add disabled_skills config option to disable specific builtin skills - Update BuiltinSkill type to include mcpConfig field - Update skill merger to handle mcpConfig from builtin to loaded skills - Merge disabled_skills config and filter unavailable builtin skills at plugin init - Update README with Built-in Skills documentation - Regenerate JSON schema 🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
724 lines
27 KiB
TypeScript
724 lines
27 KiB
TypeScript
import type { Plugin } from "@opencode-ai/plugin";
|
|
import { createBuiltinAgents } from "./agents";
|
|
import {
|
|
createTodoContinuationEnforcer,
|
|
createContextWindowMonitorHook,
|
|
createSessionRecoveryHook,
|
|
createSessionNotification,
|
|
createCommentCheckerHooks,
|
|
createToolOutputTruncatorHook,
|
|
createDirectoryAgentsInjectorHook,
|
|
createDirectoryReadmeInjectorHook,
|
|
createEmptyTaskResponseDetectorHook,
|
|
createThinkModeHook,
|
|
createClaudeCodeHooksHook,
|
|
createAnthropicContextWindowLimitRecoveryHook,
|
|
createPreemptiveCompactionHook,
|
|
createCompactionContextInjector,
|
|
createRulesInjectorHook,
|
|
createBackgroundNotificationHook,
|
|
createAutoUpdateCheckerHook,
|
|
createKeywordDetectorHook,
|
|
createAgentUsageReminderHook,
|
|
createNonInteractiveEnvHook,
|
|
createInteractiveBashSessionHook,
|
|
createEmptyMessageSanitizerHook,
|
|
createThinkingBlockValidatorHook,
|
|
createRalphLoopHook,
|
|
createAutoSlashCommandHook,
|
|
} from "./hooks";
|
|
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
|
import {
|
|
loadUserCommands,
|
|
loadProjectCommands,
|
|
loadOpencodeGlobalCommands,
|
|
loadOpencodeProjectCommands,
|
|
} from "./features/claude-code-command-loader";
|
|
import { loadBuiltinCommands } from "./features/builtin-commands";
|
|
import {
|
|
loadUserSkills,
|
|
loadProjectSkills,
|
|
loadOpencodeGlobalSkills,
|
|
loadOpencodeProjectSkills,
|
|
discoverUserClaudeSkills,
|
|
discoverProjectClaudeSkills,
|
|
discoverOpencodeGlobalSkills,
|
|
discoverOpencodeProjectSkills,
|
|
mergeSkills,
|
|
} from "./features/opencode-skill-loader";
|
|
import { createBuiltinSkills } from "./features/builtin-skills";
|
|
|
|
import {
|
|
loadUserAgents,
|
|
loadProjectAgents,
|
|
} from "./features/claude-code-agent-loader";
|
|
import { loadMcpConfigs } from "./features/claude-code-mcp-loader";
|
|
import { loadAllPluginComponents } from "./features/claude-code-plugin-loader";
|
|
import {
|
|
setMainSession,
|
|
getMainSessionID,
|
|
} from "./features/claude-code-session-state";
|
|
import { builtinTools, createCallOmoAgent, createBackgroundTools, createLookAt, createSkillTool, createSkillMcpTool, interactive_bash, getTmuxPath } from "./tools";
|
|
import { BackgroundManager } from "./features/background-agent";
|
|
import { SkillMcpManager } from "./features/skill-mcp-manager";
|
|
import { createBuiltinMcps } from "./mcp";
|
|
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config";
|
|
import { log, deepMerge, getUserConfigDir, addConfigLoadError, parseJsonc, detectConfigFile, migrateConfigFile } from "./shared";
|
|
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "./agents/plan-prompt";
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
|
|
function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig | null {
|
|
try {
|
|
if (fs.existsSync(configPath)) {
|
|
const content = fs.readFileSync(configPath, "utf-8");
|
|
const rawConfig = parseJsonc<Record<string, unknown>>(content);
|
|
|
|
migrateConfigFile(configPath, rawConfig);
|
|
|
|
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
|
|
|
if (!result.success) {
|
|
const errorMsg = result.error.issues.map(i => `${i.path.join(".")}: ${i.message}`).join(", ");
|
|
log(`Config validation error in ${configPath}:`, result.error.issues);
|
|
addConfigLoadError({ path: configPath, error: `Validation error: ${errorMsg}` });
|
|
return null;
|
|
}
|
|
|
|
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
|
|
return result.data;
|
|
}
|
|
} catch (err) {
|
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
log(`Error loading config from ${configPath}:`, err);
|
|
addConfigLoadError({ path: configPath, error: errorMsg });
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function mergeConfigs(
|
|
base: OhMyOpenCodeConfig,
|
|
override: OhMyOpenCodeConfig
|
|
): OhMyOpenCodeConfig {
|
|
return {
|
|
...base,
|
|
...override,
|
|
agents: deepMerge(base.agents, override.agents),
|
|
disabled_agents: [
|
|
...new Set([
|
|
...(base.disabled_agents ?? []),
|
|
...(override.disabled_agents ?? []),
|
|
]),
|
|
],
|
|
disabled_mcps: [
|
|
...new Set([
|
|
...(base.disabled_mcps ?? []),
|
|
...(override.disabled_mcps ?? []),
|
|
]),
|
|
],
|
|
disabled_hooks: [
|
|
...new Set([
|
|
...(base.disabled_hooks ?? []),
|
|
...(override.disabled_hooks ?? []),
|
|
]),
|
|
],
|
|
disabled_commands: [
|
|
...new Set([
|
|
...(base.disabled_commands ?? []),
|
|
...(override.disabled_commands ?? []),
|
|
]),
|
|
],
|
|
disabled_skills: [
|
|
...new Set([
|
|
...(base.disabled_skills ?? []),
|
|
...(override.disabled_skills ?? []),
|
|
]),
|
|
],
|
|
claude_code: deepMerge(base.claude_code, override.claude_code),
|
|
};
|
|
}
|
|
|
|
function loadPluginConfig(directory: string, ctx: any): OhMyOpenCodeConfig {
|
|
// User-level config path (OS-specific) - prefer .jsonc over .json
|
|
const userBasePath = path.join(getUserConfigDir(), "opencode", "oh-my-opencode");
|
|
const userDetected = detectConfigFile(userBasePath);
|
|
const userConfigPath = userDetected.format !== "none" ? userDetected.path : userBasePath + ".json";
|
|
|
|
// Project-level config path - prefer .jsonc over .json
|
|
const projectBasePath = path.join(directory, ".opencode", "oh-my-opencode");
|
|
const projectDetected = detectConfigFile(projectBasePath);
|
|
const projectConfigPath = projectDetected.format !== "none" ? projectDetected.path : projectBasePath + ".json";
|
|
|
|
// Load user config first (base)
|
|
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath, ctx) ?? {};
|
|
|
|
// Override with project config
|
|
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
|
|
if (projectConfig) {
|
|
config = mergeConfigs(config, projectConfig);
|
|
}
|
|
|
|
log("Final merged config", {
|
|
agents: config.agents,
|
|
disabled_agents: config.disabled_agents,
|
|
disabled_mcps: config.disabled_mcps,
|
|
disabled_hooks: config.disabled_hooks,
|
|
claude_code: config.claude_code,
|
|
});
|
|
return config;
|
|
}
|
|
|
|
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
|
|
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
|
|
const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName);
|
|
|
|
const modelContextLimitsCache = new Map<string, number>();
|
|
let anthropicContext1MEnabled = false;
|
|
|
|
const getModelLimit = (providerID: string, modelID: string): number | undefined => {
|
|
const key = `${providerID}/${modelID}`;
|
|
const cached = modelContextLimitsCache.get(key);
|
|
if (cached) return cached;
|
|
|
|
if (providerID === "anthropic" && anthropicContext1MEnabled && modelID.includes("sonnet")) {
|
|
return 1_000_000;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const contextWindowMonitor = isHookEnabled("context-window-monitor")
|
|
? createContextWindowMonitorHook(ctx)
|
|
: null;
|
|
const sessionRecovery = isHookEnabled("session-recovery")
|
|
? createSessionRecoveryHook(ctx, { experimental: pluginConfig.experimental })
|
|
: null;
|
|
const sessionNotification = isHookEnabled("session-notification")
|
|
? createSessionNotification(ctx)
|
|
: null;
|
|
|
|
const commentChecker = isHookEnabled("comment-checker")
|
|
? createCommentCheckerHooks(pluginConfig.comment_checker)
|
|
: null;
|
|
const toolOutputTruncator = isHookEnabled("tool-output-truncator")
|
|
? createToolOutputTruncatorHook(ctx, { experimental: pluginConfig.experimental })
|
|
: null;
|
|
const directoryAgentsInjector = isHookEnabled("directory-agents-injector")
|
|
? createDirectoryAgentsInjectorHook(ctx)
|
|
: null;
|
|
const directoryReadmeInjector = isHookEnabled("directory-readme-injector")
|
|
? createDirectoryReadmeInjectorHook(ctx)
|
|
: null;
|
|
const emptyTaskResponseDetector = isHookEnabled("empty-task-response-detector")
|
|
? createEmptyTaskResponseDetectorHook(ctx)
|
|
: null;
|
|
const thinkMode = isHookEnabled("think-mode")
|
|
? createThinkModeHook()
|
|
: null;
|
|
const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {
|
|
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
|
|
});
|
|
const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery")
|
|
? createAnthropicContextWindowLimitRecoveryHook(ctx, {
|
|
experimental: pluginConfig.experimental,
|
|
dcpForCompaction: pluginConfig.experimental?.dcp_for_compaction,
|
|
})
|
|
: null;
|
|
const compactionContextInjector = isHookEnabled("compaction-context-injector")
|
|
? createCompactionContextInjector()
|
|
: undefined;
|
|
const preemptiveCompaction = isHookEnabled("preemptive-compaction")
|
|
? createPreemptiveCompactionHook(ctx, {
|
|
experimental: pluginConfig.experimental,
|
|
onBeforeSummarize: compactionContextInjector,
|
|
getModelLimit,
|
|
})
|
|
: null;
|
|
const rulesInjector = isHookEnabled("rules-injector")
|
|
? createRulesInjectorHook(ctx)
|
|
: null;
|
|
const autoUpdateChecker = isHookEnabled("auto-update-checker")
|
|
? createAutoUpdateCheckerHook(ctx, {
|
|
showStartupToast: isHookEnabled("startup-toast"),
|
|
isSisyphusEnabled: pluginConfig.sisyphus_agent?.disabled !== true,
|
|
autoUpdate: pluginConfig.auto_update ?? true,
|
|
})
|
|
: null;
|
|
const keywordDetector = isHookEnabled("keyword-detector")
|
|
? createKeywordDetectorHook(ctx)
|
|
: null;
|
|
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
|
|
? createAgentUsageReminderHook(ctx)
|
|
: null;
|
|
const nonInteractiveEnv = isHookEnabled("non-interactive-env")
|
|
? createNonInteractiveEnvHook(ctx)
|
|
: null;
|
|
const interactiveBashSession = isHookEnabled("interactive-bash-session")
|
|
? createInteractiveBashSessionHook(ctx)
|
|
: null;
|
|
const emptyMessageSanitizer = isHookEnabled("empty-message-sanitizer")
|
|
? createEmptyMessageSanitizerHook()
|
|
: null;
|
|
const thinkingBlockValidator = isHookEnabled("thinking-block-validator")
|
|
? createThinkingBlockValidatorHook()
|
|
: null;
|
|
|
|
const ralphLoop = isHookEnabled("ralph-loop")
|
|
? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop })
|
|
: null;
|
|
|
|
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
|
? createAutoSlashCommandHook()
|
|
: null;
|
|
|
|
const backgroundManager = new BackgroundManager(ctx);
|
|
|
|
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
|
|
? createTodoContinuationEnforcer(ctx, { backgroundManager })
|
|
: null;
|
|
|
|
if (sessionRecovery && todoContinuationEnforcer) {
|
|
sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering);
|
|
sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete);
|
|
}
|
|
|
|
const backgroundNotificationHook = isHookEnabled("background-notification")
|
|
? createBackgroundNotificationHook(backgroundManager)
|
|
: null;
|
|
const backgroundTools = createBackgroundTools(backgroundManager, ctx.client);
|
|
|
|
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
|
|
const lookAt = createLookAt(ctx);
|
|
const disabledSkills = new Set(pluginConfig.disabled_skills ?? []);
|
|
const builtinSkills = createBuiltinSkills().filter(
|
|
(skill) => !disabledSkills.has(skill.name as any)
|
|
);
|
|
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false;
|
|
const mergedSkills = mergeSkills(
|
|
builtinSkills,
|
|
pluginConfig.skills,
|
|
includeClaudeSkills ? discoverUserClaudeSkills() : [],
|
|
discoverOpencodeGlobalSkills(),
|
|
includeClaudeSkills ? discoverProjectClaudeSkills() : [],
|
|
discoverOpencodeProjectSkills(),
|
|
);
|
|
const skillMcpManager = new SkillMcpManager();
|
|
const getSessionIDForMcp = () => getMainSessionID() || "";
|
|
const skillTool = createSkillTool({
|
|
skills: mergedSkills,
|
|
mcpManager: skillMcpManager,
|
|
getSessionID: getSessionIDForMcp,
|
|
});
|
|
const skillMcpTool = createSkillMcpTool({
|
|
manager: skillMcpManager,
|
|
getLoadedSkills: () => mergedSkills,
|
|
getSessionID: getSessionIDForMcp,
|
|
});
|
|
|
|
const googleAuthHooks = pluginConfig.google_auth !== false
|
|
? await createGoogleAntigravityAuthPlugin(ctx)
|
|
: null;
|
|
|
|
const tmuxAvailable = await getTmuxPath();
|
|
|
|
return {
|
|
...(googleAuthHooks ? { auth: googleAuthHooks.auth } : {}),
|
|
|
|
tool: {
|
|
...builtinTools,
|
|
...backgroundTools,
|
|
call_omo_agent: callOmoAgent,
|
|
look_at: lookAt,
|
|
skill: skillTool,
|
|
skill_mcp: skillMcpTool,
|
|
...(tmuxAvailable ? { interactive_bash } : {}),
|
|
},
|
|
|
|
"chat.message": async (input, output) => {
|
|
await claudeCodeHooks["chat.message"]?.(input, output);
|
|
await keywordDetector?.["chat.message"]?.(input, output);
|
|
await autoSlashCommand?.["chat.message"]?.(input, output);
|
|
|
|
if (ralphLoop) {
|
|
const parts = (output as { parts?: Array<{ type: string; text?: string }> }).parts;
|
|
const promptText = parts
|
|
?.filter((p) => p.type === "text" && p.text)
|
|
.map((p) => p.text)
|
|
.join("\n")
|
|
.trim() || "";
|
|
|
|
const isRalphLoopTemplate = promptText.includes("You are starting a Ralph Loop") &&
|
|
promptText.includes("<user-task>");
|
|
const isCancelRalphTemplate = promptText.includes("Cancel the currently active Ralph Loop");
|
|
|
|
if (isRalphLoopTemplate) {
|
|
const taskMatch = promptText.match(/<user-task>\s*([\s\S]*?)\s*<\/user-task>/i);
|
|
const rawTask = taskMatch?.[1]?.trim() || "";
|
|
|
|
const quotedMatch = rawTask.match(/^["'](.+?)["']/);
|
|
const prompt = quotedMatch?.[1] || rawTask.split(/\s+--/)[0]?.trim() || "Complete the task as instructed";
|
|
|
|
const maxIterMatch = rawTask.match(/--max-iterations=(\d+)/i);
|
|
const promiseMatch = rawTask.match(/--completion-promise=["']?([^"'\s]+)["']?/i);
|
|
|
|
log("[ralph-loop] Starting loop from chat.message", { sessionID: input.sessionID, prompt });
|
|
ralphLoop.startLoop(input.sessionID, prompt, {
|
|
maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
|
|
completionPromise: promiseMatch?.[1],
|
|
});
|
|
} else if (isCancelRalphTemplate) {
|
|
log("[ralph-loop] Cancelling loop from chat.message", { sessionID: input.sessionID });
|
|
ralphLoop.cancelLoop(input.sessionID);
|
|
}
|
|
}
|
|
},
|
|
|
|
"experimental.chat.messages.transform": async (
|
|
input: Record<string, never>,
|
|
output: { messages: Array<{ info: unknown; parts: unknown[] }> }
|
|
) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
await thinkingBlockValidator?.["experimental.chat.messages.transform"]?.(input, output as any);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
await emptyMessageSanitizer?.["experimental.chat.messages.transform"]?.(input, output as any);
|
|
},
|
|
|
|
config: async (config) => {
|
|
type ProviderConfig = {
|
|
options?: { headers?: Record<string, string> }
|
|
models?: Record<string, { limit?: { context?: number } }>
|
|
}
|
|
const providers = config.provider as Record<string, ProviderConfig> | undefined;
|
|
|
|
const anthropicBeta = providers?.anthropic?.options?.headers?.["anthropic-beta"];
|
|
anthropicContext1MEnabled = anthropicBeta?.includes("context-1m") ?? false;
|
|
|
|
if (providers) {
|
|
for (const [providerID, providerConfig] of Object.entries(providers)) {
|
|
const models = providerConfig?.models;
|
|
if (models) {
|
|
for (const [modelID, modelConfig] of Object.entries(models)) {
|
|
const contextLimit = modelConfig?.limit?.context;
|
|
if (contextLimit) {
|
|
modelContextLimitsCache.set(`${providerID}/${modelID}`, contextLimit);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
}
|
|
}
|
|
|
|
const pluginComponents = (pluginConfig.claude_code?.plugins ?? true)
|
|
? await loadAllPluginComponents({
|
|
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
|
|
})
|
|
: { commands: {}, skills: {}, agents: {}, mcpServers: {}, hooksConfigs: [], plugins: [], errors: [] };
|
|
|
|
if (pluginComponents.plugins.length > 0) {
|
|
log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, {
|
|
plugins: pluginComponents.plugins.map(p => `${p.name}@${p.version}`),
|
|
});
|
|
}
|
|
|
|
if (pluginComponents.errors.length > 0) {
|
|
log(`Plugin load errors`, { errors: pluginComponents.errors });
|
|
}
|
|
|
|
const builtinAgents = createBuiltinAgents(
|
|
pluginConfig.disabled_agents,
|
|
pluginConfig.agents,
|
|
ctx.directory,
|
|
config.model,
|
|
);
|
|
|
|
const userAgents = (pluginConfig.claude_code?.agents ?? true) ? loadUserAgents() : {};
|
|
const projectAgents = (pluginConfig.claude_code?.agents ?? true) ? loadProjectAgents() : {};
|
|
const pluginAgents = pluginComponents.agents;
|
|
|
|
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
|
|
const builderEnabled = pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
|
|
const plannerEnabled = pluginConfig.sisyphus_agent?.planner_enabled ?? true;
|
|
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
|
|
|
|
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
|
|
// Set Sisyphus as default agent (feature added in OpenCode PR #5843)
|
|
(config as { default_agent?: string }).default_agent = "Sisyphus";
|
|
|
|
const agentConfig: Record<string, unknown> = {
|
|
Sisyphus: builtinAgents.Sisyphus,
|
|
};
|
|
|
|
if (builderEnabled) {
|
|
const { name: _buildName, ...buildConfigWithoutName } = config.agent?.build ?? {};
|
|
const openCodeBuilderOverride = pluginConfig.agents?.["OpenCode-Builder"];
|
|
const openCodeBuilderBase = {
|
|
...buildConfigWithoutName,
|
|
description: `${config.agent?.build?.description ?? "Build agent"} (OpenCode default)`,
|
|
};
|
|
|
|
agentConfig["OpenCode-Builder"] = openCodeBuilderOverride
|
|
? { ...openCodeBuilderBase, ...openCodeBuilderOverride }
|
|
: openCodeBuilderBase;
|
|
}
|
|
|
|
if (plannerEnabled) {
|
|
const { name: _planName, ...planConfigWithoutName } = config.agent?.plan ?? {};
|
|
const plannerSisyphusOverride = pluginConfig.agents?.["Planner-Sisyphus"];
|
|
const plannerSisyphusBase = {
|
|
...planConfigWithoutName,
|
|
prompt: PLAN_SYSTEM_PROMPT,
|
|
permission: PLAN_PERMISSION,
|
|
description: `${config.agent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
|
|
color: config.agent?.plan?.color ?? "#6495ED",
|
|
};
|
|
|
|
agentConfig["Planner-Sisyphus"] = plannerSisyphusOverride
|
|
? { ...plannerSisyphusBase, ...plannerSisyphusOverride }
|
|
: plannerSisyphusBase;
|
|
}
|
|
|
|
// Filter out build/plan from config.agent - they'll be re-added as subagents if replaced
|
|
const filteredConfigAgents = config.agent ?
|
|
Object.fromEntries(
|
|
Object.entries(config.agent).filter(([key]) => {
|
|
if (key === "build") return false;
|
|
if (key === "plan" && replacePlan) return false;
|
|
return true;
|
|
})
|
|
) : {};
|
|
|
|
config.agent = {
|
|
...agentConfig,
|
|
...Object.fromEntries(Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")),
|
|
...userAgents,
|
|
...projectAgents,
|
|
...pluginAgents,
|
|
...filteredConfigAgents, // Filtered config agents (excludes build/plan if replaced)
|
|
// Demote build/plan to subagent mode when replaced
|
|
build: { ...config.agent?.build, mode: "subagent" },
|
|
...(replacePlan ? { plan: { ...config.agent?.plan, mode: "subagent" } } : {}),
|
|
};
|
|
} else {
|
|
config.agent = {
|
|
...builtinAgents,
|
|
...userAgents,
|
|
...projectAgents,
|
|
...pluginAgents,
|
|
...config.agent,
|
|
};
|
|
}
|
|
|
|
config.tools = {
|
|
...config.tools,
|
|
"grep_app_*": false, // Disable grep_app tools globally to reduce token usage (only librarian needs them)
|
|
};
|
|
|
|
if (config.agent.explore) {
|
|
config.agent.explore.tools = {
|
|
...config.agent.explore.tools,
|
|
call_omo_agent: false,
|
|
};
|
|
}
|
|
if (config.agent.librarian) {
|
|
config.agent.librarian.tools = {
|
|
...config.agent.librarian.tools,
|
|
call_omo_agent: false,
|
|
"grep_app_*": true,
|
|
};
|
|
}
|
|
if (config.agent["multimodal-looker"]) {
|
|
config.agent["multimodal-looker"].tools = {
|
|
...config.agent["multimodal-looker"].tools,
|
|
task: false,
|
|
call_omo_agent: false,
|
|
look_at: false,
|
|
};
|
|
}
|
|
|
|
config.permission = {
|
|
...config.permission,
|
|
webfetch: "allow",
|
|
external_directory: "allow",
|
|
}
|
|
|
|
const mcpResult = (pluginConfig.claude_code?.mcp ?? true)
|
|
? await loadMcpConfigs()
|
|
: { servers: {} };
|
|
|
|
config.mcp = {
|
|
...config.mcp,
|
|
...createBuiltinMcps(pluginConfig.disabled_mcps),
|
|
...mcpResult.servers,
|
|
...pluginComponents.mcpServers,
|
|
};
|
|
|
|
const builtinCommands = loadBuiltinCommands(pluginConfig.disabled_commands);
|
|
const userCommands = (pluginConfig.claude_code?.commands ?? true) ? loadUserCommands() : {};
|
|
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
|
|
const systemCommands = config.command ?? {};
|
|
const projectCommands = (pluginConfig.claude_code?.commands ?? true) ? loadProjectCommands() : {};
|
|
const opencodeProjectCommands = loadOpencodeProjectCommands();
|
|
|
|
const userSkills = (pluginConfig.claude_code?.skills ?? true) ? loadUserSkills() : {};
|
|
const projectSkills = (pluginConfig.claude_code?.skills ?? true) ? loadProjectSkills() : {};
|
|
const opencodeGlobalSkills = loadOpencodeGlobalSkills();
|
|
const opencodeProjectSkills = loadOpencodeProjectSkills();
|
|
|
|
config.command = {
|
|
...builtinCommands,
|
|
...userCommands,
|
|
...userSkills,
|
|
...opencodeGlobalCommands,
|
|
...opencodeGlobalSkills,
|
|
...systemCommands,
|
|
...projectCommands,
|
|
...projectSkills,
|
|
...opencodeProjectCommands,
|
|
...opencodeProjectSkills,
|
|
...pluginComponents.commands,
|
|
...pluginComponents.skills,
|
|
};
|
|
},
|
|
|
|
event: async (input) => {
|
|
await autoUpdateChecker?.event(input);
|
|
await claudeCodeHooks.event(input);
|
|
await backgroundNotificationHook?.event(input);
|
|
await sessionNotification?.(input);
|
|
await todoContinuationEnforcer?.handler(input);
|
|
await contextWindowMonitor?.event(input);
|
|
await directoryAgentsInjector?.event(input);
|
|
await directoryReadmeInjector?.event(input);
|
|
await rulesInjector?.event(input);
|
|
await thinkMode?.event(input);
|
|
await anthropicContextWindowLimitRecovery?.event(input);
|
|
await preemptiveCompaction?.event(input);
|
|
await agentUsageReminder?.event(input);
|
|
await interactiveBashSession?.event(input);
|
|
await ralphLoop?.event(input);
|
|
|
|
const { event } = input;
|
|
const props = event.properties as Record<string, unknown> | undefined;
|
|
|
|
if (event.type === "session.created") {
|
|
const sessionInfo = props?.info as
|
|
| { id?: string; title?: string; parentID?: string }
|
|
| undefined;
|
|
if (!sessionInfo?.parentID) {
|
|
setMainSession(sessionInfo?.id);
|
|
}
|
|
}
|
|
|
|
if (event.type === "session.deleted") {
|
|
const sessionInfo = props?.info as { id?: string } | undefined;
|
|
if (sessionInfo?.id === getMainSessionID()) {
|
|
setMainSession(undefined);
|
|
}
|
|
if (sessionInfo?.id) {
|
|
await skillMcpManager.disconnectSession(sessionInfo.id);
|
|
}
|
|
}
|
|
|
|
if (event.type === "session.error") {
|
|
const sessionID = props?.sessionID as string | undefined;
|
|
const error = props?.error;
|
|
|
|
if (sessionRecovery?.isRecoverableError(error)) {
|
|
const messageInfo = {
|
|
id: props?.messageID as string | undefined,
|
|
role: "assistant" as const,
|
|
sessionID,
|
|
error,
|
|
};
|
|
const recovered =
|
|
await sessionRecovery.handleSessionRecovery(messageInfo);
|
|
|
|
if (recovered && sessionID && sessionID === getMainSessionID()) {
|
|
await ctx.client.session
|
|
.prompt({
|
|
path: { id: sessionID },
|
|
body: { parts: [{ type: "text", text: "continue" }] },
|
|
query: { directory: ctx.directory },
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
"tool.execute.before": async (input, output) => {
|
|
await claudeCodeHooks["tool.execute.before"](input, output);
|
|
await nonInteractiveEnv?.["tool.execute.before"](input, output);
|
|
await commentChecker?.["tool.execute.before"](input, output);
|
|
await directoryAgentsInjector?.["tool.execute.before"]?.(input, output);
|
|
await directoryReadmeInjector?.["tool.execute.before"]?.(input, output);
|
|
await rulesInjector?.["tool.execute.before"]?.(input, output);
|
|
|
|
if (input.tool === "task") {
|
|
const args = output.args as Record<string, unknown>;
|
|
const subagentType = args.subagent_type as string;
|
|
const isExploreOrLibrarian = ["explore", "librarian"].includes(subagentType);
|
|
|
|
args.tools = {
|
|
...(args.tools as Record<string, boolean> | undefined),
|
|
background_task: false,
|
|
...(isExploreOrLibrarian ? { call_omo_agent: false } : {}),
|
|
};
|
|
}
|
|
|
|
if (ralphLoop && input.tool === "slashcommand") {
|
|
const args = output.args as { command?: string } | undefined;
|
|
const command = args?.command?.replace(/^\//, "").toLowerCase();
|
|
const sessionID = input.sessionID || getMainSessionID();
|
|
|
|
if (command === "ralph-loop" && sessionID) {
|
|
const rawArgs = args?.command?.replace(/^\/?(ralph-loop)\s*/i, "") || "";
|
|
const taskMatch = rawArgs.match(/^["'](.+?)["']/);
|
|
const prompt = taskMatch?.[1] || rawArgs.split(/\s+--/)[0]?.trim() || "Complete the task as instructed";
|
|
|
|
const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i);
|
|
const promiseMatch = rawArgs.match(/--completion-promise=["']?([^"'\s]+)["']?/i);
|
|
|
|
ralphLoop.startLoop(sessionID, prompt, {
|
|
maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
|
|
completionPromise: promiseMatch?.[1],
|
|
});
|
|
} else if (command === "cancel-ralph" && sessionID) {
|
|
ralphLoop.cancelLoop(sessionID);
|
|
}
|
|
}
|
|
},
|
|
|
|
"tool.execute.after": async (input, output) => {
|
|
await claudeCodeHooks["tool.execute.after"](input, output);
|
|
await toolOutputTruncator?.["tool.execute.after"](input, output);
|
|
await contextWindowMonitor?.["tool.execute.after"](input, output);
|
|
await commentChecker?.["tool.execute.after"](input, output);
|
|
await directoryAgentsInjector?.["tool.execute.after"](input, output);
|
|
await directoryReadmeInjector?.["tool.execute.after"](input, output);
|
|
await rulesInjector?.["tool.execute.after"](input, output);
|
|
await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
|
|
await agentUsageReminder?.["tool.execute.after"](input, output);
|
|
await interactiveBashSession?.["tool.execute.after"](input, output);
|
|
},
|
|
};
|
|
};
|
|
|
|
export default OhMyOpenCodePlugin;
|
|
|
|
export type {
|
|
OhMyOpenCodeConfig,
|
|
AgentName,
|
|
AgentOverrideConfig,
|
|
AgentOverrides,
|
|
McpName,
|
|
HookName,
|
|
BuiltinCommandName,
|
|
} from "./config";
|
|
|
|
// NOTE: Do NOT export functions from main index.ts!
|
|
// OpenCode treats ALL exports as plugin instances and calls them.
|
|
// Config error utilities are available via "./shared/config-errors" for internal use only.
|
|
export type { ConfigLoadError } from "./shared/config-errors";
|