| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767 |
- import type { Plugin, ToolDefinition } from '@opencode-ai/plugin';
- import {
- type AdmissionRuntimeLease,
- acquireAdmissionRuntime,
- } from './admission-runtime';
- import {
- applyModelInheritanceToConfig,
- createAgents,
- ensureCouncilCompactionException,
- getAgentConfigs,
- isSubagent,
- resolvePrimaryModelValue,
- } from './agents';
- import { buildOrchestratorPrompt } from './agents/orchestrator';
- import { CompanionManager } from './companion/manager';
- import { ensureCompanionVersion } from './companion/updater';
- import { deepMerge, loadPluginConfig, type MultiplexerConfig } from './config';
- import { parseList } from './config/agent-mcps';
- import {
- AGENT_ALIASES,
- DEFAULT_MAX_SESSION_METADATA_ENTRIES,
- TOAST_DURATION_MS,
- } from './config/constants';
- import { RuntimeConfig } from './config/runtime';
- import { applyOrchestratorModelConfig } from './config/strip-orchestrator-model';
- import { HEALTH_CHECK, minimumExpectedToolCount } from './health-check';
- import {
- createAbsolutePathRescueHook,
- createApplyPatchHook,
- createAutoUpdateCheckerHook,
- createCacheMonitorHook,
- createChatHeadersHook,
- createDeepworkCommandHook,
- createFilterAvailableSkillsHook,
- createJsonErrorRecoveryHook,
- createLoopCommandHook,
- createOrchestratorWakeScheduler,
- createPhaseReminderHook,
- createPostFileToolNudgeHook,
- createReflectCommandHook,
- createSearchPathGuardHook,
- createTaskSessionManagerHook,
- createToolLoopGuardHook,
- ForegroundFallbackManager,
- formatStoppedJobDelta,
- SessionLifecycle,
- } from './hooks';
- import { processImageAttachments } from './hooks/image-hook';
- import { createRevivedRunTracker } from './hooks/task-session-manager/revived-run-tracker';
- import type { ToolLoopGuardHook } from './hooks/tool-loop-guard/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,
- createTaskMessageTool,
- createTaskResultTool,
- createTaskReviveTool,
- createTaskStatusTool,
- createWaitForUserTool,
- createWebfetchTool,
- } from './tools';
- import { pickAgentModelRef } from './tools/smartfetch/secondary-model';
- import {
- applyActivityEvent,
- resolveEventSessionID,
- TaskActivityTracker,
- } from './tools/task-activity';
- import {
- clearTuiAgentActivities,
- readTuiSnapshot,
- recordTuiAgentActivity,
- recordTuiAgentModel,
- recordTuiAgentModels,
- recordTuiSessionParent,
- } from './tui-state';
- import {
- BackgroundJobBoard,
- BackgroundJobCoordinator,
- BackgroundJobSupervisor,
- type BackgroundTaskConcurrency,
- createDisplayNameMentionRewriter,
- normalizeAgentName,
- resolveRuntimeAgentName,
- } from './utils';
- import type { ContextFile } from './utils/background-job-board';
- import { isPluginDisabledByEnv } from './utils/env';
- import { probeJSDOM } from './utils/jsdom';
- import { initLogger, log } from './utils/logger';
- import { SessionMetadataStore } from './utils/session-metadata';
- import { collapseSystemInPlace } from './utils/system-collapse';
- import { createV2Setup } from './v2';
- /**
- * 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}`);
- }
- }
- // Debounce: only show image-skipped toast once per 60 seconds per project
- const lastImageSkippedToastByDir = new Map<string, number>();
- const IMAGE_SKIPPED_DEBOUNCE_MS = 60_000;
- // 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 RuntimeConfig.
- /**
- * Decide whether multiplexer pane management initializes for a plugin
- * input. v1 hosts (hostFlavor absent) keep the exact env-based
- * conditions — configured type, resolvable multiplexer, inside-session
- * env marker; v2 hosts, marked `hostFlavor: 'v2'` by the v2 client shim,
- * are gated off before any multiplexer initialization runs.
- */
- export function shouldEnableMultiplexer(input: {
- hostFlavor?: string;
- multiplexerConfig: MultiplexerConfig;
- }): boolean {
- if ((input as { hostFlavor?: string }).hostFlavor === 'v2') {
- log('[v2] multiplexer disabled on v2 host');
- return false;
- }
- // Get multiplexer instance for capability checks (v1 path, unchanged)
- const multiplexer = getMultiplexer(input.multiplexerConfig);
- return (
- input.multiplexerConfig.type !== 'none' &&
- multiplexer !== null &&
- multiplexer.isInsideSession()
- );
- }
- /**
- * Config handed to MultiplexerSessionManager. The manager is created
- * unconditionally (its lifecycle hooks are wired into the job coordinator),
- * but it self-gates only on env (inside tmux/zellij) — which would
- * incorrectly self-enable on a v2 host running inside tmux. v2 hosts are
- * therefore forced to type 'none', which disables every manager path (all
- * its public methods no-op when `enabled` is false). v1 hosts receive the
- * real config object unchanged.
- */
- export function sessionManagerMultiplexerConfig(
- hostFlavor: string | undefined,
- config: MultiplexerConfig,
- ): MultiplexerConfig {
- return hostFlavor === 'v2' ? { ...config, type: 'none' } : config;
- }
- export 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 runtime: RuntimeConfig;
- let agentDefs: ReturnType<typeof createAgents>;
- let agents: ReturnType<typeof getAgentConfigs>;
- let mcps: ReturnType<typeof createBuiltinMcps>;
- let multiplexerConfig: MultiplexerConfig;
- let multiplexerEnabled: boolean;
- // Host flavor ('v2' on OpenCode v2 hosts via the client shim, undefined on
- // v1). Survives the try block so prompt-assembly hooks can use it.
- let hostFlavor: string | undefined;
- let multiplexerSessionManager: MultiplexerSessionManager;
- let autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook>;
- const sessionMetadata = new SessionMetadataStore({
- maxEntries: DEFAULT_MAX_SESSION_METADATA_ENTRIES,
- onEvict: (sessionID) => {
- log('[session] evicted oldest session metadata', {
- threshold: DEFAULT_MAX_SESSION_METADATA_ENTRIES,
- droppedSessionId: sessionID,
- });
- },
- });
- const ownedTuiActivitySessions = new Map<string, string>();
- // Busy/retry arrived before the session's agent was known. chat.message
- // latches the agent and flushes these so the spinner still starts.
- const pendingTuiBusySessions = new Set<string>();
- const tuiActivityDirectory = (sessionID: string): string => {
- return sessionMetadata.getDirectory(sessionID) ?? ctx.directory;
- };
- // Sidebar activity scoping (#1147): every active session and the visible
- // route session resolve their conversation root against the persistent
- // sessionParents index at render time. The recorder only persists the
- // child→parent links; roots are never stored per-activity, so a
- // late-learned link re-roots everything consistently. Process identity
- // cannot scope this because v2 daemons are shared across windows.
- const markTuiAgentActive = (sessionID: string, agentName: string): void => {
- const directory = tuiActivityDirectory(sessionID);
- recordTuiAgentActivity({ sessionID, agentName, active: true }, directory);
- ownedTuiActivitySessions.set(sessionID, directory);
- void hydrateTuiSessionParent(sessionID, directory);
- };
- // Sessions that predate this fix or whose session.created event was
- // missed have no link in the persistent index. Ask the host once per
- // session and walk up to a confirmed root; absence of parentID on a
- // valid response is a final answer (top-level chat).
- const hydratedTuiParents = new Set<string>();
- const hydrateTuiSessionParent = async (
- startSessionID: string,
- directory: string,
- ): Promise<void> => {
- const sessionApi = (ctx as { client?: { session?: { get?: unknown } } })
- .client?.session;
- if (typeof sessionApi?.get !== 'function') return;
- const lookup = sessionApi.get as (input: {
- path: { id: string };
- query: { directory: string };
- }) => Promise<{ data?: unknown; error?: unknown; parentID?: unknown }>;
- const visited = new Set<string>();
- let current = startSessionID;
- while (!visited.has(current)) {
- visited.add(current);
- const snapshot = readTuiSnapshot(directory);
- const known = snapshot.sessionParents[current];
- if (known !== undefined) {
- current = known; // Persisted link; keep walking toward the root.
- continue;
- }
- if (hydratedTuiParents.has(current)) return;
- hydratedTuiParents.add(current);
- let parentID: unknown;
- try {
- // Call with the session object as receiver: the SDK's generated
- // method reads `this._client` (#595 class of regression).
- const response = await lookup.call(sessionApi, {
- path: { id: current },
- query: { directory },
- });
- if (response?.error !== undefined) {
- // HTTP error resolved instead of thrown: release the slot so a
- // later activity can retry.
- hydratedTuiParents.delete(current);
- return;
- }
- const info = response?.data;
- if (info === null || typeof info !== 'object') {
- // Malformed response outside the host contract: release the
- // slot rather than caching "confirmed root" on garbage.
- hydratedTuiParents.delete(current);
- return;
- }
- parentID = (info as { parentID?: unknown }).parentID;
- } catch {
- hydratedTuiParents.delete(current);
- return;
- }
- if (typeof parentID === 'string' && parentID !== current) {
- recordTuiSessionParent(current, parentID, directory);
- current = parentID;
- continue;
- }
- if (parentID !== undefined && parentID !== null) {
- // Malformed non-string parent: release the slot so a later
- // activity can retry instead of caching a false confirmed root.
- hydratedTuiParents.delete(current);
- }
- // Valid response without a parent: confirmed root, stop.
- return;
- }
- };
- const markTuiAgentInactive = (sessionID: string): void => {
- const directory =
- ownedTuiActivitySessions.get(sessionID) ??
- tuiActivityDirectory(sessionID);
- recordTuiAgentActivity({ sessionID, active: false }, directory);
- ownedTuiActivitySessions.delete(sessionID);
- };
- const clearTuiActivities = (): void => {
- for (const [sessionID, directory] of ownedTuiActivitySessions) {
- recordTuiAgentActivity({ sessionID, active: false }, directory);
- }
- ownedTuiActivitySessions.clear();
- };
- clearTuiAgentActivities(ctx.directory);
- 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 orchestratorWakeScheduler: ReturnType<
- typeof createOrchestratorWakeScheduler
- >;
- let phaseReminder: ReturnType<typeof createPhaseReminderHook>;
- let filterAvailableSkills: ReturnType<typeof createFilterAvailableSkillsHook>;
- let postFileToolNudge: ReturnType<typeof createPostFileToolNudgeHook>;
- let applyPatch: ReturnType<typeof createApplyPatchHook>;
- let searchPathGuard: ReturnType<typeof createSearchPathGuardHook>;
- let absolutePathRescue: ReturnType<typeof createAbsolutePathRescueHook>;
- let jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook>;
- let toolLoopGuard: ToolLoopGuardHook;
- let postFileToolNudgeAfter: (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 backgroundJobSupervisor: BackgroundJobSupervisor;
- let backgroundTaskConcurrency: BackgroundTaskConcurrency;
- let admissionRuntimeLease: AdmissionRuntimeLease | undefined;
- let finalHostAgentConfig: Record<string, unknown> | undefined;
- let interviewManager: ReturnType<typeof createInterviewManager>;
- let companionManager: CompanionManager;
- let taskCancelTools: ReturnType<typeof createCancelTaskTool>;
- let taskMessageTools: ReturnType<typeof createTaskMessageTool>;
- let taskResultTools: ReturnType<typeof createTaskResultTool>;
- let taskReviveTools: ReturnType<typeof createTaskReviveTool>;
- let revivedRunTracker: ReturnType<typeof createRevivedRunTracker>;
- let markRevivedRunPending: (taskID: string) => void = () => {};
- let markRevivedRunSettled: (taskID: string) => void = () => {};
- let getRevivedContextFiles = (_taskID: string): ContextFile[] => [];
- let pruneRevivedContext = () => {};
- let taskStatusTools: ReturnType<typeof createTaskStatusTool>;
- const taskActivityTracker = new TaskActivityTracker();
- 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;
- const resolvePrimaryModelFromFinalHostConfig = (
- agentType: string,
- ): string | undefined => {
- const readModel = (entry: unknown): string | undefined => {
- if (entry === null || typeof entry !== 'object') return undefined;
- return resolvePrimaryModelValue((entry as Record<string, unknown>).model);
- };
- const directModel = readModel(finalHostAgentConfig?.[agentType]);
- if (directModel) return directModel;
- const resolvedName = resolveRuntimeAgentName(runtime, agentType);
- return readModel(finalHostAgentConfig?.[resolvedName]);
- };
- try {
- config = loadPluginConfig(ctx.directory);
- // Seed the per-directory runtime registry with the raw plugin file
- // config. The runtime preset reapplication below mutates `config` for
- // legacy consumers; RuntimeConfig keeps the pre-mutation snapshot and
- // derives preset/runtime state through its own getters.
- RuntimeConfig.init(ctx.directory, config);
- // Safety net: instance disposal reruns the plugin factory and rebuilds
- // factory-local state, while module-level runtime preset state may persist.
- // Reapply that persisted preset so each fresh generation creates agents
- // with the correct models.
- const runtimePreset = RuntimeConfig.get(ctx.directory).getRuntimePreset();
- 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
- RuntimeConfig.get(ctx.directory).setRuntimePreset(null);
- }
- runtime = RuntimeConfig.get(ctx.directory);
- rewriteDisplayNameMentions = createDisplayNameMentionRewriter(runtime);
- // Host flavor marker ('v2' on OpenCode v2 hosts, set by the v2 client
- // shim; absent on v1). Threads the native delegation vocabulary into
- // prompt assembly so v2 prompts say subagent(...)/agent directly.
- hostFlavor = (ctx as Parameters<Plugin>[0] & { hostFlavor?: string })
- .hostFlavor;
- agentDefs = createAgents(runtime, {
- projectDirectory: ctx.directory,
- hostFlavor,
- });
- agents = getAgentConfigs(runtime, {
- projectDirectory: ctx.directory,
- hostFlavor,
- });
- // Parse multiplexer config with defaults
- multiplexerConfig = runtime.multiplexer;
- multiplexerEnabled = shouldEnableMultiplexer({
- hostFlavor,
- multiplexerConfig,
- });
- log('[plugin] initialized with multiplexer config', {
- multiplexerConfig,
- enabled: multiplexerEnabled,
- directory: ctx.directory,
- });
- // Start background availability check if enabled
- if (multiplexerEnabled) {
- startAvailabilityCheck(multiplexerConfig);
- }
- mcps = createBuiltinMcps(runtime.disabledMcps);
- acpRunTools =
- Object.keys(runtime.acpAgents ?? {}).length > 0
- ? { acp_run: createAcpRunTool(runtime.acpAgents) }
- : {};
- const webfetchModel = runtime.webfetch?.model;
- const webfetchModels = (() => {
- if (!webfetchModel) return undefined;
- const entries = Array.isArray(webfetchModel)
- ? webfetchModel
- : [webfetchModel];
- type ModelRefInput = string | { id: string; variant?: string };
- const models: Array<{ id: string; variant?: string }> = [];
- for (const entry of entries as ModelRefInput[]) {
- const id = typeof entry === 'string' ? entry : entry.id;
- if (!id) continue;
- models.push({
- id,
- ...(typeof entry === 'object' && entry.variant
- ? { variant: entry.variant }
- : {}),
- });
- }
- return models.length > 0 ? models : undefined;
- })();
- webfetch = createWebfetchTool(ctx, {
- binaryDir: undefined,
- webfetchModels,
- explorerModel: pickAgentModelRef(runtime.agent('explorer')?.model),
- librarianModel: pickAgentModelRef(runtime.agent('librarian')?.model),
- smallModelRef: () => runtime.smallModel(),
- });
- backgroundJobBoard = new BackgroundJobBoard({
- maxReusablePerAgent: runtime.backgroundJobs.maxSessionsPerAgent,
- maxContextLines: runtime.backgroundJobs.maxContextLines,
- readContextMinLines: runtime.backgroundJobs.readContextMinLines,
- readContextMaxFiles: runtime.backgroundJobs.readContextMaxFiles,
- });
- admissionRuntimeLease = acquireAdmissionRuntime(
- ctx.directory,
- runtime.backgroundJobs.concurrency,
- );
- backgroundTaskConcurrency = admissionRuntimeLease.backgroundTaskConcurrency;
- // Initialize coordinator as the sole writer to the board
- const backgroundJobCoordinator = new BackgroundJobCoordinator(
- backgroundJobBoard,
- );
- backgroundJobSupervisor = new BackgroundJobSupervisor({
- backgroundJobStore: backgroundJobCoordinator,
- wallClockTimeoutMs: runtime.backgroundJobs.wallClockTimeoutMs,
- abortGraceMs: runtime.backgroundJobs.abortGraceMs,
- abort: (taskID) =>
- ctx.client.session.abort({
- path: { id: taskID },
- }),
- });
- backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
- backgroundJobSupervisor.onTerminal(record);
- backgroundTaskConcurrency.releaseTask(record.taskID);
- });
- revivedRunTracker = createRevivedRunTracker({
- input: ctx,
- backgroundJobBoard: backgroundJobCoordinator,
- backgroundJobSupervisor,
- onRegister: (taskID) => markRevivedRunPending(taskID),
- onSettled: (taskID) => markRevivedRunSettled(taskID),
- contextFilesForPrompt: (taskID) => getRevivedContextFiles(taskID),
- pruneContext: () => pruneRevivedContext(),
- });
- backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
- revivedRunTracker.onTerminal(record);
- });
- // Initialize MultiplexerSessionManager to handle OpenCode's built-in
- // Task tool sessions. On v2 hosts the multiplexer is host-gated off
- // (shouldEnableMultiplexer), so the manager's config is forced to
- // 'none' — otherwise its env-based self-gate could re-enable pane
- // management inside tmux/zellij on a v2 host.
- multiplexerSessionManager = new MultiplexerSessionManager(
- ctx,
- sessionManagerMultiplexerConfig(hostFlavor, multiplexerConfig),
- backgroundJobCoordinator,
- );
- backgroundJobCoordinator.addTerminalStateListener((taskID) => {
- void multiplexerSessionManager.closeSessionFromCoordinator(taskID);
- });
- backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
- if (record.deadlineExceededAt === undefined) return;
- void multiplexerSessionManager.closeSessionPermanentlyFromCoordinator(
- record.taskID,
- );
- });
- sessionLifecycle = new SessionLifecycle(log);
- // Initialize auto-update checker hook
- autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
- autoUpdate: runtime.autoUpdate,
- companion: runtime.companion,
- });
- 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(
- runtime.runtimeChains,
- runtime.fallback.enabled !== false,
- ctx,
- runtime.fallback.maxRetries,
- sessionLifecycle,
- // A managed background-task session switching models mid-flight must
- // move its admission accounting (provider/model caps) to the new
- // model. No-op for unknown/non-task sessions; idempotent per model.
- (sessionID, model) =>
- backgroundTaskConcurrency.migrateTask(sessionID, model),
- runtime.fallback.initialRetryDelayMs,
- runtime.fallback.retryDelayMs,
- );
- deepworkCommandHook = createDeepworkCommandHook();
- reflectCommandHook = createReflectCommandHook();
- loopCommandHook = createLoopCommandHook();
- taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
- strategy: runtime.backgroundJobs.strategy,
- maxSessionsPerAgent: runtime.backgroundJobs.maxSessionsPerAgent,
- maxRetainedSnapshots: runtime.backgroundJobs.maxRetainedSnapshots,
- readContextMinLines: runtime.backgroundJobs.readContextMinLines,
- readContextMaxFiles: runtime.backgroundJobs.readContextMaxFiles,
- backgroundJobBoard: backgroundJobCoordinator,
- backgroundJobSupervisor,
- backgroundTaskConcurrency,
- pendingCallTracker: admissionRuntimeLease.pendingCallTracker,
- getModelForAgent: (agentType: string, parentSessionID?: string) =>
- // Admission must use the config after the host has merged all of its
- // agent layers. The direct lookup preserves display-name keys; the
- // resolved lookup handles canonical names and legacy aliases. A
- // parent model is only an inheritance fallback when neither final
- // agent entry carries one.
- resolvePrimaryModelFromFinalHostConfig(agentType) ??
- (parentSessionID
- ? sessionMetadata.getModel(parentSessionID)
- : undefined),
- sameProviderPolicy: runtime.backgroundJobs.sameProviderPolicy,
- getSessionModel: (sessionID) => sessionMetadata.getModel(sessionID),
- shouldManageSession: (sessionID) =>
- sessionMetadata.getAgent(sessionID) === 'orchestrator',
- registerSessionAsOrchestrator: (sessionID) => {
- sessionMetadata.setAgent(sessionID, 'orchestrator');
- },
- isFallbackInProgress: (sessionID) =>
- foregroundFallback.isFallbackInProgress(sessionID),
- willAttemptFallback: (sessionID) =>
- foregroundFallback.willAttemptFallback(sessionID),
- coordinator: sessionLifecycle,
- revivedRunTracker,
- });
- markRevivedRunPending = taskSessionManagerHook.markRevivedRunPending;
- markRevivedRunSettled = taskSessionManagerHook.clearRevivedRunPending;
- getRevivedContextFiles = taskSessionManagerHook.contextFilesForTask;
- pruneRevivedContext = taskSessionManagerHook.pruneTaskContext;
- orchestratorWakeScheduler = createOrchestratorWakeScheduler(ctx, {
- config: runtime.backgroundJobs.orchestratorWake,
- shouldManageSession: (sessionID) =>
- sessionMetadata.getAgent(sessionID) === 'orchestrator',
- hasInputWait: (sessionID) =>
- taskSessionManagerHook.hasInputWait(sessionID),
- isFallbackInProgress: (sessionID) =>
- foregroundFallback.isFallbackInProgress(sessionID),
- isStoppedJobRecoveryCurrent: (taskID, generation) => {
- const record = backgroundJobCoordinator.get(taskID);
- return (
- record?.generation === generation &&
- record.state === 'stopped' &&
- record.terminalUnreconciled
- );
- },
- coordinator: sessionLifecycle,
- });
- backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
- if (record.state !== 'stopped' || !record.terminalUnreconciled) return;
- orchestratorWakeScheduler.triggerStoppedJobRecovery(
- record.parentSessionID,
- // Self-contained stop facts: the recovery wake is an
- // internal-initiator message, so under `checkpoint-compatible` it
- // cannot create a board snapshot and any retained snapshot predates
- // this stop (issue #1051).
- formatStoppedJobDelta({
- alias: record.alias,
- taskID: record.taskID,
- generation: record.generation,
- state: record.state,
- reason: record.timedOut
- ? 'wall-clock deadline exceeded'
- : record.statusUncertain
- ? 'runtime status uncertain'
- : 'stopped without a terminal result',
- }),
- `${record.taskID}:${record.generation}`,
- );
- });
- // 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) =>
- sessionMetadata.getAgent(sessionID) === 'orchestrator';
- phaseReminder = createPhaseReminderHook({
- shouldInject: shouldInjectOrchestratorReminder,
- });
- filterAvailableSkills = createFilterAvailableSkillsHook(ctx, runtime);
- postFileToolNudge = createPostFileToolNudgeHook({
- shouldInject: shouldInjectOrchestratorReminder,
- coordinator: sessionLifecycle,
- });
- applyPatch = createApplyPatchHook(ctx);
- searchPathGuard = createSearchPathGuardHook(ctx);
- absolutePathRescue = createAbsolutePathRescueHook(ctx);
- jsonErrorRecovery = createJsonErrorRecoveryHook(ctx);
- toolLoopGuard = createToolLoopGuardHook();
- // 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),
- );
- 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,
- runtime.companion,
- );
- taskCancelTools = createCancelTaskTool({
- input: ctx,
- backgroundJobBoard: backgroundJobCoordinator,
- shouldManageSession: (sessionID) =>
- sessionMetadata.getAgent(sessionID) === 'orchestrator',
- });
- taskMessageTools = createTaskMessageTool({
- input: ctx,
- backgroundJobBoard: backgroundJobCoordinator,
- });
- taskResultTools = createTaskResultTool({
- input: ctx,
- backgroundJobBoard: backgroundJobCoordinator,
- });
- taskReviveTools = createTaskReviveTool({
- input: ctx,
- backgroundJobBoard: backgroundJobCoordinator,
- shouldManageSession: (sessionID) =>
- sessionMetadata.getAgent(sessionID) === 'orchestrator',
- backgroundJobSupervisor,
- revivedRunTracker,
- });
- taskStatusTools = createTaskStatusTool({
- input: ctx,
- backgroundJobBoard: backgroundJobCoordinator,
- activityTracker: taskActivityTracker,
- });
- waitForUserTools = createWaitForUserTool({
- shouldManageSession: (sessionID) =>
- sessionMetadata.getAgent(sessionID) === 'orchestrator',
- resolveAgentName: (agent) => resolveRuntimeAgentName(runtime, agent),
- registerSessionAsOrchestrator: (sessionID) => {
- sessionMetadata.setAgent(sessionID, 'orchestrator');
- },
- beginUserWait: (sessionID) => {
- taskSessionManagerHook.beginUserWait(sessionID);
- orchestratorWakeScheduler.suppress(sessionID);
- },
- waitForUserGuardEnabled: runtime.backgroundJobs.waitForUserGuard,
- hasOutstandingBackgroundTasks: (sessionID) =>
- runtime.backgroundJobs.orchestratorWake.enabled &&
- backgroundJobCoordinator.hasRunning(sessionID),
- });
- const shouldRegisterWebfetch = runtime.webfetch.enabled !== false;
- tools = {
- ...taskCancelTools,
- ...taskMessageTools,
- ...taskResultTools,
- ...taskReviveTools,
- ...taskStatusTools,
- ...waitForUserTools,
- ...acpRunTools,
- ...(shouldRegisterWebfetch ? { webfetch } : {}),
- ast_grep_search,
- ast_grep_replace,
- };
- if (runtime.disabledTools.length > 0) {
- const disabledTools = new Set(runtime.disabledTools);
- tools = Object.fromEntries(
- Object.entries(tools).filter(([name]) => !disabledTools.has(name)),
- );
- }
- toolCount = Object.keys(tools).length;
- } catch (err) {
- admissionRuntimeLease?.release();
- // 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 =
- runtime.disabledMcps.length > 0 ? 0 : HEALTH_CHECK.minMcps;
- const toolThreshold = minimumExpectedToolCount(
- runtime.disabledTools,
- runtime.webfetch.enabled !== false,
- );
- 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 (runtime.companion?.enabled === true) {
- try {
- const companionResult = await ensureCompanionVersion({
- config: runtime.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 = runtime.agents()[agentName];
- const defaultVariant =
- typeof configEntry?.variant === 'string'
- ? configEntry.variant
- : undefined;
- const chainMatches = runtime.modelArrays[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>) => {
- // Capture the host opencode config BEFORE any mutation so runtime
- // consumers can distinguish host-provided values from plugin-applied
- // ones (host override > runtime override > plugin file).
- RuntimeConfig.get(ctx.directory).captureHostConfig(opencodeConfig);
- // Force default_agent to the orchestrator's visible entry 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"). With a display name, the canonical 'orchestrator'
- // registration is a hidden alias, so default to its visible entry.
- // 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 (runtime.setDefaultAgent) {
- const existing = (opencodeConfig as { default_agent?: string })
- .default_agent;
- if (!existing || isSubagent(existing)) {
- const orchestratorAlias = agents.orchestrator as
- | {
- displayName?: string;
- hidden?: boolean;
- }
- | undefined;
- (opencodeConfig as { default_agent?: string }).default_agent =
- orchestratorAlias?.hidden && orchestratorAlias.displayName
- ? normalizeAgentName(orchestratorAlias.displayName)
- : '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 = runtime.modelArrays[name]?.[0]?.id;
- if (primary && existing.model !== primary) {
- runtime.everModelSwitched(name);
- }
- if (runtime.hasModelSwitched(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>;
- applyModelInheritanceToConfig(configAgent, runtime);
- // 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(runtime.modelArrays).length > 0) {
- for (const [agentName, models] of Object.entries(runtime.modelArrays)) {
- 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: instance disposal recreates the plugin
- // factory and its factory-local state, while module-level runtime
- // preset data may persist. Apply that persisted selection after normal
- // model resolution for the current generation.
- const runtimePresetName = runtime.getRuntimePreset();
- 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,
- });
- }
- }
- // 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
- : runtime.runtimeChains[agentDef.name]?.[0]
- ? runtime.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: runtime.stripOrchestratorModel,
- presets: runtime.plugin?.presets,
- configPreset: runtime.preset,
- runtimePreset: runtimePresetName,
- });
- // Host `agent.council.prompt` wins the shallow merge above and can
- // drop the compaction exception applied in createAgents. Re-apply to
- // the canonical key and the visible display-name alias.
- const councilPlugin = agents.council as
- | { displayName?: string }
- | undefined;
- const councilKeys = new Set<string>(['council']);
- if (typeof councilPlugin?.displayName === 'string') {
- councilKeys.add(normalizeAgentName(councilPlugin.displayName));
- }
- for (const key of councilKeys) {
- const entry = configAgent[key] as Record<string, unknown> | undefined;
- if (typeof entry?.prompt === 'string') {
- entry.prompt = ensureCouncilCompactionException(entry.prompt);
- }
- }
- // This is the source of truth for admission. It is intentionally
- // captured only after every host/plugin merge and the final model
- // inheritance, array-primary, preset, and orchestrator-model passes.
- finalHostAgentConfig = configAgent;
- // 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 };
- };
- };
- // Session-scoped events (session.*) carry the session id in info.id;
- // message/step-scoped events (message.updated, step-finish) carry the
- // message id in info.id and the session id in info.sessionID. Resolve
- // by session so child activity refreshes the correct stuck timer.
- const eventSessionID = resolveEventSessionID(event);
- const statusType = event.properties?.status?.type;
- if (
- eventSessionID &&
- sessionMetadata.getAgent(eventSessionID) === 'orchestrator' &&
- (event.type === 'session.idle' ||
- (event.type === 'session.status' && statusType === 'idle'))
- ) {
- toolLoopGuard.resetTurn(eventSessionID);
- }
- if (eventSessionID && event.type === 'session.deleted') {
- toolLoopGuard.resetSession(eventSessionID);
- }
- if (eventSessionID) {
- applyActivityEvent(taskActivityTracker, event);
- if (
- event.type === 'session.status' &&
- (statusType === 'busy' || statusType === 'retry')
- ) {
- sessionMetadata.markOrchestratorActive(eventSessionID);
- const agentName = sessionMetadata.getAgent(eventSessionID);
- if (agentName) {
- pendingTuiBusySessions.delete(eventSessionID);
- markTuiAgentActive(eventSessionID, agentName);
- } else {
- pendingTuiBusySessions.add(eventSessionID);
- }
- } else if (
- event.type === 'session.idle' ||
- (event.type === 'session.status' && statusType === 'idle') ||
- event.type === 'session.deleted'
- ) {
- pendingTuiBusySessions.delete(eventSessionID);
- sessionMetadata.markOrchestratorIdle(eventSessionID);
- markTuiAgentInactive(eventSessionID);
- }
- }
- 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;
- // Track each session's current model so background task admission
- // can resolve the model a model-less subagent will inherit.
- if (typeof info?.sessionID === 'string' && providerID && modelID) {
- const model = `${providerID}/${modelID}`;
- sessionMetadata.setModel(info.sessionID, model);
- // Managed background-task sessions are identified by their session
- // ID. If the model serving one changed (fallback re-prompt, runtime
- // switch), migrate the admission accounting so provider/model caps
- // keep tracking the model actually in use. No-op for other
- // sessions and idempotent when the model is unchanged.
- backgroundTaskConcurrency.migrateTask(info.sessionID, model);
- }
- if (typeof info?.agent === 'string' && providerID && modelID) {
- const agentName = resolveRuntimeAgentName(runtime, info.agent);
- const model = `${providerID}/${modelID}`;
- const variant = resolveTuiVariantForModel(agentName, model);
- recordTuiAgentModel(
- {
- agentName,
- model,
- variant: variant ?? null,
- },
- (info?.sessionID && sessionMetadata.getDirectory(info.sessionID)) ??
- ctx.directory,
- );
- }
- }
- if (event.type === 'session.created') {
- const createdSessionId = event.properties?.info?.id;
- const createdSessionDir = event.properties?.info?.directory;
- const createdSessionParent = (
- event.properties as { info?: { parentID?: unknown } } | undefined
- )?.info?.parentID;
- if (createdSessionId && typeof createdSessionParent === 'string') {
- // Persist the child→parent link so any process can resolve the
- // conversation root, surviving restarts and revives (#1147).
- recordTuiSessionParent(
- createdSessionId,
- createdSessionParent,
- createdSessionDir ?? ctx.directory,
- );
- }
- if (createdSessionId && createdSessionDir) {
- sessionMetadata.setDirectory(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();
- },
- );
- if (event.type === 'server.instance.disposed') {
- clearTuiActivities();
- }
- await orchestratorWakeScheduler.event(
- input as {
- event: {
- type: string;
- properties?: {
- info?: { id?: string };
- sessionID?: string;
- status?: { type?: string };
- };
- };
- },
- );
- // 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 ? sessionMetadata.getAgent(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) {
- sessionMetadata.delete(sessionID);
- }
- }
- },
- dispose: async () => {
- await taskSessionManagerHook.event({
- event: { type: 'server.instance.disposed' },
- });
- await orchestratorWakeScheduler.event({
- event: { type: 'server.instance.disposed' },
- });
- await interviewManager.dispose();
- await multiplexerSessionManager.cleanupOnInstanceDisposed();
- clearTuiActivities();
- // Release only this generation's ownership. The admission runtime
- // defers final scheduler/tracker teardown by one macrotask so an
- // immediate config-update re-init can retain active and queued calls.
- admissionRuntimeLease?.release();
- },
- 'tool.execute.before': async (input, output) => {
- await applyPatch['tool.execute.before'](input as never, output as never);
- // Rewrite guessed non-existing absolute paths BEFORE the search
- // guard: the guard blocks grep/glob on missing paths, so running
- // the rescue after it would never see a rescuable path (#1143).
- await absolutePathRescue['tool.execute.before'](
- input as never,
- output as never,
- );
- await searchPathGuard['tool.execute.before'](
- input as never,
- output as never,
- );
- await taskSessionManagerHook['tool.execute.before'](
- input as never,
- output as never,
- );
- // Record a call only after all rejecting before-hooks have accepted it.
- // In particular, search-path-guard can reject grep/glob before the host
- // emits tool.execute.after; running the loop guard first would leave a
- // pending call-key entry with no completion to consume it.
- await toolLoopGuard['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;
- model?: {
- providerID: string;
- modelID: string;
- };
- variant?: string;
- parts?: unknown[];
- /** OpenCode chat.message message identity when present. */
- messageID?: string;
- },
- output?: {
- message?: {
- id?: string;
- agent?: string;
- role?: string;
- sessionID?: string;
- model?: {
- providerID: string;
- modelID: string;
- variant?: string;
- };
- };
- parts?: unknown[];
- },
- ) => {
- const rawAgent = input.agent ?? output?.message?.agent;
- const agent = rawAgent
- ? resolveRuntimeAgentName(runtime, rawAgent)
- : undefined;
- if (
- agent &&
- output?.message &&
- typeof output.message.agent === 'string'
- ) {
- output.message.agent = agent;
- }
- if (agent) {
- foregroundFallback.registerSessionAgent(input.sessionID, agent);
- sessionMetadata.setAgent(input.sessionID, agent);
- // Spinner follows session.status, not chat.message: v2 context
- // hooks re-deliver chat.message after idle and would otherwise
- // relight a finished row (and the parent of a background child).
- // An already-active session (busy under a stale/unknown agent)
- // refreshes the association so the row follows the real agent.
- if (
- pendingTuiBusySessions.has(input.sessionID) ||
- ownedTuiActivitySessions.has(input.sessionID)
- ) {
- pendingTuiBusySessions.delete(input.sessionID);
- markTuiAgentActive(input.sessionID, agent);
- }
- companionManager.onSessionStatus({
- sessionId: input.sessionID,
- agent,
- status: 'busy',
- });
- }
- // chat.message carries the model selected for this message, and it
- // fires before the message.updated event that the event hook relies
- // on. Recording it here closes the early window where a session-
- // inheriting background task could be admitted before its parent's
- // model is known — admission then resolves the correct provider/model
- // cap immediately.
- const messageModel = input.model ?? output?.message?.model;
- if (
- messageModel &&
- typeof messageModel.providerID === 'string' &&
- typeof messageModel.modelID === 'string'
- ) {
- const model = `${messageModel.providerID}/${messageModel.modelID}`;
- sessionMetadata.setModel(input.sessionID, model);
- backgroundTaskConcurrency.migrateTask(input.sessionID, model);
- }
- taskSessionManagerHook.observeChatMessage(input, output);
- orchestratorWakeScheduler.observeChatMessage(input, output);
- const messageID = input.messageID ?? output?.message?.id;
- if (messageID) {
- toolLoopGuard.observeNewUserMessage(input.sessionID, messageID);
- }
- },
- // 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
- ? sessionMetadata.getAgent(input.sessionID)
- : undefined;
- if (agentName === 'orchestrator') {
- const alreadyInjected = output.system.some(
- (s) =>
- typeof s === 'string' &&
- s.includes('<Role>') &&
- s.includes('orchestrator'),
- );
- if (!alreadyInjected) {
- // Place the orchestrator prompt after AGENTS.md so the user's
- // behavioral rules (language, code conventions, etc.) retain
- // their intended priority. AGENTS.md is injected by OpenCode
- // core into system[0]; prepending the orchestrator prompt before
- // it buries user-defined rules under thousands of lines of
- // orchestration instructions.
- const orchestratorDef = agentDefs.find(
- (a) => a.name === 'orchestrator',
- );
- const orchestratorPrompt =
- typeof orchestratorDef?.config?.prompt === 'string'
- ? orchestratorDef.config.prompt
- : buildOrchestratorPrompt(
- runtime.disabledAgents,
- undefined,
- true,
- true,
- hostFlavor,
- );
- output.system[0] = `${output.system[0] || ''}\n\n${orchestratorPrompt}`;
- }
- }
- // 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.
- const imageResult = processImageAttachments({
- messages: typedOutput.messages,
- workDir: ctx.directory,
- imageRouting: runtime.imageRouting,
- disabledAgents: runtime.disabledAgents,
- log,
- });
- if (imageResult) {
- const now = Date.now();
- const last = lastImageSkippedToastByDir.get(ctx.directory) ?? 0;
- if (now - last > IMAGE_SKIPPED_DEBOUNCE_MS) {
- ctx.client.tui
- .showToast({
- body: {
- title: 'Images skipped',
- message:
- 'Observer agent is disabled, so images can\'t be analyzed. Set image_routing to "direct" to send images to your model, or enable observer.',
- variant: 'warning',
- duration: TOAST_DURATION_MS,
- },
- })
- .then(() => {
- // Only advance the debounce window on a successful toast
- // so a failed attempt doesn't suppress the next warning.
- // Greptile: "Failed Toast Starts Debounce Window".
- lastImageSkippedToastByDir.set(ctx.directory, now);
- })
- .catch(() => {});
- }
- }
- // 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 jsonErrorRecoveryAfter(input, output);
- await toolLoopGuard['tool.execute.after'](
- input as never,
- output as never,
- );
- await taskSessionManagerAfter(input, output);
- },
- };
- };
- export default {
- id: 'oh-my-opencode-slim',
- // NOTE: do not add a `tui` key here. OpenCode v1.18.23+ (and v2's
- // byte-identical readV1Plugin) validate the default export of a server
- // plugin module: `tui`, when present, must be a function and must not
- // coexist with `server` — a boolean marker makes the whole plugin fail
- // to load with "invalid tui export". The TUI entry is discovered
- // separately by hosts through the package.json `./tui` export
- // (dist/tui2.js), never through this module.
- server: OhMyOpenCodeLite,
- setup: createV2Setup(),
- };
- export type {
- AgentName,
- AgentOverrideConfig,
- McpName,
- MultiplexerConfig,
- MultiplexerLayout,
- MultiplexerType,
- PluginConfig,
- } from './config';
- export type { RemoteMcpConfig } from './mcp';
|