index.ts 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184
  1. import type { Plugin, ToolDefinition } from '@opencode-ai/plugin';
  2. import { createAgents, getAgentConfigs, getDisabledAgents } from './agents';
  3. import { buildOrchestratorPrompt } from './agents/orchestrator';
  4. import { CompanionManager } from './companion/manager';
  5. import { ensureCompanionVersion } from './companion/updater';
  6. import {
  7. type AgentOverrideConfig,
  8. deepMerge,
  9. loadPluginConfig,
  10. type MultiplexerConfig,
  11. } from './config';
  12. import { parseList } from './config/agent-mcps';
  13. import { AGENT_ALIASES } from './config/constants';
  14. import {
  15. getActiveRuntimePreset,
  16. getPreviousRuntimePreset,
  17. setActiveRuntimePreset,
  18. } from './config/runtime-preset';
  19. import { CouncilManager } from './council';
  20. import {
  21. createApplyPatchHook,
  22. createAutoUpdateCheckerHook,
  23. createChatHeadersHook,
  24. createDeepworkCommandHook,
  25. createDelegateTaskRetryHook,
  26. createFilterAvailableSkillsHook,
  27. createJsonErrorRecoveryHook,
  28. createLoopCommandHook,
  29. createPhaseReminderHook,
  30. createPostFileToolNudgeHook,
  31. createReflectCommandHook,
  32. createTaskSessionManagerHook,
  33. ForegroundFallbackManager,
  34. } from './hooks';
  35. import { processImageAttachments } from './hooks/image-hook';
  36. import type { MessageWithParts } from './hooks/types';
  37. import { createInterviewManager } from './interview';
  38. import { createBuiltinMcps } from './mcp';
  39. import {
  40. getMultiplexer,
  41. MultiplexerSessionManager,
  42. startAvailabilityCheck,
  43. } from './multiplexer';
  44. import {
  45. ast_grep_replace,
  46. ast_grep_search,
  47. createAcpRunTool,
  48. createCancelTaskTool,
  49. createCouncilTool,
  50. createPresetManager,
  51. createWebfetchTool,
  52. } from './tools';
  53. import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
  54. import {
  55. BackgroundJobBoard,
  56. createDisplayNameMentionRewriter,
  57. resolveRuntimeAgentName,
  58. } from './utils';
  59. import { isPluginDisabledByEnv } from './utils/env';
  60. import { initLogger, log } from './utils/logger';
  61. import { SubagentDepthTracker } from './utils/subagent-depth';
  62. import { collapseSystemInPlace } from './utils/system-collapse';
  63. /**
  64. * Best-effort log to opencode's app logger.
  65. * Wrapped in try/catch to avoid deadlocking on opencode v1.4.8–v1.4.9
  66. * where client.app.log() during init triggers a middleware cycle.
  67. */
  68. async function appLog(
  69. ctx: Parameters<Plugin>[0],
  70. level: 'error' | 'warn' | 'info',
  71. message: string,
  72. ): Promise<void> {
  73. try {
  74. await ctx.client.app.log({
  75. body: { service: 'oh-my-opencode-slim', level, message },
  76. });
  77. } catch {
  78. // client.app.log may deadlock or be unavailable; stderr is the
  79. // fallback
  80. const prefix =
  81. level === 'error' ? 'ERROR' : level === 'warn' ? 'WARN' : 'INFO';
  82. console.error(`[oh-my-opencode-slim] ${prefix}: ${message}`);
  83. }
  84. }
  85. /** Minimum expected registrations for a healthy plugin load. */
  86. const HEALTH_CHECK = {
  87. minAgents: 5,
  88. // Default tool set when council and ACP agents are not configured:
  89. // cancel_task, webfetch, ast_grep_search, ast_grep_replace.
  90. minTools: 4,
  91. minMcps: 1,
  92. } as const;
  93. /**
  94. * Probe jsdom at init time so the first webfetch call doesn't fail
  95. * silently. Logs a warning if jsdom can't be imported or instantiated,
  96. * but does not throw; the plugin works without webfetch.
  97. */
  98. async function probeJSDOM(): Promise<string | null> {
  99. try {
  100. const { JSDOM } = await import('jsdom');
  101. new JSDOM('<!DOCTYPE html><html><body>test</body></html>');
  102. return null;
  103. } catch (err) {
  104. return String(err);
  105. }
  106. }
  107. // Module-level runtime preset tracking. Survives plugin re-inits triggered
  108. // by client.config.update() → Instance.dispose(). When the plugin function
  109. // re-runs, it checks this variable and applies the runtime preset instead
  110. // of the config file's preset. State lives in config/runtime-preset.ts.
  111. const OhMyOpenCodeLite: Plugin = async (ctx) => {
  112. const sessionId = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
  113. initLogger(sessionId);
  114. if (isPluginDisabledByEnv()) {
  115. log('[plugin] disabled by OH_MY_OPENCODE_SLIM_DISABLE');
  116. return {};
  117. }
  118. // Declare variables that must survive the try/catch for the return
  119. // closure. These are set inside the try block.
  120. let config: ReturnType<typeof loadPluginConfig>;
  121. let disabledAgents: Set<string>;
  122. let agentDefs: ReturnType<typeof createAgents>;
  123. let agents: ReturnType<typeof getAgentConfigs>;
  124. let mcps: ReturnType<typeof createBuiltinMcps>;
  125. let modelArrayMap: Record<string, Array<{ id: string; variant?: string }>>;
  126. let runtimeChains: Record<string, string[]>;
  127. let multiplexerConfig: MultiplexerConfig;
  128. let multiplexerEnabled: boolean;
  129. let depthTracker: SubagentDepthTracker;
  130. let multiplexerSessionManager: MultiplexerSessionManager;
  131. let autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook>;
  132. let phaseReminderHook: ReturnType<typeof createPhaseReminderHook>;
  133. let filterAvailableSkillsHook: ReturnType<
  134. typeof createFilterAvailableSkillsHook
  135. >;
  136. let sessionAgentMap: Map<string, string>;
  137. let postFileToolNudgeHook: ReturnType<typeof createPostFileToolNudgeHook>;
  138. let chatHeadersHook: ReturnType<typeof createChatHeadersHook>;
  139. let delegateTaskRetryHook: ReturnType<typeof createDelegateTaskRetryHook>;
  140. let applyPatchHook: ReturnType<typeof createApplyPatchHook>;
  141. let jsonErrorRecoveryHook: ReturnType<typeof createJsonErrorRecoveryHook>;
  142. let foregroundFallback: ForegroundFallbackManager;
  143. let deepworkCommandHook: ReturnType<typeof createDeepworkCommandHook>;
  144. let reflectCommandHook: ReturnType<typeof createReflectCommandHook>;
  145. let loopCommandHook: ReturnType<typeof createLoopCommandHook>;
  146. let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
  147. let backgroundJobBoard: BackgroundJobBoard;
  148. let interviewManager: ReturnType<typeof createInterviewManager>;
  149. let presetManager: ReturnType<typeof createPresetManager>;
  150. let companionManager: CompanionManager;
  151. let councilTools: ReturnType<typeof createCouncilTool>;
  152. let cancelTaskTools: ReturnType<typeof createCancelTaskTool>;
  153. let acpRunTools: Record<string, ReturnType<typeof createAcpRunTool>>;
  154. let webfetch: ReturnType<typeof createWebfetchTool>;
  155. let tools: Record<string, ToolDefinition>;
  156. let rewriteDisplayNameMentions: ReturnType<
  157. typeof createDisplayNameMentionRewriter
  158. >;
  159. // Counters for post-init health check (set inside try, checked outside)
  160. let toolCount = 0;
  161. try {
  162. config = loadPluginConfig(ctx.directory);
  163. // Safety net: if a runtime preset was set via /preset command and
  164. // OpenCode ever fully re-runs the plugin function (not just the
  165. // config() hook), override config.preset so agents are created with
  166. // the correct models. Currently only the config() hook re-runs after
  167. // Instance.dispose(), so this is a defensive guard.
  168. const runtimePreset = getActiveRuntimePreset();
  169. if (runtimePreset && config.presets?.[runtimePreset]) {
  170. config.preset = runtimePreset;
  171. // Re-merge runtime preset into config.agents (loadPluginConfig
  172. // already merged the config-file preset, not the runtime one).
  173. // Runtime preset is override so it wins over config-file preset.
  174. const presetAgents = config.presets[runtimePreset];
  175. config.agents = deepMerge(config.agents, presetAgents);
  176. } else if (runtimePreset) {
  177. // Preset was deleted from config since last switch — clear stale state
  178. setActiveRuntimePreset(null);
  179. }
  180. disabledAgents = getDisabledAgents(config);
  181. rewriteDisplayNameMentions = createDisplayNameMentionRewriter(config);
  182. agentDefs = createAgents(config, { projectDirectory: ctx.directory });
  183. agents = getAgentConfigs(config, { projectDirectory: ctx.directory });
  184. // Build model array map and runtime fallback chains from _modelArray
  185. // entries (when the user configures model as an array in
  186. // agents.<name>.model). A single pass populates both data structures.
  187. modelArrayMap = {} as Record<
  188. string,
  189. Array<{ id: string; variant?: string }>
  190. >;
  191. runtimeChains = {} as Record<string, string[]>;
  192. for (const agentDef of agentDefs) {
  193. if (agentDef._modelArray?.length) {
  194. modelArrayMap[agentDef.name] = agentDef._modelArray;
  195. runtimeChains[agentDef.name] = agentDef._modelArray.map((m) => m.id);
  196. }
  197. }
  198. // Parse multiplexer config with defaults
  199. multiplexerConfig = {
  200. type: config.multiplexer?.type ?? 'none',
  201. layout: config.multiplexer?.layout ?? 'main-vertical',
  202. main_pane_size: config.multiplexer?.main_pane_size ?? 60,
  203. zellij_pane_mode: config.multiplexer?.zellij_pane_mode ?? 'agent-tab',
  204. };
  205. // Get multiplexer instance for capability checks
  206. const multiplexer = getMultiplexer(multiplexerConfig);
  207. multiplexerEnabled =
  208. multiplexerConfig.type !== 'none' &&
  209. multiplexer !== null &&
  210. multiplexer.isInsideSession();
  211. log('[plugin] initialized with multiplexer config', {
  212. multiplexerConfig,
  213. enabled: multiplexerEnabled,
  214. directory: ctx.directory,
  215. });
  216. // Start background availability check if enabled
  217. if (multiplexerEnabled) {
  218. startAvailabilityCheck(multiplexerConfig);
  219. }
  220. depthTracker = new SubagentDepthTracker();
  221. // Initialize council tools (only when council is configured)
  222. councilTools = config.council
  223. ? createCouncilTool(
  224. ctx,
  225. new CouncilManager(ctx, config, depthTracker, multiplexerEnabled),
  226. )
  227. : {};
  228. mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
  229. acpRunTools =
  230. Object.keys(config.acpAgents ?? {}).length > 0
  231. ? { acp_run: createAcpRunTool(config.acpAgents) }
  232. : {};
  233. webfetch = createWebfetchTool(ctx);
  234. backgroundJobBoard = new BackgroundJobBoard({
  235. maxReusablePerAgent: config.backgroundJobs?.maxSessionsPerAgent ?? 2,
  236. readContextMinLines: config.backgroundJobs?.readContextMinLines ?? 10,
  237. readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8,
  238. });
  239. // Initialize MultiplexerSessionManager to handle OpenCode's built-in
  240. // Task tool sessions
  241. multiplexerSessionManager = new MultiplexerSessionManager(
  242. ctx,
  243. multiplexerConfig,
  244. backgroundJobBoard,
  245. );
  246. backgroundJobBoard.addTerminalStateListener((taskID) => {
  247. void multiplexerSessionManager.retryDeferredIdleClose(taskID);
  248. });
  249. // Initialize auto-update checker hook
  250. autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
  251. autoUpdate: config.autoUpdate ?? true,
  252. companion: config.companion,
  253. });
  254. // Initialize phase reminder hook for workflow compliance
  255. phaseReminderHook = createPhaseReminderHook();
  256. // Initialize available skills filter hook
  257. filterAvailableSkillsHook = createFilterAvailableSkillsHook(ctx, config);
  258. // Track session → agent mapping for serve-mode system prompt injection
  259. sessionAgentMap = new Map<string, string>();
  260. // Initialize post-file-tool nudge hook
  261. postFileToolNudgeHook = createPostFileToolNudgeHook({
  262. shouldInject: (sessionID) =>
  263. sessionAgentMap.get(sessionID) === 'orchestrator',
  264. });
  265. chatHeadersHook = createChatHeadersHook(ctx);
  266. // Initialize delegate-task retry guidance hook
  267. delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
  268. applyPatchHook = createApplyPatchHook(ctx);
  269. // Initialize JSON parse error recovery hook
  270. jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
  271. // Initialize foreground fallback manager for runtime model switching
  272. foregroundFallback = new ForegroundFallbackManager(
  273. ctx.client,
  274. runtimeChains,
  275. config.fallback?.enabled !== false &&
  276. Object.keys(runtimeChains).length > 0,
  277. );
  278. deepworkCommandHook = createDeepworkCommandHook();
  279. reflectCommandHook = createReflectCommandHook();
  280. loopCommandHook = createLoopCommandHook();
  281. taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
  282. maxSessionsPerAgent: config.backgroundJobs?.maxSessionsPerAgent ?? 2,
  283. readContextMinLines: config.backgroundJobs?.readContextMinLines ?? 10,
  284. readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8,
  285. backgroundJobBoard,
  286. shouldManageSession: (sessionID) =>
  287. sessionAgentMap.get(sessionID) === 'orchestrator',
  288. });
  289. interviewManager = createInterviewManager(ctx, config);
  290. presetManager = createPresetManager(ctx, config);
  291. companionManager = new CompanionManager(
  292. `proc_${process.pid}`,
  293. ctx.directory,
  294. config.companion,
  295. );
  296. cancelTaskTools = createCancelTaskTool({
  297. client: ctx.client,
  298. backgroundJobBoard,
  299. shouldManageSession: (sessionID) =>
  300. sessionAgentMap.get(sessionID) === 'orchestrator',
  301. });
  302. tools = {
  303. ...councilTools,
  304. ...cancelTaskTools,
  305. ...acpRunTools,
  306. webfetch,
  307. ast_grep_search,
  308. ast_grep_replace,
  309. };
  310. if (config.disabled_tools && config.disabled_tools.length > 0) {
  311. const disabledTools = new Set(config.disabled_tools);
  312. tools = Object.fromEntries(
  313. Object.entries(tools).filter(([name]) => !disabledTools.has(name)),
  314. );
  315. }
  316. toolCount = Object.keys(tools).length;
  317. } catch (err) {
  318. // Plugin init failed: log visibly before re-throwing so the user
  319. // sees something actionable instead of a silent "loaded but empty".
  320. log('[plugin] FATAL: init failed', String(err));
  321. await appLog(
  322. ctx,
  323. 'error',
  324. `INIT FAILED: ${String(err)}. Report at github.com/alvinunreal/oh-my-opencode-slim/issues/310`,
  325. );
  326. throw err;
  327. }
  328. // ── Health check: validate registrations ────────────────────────────
  329. const agentCount = Object.keys(agents).length;
  330. const mcpCount = Object.keys(mcps).length;
  331. // Skip MCP threshold when user explicitly disabled all built-in MCPs
  332. const mcpThreshold =
  333. config.disabled_mcps && config.disabled_mcps.length > 0
  334. ? 0
  335. : HEALTH_CHECK.minMcps;
  336. if (
  337. agentCount < HEALTH_CHECK.minAgents ||
  338. toolCount < HEALTH_CHECK.minTools ||
  339. mcpCount < mcpThreshold
  340. ) {
  341. const msg = [
  342. 'Health check: registrations suspiciously low.',
  343. ` agents: ${agentCount} (expected >=${HEALTH_CHECK.minAgents})`,
  344. ` tools: ${toolCount} (expected >=${HEALTH_CHECK.minTools})`,
  345. ` mcps: ${mcpCount} (expected >=${mcpThreshold})`,
  346. 'This usually means a dependency failed to resolve (jsdom, etc).',
  347. 'If you recently updated opencode, see:',
  348. ' github.com/alvinunreal/oh-my-opencode-slim/issues/310',
  349. ].join('\n');
  350. log(`[plugin] WARN: ${msg}`);
  351. await appLog(ctx, 'warn', msg);
  352. } else {
  353. log('[plugin] health check passed', {
  354. agents: agentCount,
  355. tools: toolCount,
  356. mcps: mcpCount,
  357. });
  358. }
  359. // ── Probe jsdom (async, non-blocking) ───────────────────────────────
  360. // Don't await this; we don't want to block init. The warning will
  361. // appear shortly after startup if jsdom is broken.
  362. probeJSDOM().then((err) => {
  363. if (err) {
  364. const msg = `jsdom probe failed; webfetch tool will not work: ${err}`;
  365. log(`[plugin] WARN: ${msg}`);
  366. appLog(ctx, 'warn', msg).catch(() => {});
  367. }
  368. });
  369. if (config.companion?.enabled === true) {
  370. try {
  371. const companionResult = await ensureCompanionVersion({
  372. config: config.companion,
  373. downloadTimeoutMs: 3_000,
  374. lockTimeoutMs: 500,
  375. });
  376. if (companionResult.status === 'installed') {
  377. log('[companion] updated before startup', companionResult.version);
  378. } else if (companionResult.status === 'failed') {
  379. log('[companion] startup update failed', companionResult.error);
  380. }
  381. } catch (err) {
  382. log('[companion] startup update failed', String(err));
  383. }
  384. }
  385. companionManager.onLoad();
  386. function resolveTuiVariantForModel(
  387. agentName: string,
  388. model: string,
  389. ): string | undefined {
  390. const configEntry = config.agents?.[agentName];
  391. const defaultVariant =
  392. typeof configEntry?.variant === 'string'
  393. ? configEntry.variant
  394. : undefined;
  395. const chainMatches = modelArrayMap[agentName]?.filter(
  396. (entry) => entry.id === model,
  397. );
  398. if (chainMatches) {
  399. if (chainMatches.length === 1) {
  400. return chainMatches[0].variant ?? defaultVariant;
  401. }
  402. return undefined;
  403. }
  404. if (
  405. typeof configEntry?.model === 'string' &&
  406. configEntry.model === model &&
  407. defaultVariant
  408. ) {
  409. return defaultVariant;
  410. }
  411. return undefined;
  412. }
  413. return {
  414. name: 'oh-my-opencode-slim',
  415. agent: agents,
  416. tool: tools,
  417. mcp: mcps,
  418. config: async (opencodeConfig: Record<string, unknown>) => {
  419. // Only set default_agent if not already configured by the user
  420. // and the plugin config doesn't explicitly disable this behavior
  421. if (
  422. config.setDefaultAgent !== false &&
  423. !(opencodeConfig as { default_agent?: string }).default_agent
  424. ) {
  425. (opencodeConfig as { default_agent?: string }).default_agent =
  426. 'orchestrator';
  427. }
  428. // Merge Agent configs — per-agent shallow merge to preserve
  429. // user-supplied fields (e.g. tools, permission) from opencode.json
  430. if (!opencodeConfig.agent) {
  431. opencodeConfig.agent = { ...agents };
  432. } else {
  433. for (const [name, pluginAgent] of Object.entries(agents)) {
  434. const existing = (opencodeConfig.agent as Record<string, unknown>)[
  435. name
  436. ] as Record<string, unknown> | undefined;
  437. if (existing) {
  438. // Shallow merge: plugin defaults first, user overrides win
  439. (opencodeConfig.agent as Record<string, unknown>)[name] = {
  440. ...pluginAgent,
  441. ...existing,
  442. };
  443. } else {
  444. (opencodeConfig.agent as Record<string, unknown>)[name] = {
  445. ...pluginAgent,
  446. };
  447. }
  448. }
  449. }
  450. const configAgent = opencodeConfig.agent as Record<string, unknown>;
  451. // Model resolution for foreground agents: use _modelArray entries
  452. // to pick the first model for startup-time selection.
  453. //
  454. // Runtime failover on API errors (e.g. rate limits
  455. // mid-conversation) is handled separately by
  456. // ForegroundFallbackManager via the event hook.
  457. if (Object.keys(modelArrayMap).length > 0) {
  458. for (const [agentName, models] of Object.entries(modelArrayMap)) {
  459. if (models.length === 0) continue;
  460. // Use the first model in the model array. Not all providers
  461. // require entries in opencodeConfig.provider — some are loaded
  462. // automatically by opencode (e.g. github-copilot, openrouter).
  463. // We cannot distinguish these from truly unconfigured providers
  464. // at config-hook time, so we cannot gate on the provider config
  465. // keys. Runtime failover is handled separately by
  466. // ForegroundFallbackManager.
  467. const chosen = models[0];
  468. const entry = configAgent[agentName] as
  469. | Record<string, unknown>
  470. | undefined;
  471. if (entry) {
  472. // Only apply model array resolution if no user-selected model
  473. // exists. A user-selected model (via /model command) takes
  474. // precedence over the config's fallback chain to preserve
  475. // runtime selections and avoid breaking provider cache.
  476. if (entry.model === undefined) {
  477. entry.model = chosen.id;
  478. if (chosen.variant) {
  479. entry.variant = chosen.variant;
  480. }
  481. }
  482. } else {
  483. // Agent exists in slim but not in opencodeConfig.agent —
  484. // create entry
  485. (configAgent as Record<string, unknown>)[agentName] = {
  486. model: chosen.id,
  487. ...(chosen.variant ? { variant: chosen.variant } : {}),
  488. };
  489. }
  490. log('[plugin] resolved model from array', {
  491. agent: agentName,
  492. model: chosen.id,
  493. variant: chosen.variant,
  494. });
  495. }
  496. }
  497. // Runtime preset override: if /preset switched to a runtime preset,
  498. // override the model/variant/temperature from the preset's agent
  499. // config. This runs after the normal model resolution because the
  500. // config() hook re-runs with stale modelArrayMap after dispose(),
  501. // but the runtime preset data is in the captured `config` closure.
  502. const runtimePresetName = getActiveRuntimePreset();
  503. if (runtimePresetName && config.presets?.[runtimePresetName]) {
  504. const runtimePreset = config.presets[runtimePresetName];
  505. for (const [agentName, override] of Object.entries(runtimePreset)) {
  506. // Resolve legacy alias keys (e.g. "explore" → "explorer")
  507. // so presets using aliases work in this path.
  508. const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
  509. const entry = configAgent[resolvedName] as
  510. | Record<string, unknown>
  511. | undefined;
  512. if (!entry) continue;
  513. if (typeof override.model === 'string') {
  514. entry.model = override.model;
  515. } else if (
  516. Array.isArray(override.model) &&
  517. override.model.length > 0
  518. ) {
  519. const first = override.model[0];
  520. entry.model = typeof first === 'string' ? first : first.id;
  521. // Extract inline variant from array-form model entry
  522. if (typeof first !== 'string' && first.variant) {
  523. entry.variant = first.variant;
  524. }
  525. }
  526. // Explicitly set or clear scalar fields so switching from
  527. // Preset A (which sets a field) to Preset B (which doesn't)
  528. // doesn't leave stale values behind.
  529. if (typeof override.variant === 'string') {
  530. entry.variant = override.variant;
  531. } else if ('variant' in override) {
  532. delete entry.variant;
  533. }
  534. if (typeof override.temperature === 'number') {
  535. entry.temperature = override.temperature;
  536. } else if ('temperature' in override) {
  537. delete entry.temperature;
  538. }
  539. if (
  540. override.options &&
  541. typeof override.options === 'object' &&
  542. !Array.isArray(override.options)
  543. ) {
  544. entry.options = override.options;
  545. } else if ('options' in override) {
  546. delete entry.options;
  547. }
  548. log('[plugin] runtime preset override', {
  549. preset: runtimePresetName,
  550. agent: agentName,
  551. model: entry.model as string,
  552. });
  553. }
  554. // Reset agents from the previous preset that aren't in the new one.
  555. // The stale model resolution above overwrites the reset values sent
  556. // by preset-manager, so we re-apply them here from config-file
  557. // baseline.
  558. const prevPresetName = getPreviousRuntimePreset();
  559. if (prevPresetName && config.presets?.[prevPresetName]) {
  560. const prevPreset = config.presets[prevPresetName];
  561. // Build resolved key set from new preset for correct comparison
  562. // (handles alias keys like "explore" → "explorer")
  563. const newPresetResolved = new Set(
  564. Object.keys(runtimePreset).map((k) => AGENT_ALIASES[k] ?? k),
  565. );
  566. for (const agentName of Object.keys(prevPreset)) {
  567. const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
  568. if (newPresetResolved.has(resolvedName)) continue; // new preset handles it
  569. const entry = configAgent[resolvedName] as
  570. | Record<string, unknown>
  571. | undefined;
  572. if (!entry) continue;
  573. // Reset to config-file baseline. Use the previous preset's
  574. // override to identify which fields to clear even when the
  575. // baseline doesn't define them.
  576. const baseline = config.agents?.[resolvedName];
  577. const prevOverride = prevPreset[agentName] as
  578. | AgentOverrideConfig
  579. | undefined;
  580. if (typeof baseline?.model === 'string') {
  581. entry.model = baseline.model;
  582. }
  583. if (typeof baseline?.variant === 'string') {
  584. entry.variant = baseline.variant;
  585. } else if (prevOverride && 'variant' in prevOverride) {
  586. delete entry.variant;
  587. }
  588. if (typeof baseline?.temperature === 'number') {
  589. entry.temperature = baseline.temperature;
  590. } else if (prevOverride && 'temperature' in prevOverride) {
  591. delete entry.temperature;
  592. }
  593. if (
  594. baseline?.options &&
  595. typeof baseline.options === 'object' &&
  596. !Array.isArray(baseline.options)
  597. ) {
  598. entry.options = baseline.options;
  599. } else if (prevOverride && 'options' in prevOverride) {
  600. delete entry.options;
  601. }
  602. log('[plugin] runtime preset reset from previous', {
  603. previousPreset: prevPresetName,
  604. agent: resolvedName,
  605. model: entry.model as string,
  606. });
  607. }
  608. }
  609. }
  610. const tuiAgentModels: Record<string, string> = {};
  611. const tuiAgentVariants: Record<string, string> = {};
  612. for (const agentDef of agentDefs) {
  613. if (agentDef.name === 'councillor') continue;
  614. const entry = configAgent[agentDef.name] as
  615. | Record<string, unknown>
  616. | undefined;
  617. const resolvedModel =
  618. typeof entry?.model === 'string'
  619. ? entry.model
  620. : runtimeChains[agentDef.name]?.[0]
  621. ? runtimeChains[agentDef.name][0]
  622. : typeof agentDef.config.model === 'string'
  623. ? agentDef.config.model
  624. : undefined;
  625. const resolvedVariant =
  626. typeof entry?.variant === 'string'
  627. ? entry.variant
  628. : typeof agentDef.config.variant === 'string'
  629. ? agentDef.config.variant
  630. : undefined;
  631. tuiAgentModels[agentDef.name] = resolvedModel ?? 'default';
  632. if (resolvedVariant) {
  633. tuiAgentVariants[agentDef.name] = resolvedVariant;
  634. }
  635. }
  636. recordTuiAgentModels({
  637. agentModels: tuiAgentModels,
  638. agentVariants: tuiAgentVariants,
  639. });
  640. // Merge MCP configs
  641. const configMcp = opencodeConfig.mcp as
  642. | Record<string, unknown>
  643. | undefined;
  644. if (!configMcp) {
  645. opencodeConfig.mcp = { ...mcps };
  646. } else {
  647. Object.assign(configMcp, mcps);
  648. }
  649. // Get all MCP names from the merged config (built-in + custom)
  650. const mergedMcpConfig = opencodeConfig.mcp as
  651. | Record<string, unknown>
  652. | undefined;
  653. const allMcpNames = Object.keys(mergedMcpConfig ?? mcps);
  654. // For each agent, create permission rules based on their mcps list
  655. for (const [agentName, agentConfig] of Object.entries(agents)) {
  656. const agentMcps = (agentConfig as { mcps?: string[] })?.mcps;
  657. if (!agentMcps) continue;
  658. // Get or create agent permission config
  659. if (!configAgent[agentName]) {
  660. configAgent[agentName] = { ...agentConfig };
  661. }
  662. const agentConfigEntry = configAgent[agentName] as Record<
  663. string,
  664. unknown
  665. >;
  666. const agentPermission = (agentConfigEntry.permission ?? {}) as Record<
  667. string,
  668. unknown
  669. >;
  670. // Parse mcps list with wildcard and exclusion support
  671. const allowedMcps = parseList(agentMcps, allMcpNames);
  672. // Create permission rules for each MCP
  673. // MCP tools are named as <server>_<tool>, so we use <server>_*
  674. for (const mcpName of allMcpNames) {
  675. const sanitizedMcpName = mcpName.replace(/[^a-zA-Z0-9_-]/g, '_');
  676. const permissionKey = `${sanitizedMcpName}_*`;
  677. const action = allowedMcps.includes(mcpName) ? 'allow' : 'deny';
  678. // Only set if not already defined by user
  679. if (!(permissionKey in agentPermission)) {
  680. agentPermission[permissionKey] = action;
  681. }
  682. }
  683. // Update agent config with permissions
  684. agentConfigEntry.permission = agentPermission;
  685. }
  686. interviewManager.registerCommand(opencodeConfig);
  687. deepworkCommandHook.registerCommand(opencodeConfig);
  688. reflectCommandHook.registerCommand(opencodeConfig);
  689. loopCommandHook.registerCommand(opencodeConfig);
  690. presetManager.registerCommand(opencodeConfig);
  691. },
  692. event: async (input) => {
  693. const event = input.event as {
  694. type: string;
  695. properties?: {
  696. info?: {
  697. id?: string;
  698. parentID?: string;
  699. title?: string;
  700. agent?: string;
  701. providerID?: string;
  702. modelID?: string;
  703. model?: {
  704. providerID?: string;
  705. modelID?: string;
  706. };
  707. sessionID?: string;
  708. };
  709. sessionID?: string;
  710. id?: string;
  711. requestID?: string;
  712. status?: { type: string };
  713. };
  714. };
  715. if (event.type === 'message.updated') {
  716. const info = event.properties?.info;
  717. const providerID =
  718. typeof info?.providerID === 'string'
  719. ? info.providerID
  720. : typeof info?.model?.providerID === 'string'
  721. ? info.model.providerID
  722. : undefined;
  723. const modelID =
  724. typeof info?.modelID === 'string'
  725. ? info.modelID
  726. : typeof info?.model?.modelID === 'string'
  727. ? info.model.modelID
  728. : undefined;
  729. if (typeof info?.agent === 'string' && providerID && modelID) {
  730. const agentName = resolveRuntimeAgentName(config, info.agent);
  731. const model = `${providerID}/${modelID}`;
  732. const variant = resolveTuiVariantForModel(agentName, model);
  733. recordTuiAgentModel({
  734. agentName,
  735. model,
  736. variant: variant ?? null,
  737. });
  738. }
  739. }
  740. if (event.type === 'session.created') {
  741. const childSessionId = event.properties?.info?.id;
  742. const parentSessionId = event.properties?.info?.parentID;
  743. if (depthTracker && childSessionId && parentSessionId) {
  744. depthTracker.registerChild(parentSessionId, childSessionId);
  745. }
  746. }
  747. // Handle multiplexer pane spawning for OpenCode's Task tool sessions
  748. await multiplexerSessionManager.onSessionCreated(event);
  749. // Handle session status/idle events for pane cleanup early so child panes
  750. // close promptly even if later hooks do additional work on idle.
  751. await multiplexerSessionManager.onSessionStatus(event);
  752. // Handle session.deleted events for pane cleanup
  753. await multiplexerSessionManager.onSessionDeleted(event);
  754. // Runtime model fallback for foreground agents (rate-limit detection)
  755. await foregroundFallback.handleEvent(input.event);
  756. // Handle auto-update checking
  757. await autoUpdateChecker.event(input);
  758. await interviewManager.handleEvent(
  759. input as {
  760. event: { type: string; properties?: Record<string, unknown> };
  761. },
  762. );
  763. await taskSessionManagerHook.event(
  764. input as {
  765. event: {
  766. type: string;
  767. properties?: { info?: { id?: string }; sessionID?: string };
  768. };
  769. },
  770. );
  771. if (
  772. event.type === 'permission.asked' ||
  773. event.type === 'question.asked'
  774. ) {
  775. companionManager.onWaitingInput();
  776. }
  777. if (
  778. event.type === 'permission.replied' ||
  779. event.type === 'question.replied' ||
  780. event.type === 'question.rejected'
  781. ) {
  782. companionManager.onInputResolved();
  783. }
  784. if (input.event.type === 'session.status') {
  785. const props = input.event.properties as
  786. | { sessionID?: string; status?: { type?: string } }
  787. | undefined;
  788. const sessionID = props?.sessionID;
  789. companionManager.onSessionStatus({
  790. sessionId: sessionID,
  791. agent: sessionID ? sessionAgentMap.get(sessionID) : undefined,
  792. status: props?.status?.type,
  793. });
  794. }
  795. if (input.event.type === 'session.deleted') {
  796. const props = input.event.properties as
  797. | { info?: { id?: string }; sessionID?: string }
  798. | undefined;
  799. const sessionID = props?.info?.id ?? props?.sessionID;
  800. companionManager.onSessionDeleted(sessionID);
  801. }
  802. if (input.event.type === 'session.deleted') {
  803. const props = input.event.properties as
  804. | { info?: { id?: string }; sessionID?: string }
  805. | undefined;
  806. const sessionID = props?.info?.id ?? props?.sessionID;
  807. if (depthTracker && sessionID) {
  808. depthTracker.cleanup(sessionID);
  809. }
  810. if (sessionID) {
  811. sessionAgentMap.delete(sessionID);
  812. }
  813. }
  814. },
  815. // Best-effort rescue only for stale apply_patch input before native
  816. // execution
  817. 'tool.execute.before': async (input, output) => {
  818. await applyPatchHook['tool.execute.before'](
  819. input as {
  820. tool: string;
  821. directory?: string;
  822. },
  823. output as {
  824. args?: { patchText?: unknown; [key: string]: unknown };
  825. },
  826. );
  827. await taskSessionManagerHook['tool.execute.before'](
  828. input as {
  829. tool: string;
  830. sessionID?: string;
  831. callID?: string;
  832. },
  833. output as { args?: unknown },
  834. );
  835. // No-op for divoom
  836. },
  837. 'command.execute.before': async (input, output) => {
  838. await interviewManager.handleCommandExecuteBefore(
  839. input as {
  840. command: string;
  841. sessionID: string;
  842. arguments: string;
  843. },
  844. output as { parts: Array<{ type: string; text?: string }> },
  845. );
  846. await presetManager.handleCommandExecuteBefore(
  847. input as {
  848. command: string;
  849. sessionID: string;
  850. arguments: string;
  851. },
  852. output as { parts: Array<{ type: string; text?: string }> },
  853. );
  854. await deepworkCommandHook.handleCommandExecuteBefore(
  855. input as {
  856. command: string;
  857. sessionID: string;
  858. arguments: string;
  859. },
  860. output as { parts: Array<{ type: string; text?: string }> },
  861. );
  862. await reflectCommandHook.handleCommandExecuteBefore(
  863. input as {
  864. command: string;
  865. sessionID: string;
  866. arguments: string;
  867. },
  868. output as { parts: Array<{ type: string; text?: string }> },
  869. );
  870. await loopCommandHook.handleCommandExecuteBefore(
  871. input as {
  872. command: string;
  873. sessionID: string;
  874. arguments: string;
  875. },
  876. output as { parts: Array<{ type: string; text?: string }> },
  877. );
  878. },
  879. 'chat.headers': chatHeadersHook['chat.headers'],
  880. // Track which agent each session uses (needed for serve-mode prompt
  881. // injection)
  882. 'chat.message': async (
  883. input: { sessionID: string; agent?: string },
  884. output?: { message?: { agent?: string } },
  885. ) => {
  886. const rawAgent = input.agent ?? output?.message?.agent;
  887. const agent = rawAgent
  888. ? resolveRuntimeAgentName(config, rawAgent)
  889. : undefined;
  890. if (
  891. agent &&
  892. output?.message &&
  893. typeof output.message.agent === 'string'
  894. ) {
  895. output.message.agent = agent;
  896. }
  897. if (agent) {
  898. sessionAgentMap.set(input.sessionID, agent);
  899. // A chat message means this session is actively working. This also
  900. // covers the race where session.status busy fires before the
  901. // session's agent is known.
  902. companionManager.onSessionStatus({
  903. sessionId: input.sessionID,
  904. agent,
  905. status: 'busy',
  906. });
  907. }
  908. },
  909. // Inject orchestrator system prompt for serve-mode sessions. In serve
  910. // mode, the agent's prompt field may be absent from the agents
  911. // registry (built before plugin config hooks run). This hook injects
  912. // it at LLM call time. Uses the already-resolved prompt from
  913. // agentDefs (which has custom replacement or append prompts applied)
  914. // instead of rebuilding the default.
  915. 'experimental.chat.system.transform': async (
  916. input: { sessionID?: string },
  917. output: { system: string[] },
  918. ): Promise<void> => {
  919. const agentName = input.sessionID
  920. ? sessionAgentMap.get(input.sessionID)
  921. : undefined;
  922. if (agentName === 'orchestrator') {
  923. const alreadyInjected = output.system.some(
  924. (s) =>
  925. typeof s === 'string' &&
  926. s.includes('<Role>') &&
  927. s.includes('orchestrator'),
  928. );
  929. if (!alreadyInjected) {
  930. // Prepend the orchestrator prompt to the system array. Use the
  931. // resolved prompt from the orchestrator agent definition (which
  932. // includes any custom replacement or append from orchestrator.md
  933. // / orchestrator_append.md) Fall back to
  934. // buildOrchestratorPrompt only if the resolved prompt is
  935. // missing.
  936. const orchestratorDef = agentDefs.find(
  937. (a) => a.name === 'orchestrator',
  938. );
  939. const orchestratorPrompt =
  940. typeof orchestratorDef?.config?.prompt === 'string'
  941. ? orchestratorDef.config.prompt
  942. : buildOrchestratorPrompt(disabledAgents);
  943. output.system[0] =
  944. orchestratorPrompt +
  945. (output.system[0] ? `\n\n${output.system[0]}` : '');
  946. }
  947. }
  948. // Collapse to single system message for provider compatibility.
  949. // Some providers (e.g. Qwen via VLLM/DashScope) reject multiple
  950. // system messages. Sub-hooks above may push additional entries; join
  951. // them back into one element so OpenCode emits a single system
  952. // message.
  953. collapseSystemInPlace(output.system);
  954. },
  955. // Inject phase reminder and filter available skills before sending to
  956. // API (doesn't show in UI)
  957. 'experimental.chat.messages.transform': async (
  958. input: Record<string, never>,
  959. output: { messages: unknown[] },
  960. ): Promise<void> => {
  961. const typedOutput = output as { messages: MessageWithParts[] };
  962. for (const message of typedOutput.messages) {
  963. if (message.info.role !== 'user') {
  964. continue;
  965. }
  966. for (const part of message.parts) {
  967. if (part.type !== 'text' || typeof part.text !== 'string') {
  968. continue;
  969. }
  970. part.text = rewriteDisplayNameMentions(part.text);
  971. }
  972. }
  973. // Strip image parts from orchestrator messages when @observer is
  974. // available. When the orchestrator's model doesn't support image
  975. // input, the API call fails before the LLM can respond. We replace
  976. // image bytes with a text nudge so the orchestrator delegates to
  977. // @observer instead.
  978. processImageAttachments({
  979. messages: typedOutput.messages,
  980. workDir: ctx.directory,
  981. disabledAgents,
  982. log,
  983. });
  984. await taskSessionManagerHook['experimental.chat.messages.transform'](
  985. input,
  986. typedOutput,
  987. );
  988. await phaseReminderHook['experimental.chat.messages.transform'](
  989. input,
  990. typedOutput,
  991. );
  992. await filterAvailableSkillsHook['experimental.chat.messages.transform'](
  993. input,
  994. typedOutput,
  995. );
  996. },
  997. // Post-tool hooks: retry guidance for delegation errors + file-tool
  998. // nudge
  999. 'tool.execute.after': async (input, output) => {
  1000. const meta = input as {
  1001. tool?: string;
  1002. sessionID?: string;
  1003. callID?: string;
  1004. };
  1005. const runPostToolHook = async (
  1006. name: string,
  1007. fn: () => Promise<void>,
  1008. ): Promise<void> => {
  1009. try {
  1010. await fn();
  1011. } catch (error) {
  1012. log('[plugin] post-tool hook failed open', {
  1013. hook: name,
  1014. tool: meta.tool,
  1015. sessionID: meta.sessionID,
  1016. callID: meta.callID,
  1017. error: error instanceof Error ? error.message : String(error),
  1018. });
  1019. }
  1020. };
  1021. await runPostToolHook('delegate-task-retry', () =>
  1022. delegateTaskRetryHook['tool.execute.after'](
  1023. input as { tool: string },
  1024. output as { output: unknown },
  1025. ),
  1026. );
  1027. await runPostToolHook('json-error-recovery', () =>
  1028. jsonErrorRecoveryHook['tool.execute.after'](
  1029. input as {
  1030. tool: string;
  1031. sessionID: string;
  1032. callID: string;
  1033. },
  1034. output as {
  1035. title: string;
  1036. output: unknown;
  1037. metadata: unknown;
  1038. },
  1039. ),
  1040. );
  1041. await runPostToolHook('post-file-tool-nudge', () =>
  1042. postFileToolNudgeHook['tool.execute.after'](
  1043. input as {
  1044. tool: string;
  1045. sessionID?: string;
  1046. callID?: string;
  1047. },
  1048. output as {
  1049. title: string;
  1050. output: string;
  1051. metadata: Record<string, unknown>;
  1052. },
  1053. ),
  1054. );
  1055. await runPostToolHook('task-session-manager', () =>
  1056. taskSessionManagerHook['tool.execute.after'](
  1057. input as {
  1058. tool: string;
  1059. sessionID?: string;
  1060. callID?: string;
  1061. },
  1062. output as { output: unknown },
  1063. ),
  1064. );
  1065. },
  1066. };
  1067. };
  1068. export default OhMyOpenCodeLite;
  1069. export type {
  1070. AgentName,
  1071. AgentOverrideConfig,
  1072. McpName,
  1073. MultiplexerConfig,
  1074. MultiplexerLayout,
  1075. MultiplexerType,
  1076. PluginConfig,
  1077. TmuxConfig,
  1078. TmuxLayout,
  1079. } from './config';
  1080. export type { RemoteMcpConfig } from './mcp';