index.ts 19 KB

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