| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282 |
- import type { Plugin, ToolDefinition } from '@opencode-ai/plugin';
- import {
- createAgents,
- getAgentConfigs,
- getDisabledAgents,
- isSubagent,
- } from './agents';
- import { buildOrchestratorPrompt } from './agents/orchestrator';
- import { CompanionManager } from './companion/manager';
- import { ensureCompanionVersion } from './companion/updater';
- import {
- type AgentOverrideConfig,
- deepMerge,
- loadPluginConfig,
- type MultiplexerConfig,
- } from './config';
- import { parseList } from './config/agent-mcps';
- import {
- AGENT_ALIASES,
- DEFAULT_MAX_CONTEXT_LINES,
- DEFAULT_MAX_RETAINED_SNAPSHOTS,
- DEFAULT_MAX_SESSIONS_PER_AGENT,
- DEFAULT_READ_CONTEXT_MAX_FILES,
- DEFAULT_READ_CONTEXT_MIN_LINES,
- resolveImageRouting,
- } from './config/constants';
- import {
- getActiveRuntimePreset,
- getPreviousRuntimePreset,
- setActiveRuntimePreset,
- } from './config/runtime-preset';
- import { applyOrchestratorModelConfig } from './config/strip-orchestrator-model';
- import {
- createApplyPatchHook,
- createAutoUpdateCheckerHook,
- createCacheMonitorHook,
- createChatHeadersHook,
- createDeepworkCommandHook,
- createDelegateTaskRetryHook,
- createFilterAvailableSkillsHook,
- createJsonErrorRecoveryHook,
- createLoopCommandHook,
- createPhaseReminderHook,
- createPostFileToolNudgeHook,
- createReflectCommandHook,
- createTaskSessionManagerHook,
- ForegroundFallbackManager,
- SessionLifecycle,
- } from './hooks';
- import { processImageAttachments } from './hooks/image-hook';
- import { isMessageWithParts, type MessageWithParts } from './hooks/types';
- import { handleTaskSessionEvent } from './index-event';
- import { createInterviewManager } from './interview';
- import { createBuiltinMcps } from './mcp';
- import {
- getMultiplexer,
- MultiplexerSessionManager,
- startAvailabilityCheck,
- } from './multiplexer';
- import {
- ast_grep_replace,
- ast_grep_search,
- createAcpRunTool,
- createCancelTaskTool,
- createWaitForUserTool,
- createWebfetchTool,
- } from './tools';
- import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
- import {
- BackgroundJobBoard,
- BackgroundJobCoordinator,
- createDisplayNameMentionRewriter,
- resolveRuntimeAgentName,
- } from './utils';
- import { isPluginDisabledByEnv } from './utils/env';
- import { initLogger, log } from './utils/logger';
- import { collapseSystemInPlace } from './utils/system-collapse';
- /**
- * Best-effort log to opencode's app logger.
- * Wrapped in try/catch to avoid deadlocking on opencode v1.4.8–v1.4.9
- * where client.app.log() during init triggers a middleware cycle.
- */
- async function appLog(
- ctx: Parameters<Plugin>[0],
- level: 'error' | 'warn' | 'info',
- message: string,
- ): Promise<void> {
- try {
- await ctx.client.app.log({
- body: { service: 'oh-my-opencode-slim', level, message },
- });
- } catch {
- // client.app.log may deadlock or be unavailable; stderr is the
- // fallback
- const prefix =
- level === 'error' ? 'ERROR' : level === 'warn' ? 'WARN' : 'INFO';
- console.error(`[oh-my-opencode-slim] ${prefix}: ${message}`);
- }
- }
- /** Minimum expected registrations for a healthy plugin load. */
- const HEALTH_CHECK = {
- minAgents: 5,
- // Default tool set when council and ACP agents are not configured:
- // cancel_task, wait_for_user, webfetch, ast_grep_search, ast_grep_replace.
- minTools: 5,
- minMcps: 1,
- } as const;
- const BASELINE_TOOL_NAMES = new Set([
- 'cancel_task',
- 'wait_for_user',
- 'webfetch',
- 'ast_grep_search',
- 'ast_grep_replace',
- ]);
- /** @internal Exposed for deterministic health-threshold tests. */
- export function minimumExpectedToolCount(
- disabledTools: readonly string[] = [],
- ): number {
- const disabledBaselineTools = new Set(
- disabledTools.filter((toolName) => BASELINE_TOOL_NAMES.has(toolName)),
- );
- return HEALTH_CHECK.minTools - disabledBaselineTools.size;
- }
- /**
- * Probe jsdom at init time so the first webfetch call doesn't fail
- * silently. Logs a warning if jsdom can't be imported or instantiated,
- * but does not throw; the plugin works without webfetch.
- */
- async function probeJSDOM(): Promise<string | null> {
- try {
- const { JSDOM } = await import('jsdom');
- new JSDOM('<!DOCTYPE html><html><body>test</body></html>');
- return null;
- } catch (err) {
- return String(err);
- }
- }
- // Module-level runtime preset tracking. Survives plugin re-inits triggered
- // by client.config.update() → Instance.dispose(). When the plugin function
- // re-runs, it checks this variable and applies the runtime preset instead
- // of the config file's preset. State lives in config/runtime-preset.ts.
- const OhMyOpenCodeLite: Plugin = async (ctx) => {
- const sessionId = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
- initLogger(sessionId);
- if (isPluginDisabledByEnv()) {
- log('[plugin] disabled by OH_MY_OPENCODE_SLIM_DISABLE');
- return {};
- }
- // Observation-only prompt-cache watchdog; safe to create before config
- // loads and must see every event, so it sits outside the try block.
- const cacheMonitor = createCacheMonitorHook();
- // Declare variables that must survive the try/catch for the return
- // closure. These are set inside the try block.
- let config: ReturnType<typeof loadPluginConfig>;
- let disabledAgents: Set<string>;
- let agentDefs: ReturnType<typeof createAgents>;
- let agents: ReturnType<typeof getAgentConfigs>;
- let mcps: ReturnType<typeof createBuiltinMcps>;
- let modelArrayMap: Record<string, Array<{ id: string; variant?: string }>>;
- let everModelSwitched: Set<string>;
- let runtimeChains: Record<string, string[]>;
- let multiplexerConfig: MultiplexerConfig;
- let multiplexerEnabled: boolean;
- let multiplexerSessionManager: MultiplexerSessionManager;
- let autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook>;
- let sessionAgentMap: Map<string, string>;
- // ponytail: cache sessionID -> project directory so TUI model writes
- // land in the right per-project file after a project switch (ctx.directory is stale)
- const sessionDirectories = new Map<string, string>();
- let sessionLifecycle: SessionLifecycle;
- let chatHeadersHook: ReturnType<typeof createChatHeadersHook>;
- let foregroundFallback: ForegroundFallbackManager;
- let deepworkCommandHook: ReturnType<typeof createDeepworkCommandHook>;
- let reflectCommandHook: ReturnType<typeof createReflectCommandHook>;
- let loopCommandHook: ReturnType<typeof createLoopCommandHook>;
- let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
- let phaseReminder: ReturnType<typeof createPhaseReminderHook>;
- let filterAvailableSkills: ReturnType<typeof createFilterAvailableSkillsHook>;
- let postFileToolNudge: ReturnType<typeof createPostFileToolNudgeHook>;
- let delegateTaskRetry: ReturnType<typeof createDelegateTaskRetryHook>;
- let applyPatch: ReturnType<typeof createApplyPatchHook>;
- let jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook>;
- let postFileToolNudgeAfter: (i: unknown, o: unknown) => Promise<void>;
- let delegateTaskRetryAfter: (i: unknown, o: unknown) => Promise<void>;
- let jsonErrorRecoveryAfter: (i: unknown, o: unknown) => Promise<void>;
- let taskSessionManagerAfter: (i: unknown, o: unknown) => Promise<void>;
- let backgroundJobBoard: BackgroundJobBoard;
- let interviewManager: ReturnType<typeof createInterviewManager>;
- let companionManager: CompanionManager;
- let cancelTaskTools: ReturnType<typeof createCancelTaskTool>;
- let waitForUserTools: ReturnType<typeof createWaitForUserTool>;
- let acpRunTools: Record<string, ReturnType<typeof createAcpRunTool>>;
- let webfetch: ReturnType<typeof createWebfetchTool>;
- let tools: Record<string, ToolDefinition>;
- let rewriteDisplayNameMentions: ReturnType<
- typeof createDisplayNameMentionRewriter
- >;
- // Counters for post-init health check (set inside try, checked outside)
- let toolCount = 0;
- try {
- config = loadPluginConfig(ctx.directory);
- // Safety net: if a runtime preset was set via /preset command and
- // OpenCode ever fully re-runs the plugin function (not just the
- // config() hook), override config.preset so agents are created with
- // the correct models. Currently only the config() hook re-runs after
- // Instance.dispose(), so this is a defensive guard.
- const runtimePreset = getActiveRuntimePreset();
- if (runtimePreset && config.presets?.[runtimePreset]) {
- config.preset = runtimePreset;
- // Re-merge runtime preset into config.agents (loadPluginConfig
- // already merged the config-file preset, not the runtime one).
- // Runtime preset is override so it wins over config-file preset.
- const presetAgents = config.presets[runtimePreset];
- config.agents = deepMerge(config.agents, presetAgents);
- } else if (runtimePreset) {
- // Preset was deleted from config since last switch - clear stale state
- setActiveRuntimePreset(null);
- }
- disabledAgents = getDisabledAgents(config);
- rewriteDisplayNameMentions = createDisplayNameMentionRewriter(config);
- agentDefs = createAgents(config, { projectDirectory: ctx.directory });
- agents = getAgentConfigs(config, { projectDirectory: ctx.directory });
- // Build model array map and runtime fallback chains from _modelArray
- // entries (when the user configures model as an array in
- // agents.<name>.model). A single pass populates both data structures.
- modelArrayMap = {} as Record<
- string,
- Array<{ id: string; variant?: string }>
- >;
- everModelSwitched = new Set<string>();
- runtimeChains = {} as Record<string, string[]>;
- for (const agentDef of agentDefs) {
- if (agentDef._modelArray?.length) {
- modelArrayMap[agentDef.name] = agentDef._modelArray;
- runtimeChains[agentDef.name] = agentDef._modelArray.map((m) => m.id);
- }
- }
- // Parse multiplexer config with defaults
- multiplexerConfig = {
- type: config.multiplexer?.type ?? 'none',
- layout: config.multiplexer?.layout ?? 'main-vertical',
- main_pane_size: config.multiplexer?.main_pane_size ?? 60,
- zellij_pane_mode: config.multiplexer?.zellij_pane_mode ?? 'agent-tab',
- };
- // Get multiplexer instance for capability checks
- const multiplexer = getMultiplexer(multiplexerConfig);
- multiplexerEnabled =
- multiplexerConfig.type !== 'none' &&
- multiplexer !== null &&
- multiplexer.isInsideSession();
- log('[plugin] initialized with multiplexer config', {
- multiplexerConfig,
- enabled: multiplexerEnabled,
- directory: ctx.directory,
- });
- // Start background availability check if enabled
- if (multiplexerEnabled) {
- startAvailabilityCheck(multiplexerConfig);
- }
- mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
- acpRunTools =
- Object.keys(config.acpAgents ?? {}).length > 0
- ? { acp_run: createAcpRunTool(config.acpAgents) }
- : {};
- webfetch = createWebfetchTool(ctx);
- backgroundJobBoard = new BackgroundJobBoard({
- maxReusablePerAgent:
- config.backgroundJobs?.maxSessionsPerAgent ??
- DEFAULT_MAX_SESSIONS_PER_AGENT,
- maxContextLines:
- config.backgroundJobs?.maxContextLines ?? DEFAULT_MAX_CONTEXT_LINES,
- readContextMinLines:
- config.backgroundJobs?.readContextMinLines ??
- DEFAULT_READ_CONTEXT_MIN_LINES,
- readContextMaxFiles:
- config.backgroundJobs?.readContextMaxFiles ??
- DEFAULT_READ_CONTEXT_MAX_FILES,
- });
- // Initialize coordinator as the sole writer to the board
- const backgroundJobCoordinator = new BackgroundJobCoordinator(
- backgroundJobBoard,
- );
- // Initialize MultiplexerSessionManager to handle OpenCode's built-in
- // Task tool sessions
- multiplexerSessionManager = new MultiplexerSessionManager(
- ctx,
- multiplexerConfig,
- backgroundJobCoordinator,
- );
- backgroundJobCoordinator.addTerminalStateListener((taskID) => {
- void multiplexerSessionManager.closeSessionFromCoordinator(taskID);
- });
- sessionLifecycle = new SessionLifecycle(log);
- // Initialize auto-update checker hook
- autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
- autoUpdate: config.autoUpdate ?? true,
- companion: config.companion,
- });
- // Track session → agent mapping for serve-mode system prompt injection
- sessionAgentMap = new Map<string, string>();
- chatHeadersHook = createChatHeadersHook(ctx);
- // Initialize foreground fallback manager for runtime model switching.
- // Agents without a chain (e.g. councillor, owned by CouncilManager) are
- // left alone — FG only aborts/re-prompts when it has a model to switch to.
- foregroundFallback = new ForegroundFallbackManager(
- ctx.client,
- runtimeChains,
- config.fallback?.enabled !== false,
- config.fallback?.maxRetries ?? 3,
- sessionLifecycle,
- );
- deepworkCommandHook = createDeepworkCommandHook();
- reflectCommandHook = createReflectCommandHook();
- loopCommandHook = createLoopCommandHook();
- taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
- strategy: config.backgroundJobs?.strategy ?? 'latest',
- maxSessionsPerAgent:
- config.backgroundJobs?.maxSessionsPerAgent ??
- DEFAULT_MAX_SESSIONS_PER_AGENT,
- maxRetainedSnapshots:
- config.backgroundJobs?.maxRetainedSnapshots ??
- DEFAULT_MAX_RETAINED_SNAPSHOTS,
- readContextMinLines:
- config.backgroundJobs?.readContextMinLines ??
- DEFAULT_READ_CONTEXT_MIN_LINES,
- readContextMaxFiles:
- config.backgroundJobs?.readContextMaxFiles ??
- DEFAULT_READ_CONTEXT_MAX_FILES,
- continueOnIdle: config.backgroundJobs?.continueOnIdle === true,
- backgroundJobBoard: backgroundJobCoordinator,
- shouldManageSession: (sessionID) =>
- sessionAgentMap.get(sessionID) === 'orchestrator',
- registerSessionAsOrchestrator: (sessionID) => {
- sessionAgentMap.set(sessionID, 'orchestrator');
- },
- isFallbackInProgress: (sessionID) =>
- foregroundFallback.isFallbackInProgress(sessionID),
- coordinator: sessionLifecycle,
- });
- // Initialize hooks and wrapPostToolHook helper for error isolation
- // Wrap tool.execute.after handlers with per-hook error isolation.
- // Preserves the old runPostToolHook behavior: one failing hook doesn't
- // block the rest.
- const wrapPostToolHook = (
- name: string,
- fn: (i: unknown, o: unknown) => Promise<void>,
- ): ((i: unknown, o: unknown) => Promise<void>) => {
- return async (i, o) => {
- try {
- await fn(i, o);
- } catch (error) {
- const meta = i as {
- tool?: string;
- sessionID?: string;
- callID?: string;
- };
- log('[plugin] post-tool hook failed open', {
- hook: name,
- tool: meta.tool,
- sessionID: meta.sessionID,
- callID: meta.callID,
- error: error instanceof Error ? error.message : String(error),
- });
- }
- };
- };
- // Both message transforms share this gate so a rejected nudge cannot be
- // followed by a phase reminder in the same outgoing turn.
- const shouldInjectOrchestratorReminder = (sessionID: string) =>
- sessionAgentMap.get(sessionID) === 'orchestrator';
- phaseReminder = createPhaseReminderHook({
- shouldInject: shouldInjectOrchestratorReminder,
- });
- filterAvailableSkills = createFilterAvailableSkillsHook(ctx, config);
- postFileToolNudge = createPostFileToolNudgeHook({
- shouldInject: shouldInjectOrchestratorReminder,
- coordinator: sessionLifecycle,
- });
- delegateTaskRetry = createDelegateTaskRetryHook(ctx);
- applyPatch = createApplyPatchHook(ctx);
- jsonErrorRecovery = createJsonErrorRecoveryHook(ctx);
- // Pre-created wrapped handlers for tool.execute.after (error-isolated)
- postFileToolNudgeAfter = wrapPostToolHook('post-file-tool-nudge', (i, o) =>
- postFileToolNudge['tool.execute.after'](i as never, o as never),
- );
- delegateTaskRetryAfter = wrapPostToolHook('delegate-task-retry', (i, o) =>
- delegateTaskRetry['tool.execute.after'](i as never, o as never),
- );
- jsonErrorRecoveryAfter = wrapPostToolHook('json-error-recovery', (i, o) =>
- jsonErrorRecovery['tool.execute.after'](i as never, o as never),
- );
- taskSessionManagerAfter = wrapPostToolHook('task-session-manager', (i, o) =>
- taskSessionManagerHook['tool.execute.after'](i as never, o as never),
- );
- interviewManager = createInterviewManager(ctx, config);
- companionManager = new CompanionManager(
- `proc_${process.pid}`,
- ctx.directory,
- config.companion,
- );
- cancelTaskTools = createCancelTaskTool({
- client: ctx.client,
- backgroundJobBoard: backgroundJobCoordinator,
- shouldManageSession: (sessionID) =>
- sessionAgentMap.get(sessionID) === 'orchestrator',
- });
- waitForUserTools = createWaitForUserTool({
- shouldManageSession: (sessionID) =>
- sessionAgentMap.get(sessionID) === 'orchestrator',
- resolveAgentName: (agent) => resolveRuntimeAgentName(config, agent),
- registerSessionAsOrchestrator: (sessionID) => {
- sessionAgentMap.set(sessionID, 'orchestrator');
- },
- beginUserWait: (sessionID) =>
- taskSessionManagerHook.beginUserWait(sessionID),
- });
- tools = {
- ...cancelTaskTools,
- ...waitForUserTools,
- ...acpRunTools,
- webfetch,
- ast_grep_search,
- ast_grep_replace,
- };
- if (config.disabled_tools && config.disabled_tools.length > 0) {
- const disabledTools = new Set(config.disabled_tools);
- tools = Object.fromEntries(
- Object.entries(tools).filter(([name]) => !disabledTools.has(name)),
- );
- }
- toolCount = Object.keys(tools).length;
- } catch (err) {
- // Plugin init failed: log visibly before re-throwing so the user
- // sees something actionable instead of a silent "loaded but empty".
- log('[plugin] FATAL: init failed', String(err));
- await appLog(
- ctx,
- 'error',
- `INIT FAILED: ${String(err)}. Report at github.com/alvinunreal/oh-my-opencode-slim/issues/310`,
- );
- throw err;
- }
- // ── Health check: validate registrations ────────────────────────────
- const agentCount = Object.keys(agents).length;
- const mcpCount = Object.keys(mcps).length;
- // Skip MCP threshold when user explicitly disabled all built-in MCPs
- const mcpThreshold =
- config.disabled_mcps && config.disabled_mcps.length > 0
- ? 0
- : HEALTH_CHECK.minMcps;
- const toolThreshold = minimumExpectedToolCount(config.disabled_tools);
- if (
- agentCount < HEALTH_CHECK.minAgents ||
- toolCount < toolThreshold ||
- mcpCount < mcpThreshold
- ) {
- const msg = [
- 'Health check: registrations suspiciously low.',
- ` agents: ${agentCount} (expected >=${HEALTH_CHECK.minAgents})`,
- ` tools: ${toolCount} (expected >=${toolThreshold})`,
- ` mcps: ${mcpCount} (expected >=${mcpThreshold})`,
- 'This usually means a dependency failed to resolve (jsdom, etc).',
- 'If you recently updated opencode, see:',
- ' github.com/alvinunreal/oh-my-opencode-slim/issues/310',
- ].join('\n');
- log(`[plugin] WARN: ${msg}`);
- await appLog(ctx, 'warn', msg);
- } else {
- log('[plugin] health check passed', {
- agents: agentCount,
- tools: toolCount,
- mcps: mcpCount,
- });
- }
- // ── Probe jsdom (async, non-blocking) ───────────────────────────────
- // Don't await this; we don't want to block init. The warning will
- // appear shortly after startup if jsdom is broken.
- probeJSDOM().then((err) => {
- if (err) {
- const msg = `jsdom probe failed; webfetch tool will not work: ${err}`;
- log(`[plugin] WARN: ${msg}`);
- appLog(ctx, 'warn', msg).catch(() => {});
- }
- });
- if (config.companion?.enabled === true) {
- try {
- const companionResult = await ensureCompanionVersion({
- config: config.companion,
- downloadTimeoutMs: 3_000,
- lockTimeoutMs: 500,
- });
- if (companionResult.status === 'installed') {
- log('[companion] updated before startup', companionResult.version);
- } else if (companionResult.status === 'failed') {
- log('[companion] startup update failed', companionResult.error);
- }
- } catch (err) {
- log('[companion] startup update failed', String(err));
- }
- }
- companionManager.onLoad();
- function resolveTuiVariantForModel(
- agentName: string,
- model: string,
- ): string | undefined {
- const configEntry = config.agents?.[agentName];
- const defaultVariant =
- typeof configEntry?.variant === 'string'
- ? configEntry.variant
- : undefined;
- const chainMatches = modelArrayMap[agentName]?.filter(
- (entry) => entry.id === model,
- );
- if (chainMatches) {
- if (chainMatches.length === 1) {
- return chainMatches[0].variant ?? defaultVariant;
- }
- return undefined;
- }
- if (
- typeof configEntry?.model === 'string' &&
- configEntry.model === model &&
- defaultVariant
- ) {
- return defaultVariant;
- }
- return undefined;
- }
- return {
- name: 'oh-my-opencode-slim',
- agent: agents,
- tool: tools,
- mcp: mcps,
- config: async (opencodeConfig: Record<string, unknown>) => {
- // Force default_agent to 'orchestrator' when unset, and also when the
- // user pointed it at an omos subagent name (opencode rejects subagent
- // names as default_agent with "default agent must be a primary agent").
- // Other values (opencode's built-in 'build'/'plan', or a user-defined
- // primary agent) are respected. This guards against promptAsync calls
- // that omit the `agent` field from falling back to 'build' when the
- // orchestrator agent is temporarily unresolved.
- if (config.setDefaultAgent !== false) {
- const existing = (opencodeConfig as { default_agent?: string })
- .default_agent;
- if (!existing || isSubagent(existing)) {
- (opencodeConfig as { default_agent?: string }).default_agent =
- 'orchestrator';
- }
- }
- // Merge Agent configs - per-agent shallow merge to preserve
- // user-supplied fields (e.g. tools, permission) from opencode.json
- if (!opencodeConfig.agent) {
- opencodeConfig.agent = { ...agents };
- } else {
- for (const [name, pluginAgent] of Object.entries(agents)) {
- const existing = (opencodeConfig.agent as Record<string, unknown>)[
- name
- ] as Record<string, unknown> | undefined;
- // User explicitly picked a model via /model → disable fallback.
- // Only marks the agent if the model differs from the chain primary.
- // Once marked, stays disabled even if user switches back to chain[0].
- if (existing && typeof existing.model === 'string') {
- const primary = modelArrayMap[name]?.[0]?.id;
- if (primary && existing.model !== primary) {
- everModelSwitched.add(name);
- }
- if (everModelSwitched.has(name)) {
- foregroundFallback.disableChain(name);
- }
- }
- if (existing) {
- // Shallow merge: plugin defaults first, user overrides win
- (opencodeConfig.agent as Record<string, unknown>)[name] = {
- ...pluginAgent,
- ...existing,
- };
- } else {
- (opencodeConfig.agent as Record<string, unknown>)[name] = {
- ...pluginAgent,
- };
- }
- }
- }
- const configAgent = opencodeConfig.agent as Record<string, unknown>;
- // Model resolution for foreground agents: use _modelArray entries
- // to pick the first model for startup-time selection.
- //
- // Runtime failover on API errors (e.g. rate limits
- // mid-conversation) is handled separately by
- // ForegroundFallbackManager via the event hook.
- if (Object.keys(modelArrayMap).length > 0) {
- for (const [agentName, models] of Object.entries(modelArrayMap)) {
- if (models.length === 0) continue;
- // Use the first model in the model array. Not all providers
- // require entries in opencodeConfig.provider - some are loaded
- // automatically by opencode (e.g. github-copilot, openrouter).
- // We cannot distinguish these from truly unconfigured providers
- // at config-hook time, so we cannot gate on the provider config
- // keys. Runtime failover is handled separately by
- // ForegroundFallbackManager.
- const chosen = models[0];
- const entry = configAgent[agentName] as
- | Record<string, unknown>
- | undefined;
- if (entry) {
- // Only apply model array resolution if no user-selected model
- // exists. A user-selected model (via /model command) takes
- // precedence over the config's fallback chain to preserve
- // runtime selections and avoid breaking provider cache.
- if (entry.model === undefined) {
- entry.model = chosen.id;
- if (chosen.variant) {
- entry.variant = chosen.variant;
- }
- }
- } else {
- // Agent exists in slim but not in opencodeConfig.agent -
- // create entry
- (configAgent as Record<string, unknown>)[agentName] = {
- model: chosen.id,
- ...(chosen.variant ? { variant: chosen.variant } : {}),
- };
- }
- log('[plugin] resolved model from array', {
- agent: agentName,
- model: chosen.id,
- variant: chosen.variant,
- });
- }
- }
- // Runtime preset override: if /preset switched to a runtime preset,
- // override the model/variant/temperature from the preset's agent
- // config. This runs after the normal model resolution because the
- // config() hook re-runs with stale modelArrayMap after dispose(),
- // but the runtime preset data is in the captured `config` closure.
- const runtimePresetName = getActiveRuntimePreset();
- if (runtimePresetName && config.presets?.[runtimePresetName]) {
- const runtimePreset = config.presets[runtimePresetName];
- for (const [agentName, override] of Object.entries(runtimePreset)) {
- // Resolve legacy alias keys (e.g. "explore" → "explorer")
- // so presets using aliases work in this path.
- const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
- const entry = configAgent[resolvedName] as
- | Record<string, unknown>
- | undefined;
- if (!entry) continue;
- if (typeof override.model === 'string') {
- entry.model = override.model;
- } else if (
- Array.isArray(override.model) &&
- override.model.length > 0
- ) {
- const first = override.model[0];
- entry.model = typeof first === 'string' ? first : first.id;
- // Extract inline variant from array-form model entry
- if (typeof first !== 'string' && first.variant) {
- entry.variant = first.variant;
- }
- }
- // Explicitly set or clear scalar fields so switching from
- // Preset A (which sets a field) to Preset B (which doesn't)
- // doesn't leave stale values behind.
- if (typeof override.variant === 'string') {
- entry.variant = override.variant;
- } else if ('variant' in override) {
- delete entry.variant;
- }
- if (typeof override.temperature === 'number') {
- entry.temperature = override.temperature;
- } else if ('temperature' in override) {
- delete entry.temperature;
- }
- if (
- override.options &&
- typeof override.options === 'object' &&
- !Array.isArray(override.options)
- ) {
- entry.options = override.options;
- } else if ('options' in override) {
- delete entry.options;
- }
- log('[plugin] runtime preset override', {
- preset: runtimePresetName,
- agent: agentName,
- model: entry.model as string,
- });
- }
- // Reset agents from the previous preset that aren't in the new one.
- // The stale model resolution above overwrites the reset values sent
- // by preset-manager, so we re-apply them here from config-file
- // baseline.
- const prevPresetName = getPreviousRuntimePreset();
- if (prevPresetName && config.presets?.[prevPresetName]) {
- const prevPreset = config.presets[prevPresetName];
- // Build resolved key set from new preset for correct comparison
- // (handles alias keys like "explore" → "explorer")
- const newPresetResolved = new Set(
- Object.keys(runtimePreset).map((k) => AGENT_ALIASES[k] ?? k),
- );
- for (const agentName of Object.keys(prevPreset)) {
- const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
- if (newPresetResolved.has(resolvedName)) continue; // new preset handles it
- const entry = configAgent[resolvedName] as
- | Record<string, unknown>
- | undefined;
- if (!entry) continue;
- // Reset to config-file baseline. Use the previous preset's
- // override to identify which fields to clear even when the
- // baseline doesn't define them.
- const baseline = config.agents?.[resolvedName];
- const prevOverride = prevPreset[agentName] as
- | AgentOverrideConfig
- | undefined;
- if (typeof baseline?.model === 'string') {
- entry.model = baseline.model;
- }
- if (typeof baseline?.variant === 'string') {
- entry.variant = baseline.variant;
- } else if (prevOverride && 'variant' in prevOverride) {
- delete entry.variant;
- }
- if (typeof baseline?.temperature === 'number') {
- entry.temperature = baseline.temperature;
- } else if (prevOverride && 'temperature' in prevOverride) {
- delete entry.temperature;
- }
- if (
- baseline?.options &&
- typeof baseline.options === 'object' &&
- !Array.isArray(baseline.options)
- ) {
- entry.options = baseline.options;
- } else if (prevOverride && 'options' in prevOverride) {
- delete entry.options;
- }
- log('[plugin] runtime preset reset from previous', {
- previousPreset: prevPresetName,
- agent: resolvedName,
- model: entry.model as string,
- });
- }
- }
- }
- // Capture the resolved model state before optionally removing the
- // orchestrator model from the SDK config, so the TUI keeps showing the
- // configured model rather than a fallback or "default".
- const tuiAgentModels: Record<string, string> = {};
- const tuiAgentVariants: Record<string, string> = {};
- for (const agentDef of agentDefs) {
- if (
- agentDef.name === 'council' ||
- agentDef.name === 'councillor' ||
- agentDef.name.startsWith('councillor-')
- )
- continue;
- const entry = configAgent[agentDef.name] as
- | Record<string, unknown>
- | undefined;
- const resolvedModel =
- typeof entry?.model === 'string'
- ? entry.model
- : runtimeChains[agentDef.name]?.[0]
- ? runtimeChains[agentDef.name][0]
- : typeof agentDef.config.model === 'string'
- ? agentDef.config.model
- : undefined;
- const resolvedVariant =
- typeof entry?.variant === 'string'
- ? entry.variant
- : typeof agentDef.config.variant === 'string'
- ? agentDef.config.variant
- : undefined;
- tuiAgentModels[agentDef.name] = resolvedModel ?? 'default';
- if (resolvedVariant) {
- tuiAgentVariants[agentDef.name] = resolvedVariant;
- }
- }
- recordTuiAgentModels(
- {
- agentModels: tuiAgentModels,
- agentVariants: tuiAgentVariants,
- },
- ctx.directory,
- );
- applyOrchestratorModelConfig({
- agents: configAgent,
- enabled: config.stripOrchestratorModel,
- presets: config.presets,
- configPreset: config.preset,
- runtimePreset: runtimePresetName,
- });
- // Merge MCP configs
- const configMcp = opencodeConfig.mcp as
- | Record<string, unknown>
- | undefined;
- if (!configMcp) {
- opencodeConfig.mcp = { ...mcps };
- } else {
- Object.assign(configMcp, mcps);
- }
- // Get all MCP names from the merged config (built-in + custom)
- const mergedMcpConfig = opencodeConfig.mcp as
- | Record<string, unknown>
- | undefined;
- const allMcpNames = Object.keys(mergedMcpConfig ?? mcps);
- // For each agent, create permission rules based on their mcps list
- for (const [agentName, agentConfig] of Object.entries(agents)) {
- const agentMcps = (agentConfig as { mcps?: string[] })?.mcps;
- if (!agentMcps) continue;
- // Get or create agent permission config
- if (!configAgent[agentName]) {
- configAgent[agentName] = { ...agentConfig };
- }
- const agentConfigEntry = configAgent[agentName] as Record<
- string,
- unknown
- >;
- const agentPermission = (agentConfigEntry.permission ?? {}) as Record<
- string,
- unknown
- >;
- // Parse mcps list with wildcard and exclusion support
- const allowedMcps = parseList(agentMcps, allMcpNames);
- // Create permission rules for each MCP
- // MCP tools are named as <server>_<tool>, so we use <server>_*
- for (const mcpName of allMcpNames) {
- const sanitizedMcpName = mcpName.replace(/[^a-zA-Z0-9_-]/g, '_');
- const permissionKey = `${sanitizedMcpName}_*`;
- const action = allowedMcps.includes(mcpName) ? 'allow' : 'deny';
- // Only set if not already defined by user
- if (!(permissionKey in agentPermission)) {
- agentPermission[permissionKey] = action;
- }
- }
- // Update agent config with permissions
- agentConfigEntry.permission = agentPermission;
- }
- interviewManager.registerCommand(opencodeConfig);
- deepworkCommandHook.registerCommand(opencodeConfig);
- reflectCommandHook.registerCommand(opencodeConfig);
- loopCommandHook.registerCommand(opencodeConfig);
- },
- event: async (input) => {
- await cacheMonitor.event(input);
- const event = input.event as {
- type: string;
- properties?: {
- info?: {
- id?: string;
- parentID?: string;
- title?: string;
- agent?: string;
- providerID?: string;
- modelID?: string;
- model?: {
- providerID?: string;
- modelID?: string;
- };
- sessionID?: string;
- directory?: string;
- };
- sessionID?: string;
- id?: string;
- requestID?: string;
- status?: { type: string };
- };
- };
- if (event.type === 'message.updated') {
- const info = event.properties?.info;
- const providerID =
- typeof info?.providerID === 'string'
- ? info.providerID
- : typeof info?.model?.providerID === 'string'
- ? info.model.providerID
- : undefined;
- const modelID =
- typeof info?.modelID === 'string'
- ? info.modelID
- : typeof info?.model?.modelID === 'string'
- ? info.model.modelID
- : undefined;
- if (typeof info?.agent === 'string' && providerID && modelID) {
- const agentName = resolveRuntimeAgentName(config, info.agent);
- const model = `${providerID}/${modelID}`;
- const variant = resolveTuiVariantForModel(agentName, model);
- recordTuiAgentModel(
- {
- agentName,
- model,
- variant: variant ?? null,
- },
- (info?.sessionID && sessionDirectories.get(info.sessionID)) ??
- ctx.directory,
- );
- }
- }
- if (event.type === 'session.created') {
- const createdSessionId = event.properties?.info?.id;
- const createdSessionDir = event.properties?.info?.directory;
- if (createdSessionId && createdSessionDir) {
- sessionDirectories.set(createdSessionId, createdSessionDir);
- }
- }
- await handleTaskSessionEvent(
- input as {
- event: {
- type: string;
- properties?: { info?: { id?: string }; sessionID?: string };
- };
- },
- taskSessionManagerHook.event,
- async () => {
- // Handle multiplexer pane spawning for OpenCode's Task tool sessions
- await multiplexerSessionManager.onSessionCreated(event);
- // Handle session status/idle events for pane cleanup early so child panes
- // close promptly even if later hooks do additional work on idle.
- await multiplexerSessionManager.onSessionStatus(event);
- // Handle session.deleted events for pane cleanup
- await multiplexerSessionManager.onSessionDeleted(event);
- },
- async () => {
- await multiplexerSessionManager.cleanupOnInstanceDisposed();
- },
- );
- // Runtime model fallback for foreground agents (rate-limit detection)
- await foregroundFallback.handleEvent(input.event);
- // Handle auto-update checking
- await autoUpdateChecker.event(input);
- await interviewManager.handleEvent(
- input as {
- event: { type: string; properties?: Record<string, unknown> };
- },
- );
- if (
- event.type === 'permission.asked' ||
- event.type === 'question.asked'
- ) {
- companionManager.onWaitingInput();
- }
- if (
- event.type === 'permission.replied' ||
- event.type === 'question.replied' ||
- event.type === 'question.rejected'
- ) {
- companionManager.onInputResolved();
- }
- if (input.event.type === 'session.status') {
- const props = input.event.properties as
- | { sessionID?: string; status?: { type?: string } }
- | undefined;
- const sessionID = props?.sessionID;
- companionManager.onSessionStatus({
- sessionId: sessionID,
- agent: sessionID ? sessionAgentMap.get(sessionID) : undefined,
- status: props?.status?.type,
- });
- }
- if (input.event.type === 'session.deleted') {
- const props = input.event.properties as
- | { info?: { id?: string }; sessionID?: string }
- | undefined;
- const sessionID = props?.info?.id || props?.sessionID;
- if (sessionID) {
- sessionLifecycle.dispatchSessionDeleted(sessionID);
- }
- companionManager.onSessionDeleted(sessionID);
- if (sessionID) {
- sessionAgentMap.delete(sessionID);
- sessionDirectories.delete(sessionID);
- }
- }
- },
- 'tool.execute.before': async (input, output) => {
- await applyPatch['tool.execute.before'](input as never, output as never);
- await taskSessionManagerHook['tool.execute.before'](
- input as never,
- output as never,
- );
- },
- 'command.execute.before': async (input, output) => {
- await interviewManager.handleCommandExecuteBefore(
- input as {
- command: string;
- sessionID: string;
- arguments: string;
- },
- output as { parts: Array<{ type: string; text?: string }> },
- );
- await deepworkCommandHook.handleCommandExecuteBefore(
- input as {
- command: string;
- sessionID: string;
- arguments: string;
- },
- output as { parts: Array<{ type: string; text?: string }> },
- );
- await reflectCommandHook.handleCommandExecuteBefore(
- input as {
- command: string;
- sessionID: string;
- arguments: string;
- },
- output as { parts: Array<{ type: string; text?: string }> },
- );
- await loopCommandHook.handleCommandExecuteBefore(
- input as {
- command: string;
- sessionID: string;
- arguments: string;
- },
- output as { parts: Array<{ type: string; text?: string }> },
- );
- },
- 'chat.headers': chatHeadersHook['chat.headers'],
- // Track which agent each session uses (needed for serve-mode prompt
- // injection)
- 'chat.message': async (
- input: {
- sessionID: string;
- agent?: string;
- parts?: unknown[];
- /** OpenCode chat.message message identity when present. */
- messageID?: string;
- },
- output?: {
- message?: {
- id?: string;
- agent?: string;
- role?: string;
- sessionID?: string;
- };
- parts?: unknown[];
- },
- ) => {
- const rawAgent = input.agent ?? output?.message?.agent;
- const agent = rawAgent
- ? resolveRuntimeAgentName(config, rawAgent)
- : undefined;
- if (
- agent &&
- output?.message &&
- typeof output.message.agent === 'string'
- ) {
- output.message.agent = agent;
- }
- if (agent) {
- foregroundFallback.registerSessionAgent(input.sessionID, agent);
- sessionAgentMap.set(input.sessionID, agent);
- // A chat message means this session is actively working. This also
- // covers the race where session.status busy fires before the
- // session's agent is known.
- companionManager.onSessionStatus({
- sessionId: input.sessionID,
- agent,
- status: 'busy',
- });
- }
- taskSessionManagerHook.observeChatMessage(input, output);
- },
- // Inject orchestrator system prompt for serve-mode sessions. In serve
- // mode, the agent's prompt field may be absent from the agents
- // registry (built before plugin config hooks run). This hook injects
- // it at LLM call time. Uses the already-resolved prompt from
- // agentDefs (which has custom replacement or append prompts applied)
- // instead of rebuilding the default.
- 'experimental.chat.system.transform': async (
- input: { sessionID?: string },
- output: { system: string[] },
- ): Promise<void> => {
- const agentName = input.sessionID
- ? sessionAgentMap.get(input.sessionID)
- : undefined;
- if (agentName === 'orchestrator') {
- const alreadyInjected = output.system.some(
- (s) =>
- typeof s === 'string' &&
- s.includes('<Role>') &&
- s.includes('orchestrator'),
- );
- if (!alreadyInjected) {
- // Prepend the orchestrator prompt to the system array. Use the
- // resolved prompt from the orchestrator agent definition (which
- // includes any custom replacement or append from orchestrator.md
- // / orchestrator_append.md) Fall back to
- // buildOrchestratorPrompt only if the resolved prompt is
- // missing.
- const orchestratorDef = agentDefs.find(
- (a) => a.name === 'orchestrator',
- );
- const orchestratorPrompt =
- typeof orchestratorDef?.config?.prompt === 'string'
- ? orchestratorDef.config.prompt
- : buildOrchestratorPrompt(disabledAgents);
- output.system[0] =
- orchestratorPrompt +
- (output.system[0] ? `\n\n${output.system[0]}` : '');
- }
- }
- // Collapse to single system message for provider compatibility.
- // Some providers (e.g. Qwen via VLLM/DashScope) reject multiple
- // system messages. Sub-hooks above may push additional entries; join
- // them back into one element so OpenCode emits a single system
- // message.
- collapseSystemInPlace(output.system);
- },
- // Inject phase reminder and filter available skills before sending to
- // API (doesn't show in UI)
- 'experimental.chat.messages.transform': async (
- input: Record<string, never>,
- output: { messages: unknown[] },
- ): Promise<void> => {
- const typedOutput = output as { messages: MessageWithParts[] };
- for (const message of typedOutput.messages) {
- if (!isMessageWithParts(message)) {
- continue;
- }
- if (message.info.role !== 'user') {
- continue;
- }
- for (const part of message.parts) {
- if (part.type !== 'text' || typeof part.text !== 'string') {
- continue;
- }
- part.text = rewriteDisplayNameMentions(part.text);
- }
- }
- // Strip image parts from orchestrator messages when @observer is
- // available. When the orchestrator's model doesn't support image
- // input, the API call fails before the LLM can respond. We replace
- // image bytes with a text nudge so the orchestrator delegates to
- // @observer instead.
- processImageAttachments({
- messages: typedOutput.messages,
- workDir: ctx.directory,
- imageRouting: resolveImageRouting(config.image_routing),
- disabledAgents,
- log,
- });
- // Repair session mappings before reminder gates; nudge metadata precedes phase dedup.
- await taskSessionManagerHook['experimental.chat.messages.transform'](
- input as never,
- typedOutput as never,
- );
- await postFileToolNudge['experimental.chat.messages.transform'](
- input as never,
- typedOutput as never,
- );
- await phaseReminder['experimental.chat.messages.transform'](
- input as never,
- typedOutput as never,
- );
- await filterAvailableSkills['experimental.chat.messages.transform'](
- input as never,
- typedOutput as never,
- );
- await taskSessionManagerHook.injectBackgroundJobBoard(input, typedOutput);
- },
- 'tool.execute.after': async (input, output) => {
- await postFileToolNudgeAfter(input, output);
- await delegateTaskRetryAfter(input, output);
- await jsonErrorRecoveryAfter(input, output);
- await taskSessionManagerAfter(input, output);
- },
- };
- };
- export default OhMyOpenCodeLite;
- export type {
- AgentName,
- AgentOverrideConfig,
- McpName,
- MultiplexerConfig,
- MultiplexerLayout,
- MultiplexerType,
- PluginConfig,
- } from './config';
- export type { RemoteMcpConfig } from './mcp';
|