index.ts 20 KB

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