index.ts 67 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767
  1. import type { Plugin, ToolDefinition } from '@opencode-ai/plugin';
  2. import {
  3. type AdmissionRuntimeLease,
  4. acquireAdmissionRuntime,
  5. } from './admission-runtime';
  6. import {
  7. applyModelInheritanceToConfig,
  8. createAgents,
  9. ensureCouncilCompactionException,
  10. getAgentConfigs,
  11. isSubagent,
  12. resolvePrimaryModelValue,
  13. } from './agents';
  14. import { buildOrchestratorPrompt } from './agents/orchestrator';
  15. import { CompanionManager } from './companion/manager';
  16. import { ensureCompanionVersion } from './companion/updater';
  17. import { deepMerge, loadPluginConfig, type MultiplexerConfig } from './config';
  18. import { parseList } from './config/agent-mcps';
  19. import {
  20. AGENT_ALIASES,
  21. DEFAULT_MAX_SESSION_METADATA_ENTRIES,
  22. TOAST_DURATION_MS,
  23. } from './config/constants';
  24. import { RuntimeConfig } from './config/runtime';
  25. import { applyOrchestratorModelConfig } from './config/strip-orchestrator-model';
  26. import { HEALTH_CHECK, minimumExpectedToolCount } from './health-check';
  27. import {
  28. createAbsolutePathRescueHook,
  29. createApplyPatchHook,
  30. createAutoUpdateCheckerHook,
  31. createCacheMonitorHook,
  32. createChatHeadersHook,
  33. createDeepworkCommandHook,
  34. createFilterAvailableSkillsHook,
  35. createJsonErrorRecoveryHook,
  36. createLoopCommandHook,
  37. createOrchestratorWakeScheduler,
  38. createPhaseReminderHook,
  39. createPostFileToolNudgeHook,
  40. createReflectCommandHook,
  41. createSearchPathGuardHook,
  42. createTaskSessionManagerHook,
  43. createToolLoopGuardHook,
  44. ForegroundFallbackManager,
  45. formatStoppedJobDelta,
  46. SessionLifecycle,
  47. } from './hooks';
  48. import { processImageAttachments } from './hooks/image-hook';
  49. import { createRevivedRunTracker } from './hooks/task-session-manager/revived-run-tracker';
  50. import type { ToolLoopGuardHook } from './hooks/tool-loop-guard/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. createTaskMessageTool,
  66. createTaskResultTool,
  67. createTaskReviveTool,
  68. createTaskStatusTool,
  69. createWaitForUserTool,
  70. createWebfetchTool,
  71. } from './tools';
  72. import { pickAgentModelRef } from './tools/smartfetch/secondary-model';
  73. import {
  74. applyActivityEvent,
  75. resolveEventSessionID,
  76. TaskActivityTracker,
  77. } from './tools/task-activity';
  78. import {
  79. clearTuiAgentActivities,
  80. readTuiSnapshot,
  81. recordTuiAgentActivity,
  82. recordTuiAgentModel,
  83. recordTuiAgentModels,
  84. recordTuiSessionParent,
  85. } from './tui-state';
  86. import {
  87. BackgroundJobBoard,
  88. BackgroundJobCoordinator,
  89. BackgroundJobSupervisor,
  90. type BackgroundTaskConcurrency,
  91. createDisplayNameMentionRewriter,
  92. normalizeAgentName,
  93. resolveRuntimeAgentName,
  94. } from './utils';
  95. import type { ContextFile } from './utils/background-job-board';
  96. import { isPluginDisabledByEnv } from './utils/env';
  97. import { probeJSDOM } from './utils/jsdom';
  98. import { initLogger, log } from './utils/logger';
  99. import { SessionMetadataStore } from './utils/session-metadata';
  100. import { collapseSystemInPlace } from './utils/system-collapse';
  101. import { createV2Setup } from './v2';
  102. /**
  103. * Best-effort log to opencode's app logger.
  104. * Wrapped in try/catch to avoid deadlocking on opencode v1.4.8–v1.4.9
  105. * where client.app.log() during init triggers a middleware cycle.
  106. */
  107. async function appLog(
  108. ctx: Parameters<Plugin>[0],
  109. level: 'error' | 'warn' | 'info',
  110. message: string,
  111. ): Promise<void> {
  112. try {
  113. await ctx.client.app.log({
  114. body: { service: 'oh-my-opencode-slim', level, message },
  115. });
  116. } catch {
  117. // client.app.log may deadlock or be unavailable; stderr is the
  118. // fallback
  119. const prefix =
  120. level === 'error' ? 'ERROR' : level === 'warn' ? 'WARN' : 'INFO';
  121. console.error(`[oh-my-opencode-slim] ${prefix}: ${message}`);
  122. }
  123. }
  124. // Debounce: only show image-skipped toast once per 60 seconds per project
  125. const lastImageSkippedToastByDir = new Map<string, number>();
  126. const IMAGE_SKIPPED_DEBOUNCE_MS = 60_000;
  127. // Module-level runtime preset tracking. Survives plugin re-inits triggered
  128. // by client.config.update() → Instance.dispose(). When the plugin function
  129. // re-runs, it checks this variable and applies the runtime preset instead
  130. // of the config file's preset. State lives in RuntimeConfig.
  131. /**
  132. * Decide whether multiplexer pane management initializes for a plugin
  133. * input. v1 hosts (hostFlavor absent) keep the exact env-based
  134. * conditions — configured type, resolvable multiplexer, inside-session
  135. * env marker; v2 hosts, marked `hostFlavor: 'v2'` by the v2 client shim,
  136. * are gated off before any multiplexer initialization runs.
  137. */
  138. export function shouldEnableMultiplexer(input: {
  139. hostFlavor?: string;
  140. multiplexerConfig: MultiplexerConfig;
  141. }): boolean {
  142. if ((input as { hostFlavor?: string }).hostFlavor === 'v2') {
  143. log('[v2] multiplexer disabled on v2 host');
  144. return false;
  145. }
  146. // Get multiplexer instance for capability checks (v1 path, unchanged)
  147. const multiplexer = getMultiplexer(input.multiplexerConfig);
  148. return (
  149. input.multiplexerConfig.type !== 'none' &&
  150. multiplexer !== null &&
  151. multiplexer.isInsideSession()
  152. );
  153. }
  154. /**
  155. * Config handed to MultiplexerSessionManager. The manager is created
  156. * unconditionally (its lifecycle hooks are wired into the job coordinator),
  157. * but it self-gates only on env (inside tmux/zellij) — which would
  158. * incorrectly self-enable on a v2 host running inside tmux. v2 hosts are
  159. * therefore forced to type 'none', which disables every manager path (all
  160. * its public methods no-op when `enabled` is false). v1 hosts receive the
  161. * real config object unchanged.
  162. */
  163. export function sessionManagerMultiplexerConfig(
  164. hostFlavor: string | undefined,
  165. config: MultiplexerConfig,
  166. ): MultiplexerConfig {
  167. return hostFlavor === 'v2' ? { ...config, type: 'none' } : config;
  168. }
  169. export const OhMyOpenCodeLite: Plugin = async (ctx) => {
  170. const sessionId = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
  171. initLogger(sessionId);
  172. if (isPluginDisabledByEnv()) {
  173. log('[plugin] disabled by OH_MY_OPENCODE_SLIM_DISABLE');
  174. return {};
  175. }
  176. // Observation-only prompt-cache watchdog; safe to create before config
  177. // loads and must see every event, so it sits outside the try block.
  178. const cacheMonitor = createCacheMonitorHook();
  179. // Declare variables that must survive the try/catch for the return
  180. // closure. These are set inside the try block.
  181. let config: ReturnType<typeof loadPluginConfig>;
  182. let runtime: RuntimeConfig;
  183. let agentDefs: ReturnType<typeof createAgents>;
  184. let agents: ReturnType<typeof getAgentConfigs>;
  185. let mcps: ReturnType<typeof createBuiltinMcps>;
  186. let multiplexerConfig: MultiplexerConfig;
  187. let multiplexerEnabled: boolean;
  188. // Host flavor ('v2' on OpenCode v2 hosts via the client shim, undefined on
  189. // v1). Survives the try block so prompt-assembly hooks can use it.
  190. let hostFlavor: string | undefined;
  191. let multiplexerSessionManager: MultiplexerSessionManager;
  192. let autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook>;
  193. const sessionMetadata = new SessionMetadataStore({
  194. maxEntries: DEFAULT_MAX_SESSION_METADATA_ENTRIES,
  195. onEvict: (sessionID) => {
  196. log('[session] evicted oldest session metadata', {
  197. threshold: DEFAULT_MAX_SESSION_METADATA_ENTRIES,
  198. droppedSessionId: sessionID,
  199. });
  200. },
  201. });
  202. const ownedTuiActivitySessions = new Map<string, string>();
  203. // Busy/retry arrived before the session's agent was known. chat.message
  204. // latches the agent and flushes these so the spinner still starts.
  205. const pendingTuiBusySessions = new Set<string>();
  206. const tuiActivityDirectory = (sessionID: string): string => {
  207. return sessionMetadata.getDirectory(sessionID) ?? ctx.directory;
  208. };
  209. // Sidebar activity scoping (#1147): every active session and the visible
  210. // route session resolve their conversation root against the persistent
  211. // sessionParents index at render time. The recorder only persists the
  212. // child→parent links; roots are never stored per-activity, so a
  213. // late-learned link re-roots everything consistently. Process identity
  214. // cannot scope this because v2 daemons are shared across windows.
  215. const markTuiAgentActive = (sessionID: string, agentName: string): void => {
  216. const directory = tuiActivityDirectory(sessionID);
  217. recordTuiAgentActivity({ sessionID, agentName, active: true }, directory);
  218. ownedTuiActivitySessions.set(sessionID, directory);
  219. void hydrateTuiSessionParent(sessionID, directory);
  220. };
  221. // Sessions that predate this fix or whose session.created event was
  222. // missed have no link in the persistent index. Ask the host once per
  223. // session and walk up to a confirmed root; absence of parentID on a
  224. // valid response is a final answer (top-level chat).
  225. const hydratedTuiParents = new Set<string>();
  226. const hydrateTuiSessionParent = async (
  227. startSessionID: string,
  228. directory: string,
  229. ): Promise<void> => {
  230. const sessionApi = (ctx as { client?: { session?: { get?: unknown } } })
  231. .client?.session;
  232. if (typeof sessionApi?.get !== 'function') return;
  233. const lookup = sessionApi.get as (input: {
  234. path: { id: string };
  235. query: { directory: string };
  236. }) => Promise<{ data?: unknown; error?: unknown; parentID?: unknown }>;
  237. const visited = new Set<string>();
  238. let current = startSessionID;
  239. while (!visited.has(current)) {
  240. visited.add(current);
  241. const snapshot = readTuiSnapshot(directory);
  242. const known = snapshot.sessionParents[current];
  243. if (known !== undefined) {
  244. current = known; // Persisted link; keep walking toward the root.
  245. continue;
  246. }
  247. if (hydratedTuiParents.has(current)) return;
  248. hydratedTuiParents.add(current);
  249. let parentID: unknown;
  250. try {
  251. // Call with the session object as receiver: the SDK's generated
  252. // method reads `this._client` (#595 class of regression).
  253. const response = await lookup.call(sessionApi, {
  254. path: { id: current },
  255. query: { directory },
  256. });
  257. if (response?.error !== undefined) {
  258. // HTTP error resolved instead of thrown: release the slot so a
  259. // later activity can retry.
  260. hydratedTuiParents.delete(current);
  261. return;
  262. }
  263. const info = response?.data;
  264. if (info === null || typeof info !== 'object') {
  265. // Malformed response outside the host contract: release the
  266. // slot rather than caching "confirmed root" on garbage.
  267. hydratedTuiParents.delete(current);
  268. return;
  269. }
  270. parentID = (info as { parentID?: unknown }).parentID;
  271. } catch {
  272. hydratedTuiParents.delete(current);
  273. return;
  274. }
  275. if (typeof parentID === 'string' && parentID !== current) {
  276. recordTuiSessionParent(current, parentID, directory);
  277. current = parentID;
  278. continue;
  279. }
  280. if (parentID !== undefined && parentID !== null) {
  281. // Malformed non-string parent: release the slot so a later
  282. // activity can retry instead of caching a false confirmed root.
  283. hydratedTuiParents.delete(current);
  284. }
  285. // Valid response without a parent: confirmed root, stop.
  286. return;
  287. }
  288. };
  289. const markTuiAgentInactive = (sessionID: string): void => {
  290. const directory =
  291. ownedTuiActivitySessions.get(sessionID) ??
  292. tuiActivityDirectory(sessionID);
  293. recordTuiAgentActivity({ sessionID, active: false }, directory);
  294. ownedTuiActivitySessions.delete(sessionID);
  295. };
  296. const clearTuiActivities = (): void => {
  297. for (const [sessionID, directory] of ownedTuiActivitySessions) {
  298. recordTuiAgentActivity({ sessionID, active: false }, directory);
  299. }
  300. ownedTuiActivitySessions.clear();
  301. };
  302. clearTuiAgentActivities(ctx.directory);
  303. let sessionLifecycle: SessionLifecycle;
  304. let chatHeadersHook: ReturnType<typeof createChatHeadersHook>;
  305. let foregroundFallback: ForegroundFallbackManager;
  306. let deepworkCommandHook: ReturnType<typeof createDeepworkCommandHook>;
  307. let reflectCommandHook: ReturnType<typeof createReflectCommandHook>;
  308. let loopCommandHook: ReturnType<typeof createLoopCommandHook>;
  309. let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
  310. let orchestratorWakeScheduler: ReturnType<
  311. typeof createOrchestratorWakeScheduler
  312. >;
  313. let phaseReminder: ReturnType<typeof createPhaseReminderHook>;
  314. let filterAvailableSkills: ReturnType<typeof createFilterAvailableSkillsHook>;
  315. let postFileToolNudge: ReturnType<typeof createPostFileToolNudgeHook>;
  316. let applyPatch: ReturnType<typeof createApplyPatchHook>;
  317. let searchPathGuard: ReturnType<typeof createSearchPathGuardHook>;
  318. let absolutePathRescue: ReturnType<typeof createAbsolutePathRescueHook>;
  319. let jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook>;
  320. let toolLoopGuard: ToolLoopGuardHook;
  321. let postFileToolNudgeAfter: (i: unknown, o: unknown) => Promise<void>;
  322. let jsonErrorRecoveryAfter: (i: unknown, o: unknown) => Promise<void>;
  323. let taskSessionManagerAfter: (i: unknown, o: unknown) => Promise<void>;
  324. let backgroundJobBoard: BackgroundJobBoard;
  325. let backgroundJobSupervisor: BackgroundJobSupervisor;
  326. let backgroundTaskConcurrency: BackgroundTaskConcurrency;
  327. let admissionRuntimeLease: AdmissionRuntimeLease | undefined;
  328. let finalHostAgentConfig: Record<string, unknown> | undefined;
  329. let interviewManager: ReturnType<typeof createInterviewManager>;
  330. let companionManager: CompanionManager;
  331. let taskCancelTools: ReturnType<typeof createCancelTaskTool>;
  332. let taskMessageTools: ReturnType<typeof createTaskMessageTool>;
  333. let taskResultTools: ReturnType<typeof createTaskResultTool>;
  334. let taskReviveTools: ReturnType<typeof createTaskReviveTool>;
  335. let revivedRunTracker: ReturnType<typeof createRevivedRunTracker>;
  336. let markRevivedRunPending: (taskID: string) => void = () => {};
  337. let markRevivedRunSettled: (taskID: string) => void = () => {};
  338. let getRevivedContextFiles = (_taskID: string): ContextFile[] => [];
  339. let pruneRevivedContext = () => {};
  340. let taskStatusTools: ReturnType<typeof createTaskStatusTool>;
  341. const taskActivityTracker = new TaskActivityTracker();
  342. let waitForUserTools: ReturnType<typeof createWaitForUserTool>;
  343. let acpRunTools: Record<string, ReturnType<typeof createAcpRunTool>>;
  344. let webfetch: ReturnType<typeof createWebfetchTool>;
  345. let tools: Record<string, ToolDefinition>;
  346. let rewriteDisplayNameMentions: ReturnType<
  347. typeof createDisplayNameMentionRewriter
  348. >;
  349. // Counters for post-init health check (set inside try, checked outside)
  350. let toolCount = 0;
  351. const resolvePrimaryModelFromFinalHostConfig = (
  352. agentType: string,
  353. ): string | undefined => {
  354. const readModel = (entry: unknown): string | undefined => {
  355. if (entry === null || typeof entry !== 'object') return undefined;
  356. return resolvePrimaryModelValue((entry as Record<string, unknown>).model);
  357. };
  358. const directModel = readModel(finalHostAgentConfig?.[agentType]);
  359. if (directModel) return directModel;
  360. const resolvedName = resolveRuntimeAgentName(runtime, agentType);
  361. return readModel(finalHostAgentConfig?.[resolvedName]);
  362. };
  363. try {
  364. config = loadPluginConfig(ctx.directory);
  365. // Seed the per-directory runtime registry with the raw plugin file
  366. // config. The runtime preset reapplication below mutates `config` for
  367. // legacy consumers; RuntimeConfig keeps the pre-mutation snapshot and
  368. // derives preset/runtime state through its own getters.
  369. RuntimeConfig.init(ctx.directory, config);
  370. // Safety net: instance disposal reruns the plugin factory and rebuilds
  371. // factory-local state, while module-level runtime preset state may persist.
  372. // Reapply that persisted preset so each fresh generation creates agents
  373. // with the correct models.
  374. const runtimePreset = RuntimeConfig.get(ctx.directory).getRuntimePreset();
  375. if (runtimePreset && config.presets?.[runtimePreset]) {
  376. config.preset = runtimePreset;
  377. // Re-merge runtime preset into config.agents (loadPluginConfig
  378. // already merged the config-file preset, not the runtime one).
  379. // Runtime preset is override so it wins over config-file preset.
  380. const presetAgents = config.presets[runtimePreset];
  381. config.agents = deepMerge(config.agents, presetAgents);
  382. } else if (runtimePreset) {
  383. // Preset was deleted from config since last switch - clear stale state
  384. RuntimeConfig.get(ctx.directory).setRuntimePreset(null);
  385. }
  386. runtime = RuntimeConfig.get(ctx.directory);
  387. rewriteDisplayNameMentions = createDisplayNameMentionRewriter(runtime);
  388. // Host flavor marker ('v2' on OpenCode v2 hosts, set by the v2 client
  389. // shim; absent on v1). Threads the native delegation vocabulary into
  390. // prompt assembly so v2 prompts say subagent(...)/agent directly.
  391. hostFlavor = (ctx as Parameters<Plugin>[0] & { hostFlavor?: string })
  392. .hostFlavor;
  393. agentDefs = createAgents(runtime, {
  394. projectDirectory: ctx.directory,
  395. hostFlavor,
  396. });
  397. agents = getAgentConfigs(runtime, {
  398. projectDirectory: ctx.directory,
  399. hostFlavor,
  400. });
  401. // Parse multiplexer config with defaults
  402. multiplexerConfig = runtime.multiplexer;
  403. multiplexerEnabled = shouldEnableMultiplexer({
  404. hostFlavor,
  405. multiplexerConfig,
  406. });
  407. log('[plugin] initialized with multiplexer config', {
  408. multiplexerConfig,
  409. enabled: multiplexerEnabled,
  410. directory: ctx.directory,
  411. });
  412. // Start background availability check if enabled
  413. if (multiplexerEnabled) {
  414. startAvailabilityCheck(multiplexerConfig);
  415. }
  416. mcps = createBuiltinMcps(runtime.disabledMcps);
  417. acpRunTools =
  418. Object.keys(runtime.acpAgents ?? {}).length > 0
  419. ? { acp_run: createAcpRunTool(runtime.acpAgents) }
  420. : {};
  421. const webfetchModel = runtime.webfetch?.model;
  422. const webfetchModels = (() => {
  423. if (!webfetchModel) return undefined;
  424. const entries = Array.isArray(webfetchModel)
  425. ? webfetchModel
  426. : [webfetchModel];
  427. type ModelRefInput = string | { id: string; variant?: string };
  428. const models: Array<{ id: string; variant?: string }> = [];
  429. for (const entry of entries as ModelRefInput[]) {
  430. const id = typeof entry === 'string' ? entry : entry.id;
  431. if (!id) continue;
  432. models.push({
  433. id,
  434. ...(typeof entry === 'object' && entry.variant
  435. ? { variant: entry.variant }
  436. : {}),
  437. });
  438. }
  439. return models.length > 0 ? models : undefined;
  440. })();
  441. webfetch = createWebfetchTool(ctx, {
  442. binaryDir: undefined,
  443. webfetchModels,
  444. explorerModel: pickAgentModelRef(runtime.agent('explorer')?.model),
  445. librarianModel: pickAgentModelRef(runtime.agent('librarian')?.model),
  446. smallModelRef: () => runtime.smallModel(),
  447. });
  448. backgroundJobBoard = new BackgroundJobBoard({
  449. maxReusablePerAgent: runtime.backgroundJobs.maxSessionsPerAgent,
  450. maxContextLines: runtime.backgroundJobs.maxContextLines,
  451. readContextMinLines: runtime.backgroundJobs.readContextMinLines,
  452. readContextMaxFiles: runtime.backgroundJobs.readContextMaxFiles,
  453. });
  454. admissionRuntimeLease = acquireAdmissionRuntime(
  455. ctx.directory,
  456. runtime.backgroundJobs.concurrency,
  457. );
  458. backgroundTaskConcurrency = admissionRuntimeLease.backgroundTaskConcurrency;
  459. // Initialize coordinator as the sole writer to the board
  460. const backgroundJobCoordinator = new BackgroundJobCoordinator(
  461. backgroundJobBoard,
  462. );
  463. backgroundJobSupervisor = new BackgroundJobSupervisor({
  464. backgroundJobStore: backgroundJobCoordinator,
  465. wallClockTimeoutMs: runtime.backgroundJobs.wallClockTimeoutMs,
  466. abortGraceMs: runtime.backgroundJobs.abortGraceMs,
  467. abort: (taskID) =>
  468. ctx.client.session.abort({
  469. path: { id: taskID },
  470. }),
  471. });
  472. backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
  473. backgroundJobSupervisor.onTerminal(record);
  474. backgroundTaskConcurrency.releaseTask(record.taskID);
  475. });
  476. revivedRunTracker = createRevivedRunTracker({
  477. input: ctx,
  478. backgroundJobBoard: backgroundJobCoordinator,
  479. backgroundJobSupervisor,
  480. onRegister: (taskID) => markRevivedRunPending(taskID),
  481. onSettled: (taskID) => markRevivedRunSettled(taskID),
  482. contextFilesForPrompt: (taskID) => getRevivedContextFiles(taskID),
  483. pruneContext: () => pruneRevivedContext(),
  484. });
  485. backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
  486. revivedRunTracker.onTerminal(record);
  487. });
  488. // Initialize MultiplexerSessionManager to handle OpenCode's built-in
  489. // Task tool sessions. On v2 hosts the multiplexer is host-gated off
  490. // (shouldEnableMultiplexer), so the manager's config is forced to
  491. // 'none' — otherwise its env-based self-gate could re-enable pane
  492. // management inside tmux/zellij on a v2 host.
  493. multiplexerSessionManager = new MultiplexerSessionManager(
  494. ctx,
  495. sessionManagerMultiplexerConfig(hostFlavor, multiplexerConfig),
  496. backgroundJobCoordinator,
  497. );
  498. backgroundJobCoordinator.addTerminalStateListener((taskID) => {
  499. void multiplexerSessionManager.closeSessionFromCoordinator(taskID);
  500. });
  501. backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
  502. if (record.deadlineExceededAt === undefined) return;
  503. void multiplexerSessionManager.closeSessionPermanentlyFromCoordinator(
  504. record.taskID,
  505. );
  506. });
  507. sessionLifecycle = new SessionLifecycle(log);
  508. // Initialize auto-update checker hook
  509. autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
  510. autoUpdate: runtime.autoUpdate,
  511. companion: runtime.companion,
  512. });
  513. chatHeadersHook = createChatHeadersHook(ctx);
  514. // Initialize foreground fallback manager for runtime model switching.
  515. // Agents without a chain (e.g. councillor, owned by CouncilManager) are
  516. // left alone — FG only aborts/re-prompts when it has a model to switch to.
  517. foregroundFallback = new ForegroundFallbackManager(
  518. runtime.runtimeChains,
  519. runtime.fallback.enabled !== false,
  520. ctx,
  521. runtime.fallback.maxRetries,
  522. sessionLifecycle,
  523. // A managed background-task session switching models mid-flight must
  524. // move its admission accounting (provider/model caps) to the new
  525. // model. No-op for unknown/non-task sessions; idempotent per model.
  526. (sessionID, model) =>
  527. backgroundTaskConcurrency.migrateTask(sessionID, model),
  528. runtime.fallback.initialRetryDelayMs,
  529. runtime.fallback.retryDelayMs,
  530. );
  531. deepworkCommandHook = createDeepworkCommandHook();
  532. reflectCommandHook = createReflectCommandHook();
  533. loopCommandHook = createLoopCommandHook();
  534. taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
  535. strategy: runtime.backgroundJobs.strategy,
  536. maxSessionsPerAgent: runtime.backgroundJobs.maxSessionsPerAgent,
  537. maxRetainedSnapshots: runtime.backgroundJobs.maxRetainedSnapshots,
  538. readContextMinLines: runtime.backgroundJobs.readContextMinLines,
  539. readContextMaxFiles: runtime.backgroundJobs.readContextMaxFiles,
  540. backgroundJobBoard: backgroundJobCoordinator,
  541. backgroundJobSupervisor,
  542. backgroundTaskConcurrency,
  543. pendingCallTracker: admissionRuntimeLease.pendingCallTracker,
  544. getModelForAgent: (agentType: string, parentSessionID?: string) =>
  545. // Admission must use the config after the host has merged all of its
  546. // agent layers. The direct lookup preserves display-name keys; the
  547. // resolved lookup handles canonical names and legacy aliases. A
  548. // parent model is only an inheritance fallback when neither final
  549. // agent entry carries one.
  550. resolvePrimaryModelFromFinalHostConfig(agentType) ??
  551. (parentSessionID
  552. ? sessionMetadata.getModel(parentSessionID)
  553. : undefined),
  554. sameProviderPolicy: runtime.backgroundJobs.sameProviderPolicy,
  555. getSessionModel: (sessionID) => sessionMetadata.getModel(sessionID),
  556. shouldManageSession: (sessionID) =>
  557. sessionMetadata.getAgent(sessionID) === 'orchestrator',
  558. registerSessionAsOrchestrator: (sessionID) => {
  559. sessionMetadata.setAgent(sessionID, 'orchestrator');
  560. },
  561. isFallbackInProgress: (sessionID) =>
  562. foregroundFallback.isFallbackInProgress(sessionID),
  563. willAttemptFallback: (sessionID) =>
  564. foregroundFallback.willAttemptFallback(sessionID),
  565. coordinator: sessionLifecycle,
  566. revivedRunTracker,
  567. });
  568. markRevivedRunPending = taskSessionManagerHook.markRevivedRunPending;
  569. markRevivedRunSettled = taskSessionManagerHook.clearRevivedRunPending;
  570. getRevivedContextFiles = taskSessionManagerHook.contextFilesForTask;
  571. pruneRevivedContext = taskSessionManagerHook.pruneTaskContext;
  572. orchestratorWakeScheduler = createOrchestratorWakeScheduler(ctx, {
  573. config: runtime.backgroundJobs.orchestratorWake,
  574. shouldManageSession: (sessionID) =>
  575. sessionMetadata.getAgent(sessionID) === 'orchestrator',
  576. hasInputWait: (sessionID) =>
  577. taskSessionManagerHook.hasInputWait(sessionID),
  578. isFallbackInProgress: (sessionID) =>
  579. foregroundFallback.isFallbackInProgress(sessionID),
  580. isStoppedJobRecoveryCurrent: (taskID, generation) => {
  581. const record = backgroundJobCoordinator.get(taskID);
  582. return (
  583. record?.generation === generation &&
  584. record.state === 'stopped' &&
  585. record.terminalUnreconciled
  586. );
  587. },
  588. coordinator: sessionLifecycle,
  589. });
  590. backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
  591. if (record.state !== 'stopped' || !record.terminalUnreconciled) return;
  592. orchestratorWakeScheduler.triggerStoppedJobRecovery(
  593. record.parentSessionID,
  594. // Self-contained stop facts: the recovery wake is an
  595. // internal-initiator message, so under `checkpoint-compatible` it
  596. // cannot create a board snapshot and any retained snapshot predates
  597. // this stop (issue #1051).
  598. formatStoppedJobDelta({
  599. alias: record.alias,
  600. taskID: record.taskID,
  601. generation: record.generation,
  602. state: record.state,
  603. reason: record.timedOut
  604. ? 'wall-clock deadline exceeded'
  605. : record.statusUncertain
  606. ? 'runtime status uncertain'
  607. : 'stopped without a terminal result',
  608. }),
  609. `${record.taskID}:${record.generation}`,
  610. );
  611. });
  612. // Initialize hooks and wrapPostToolHook helper for error isolation
  613. // Wrap tool.execute.after handlers with per-hook error isolation.
  614. // Preserves the old runPostToolHook behavior: one failing hook doesn't
  615. // block the rest.
  616. const wrapPostToolHook = (
  617. name: string,
  618. fn: (i: unknown, o: unknown) => Promise<void>,
  619. ): ((i: unknown, o: unknown) => Promise<void>) => {
  620. return async (i, o) => {
  621. try {
  622. await fn(i, o);
  623. } catch (error) {
  624. const meta = i as {
  625. tool?: string;
  626. sessionID?: string;
  627. callID?: string;
  628. };
  629. log('[plugin] post-tool hook failed open', {
  630. hook: name,
  631. tool: meta.tool,
  632. sessionID: meta.sessionID,
  633. callID: meta.callID,
  634. error: error instanceof Error ? error.message : String(error),
  635. });
  636. }
  637. };
  638. };
  639. // Both message transforms share this gate so a rejected nudge cannot be
  640. // followed by a phase reminder in the same outgoing turn.
  641. const shouldInjectOrchestratorReminder = (sessionID: string) =>
  642. sessionMetadata.getAgent(sessionID) === 'orchestrator';
  643. phaseReminder = createPhaseReminderHook({
  644. shouldInject: shouldInjectOrchestratorReminder,
  645. });
  646. filterAvailableSkills = createFilterAvailableSkillsHook(ctx, runtime);
  647. postFileToolNudge = createPostFileToolNudgeHook({
  648. shouldInject: shouldInjectOrchestratorReminder,
  649. coordinator: sessionLifecycle,
  650. });
  651. applyPatch = createApplyPatchHook(ctx);
  652. searchPathGuard = createSearchPathGuardHook(ctx);
  653. absolutePathRescue = createAbsolutePathRescueHook(ctx);
  654. jsonErrorRecovery = createJsonErrorRecoveryHook(ctx);
  655. toolLoopGuard = createToolLoopGuardHook();
  656. // Pre-created wrapped handlers for tool.execute.after (error-isolated)
  657. postFileToolNudgeAfter = wrapPostToolHook('post-file-tool-nudge', (i, o) =>
  658. postFileToolNudge['tool.execute.after'](i as never, o as never),
  659. );
  660. jsonErrorRecoveryAfter = wrapPostToolHook('json-error-recovery', (i, o) =>
  661. jsonErrorRecovery['tool.execute.after'](i as never, o as never),
  662. );
  663. taskSessionManagerAfter = wrapPostToolHook('task-session-manager', (i, o) =>
  664. taskSessionManagerHook['tool.execute.after'](i as never, o as never),
  665. );
  666. interviewManager = createInterviewManager(ctx, config);
  667. companionManager = new CompanionManager(
  668. `proc_${process.pid}`,
  669. ctx.directory,
  670. runtime.companion,
  671. );
  672. taskCancelTools = createCancelTaskTool({
  673. input: ctx,
  674. backgroundJobBoard: backgroundJobCoordinator,
  675. shouldManageSession: (sessionID) =>
  676. sessionMetadata.getAgent(sessionID) === 'orchestrator',
  677. });
  678. taskMessageTools = createTaskMessageTool({
  679. input: ctx,
  680. backgroundJobBoard: backgroundJobCoordinator,
  681. });
  682. taskResultTools = createTaskResultTool({
  683. input: ctx,
  684. backgroundJobBoard: backgroundJobCoordinator,
  685. });
  686. taskReviveTools = createTaskReviveTool({
  687. input: ctx,
  688. backgroundJobBoard: backgroundJobCoordinator,
  689. shouldManageSession: (sessionID) =>
  690. sessionMetadata.getAgent(sessionID) === 'orchestrator',
  691. backgroundJobSupervisor,
  692. revivedRunTracker,
  693. });
  694. taskStatusTools = createTaskStatusTool({
  695. input: ctx,
  696. backgroundJobBoard: backgroundJobCoordinator,
  697. activityTracker: taskActivityTracker,
  698. });
  699. waitForUserTools = createWaitForUserTool({
  700. shouldManageSession: (sessionID) =>
  701. sessionMetadata.getAgent(sessionID) === 'orchestrator',
  702. resolveAgentName: (agent) => resolveRuntimeAgentName(runtime, agent),
  703. registerSessionAsOrchestrator: (sessionID) => {
  704. sessionMetadata.setAgent(sessionID, 'orchestrator');
  705. },
  706. beginUserWait: (sessionID) => {
  707. taskSessionManagerHook.beginUserWait(sessionID);
  708. orchestratorWakeScheduler.suppress(sessionID);
  709. },
  710. waitForUserGuardEnabled: runtime.backgroundJobs.waitForUserGuard,
  711. hasOutstandingBackgroundTasks: (sessionID) =>
  712. runtime.backgroundJobs.orchestratorWake.enabled &&
  713. backgroundJobCoordinator.hasRunning(sessionID),
  714. });
  715. const shouldRegisterWebfetch = runtime.webfetch.enabled !== false;
  716. tools = {
  717. ...taskCancelTools,
  718. ...taskMessageTools,
  719. ...taskResultTools,
  720. ...taskReviveTools,
  721. ...taskStatusTools,
  722. ...waitForUserTools,
  723. ...acpRunTools,
  724. ...(shouldRegisterWebfetch ? { webfetch } : {}),
  725. ast_grep_search,
  726. ast_grep_replace,
  727. };
  728. if (runtime.disabledTools.length > 0) {
  729. const disabledTools = new Set(runtime.disabledTools);
  730. tools = Object.fromEntries(
  731. Object.entries(tools).filter(([name]) => !disabledTools.has(name)),
  732. );
  733. }
  734. toolCount = Object.keys(tools).length;
  735. } catch (err) {
  736. admissionRuntimeLease?.release();
  737. // Plugin init failed: log visibly before re-throwing so the user
  738. // sees something actionable instead of a silent "loaded but empty".
  739. log('[plugin] FATAL: init failed', String(err));
  740. await appLog(
  741. ctx,
  742. 'error',
  743. `INIT FAILED: ${String(err)}. Report at github.com/alvinunreal/oh-my-opencode-slim/issues/310`,
  744. );
  745. throw err;
  746. }
  747. // ── Health check: validate registrations ────────────────────────────
  748. const agentCount = Object.keys(agents).length;
  749. const mcpCount = Object.keys(mcps).length;
  750. // Skip MCP threshold when user explicitly disabled all built-in MCPs
  751. const mcpThreshold =
  752. runtime.disabledMcps.length > 0 ? 0 : HEALTH_CHECK.minMcps;
  753. const toolThreshold = minimumExpectedToolCount(
  754. runtime.disabledTools,
  755. runtime.webfetch.enabled !== false,
  756. );
  757. if (
  758. agentCount < HEALTH_CHECK.minAgents ||
  759. toolCount < toolThreshold ||
  760. mcpCount < mcpThreshold
  761. ) {
  762. const msg = [
  763. 'Health check: registrations suspiciously low.',
  764. ` agents: ${agentCount} (expected >=${HEALTH_CHECK.minAgents})`,
  765. ` tools: ${toolCount} (expected >=${toolThreshold})`,
  766. ` mcps: ${mcpCount} (expected >=${mcpThreshold})`,
  767. 'This usually means a dependency failed to resolve (jsdom, etc).',
  768. 'If you recently updated opencode, see:',
  769. ' github.com/alvinunreal/oh-my-opencode-slim/issues/310',
  770. ].join('\n');
  771. log(`[plugin] WARN: ${msg}`);
  772. await appLog(ctx, 'warn', msg);
  773. } else {
  774. log('[plugin] health check passed', {
  775. agents: agentCount,
  776. tools: toolCount,
  777. mcps: mcpCount,
  778. });
  779. }
  780. // ── Probe jsdom (async, non-blocking) ───────────────────────────────
  781. // Don't await this; we don't want to block init. The warning will
  782. // appear shortly after startup if jsdom is broken.
  783. probeJSDOM().then((err) => {
  784. if (err) {
  785. const msg = `jsdom probe failed; webfetch tool will not work: ${err}`;
  786. log(`[plugin] WARN: ${msg}`);
  787. appLog(ctx, 'warn', msg).catch(() => {});
  788. }
  789. });
  790. if (runtime.companion?.enabled === true) {
  791. try {
  792. const companionResult = await ensureCompanionVersion({
  793. config: runtime.companion,
  794. downloadTimeoutMs: 3_000,
  795. lockTimeoutMs: 500,
  796. });
  797. if (companionResult.status === 'installed') {
  798. log('[companion] updated before startup', companionResult.version);
  799. } else if (companionResult.status === 'failed') {
  800. log('[companion] startup update failed', companionResult.error);
  801. }
  802. } catch (err) {
  803. log('[companion] startup update failed', String(err));
  804. }
  805. }
  806. companionManager.onLoad();
  807. function resolveTuiVariantForModel(
  808. agentName: string,
  809. model: string,
  810. ): string | undefined {
  811. const configEntry = runtime.agents()[agentName];
  812. const defaultVariant =
  813. typeof configEntry?.variant === 'string'
  814. ? configEntry.variant
  815. : undefined;
  816. const chainMatches = runtime.modelArrays[agentName]?.filter(
  817. (entry) => entry.id === model,
  818. );
  819. if (chainMatches) {
  820. if (chainMatches.length === 1) {
  821. return chainMatches[0].variant ?? defaultVariant;
  822. }
  823. return undefined;
  824. }
  825. if (
  826. typeof configEntry?.model === 'string' &&
  827. configEntry.model === model &&
  828. defaultVariant
  829. ) {
  830. return defaultVariant;
  831. }
  832. return undefined;
  833. }
  834. return {
  835. name: 'oh-my-opencode-slim',
  836. agent: agents,
  837. tool: tools,
  838. mcp: mcps,
  839. config: async (opencodeConfig: Record<string, unknown>) => {
  840. // Capture the host opencode config BEFORE any mutation so runtime
  841. // consumers can distinguish host-provided values from plugin-applied
  842. // ones (host override > runtime override > plugin file).
  843. RuntimeConfig.get(ctx.directory).captureHostConfig(opencodeConfig);
  844. // Force default_agent to the orchestrator's visible entry when unset,
  845. // and also when the user pointed it at an omos subagent name (opencode
  846. // rejects subagent names as default_agent with "default agent must be a
  847. // primary agent"). With a display name, the canonical 'orchestrator'
  848. // registration is a hidden alias, so default to its visible entry.
  849. // Other values (opencode's built-in 'build'/'plan', or a user-defined
  850. // primary agent) are respected. This guards against promptAsync calls
  851. // that omit the `agent` field from falling back to 'build' when the
  852. // orchestrator agent is temporarily unresolved.
  853. if (runtime.setDefaultAgent) {
  854. const existing = (opencodeConfig as { default_agent?: string })
  855. .default_agent;
  856. if (!existing || isSubagent(existing)) {
  857. const orchestratorAlias = agents.orchestrator as
  858. | {
  859. displayName?: string;
  860. hidden?: boolean;
  861. }
  862. | undefined;
  863. (opencodeConfig as { default_agent?: string }).default_agent =
  864. orchestratorAlias?.hidden && orchestratorAlias.displayName
  865. ? normalizeAgentName(orchestratorAlias.displayName)
  866. : 'orchestrator';
  867. }
  868. }
  869. // Merge Agent configs - per-agent shallow merge to preserve
  870. // user-supplied fields (e.g. tools, permission) from opencode.json
  871. if (!opencodeConfig.agent) {
  872. opencodeConfig.agent = { ...agents };
  873. } else {
  874. for (const [name, pluginAgent] of Object.entries(agents)) {
  875. const existing = (opencodeConfig.agent as Record<string, unknown>)[
  876. name
  877. ] as Record<string, unknown> | undefined;
  878. // User explicitly picked a model via /model → disable fallback.
  879. // Only marks the agent if the model differs from the chain primary.
  880. // Once marked, stays disabled even if user switches back to chain[0].
  881. if (existing && typeof existing.model === 'string') {
  882. const primary = runtime.modelArrays[name]?.[0]?.id;
  883. if (primary && existing.model !== primary) {
  884. runtime.everModelSwitched(name);
  885. }
  886. if (runtime.hasModelSwitched(name)) {
  887. foregroundFallback.disableChain(name);
  888. }
  889. }
  890. if (existing) {
  891. // Shallow merge: plugin defaults first, user overrides win
  892. (opencodeConfig.agent as Record<string, unknown>)[name] = {
  893. ...pluginAgent,
  894. ...existing,
  895. };
  896. } else {
  897. (opencodeConfig.agent as Record<string, unknown>)[name] = {
  898. ...pluginAgent,
  899. };
  900. }
  901. }
  902. }
  903. const configAgent = opencodeConfig.agent as Record<string, unknown>;
  904. applyModelInheritanceToConfig(configAgent, runtime);
  905. // Model resolution for foreground agents: use _modelArray entries
  906. // to pick the first model for startup-time selection.
  907. //
  908. // Runtime failover on API errors (e.g. rate limits
  909. // mid-conversation) is handled separately by
  910. // ForegroundFallbackManager via the event hook.
  911. if (Object.keys(runtime.modelArrays).length > 0) {
  912. for (const [agentName, models] of Object.entries(runtime.modelArrays)) {
  913. if (models.length === 0) continue;
  914. // Use the first model in the model array. Not all providers
  915. // require entries in opencodeConfig.provider - some are loaded
  916. // automatically by opencode (e.g. github-copilot, openrouter).
  917. // We cannot distinguish these from truly unconfigured providers
  918. // at config-hook time, so we cannot gate on the provider config
  919. // keys. Runtime failover is handled separately by
  920. // ForegroundFallbackManager.
  921. const chosen = models[0];
  922. const entry = configAgent[agentName] as
  923. | Record<string, unknown>
  924. | undefined;
  925. if (entry) {
  926. // Only apply model array resolution if no user-selected model
  927. // exists. A user-selected model (via /model command) takes
  928. // precedence over the config's fallback chain to preserve
  929. // runtime selections and avoid breaking provider cache.
  930. if (entry.model === undefined) {
  931. entry.model = chosen.id;
  932. if (chosen.variant) {
  933. entry.variant = chosen.variant;
  934. }
  935. }
  936. } else {
  937. // Agent exists in slim but not in opencodeConfig.agent -
  938. // create entry
  939. (configAgent as Record<string, unknown>)[agentName] = {
  940. model: chosen.id,
  941. ...(chosen.variant ? { variant: chosen.variant } : {}),
  942. };
  943. }
  944. log('[plugin] resolved model from array', {
  945. agent: agentName,
  946. model: chosen.id,
  947. variant: chosen.variant,
  948. });
  949. }
  950. }
  951. // Runtime preset override: instance disposal recreates the plugin
  952. // factory and its factory-local state, while module-level runtime
  953. // preset data may persist. Apply that persisted selection after normal
  954. // model resolution for the current generation.
  955. const runtimePresetName = runtime.getRuntimePreset();
  956. if (runtimePresetName && config.presets?.[runtimePresetName]) {
  957. const runtimePreset = config.presets[runtimePresetName];
  958. for (const [agentName, override] of Object.entries(runtimePreset)) {
  959. // Resolve legacy alias keys (e.g. "explore" → "explorer")
  960. // so presets using aliases work in this path.
  961. const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
  962. const entry = configAgent[resolvedName] as
  963. | Record<string, unknown>
  964. | undefined;
  965. if (!entry) continue;
  966. if (typeof override.model === 'string') {
  967. entry.model = override.model;
  968. } else if (
  969. Array.isArray(override.model) &&
  970. override.model.length > 0
  971. ) {
  972. const first = override.model[0];
  973. entry.model = typeof first === 'string' ? first : first.id;
  974. // Extract inline variant from array-form model entry
  975. if (typeof first !== 'string' && first.variant) {
  976. entry.variant = first.variant;
  977. }
  978. }
  979. // Explicitly set or clear scalar fields so switching from
  980. // Preset A (which sets a field) to Preset B (which doesn't)
  981. // doesn't leave stale values behind.
  982. if (typeof override.variant === 'string') {
  983. entry.variant = override.variant;
  984. } else if ('variant' in override) {
  985. delete entry.variant;
  986. }
  987. if (typeof override.temperature === 'number') {
  988. entry.temperature = override.temperature;
  989. } else if ('temperature' in override) {
  990. delete entry.temperature;
  991. }
  992. if (
  993. override.options &&
  994. typeof override.options === 'object' &&
  995. !Array.isArray(override.options)
  996. ) {
  997. entry.options = override.options;
  998. } else if ('options' in override) {
  999. delete entry.options;
  1000. }
  1001. log('[plugin] runtime preset override', {
  1002. preset: runtimePresetName,
  1003. agent: agentName,
  1004. model: entry.model as string,
  1005. });
  1006. }
  1007. }
  1008. // Capture the resolved model state before optionally removing the
  1009. // orchestrator model from the SDK config, so the TUI keeps showing the
  1010. // configured model rather than a fallback or "default".
  1011. const tuiAgentModels: Record<string, string> = {};
  1012. const tuiAgentVariants: Record<string, string> = {};
  1013. for (const agentDef of agentDefs) {
  1014. if (
  1015. agentDef.name === 'council' ||
  1016. agentDef.name === 'councillor' ||
  1017. agentDef.name.startsWith('councillor-')
  1018. )
  1019. continue;
  1020. const entry = configAgent[agentDef.name] as
  1021. | Record<string, unknown>
  1022. | undefined;
  1023. const resolvedModel =
  1024. typeof entry?.model === 'string'
  1025. ? entry.model
  1026. : runtime.runtimeChains[agentDef.name]?.[0]
  1027. ? runtime.runtimeChains[agentDef.name][0]
  1028. : typeof agentDef.config.model === 'string'
  1029. ? agentDef.config.model
  1030. : undefined;
  1031. const resolvedVariant =
  1032. typeof entry?.variant === 'string'
  1033. ? entry.variant
  1034. : typeof agentDef.config.variant === 'string'
  1035. ? agentDef.config.variant
  1036. : undefined;
  1037. tuiAgentModels[agentDef.name] = resolvedModel ?? 'default';
  1038. if (resolvedVariant) {
  1039. tuiAgentVariants[agentDef.name] = resolvedVariant;
  1040. }
  1041. }
  1042. recordTuiAgentModels(
  1043. {
  1044. agentModels: tuiAgentModels,
  1045. agentVariants: tuiAgentVariants,
  1046. },
  1047. ctx.directory,
  1048. );
  1049. applyOrchestratorModelConfig({
  1050. agents: configAgent,
  1051. enabled: runtime.stripOrchestratorModel,
  1052. presets: runtime.plugin?.presets,
  1053. configPreset: runtime.preset,
  1054. runtimePreset: runtimePresetName,
  1055. });
  1056. // Host `agent.council.prompt` wins the shallow merge above and can
  1057. // drop the compaction exception applied in createAgents. Re-apply to
  1058. // the canonical key and the visible display-name alias.
  1059. const councilPlugin = agents.council as
  1060. | { displayName?: string }
  1061. | undefined;
  1062. const councilKeys = new Set<string>(['council']);
  1063. if (typeof councilPlugin?.displayName === 'string') {
  1064. councilKeys.add(normalizeAgentName(councilPlugin.displayName));
  1065. }
  1066. for (const key of councilKeys) {
  1067. const entry = configAgent[key] as Record<string, unknown> | undefined;
  1068. if (typeof entry?.prompt === 'string') {
  1069. entry.prompt = ensureCouncilCompactionException(entry.prompt);
  1070. }
  1071. }
  1072. // This is the source of truth for admission. It is intentionally
  1073. // captured only after every host/plugin merge and the final model
  1074. // inheritance, array-primary, preset, and orchestrator-model passes.
  1075. finalHostAgentConfig = configAgent;
  1076. // Merge MCP configs
  1077. const configMcp = opencodeConfig.mcp as
  1078. | Record<string, unknown>
  1079. | undefined;
  1080. if (!configMcp) {
  1081. opencodeConfig.mcp = { ...mcps };
  1082. } else {
  1083. Object.assign(configMcp, mcps);
  1084. }
  1085. // Get all MCP names from the merged config (built-in + custom)
  1086. const mergedMcpConfig = opencodeConfig.mcp as
  1087. | Record<string, unknown>
  1088. | undefined;
  1089. const allMcpNames = Object.keys(mergedMcpConfig ?? mcps);
  1090. // For each agent, create permission rules based on their mcps list
  1091. for (const [agentName, agentConfig] of Object.entries(agents)) {
  1092. const agentMcps = (agentConfig as { mcps?: string[] })?.mcps;
  1093. if (!agentMcps) continue;
  1094. // Get or create agent permission config
  1095. if (!configAgent[agentName]) {
  1096. configAgent[agentName] = { ...agentConfig };
  1097. }
  1098. const agentConfigEntry = configAgent[agentName] as Record<
  1099. string,
  1100. unknown
  1101. >;
  1102. const agentPermission = (agentConfigEntry.permission ?? {}) as Record<
  1103. string,
  1104. unknown
  1105. >;
  1106. // Parse mcps list with wildcard and exclusion support
  1107. const allowedMcps = parseList(agentMcps, allMcpNames);
  1108. // Create permission rules for each MCP
  1109. // MCP tools are named as <server>_<tool>, so we use <server>_*
  1110. for (const mcpName of allMcpNames) {
  1111. const sanitizedMcpName = mcpName.replace(/[^a-zA-Z0-9_-]/g, '_');
  1112. const permissionKey = `${sanitizedMcpName}_*`;
  1113. const action = allowedMcps.includes(mcpName) ? 'allow' : 'deny';
  1114. // Only set if not already defined by user
  1115. if (!(permissionKey in agentPermission)) {
  1116. agentPermission[permissionKey] = action;
  1117. }
  1118. }
  1119. // Update agent config with permissions
  1120. agentConfigEntry.permission = agentPermission;
  1121. }
  1122. interviewManager.registerCommand(opencodeConfig);
  1123. deepworkCommandHook.registerCommand(opencodeConfig);
  1124. reflectCommandHook.registerCommand(opencodeConfig);
  1125. loopCommandHook.registerCommand(opencodeConfig);
  1126. },
  1127. event: async (input) => {
  1128. await cacheMonitor.event(input);
  1129. const event = input.event as {
  1130. type: string;
  1131. properties?: {
  1132. info?: {
  1133. id?: string;
  1134. parentID?: string;
  1135. title?: string;
  1136. agent?: string;
  1137. providerID?: string;
  1138. modelID?: string;
  1139. model?: {
  1140. providerID?: string;
  1141. modelID?: string;
  1142. };
  1143. sessionID?: string;
  1144. directory?: string;
  1145. };
  1146. sessionID?: string;
  1147. id?: string;
  1148. requestID?: string;
  1149. status?: { type: string };
  1150. };
  1151. };
  1152. // Session-scoped events (session.*) carry the session id in info.id;
  1153. // message/step-scoped events (message.updated, step-finish) carry the
  1154. // message id in info.id and the session id in info.sessionID. Resolve
  1155. // by session so child activity refreshes the correct stuck timer.
  1156. const eventSessionID = resolveEventSessionID(event);
  1157. const statusType = event.properties?.status?.type;
  1158. if (
  1159. eventSessionID &&
  1160. sessionMetadata.getAgent(eventSessionID) === 'orchestrator' &&
  1161. (event.type === 'session.idle' ||
  1162. (event.type === 'session.status' && statusType === 'idle'))
  1163. ) {
  1164. toolLoopGuard.resetTurn(eventSessionID);
  1165. }
  1166. if (eventSessionID && event.type === 'session.deleted') {
  1167. toolLoopGuard.resetSession(eventSessionID);
  1168. }
  1169. if (eventSessionID) {
  1170. applyActivityEvent(taskActivityTracker, event);
  1171. if (
  1172. event.type === 'session.status' &&
  1173. (statusType === 'busy' || statusType === 'retry')
  1174. ) {
  1175. sessionMetadata.markOrchestratorActive(eventSessionID);
  1176. const agentName = sessionMetadata.getAgent(eventSessionID);
  1177. if (agentName) {
  1178. pendingTuiBusySessions.delete(eventSessionID);
  1179. markTuiAgentActive(eventSessionID, agentName);
  1180. } else {
  1181. pendingTuiBusySessions.add(eventSessionID);
  1182. }
  1183. } else if (
  1184. event.type === 'session.idle' ||
  1185. (event.type === 'session.status' && statusType === 'idle') ||
  1186. event.type === 'session.deleted'
  1187. ) {
  1188. pendingTuiBusySessions.delete(eventSessionID);
  1189. sessionMetadata.markOrchestratorIdle(eventSessionID);
  1190. markTuiAgentInactive(eventSessionID);
  1191. }
  1192. }
  1193. if (event.type === 'message.updated') {
  1194. const info = event.properties?.info;
  1195. const providerID =
  1196. typeof info?.providerID === 'string'
  1197. ? info.providerID
  1198. : typeof info?.model?.providerID === 'string'
  1199. ? info.model.providerID
  1200. : undefined;
  1201. const modelID =
  1202. typeof info?.modelID === 'string'
  1203. ? info.modelID
  1204. : typeof info?.model?.modelID === 'string'
  1205. ? info.model.modelID
  1206. : undefined;
  1207. // Track each session's current model so background task admission
  1208. // can resolve the model a model-less subagent will inherit.
  1209. if (typeof info?.sessionID === 'string' && providerID && modelID) {
  1210. const model = `${providerID}/${modelID}`;
  1211. sessionMetadata.setModel(info.sessionID, model);
  1212. // Managed background-task sessions are identified by their session
  1213. // ID. If the model serving one changed (fallback re-prompt, runtime
  1214. // switch), migrate the admission accounting so provider/model caps
  1215. // keep tracking the model actually in use. No-op for other
  1216. // sessions and idempotent when the model is unchanged.
  1217. backgroundTaskConcurrency.migrateTask(info.sessionID, model);
  1218. }
  1219. if (typeof info?.agent === 'string' && providerID && modelID) {
  1220. const agentName = resolveRuntimeAgentName(runtime, info.agent);
  1221. const model = `${providerID}/${modelID}`;
  1222. const variant = resolveTuiVariantForModel(agentName, model);
  1223. recordTuiAgentModel(
  1224. {
  1225. agentName,
  1226. model,
  1227. variant: variant ?? null,
  1228. },
  1229. (info?.sessionID && sessionMetadata.getDirectory(info.sessionID)) ??
  1230. ctx.directory,
  1231. );
  1232. }
  1233. }
  1234. if (event.type === 'session.created') {
  1235. const createdSessionId = event.properties?.info?.id;
  1236. const createdSessionDir = event.properties?.info?.directory;
  1237. const createdSessionParent = (
  1238. event.properties as { info?: { parentID?: unknown } } | undefined
  1239. )?.info?.parentID;
  1240. if (createdSessionId && typeof createdSessionParent === 'string') {
  1241. // Persist the child→parent link so any process can resolve the
  1242. // conversation root, surviving restarts and revives (#1147).
  1243. recordTuiSessionParent(
  1244. createdSessionId,
  1245. createdSessionParent,
  1246. createdSessionDir ?? ctx.directory,
  1247. );
  1248. }
  1249. if (createdSessionId && createdSessionDir) {
  1250. sessionMetadata.setDirectory(createdSessionId, createdSessionDir);
  1251. }
  1252. }
  1253. await handleTaskSessionEvent(
  1254. input as {
  1255. event: {
  1256. type: string;
  1257. properties?: { info?: { id?: string }; sessionID?: string };
  1258. };
  1259. },
  1260. taskSessionManagerHook.event,
  1261. async () => {
  1262. // Handle multiplexer pane spawning for OpenCode's Task tool sessions
  1263. await multiplexerSessionManager.onSessionCreated(event);
  1264. // Handle session status/idle events for pane cleanup early so child panes
  1265. // close promptly even if later hooks do additional work on idle.
  1266. await multiplexerSessionManager.onSessionStatus(event);
  1267. // Handle session.deleted events for pane cleanup
  1268. await multiplexerSessionManager.onSessionDeleted(event);
  1269. },
  1270. async () => {
  1271. await multiplexerSessionManager.cleanupOnInstanceDisposed();
  1272. },
  1273. );
  1274. if (event.type === 'server.instance.disposed') {
  1275. clearTuiActivities();
  1276. }
  1277. await orchestratorWakeScheduler.event(
  1278. input as {
  1279. event: {
  1280. type: string;
  1281. properties?: {
  1282. info?: { id?: string };
  1283. sessionID?: string;
  1284. status?: { type?: string };
  1285. };
  1286. };
  1287. },
  1288. );
  1289. // Runtime model fallback for foreground agents (rate-limit detection)
  1290. await foregroundFallback.handleEvent(input.event);
  1291. // Handle auto-update checking
  1292. await autoUpdateChecker.event(input);
  1293. await interviewManager.handleEvent(
  1294. input as {
  1295. event: { type: string; properties?: Record<string, unknown> };
  1296. },
  1297. );
  1298. if (
  1299. event.type === 'permission.asked' ||
  1300. event.type === 'question.asked'
  1301. ) {
  1302. companionManager.onWaitingInput();
  1303. }
  1304. if (
  1305. event.type === 'permission.replied' ||
  1306. event.type === 'question.replied' ||
  1307. event.type === 'question.rejected'
  1308. ) {
  1309. companionManager.onInputResolved();
  1310. }
  1311. if (input.event.type === 'session.status') {
  1312. const props = input.event.properties as
  1313. | { sessionID?: string; status?: { type?: string } }
  1314. | undefined;
  1315. const sessionID = props?.sessionID;
  1316. companionManager.onSessionStatus({
  1317. sessionId: sessionID,
  1318. agent: sessionID ? sessionMetadata.getAgent(sessionID) : undefined,
  1319. status: props?.status?.type,
  1320. });
  1321. }
  1322. if (input.event.type === 'session.deleted') {
  1323. const props = input.event.properties as
  1324. | { info?: { id?: string }; sessionID?: string }
  1325. | undefined;
  1326. const sessionID = props?.info?.id || props?.sessionID;
  1327. if (sessionID) {
  1328. sessionLifecycle.dispatchSessionDeleted(sessionID);
  1329. }
  1330. companionManager.onSessionDeleted(sessionID);
  1331. if (sessionID) {
  1332. sessionMetadata.delete(sessionID);
  1333. }
  1334. }
  1335. },
  1336. dispose: async () => {
  1337. await taskSessionManagerHook.event({
  1338. event: { type: 'server.instance.disposed' },
  1339. });
  1340. await orchestratorWakeScheduler.event({
  1341. event: { type: 'server.instance.disposed' },
  1342. });
  1343. await interviewManager.dispose();
  1344. await multiplexerSessionManager.cleanupOnInstanceDisposed();
  1345. clearTuiActivities();
  1346. // Release only this generation's ownership. The admission runtime
  1347. // defers final scheduler/tracker teardown by one macrotask so an
  1348. // immediate config-update re-init can retain active and queued calls.
  1349. admissionRuntimeLease?.release();
  1350. },
  1351. 'tool.execute.before': async (input, output) => {
  1352. await applyPatch['tool.execute.before'](input as never, output as never);
  1353. // Rewrite guessed non-existing absolute paths BEFORE the search
  1354. // guard: the guard blocks grep/glob on missing paths, so running
  1355. // the rescue after it would never see a rescuable path (#1143).
  1356. await absolutePathRescue['tool.execute.before'](
  1357. input as never,
  1358. output as never,
  1359. );
  1360. await searchPathGuard['tool.execute.before'](
  1361. input as never,
  1362. output as never,
  1363. );
  1364. await taskSessionManagerHook['tool.execute.before'](
  1365. input as never,
  1366. output as never,
  1367. );
  1368. // Record a call only after all rejecting before-hooks have accepted it.
  1369. // In particular, search-path-guard can reject grep/glob before the host
  1370. // emits tool.execute.after; running the loop guard first would leave a
  1371. // pending call-key entry with no completion to consume it.
  1372. await toolLoopGuard['tool.execute.before'](
  1373. input as never,
  1374. output as never,
  1375. );
  1376. },
  1377. 'command.execute.before': async (input, output) => {
  1378. await interviewManager.handleCommandExecuteBefore(
  1379. input as {
  1380. command: string;
  1381. sessionID: string;
  1382. arguments: string;
  1383. },
  1384. output as { parts: Array<{ type: string; text?: string }> },
  1385. );
  1386. await deepworkCommandHook.handleCommandExecuteBefore(
  1387. input as {
  1388. command: string;
  1389. sessionID: string;
  1390. arguments: string;
  1391. },
  1392. output as { parts: Array<{ type: string; text?: string }> },
  1393. );
  1394. await reflectCommandHook.handleCommandExecuteBefore(
  1395. input as {
  1396. command: string;
  1397. sessionID: string;
  1398. arguments: string;
  1399. },
  1400. output as { parts: Array<{ type: string; text?: string }> },
  1401. );
  1402. await loopCommandHook.handleCommandExecuteBefore(
  1403. input as {
  1404. command: string;
  1405. sessionID: string;
  1406. arguments: string;
  1407. },
  1408. output as { parts: Array<{ type: string; text?: string }> },
  1409. );
  1410. },
  1411. 'chat.headers': chatHeadersHook['chat.headers'],
  1412. // Track which agent each session uses (needed for serve-mode prompt
  1413. // injection)
  1414. 'chat.message': async (
  1415. input: {
  1416. sessionID: string;
  1417. agent?: string;
  1418. model?: {
  1419. providerID: string;
  1420. modelID: string;
  1421. };
  1422. variant?: string;
  1423. parts?: unknown[];
  1424. /** OpenCode chat.message message identity when present. */
  1425. messageID?: string;
  1426. },
  1427. output?: {
  1428. message?: {
  1429. id?: string;
  1430. agent?: string;
  1431. role?: string;
  1432. sessionID?: string;
  1433. model?: {
  1434. providerID: string;
  1435. modelID: string;
  1436. variant?: string;
  1437. };
  1438. };
  1439. parts?: unknown[];
  1440. },
  1441. ) => {
  1442. const rawAgent = input.agent ?? output?.message?.agent;
  1443. const agent = rawAgent
  1444. ? resolveRuntimeAgentName(runtime, rawAgent)
  1445. : undefined;
  1446. if (
  1447. agent &&
  1448. output?.message &&
  1449. typeof output.message.agent === 'string'
  1450. ) {
  1451. output.message.agent = agent;
  1452. }
  1453. if (agent) {
  1454. foregroundFallback.registerSessionAgent(input.sessionID, agent);
  1455. sessionMetadata.setAgent(input.sessionID, agent);
  1456. // Spinner follows session.status, not chat.message: v2 context
  1457. // hooks re-deliver chat.message after idle and would otherwise
  1458. // relight a finished row (and the parent of a background child).
  1459. // An already-active session (busy under a stale/unknown agent)
  1460. // refreshes the association so the row follows the real agent.
  1461. if (
  1462. pendingTuiBusySessions.has(input.sessionID) ||
  1463. ownedTuiActivitySessions.has(input.sessionID)
  1464. ) {
  1465. pendingTuiBusySessions.delete(input.sessionID);
  1466. markTuiAgentActive(input.sessionID, agent);
  1467. }
  1468. companionManager.onSessionStatus({
  1469. sessionId: input.sessionID,
  1470. agent,
  1471. status: 'busy',
  1472. });
  1473. }
  1474. // chat.message carries the model selected for this message, and it
  1475. // fires before the message.updated event that the event hook relies
  1476. // on. Recording it here closes the early window where a session-
  1477. // inheriting background task could be admitted before its parent's
  1478. // model is known — admission then resolves the correct provider/model
  1479. // cap immediately.
  1480. const messageModel = input.model ?? output?.message?.model;
  1481. if (
  1482. messageModel &&
  1483. typeof messageModel.providerID === 'string' &&
  1484. typeof messageModel.modelID === 'string'
  1485. ) {
  1486. const model = `${messageModel.providerID}/${messageModel.modelID}`;
  1487. sessionMetadata.setModel(input.sessionID, model);
  1488. backgroundTaskConcurrency.migrateTask(input.sessionID, model);
  1489. }
  1490. taskSessionManagerHook.observeChatMessage(input, output);
  1491. orchestratorWakeScheduler.observeChatMessage(input, output);
  1492. const messageID = input.messageID ?? output?.message?.id;
  1493. if (messageID) {
  1494. toolLoopGuard.observeNewUserMessage(input.sessionID, messageID);
  1495. }
  1496. },
  1497. // Inject orchestrator system prompt for serve-mode sessions. In serve
  1498. // mode, the agent's prompt field may be absent from the agents
  1499. // registry (built before plugin config hooks run). This hook injects
  1500. // it at LLM call time. Uses the already-resolved prompt from
  1501. // agentDefs (which has custom replacement or append prompts applied)
  1502. // instead of rebuilding the default.
  1503. 'experimental.chat.system.transform': async (
  1504. input: { sessionID?: string },
  1505. output: { system: string[] },
  1506. ): Promise<void> => {
  1507. const agentName = input.sessionID
  1508. ? sessionMetadata.getAgent(input.sessionID)
  1509. : undefined;
  1510. if (agentName === 'orchestrator') {
  1511. const alreadyInjected = output.system.some(
  1512. (s) =>
  1513. typeof s === 'string' &&
  1514. s.includes('<Role>') &&
  1515. s.includes('orchestrator'),
  1516. );
  1517. if (!alreadyInjected) {
  1518. // Place the orchestrator prompt after AGENTS.md so the user's
  1519. // behavioral rules (language, code conventions, etc.) retain
  1520. // their intended priority. AGENTS.md is injected by OpenCode
  1521. // core into system[0]; prepending the orchestrator prompt before
  1522. // it buries user-defined rules under thousands of lines of
  1523. // orchestration instructions.
  1524. const orchestratorDef = agentDefs.find(
  1525. (a) => a.name === 'orchestrator',
  1526. );
  1527. const orchestratorPrompt =
  1528. typeof orchestratorDef?.config?.prompt === 'string'
  1529. ? orchestratorDef.config.prompt
  1530. : buildOrchestratorPrompt(
  1531. runtime.disabledAgents,
  1532. undefined,
  1533. true,
  1534. true,
  1535. hostFlavor,
  1536. );
  1537. output.system[0] = `${output.system[0] || ''}\n\n${orchestratorPrompt}`;
  1538. }
  1539. }
  1540. // Collapse to single system message for provider compatibility.
  1541. // Some providers (e.g. Qwen via VLLM/DashScope) reject multiple
  1542. // system messages. Sub-hooks above may push additional entries; join
  1543. // them back into one element so OpenCode emits a single system
  1544. // message.
  1545. collapseSystemInPlace(output.system);
  1546. },
  1547. // Inject phase reminder and filter available skills before sending to
  1548. // API (doesn't show in UI)
  1549. 'experimental.chat.messages.transform': async (
  1550. input: Record<string, never>,
  1551. output: { messages: unknown[] },
  1552. ): Promise<void> => {
  1553. const typedOutput = output as { messages: MessageWithParts[] };
  1554. for (const message of typedOutput.messages) {
  1555. if (!isMessageWithParts(message)) {
  1556. continue;
  1557. }
  1558. if (message.info.role !== 'user') {
  1559. continue;
  1560. }
  1561. for (const part of message.parts) {
  1562. if (part.type !== 'text' || typeof part.text !== 'string') {
  1563. continue;
  1564. }
  1565. part.text = rewriteDisplayNameMentions(part.text);
  1566. }
  1567. }
  1568. // Strip image parts from orchestrator messages when @observer is
  1569. // available. When the orchestrator's model doesn't support image
  1570. // input, the API call fails before the LLM can respond. We replace
  1571. // image bytes with a text nudge so the orchestrator delegates to
  1572. // @observer instead.
  1573. const imageResult = processImageAttachments({
  1574. messages: typedOutput.messages,
  1575. workDir: ctx.directory,
  1576. imageRouting: runtime.imageRouting,
  1577. disabledAgents: runtime.disabledAgents,
  1578. log,
  1579. });
  1580. if (imageResult) {
  1581. const now = Date.now();
  1582. const last = lastImageSkippedToastByDir.get(ctx.directory) ?? 0;
  1583. if (now - last > IMAGE_SKIPPED_DEBOUNCE_MS) {
  1584. ctx.client.tui
  1585. .showToast({
  1586. body: {
  1587. title: 'Images skipped',
  1588. message:
  1589. 'Observer agent is disabled, so images can\'t be analyzed. Set image_routing to "direct" to send images to your model, or enable observer.',
  1590. variant: 'warning',
  1591. duration: TOAST_DURATION_MS,
  1592. },
  1593. })
  1594. .then(() => {
  1595. // Only advance the debounce window on a successful toast
  1596. // so a failed attempt doesn't suppress the next warning.
  1597. // Greptile: "Failed Toast Starts Debounce Window".
  1598. lastImageSkippedToastByDir.set(ctx.directory, now);
  1599. })
  1600. .catch(() => {});
  1601. }
  1602. }
  1603. // Repair session mappings before reminder gates; nudge metadata precedes phase dedup.
  1604. await taskSessionManagerHook['experimental.chat.messages.transform'](
  1605. input as never,
  1606. typedOutput as never,
  1607. );
  1608. await postFileToolNudge['experimental.chat.messages.transform'](
  1609. input as never,
  1610. typedOutput as never,
  1611. );
  1612. await phaseReminder['experimental.chat.messages.transform'](
  1613. input as never,
  1614. typedOutput as never,
  1615. );
  1616. await filterAvailableSkills['experimental.chat.messages.transform'](
  1617. input as never,
  1618. typedOutput as never,
  1619. );
  1620. await taskSessionManagerHook.injectBackgroundJobBoard(input, typedOutput);
  1621. },
  1622. 'tool.execute.after': async (input, output) => {
  1623. await postFileToolNudgeAfter(input, output);
  1624. await jsonErrorRecoveryAfter(input, output);
  1625. await toolLoopGuard['tool.execute.after'](
  1626. input as never,
  1627. output as never,
  1628. );
  1629. await taskSessionManagerAfter(input, output);
  1630. },
  1631. };
  1632. };
  1633. export default {
  1634. id: 'oh-my-opencode-slim',
  1635. // NOTE: do not add a `tui` key here. OpenCode v1.18.23+ (and v2's
  1636. // byte-identical readV1Plugin) validate the default export of a server
  1637. // plugin module: `tui`, when present, must be a function and must not
  1638. // coexist with `server` — a boolean marker makes the whole plugin fail
  1639. // to load with "invalid tui export". The TUI entry is discovered
  1640. // separately by hosts through the package.json `./tui` export
  1641. // (dist/tui2.js), never through this module.
  1642. server: OhMyOpenCodeLite,
  1643. setup: createV2Setup(),
  1644. };
  1645. export type {
  1646. AgentName,
  1647. AgentOverrideConfig,
  1648. McpName,
  1649. MultiplexerConfig,
  1650. MultiplexerLayout,
  1651. MultiplexerType,
  1652. PluginConfig,
  1653. } from './config';
  1654. export type { RemoteMcpConfig } from './mcp';