| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525 |
- import type { Plugin } from '@opencode-ai/plugin';
- import { createAgents, getAgentConfigs } from './agents';
- import { BackgroundTaskManager, MultiplexerSessionManager } from './background';
- import { loadPluginConfig, type MultiplexerConfig } from './config';
- import { parseList } from './config/agent-mcps';
- import { CouncilManager } from './council';
- import {
- createAutoUpdateCheckerHook,
- createChatHeadersHook,
- createDelegateTaskRetryHook,
- createFilterAvailableSkillsHook,
- createJsonErrorRecoveryHook,
- createPhaseReminderHook,
- createPostFileToolNudgeHook,
- createTodoContinuationHook,
- ForegroundFallbackManager,
- } from './hooks';
- import { createBuiltinMcps } from './mcp';
- import { getMultiplexer, startAvailabilityCheck } from './multiplexer';
- import {
- ast_grep_replace,
- ast_grep_search,
- createBackgroundTools,
- createCouncilTool,
- lsp_diagnostics,
- lsp_find_references,
- lsp_goto_definition,
- lsp_rename,
- setUserLspConfig,
- } from './tools';
- import { log } from './utils/logger';
- const OhMyOpenCodeLite: Plugin = async (ctx) => {
- const config = loadPluginConfig(ctx.directory);
- const agentDefs = createAgents(config);
- const agents = getAgentConfigs(config);
- // Build a map of agent name → priority model array for runtime fallback.
- // Populated when the user configures model as an array in their plugin config.
- const modelArrayMap: Record<
- string,
- Array<{ id: string; variant?: string }>
- > = {};
- for (const agentDef of agentDefs) {
- if (agentDef._modelArray && agentDef._modelArray.length > 0) {
- modelArrayMap[agentDef.name] = agentDef._modelArray;
- }
- }
- // Build runtime fallback chains for all foreground agents.
- // Each chain is an ordered list of model strings to try when the current
- // model is rate-limited. Seeds from _modelArray entries (when the user
- // configures model as an array), then appends fallback.chains entries.
- const runtimeChains: Record<string, string[]> = {};
- for (const agentDef of agentDefs) {
- if (agentDef._modelArray?.length) {
- runtimeChains[agentDef.name] = agentDef._modelArray.map((m) => m.id);
- }
- }
- if (config.fallback?.enabled !== false) {
- const chains =
- (config.fallback?.chains as Record<string, string[] | undefined>) ?? {};
- for (const [agentName, chainModels] of Object.entries(chains)) {
- if (!chainModels?.length) continue;
- const existing = runtimeChains[agentName] ?? [];
- const seen = new Set(existing);
- for (const m of chainModels) {
- if (!seen.has(m)) {
- seen.add(m);
- existing.push(m);
- }
- }
- runtimeChains[agentName] = existing;
- }
- }
- // Parse multiplexer config with defaults
- const multiplexerConfig: MultiplexerConfig = {
- type: config.multiplexer?.type ?? 'none',
- layout: config.multiplexer?.layout ?? 'main-vertical',
- main_pane_size: config.multiplexer?.main_pane_size ?? 60,
- };
- // Get multiplexer instance for capability checks
- const multiplexer = getMultiplexer(multiplexerConfig);
- const multiplexerEnabled =
- multiplexerConfig.type !== 'none' && multiplexer !== null;
- log('[plugin] initialized with multiplexer config', {
- multiplexerConfig,
- enabled: multiplexerEnabled,
- directory: ctx.directory,
- });
- // Start background availability check if enabled
- if (multiplexerEnabled) {
- startAvailabilityCheck(multiplexerConfig);
- }
- const backgroundManager = new BackgroundTaskManager(
- ctx,
- multiplexerConfig,
- config,
- );
- const backgroundTools = createBackgroundTools(
- ctx,
- backgroundManager,
- multiplexerConfig,
- config,
- );
- // Initialize council tools (only when council is configured)
- const councilTools = config.council
- ? createCouncilTool(
- ctx,
- new CouncilManager(
- ctx,
- config,
- backgroundManager.getDepthTracker(),
- multiplexerEnabled,
- ),
- )
- : {};
- const mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
- // Initialize MultiplexerSessionManager to handle OpenCode's built-in Task tool sessions
- const multiplexerSessionManager = new MultiplexerSessionManager(
- ctx,
- multiplexerConfig,
- );
- // Initialize auto-update checker hook
- const autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
- showStartupToast: true,
- autoUpdate: true,
- });
- // Initialize phase reminder hook for workflow compliance
- const phaseReminderHook = createPhaseReminderHook();
- // Initialize available skills filter hook
- const filterAvailableSkillsHook = createFilterAvailableSkillsHook(
- ctx,
- config,
- );
- // Initialize post-file-tool nudge hook
- const postFileToolNudgeHook = createPostFileToolNudgeHook();
- const chatHeadersHook = createChatHeadersHook(ctx);
- // Initialize delegate-task retry guidance hook
- const delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
- // Initialize JSON parse error recovery hook
- const jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
- // Initialize foreground fallback manager for runtime model switching
- const foregroundFallback = new ForegroundFallbackManager(
- ctx.client,
- runtimeChains,
- config.fallback?.enabled !== false && Object.keys(runtimeChains).length > 0,
- );
- // Initialize todo-continuation hook (opt-in auto-continue for incomplete todos)
- const todoContinuationHook = createTodoContinuationHook(ctx, {
- maxContinuations: config.todoContinuation?.maxContinuations ?? 5,
- cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
- autoEnable: config.todoContinuation?.autoEnable ?? false,
- autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
- });
- return {
- name: 'oh-my-opencode-slim',
- agent: agents,
- tool: {
- ...backgroundTools,
- ...councilTools,
- ...todoContinuationHook.tool,
- lsp_goto_definition,
- lsp_find_references,
- lsp_diagnostics,
- lsp_rename,
- ast_grep_search,
- ast_grep_replace,
- },
- mcp: mcps,
- config: async (opencodeConfig: Record<string, unknown>) => {
- // Set user's lsp config from opencode.json for LSP tools
- const lspConfig = opencodeConfig.lsp as
- | Record<string, unknown>
- | undefined;
- setUserLspConfig(lspConfig);
- // Only set default_agent if not already configured by the user
- // and the plugin config doesn't explicitly disable this behavior
- if (
- config.setDefaultAgent !== false &&
- !(opencodeConfig as { default_agent?: string }).default_agent
- ) {
- (opencodeConfig as { default_agent?: string }).default_agent =
- 'orchestrator';
- }
- // Merge Agent configs — per-agent shallow merge to preserve
- // user-supplied fields (e.g. tools, permission) from opencode.json
- if (!opencodeConfig.agent) {
- opencodeConfig.agent = { ...agents };
- } else {
- for (const [name, pluginAgent] of Object.entries(agents)) {
- const existing = (opencodeConfig.agent as Record<string, unknown>)[
- name
- ] as Record<string, unknown> | undefined;
- if (existing) {
- // Shallow merge: plugin defaults first, user overrides win
- (opencodeConfig.agent as Record<string, unknown>)[name] = {
- ...pluginAgent,
- ...existing,
- };
- } else {
- (opencodeConfig.agent as Record<string, unknown>)[name] = {
- ...pluginAgent,
- };
- }
- }
- }
- const configAgent = opencodeConfig.agent as Record<string, unknown>;
- // Model resolution for foreground agents: combine _modelArray entries
- // with fallback.chains config, then pick the first model in the
- // effective array for startup-time selection.
- //
- // Runtime failover on API errors (e.g. rate limits mid-conversation)
- // is handled separately by ForegroundFallbackManager via the event hook.
- const fallbackChainsEnabled = config.fallback?.enabled !== false;
- const fallbackChains = fallbackChainsEnabled
- ? ((config.fallback?.chains as Record<string, string[] | undefined>) ??
- {})
- : {};
- // Build effective model arrays: seed from _modelArray, then append
- // fallback.chains entries so the resolver considers the full chain
- // when picking the best available provider at startup.
- const effectiveArrays: Record<
- string,
- Array<{ id: string; variant?: string }>
- > = {};
- for (const [agentName, models] of Object.entries(modelArrayMap)) {
- effectiveArrays[agentName] = [...models];
- }
- for (const [agentName, chainModels] of Object.entries(fallbackChains)) {
- if (!chainModels || chainModels.length === 0) continue;
- if (!effectiveArrays[agentName]) {
- // Agent has no _modelArray — seed from its current string model so
- // the fallback chain appends after it rather than replacing it.
- const entry = configAgent[agentName] as
- | Record<string, unknown>
- | undefined;
- const currentModel =
- typeof entry?.model === 'string' ? entry.model : undefined;
- effectiveArrays[agentName] = currentModel
- ? [{ id: currentModel }]
- : [];
- }
- const seen = new Set(effectiveArrays[agentName].map((m) => m.id));
- for (const chainModel of chainModels) {
- if (!seen.has(chainModel)) {
- seen.add(chainModel);
- effectiveArrays[agentName].push({ id: chainModel });
- }
- }
- }
- if (Object.keys(effectiveArrays).length > 0) {
- for (const [agentName, modelArray] of Object.entries(effectiveArrays)) {
- if (modelArray.length === 0) continue;
- // Use the first model in the effective array.
- // Not all providers require entries in opencodeConfig.provider —
- // some are loaded automatically by opencode (e.g. github-copilot,
- // openrouter). We cannot distinguish these from truly unconfigured
- // providers at config-hook time, so we cannot gate on the provider
- // config keys. Runtime failover is handled separately by
- // ForegroundFallbackManager.
- const chosen = modelArray[0];
- const entry = configAgent[agentName] as
- | Record<string, unknown>
- | undefined;
- if (entry) {
- entry.model = chosen.id;
- if (chosen.variant) {
- entry.variant = chosen.variant;
- }
- }
- log('[plugin] resolved model from array', {
- agent: agentName,
- model: chosen.id,
- variant: chosen.variant,
- });
- }
- }
- // Merge MCP configs
- const configMcp = opencodeConfig.mcp as
- | Record<string, unknown>
- | undefined;
- if (!configMcp) {
- opencodeConfig.mcp = { ...mcps };
- } else {
- Object.assign(configMcp, mcps);
- }
- // Get all MCP names from the merged config (built-in + custom)
- const mergedMcpConfig = opencodeConfig.mcp as
- | Record<string, unknown>
- | undefined;
- const allMcpNames = Object.keys(mergedMcpConfig ?? mcps);
- // For each agent, create permission rules based on their mcps list
- for (const [agentName, agentConfig] of Object.entries(agents)) {
- const agentMcps = (agentConfig as { mcps?: string[] })?.mcps;
- if (!agentMcps) continue;
- // Get or create agent permission config
- if (!configAgent[agentName]) {
- configAgent[agentName] = { ...agentConfig };
- }
- const agentConfigEntry = configAgent[agentName] as Record<
- string,
- unknown
- >;
- const agentPermission = (agentConfigEntry.permission ?? {}) as Record<
- string,
- unknown
- >;
- // Parse mcps list with wildcard and exclusion support
- const allowedMcps = parseList(agentMcps, allMcpNames);
- // Create permission rules for each MCP
- // MCP tools are named as <server>_<tool>, so we use <server>_*
- for (const mcpName of allMcpNames) {
- const sanitizedMcpName = mcpName.replace(/[^a-zA-Z0-9_-]/g, '_');
- const permissionKey = `${sanitizedMcpName}_*`;
- const action = allowedMcps.includes(mcpName) ? 'allow' : 'deny';
- // Only set if not already defined by user
- if (!(permissionKey in agentPermission)) {
- agentPermission[permissionKey] = action;
- }
- }
- // Update agent config with permissions
- agentConfigEntry.permission = agentPermission;
- }
- // Register /auto-continue command so OpenCode recognizes it.
- // Actual handling is done by command.execute.before hook below
- // (no LLM round-trip — injected directly into output.parts).
- const configCommand = opencodeConfig.command as
- | Record<string, unknown>
- | undefined;
- if (!configCommand?.['auto-continue']) {
- if (!opencodeConfig.command) {
- opencodeConfig.command = {};
- }
- (opencodeConfig.command as Record<string, unknown>)['auto-continue'] = {
- template: 'Call the auto_continue tool with enabled=true',
- description:
- 'Enable auto-continuation — orchestrator keeps working through incomplete todos',
- };
- }
- },
- event: async (input) => {
- // Runtime model fallback for foreground agents (rate-limit detection)
- await foregroundFallback.handleEvent(input.event);
- // Todo-continuation: auto-continue orchestrator on incomplete todos
- await todoContinuationHook.handleEvent(input);
- // Handle auto-update checking
- await autoUpdateChecker.event(input);
- // Handle multiplexer pane spawning for OpenCode's Task tool sessions
- await multiplexerSessionManager.onSessionCreated(
- input.event as {
- type: string;
- properties?: {
- info?: { id?: string; parentID?: string; title?: string };
- };
- },
- );
- // Handle session.status events for:
- // 1. BackgroundTaskManager: completion detection
- // 2. MultiplexerSessionManager: pane cleanup
- await backgroundManager.handleSessionStatus(
- input.event as {
- type: string;
- properties?: { sessionID?: string; status?: { type: string } };
- },
- );
- await multiplexerSessionManager.onSessionStatus(
- input.event as {
- type: string;
- properties?: { sessionID?: string; status?: { type: string } };
- },
- );
- // Handle session.deleted events for:
- // 1. BackgroundTaskManager: task cleanup
- // 2. MultiplexerSessionManager: pane cleanup
- await backgroundManager.handleSessionDeleted(
- input.event as {
- type: string;
- properties?: { info?: { id?: string }; sessionID?: string };
- },
- );
- await multiplexerSessionManager.onSessionDeleted(
- input.event as {
- type: string;
- properties?: { sessionID?: string };
- },
- );
- },
- // Direct interception of /auto-continue command — bypasses LLM round-trip
- 'command.execute.before': async (input, output) => {
- await todoContinuationHook.handleCommandExecuteBefore(
- input as {
- command: string;
- sessionID: string;
- arguments: string;
- },
- output as { parts: Array<{ type: string; text?: string }> },
- );
- },
- 'chat.headers': chatHeadersHook['chat.headers'],
- // Inject phase reminder and filter available skills before sending to API (doesn't show in UI)
- 'experimental.chat.messages.transform': async (
- input: Record<string, never>,
- output: { messages: unknown[] },
- ): Promise<void> => {
- // Type assertion since we know the structure matches MessageWithParts[]
- const typedOutput = output as {
- messages: Array<{
- info: { role: string; agent?: string; sessionID?: string };
- parts: Array<{
- type: string;
- text?: string;
- [key: string]: unknown;
- }>;
- }>;
- };
- await phaseReminderHook['experimental.chat.messages.transform'](
- input,
- typedOutput,
- );
- await filterAvailableSkillsHook['experimental.chat.messages.transform'](
- input,
- typedOutput,
- );
- },
- // Post-tool hooks: retry guidance for delegation errors + file-tool nudge
- 'tool.execute.after': async (input, output) => {
- await delegateTaskRetryHook['tool.execute.after'](
- input as { tool: string },
- output as { output: unknown },
- );
- await jsonErrorRecoveryHook['tool.execute.after'](
- input as {
- tool: string;
- sessionID: string;
- callID: string;
- },
- output as {
- title: string;
- output: unknown;
- metadata: unknown;
- },
- );
- await postFileToolNudgeHook['tool.execute.after'](
- input as {
- tool: string;
- sessionID?: string;
- callID?: string;
- },
- output as {
- title: string;
- output: string;
- metadata: Record<string, unknown>;
- },
- );
- },
- };
- };
- export default OhMyOpenCodeLite;
- export type {
- AgentName,
- AgentOverrideConfig,
- McpName,
- MultiplexerConfig,
- MultiplexerLayout,
- MultiplexerType,
- PluginConfig,
- TmuxConfig,
- TmuxLayout,
- } from './config';
- export type { RemoteMcpConfig } from './mcp';
|