index.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. import type { Plugin } from '@opencode-ai/plugin';
  2. import { createAgents, getAgentConfigs } from './agents';
  3. import { BackgroundTaskManager, TmuxSessionManager } from './background';
  4. import { loadPluginConfig, type TmuxConfig } from './config';
  5. import { parseList } from './config/agent-mcps';
  6. import {
  7. createAutoUpdateCheckerHook,
  8. createChatHeadersHook,
  9. createDelegateTaskRetryHook,
  10. createJsonErrorRecoveryHook,
  11. createPhaseReminderHook,
  12. createPostReadNudgeHook,
  13. ForegroundFallbackManager,
  14. } from './hooks';
  15. import { createBuiltinMcps } from './mcp';
  16. import {
  17. ast_grep_replace,
  18. ast_grep_search,
  19. createBackgroundTools,
  20. lsp_diagnostics,
  21. lsp_find_references,
  22. lsp_goto_definition,
  23. lsp_rename,
  24. setUserLspConfig,
  25. } from './tools';
  26. import { startTmuxCheck } from './utils';
  27. import { log } from './utils/logger';
  28. const OhMyOpenCodeLite: Plugin = async (ctx) => {
  29. const config = loadPluginConfig(ctx.directory);
  30. const agentDefs = createAgents(config);
  31. const agents = getAgentConfigs(config);
  32. // Build a map of agent name → priority model array for runtime fallback.
  33. // Populated when the user configures model as an array in their plugin config.
  34. const modelArrayMap: Record<
  35. string,
  36. Array<{ id: string; variant?: string }>
  37. > = {};
  38. for (const agentDef of agentDefs) {
  39. if (agentDef._modelArray && agentDef._modelArray.length > 0) {
  40. modelArrayMap[agentDef.name] = agentDef._modelArray;
  41. }
  42. }
  43. // Build runtime fallback chains for all foreground agents.
  44. // Each chain is an ordered list of model strings to try when the current
  45. // model is rate-limited. Seeds from _modelArray entries (when the user
  46. // configures model as an array), then appends fallback.chains entries.
  47. const runtimeChains: Record<string, string[]> = {};
  48. for (const agentDef of agentDefs) {
  49. if (agentDef._modelArray?.length) {
  50. runtimeChains[agentDef.name] = agentDef._modelArray.map((m) => m.id);
  51. }
  52. }
  53. if (config.fallback?.enabled !== false) {
  54. const chains =
  55. (config.fallback?.chains as Record<string, string[] | undefined>) ?? {};
  56. for (const [agentName, chainModels] of Object.entries(chains)) {
  57. if (!chainModels?.length) continue;
  58. const existing = runtimeChains[agentName] ?? [];
  59. const seen = new Set(existing);
  60. for (const m of chainModels) {
  61. if (!seen.has(m)) {
  62. seen.add(m);
  63. existing.push(m);
  64. }
  65. }
  66. runtimeChains[agentName] = existing;
  67. }
  68. }
  69. // Parse tmux config with defaults
  70. const tmuxConfig: TmuxConfig = {
  71. enabled: config.tmux?.enabled ?? false,
  72. layout: config.tmux?.layout ?? 'main-vertical',
  73. main_pane_size: config.tmux?.main_pane_size ?? 60,
  74. };
  75. log('[plugin] initialized with tmux config', {
  76. tmuxConfig,
  77. rawTmuxConfig: config.tmux,
  78. directory: ctx.directory,
  79. });
  80. // Start background tmux check if enabled
  81. if (tmuxConfig.enabled) {
  82. startTmuxCheck();
  83. }
  84. const backgroundManager = new BackgroundTaskManager(ctx, tmuxConfig, config);
  85. const backgroundTools = createBackgroundTools(
  86. ctx,
  87. backgroundManager,
  88. tmuxConfig,
  89. config,
  90. );
  91. const mcps = createBuiltinMcps(config.disabled_mcps);
  92. // Initialize TmuxSessionManager to handle OpenCode's built-in Task tool sessions
  93. const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig);
  94. // Initialize auto-update checker hook
  95. const autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
  96. showStartupToast: true,
  97. autoUpdate: true,
  98. });
  99. // Initialize phase reminder hook for workflow compliance
  100. const phaseReminderHook = createPhaseReminderHook();
  101. // Initialize post-read nudge hook
  102. const postReadNudgeHook = createPostReadNudgeHook();
  103. const chatHeadersHook = createChatHeadersHook(ctx);
  104. // Initialize delegate-task retry guidance hook
  105. const delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
  106. // Initialize JSON parse error recovery hook
  107. const jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
  108. // Initialize foreground fallback manager for runtime model switching
  109. const foregroundFallback = new ForegroundFallbackManager(
  110. ctx.client,
  111. runtimeChains,
  112. config.fallback?.enabled !== false && Object.keys(runtimeChains).length > 0,
  113. );
  114. return {
  115. name: 'oh-my-opencode-slim',
  116. agent: agents,
  117. tool: {
  118. ...backgroundTools,
  119. lsp_goto_definition,
  120. lsp_find_references,
  121. lsp_diagnostics,
  122. lsp_rename,
  123. ast_grep_search,
  124. ast_grep_replace,
  125. },
  126. mcp: mcps,
  127. config: async (opencodeConfig: Record<string, unknown>) => {
  128. // Set user's lsp config from opencode.json for LSP tools
  129. const lspConfig = opencodeConfig.lsp as
  130. | Record<string, unknown>
  131. | undefined;
  132. setUserLspConfig(lspConfig);
  133. // Only set default_agent if not already configured by the user
  134. // and the plugin config doesn't explicitly disable this behavior
  135. if (
  136. config.setDefaultAgent !== false &&
  137. !(opencodeConfig as { default_agent?: string }).default_agent
  138. ) {
  139. (opencodeConfig as { default_agent?: string }).default_agent =
  140. 'orchestrator';
  141. }
  142. // Merge Agent configs — per-agent shallow merge to preserve
  143. // user-supplied fields (e.g. tools, permission) from opencode.json
  144. if (!opencodeConfig.agent) {
  145. opencodeConfig.agent = { ...agents };
  146. } else {
  147. for (const [name, pluginAgent] of Object.entries(agents)) {
  148. const existing = (opencodeConfig.agent as Record<string, unknown>)[
  149. name
  150. ] as Record<string, unknown> | undefined;
  151. if (existing) {
  152. // Shallow merge: plugin defaults first, user overrides win
  153. (opencodeConfig.agent as Record<string, unknown>)[name] = {
  154. ...pluginAgent,
  155. ...existing,
  156. };
  157. } else {
  158. (opencodeConfig.agent as Record<string, unknown>)[name] = {
  159. ...pluginAgent,
  160. };
  161. }
  162. }
  163. }
  164. const configAgent = opencodeConfig.agent as Record<string, unknown>;
  165. // Model resolution for foreground agents: combine _modelArray entries
  166. // with fallback.chains config, then pick the first model in the
  167. // effective array for startup-time selection.
  168. //
  169. // Runtime failover on API errors (e.g. rate limits mid-conversation)
  170. // is handled separately by ForegroundFallbackManager via the event hook.
  171. const fallbackChainsEnabled = config.fallback?.enabled !== false;
  172. const fallbackChains = fallbackChainsEnabled
  173. ? ((config.fallback?.chains as Record<string, string[] | undefined>) ??
  174. {})
  175. : {};
  176. // Build effective model arrays: seed from _modelArray, then append
  177. // fallback.chains entries so the resolver considers the full chain
  178. // when picking the best available provider at startup.
  179. const effectiveArrays: Record<
  180. string,
  181. Array<{ id: string; variant?: string }>
  182. > = {};
  183. for (const [agentName, models] of Object.entries(modelArrayMap)) {
  184. effectiveArrays[agentName] = [...models];
  185. }
  186. for (const [agentName, chainModels] of Object.entries(fallbackChains)) {
  187. if (!chainModels || chainModels.length === 0) continue;
  188. if (!effectiveArrays[agentName]) {
  189. // Agent has no _modelArray — seed from its current string model so
  190. // the fallback chain appends after it rather than replacing it.
  191. const entry = configAgent[agentName] as
  192. | Record<string, unknown>
  193. | undefined;
  194. const currentModel =
  195. typeof entry?.model === 'string' ? entry.model : undefined;
  196. effectiveArrays[agentName] = currentModel
  197. ? [{ id: currentModel }]
  198. : [];
  199. }
  200. const seen = new Set(effectiveArrays[agentName].map((m) => m.id));
  201. for (const chainModel of chainModels) {
  202. if (!seen.has(chainModel)) {
  203. seen.add(chainModel);
  204. effectiveArrays[agentName].push({ id: chainModel });
  205. }
  206. }
  207. }
  208. if (Object.keys(effectiveArrays).length > 0) {
  209. for (const [agentName, modelArray] of Object.entries(effectiveArrays)) {
  210. if (modelArray.length === 0) continue;
  211. // Use the first model in the effective array.
  212. // Not all providers require entries in opencodeConfig.provider —
  213. // some are loaded automatically by opencode (e.g. github-copilot,
  214. // openrouter). We cannot distinguish these from truly unconfigured
  215. // providers at config-hook time, so we cannot gate on the provider
  216. // config keys. Runtime failover is handled separately by
  217. // ForegroundFallbackManager.
  218. const chosen = modelArray[0];
  219. const entry = configAgent[agentName] as
  220. | Record<string, unknown>
  221. | undefined;
  222. if (entry) {
  223. entry.model = chosen.id;
  224. if (chosen.variant) {
  225. entry.variant = chosen.variant;
  226. }
  227. }
  228. log('[plugin] resolved model from array', {
  229. agent: agentName,
  230. model: chosen.id,
  231. variant: chosen.variant,
  232. });
  233. }
  234. }
  235. // Merge MCP configs
  236. const configMcp = opencodeConfig.mcp as
  237. | Record<string, unknown>
  238. | undefined;
  239. if (!configMcp) {
  240. opencodeConfig.mcp = { ...mcps };
  241. } else {
  242. Object.assign(configMcp, mcps);
  243. }
  244. // Get all MCP names from our config
  245. const allMcpNames = Object.keys(mcps);
  246. // For each agent, create permission rules based on their mcps list
  247. for (const [agentName, agentConfig] of Object.entries(agents)) {
  248. const agentMcps = (agentConfig as { mcps?: string[] })?.mcps;
  249. if (!agentMcps) continue;
  250. // Get or create agent permission config
  251. if (!configAgent[agentName]) {
  252. configAgent[agentName] = { ...agentConfig };
  253. }
  254. const agentConfigEntry = configAgent[agentName] as Record<
  255. string,
  256. unknown
  257. >;
  258. const agentPermission = (agentConfigEntry.permission ?? {}) as Record<
  259. string,
  260. unknown
  261. >;
  262. // Parse mcps list with wildcard and exclusion support
  263. const allowedMcps = parseList(agentMcps, allMcpNames);
  264. // Create permission rules for each MCP
  265. // MCP tools are named as <server>_<tool>, so we use <server>_*
  266. for (const mcpName of allMcpNames) {
  267. const sanitizedMcpName = mcpName.replace(/[^a-zA-Z0-9_-]/g, '_');
  268. const permissionKey = `${sanitizedMcpName}_*`;
  269. const action = allowedMcps.includes(mcpName) ? 'allow' : 'deny';
  270. // Only set if not already defined by user
  271. if (!(permissionKey in agentPermission)) {
  272. agentPermission[permissionKey] = action;
  273. }
  274. }
  275. // Update agent config with permissions
  276. agentConfigEntry.permission = agentPermission;
  277. }
  278. },
  279. event: async (input) => {
  280. // Runtime model fallback for foreground agents (rate-limit detection)
  281. await foregroundFallback.handleEvent(input.event);
  282. // Handle auto-update checking
  283. await autoUpdateChecker.event(input);
  284. // Handle tmux pane spawning for OpenCode's Task tool sessions
  285. await tmuxSessionManager.onSessionCreated(
  286. input.event as {
  287. type: string;
  288. properties?: {
  289. info?: { id?: string; parentID?: string; title?: string };
  290. };
  291. },
  292. );
  293. // Handle session.status events for:
  294. // 1. BackgroundTaskManager: completion detection
  295. // 2. TmuxSessionManager: pane cleanup
  296. await backgroundManager.handleSessionStatus(
  297. input.event as {
  298. type: string;
  299. properties?: { sessionID?: string; status?: { type: string } };
  300. },
  301. );
  302. await tmuxSessionManager.onSessionStatus(
  303. input.event as {
  304. type: string;
  305. properties?: { sessionID?: string; status?: { type: string } };
  306. },
  307. );
  308. // Handle session.deleted events for:
  309. // 1. BackgroundTaskManager: task cleanup
  310. // 2. TmuxSessionManager: pane cleanup
  311. await backgroundManager.handleSessionDeleted(
  312. input.event as {
  313. type: string;
  314. properties?: { info?: { id?: string }; sessionID?: string };
  315. },
  316. );
  317. await tmuxSessionManager.onSessionDeleted(
  318. input.event as {
  319. type: string;
  320. properties?: { sessionID?: string };
  321. },
  322. );
  323. },
  324. 'chat.headers': chatHeadersHook['chat.headers'],
  325. // Inject phase reminder before sending to API (doesn't show in UI)
  326. 'experimental.chat.messages.transform':
  327. phaseReminderHook['experimental.chat.messages.transform'],
  328. // Post-tool hooks: retry guidance for delegation errors + post-read nudge
  329. 'tool.execute.after': async (input, output) => {
  330. await delegateTaskRetryHook['tool.execute.after'](
  331. input as { tool: string },
  332. output as { output: unknown },
  333. );
  334. await jsonErrorRecoveryHook['tool.execute.after'](
  335. input as {
  336. tool: string;
  337. sessionID: string;
  338. callID: string;
  339. },
  340. output as {
  341. title: string;
  342. output: unknown;
  343. metadata: unknown;
  344. },
  345. );
  346. await postReadNudgeHook['tool.execute.after'](
  347. input as {
  348. tool: string;
  349. sessionID?: string;
  350. callID?: string;
  351. },
  352. output as {
  353. title: string;
  354. output: string;
  355. metadata: Record<string, unknown>;
  356. },
  357. );
  358. },
  359. };
  360. };
  361. export default OhMyOpenCodeLite;
  362. export type {
  363. AgentName,
  364. AgentOverrideConfig,
  365. McpName,
  366. PluginConfig,
  367. TmuxConfig,
  368. TmuxLayout,
  369. } from './config';
  370. export type { RemoteMcpConfig } from './mcp';