index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. import type { Plugin } from '@opencode-ai/plugin';
  2. import { createAgents, getAgentConfigs } from './agents';
  3. import { BackgroundTaskManager, MultiplexerSessionManager } from './background';
  4. import { loadPluginConfig, type MultiplexerConfig } from './config';
  5. import { parseList } from './config/agent-mcps';
  6. import { CouncilManager } from './council';
  7. import {
  8. createAutoUpdateCheckerHook,
  9. createChatHeadersHook,
  10. createDelegateTaskRetryHook,
  11. createFilterAvailableSkillsHook,
  12. createJsonErrorRecoveryHook,
  13. createPhaseReminderHook,
  14. createPostFileToolNudgeHook,
  15. createTodoContinuationHook,
  16. ForegroundFallbackManager,
  17. } from './hooks';
  18. import { createBuiltinMcps } from './mcp';
  19. import { getMultiplexer, startAvailabilityCheck } from './multiplexer';
  20. import {
  21. ast_grep_replace,
  22. ast_grep_search,
  23. createBackgroundTools,
  24. createCouncilTool,
  25. lsp_diagnostics,
  26. lsp_find_references,
  27. lsp_goto_definition,
  28. lsp_rename,
  29. setUserLspConfig,
  30. } from './tools';
  31. import { log } from './utils/logger';
  32. const OhMyOpenCodeLite: Plugin = async (ctx) => {
  33. const config = loadPluginConfig(ctx.directory);
  34. const agentDefs = createAgents(config);
  35. const agents = getAgentConfigs(config);
  36. // Build a map of agent name → priority model array for runtime fallback.
  37. // Populated when the user configures model as an array in their plugin config.
  38. const modelArrayMap: Record<
  39. string,
  40. Array<{ id: string; variant?: string }>
  41. > = {};
  42. for (const agentDef of agentDefs) {
  43. if (agentDef._modelArray && agentDef._modelArray.length > 0) {
  44. modelArrayMap[agentDef.name] = agentDef._modelArray;
  45. }
  46. }
  47. // Build runtime fallback chains for all foreground agents.
  48. // Each chain is an ordered list of model strings to try when the current
  49. // model is rate-limited. Seeds from _modelArray entries (when the user
  50. // configures model as an array), then appends fallback.chains entries.
  51. const runtimeChains: Record<string, string[]> = {};
  52. for (const agentDef of agentDefs) {
  53. if (agentDef._modelArray?.length) {
  54. runtimeChains[agentDef.name] = agentDef._modelArray.map((m) => m.id);
  55. }
  56. }
  57. if (config.fallback?.enabled !== false) {
  58. const chains =
  59. (config.fallback?.chains as Record<string, string[] | undefined>) ?? {};
  60. for (const [agentName, chainModels] of Object.entries(chains)) {
  61. if (!chainModels?.length) continue;
  62. const existing = runtimeChains[agentName] ?? [];
  63. const seen = new Set(existing);
  64. for (const m of chainModels) {
  65. if (!seen.has(m)) {
  66. seen.add(m);
  67. existing.push(m);
  68. }
  69. }
  70. runtimeChains[agentName] = existing;
  71. }
  72. }
  73. // Parse multiplexer config with defaults
  74. const multiplexerConfig: MultiplexerConfig = {
  75. type: config.multiplexer?.type ?? 'none',
  76. layout: config.multiplexer?.layout ?? 'main-vertical',
  77. main_pane_size: config.multiplexer?.main_pane_size ?? 60,
  78. };
  79. // Get multiplexer instance for capability checks
  80. const multiplexer = getMultiplexer(multiplexerConfig);
  81. const multiplexerEnabled =
  82. multiplexerConfig.type !== 'none' && multiplexer !== null;
  83. log('[plugin] initialized with multiplexer config', {
  84. multiplexerConfig,
  85. enabled: multiplexerEnabled,
  86. directory: ctx.directory,
  87. });
  88. // Start background availability check if enabled
  89. if (multiplexerEnabled) {
  90. startAvailabilityCheck(multiplexerConfig);
  91. }
  92. const backgroundManager = new BackgroundTaskManager(
  93. ctx,
  94. multiplexerConfig,
  95. config,
  96. );
  97. const backgroundTools = createBackgroundTools(
  98. ctx,
  99. backgroundManager,
  100. multiplexerConfig,
  101. config,
  102. );
  103. // Initialize council tools (only when council is configured)
  104. const councilTools = config.council
  105. ? createCouncilTool(
  106. ctx,
  107. new CouncilManager(
  108. ctx,
  109. config,
  110. backgroundManager.getDepthTracker(),
  111. multiplexerEnabled,
  112. ),
  113. )
  114. : {};
  115. const mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
  116. // Initialize MultiplexerSessionManager to handle OpenCode's built-in Task tool sessions
  117. const multiplexerSessionManager = new MultiplexerSessionManager(
  118. ctx,
  119. multiplexerConfig,
  120. );
  121. // Initialize auto-update checker hook
  122. const autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
  123. showStartupToast: true,
  124. autoUpdate: true,
  125. });
  126. // Initialize phase reminder hook for workflow compliance
  127. const phaseReminderHook = createPhaseReminderHook();
  128. // Initialize available skills filter hook
  129. const filterAvailableSkillsHook = createFilterAvailableSkillsHook(
  130. ctx,
  131. config,
  132. );
  133. // Initialize post-file-tool nudge hook
  134. const postFileToolNudgeHook = createPostFileToolNudgeHook();
  135. const chatHeadersHook = createChatHeadersHook(ctx);
  136. // Initialize delegate-task retry guidance hook
  137. const delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
  138. // Initialize JSON parse error recovery hook
  139. const jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
  140. // Initialize foreground fallback manager for runtime model switching
  141. const foregroundFallback = new ForegroundFallbackManager(
  142. ctx.client,
  143. runtimeChains,
  144. config.fallback?.enabled !== false && Object.keys(runtimeChains).length > 0,
  145. );
  146. // Initialize todo-continuation hook (opt-in auto-continue for incomplete todos)
  147. const todoContinuationHook = createTodoContinuationHook(ctx, {
  148. maxContinuations: config.todoContinuation?.maxContinuations ?? 5,
  149. cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
  150. autoEnable: config.todoContinuation?.autoEnable ?? false,
  151. autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
  152. });
  153. return {
  154. name: 'oh-my-opencode-slim',
  155. agent: agents,
  156. tool: {
  157. ...backgroundTools,
  158. ...councilTools,
  159. ...todoContinuationHook.tool,
  160. lsp_goto_definition,
  161. lsp_find_references,
  162. lsp_diagnostics,
  163. lsp_rename,
  164. ast_grep_search,
  165. ast_grep_replace,
  166. },
  167. mcp: mcps,
  168. config: async (opencodeConfig: Record<string, unknown>) => {
  169. // Set user's lsp config from opencode.json for LSP tools
  170. const lspConfig = opencodeConfig.lsp as
  171. | Record<string, unknown>
  172. | undefined;
  173. setUserLspConfig(lspConfig);
  174. // Only set default_agent if not already configured by the user
  175. // and the plugin config doesn't explicitly disable this behavior
  176. if (
  177. config.setDefaultAgent !== false &&
  178. !(opencodeConfig as { default_agent?: string }).default_agent
  179. ) {
  180. (opencodeConfig as { default_agent?: string }).default_agent =
  181. 'orchestrator';
  182. }
  183. // Merge Agent configs — per-agent shallow merge to preserve
  184. // user-supplied fields (e.g. tools, permission) from opencode.json
  185. if (!opencodeConfig.agent) {
  186. opencodeConfig.agent = { ...agents };
  187. } else {
  188. for (const [name, pluginAgent] of Object.entries(agents)) {
  189. const existing = (opencodeConfig.agent as Record<string, unknown>)[
  190. name
  191. ] as Record<string, unknown> | undefined;
  192. if (existing) {
  193. // Shallow merge: plugin defaults first, user overrides win
  194. (opencodeConfig.agent as Record<string, unknown>)[name] = {
  195. ...pluginAgent,
  196. ...existing,
  197. };
  198. } else {
  199. (opencodeConfig.agent as Record<string, unknown>)[name] = {
  200. ...pluginAgent,
  201. };
  202. }
  203. }
  204. }
  205. const configAgent = opencodeConfig.agent as Record<string, unknown>;
  206. // Model resolution for foreground agents: combine _modelArray entries
  207. // with fallback.chains config, then pick the first model in the
  208. // effective array for startup-time selection.
  209. //
  210. // Runtime failover on API errors (e.g. rate limits mid-conversation)
  211. // is handled separately by ForegroundFallbackManager via the event hook.
  212. const fallbackChainsEnabled = config.fallback?.enabled !== false;
  213. const fallbackChains = fallbackChainsEnabled
  214. ? ((config.fallback?.chains as Record<string, string[] | undefined>) ??
  215. {})
  216. : {};
  217. // Build effective model arrays: seed from _modelArray, then append
  218. // fallback.chains entries so the resolver considers the full chain
  219. // when picking the best available provider at startup.
  220. const effectiveArrays: Record<
  221. string,
  222. Array<{ id: string; variant?: string }>
  223. > = {};
  224. for (const [agentName, models] of Object.entries(modelArrayMap)) {
  225. effectiveArrays[agentName] = [...models];
  226. }
  227. for (const [agentName, chainModels] of Object.entries(fallbackChains)) {
  228. if (!chainModels || chainModels.length === 0) continue;
  229. if (!effectiveArrays[agentName]) {
  230. // Agent has no _modelArray — seed from its current string model so
  231. // the fallback chain appends after it rather than replacing it.
  232. const entry = configAgent[agentName] as
  233. | Record<string, unknown>
  234. | undefined;
  235. const currentModel =
  236. typeof entry?.model === 'string' ? entry.model : undefined;
  237. effectiveArrays[agentName] = currentModel
  238. ? [{ id: currentModel }]
  239. : [];
  240. }
  241. const seen = new Set(effectiveArrays[agentName].map((m) => m.id));
  242. for (const chainModel of chainModels) {
  243. if (!seen.has(chainModel)) {
  244. seen.add(chainModel);
  245. effectiveArrays[agentName].push({ id: chainModel });
  246. }
  247. }
  248. }
  249. if (Object.keys(effectiveArrays).length > 0) {
  250. for (const [agentName, modelArray] of Object.entries(effectiveArrays)) {
  251. if (modelArray.length === 0) continue;
  252. // Use the first model in the effective array.
  253. // Not all providers require entries in opencodeConfig.provider —
  254. // some are loaded automatically by opencode (e.g. github-copilot,
  255. // openrouter). We cannot distinguish these from truly unconfigured
  256. // providers at config-hook time, so we cannot gate on the provider
  257. // config keys. Runtime failover is handled separately by
  258. // ForegroundFallbackManager.
  259. const chosen = modelArray[0];
  260. const entry = configAgent[agentName] as
  261. | Record<string, unknown>
  262. | undefined;
  263. if (entry) {
  264. entry.model = chosen.id;
  265. if (chosen.variant) {
  266. entry.variant = chosen.variant;
  267. }
  268. }
  269. log('[plugin] resolved model from array', {
  270. agent: agentName,
  271. model: chosen.id,
  272. variant: chosen.variant,
  273. });
  274. }
  275. }
  276. // Merge MCP configs
  277. const configMcp = opencodeConfig.mcp as
  278. | Record<string, unknown>
  279. | undefined;
  280. if (!configMcp) {
  281. opencodeConfig.mcp = { ...mcps };
  282. } else {
  283. Object.assign(configMcp, mcps);
  284. }
  285. // Get all MCP names from the merged config (built-in + custom)
  286. const mergedMcpConfig = opencodeConfig.mcp as
  287. | Record<string, unknown>
  288. | undefined;
  289. const allMcpNames = Object.keys(mergedMcpConfig ?? mcps);
  290. // For each agent, create permission rules based on their mcps list
  291. for (const [agentName, agentConfig] of Object.entries(agents)) {
  292. const agentMcps = (agentConfig as { mcps?: string[] })?.mcps;
  293. if (!agentMcps) continue;
  294. // Get or create agent permission config
  295. if (!configAgent[agentName]) {
  296. configAgent[agentName] = { ...agentConfig };
  297. }
  298. const agentConfigEntry = configAgent[agentName] as Record<
  299. string,
  300. unknown
  301. >;
  302. const agentPermission = (agentConfigEntry.permission ?? {}) as Record<
  303. string,
  304. unknown
  305. >;
  306. // Parse mcps list with wildcard and exclusion support
  307. const allowedMcps = parseList(agentMcps, allMcpNames);
  308. // Create permission rules for each MCP
  309. // MCP tools are named as <server>_<tool>, so we use <server>_*
  310. for (const mcpName of allMcpNames) {
  311. const sanitizedMcpName = mcpName.replace(/[^a-zA-Z0-9_-]/g, '_');
  312. const permissionKey = `${sanitizedMcpName}_*`;
  313. const action = allowedMcps.includes(mcpName) ? 'allow' : 'deny';
  314. // Only set if not already defined by user
  315. if (!(permissionKey in agentPermission)) {
  316. agentPermission[permissionKey] = action;
  317. }
  318. }
  319. // Update agent config with permissions
  320. agentConfigEntry.permission = agentPermission;
  321. }
  322. // Register /auto-continue command so OpenCode recognizes it.
  323. // Actual handling is done by command.execute.before hook below
  324. // (no LLM round-trip — injected directly into output.parts).
  325. const configCommand = opencodeConfig.command as
  326. | Record<string, unknown>
  327. | undefined;
  328. if (!configCommand?.['auto-continue']) {
  329. if (!opencodeConfig.command) {
  330. opencodeConfig.command = {};
  331. }
  332. (opencodeConfig.command as Record<string, unknown>)['auto-continue'] = {
  333. template: 'Call the auto_continue tool with enabled=true',
  334. description:
  335. 'Enable auto-continuation — orchestrator keeps working through incomplete todos',
  336. };
  337. }
  338. },
  339. event: async (input) => {
  340. // Runtime model fallback for foreground agents (rate-limit detection)
  341. await foregroundFallback.handleEvent(input.event);
  342. // Todo-continuation: auto-continue orchestrator on incomplete todos
  343. await todoContinuationHook.handleEvent(input);
  344. // Handle auto-update checking
  345. await autoUpdateChecker.event(input);
  346. // Handle multiplexer pane spawning for OpenCode's Task tool sessions
  347. await multiplexerSessionManager.onSessionCreated(
  348. input.event as {
  349. type: string;
  350. properties?: {
  351. info?: { id?: string; parentID?: string; title?: string };
  352. };
  353. },
  354. );
  355. // Handle session.status events for:
  356. // 1. BackgroundTaskManager: completion detection
  357. // 2. MultiplexerSessionManager: pane cleanup
  358. await backgroundManager.handleSessionStatus(
  359. input.event as {
  360. type: string;
  361. properties?: { sessionID?: string; status?: { type: string } };
  362. },
  363. );
  364. await multiplexerSessionManager.onSessionStatus(
  365. input.event as {
  366. type: string;
  367. properties?: { sessionID?: string; status?: { type: string } };
  368. },
  369. );
  370. // Handle session.deleted events for:
  371. // 1. BackgroundTaskManager: task cleanup
  372. // 2. MultiplexerSessionManager: pane cleanup
  373. await backgroundManager.handleSessionDeleted(
  374. input.event as {
  375. type: string;
  376. properties?: { info?: { id?: string }; sessionID?: string };
  377. },
  378. );
  379. await multiplexerSessionManager.onSessionDeleted(
  380. input.event as {
  381. type: string;
  382. properties?: { sessionID?: string };
  383. },
  384. );
  385. },
  386. // Direct interception of /auto-continue command — bypasses LLM round-trip
  387. 'command.execute.before': async (input, output) => {
  388. await todoContinuationHook.handleCommandExecuteBefore(
  389. input as {
  390. command: string;
  391. sessionID: string;
  392. arguments: string;
  393. },
  394. output as { parts: Array<{ type: string; text?: string }> },
  395. );
  396. },
  397. 'chat.headers': chatHeadersHook['chat.headers'],
  398. // Inject phase reminder and filter available skills before sending to API (doesn't show in UI)
  399. 'experimental.chat.messages.transform': async (
  400. input: Record<string, never>,
  401. output: { messages: unknown[] },
  402. ): Promise<void> => {
  403. // Type assertion since we know the structure matches MessageWithParts[]
  404. const typedOutput = output as {
  405. messages: Array<{
  406. info: { role: string; agent?: string; sessionID?: string };
  407. parts: Array<{
  408. type: string;
  409. text?: string;
  410. [key: string]: unknown;
  411. }>;
  412. }>;
  413. };
  414. await phaseReminderHook['experimental.chat.messages.transform'](
  415. input,
  416. typedOutput,
  417. );
  418. await filterAvailableSkillsHook['experimental.chat.messages.transform'](
  419. input,
  420. typedOutput,
  421. );
  422. },
  423. // Post-tool hooks: retry guidance for delegation errors + file-tool nudge
  424. 'tool.execute.after': async (input, output) => {
  425. await delegateTaskRetryHook['tool.execute.after'](
  426. input as { tool: string },
  427. output as { output: unknown },
  428. );
  429. await jsonErrorRecoveryHook['tool.execute.after'](
  430. input as {
  431. tool: string;
  432. sessionID: string;
  433. callID: string;
  434. },
  435. output as {
  436. title: string;
  437. output: unknown;
  438. metadata: unknown;
  439. },
  440. );
  441. await postFileToolNudgeHook['tool.execute.after'](
  442. input as {
  443. tool: string;
  444. sessionID?: string;
  445. callID?: string;
  446. },
  447. output as {
  448. title: string;
  449. output: string;
  450. metadata: Record<string, unknown>;
  451. },
  452. );
  453. },
  454. };
  455. };
  456. export default OhMyOpenCodeLite;
  457. export type {
  458. AgentName,
  459. AgentOverrideConfig,
  460. McpName,
  461. MultiplexerConfig,
  462. MultiplexerLayout,
  463. MultiplexerType,
  464. PluginConfig,
  465. TmuxConfig,
  466. TmuxLayout,
  467. } from './config';
  468. export type { RemoteMcpConfig } from './mcp';