From 598a4389d14ba4cdd1eee516662c1e60d4cc8479 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:25:25 +0900 Subject: [PATCH] refactor(core): split index.ts and config-handler.ts into focused modules Main entry point: - create-hooks.ts, create-tools.ts, create-managers.ts - plugin-interface.ts: plugin interface types - plugin/ directory: plugin lifecycle modules Config handler: - agent-config-handler.ts, command-config-handler.ts - tool-config-handler.ts, mcp-config-handler.ts - provider-config-handler.ts, category-config-resolver.ts - agent-priority-order.ts, prometheus-agent-config-builder.ts - plugin-components-loader.ts --- src/create-hooks.ts | 61 ++ src/create-managers.ts | 79 ++ src/create-tools.ts | 53 + src/index.ts | 996 ++---------------- src/plugin-handlers/agent-config-handler.ts | 188 ++++ src/plugin-handlers/agent-priority-order.ts | 23 + .../category-config-resolver.ts | 9 + src/plugin-handlers/command-config-handler.ts | 62 ++ src/plugin-handlers/config-handler.ts | 532 +--------- src/plugin-handlers/index.ts | 9 + src/plugin-handlers/mcp-config-handler.ts | 21 + .../plugin-components-loader.ts | 70 ++ .../prometheus-agent-config-builder.ts | 98 ++ .../provider-config-handler.ts | 36 + src/plugin-handlers/tool-config-handler.ts | 91 ++ src/plugin-interface.ts | 65 ++ src/plugin/available-categories.ts | 29 + src/plugin/chat-message.ts | 139 +++ src/plugin/chat-params.ts | 71 ++ src/plugin/event.ts | 133 +++ src/plugin/hooks/create-continuation-hooks.ts | 104 ++ src/plugin/hooks/create-core-hooks.ts | 42 + src/plugin/hooks/create-session-hooks.ts | 181 ++++ src/plugin/hooks/create-skill-hooks.ts | 37 + src/plugin/hooks/create-tool-guard-hooks.ts | 98 ++ src/plugin/hooks/create-transform-hooks.ts | 65 ++ src/plugin/messages-transform.ts | 24 + src/plugin/skill-context.ts | 87 ++ src/plugin/tool-execute-after.ts | 47 + src/plugin/tool-execute-before.ts | 99 ++ src/plugin/tool-registry.ts | 143 +++ src/plugin/types.ts | 15 + src/plugin/unstable-agent-babysitter.ts | 41 + 33 files changed, 2305 insertions(+), 1443 deletions(-) create mode 100644 src/create-hooks.ts create mode 100644 src/create-managers.ts create mode 100644 src/create-tools.ts create mode 100644 src/plugin-handlers/agent-config-handler.ts create mode 100644 src/plugin-handlers/agent-priority-order.ts create mode 100644 src/plugin-handlers/category-config-resolver.ts create mode 100644 src/plugin-handlers/command-config-handler.ts create mode 100644 src/plugin-handlers/mcp-config-handler.ts create mode 100644 src/plugin-handlers/plugin-components-loader.ts create mode 100644 src/plugin-handlers/prometheus-agent-config-builder.ts create mode 100644 src/plugin-handlers/provider-config-handler.ts create mode 100644 src/plugin-handlers/tool-config-handler.ts create mode 100644 src/plugin-interface.ts create mode 100644 src/plugin/available-categories.ts create mode 100644 src/plugin/chat-message.ts create mode 100644 src/plugin/chat-params.ts create mode 100644 src/plugin/event.ts create mode 100644 src/plugin/hooks/create-continuation-hooks.ts create mode 100644 src/plugin/hooks/create-core-hooks.ts create mode 100644 src/plugin/hooks/create-session-hooks.ts create mode 100644 src/plugin/hooks/create-skill-hooks.ts create mode 100644 src/plugin/hooks/create-tool-guard-hooks.ts create mode 100644 src/plugin/hooks/create-transform-hooks.ts create mode 100644 src/plugin/messages-transform.ts create mode 100644 src/plugin/skill-context.ts create mode 100644 src/plugin/tool-execute-after.ts create mode 100644 src/plugin/tool-execute-before.ts create mode 100644 src/plugin/tool-registry.ts create mode 100644 src/plugin/types.ts create mode 100644 src/plugin/unstable-agent-babysitter.ts diff --git a/src/create-hooks.ts b/src/create-hooks.ts new file mode 100644 index 000000000..efa81fab8 --- /dev/null +++ b/src/create-hooks.ts @@ -0,0 +1,61 @@ +import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder" +import type { HookName, OhMyOpenCodeConfig } from "./config" +import type { LoadedSkill } from "./features/opencode-skill-loader/types" +import type { BackgroundManager } from "./features/background-agent" +import type { PluginContext } from "./plugin/types" + +import { createCoreHooks } from "./plugin/hooks/create-core-hooks" +import { createContinuationHooks } from "./plugin/hooks/create-continuation-hooks" +import { createSkillHooks } from "./plugin/hooks/create-skill-hooks" + +export type CreatedHooks = ReturnType + +export function createHooks(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + backgroundManager: BackgroundManager + isHookEnabled: (hookName: HookName) => boolean + safeHookEnabled: boolean + mergedSkills: LoadedSkill[] + availableSkills: AvailableSkill[] +}) { + const { + ctx, + pluginConfig, + backgroundManager, + isHookEnabled, + safeHookEnabled, + mergedSkills, + availableSkills, + } = args + + const core = createCoreHooks({ + ctx, + pluginConfig, + isHookEnabled, + safeHookEnabled, + }) + + const continuation = createContinuationHooks({ + ctx, + pluginConfig, + isHookEnabled, + safeHookEnabled, + backgroundManager, + sessionRecovery: core.sessionRecovery, + }) + + const skill = createSkillHooks({ + ctx, + isHookEnabled, + safeHookEnabled, + mergedSkills, + availableSkills, + }) + + return { + ...core, + ...continuation, + ...skill, + } +} diff --git a/src/create-managers.ts b/src/create-managers.ts new file mode 100644 index 000000000..fb8891d21 --- /dev/null +++ b/src/create-managers.ts @@ -0,0 +1,79 @@ +import type { OhMyOpenCodeConfig } from "./config" +import type { ModelCacheState } from "./plugin-state" +import type { PluginContext, TmuxConfig } from "./plugin/types" + +import type { SubagentSessionCreatedEvent } from "./features/background-agent" +import { BackgroundManager } from "./features/background-agent" +import { SkillMcpManager } from "./features/skill-mcp-manager" +import { initTaskToastManager } from "./features/task-toast-manager" +import { TmuxSessionManager } from "./features/tmux-subagent" +import { createConfigHandler } from "./plugin-handlers" +import { log } from "./shared" + +export type Managers = { + tmuxSessionManager: TmuxSessionManager + backgroundManager: BackgroundManager + skillMcpManager: SkillMcpManager + configHandler: ReturnType +} + +export function createManagers(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + tmuxConfig: TmuxConfig + modelCacheState: ModelCacheState +}): Managers { + const { ctx, pluginConfig, tmuxConfig, modelCacheState } = args + + const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig) + + const backgroundManager = new BackgroundManager( + ctx, + pluginConfig.background_task, + { + tmuxConfig, + onSubagentSessionCreated: async (event: SubagentSessionCreatedEvent) => { + log("[index] onSubagentSessionCreated callback received", { + sessionID: event.sessionID, + parentID: event.parentID, + title: event.title, + }) + + await tmuxSessionManager.onSessionCreated({ + type: "session.created", + properties: { + info: { + id: event.sessionID, + parentID: event.parentID, + title: event.title, + }, + }, + }) + + log("[index] onSubagentSessionCreated callback completed") + }, + onShutdown: () => { + tmuxSessionManager.cleanup().catch((error) => { + log("[index] tmux cleanup error during shutdown:", error) + }) + }, + }, + ) + + initTaskToastManager(ctx.client) + + const skillMcpManager = new SkillMcpManager() + + const configHandler = createConfigHandler({ + ctx: { directory: ctx.directory, client: ctx.client }, + pluginConfig, + modelCacheState, + }) + + return { + tmuxSessionManager, + backgroundManager, + skillMcpManager, + configHandler, + } +} diff --git a/src/create-tools.ts b/src/create-tools.ts new file mode 100644 index 000000000..880e0a427 --- /dev/null +++ b/src/create-tools.ts @@ -0,0 +1,53 @@ +import type { AvailableCategory, AvailableSkill } from "./agents/dynamic-agent-prompt-builder" +import type { OhMyOpenCodeConfig } from "./config" +import type { BrowserAutomationProvider } from "./config/schema/browser-automation" +import type { LoadedSkill } from "./features/opencode-skill-loader/types" +import type { PluginContext, ToolsRecord } from "./plugin/types" +import type { Managers } from "./create-managers" + +import { createAvailableCategories } from "./plugin/available-categories" +import { createSkillContext } from "./plugin/skill-context" +import { createToolRegistry } from "./plugin/tool-registry" + +export type CreateToolsResult = { + filteredTools: ToolsRecord + mergedSkills: LoadedSkill[] + availableSkills: AvailableSkill[] + availableCategories: AvailableCategory[] + browserProvider: BrowserAutomationProvider + disabledSkills: Set + taskSystemEnabled: boolean +} + +export async function createTools(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + managers: Pick +}): Promise { + const { ctx, pluginConfig, managers } = args + + const skillContext = await createSkillContext({ + directory: ctx.directory, + pluginConfig, + }) + + const availableCategories = createAvailableCategories(pluginConfig) + + const { filteredTools, taskSystemEnabled } = createToolRegistry({ + ctx, + pluginConfig, + managers, + skillContext, + availableCategories, + }) + + return { + filteredTools, + mergedSkills: skillContext.mergedSkills, + availableSkills: skillContext.availableSkills, + availableCategories, + browserProvider: skillContext.browserProvider, + disabledSkills: skillContext.disabledSkills, + taskSystemEnabled, + } +} diff --git a/src/index.ts b/src/index.ts index db49858c3..69cae8cd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,137 +1,33 @@ -import type { Plugin, ToolDefinition } from "@opencode-ai/plugin"; -import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder"; -import { - createTodoContinuationEnforcer, - createContextWindowMonitorHook, - createSessionRecoveryHook, - createSessionNotification, - createCommentCheckerHooks, - createToolOutputTruncatorHook, - createDirectoryAgentsInjectorHook, - createDirectoryReadmeInjectorHook, - createEmptyTaskResponseDetectorHook, - createThinkModeHook, - createClaudeCodeHooksHook, - createAnthropicContextWindowLimitRecoveryHook, - createRulesInjectorHook, - createBackgroundNotificationHook, - createAutoUpdateCheckerHook, - createKeywordDetectorHook, - createAgentUsageReminderHook, - createNonInteractiveEnvHook, - createInteractiveBashSessionHook, - createThinkingBlockValidatorHook, - createCategorySkillReminderHook, - createRalphLoopHook, - createAutoSlashCommandHook, - createEditErrorRecoveryHook, - createDelegateTaskRetryHook, - createTaskResumeInfoHook, - createStartWorkHook, - createAtlasHook, - createPrometheusMdOnlyHook, - createSisyphusJuniorNotepadHook, - createQuestionLabelTruncatorHook, - createSubagentQuestionBlockerHook, - createStopContinuationGuardHook, - createCompactionContextInjector, - createCompactionTodoPreserverHook, - createUnstableAgentBabysitterHook, - createPreemptiveCompactionHook, - createTasksTodowriteDisablerHook, - createWriteExistingFileGuardHook, -} from "./hooks"; -import { createAnthropicEffortHook } from "./hooks/anthropic-effort"; -import { - contextCollector, - createContextInjectorMessagesTransformHook, -} from "./features/context-injector"; -import { - applyAgentVariant, - resolveAgentVariant, - resolveVariantForModel, -} from "./shared/agent-variant"; -import { createFirstMessageVariantGate } from "./shared/first-message-variant"; -import { - discoverUserClaudeSkills, - discoverProjectClaudeSkills, - discoverOpencodeGlobalSkills, - discoverOpencodeProjectSkills, - mergeSkills, -} from "./features/opencode-skill-loader"; -import type { SkillScope } from "./features/opencode-skill-loader/types"; -import { createBuiltinSkills } from "./features/builtin-skills"; -import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader"; -import { - setMainSession, - getMainSessionID, - setSessionAgent, - updateSessionAgent, - clearSessionAgent, -} from "./features/claude-code-session-state"; -import { - builtinTools, - createCallOmoAgent, - createBackgroundTools, - createLookAt, - createSkillTool, - createSkillMcpTool, - createSlashcommandTool, - discoverCommandsSync, - sessionExists, - createDelegateTask, - interactive_bash, - startTmuxCheck, - lspManager, - createTaskCreateTool, - createTaskGetTool, - createTaskList, - createTaskUpdateTool, - createGrepTools, - createGlobTools, - createAstGrepTools, - createSessionManagerTools, -} from "./tools"; -import { - CATEGORY_DESCRIPTIONS, - DEFAULT_CATEGORIES, -} from "./tools/delegate-task/constants"; -import { BackgroundManager } from "./features/background-agent"; -import { SkillMcpManager } from "./features/skill-mcp-manager"; -import { initTaskToastManager } from "./features/task-toast-manager"; -import { TmuxSessionManager } from "./features/tmux-subagent"; -import { clearBoulderState } from "./features/boulder-state"; -import { type HookName } from "./config"; -import { - log, - detectExternalNotificationPlugin, - getNotificationConflictWarning, - resetMessageCursor, - hasConnectedProvidersCache, - getOpenCodeVersion, - isOpenCodeVersionAtLeast, - OPENCODE_NATIVE_AGENTS_INJECTION_VERSION, - injectServerAuthIntoClient, -} from "./shared"; -import { filterDisabledTools } from "./shared/disabled-tools"; -import { safeCreateHook } from "./shared/safe-create-hook"; -import { loadPluginConfig } from "./plugin-config"; -import { createModelCacheState } from "./plugin-state"; -import { createConfigHandler } from "./plugin-handlers"; -import { consumeToolMetadata } from "./features/tool-metadata-store"; +import type { Plugin } from "@opencode-ai/plugin" + +import type { HookName } from "./config" + +import { createHooks } from "./create-hooks" +import { createManagers } from "./create-managers" +import { createTools } from "./create-tools" +import { createPluginInterface } from "./plugin-interface" + +import { loadPluginConfig } from "./plugin-config" +import { createModelCacheState } from "./plugin-state" +import { createFirstMessageVariantGate } from "./shared/first-message-variant" +import { injectServerAuthIntoClient, log } from "./shared" +import { startTmuxCheck } from "./tools" const OhMyOpenCodePlugin: Plugin = async (ctx) => { log("[OhMyOpenCodePlugin] ENTRY - plugin loading", { directory: ctx.directory, - }); - injectServerAuthIntoClient(ctx.client); - // Start background tmux check immediately - startTmuxCheck(); + }) - const pluginConfig = loadPluginConfig(ctx.directory, ctx); - const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []); + injectServerAuthIntoClient(ctx.client) + startTmuxCheck() - const firstMessageVariantGate = createFirstMessageVariantGate(); + const pluginConfig = loadPluginConfig(ctx.directory, ctx) + const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []) + + const isHookEnabled = (hookName: HookName): boolean => !disabledHooks.has(hookName) + const safeHookEnabled = pluginConfig.experimental?.safe_hook_creation ?? true + + const firstMessageVariantGate = createFirstMessageVariantGate() const tmuxConfig = { enabled: pluginConfig.tmux?.enabled ?? false, @@ -139,831 +35,59 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { main_pane_size: pluginConfig.tmux?.main_pane_size ?? 60, main_pane_min_width: pluginConfig.tmux?.main_pane_min_width ?? 120, agent_pane_min_width: pluginConfig.tmux?.agent_pane_min_width ?? 40, - } as const; - const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName); - const safeHookEnabled = pluginConfig.experimental?.safe_hook_creation ?? true; - - const modelCacheState = createModelCacheState(); - - const contextWindowMonitor = isHookEnabled("context-window-monitor") - ? safeCreateHook("context-window-monitor", () => createContextWindowMonitorHook(ctx), { enabled: safeHookEnabled }) - : null; - const preemptiveCompaction = - isHookEnabled("preemptive-compaction") && - pluginConfig.experimental?.preemptive_compaction - ? safeCreateHook("preemptive-compaction", () => createPreemptiveCompactionHook(ctx), { enabled: safeHookEnabled }) - : null; - const sessionRecovery = isHookEnabled("session-recovery") - ? safeCreateHook("session-recovery", () => createSessionRecoveryHook(ctx, { - experimental: pluginConfig.experimental, - }), { enabled: safeHookEnabled }) - : null; - - // Check for conflicting notification plugins before creating session-notification - let sessionNotification = null; - if (isHookEnabled("session-notification")) { - const forceEnable = pluginConfig.notification?.force_enable ?? false; - const externalNotifier = detectExternalNotificationPlugin(ctx.directory); - - if (externalNotifier.detected && !forceEnable) { - // External notification plugin detected - skip our notification to avoid conflicts - log(getNotificationConflictWarning(externalNotifier.pluginName!)); - log("session-notification disabled due to external notifier conflict", { - detected: externalNotifier.pluginName, - allPlugins: externalNotifier.allPlugins, - }); - } else { - sessionNotification = safeCreateHook("session-notification", () => createSessionNotification(ctx), { enabled: safeHookEnabled }); - } } - const commentChecker = isHookEnabled("comment-checker") - ? safeCreateHook("comment-checker", () => createCommentCheckerHooks(pluginConfig.comment_checker), { enabled: safeHookEnabled }) - : null; - const toolOutputTruncator = isHookEnabled("tool-output-truncator") - ? safeCreateHook("tool-output-truncator", () => createToolOutputTruncatorHook(ctx, { - experimental: pluginConfig.experimental, - }), { enabled: safeHookEnabled }) - : null; - // Check for native OpenCode AGENTS.md injection support before creating hook - let directoryAgentsInjector = null; - if (isHookEnabled("directory-agents-injector")) { - const currentVersion = getOpenCodeVersion(); - const hasNativeSupport = - currentVersion !== null && - isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION); + const modelCacheState = createModelCacheState() - if (hasNativeSupport) { - log( - "directory-agents-injector auto-disabled due to native OpenCode support", - { - currentVersion, - nativeVersion: OPENCODE_NATIVE_AGENTS_INJECTION_VERSION, - }, - ); - } else { - directoryAgentsInjector = safeCreateHook("directory-agents-injector", () => createDirectoryAgentsInjectorHook(ctx), { enabled: safeHookEnabled }); - } - } - const directoryReadmeInjector = isHookEnabled("directory-readme-injector") - ? safeCreateHook("directory-readme-injector", () => createDirectoryReadmeInjectorHook(ctx), { enabled: safeHookEnabled }) - : null; - const emptyTaskResponseDetector = isHookEnabled( - "empty-task-response-detector", - ) - ? safeCreateHook("empty-task-response-detector", () => createEmptyTaskResponseDetectorHook(ctx), { enabled: safeHookEnabled }) - : null; - const thinkMode = isHookEnabled("think-mode") ? safeCreateHook("think-mode", () => createThinkModeHook(), { enabled: safeHookEnabled }) : null; - const claudeCodeHooks = createClaudeCodeHooksHook( + const managers = createManagers({ ctx, - { - disabledHooks: - (pluginConfig.claude_code?.hooks ?? true) ? undefined : true, - keywordDetectorDisabled: !isHookEnabled("keyword-detector"), - }, - contextCollector, - ); - const anthropicContextWindowLimitRecovery = isHookEnabled( - "anthropic-context-window-limit-recovery", - ) - ? safeCreateHook("anthropic-context-window-limit-recovery", () => createAnthropicContextWindowLimitRecoveryHook(ctx, { - experimental: pluginConfig.experimental, - }), { enabled: safeHookEnabled }) - : null; - const rulesInjector = isHookEnabled("rules-injector") - ? safeCreateHook("rules-injector", () => createRulesInjectorHook(ctx), { enabled: safeHookEnabled }) - : null; - const autoUpdateChecker = isHookEnabled("auto-update-checker") - ? safeCreateHook("auto-update-checker", () => createAutoUpdateCheckerHook(ctx, { - showStartupToast: isHookEnabled("startup-toast"), - isSisyphusEnabled: pluginConfig.sisyphus_agent?.disabled !== true, - autoUpdate: pluginConfig.auto_update ?? true, - }), { enabled: safeHookEnabled }) - : null; - const keywordDetector = isHookEnabled("keyword-detector") - ? safeCreateHook("keyword-detector", () => createKeywordDetectorHook(ctx, contextCollector), { enabled: safeHookEnabled }) - : null; - const contextInjectorMessagesTransform = - createContextInjectorMessagesTransformHook(contextCollector); - const agentUsageReminder = isHookEnabled("agent-usage-reminder") - ? safeCreateHook("agent-usage-reminder", () => createAgentUsageReminderHook(ctx), { enabled: safeHookEnabled }) - : null; - const nonInteractiveEnv = isHookEnabled("non-interactive-env") - ? safeCreateHook("non-interactive-env", () => createNonInteractiveEnvHook(ctx), { enabled: safeHookEnabled }) - : null; - const interactiveBashSession = isHookEnabled("interactive-bash-session") - ? safeCreateHook("interactive-bash-session", () => createInteractiveBashSessionHook(ctx), { enabled: safeHookEnabled }) - : null; - - const thinkingBlockValidator = isHookEnabled("thinking-block-validator") - ? safeCreateHook("thinking-block-validator", () => createThinkingBlockValidatorHook(), { enabled: safeHookEnabled }) - : null; - - let categorySkillReminder: ReturnType | null = null; - - const ralphLoop = isHookEnabled("ralph-loop") - ? safeCreateHook("ralph-loop", () => createRalphLoopHook(ctx, { - config: pluginConfig.ralph_loop, - checkSessionExists: async (sessionId) => sessionExists(sessionId), - }), { enabled: safeHookEnabled }) - : null; - - const editErrorRecovery = isHookEnabled("edit-error-recovery") - ? safeCreateHook("edit-error-recovery", () => createEditErrorRecoveryHook(ctx), { enabled: safeHookEnabled }) - : null; - - const delegateTaskRetry = isHookEnabled("delegate-task-retry") - ? safeCreateHook("delegate-task-retry", () => createDelegateTaskRetryHook(ctx), { enabled: safeHookEnabled }) - : null; - - const startWork = isHookEnabled("start-work") - ? safeCreateHook("start-work", () => createStartWorkHook(ctx), { enabled: safeHookEnabled }) - : null; - - const prometheusMdOnly = isHookEnabled("prometheus-md-only") - ? safeCreateHook("prometheus-md-only", () => createPrometheusMdOnlyHook(ctx), { enabled: safeHookEnabled }) - : null; - - const sisyphusJuniorNotepad = isHookEnabled("sisyphus-junior-notepad") - ? safeCreateHook("sisyphus-junior-notepad", () => createSisyphusJuniorNotepadHook(ctx), { enabled: safeHookEnabled }) - : null; - - const tasksTodowriteDisabler = isHookEnabled("tasks-todowrite-disabler") - ? safeCreateHook("tasks-todowrite-disabler", () => createTasksTodowriteDisablerHook({ - experimental: pluginConfig.experimental, - }), { enabled: safeHookEnabled }) - : null; - - const questionLabelTruncator = createQuestionLabelTruncatorHook(); - const subagentQuestionBlocker = createSubagentQuestionBlockerHook(); - const writeExistingFileGuard = isHookEnabled("write-existing-file-guard") - ? safeCreateHook("write-existing-file-guard", () => createWriteExistingFileGuardHook(ctx), { enabled: safeHookEnabled }) - : null; - - const taskResumeInfo = createTaskResumeInfoHook(); - - const anthropicEffort = isHookEnabled("anthropic-effort") - ? safeCreateHook("anthropic-effort", () => createAnthropicEffortHook(), { enabled: safeHookEnabled }) - : null; - - const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig); - - const backgroundManager = new BackgroundManager( - ctx, - pluginConfig.background_task, - { - tmuxConfig, - onSubagentSessionCreated: async (event) => { - log("[index] onSubagentSessionCreated callback received", { - sessionID: event.sessionID, - parentID: event.parentID, - title: event.title, - }); - await tmuxSessionManager.onSessionCreated({ - type: "session.created", - properties: { - info: { - id: event.sessionID, - parentID: event.parentID, - title: event.title, - }, - }, - }); - log("[index] onSubagentSessionCreated callback completed"); - }, - onShutdown: () => { - tmuxSessionManager.cleanup().catch((error) => { - log("[index] tmux cleanup error during shutdown:", error); - }); - }, - }, - ); - - const atlasHook = isHookEnabled("atlas") - ? safeCreateHook("atlas", () => createAtlasHook(ctx, { - directory: ctx.directory, - backgroundManager, - isContinuationStopped: (sessionID: string) => stopContinuationGuard?.isStopped(sessionID) ?? false, - }), { enabled: safeHookEnabled }) - : null; - - initTaskToastManager(ctx.client); - - const stopContinuationGuard = isHookEnabled("stop-continuation-guard") - ? safeCreateHook("stop-continuation-guard", () => createStopContinuationGuardHook(ctx), { enabled: safeHookEnabled }) - : null; - - const compactionContextInjector = isHookEnabled("compaction-context-injector") - ? safeCreateHook("compaction-context-injector", () => createCompactionContextInjector(), { enabled: safeHookEnabled }) - : null; - - const compactionTodoPreserver = isHookEnabled("compaction-todo-preserver") - ? safeCreateHook("compaction-todo-preserver", () => createCompactionTodoPreserverHook(ctx), { enabled: safeHookEnabled }) - : null; - - const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") - ? safeCreateHook("todo-continuation-enforcer", () => createTodoContinuationEnforcer(ctx, { - backgroundManager, - isContinuationStopped: stopContinuationGuard?.isStopped, - }), { enabled: safeHookEnabled }) - : null; - - const unstableAgentBabysitter = isHookEnabled("unstable-agent-babysitter") - ? safeCreateHook("unstable-agent-babysitter", () => createUnstableAgentBabysitterHook( - { - directory: ctx.directory, - client: { - session: { - messages: async (args) => { - const result = await ctx.client.session.messages(args); - if (Array.isArray(result)) return result; - if ( - typeof result === "object" && - result !== null && - "data" in result - ) { - const record = result as Record; - return { data: record.data }; - } - return []; - }, - prompt: async (args) => { - await ctx.client.session.promptAsync(args); - }, - promptAsync: async (args) => { - await ctx.client.session.promptAsync(args); - }, - }, - }, - }, - { - backgroundManager, - config: pluginConfig.babysitting, - }, - ), { enabled: safeHookEnabled }) - : null; - - if (sessionRecovery && todoContinuationEnforcer) { - sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering); - sessionRecovery.setOnRecoveryCompleteCallback( - todoContinuationEnforcer.markRecoveryComplete, - ); - } - - const backgroundNotificationHook = isHookEnabled("background-notification") - ? safeCreateHook("background-notification", () => createBackgroundNotificationHook(backgroundManager), { enabled: safeHookEnabled }) - : null; - const backgroundTools = createBackgroundTools(backgroundManager, ctx.client); - - const callOmoAgent = createCallOmoAgent(ctx, backgroundManager); - const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some( - (agent) => agent.toLowerCase() === "multimodal-looker", - ); - const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null; - const browserProvider = - pluginConfig.browser_automation_engine?.provider ?? "playwright"; - const disabledSkills = new Set(pluginConfig.disabled_skills ?? []); - const systemMcpNames = getSystemMcpServerNames(); - const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills }).filter((skill) => { - if (skill.mcpConfig) { - for (const mcpName of Object.keys(skill.mcpConfig)) { - if (systemMcpNames.has(mcpName)) return false; - } - } - return true; - }, - ); - const includeClaudeSkills = pluginConfig.claude_code?.skills !== false; - const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = - await Promise.all([ - includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]), - discoverOpencodeGlobalSkills(), - includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]), - discoverOpencodeProjectSkills(), - ]); - const mergedSkills = mergeSkills( - builtinSkills, - pluginConfig.skills, - userSkills, - globalSkills, - projectSkills, - opencodeProjectSkills, - ); - - function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] { - if (scope === "user" || scope === "opencode") return "user"; - if (scope === "project" || scope === "opencode-project") return "project"; - return "plugin"; - } - - const availableSkills: AvailableSkill[] = mergedSkills.map((skill) => ({ - name: skill.name, - description: skill.definition.description ?? "", - location: mapScopeToLocation(skill.scope), - })); - - const mergedCategories = pluginConfig.categories - ? { ...DEFAULT_CATEGORIES, ...pluginConfig.categories } - : DEFAULT_CATEGORIES; - - const availableCategories = Object.entries(mergedCategories).map( - ([name, categoryConfig]) => ({ - name, - description: - pluginConfig.categories?.[name]?.description - ?? CATEGORY_DESCRIPTIONS[name] - ?? "General tasks", - model: categoryConfig.model, - }), - ); - - const delegateTask = createDelegateTask({ - manager: backgroundManager, - client: ctx.client, - directory: ctx.directory, - userCategories: pluginConfig.categories, - gitMasterConfig: pluginConfig.git_master, - sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model, - browserProvider, - disabledSkills, - availableCategories, - availableSkills, - onSyncSessionCreated: async (event) => { - log("[index] onSyncSessionCreated callback", { - sessionID: event.sessionID, - parentID: event.parentID, - title: event.title, - }); - await tmuxSessionManager.onSessionCreated({ - type: "session.created", - properties: { - info: { - id: event.sessionID, - parentID: event.parentID, - title: event.title, - }, - }, - }); - }, - }); - - categorySkillReminder = isHookEnabled("category-skill-reminder") - ? safeCreateHook("category-skill-reminder", () => createCategorySkillReminderHook(ctx, availableSkills), { enabled: safeHookEnabled }) - : null; - - const skillMcpManager = new SkillMcpManager(); - const getSessionIDForMcp = () => getMainSessionID() || ""; - const skillTool = createSkillTool({ - skills: mergedSkills, - mcpManager: skillMcpManager, - getSessionID: getSessionIDForMcp, - gitMasterConfig: pluginConfig.git_master, - disabledSkills - }); - const skillMcpTool = createSkillMcpTool({ - manager: skillMcpManager, - getLoadedSkills: () => mergedSkills, - getSessionID: getSessionIDForMcp, - }); - - const commands = discoverCommandsSync(); - const slashcommandTool = createSlashcommandTool({ - commands, - skills: mergedSkills, - }); - - const autoSlashCommand = isHookEnabled("auto-slash-command") - ? safeCreateHook("auto-slash-command", () => createAutoSlashCommandHook({ skills: mergedSkills }), { enabled: safeHookEnabled }) - : null; - - const configHandler = createConfigHandler({ - ctx: { directory: ctx.directory, client: ctx.client }, pluginConfig, + tmuxConfig, modelCacheState, - }); + }) - const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false; - const taskToolsRecord: Record = taskSystemEnabled - ? { - task_create: createTaskCreateTool(pluginConfig, ctx), - task_get: createTaskGetTool(pluginConfig), - task_list: createTaskList(pluginConfig), - task_update: createTaskUpdateTool(pluginConfig, ctx), - } - : {}; + const toolsResult = await createTools({ + ctx, + pluginConfig, + managers, + }) - const allTools: Record = { - ...builtinTools, - ...createGrepTools(ctx), - ...createGlobTools(ctx), - ...createAstGrepTools(ctx), - ...createSessionManagerTools(ctx), - ...backgroundTools, - call_omo_agent: callOmoAgent, - ...(lookAt ? { look_at: lookAt } : {}), - task: delegateTask, - skill: skillTool, - skill_mcp: skillMcpTool, - slashcommand: slashcommandTool, - interactive_bash, - ...taskToolsRecord, - }; + const hooks = createHooks({ + ctx, + pluginConfig, + backgroundManager: managers.backgroundManager, + isHookEnabled, + safeHookEnabled, + mergedSkills: toolsResult.mergedSkills, + availableSkills: toolsResult.availableSkills, + }) - const filteredTools: Record = filterDisabledTools( - allTools, - pluginConfig.disabled_tools, - ); + const pluginInterface = createPluginInterface({ + ctx, + pluginConfig, + firstMessageVariantGate, + managers, + hooks, + tools: toolsResult.filteredTools, + }) return { - tool: filteredTools, - - "chat.params": async ( - input: { - sessionID: string - agent: string - model: Record - provider: Record - message: Record - }, - output: { - temperature: number - topP: number - topK: number - options: Record - }, - ) => { - const model = input.model as { providerID: string; modelID: string } - const message = input.message as { variant?: string } - await anthropicEffort?.["chat.params"]?.( - { ...input, agent: { name: input.agent }, model, provider: input.provider as { id: string }, message }, - output, - ); - }, - - "chat.message": async (input, output) => { - if (input.agent) { - setSessionAgent(input.sessionID, input.agent); - } - - const message = (output as { message: { variant?: string } }).message; - if (firstMessageVariantGate.shouldOverride(input.sessionID)) { - const variant = - input.model && input.agent - ? resolveVariantForModel(pluginConfig, input.agent, input.model) - : resolveAgentVariant(pluginConfig, input.agent); - if (variant !== undefined) { - message.variant = variant; - } - firstMessageVariantGate.markApplied(input.sessionID); - } else { - if (input.model && input.agent && message.variant === undefined) { - const variant = resolveVariantForModel( - pluginConfig, - input.agent, - input.model, - ); - if (variant !== undefined) { - message.variant = variant; - } - } else { - applyAgentVariant(pluginConfig, input.agent, message); - } - } - - await stopContinuationGuard?.["chat.message"]?.(input); - await keywordDetector?.["chat.message"]?.(input, output); - await claudeCodeHooks?.["chat.message"]?.(input, output); - await autoSlashCommand?.["chat.message"]?.(input, output); - await startWork?.["chat.message"]?.(input, output); - - if (!hasConnectedProvidersCache()) { - ctx.client.tui - .showToast({ - body: { - title: "⚠️ Provider Cache Missing", - message: - "Model filtering disabled. RESTART OpenCode to enable full functionality.", - variant: "warning" as const, - duration: 6000, - }, - }) - .catch(() => {}); - } - - 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(""); - const isCancelRalphTemplate = promptText.includes( - "Cancel the currently active Ralph Loop", - ); - - if (isRalphLoopTemplate) { - const taskMatch = promptText.match( - /\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, - output: { messages: Array<{ info: unknown; parts: unknown[] }> }, - ) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await contextInjectorMessagesTransform?.[ - "experimental.chat.messages.transform" - ]?.(input, output as any); - await thinkingBlockValidator?.[ - "experimental.chat.messages.transform" - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ]?.(input, output as any); - }, - - config: configHandler, - - event: async (input) => { - await autoUpdateChecker?.event(input); - await claudeCodeHooks?.event?.(input); - await backgroundNotificationHook?.event(input); - await sessionNotification?.(input); - await todoContinuationEnforcer?.handler(input); - await unstableAgentBabysitter?.event(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 agentUsageReminder?.event(input); - await categorySkillReminder?.event(input); - await interactiveBashSession?.event(input); - await ralphLoop?.event(input); - await stopContinuationGuard?.event(input); - await compactionTodoPreserver?.event(input); - await atlasHook?.handler(input); - - const { event } = input; - const props = event.properties as Record | undefined; - - if (event.type === "session.created") { - const sessionInfo = props?.info as - | { id?: string; title?: string; parentID?: string } - | undefined; - log("[event] session.created", { sessionInfo, props }); - if (!sessionInfo?.parentID) { - setMainSession(sessionInfo?.id); - } - firstMessageVariantGate.markSessionCreated(sessionInfo); - await tmuxSessionManager.onSessionCreated( - event as { - type: string; - properties?: { - info?: { id?: string; parentID?: string; title?: string }; - }; - }, - ); - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id === getMainSessionID()) { - setMainSession(undefined); - } - if (sessionInfo?.id) { - clearSessionAgent(sessionInfo.id); - resetMessageCursor(sessionInfo.id); - firstMessageVariantGate.clear(sessionInfo.id); - await skillMcpManager.disconnectSession(sessionInfo.id); - await lspManager.cleanupTempDirectoryClients(); - await tmuxSessionManager.onSessionDeleted({ - sessionID: sessionInfo.id, - }); - } - } - - if (event.type === "message.updated") { - const info = props?.info as Record | undefined; - const sessionID = info?.sessionID as string | undefined; - const agent = info?.agent as string | undefined; - const role = info?.role as string | undefined; - if (sessionID && agent && role === "user") { - updateSessionAgent(sessionID, agent); - } - } - - 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() && - !stopContinuationGuard?.isStopped(sessionID) - ) { - 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 subagentQuestionBlocker?.["tool.execute.before"]?.(input, output); - await writeExistingFileGuard?.["tool.execute.before"]?.(input, output); - await questionLabelTruncator?.["tool.execute.before"]?.(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); - await tasksTodowriteDisabler?.["tool.execute.before"]?.(input, output); - await prometheusMdOnly?.["tool.execute.before"]?.(input, output); - await sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output); - await atlasHook?.["tool.execute.before"]?.(input, output); - - if (input.tool === "task") { - const args = output.args as Record; - const category = typeof args.category === "string" ? args.category : undefined; - const subagentType = typeof args.subagent_type === "string" ? args.subagent_type : undefined; - if (category && !subagentType) { - args.subagent_type = "sisyphus-junior"; - } - } - - 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); - } else if (command === "ulw-loop" && sessionID) { - const rawArgs = - args?.command?.replace(/^\/?(ulw-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, { - ultrawork: true, - maxIterations: maxIterMatch - ? parseInt(maxIterMatch[1], 10) - : undefined, - completionPromise: promiseMatch?.[1], - }); - } - } - - if (input.tool === "slashcommand") { - const args = output.args as { command?: string } | undefined; - const command = args?.command?.replace(/^\//, "").toLowerCase(); - const sessionID = input.sessionID || getMainSessionID(); - - if (command === "stop-continuation" && sessionID) { - stopContinuationGuard?.stop(sessionID); - todoContinuationEnforcer?.cancelAllCountdowns(); - ralphLoop?.cancelLoop(sessionID); - clearBoulderState(ctx.directory); - log("[stop-continuation] All continuation mechanisms stopped", { - sessionID, - }); - } - } - }, - - "tool.execute.after": async (input, output) => { - // Guard against undefined output (e.g., from /review command - see issue #1035) - if (!output) { - return; - } - - // Restore metadata that fromPlugin() overwrites with { truncated, outputPath }. - // This must run FIRST, before any hook reads output.metadata. - const stored = consumeToolMetadata(input.sessionID, input.callID) - if (stored) { - if (stored.title) { - output.title = stored.title - } - if (stored.metadata) { - output.metadata = { ...output.metadata, ...stored.metadata } - } - } - - await claudeCodeHooks?.["tool.execute.after"]?.(input, output); - await toolOutputTruncator?.["tool.execute.after"](input, output); - await preemptiveCompaction?.["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 categorySkillReminder?.["tool.execute.after"](input, output); - await interactiveBashSession?.["tool.execute.after"](input, output); - await editErrorRecovery?.["tool.execute.after"](input, output); - await delegateTaskRetry?.["tool.execute.after"](input, output); - await atlasHook?.["tool.execute.after"]?.(input, output); - await taskResumeInfo?.["tool.execute.after"]?.(input, output); - }, + ...pluginInterface, "experimental.session.compacting": async ( _input: { sessionID: string }, output: { context: string[] }, ): Promise => { - await compactionTodoPreserver?.capture(_input.sessionID); - if (!compactionContextInjector) { - return; + await hooks.compactionTodoPreserver?.capture(_input.sessionID) + if (!hooks.compactionContextInjector) { + return } - output.context.push(compactionContextInjector()); + output.context.push(hooks.compactionContextInjector()) }, - }; -}; + } +} -export default OhMyOpenCodePlugin; +export default OhMyOpenCodePlugin export type { OhMyOpenCodeConfig, @@ -973,9 +97,9 @@ export type { McpName, HookName, BuiltinCommandName, -} from "./config"; +} 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"; +export type { ConfigLoadError } from "./shared/config-errors" diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts new file mode 100644 index 000000000..249216a6c --- /dev/null +++ b/src/plugin-handlers/agent-config-handler.ts @@ -0,0 +1,188 @@ +import { createBuiltinAgents } from "../agents"; +import { createSisyphusJuniorAgentWithOverrides } from "../agents/sisyphus-junior"; +import type { OhMyOpenCodeConfig } from "../config"; +import { log, migrateAgentConfig } from "../shared"; +import { AGENT_NAME_MAP } from "../shared/migration"; +import { + discoverOpencodeGlobalSkills, + discoverOpencodeProjectSkills, + discoverProjectClaudeSkills, + discoverUserClaudeSkills, +} from "../features/opencode-skill-loader"; +import { loadProjectAgents, loadUserAgents } from "../features/claude-code-agent-loader"; +import type { PluginComponents } from "./plugin-components-loader"; +import { reorderAgentsByPriority } from "./agent-priority-order"; +import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder"; + +type AgentConfigRecord = Record | undefined> & { + build?: Record; + plan?: Record; +}; + +export async function applyAgentConfig(params: { + config: Record; + pluginConfig: OhMyOpenCodeConfig; + ctx: { directory: string; client?: any }; + pluginComponents: PluginComponents; +}): Promise> { + const migratedDisabledAgents = (params.pluginConfig.disabled_agents ?? []).map( + (agent) => { + return AGENT_NAME_MAP[agent.toLowerCase()] ?? AGENT_NAME_MAP[agent] ?? agent; + }, + ) as typeof params.pluginConfig.disabled_agents; + + const includeClaudeSkillsForAwareness = params.pluginConfig.claude_code?.skills ?? true; + const [ + discoveredUserSkills, + discoveredProjectSkills, + discoveredOpencodeGlobalSkills, + discoveredOpencodeProjectSkills, + ] = await Promise.all([ + includeClaudeSkillsForAwareness ? discoverUserClaudeSkills() : Promise.resolve([]), + includeClaudeSkillsForAwareness + ? discoverProjectClaudeSkills() + : Promise.resolve([]), + discoverOpencodeGlobalSkills(), + discoverOpencodeProjectSkills(), + ]); + + const allDiscoveredSkills = [ + ...discoveredOpencodeProjectSkills, + ...discoveredProjectSkills, + ...discoveredOpencodeGlobalSkills, + ...discoveredUserSkills, + ]; + + const browserProvider = + params.pluginConfig.browser_automation_engine?.provider ?? "playwright"; + const currentModel = params.config.model as string | undefined; + const disabledSkills = new Set(params.pluginConfig.disabled_skills ?? []); + + const builtinAgents = await createBuiltinAgents( + migratedDisabledAgents, + params.pluginConfig.agents, + params.ctx.directory, + undefined, + params.pluginConfig.categories, + params.pluginConfig.git_master, + allDiscoveredSkills, + params.ctx.client, + browserProvider, + currentModel, + disabledSkills, + ); + + const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true; + const userAgents = includeClaudeAgents ? loadUserAgents() : {}; + const projectAgents = includeClaudeAgents ? loadProjectAgents() : {}; + + const rawPluginAgents = params.pluginComponents.agents; + const pluginAgents = Object.fromEntries( + Object.entries(rawPluginAgents).map(([key, value]) => [ + key, + value ? migrateAgentConfig(value as Record) : value, + ]), + ); + + const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true; + const builderEnabled = + params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; + const plannerEnabled = params.pluginConfig.sisyphus_agent?.planner_enabled ?? true; + const replacePlan = params.pluginConfig.sisyphus_agent?.replace_plan ?? true; + const shouldDemotePlan = plannerEnabled && replacePlan; + + const configAgent = params.config.agent as AgentConfigRecord | undefined; + + if (isSisyphusEnabled && builtinAgents.sisyphus) { + (params.config as { default_agent?: string }).default_agent = "sisyphus"; + + const agentConfig: Record = { + sisyphus: builtinAgents.sisyphus, + }; + + agentConfig["sisyphus-junior"] = createSisyphusJuniorAgentWithOverrides( + params.pluginConfig.agents?.["sisyphus-junior"], + undefined, + ); + + if (builderEnabled) { + const { name: _buildName, ...buildConfigWithoutName } = + configAgent?.build ?? {}; + const migratedBuildConfig = migrateAgentConfig( + buildConfigWithoutName as Record, + ); + const override = params.pluginConfig.agents?.["OpenCode-Builder"]; + const base = { + ...migratedBuildConfig, + description: `${(configAgent?.build?.description as string) ?? "Build agent"} (OpenCode default)`, + }; + agentConfig["OpenCode-Builder"] = override ? { ...base, ...override } : base; + } + + if (plannerEnabled) { + const prometheusOverride = params.pluginConfig.agents?.["prometheus"] as + | (Record & { prompt_append?: string }) + | undefined; + + agentConfig["prometheus"] = await buildPrometheusAgentConfig({ + configAgentPlan: configAgent?.plan, + pluginPrometheusOverride: prometheusOverride, + userCategories: params.pluginConfig.categories, + currentModel, + }); + } + + const filteredConfigAgents = configAgent + ? Object.fromEntries( + Object.entries(configAgent) + .filter(([key]) => { + if (key === "build") return false; + if (key === "plan" && shouldDemotePlan) return false; + if (key in builtinAgents) return false; + return true; + }) + .map(([key, value]) => [ + key, + value ? migrateAgentConfig(value as Record) : value, + ]), + ) + : {}; + + const migratedBuild = configAgent?.build + ? migrateAgentConfig(configAgent.build as Record) + : {}; + + const planDemoteConfig = shouldDemotePlan ? { mode: "subagent" as const } : undefined; + + params.config.agent = { + ...agentConfig, + ...Object.fromEntries( + Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"), + ), + ...userAgents, + ...projectAgents, + ...pluginAgents, + ...filteredConfigAgents, + build: { ...migratedBuild, mode: "subagent", hidden: true }, + ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), + }; + } else { + params.config.agent = { + ...builtinAgents, + ...userAgents, + ...projectAgents, + ...pluginAgents, + ...configAgent, + }; + } + + if (params.config.agent) { + params.config.agent = reorderAgentsByPriority( + params.config.agent as Record, + ); + } + + const agentResult = params.config.agent as Record; + log("[config-handler] agents loaded", { agentKeys: Object.keys(agentResult) }); + return agentResult; +} diff --git a/src/plugin-handlers/agent-priority-order.ts b/src/plugin-handlers/agent-priority-order.ts new file mode 100644 index 000000000..a87c0199a --- /dev/null +++ b/src/plugin-handlers/agent-priority-order.ts @@ -0,0 +1,23 @@ +const CORE_AGENT_ORDER = ["sisyphus", "hephaestus", "prometheus", "atlas"] as const; + +export function reorderAgentsByPriority( + agents: Record, +): Record { + const ordered: Record = {}; + const seen = new Set(); + + for (const key of CORE_AGENT_ORDER) { + if (Object.prototype.hasOwnProperty.call(agents, key)) { + ordered[key] = agents[key]; + seen.add(key); + } + } + + for (const [key, value] of Object.entries(agents)) { + if (!seen.has(key)) { + ordered[key] = value; + } + } + + return ordered; +} diff --git a/src/plugin-handlers/category-config-resolver.ts b/src/plugin-handlers/category-config-resolver.ts new file mode 100644 index 000000000..7a44ce9e6 --- /dev/null +++ b/src/plugin-handlers/category-config-resolver.ts @@ -0,0 +1,9 @@ +import type { CategoryConfig } from "../config/schema"; +import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"; + +export function resolveCategoryConfig( + categoryName: string, + userCategories?: Record, +): CategoryConfig | undefined { + return userCategories?.[categoryName] ?? DEFAULT_CATEGORIES[categoryName]; +} diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts new file mode 100644 index 000000000..1e2ca9402 --- /dev/null +++ b/src/plugin-handlers/command-config-handler.ts @@ -0,0 +1,62 @@ +import type { OhMyOpenCodeConfig } from "../config"; +import { + loadUserCommands, + loadProjectCommands, + loadOpencodeGlobalCommands, + loadOpencodeProjectCommands, +} from "../features/claude-code-command-loader"; +import { loadBuiltinCommands } from "../features/builtin-commands"; +import { + loadUserSkills, + loadProjectSkills, + loadOpencodeGlobalSkills, + loadOpencodeProjectSkills, +} from "../features/opencode-skill-loader"; +import type { PluginComponents } from "./plugin-components-loader"; + +export async function applyCommandConfig(params: { + config: Record; + pluginConfig: OhMyOpenCodeConfig; + pluginComponents: PluginComponents; +}): Promise { + const builtinCommands = loadBuiltinCommands(params.pluginConfig.disabled_commands); + const systemCommands = (params.config.command as Record) ?? {}; + + const includeClaudeCommands = params.pluginConfig.claude_code?.commands ?? true; + const includeClaudeSkills = params.pluginConfig.claude_code?.skills ?? true; + + const [ + userCommands, + projectCommands, + opencodeGlobalCommands, + opencodeProjectCommands, + userSkills, + projectSkills, + opencodeGlobalSkills, + opencodeProjectSkills, + ] = await Promise.all([ + includeClaudeCommands ? loadUserCommands() : Promise.resolve({}), + includeClaudeCommands ? loadProjectCommands() : Promise.resolve({}), + loadOpencodeGlobalCommands(), + loadOpencodeProjectCommands(), + includeClaudeSkills ? loadUserSkills() : Promise.resolve({}), + includeClaudeSkills ? loadProjectSkills() : Promise.resolve({}), + loadOpencodeGlobalSkills(), + loadOpencodeProjectSkills(), + ]); + + params.config.command = { + ...builtinCommands, + ...userCommands, + ...userSkills, + ...opencodeGlobalCommands, + ...opencodeGlobalSkills, + ...systemCommands, + ...projectCommands, + ...projectSkills, + ...opencodeProjectCommands, + ...opencodeProjectSkills, + ...params.pluginComponents.commands, + ...params.pluginComponents.skills, + }; +} diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 41adbaf20..5eb7f242b 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -1,39 +1,14 @@ -import { createBuiltinAgents } from "../agents"; -import { createSisyphusJuniorAgentWithOverrides } from "../agents/sisyphus-junior"; -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, -} from "../features/opencode-skill-loader"; -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 { createBuiltinMcps } from "../mcp"; import type { OhMyOpenCodeConfig } from "../config"; -import { log, fetchAvailableModels, readConnectedProvidersCache, resolveModelPipeline, addConfigLoadError } from "../shared"; -import { getOpenCodeConfigPaths } from "../shared/opencode-config-dir"; -import { migrateAgentConfig } from "../shared/permission-compat"; -import { AGENT_NAME_MAP } from "../shared/migration"; -import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements"; -import { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "../agents/prometheus"; -import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"; import type { ModelCacheState } from "../plugin-state"; -import type { CategoryConfig } from "../config/schema"; +import { log } from "../shared"; +import { applyAgentConfig } from "./agent-config-handler"; +import { applyCommandConfig } from "./command-config-handler"; +import { applyMcpConfig } from "./mcp-config-handler"; +import { applyProviderConfig } from "./provider-config-handler"; +import { loadPluginComponents } from "./plugin-components-loader"; +import { applyToolConfig } from "./tool-config-handler"; + +export { resolveCategoryConfig } from "./category-config-resolver"; export interface ConfigHandlerDeps { ctx: { directory: string; client?: any }; @@ -41,486 +16,29 @@ export interface ConfigHandlerDeps { modelCacheState: ModelCacheState; } -export function resolveCategoryConfig( - categoryName: string, - userCategories?: Record -): CategoryConfig | undefined { - return userCategories?.[categoryName] ?? DEFAULT_CATEGORIES[categoryName]; -} - -const CORE_AGENT_ORDER = ["sisyphus", "hephaestus", "prometheus", "atlas"] as const; - -function reorderAgentsByPriority(agents: Record): Record { - const ordered: Record = {}; - const seen = new Set(); - - for (const key of CORE_AGENT_ORDER) { - if (Object.prototype.hasOwnProperty.call(agents, key)) { - ordered[key] = agents[key]; - seen.add(key); - } - } - - for (const [key, value] of Object.entries(agents)) { - if (!seen.has(key)) { - ordered[key] = value; - } - } - - return ordered; -} - export function createConfigHandler(deps: ConfigHandlerDeps) { const { ctx, pluginConfig, modelCacheState } = deps; return async (config: Record) => { - type ProviderConfig = { - options?: { headers?: Record }; - models?: Record; - }; - const providers = config.provider as - | Record - | undefined; + applyProviderConfig({ config, modelCacheState }); - const anthropicBeta = - providers?.anthropic?.options?.headers?.["anthropic-beta"]; - modelCacheState.anthropicContext1MEnabled = - anthropicBeta?.includes("context-1m") ?? false; + const pluginComponents = await loadPluginComponents({ pluginConfig }); - 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) { - modelCacheState.modelContextLimitsCache.set( - `${providerID}/${modelID}`, - contextLimit - ); - } - } - } - } - } + const agentResult = await applyAgentConfig({ + config, + pluginConfig, + ctx, + pluginComponents, + }); - const emptyPluginDefaults = { - commands: {}, - skills: {}, - agents: {}, - mcpServers: {}, - hooksConfigs: [] as { hooks?: Record }[], - plugins: [] as { name: string; version: string }[], - errors: [] as { pluginKey: string; installPath: string; error: string }[], - }; + applyToolConfig({ config, pluginConfig, agentResult }); + await applyMcpConfig({ config, pluginConfig, pluginComponents }); + await applyCommandConfig({ config, pluginConfig, pluginComponents }); - let pluginComponents: typeof emptyPluginDefaults; - const pluginsEnabled = pluginConfig.claude_code?.plugins ?? true; - - if (pluginsEnabled) { - const timeoutMs = pluginConfig.experimental?.plugin_load_timeout_ms ?? 10000; - try { - let timeoutId: ReturnType; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout( - () => reject(new Error(`Plugin loading timed out after ${timeoutMs}ms`)), - timeoutMs, - ); - }); - pluginComponents = await Promise.race([ - loadAllPluginComponents({ - enabledPluginsOverride: pluginConfig.claude_code?.plugins_override, - }), - timeoutPromise, - ]).finally(() => clearTimeout(timeoutId)); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log("[config-handler] Plugin loading failed", { error: errorMessage }); - addConfigLoadError({ path: "plugin-loading", error: errorMessage }); - pluginComponents = emptyPluginDefaults; - } - } else { - pluginComponents = emptyPluginDefaults; - } - - 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 }); - } - - // Migrate disabled_agents from old names to new names - const migratedDisabledAgents = (pluginConfig.disabled_agents ?? []).map(agent => { - return AGENT_NAME_MAP[agent.toLowerCase()] ?? AGENT_NAME_MAP[agent] ?? agent - }) as typeof pluginConfig.disabled_agents - - const includeClaudeSkillsForAwareness = pluginConfig.claude_code?.skills ?? true; - const [ - discoveredUserSkills, - discoveredProjectSkills, - discoveredOpencodeGlobalSkills, - discoveredOpencodeProjectSkills, - ] = await Promise.all([ - includeClaudeSkillsForAwareness ? discoverUserClaudeSkills() : Promise.resolve([]), - includeClaudeSkillsForAwareness ? discoverProjectClaudeSkills() : Promise.resolve([]), - discoverOpencodeGlobalSkills(), - discoverOpencodeProjectSkills(), - ]); - - const allDiscoveredSkills = [ - ...discoveredOpencodeProjectSkills, - ...discoveredProjectSkills, - ...discoveredOpencodeGlobalSkills, - ...discoveredUserSkills, - ]; - - const browserProvider = pluginConfig.browser_automation_engine?.provider ?? "playwright"; - // config.model represents the currently active model in OpenCode (including UI selection) - // Pass it as uiSelectedModel so it takes highest priority in model resolution - const currentModel = config.model as string | undefined; - const disabledSkills = new Set(pluginConfig.disabled_skills ?? []); - const builtinAgents = await createBuiltinAgents( - migratedDisabledAgents, - pluginConfig.agents, - ctx.directory, - undefined, // systemDefaultModel - let fallback chain handle this - pluginConfig.categories, - pluginConfig.git_master, - allDiscoveredSkills, - ctx.client, - browserProvider, - currentModel, // uiSelectedModel - takes highest priority - disabledSkills - ); - - // Claude Code agents: Do NOT apply permission migration - // Claude Code uses whitelist-based tools format which is semantically different - // from OpenCode's denylist-based permission system - const userAgents = (pluginConfig.claude_code?.agents ?? true) - ? loadUserAgents() - : {}; - const projectAgents = (pluginConfig.claude_code?.agents ?? true) - ? loadProjectAgents() - : {}; - - // Plugin agents: Apply permission migration for compatibility - const rawPluginAgents = pluginComponents.agents; - const pluginAgents = Object.fromEntries( - Object.entries(rawPluginAgents).map(([k, v]) => [ - k, - v ? migrateAgentConfig(v as Record) : v, - ]) - ); - - 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; - const shouldDemotePlan = plannerEnabled && replacePlan; - - type AgentConfig = Record< - string, - Record | undefined - > & { - build?: Record; - plan?: Record; - explore?: { tools?: Record }; - librarian?: { tools?: Record }; - "multimodal-looker"?: { tools?: Record }; - atlas?: { tools?: Record }; - sisyphus?: { tools?: Record }; - }; - const configAgent = config.agent as AgentConfig | undefined; - - if (isSisyphusEnabled && builtinAgents.sisyphus) { - (config as { default_agent?: string }).default_agent = "sisyphus"; - - const agentConfig: Record = { - sisyphus: builtinAgents.sisyphus, - }; - - agentConfig["sisyphus-junior"] = createSisyphusJuniorAgentWithOverrides( - pluginConfig.agents?.["sisyphus-junior"], - undefined - ); - - if (builderEnabled) { - const { name: _buildName, ...buildConfigWithoutName } = - configAgent?.build ?? {}; - const migratedBuildConfig = migrateAgentConfig( - buildConfigWithoutName as Record - ); - const openCodeBuilderOverride = - pluginConfig.agents?.["OpenCode-Builder"]; - const openCodeBuilderBase = { - ...migratedBuildConfig, - description: `${configAgent?.build?.description ?? "Build agent"} (OpenCode default)`, - }; - - agentConfig["OpenCode-Builder"] = openCodeBuilderOverride - ? { ...openCodeBuilderBase, ...openCodeBuilderOverride } - : openCodeBuilderBase; - } - - if (plannerEnabled) { - const prometheusOverride = - pluginConfig.agents?.["prometheus"] as - | (Record & { - category?: string - model?: string - variant?: string - reasoningEffort?: string - textVerbosity?: string - thinking?: { type: string; budgetTokens?: number } - temperature?: number - top_p?: number - maxTokens?: number - }) - | undefined; - - const categoryConfig = prometheusOverride?.category - ? resolveCategoryConfig( - prometheusOverride.category, - pluginConfig.categories - ) - : undefined; - - const prometheusRequirement = AGENT_MODEL_REQUIREMENTS["prometheus"]; - const connectedProviders = readConnectedProvidersCache(); - // IMPORTANT: Do NOT pass ctx.client to fetchAvailableModels during plugin initialization. - // Calling client API (e.g., client.provider.list()) from config handler causes deadlock: - // - Plugin init waits for server response - // - Server waits for plugin init to complete before handling requests - // Use cache-only mode instead. If cache is unavailable, fallback chain uses first model. - // See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301 - const availableModels = await fetchAvailableModels(undefined, { - connectedProviders: connectedProviders ?? undefined, - }); - - const modelResolution = resolveModelPipeline({ - intent: { - uiSelectedModel: currentModel, - userModel: prometheusOverride?.model ?? categoryConfig?.model, - }, - constraints: { availableModels }, - policy: { - fallbackChain: prometheusRequirement?.fallbackChain, - systemDefaultModel: undefined, - }, - }); - const resolvedModel = modelResolution?.model; - const resolvedVariant = modelResolution?.variant; - - const variantToUse = prometheusOverride?.variant ?? resolvedVariant; - const reasoningEffortToUse = prometheusOverride?.reasoningEffort ?? categoryConfig?.reasoningEffort; - const textVerbosityToUse = prometheusOverride?.textVerbosity ?? categoryConfig?.textVerbosity; - const thinkingToUse = prometheusOverride?.thinking ?? categoryConfig?.thinking; - const temperatureToUse = prometheusOverride?.temperature ?? categoryConfig?.temperature; - const topPToUse = prometheusOverride?.top_p ?? categoryConfig?.top_p; - const maxTokensToUse = prometheusOverride?.maxTokens ?? categoryConfig?.maxTokens; - const prometheusBase = { - name: "prometheus", - ...(resolvedModel ? { model: resolvedModel } : {}), - ...(variantToUse ? { variant: variantToUse } : {}), - mode: "all" as const, - prompt: PROMETHEUS_SYSTEM_PROMPT, - permission: PROMETHEUS_PERMISSION, - description: `${configAgent?.plan?.description ?? "Plan agent"} (Prometheus - OhMyOpenCode)`, - color: (configAgent?.plan?.color as string) ?? "#FF5722", // Deep Orange - Fire/Flame theme - ...(temperatureToUse !== undefined ? { temperature: temperatureToUse } : {}), - ...(topPToUse !== undefined ? { top_p: topPToUse } : {}), - ...(maxTokensToUse !== undefined ? { maxTokens: maxTokensToUse } : {}), - ...(categoryConfig?.tools ? { tools: categoryConfig.tools } : {}), - ...(thinkingToUse ? { thinking: thinkingToUse } : {}), - ...(reasoningEffortToUse !== undefined - ? { reasoningEffort: reasoningEffortToUse } - : {}), - ...(textVerbosityToUse !== undefined - ? { textVerbosity: textVerbosityToUse } - : {}), - }; - - // Properly handle prompt_append for Prometheus - // Extract prompt_append and append it to prompt instead of shallow spread - // Fixes: https://github.com/code-yeongyu/oh-my-opencode/issues/723 - if (prometheusOverride) { - const { prompt_append, ...restOverride } = prometheusOverride as Record & { prompt_append?: string }; - const merged = { ...prometheusBase, ...restOverride }; - if (prompt_append && merged.prompt) { - merged.prompt = merged.prompt + "\n" + prompt_append; - } - agentConfig["prometheus"] = merged; - } else { - agentConfig["prometheus"] = prometheusBase; - } - } - - const filteredConfigAgents = configAgent - ? Object.fromEntries( - Object.entries(configAgent) - .filter(([key]) => { - if (key === "build") return false; - if (key === "plan" && shouldDemotePlan) return false; - // Filter out agents that oh-my-opencode provides to prevent - // OpenCode defaults from overwriting user config in oh-my-opencode.json - // See: https://github.com/code-yeongyu/oh-my-opencode/issues/472 - if (key in builtinAgents) return false; - return true; - }) - .map(([key, value]) => [ - key, - value ? migrateAgentConfig(value as Record) : value, - ]) - ) - : {}; - - const migratedBuild = configAgent?.build - ? migrateAgentConfig(configAgent.build as Record) - : {}; - - const planDemoteConfig = shouldDemotePlan - ? { mode: "subagent" as const - } - : undefined; - - config.agent = { - ...agentConfig, - ...Object.fromEntries( - Object.entries(builtinAgents).filter(([k]) => k !== "sisyphus") - ), - ...userAgents, - ...projectAgents, - ...pluginAgents, - ...filteredConfigAgents, - build: { ...migratedBuild, mode: "subagent", hidden: true }, - ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), - }; - } else { - config.agent = { - ...builtinAgents, - ...userAgents, - ...projectAgents, - ...pluginAgents, - ...configAgent, - }; - } - - if (config.agent) { - config.agent = reorderAgentsByPriority(config.agent as Record); - } - - const agentResult = config.agent as AgentConfig; - - config.tools = { - ...(config.tools as Record), - "grep_app_*": false, - LspHover: false, - LspCodeActions: false, - LspCodeActionResolve: false, - "task_*": false, - teammate: false, - ...(pluginConfig.experimental?.task_system ? { todowrite: false, todoread: false } : {}), - }; - - type AgentWithPermission = { permission?: Record }; - - // In CLI run mode, deny Question tool for all agents (no TUI to answer questions) - const isCliRunMode = process.env.OPENCODE_CLI_RUN_MODE === "true"; - const questionPermission = isCliRunMode ? "deny" : "allow"; - - if (agentResult.librarian) { - const agent = agentResult.librarian as AgentWithPermission; - agent.permission = { ...agent.permission, "grep_app_*": "allow" }; - } - if (agentResult["multimodal-looker"]) { - const agent = agentResult["multimodal-looker"] as AgentWithPermission; - agent.permission = { ...agent.permission, task: "deny", look_at: "deny" }; - } - if (agentResult["atlas"]) { - const agent = agentResult["atlas"] as AgentWithPermission; - agent.permission = { ...agent.permission, task: "allow", call_omo_agent: "deny", "task_*": "allow", teammate: "allow" }; - } - if (agentResult.sisyphus) { - const agent = agentResult.sisyphus as AgentWithPermission; - agent.permission = { ...agent.permission, call_omo_agent: "deny", task: "allow", question: questionPermission, "task_*": "allow", teammate: "allow" }; - } - if (agentResult.hephaestus) { - const agent = agentResult.hephaestus as AgentWithPermission; - agent.permission = { ...agent.permission, call_omo_agent: "deny", task: "allow", question: questionPermission }; - } - if (agentResult["prometheus"]) { - const agent = agentResult["prometheus"] as AgentWithPermission; - agent.permission = { ...agent.permission, call_omo_agent: "deny", task: "allow", question: questionPermission, "task_*": "allow", teammate: "allow" }; - } - if (agentResult["sisyphus-junior"]) { - const agent = agentResult["sisyphus-junior"] as AgentWithPermission; - agent.permission = { ...agent.permission, task: "allow", "task_*": "allow", teammate: "allow" }; - } - - config.permission = { - ...(config.permission as Record), - webfetch: "allow", - external_directory: "allow", - task: "deny", - }; - - const mcpResult = (pluginConfig.claude_code?.mcp ?? true) - ? await loadMcpConfigs() - : { servers: {} }; - - config.mcp = { - ...createBuiltinMcps(pluginConfig.disabled_mcps, pluginConfig), - ...(config.mcp as Record), - ...mcpResult.servers, - ...pluginComponents.mcpServers, - }; - - const builtinCommands = loadBuiltinCommands(pluginConfig.disabled_commands); - const systemCommands = (config.command as Record) ?? {}; - - // Parallel loading of all commands and skills for faster startup - const includeClaudeCommands = pluginConfig.claude_code?.commands ?? true; - const includeClaudeSkills = pluginConfig.claude_code?.skills ?? true; - - const [ - userCommands, - projectCommands, - opencodeGlobalCommands, - opencodeProjectCommands, - userSkills, - projectSkills, - opencodeGlobalSkills, - opencodeProjectSkills, - ] = await Promise.all([ - includeClaudeCommands ? loadUserCommands() : Promise.resolve({}), - includeClaudeCommands ? loadProjectCommands() : Promise.resolve({}), - loadOpencodeGlobalCommands(), - loadOpencodeProjectCommands(), - includeClaudeSkills ? loadUserSkills() : Promise.resolve({}), - includeClaudeSkills ? loadProjectSkills() : Promise.resolve({}), - loadOpencodeGlobalSkills(), - loadOpencodeProjectSkills(), - ]); - - config.command = { - ...builtinCommands, - ...userCommands, - ...userSkills, - ...opencodeGlobalCommands, - ...opencodeGlobalSkills, - ...systemCommands, - ...projectCommands, - ...projectSkills, - ...opencodeProjectCommands, - ...opencodeProjectSkills, - ...pluginComponents.commands, - ...pluginComponents.skills, - }; + log("[config-handler] config handler applied", { + agentCount: Object.keys(agentResult).length, + commandCount: Object.keys((config.command as Record) ?? {}) + .length, + }); }; } diff --git a/src/plugin-handlers/index.ts b/src/plugin-handlers/index.ts index 8dd2e6b3a..fa9bde977 100644 --- a/src/plugin-handlers/index.ts +++ b/src/plugin-handlers/index.ts @@ -1 +1,10 @@ export { createConfigHandler, type ConfigHandlerDeps } from "./config-handler"; +export * from "./provider-config-handler"; +export * from "./agent-config-handler"; +export * from "./tool-config-handler"; +export * from "./mcp-config-handler"; +export * from "./command-config-handler"; +export * from "./plugin-components-loader"; +export * from "./category-config-resolver"; +export * from "./prometheus-agent-config-builder"; +export * from "./agent-priority-order"; diff --git a/src/plugin-handlers/mcp-config-handler.ts b/src/plugin-handlers/mcp-config-handler.ts new file mode 100644 index 000000000..677469be5 --- /dev/null +++ b/src/plugin-handlers/mcp-config-handler.ts @@ -0,0 +1,21 @@ +import type { OhMyOpenCodeConfig } from "../config"; +import { loadMcpConfigs } from "../features/claude-code-mcp-loader"; +import { createBuiltinMcps } from "../mcp"; +import type { PluginComponents } from "./plugin-components-loader"; + +export async function applyMcpConfig(params: { + config: Record; + pluginConfig: OhMyOpenCodeConfig; + pluginComponents: PluginComponents; +}): Promise { + const mcpResult = params.pluginConfig.claude_code?.mcp ?? true + ? await loadMcpConfigs() + : { servers: {} }; + + params.config.mcp = { + ...createBuiltinMcps(params.pluginConfig.disabled_mcps, params.pluginConfig), + ...(params.config.mcp as Record), + ...mcpResult.servers, + ...params.pluginComponents.mcpServers, + }; +} diff --git a/src/plugin-handlers/plugin-components-loader.ts b/src/plugin-handlers/plugin-components-loader.ts new file mode 100644 index 000000000..7d122a39e --- /dev/null +++ b/src/plugin-handlers/plugin-components-loader.ts @@ -0,0 +1,70 @@ +import type { OhMyOpenCodeConfig } from "../config"; +import { loadAllPluginComponents } from "../features/claude-code-plugin-loader"; +import { addConfigLoadError, log } from "../shared"; + +export type PluginComponents = { + commands: Record; + skills: Record; + agents: Record; + mcpServers: Record; + hooksConfigs: Array<{ hooks?: Record }>; + plugins: Array<{ name: string; version: string }>; + errors: Array<{ pluginKey: string; installPath: string; error: string }>; +}; + +const EMPTY_PLUGIN_COMPONENTS: PluginComponents = { + commands: {}, + skills: {}, + agents: {}, + mcpServers: {}, + hooksConfigs: [], + plugins: [], + errors: [], +}; + +export async function loadPluginComponents(params: { + pluginConfig: OhMyOpenCodeConfig; +}): Promise { + const pluginsEnabled = params.pluginConfig.claude_code?.plugins ?? true; + if (!pluginsEnabled) { + return EMPTY_PLUGIN_COMPONENTS; + } + + const timeoutMs = params.pluginConfig.experimental?.plugin_load_timeout_ms ?? 10000; + + try { + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`Plugin loading timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + + const pluginComponents = (await Promise.race([ + loadAllPluginComponents({ + enabledPluginsOverride: params.pluginConfig.claude_code?.plugins_override, + }), + timeoutPromise, + ]).finally(() => { + if (timeoutId) clearTimeout(timeoutId); + })) as PluginComponents; + + 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 }); + } + + return pluginComponents; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log("[config-handler] Plugin loading failed", { error: errorMessage }); + addConfigLoadError({ path: "plugin-loading", error: errorMessage }); + return EMPTY_PLUGIN_COMPONENTS; + } +} diff --git a/src/plugin-handlers/prometheus-agent-config-builder.ts b/src/plugin-handlers/prometheus-agent-config-builder.ts new file mode 100644 index 000000000..6e3129add --- /dev/null +++ b/src/plugin-handlers/prometheus-agent-config-builder.ts @@ -0,0 +1,98 @@ +import type { CategoryConfig } from "../config/schema"; +import { PROMETHEUS_PERMISSION, PROMETHEUS_SYSTEM_PROMPT } from "../agents/prometheus"; +import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements"; +import { + fetchAvailableModels, + readConnectedProvidersCache, + resolveModelPipeline, +} from "../shared"; +import { resolveCategoryConfig } from "./category-config-resolver"; + +type PrometheusOverride = Record & { + category?: string; + model?: string; + variant?: string; + reasoningEffort?: string; + textVerbosity?: string; + thinking?: { type: string; budgetTokens?: number }; + temperature?: number; + top_p?: number; + maxTokens?: number; + prompt_append?: string; +}; + +export async function buildPrometheusAgentConfig(params: { + configAgentPlan: Record | undefined; + pluginPrometheusOverride: PrometheusOverride | undefined; + userCategories: Record | undefined; + currentModel: string | undefined; +}): Promise> { + const categoryConfig = params.pluginPrometheusOverride?.category + ? resolveCategoryConfig(params.pluginPrometheusOverride.category, params.userCategories) + : undefined; + + const requirement = AGENT_MODEL_REQUIREMENTS["prometheus"]; + const connectedProviders = readConnectedProvidersCache(); + const availableModels = await fetchAvailableModels(undefined, { + connectedProviders: connectedProviders ?? undefined, + }); + + const modelResolution = resolveModelPipeline({ + intent: { + uiSelectedModel: params.currentModel, + userModel: params.pluginPrometheusOverride?.model ?? categoryConfig?.model, + }, + constraints: { availableModels }, + policy: { + fallbackChain: requirement?.fallbackChain, + systemDefaultModel: undefined, + }, + }); + + const resolvedModel = modelResolution?.model; + const resolvedVariant = modelResolution?.variant; + + const variantToUse = params.pluginPrometheusOverride?.variant ?? resolvedVariant; + const reasoningEffortToUse = + params.pluginPrometheusOverride?.reasoningEffort ?? categoryConfig?.reasoningEffort; + const textVerbosityToUse = + params.pluginPrometheusOverride?.textVerbosity ?? categoryConfig?.textVerbosity; + const thinkingToUse = params.pluginPrometheusOverride?.thinking ?? categoryConfig?.thinking; + const temperatureToUse = + params.pluginPrometheusOverride?.temperature ?? categoryConfig?.temperature; + const topPToUse = params.pluginPrometheusOverride?.top_p ?? categoryConfig?.top_p; + const maxTokensToUse = + params.pluginPrometheusOverride?.maxTokens ?? categoryConfig?.maxTokens; + + const base: Record = { + name: "prometheus", + ...(resolvedModel ? { model: resolvedModel } : {}), + ...(variantToUse ? { variant: variantToUse } : {}), + mode: "all", + prompt: PROMETHEUS_SYSTEM_PROMPT, + permission: PROMETHEUS_PERMISSION, + description: `${(params.configAgentPlan?.description as string) ?? "Plan agent"} (Prometheus - OhMyOpenCode)`, + color: (params.configAgentPlan?.color as string) ?? "#FF5722", + ...(temperatureToUse !== undefined ? { temperature: temperatureToUse } : {}), + ...(topPToUse !== undefined ? { top_p: topPToUse } : {}), + ...(maxTokensToUse !== undefined ? { maxTokens: maxTokensToUse } : {}), + ...(categoryConfig?.tools ? { tools: categoryConfig.tools } : {}), + ...(thinkingToUse ? { thinking: thinkingToUse } : {}), + ...(reasoningEffortToUse !== undefined + ? { reasoningEffort: reasoningEffortToUse } + : {}), + ...(textVerbosityToUse !== undefined + ? { textVerbosity: textVerbosityToUse } + : {}), + }; + + const override = params.pluginPrometheusOverride; + if (!override) return base; + + const { prompt_append, ...restOverride } = override; + const merged = { ...base, ...restOverride }; + if (prompt_append && typeof merged.prompt === "string") { + merged.prompt = merged.prompt + "\n" + prompt_append; + } + return merged; +} diff --git a/src/plugin-handlers/provider-config-handler.ts b/src/plugin-handlers/provider-config-handler.ts new file mode 100644 index 000000000..75964d20b --- /dev/null +++ b/src/plugin-handlers/provider-config-handler.ts @@ -0,0 +1,36 @@ +import type { ModelCacheState } from "../plugin-state"; + +type ProviderConfig = { + options?: { headers?: Record }; + models?: Record; +}; + +export function applyProviderConfig(params: { + config: Record; + modelCacheState: ModelCacheState; +}): void { + const providers = params.config.provider as + | Record + | undefined; + + const anthropicBeta = providers?.anthropic?.options?.headers?.["anthropic-beta"]; + params.modelCacheState.anthropicContext1MEnabled = + anthropicBeta?.includes("context-1m") ?? false; + + if (!providers) return; + + for (const [providerID, providerConfig] of Object.entries(providers)) { + const models = providerConfig?.models; + if (!models) continue; + + for (const [modelID, modelConfig] of Object.entries(models)) { + const contextLimit = modelConfig?.limit?.context; + if (!contextLimit) continue; + + params.modelCacheState.modelContextLimitsCache.set( + `${providerID}/${modelID}`, + contextLimit, + ); + } + } +} diff --git a/src/plugin-handlers/tool-config-handler.ts b/src/plugin-handlers/tool-config-handler.ts new file mode 100644 index 000000000..f55044bd4 --- /dev/null +++ b/src/plugin-handlers/tool-config-handler.ts @@ -0,0 +1,91 @@ +import type { OhMyOpenCodeConfig } from "../config"; + +type AgentWithPermission = { permission?: Record }; + +export function applyToolConfig(params: { + config: Record; + pluginConfig: OhMyOpenCodeConfig; + agentResult: Record; +}): void { + params.config.tools = { + ...(params.config.tools as Record), + "grep_app_*": false, + LspHover: false, + LspCodeActions: false, + LspCodeActionResolve: false, + "task_*": false, + teammate: false, + ...(params.pluginConfig.experimental?.task_system + ? { todowrite: false, todoread: false } + : {}), + }; + + const isCliRunMode = process.env.OPENCODE_CLI_RUN_MODE === "true"; + const questionPermission = isCliRunMode ? "deny" : "allow"; + + if (params.agentResult.librarian) { + const agent = params.agentResult.librarian as AgentWithPermission; + agent.permission = { ...agent.permission, "grep_app_*": "allow" }; + } + if (params.agentResult["multimodal-looker"]) { + const agent = params.agentResult["multimodal-looker"] as AgentWithPermission; + agent.permission = { ...agent.permission, task: "deny", look_at: "deny" }; + } + if (params.agentResult["atlas"]) { + const agent = params.agentResult["atlas"] as AgentWithPermission; + agent.permission = { + ...agent.permission, + task: "allow", + call_omo_agent: "deny", + "task_*": "allow", + teammate: "allow", + }; + } + if (params.agentResult.sisyphus) { + const agent = params.agentResult.sisyphus as AgentWithPermission; + agent.permission = { + ...agent.permission, + call_omo_agent: "deny", + task: "allow", + question: questionPermission, + "task_*": "allow", + teammate: "allow", + }; + } + if (params.agentResult.hephaestus) { + const agent = params.agentResult.hephaestus as AgentWithPermission; + agent.permission = { + ...agent.permission, + call_omo_agent: "deny", + task: "allow", + question: questionPermission, + }; + } + if (params.agentResult["prometheus"]) { + const agent = params.agentResult["prometheus"] as AgentWithPermission; + agent.permission = { + ...agent.permission, + call_omo_agent: "deny", + task: "allow", + question: questionPermission, + "task_*": "allow", + teammate: "allow", + }; + } + if (params.agentResult["sisyphus-junior"]) { + const agent = params.agentResult["sisyphus-junior"] as AgentWithPermission; + agent.permission = { + ...agent.permission, + task: "allow", + "task_*": "allow", + teammate: "allow", + }; + } + + params.config.permission = { + ...(params.config.permission as Record), + webfetch: "allow", + external_directory: "allow", + task: "deny", + }; +} diff --git a/src/plugin-interface.ts b/src/plugin-interface.ts new file mode 100644 index 000000000..f9b883a3f --- /dev/null +++ b/src/plugin-interface.ts @@ -0,0 +1,65 @@ +import type { PluginContext, PluginInterface, ToolsRecord } from "./plugin/types" +import type { OhMyOpenCodeConfig } from "./config" + +import { createChatParamsHandler } from "./plugin/chat-params" +import { createChatMessageHandler } from "./plugin/chat-message" +import { createMessagesTransformHandler } from "./plugin/messages-transform" +import { createEventHandler } from "./plugin/event" +import { createToolExecuteAfterHandler } from "./plugin/tool-execute-after" +import { createToolExecuteBeforeHandler } from "./plugin/tool-execute-before" + +import type { CreatedHooks } from "./create-hooks" +import type { Managers } from "./create-managers" + +export function createPluginInterface(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + firstMessageVariantGate: { + shouldOverride: (sessionID: string) => boolean + markApplied: (sessionID: string) => void + markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void + clear: (sessionID: string) => void + } + managers: Managers + hooks: CreatedHooks + tools: ToolsRecord +}): PluginInterface { + const { ctx, pluginConfig, firstMessageVariantGate, managers, hooks, tools } = + args + + return { + tool: tools, + + "chat.params": createChatParamsHandler({ anthropicEffort: hooks.anthropicEffort }), + + "chat.message": createChatMessageHandler({ + ctx, + pluginConfig, + firstMessageVariantGate, + hooks, + }), + + "experimental.chat.messages.transform": createMessagesTransformHandler({ + hooks, + }), + + config: managers.configHandler, + + event: createEventHandler({ + ctx, + pluginConfig, + firstMessageVariantGate, + managers, + hooks, + }), + + "tool.execute.before": createToolExecuteBeforeHandler({ + ctx, + hooks, + }), + + "tool.execute.after": createToolExecuteAfterHandler({ + hooks, + }), + } +} diff --git a/src/plugin/available-categories.ts b/src/plugin/available-categories.ts new file mode 100644 index 000000000..0cda43171 --- /dev/null +++ b/src/plugin/available-categories.ts @@ -0,0 +1,29 @@ +import type { AvailableCategory } from "../agents/dynamic-agent-prompt-builder" +import type { OhMyOpenCodeConfig } from "../config" + +import { + CATEGORY_DESCRIPTIONS, + DEFAULT_CATEGORIES, +} from "../tools/delegate-task/constants" + +export function createAvailableCategories( + pluginConfig: OhMyOpenCodeConfig, +): AvailableCategory[] { + const mergedCategories = pluginConfig.categories + ? { ...DEFAULT_CATEGORIES, ...pluginConfig.categories } + : DEFAULT_CATEGORIES + + return Object.entries(mergedCategories).map(([name, categoryConfig]) => { + const model = + typeof categoryConfig.model === "string" ? categoryConfig.model : undefined + + return { + name, + description: + pluginConfig.categories?.[name]?.description ?? + CATEGORY_DESCRIPTIONS[name] ?? + "General tasks", + model, + } + }) +} diff --git a/src/plugin/chat-message.ts b/src/plugin/chat-message.ts new file mode 100644 index 000000000..8cc1b394d --- /dev/null +++ b/src/plugin/chat-message.ts @@ -0,0 +1,139 @@ +import type { OhMyOpenCodeConfig } from "../config" +import type { PluginContext } from "./types" + +import { + applyAgentVariant, + resolveAgentVariant, + resolveVariantForModel, +} from "../shared/agent-variant" +import { hasConnectedProvidersCache } from "../shared" +import { + setSessionAgent, +} from "../features/claude-code-session-state" + +import type { CreatedHooks } from "../create-hooks" + +type FirstMessageVariantGate = { + shouldOverride: (sessionID: string) => boolean + markApplied: (sessionID: string) => void +} + +type ChatMessagePart = { type: string; text?: string; [key: string]: unknown } +type ChatMessageHandlerOutput = { message: Record; parts: ChatMessagePart[] } +type StartWorkHookOutput = { parts: Array<{ type: string; text?: string }> } + +function isStartWorkHookOutput(value: unknown): value is StartWorkHookOutput { + if (typeof value !== "object" || value === null) return false + const record = value as Record + const partsValue = record["parts"] + if (!Array.isArray(partsValue)) return false + return partsValue.every((part) => { + if (typeof part !== "object" || part === null) return false + const partRecord = part as Record + return typeof partRecord["type"] === "string" + }) +} + +export function createChatMessageHandler(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + firstMessageVariantGate: FirstMessageVariantGate + hooks: CreatedHooks +}): ( + input: { sessionID: string; agent?: string; model?: { providerID: string; modelID: string } }, + output: ChatMessageHandlerOutput +) => Promise { + const { ctx, pluginConfig, firstMessageVariantGate, hooks } = args + + return async ( + input: { sessionID: string; agent?: string; model?: { providerID: string; modelID: string } }, + output: ChatMessageHandlerOutput + ): Promise => { + if (input.agent) { + setSessionAgent(input.sessionID, input.agent) + } + + const message = output.message + + if (firstMessageVariantGate.shouldOverride(input.sessionID)) { + const variant = + input.model && input.agent + ? resolveVariantForModel(pluginConfig, input.agent, input.model) + : resolveAgentVariant(pluginConfig, input.agent) + if (variant !== undefined) { + message["variant"] = variant + } + firstMessageVariantGate.markApplied(input.sessionID) + } else { + if (input.model && input.agent && message["variant"] === undefined) { + const variant = resolveVariantForModel(pluginConfig, input.agent, input.model) + if (variant !== undefined) { + message["variant"] = variant + } + } else { + applyAgentVariant(pluginConfig, input.agent, message) + } + } + + await hooks.stopContinuationGuard?.["chat.message"]?.(input) + await hooks.keywordDetector?.["chat.message"]?.(input, output) + await hooks.claudeCodeHooks?.["chat.message"]?.(input, output) + await hooks.autoSlashCommand?.["chat.message"]?.(input, output) + if (hooks.startWork && isStartWorkHookOutput(output)) { + await hooks.startWork["chat.message"]?.(input, output) + } + + if (!hasConnectedProvidersCache()) { + ctx.client.tui + .showToast({ + body: { + title: "⚠️ Provider Cache Missing", + message: + "Model filtering disabled. RESTART OpenCode to enable full functionality.", + variant: "warning" as const, + duration: 6000, + }, + }) + .catch(() => {}) + } + + if (hooks.ralphLoop) { + const parts = output.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("") + const isCancelRalphTemplate = promptText.includes( + "Cancel the currently active Ralph Loop", + ) + + if (isRalphLoopTemplate) { + const taskMatch = promptText.match(/\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, + ) + + hooks.ralphLoop.startLoop(input.sessionID, prompt, { + maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined, + completionPromise: promiseMatch?.[1], + }) + } else if (isCancelRalphTemplate) { + hooks.ralphLoop.cancelLoop(input.sessionID) + } + } + } +} diff --git a/src/plugin/chat-params.ts b/src/plugin/chat-params.ts new file mode 100644 index 000000000..8f996a887 --- /dev/null +++ b/src/plugin/chat-params.ts @@ -0,0 +1,71 @@ +type ChatParamsInput = { + sessionID: string + agent: { name?: string } + model: { providerID: string; modelID: string } + provider: { id: string } + message: { variant?: string } +} + +type ChatParamsOutput = { + temperature?: number + topP?: number + topK?: number + options: Record +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function buildChatParamsInput(raw: unknown): ChatParamsInput | null { + if (!isRecord(raw)) return null + + const sessionID = raw.sessionID + const agent = raw.agent + const model = raw.model + const provider = raw.provider + const message = raw.message + + if (typeof sessionID !== "string") return null + if (typeof agent !== "string") return null + if (!isRecord(model)) return null + if (!isRecord(provider)) return null + if (!isRecord(message)) return null + + const providerID = model.providerID + const modelID = model.modelID + const providerId = provider.id + const variant = message.variant + + if (typeof providerID !== "string") return null + if (typeof modelID !== "string") return null + if (typeof providerId !== "string") return null + + return { + sessionID, + agent: { name: agent }, + model: { providerID, modelID }, + provider: { id: providerId }, + message: typeof variant === "string" ? { variant } : {}, + } +} + +function isChatParamsOutput(raw: unknown): raw is ChatParamsOutput { + if (!isRecord(raw)) return false + if (!isRecord(raw.options)) { + raw.options = {} + } + return isRecord(raw.options) +} + +export function createChatParamsHandler(args: { + anthropicEffort: { "chat.params"?: (input: ChatParamsInput, output: ChatParamsOutput) => Promise } | null +}): (input: unknown, output: unknown) => Promise { + return async (input, output): Promise => { + const normalizedInput = buildChatParamsInput(input) + if (!normalizedInput) return + if (!isChatParamsOutput(output)) return + + await args.anthropicEffort?.["chat.params"]?.(normalizedInput, output) + } +} diff --git a/src/plugin/event.ts b/src/plugin/event.ts new file mode 100644 index 000000000..bd05bce03 --- /dev/null +++ b/src/plugin/event.ts @@ -0,0 +1,133 @@ +import type { OhMyOpenCodeConfig } from "../config" +import type { PluginContext } from "./types" + +import { + clearSessionAgent, + getMainSessionID, + setMainSession, + updateSessionAgent, +} from "../features/claude-code-session-state" +import { resetMessageCursor } from "../shared" +import { lspManager } from "../tools" + +import type { CreatedHooks } from "../create-hooks" +import type { Managers } from "../create-managers" + +type FirstMessageVariantGate = { + markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void + clear: (sessionID: string) => void +} + +export function createEventHandler(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + firstMessageVariantGate: FirstMessageVariantGate + managers: Managers + hooks: CreatedHooks +}): (input: { event: { type: string; properties?: Record } }) => Promise { + const { ctx, firstMessageVariantGate, managers, hooks } = args + + return async (input): Promise => { + await hooks.autoUpdateChecker?.event?.(input) + await hooks.claudeCodeHooks?.event?.(input) + await hooks.backgroundNotificationHook?.event?.(input) + await hooks.sessionNotification?.(input) + await hooks.todoContinuationEnforcer?.handler?.(input) + await hooks.unstableAgentBabysitter?.event?.(input) + await hooks.contextWindowMonitor?.event?.(input) + await hooks.directoryAgentsInjector?.event?.(input) + await hooks.directoryReadmeInjector?.event?.(input) + await hooks.rulesInjector?.event?.(input) + await hooks.thinkMode?.event?.(input) + await hooks.anthropicContextWindowLimitRecovery?.event?.(input) + await hooks.agentUsageReminder?.event?.(input) + await hooks.categorySkillReminder?.event?.(input) + await hooks.interactiveBashSession?.event?.(input) + await hooks.ralphLoop?.event?.(input) + await hooks.stopContinuationGuard?.event?.(input) + await hooks.compactionTodoPreserver?.event?.(input) + await hooks.atlasHook?.handler?.(input) + + const { event } = input + const props = event.properties as Record | undefined + + if (event.type === "session.created") { + const sessionInfo = props?.info as + | { id?: string; title?: string; parentID?: string } + | undefined + + if (!sessionInfo?.parentID) { + setMainSession(sessionInfo?.id) + } + + firstMessageVariantGate.markSessionCreated(sessionInfo) + + await managers.tmuxSessionManager.onSessionCreated( + event as { + type: string + properties?: { + info?: { id?: string; parentID?: string; title?: string } + } + }, + ) + } + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id === getMainSessionID()) { + setMainSession(undefined) + } + + if (sessionInfo?.id) { + clearSessionAgent(sessionInfo.id) + resetMessageCursor(sessionInfo.id) + firstMessageVariantGate.clear(sessionInfo.id) + await managers.skillMcpManager.disconnectSession(sessionInfo.id) + await lspManager.cleanupTempDirectoryClients() + await managers.tmuxSessionManager.onSessionDeleted({ + sessionID: sessionInfo.id, + }) + } + } + + if (event.type === "message.updated") { + const info = props?.info as Record | undefined + const sessionID = info?.sessionID as string | undefined + const agent = info?.agent as string | undefined + const role = info?.role as string | undefined + if (sessionID && agent && role === "user") { + updateSessionAgent(sessionID, agent) + } + } + + if (event.type === "session.error") { + const sessionID = props?.sessionID as string | undefined + const error = props?.error + + if (hooks.sessionRecovery?.isRecoverableError(error)) { + const messageInfo = { + id: props?.messageID as string | undefined, + role: "assistant" as const, + sessionID, + error, + } + const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo) + + if ( + recovered && + sessionID && + sessionID === getMainSessionID() && + !hooks.stopContinuationGuard?.isStopped(sessionID) + ) { + await ctx.client.session + .prompt({ + path: { id: sessionID }, + body: { parts: [{ type: "text", text: "continue" }] }, + query: { directory: ctx.directory }, + }) + .catch(() => {}) + } + } + } + } +} diff --git a/src/plugin/hooks/create-continuation-hooks.ts b/src/plugin/hooks/create-continuation-hooks.ts new file mode 100644 index 000000000..90d17eebb --- /dev/null +++ b/src/plugin/hooks/create-continuation-hooks.ts @@ -0,0 +1,104 @@ +import type { HookName, OhMyOpenCodeConfig } from "../../config" +import type { BackgroundManager } from "../../features/background-agent" +import type { PluginContext } from "../types" + +import { + createTodoContinuationEnforcer, + createBackgroundNotificationHook, + createStopContinuationGuardHook, + createCompactionContextInjector, + createCompactionTodoPreserverHook, + createAtlasHook, +} from "../../hooks" +import { safeCreateHook } from "../../shared/safe-create-hook" +import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter" + +export type ContinuationHooks = { + stopContinuationGuard: ReturnType | null + compactionContextInjector: ReturnType | null + compactionTodoPreserver: ReturnType | null + todoContinuationEnforcer: ReturnType | null + unstableAgentBabysitter: ReturnType | null + backgroundNotificationHook: ReturnType | null + atlasHook: ReturnType | null +} + +type SessionRecovery = { + setOnAbortCallback: (callback: (sessionID: string) => void) => void + setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void +} | null + +export function createContinuationHooks(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + isHookEnabled: (hookName: HookName) => boolean + safeHookEnabled: boolean + backgroundManager: BackgroundManager + sessionRecovery: SessionRecovery +}): ContinuationHooks { + const { + ctx, + pluginConfig, + isHookEnabled, + safeHookEnabled, + backgroundManager, + sessionRecovery, + } = args + + const safeHook = (hookName: HookName, factory: () => T): T | null => + safeCreateHook(hookName, factory, { enabled: safeHookEnabled }) + + const stopContinuationGuard = isHookEnabled("stop-continuation-guard") + ? safeHook("stop-continuation-guard", () => createStopContinuationGuardHook(ctx)) + : null + + const compactionContextInjector = isHookEnabled("compaction-context-injector") + ? safeHook("compaction-context-injector", () => createCompactionContextInjector()) + : null + + const compactionTodoPreserver = isHookEnabled("compaction-todo-preserver") + ? safeHook("compaction-todo-preserver", () => createCompactionTodoPreserverHook(ctx)) + : null + + const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") + ? safeHook("todo-continuation-enforcer", () => + createTodoContinuationEnforcer(ctx, { + backgroundManager, + isContinuationStopped: stopContinuationGuard?.isStopped, + })) + : null + + const unstableAgentBabysitter = isHookEnabled("unstable-agent-babysitter") + ? safeHook("unstable-agent-babysitter", () => + createUnstableAgentBabysitter({ ctx, backgroundManager, pluginConfig })) + : null + + if (sessionRecovery && todoContinuationEnforcer) { + sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering) + sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete) + } + + const backgroundNotificationHook = isHookEnabled("background-notification") + ? safeHook("background-notification", () => createBackgroundNotificationHook(backgroundManager)) + : null + + const atlasHook = isHookEnabled("atlas") + ? safeHook("atlas", () => + createAtlasHook(ctx, { + directory: ctx.directory, + backgroundManager, + isContinuationStopped: (sessionID: string) => + stopContinuationGuard?.isStopped(sessionID) ?? false, + })) + : null + + return { + stopContinuationGuard, + compactionContextInjector, + compactionTodoPreserver, + todoContinuationEnforcer, + unstableAgentBabysitter, + backgroundNotificationHook, + atlasHook, + } +} diff --git a/src/plugin/hooks/create-core-hooks.ts b/src/plugin/hooks/create-core-hooks.ts new file mode 100644 index 000000000..2bfac4ae4 --- /dev/null +++ b/src/plugin/hooks/create-core-hooks.ts @@ -0,0 +1,42 @@ +import type { HookName, OhMyOpenCodeConfig } from "../../config" +import type { PluginContext } from "../types" + +import { createSessionHooks } from "./create-session-hooks" +import { createToolGuardHooks } from "./create-tool-guard-hooks" +import { createTransformHooks } from "./create-transform-hooks" + +export function createCoreHooks(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + isHookEnabled: (hookName: HookName) => boolean + safeHookEnabled: boolean +}) { + const { ctx, pluginConfig, isHookEnabled, safeHookEnabled } = args + + const session = createSessionHooks({ + ctx, + pluginConfig, + isHookEnabled, + safeHookEnabled, + }) + + const tool = createToolGuardHooks({ + ctx, + pluginConfig, + isHookEnabled, + safeHookEnabled, + }) + + const transform = createTransformHooks({ + ctx, + pluginConfig, + isHookEnabled: (name) => isHookEnabled(name as HookName), + safeHookEnabled, + }) + + return { + ...session, + ...tool, + ...transform, + } +} diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts new file mode 100644 index 000000000..28a0ecc32 --- /dev/null +++ b/src/plugin/hooks/create-session-hooks.ts @@ -0,0 +1,181 @@ +import type { OhMyOpenCodeConfig, HookName } from "../../config" +import type { PluginContext } from "../types" + +import { + createContextWindowMonitorHook, + createSessionRecoveryHook, + createSessionNotification, + createThinkModeHook, + createAnthropicContextWindowLimitRecoveryHook, + createAutoUpdateCheckerHook, + createAgentUsageReminderHook, + createNonInteractiveEnvHook, + createInteractiveBashSessionHook, + createRalphLoopHook, + createEditErrorRecoveryHook, + createDelegateTaskRetryHook, + createTaskResumeInfoHook, + createStartWorkHook, + createPrometheusMdOnlyHook, + createSisyphusJuniorNotepadHook, + createQuestionLabelTruncatorHook, + createSubagentQuestionBlockerHook, + createPreemptiveCompactionHook, +} from "../../hooks" +import { createAnthropicEffortHook } from "../../hooks/anthropic-effort" +import { + detectExternalNotificationPlugin, + getNotificationConflictWarning, + log, +} from "../../shared" +import { safeCreateHook } from "../../shared/safe-create-hook" +import { sessionExists } from "../../tools" + +export type SessionHooks = { + contextWindowMonitor: ReturnType | null + preemptiveCompaction: ReturnType | null + sessionRecovery: ReturnType | null + sessionNotification: ReturnType | null + thinkMode: ReturnType | null + anthropicContextWindowLimitRecovery: ReturnType | null + autoUpdateChecker: ReturnType | null + agentUsageReminder: ReturnType | null + nonInteractiveEnv: ReturnType | null + interactiveBashSession: ReturnType | null + ralphLoop: ReturnType | null + editErrorRecovery: ReturnType | null + delegateTaskRetry: ReturnType | null + startWork: ReturnType | null + prometheusMdOnly: ReturnType | null + sisyphusJuniorNotepad: ReturnType | null + questionLabelTruncator: ReturnType + subagentQuestionBlocker: ReturnType + taskResumeInfo: ReturnType + anthropicEffort: ReturnType | null +} + +export function createSessionHooks(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + isHookEnabled: (hookName: HookName) => boolean + safeHookEnabled: boolean +}): SessionHooks { + const { ctx, pluginConfig, isHookEnabled, safeHookEnabled } = args + const safeHook = (hookName: HookName, factory: () => T): T | null => + safeCreateHook(hookName, factory, { enabled: safeHookEnabled }) + + const contextWindowMonitor = isHookEnabled("context-window-monitor") + ? safeHook("context-window-monitor", () => createContextWindowMonitorHook(ctx)) + : null + + const preemptiveCompaction = + isHookEnabled("preemptive-compaction") && + pluginConfig.experimental?.preemptive_compaction + ? safeHook("preemptive-compaction", () => createPreemptiveCompactionHook(ctx)) + : null + + const sessionRecovery = isHookEnabled("session-recovery") + ? safeHook("session-recovery", () => + createSessionRecoveryHook(ctx, { experimental: pluginConfig.experimental })) + : null + + let sessionNotification: ReturnType | null = null + if (isHookEnabled("session-notification")) { + const forceEnable = pluginConfig.notification?.force_enable ?? false + const externalNotifier = detectExternalNotificationPlugin(ctx.directory) + if (externalNotifier.detected && !forceEnable) { + log(getNotificationConflictWarning(externalNotifier.pluginName!)) + } else { + sessionNotification = safeHook("session-notification", () => createSessionNotification(ctx)) + } + } + + const thinkMode = isHookEnabled("think-mode") + ? safeHook("think-mode", () => createThinkModeHook()) + : null + + const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery") + ? safeHook("anthropic-context-window-limit-recovery", () => + createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental: pluginConfig.experimental })) + : null + + const autoUpdateChecker = isHookEnabled("auto-update-checker") + ? safeHook("auto-update-checker", () => + createAutoUpdateCheckerHook(ctx, { + showStartupToast: isHookEnabled("startup-toast"), + isSisyphusEnabled: pluginConfig.sisyphus_agent?.disabled !== true, + autoUpdate: pluginConfig.auto_update ?? true, + })) + : null + + const agentUsageReminder = isHookEnabled("agent-usage-reminder") + ? safeHook("agent-usage-reminder", () => createAgentUsageReminderHook(ctx)) + : null + + const nonInteractiveEnv = isHookEnabled("non-interactive-env") + ? safeHook("non-interactive-env", () => createNonInteractiveEnvHook(ctx)) + : null + + const interactiveBashSession = isHookEnabled("interactive-bash-session") + ? safeHook("interactive-bash-session", () => createInteractiveBashSessionHook(ctx)) + : null + + const ralphLoop = isHookEnabled("ralph-loop") + ? safeHook("ralph-loop", () => + createRalphLoopHook(ctx, { + config: pluginConfig.ralph_loop, + checkSessionExists: async (sessionId) => sessionExists(sessionId), + })) + : null + + const editErrorRecovery = isHookEnabled("edit-error-recovery") + ? safeHook("edit-error-recovery", () => createEditErrorRecoveryHook(ctx)) + : null + + const delegateTaskRetry = isHookEnabled("delegate-task-retry") + ? safeHook("delegate-task-retry", () => createDelegateTaskRetryHook(ctx)) + : null + + const startWork = isHookEnabled("start-work") + ? safeHook("start-work", () => createStartWorkHook(ctx)) + : null + + const prometheusMdOnly = isHookEnabled("prometheus-md-only") + ? safeHook("prometheus-md-only", () => createPrometheusMdOnlyHook(ctx)) + : null + + const sisyphusJuniorNotepad = isHookEnabled("sisyphus-junior-notepad") + ? safeHook("sisyphus-junior-notepad", () => createSisyphusJuniorNotepadHook(ctx)) + : null + + const questionLabelTruncator = createQuestionLabelTruncatorHook() + const subagentQuestionBlocker = createSubagentQuestionBlockerHook() + const taskResumeInfo = createTaskResumeInfoHook() + + const anthropicEffort = isHookEnabled("anthropic-effort") + ? safeHook("anthropic-effort", () => createAnthropicEffortHook()) + : null + + return { + contextWindowMonitor, + preemptiveCompaction, + sessionRecovery, + sessionNotification, + thinkMode, + anthropicContextWindowLimitRecovery, + autoUpdateChecker, + agentUsageReminder, + nonInteractiveEnv, + interactiveBashSession, + ralphLoop, + editErrorRecovery, + delegateTaskRetry, + startWork, + prometheusMdOnly, + sisyphusJuniorNotepad, + questionLabelTruncator, + subagentQuestionBlocker, + taskResumeInfo, + anthropicEffort, + } +} diff --git a/src/plugin/hooks/create-skill-hooks.ts b/src/plugin/hooks/create-skill-hooks.ts new file mode 100644 index 000000000..043a0bbbb --- /dev/null +++ b/src/plugin/hooks/create-skill-hooks.ts @@ -0,0 +1,37 @@ +import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" +import type { HookName } from "../../config" +import type { LoadedSkill } from "../../features/opencode-skill-loader/types" +import type { PluginContext } from "../types" + +import { createAutoSlashCommandHook, createCategorySkillReminderHook } from "../../hooks" +import { safeCreateHook } from "../../shared/safe-create-hook" + +export type SkillHooks = { + categorySkillReminder: ReturnType | null + autoSlashCommand: ReturnType | null +} + +export function createSkillHooks(args: { + ctx: PluginContext + isHookEnabled: (hookName: HookName) => boolean + safeHookEnabled: boolean + mergedSkills: LoadedSkill[] + availableSkills: AvailableSkill[] +}): SkillHooks { + const { ctx, isHookEnabled, safeHookEnabled, mergedSkills, availableSkills } = args + + const safeHook = (hookName: HookName, factory: () => T): T | null => + safeCreateHook(hookName, factory, { enabled: safeHookEnabled }) + + const categorySkillReminder = isHookEnabled("category-skill-reminder") + ? safeHook("category-skill-reminder", () => + createCategorySkillReminderHook(ctx, availableSkills)) + : null + + const autoSlashCommand = isHookEnabled("auto-slash-command") + ? safeHook("auto-slash-command", () => + createAutoSlashCommandHook({ skills: mergedSkills })) + : null + + return { categorySkillReminder, autoSlashCommand } +} diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts new file mode 100644 index 000000000..ba0cb7f4b --- /dev/null +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -0,0 +1,98 @@ +import type { HookName, OhMyOpenCodeConfig } from "../../config" +import type { PluginContext } from "../types" + +import { + createCommentCheckerHooks, + createToolOutputTruncatorHook, + createDirectoryAgentsInjectorHook, + createDirectoryReadmeInjectorHook, + createEmptyTaskResponseDetectorHook, + createRulesInjectorHook, + createTasksTodowriteDisablerHook, + createWriteExistingFileGuardHook, +} from "../../hooks" +import { + getOpenCodeVersion, + isOpenCodeVersionAtLeast, + log, + OPENCODE_NATIVE_AGENTS_INJECTION_VERSION, +} from "../../shared" +import { safeCreateHook } from "../../shared/safe-create-hook" + +export type ToolGuardHooks = { + commentChecker: ReturnType | null + toolOutputTruncator: ReturnType | null + directoryAgentsInjector: ReturnType | null + directoryReadmeInjector: ReturnType | null + emptyTaskResponseDetector: ReturnType | null + rulesInjector: ReturnType | null + tasksTodowriteDisabler: ReturnType | null + writeExistingFileGuard: ReturnType | null +} + +export function createToolGuardHooks(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + isHookEnabled: (hookName: HookName) => boolean + safeHookEnabled: boolean +}): ToolGuardHooks { + const { ctx, pluginConfig, isHookEnabled, safeHookEnabled } = args + const safeHook = (hookName: HookName, factory: () => T): T | null => + safeCreateHook(hookName, factory, { enabled: safeHookEnabled }) + + const commentChecker = isHookEnabled("comment-checker") + ? safeHook("comment-checker", () => createCommentCheckerHooks(pluginConfig.comment_checker)) + : null + + const toolOutputTruncator = isHookEnabled("tool-output-truncator") + ? safeHook("tool-output-truncator", () => + createToolOutputTruncatorHook(ctx, { experimental: pluginConfig.experimental })) + : null + + let directoryAgentsInjector: ReturnType | null = null + if (isHookEnabled("directory-agents-injector")) { + const currentVersion = getOpenCodeVersion() + const hasNativeSupport = + currentVersion !== null && isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION) + if (hasNativeSupport) { + log("directory-agents-injector auto-disabled due to native OpenCode support", { + currentVersion, + nativeVersion: OPENCODE_NATIVE_AGENTS_INJECTION_VERSION, + }) + } else { + directoryAgentsInjector = safeHook("directory-agents-injector", () => createDirectoryAgentsInjectorHook(ctx)) + } + } + + const directoryReadmeInjector = isHookEnabled("directory-readme-injector") + ? safeHook("directory-readme-injector", () => createDirectoryReadmeInjectorHook(ctx)) + : null + + const emptyTaskResponseDetector = isHookEnabled("empty-task-response-detector") + ? safeHook("empty-task-response-detector", () => createEmptyTaskResponseDetectorHook(ctx)) + : null + + const rulesInjector = isHookEnabled("rules-injector") + ? safeHook("rules-injector", () => createRulesInjectorHook(ctx)) + : null + + const tasksTodowriteDisabler = isHookEnabled("tasks-todowrite-disabler") + ? safeHook("tasks-todowrite-disabler", () => + createTasksTodowriteDisablerHook({ experimental: pluginConfig.experimental })) + : null + + const writeExistingFileGuard = isHookEnabled("write-existing-file-guard") + ? safeHook("write-existing-file-guard", () => createWriteExistingFileGuardHook(ctx)) + : null + + return { + commentChecker, + toolOutputTruncator, + directoryAgentsInjector, + directoryReadmeInjector, + emptyTaskResponseDetector, + rulesInjector, + tasksTodowriteDisabler, + writeExistingFileGuard, + } +} diff --git a/src/plugin/hooks/create-transform-hooks.ts b/src/plugin/hooks/create-transform-hooks.ts new file mode 100644 index 000000000..8001d0ab1 --- /dev/null +++ b/src/plugin/hooks/create-transform-hooks.ts @@ -0,0 +1,65 @@ +import type { OhMyOpenCodeConfig } from "../../config" +import type { PluginContext } from "../types" + +import { + createClaudeCodeHooksHook, + createKeywordDetectorHook, + createThinkingBlockValidatorHook, +} from "../../hooks" +import { + contextCollector, + createContextInjectorMessagesTransformHook, +} from "../../features/context-injector" +import { safeCreateHook } from "../../shared/safe-create-hook" + +export type TransformHooks = { + claudeCodeHooks: ReturnType + keywordDetector: ReturnType | null + contextInjectorMessagesTransform: ReturnType + thinkingBlockValidator: ReturnType | null +} + +export function createTransformHooks(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + isHookEnabled: (hookName: string) => boolean + safeHookEnabled?: boolean +}): TransformHooks { + const { ctx, pluginConfig, isHookEnabled } = args + const safeHookEnabled = args.safeHookEnabled ?? true + + const claudeCodeHooks = createClaudeCodeHooksHook( + ctx, + { + disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true, + keywordDetectorDisabled: !isHookEnabled("keyword-detector"), + }, + contextCollector, + ) + + const keywordDetector = isHookEnabled("keyword-detector") + ? safeCreateHook( + "keyword-detector", + () => createKeywordDetectorHook(ctx, contextCollector), + { enabled: safeHookEnabled }, + ) + : null + + const contextInjectorMessagesTransform = + createContextInjectorMessagesTransformHook(contextCollector) + + const thinkingBlockValidator = isHookEnabled("thinking-block-validator") + ? safeCreateHook( + "thinking-block-validator", + () => createThinkingBlockValidatorHook(), + { enabled: safeHookEnabled }, + ) + : null + + return { + claudeCodeHooks, + keywordDetector, + contextInjectorMessagesTransform, + thinkingBlockValidator, + } +} diff --git a/src/plugin/messages-transform.ts b/src/plugin/messages-transform.ts new file mode 100644 index 000000000..6ea674d8a --- /dev/null +++ b/src/plugin/messages-transform.ts @@ -0,0 +1,24 @@ +import type { Message, Part } from "@opencode-ai/sdk" + +import type { CreatedHooks } from "../create-hooks" + +type MessageWithParts = { + info: Message + parts: Part[] +} + +type MessagesTransformOutput = { messages: MessageWithParts[] } + +export function createMessagesTransformHandler(args: { + hooks: CreatedHooks +}): (input: Record, output: MessagesTransformOutput) => Promise { + return async (input, output): Promise => { + await args.hooks.contextInjectorMessagesTransform?.[ + "experimental.chat.messages.transform" + ]?.(input, output) + + await args.hooks.thinkingBlockValidator?.[ + "experimental.chat.messages.transform" + ]?.(input, output) + } +} diff --git a/src/plugin/skill-context.ts b/src/plugin/skill-context.ts new file mode 100644 index 000000000..634cbc594 --- /dev/null +++ b/src/plugin/skill-context.ts @@ -0,0 +1,87 @@ +import type { AvailableSkill } from "../agents/dynamic-agent-prompt-builder" +import type { OhMyOpenCodeConfig } from "../config" +import type { BrowserAutomationProvider } from "../config/schema/browser-automation" +import type { + LoadedSkill, + SkillScope, +} from "../features/opencode-skill-loader/types" + +import { + discoverUserClaudeSkills, + discoverProjectClaudeSkills, + discoverOpencodeGlobalSkills, + discoverOpencodeProjectSkills, + mergeSkills, +} from "../features/opencode-skill-loader" +import { createBuiltinSkills } from "../features/builtin-skills" +import { getSystemMcpServerNames } from "../features/claude-code-mcp-loader" + +export type SkillContext = { + mergedSkills: LoadedSkill[] + availableSkills: AvailableSkill[] + browserProvider: BrowserAutomationProvider + disabledSkills: Set +} + +function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] { + if (scope === "user" || scope === "opencode") return "user" + if (scope === "project" || scope === "opencode-project") return "project" + return "plugin" +} + +export async function createSkillContext(args: { + directory: string + pluginConfig: OhMyOpenCodeConfig +}): Promise { + const { directory, pluginConfig } = args + + const browserProvider: BrowserAutomationProvider = + pluginConfig.browser_automation_engine?.provider ?? "playwright" + + const disabledSkills = new Set(pluginConfig.disabled_skills ?? []) + const systemMcpNames = getSystemMcpServerNames() + + const builtinSkills = createBuiltinSkills({ + browserProvider, + disabledSkills, + }).filter((skill) => { + if (skill.mcpConfig) { + for (const mcpName of Object.keys(skill.mcpConfig)) { + if (systemMcpNames.has(mcpName)) return false + } + } + return true + }) + + const includeClaudeSkills = pluginConfig.claude_code?.skills !== false + const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = + await Promise.all([ + includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]), + discoverOpencodeGlobalSkills(), + includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]), + discoverOpencodeProjectSkills(), + ]) + + const mergedSkills = mergeSkills( + builtinSkills, + pluginConfig.skills, + userSkills, + globalSkills, + projectSkills, + opencodeProjectSkills, + { configDir: directory }, + ) + + const availableSkills: AvailableSkill[] = mergedSkills.map((skill) => ({ + name: skill.name, + description: skill.definition.description ?? "", + location: mapScopeToLocation(skill.scope), + })) + + return { + mergedSkills, + availableSkills, + browserProvider, + disabledSkills, + } +} diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts new file mode 100644 index 000000000..21282e3d3 --- /dev/null +++ b/src/plugin/tool-execute-after.ts @@ -0,0 +1,47 @@ +import { consumeToolMetadata } from "../features/tool-metadata-store" +import type { CreatedHooks } from "../create-hooks" + +export function createToolExecuteAfterHandler(args: { + hooks: CreatedHooks +}): ( + input: { tool: string; sessionID: string; callID: string }, + output: + | { title: string; output: string; metadata: Record } + | undefined, +) => Promise { + const { hooks } = args + + return async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: Record } | undefined, + ): Promise => { + if (!output) return + + const stored = consumeToolMetadata(input.sessionID, input.callID) + if (stored) { + if (stored.title) { + output.title = stored.title + } + if (stored.metadata) { + output.metadata = { ...output.metadata, ...stored.metadata } + } + } + + await hooks.claudeCodeHooks?.["tool.execute.after"]?.(input, output) + await hooks.toolOutputTruncator?.["tool.execute.after"]?.(input, output) + await hooks.preemptiveCompaction?.["tool.execute.after"]?.(input, output) + await hooks.contextWindowMonitor?.["tool.execute.after"]?.(input, output) + await hooks.commentChecker?.["tool.execute.after"]?.(input, output) + await hooks.directoryAgentsInjector?.["tool.execute.after"]?.(input, output) + await hooks.directoryReadmeInjector?.["tool.execute.after"]?.(input, output) + await hooks.rulesInjector?.["tool.execute.after"]?.(input, output) + await hooks.emptyTaskResponseDetector?.["tool.execute.after"]?.(input, output) + await hooks.agentUsageReminder?.["tool.execute.after"]?.(input, output) + await hooks.categorySkillReminder?.["tool.execute.after"]?.(input, output) + await hooks.interactiveBashSession?.["tool.execute.after"]?.(input, output) + await hooks.editErrorRecovery?.["tool.execute.after"]?.(input, output) + await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output) + await hooks.atlasHook?.["tool.execute.after"]?.(input, output) + await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output) + } +} diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts new file mode 100644 index 000000000..c7fefb0a6 --- /dev/null +++ b/src/plugin/tool-execute-before.ts @@ -0,0 +1,99 @@ +import type { PluginContext } from "./types" + +import { getMainSessionID } from "../features/claude-code-session-state" +import { clearBoulderState } from "../features/boulder-state" +import { log } from "../shared" + +import type { CreatedHooks } from "../create-hooks" + +export function createToolExecuteBeforeHandler(args: { + ctx: PluginContext + hooks: CreatedHooks +}): ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record }, +) => Promise { + const { ctx, hooks } = args + + return async (input, output): Promise => { + await hooks.subagentQuestionBlocker?.["tool.execute.before"]?.(input, output) + await hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output) + await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output) + await hooks.claudeCodeHooks?.["tool.execute.before"]?.(input, output) + await hooks.nonInteractiveEnv?.["tool.execute.before"]?.(input, output) + await hooks.commentChecker?.["tool.execute.before"]?.(input, output) + await hooks.directoryAgentsInjector?.["tool.execute.before"]?.(input, output) + await hooks.directoryReadmeInjector?.["tool.execute.before"]?.(input, output) + await hooks.rulesInjector?.["tool.execute.before"]?.(input, output) + await hooks.tasksTodowriteDisabler?.["tool.execute.before"]?.(input, output) + await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output) + await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output) + await hooks.atlasHook?.["tool.execute.before"]?.(input, output) + + if (input.tool === "task") { + const argsObject = output.args + const category = typeof argsObject.category === "string" ? argsObject.category : undefined + const subagentType = typeof argsObject.subagent_type === "string" ? argsObject.subagent_type : undefined + if (category && !subagentType) { + argsObject.subagent_type = "sisyphus-junior" + } + } + + if (hooks.ralphLoop && input.tool === "slashcommand") { + const rawCommand = typeof output.args.command === "string" ? output.args.command : undefined + const command = rawCommand?.replace(/^\//, "").toLowerCase() + const sessionID = input.sessionID || getMainSessionID() + + if (command === "ralph-loop" && sessionID) { + const rawArgs = rawCommand?.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) + + hooks.ralphLoop.startLoop(sessionID, prompt, { + maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined, + completionPromise: promiseMatch?.[1], + }) + } else if (command === "cancel-ralph" && sessionID) { + hooks.ralphLoop.cancelLoop(sessionID) + } else if (command === "ulw-loop" && sessionID) { + const rawArgs = rawCommand?.replace(/^\/?(ulw-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) + + hooks.ralphLoop.startLoop(sessionID, prompt, { + ultrawork: true, + maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined, + completionPromise: promiseMatch?.[1], + }) + } + } + + if (input.tool === "slashcommand") { + const rawCommand = typeof output.args.command === "string" ? output.args.command : undefined + const command = rawCommand?.replace(/^\//, "").toLowerCase() + const sessionID = input.sessionID || getMainSessionID() + + if (command === "stop-continuation" && sessionID) { + hooks.stopContinuationGuard?.stop(sessionID) + hooks.todoContinuationEnforcer?.cancelAllCountdowns() + hooks.ralphLoop?.cancelLoop(sessionID) + clearBoulderState(ctx.directory) + log("[stop-continuation] All continuation mechanisms stopped", { + sessionID, + }) + } + } + } +} diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts new file mode 100644 index 000000000..7236ddc48 --- /dev/null +++ b/src/plugin/tool-registry.ts @@ -0,0 +1,143 @@ +import type { ToolDefinition } from "@opencode-ai/plugin" + +import type { + AvailableCategory, +} from "../agents/dynamic-agent-prompt-builder" +import type { OhMyOpenCodeConfig } from "../config" +import type { PluginContext, ToolsRecord } from "./types" + +import { + builtinTools, + createBackgroundTools, + createCallOmoAgent, + createLookAt, + createSkillTool, + createSkillMcpTool, + createSlashcommandTool, + createGrepTools, + createGlobTools, + createAstGrepTools, + createSessionManagerTools, + createDelegateTask, + discoverCommandsSync, + interactive_bash, + createTaskCreateTool, + createTaskGetTool, + createTaskList, + createTaskUpdateTool, +} from "../tools" +import { getMainSessionID } from "../features/claude-code-session-state" +import { filterDisabledTools } from "../shared/disabled-tools" +import { log } from "../shared" + +import type { Managers } from "../create-managers" +import type { SkillContext } from "./skill-context" + +export type ToolRegistryResult = { + filteredTools: ToolsRecord + taskSystemEnabled: boolean +} + +export function createToolRegistry(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + managers: Pick + skillContext: SkillContext + availableCategories: AvailableCategory[] +}): ToolRegistryResult { + const { ctx, pluginConfig, managers, skillContext, availableCategories } = args + + const backgroundTools = createBackgroundTools(managers.backgroundManager, ctx.client) + const callOmoAgent = createCallOmoAgent(ctx, managers.backgroundManager) + + const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some( + (agent) => agent.toLowerCase() === "multimodal-looker", + ) + const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null + + const delegateTask = createDelegateTask({ + manager: managers.backgroundManager, + client: ctx.client, + directory: ctx.directory, + userCategories: pluginConfig.categories, + gitMasterConfig: pluginConfig.git_master, + sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model, + browserProvider: skillContext.browserProvider, + disabledSkills: skillContext.disabledSkills, + availableCategories, + availableSkills: skillContext.availableSkills, + onSyncSessionCreated: async (event) => { + log("[index] onSyncSessionCreated callback", { + sessionID: event.sessionID, + parentID: event.parentID, + title: event.title, + }) + await managers.tmuxSessionManager.onSessionCreated({ + type: "session.created", + properties: { + info: { + id: event.sessionID, + parentID: event.parentID, + title: event.title, + }, + }, + }) + }, + }) + + const getSessionIDForMcp = (): string => getMainSessionID() || "" + + const skillTool = createSkillTool({ + skills: skillContext.mergedSkills, + mcpManager: managers.skillMcpManager, + getSessionID: getSessionIDForMcp, + gitMasterConfig: pluginConfig.git_master, + disabledSkills: skillContext.disabledSkills, + }) + + const skillMcpTool = createSkillMcpTool({ + manager: managers.skillMcpManager, + getLoadedSkills: () => skillContext.mergedSkills, + getSessionID: getSessionIDForMcp, + }) + + const commands = discoverCommandsSync() + const slashcommandTool = createSlashcommandTool({ + commands, + skills: skillContext.mergedSkills, + }) + + const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false + const taskToolsRecord: Record = taskSystemEnabled + ? { + task_create: createTaskCreateTool(pluginConfig, ctx), + task_get: createTaskGetTool(pluginConfig), + task_list: createTaskList(pluginConfig), + task_update: createTaskUpdateTool(pluginConfig, ctx), + } + : {} + + const allTools: Record = { + ...builtinTools, + ...createGrepTools(ctx), + ...createGlobTools(ctx), + ...createAstGrepTools(ctx), + ...createSessionManagerTools(ctx), + ...backgroundTools, + call_omo_agent: callOmoAgent, + ...(lookAt ? { look_at: lookAt } : {}), + task: delegateTask, + skill: skillTool, + skill_mcp: skillMcpTool, + slashcommand: slashcommandTool, + interactive_bash, + ...taskToolsRecord, + } + + const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools) + + return { + filteredTools, + taskSystemEnabled, + } +} diff --git a/src/plugin/types.ts b/src/plugin/types.ts new file mode 100644 index 000000000..583255052 --- /dev/null +++ b/src/plugin/types.ts @@ -0,0 +1,15 @@ +import type { Plugin, ToolDefinition } from "@opencode-ai/plugin" + +export type PluginContext = Parameters[0] +export type PluginInstance = Awaited> +export type PluginInterface = Omit + +export type ToolsRecord = Record + +export type TmuxConfig = { + enabled: boolean + layout: "main-horizontal" | "main-vertical" | "tiled" | "even-horizontal" | "even-vertical" + main_pane_size: number + main_pane_min_width: number + agent_pane_min_width: number +} diff --git a/src/plugin/unstable-agent-babysitter.ts b/src/plugin/unstable-agent-babysitter.ts new file mode 100644 index 000000000..6ab73bbd8 --- /dev/null +++ b/src/plugin/unstable-agent-babysitter.ts @@ -0,0 +1,41 @@ +import type { OhMyOpenCodeConfig } from "../config" +import type { PluginContext } from "./types" + +import { createUnstableAgentBabysitterHook } from "../hooks" +import type { BackgroundManager } from "../features/background-agent" + +export function createUnstableAgentBabysitter(args: { + ctx: PluginContext + backgroundManager: BackgroundManager + pluginConfig: OhMyOpenCodeConfig +}) { + const { ctx, backgroundManager, pluginConfig } = args + + return createUnstableAgentBabysitterHook( + { + directory: ctx.directory, + client: { + session: { + messages: async ({ path }) => { + const result = await ctx.client.session.messages({ path }) + if (Array.isArray(result)) return result + if (typeof result === "object" && result !== null) { + return result + } + return [] + }, + prompt: async (promptArgs) => { + await ctx.client.session.promptAsync(promptArgs) + }, + promptAsync: async (promptArgs) => { + await ctx.client.session.promptAsync(promptArgs) + }, + }, + }, + }, + { + backgroundManager, + config: pluginConfig.babysitting, + }, + ) +}