index.ts 46 KB

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