Browse Source

Merge pull request #343 from ReqX/feat/init-health-check

feat: add init-time health check and defensive logging
Alvin 3 months ago
parent
commit
a0cc45c84c
1 changed files with 333 additions and 173 deletions
  1. 333 173
      src/index.ts

+ 333 - 173
src/index.ts

@@ -36,156 +36,300 @@ import {
 import { resolveRuntimeAgentName, rewriteDisplayNameMentions } from './utils';
 import { initLogger, log } from './utils/logger';
 
+/**
+ * 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,
+  minTools: 5,
+  minMcps: 1,
+} as const;
+
+/**
+ * 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);
+  }
+}
+
 const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const sessionId = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
   initLogger(sessionId);
-  const config = loadPluginConfig(ctx.directory);
-  const agentDefs = createAgents(config);
-  const agents = getAgentConfigs(config);
-
-  // Build a map of agent name → priority model array for runtime fallback.
-  // Populated when the user configures model as an array in their plugin config.
-  const modelArrayMap: Record<
-    string,
-    Array<{ id: string; variant?: string }>
-  > = {};
-  for (const agentDef of agentDefs) {
-    if (agentDef._modelArray && agentDef._modelArray.length > 0) {
-      modelArrayMap[agentDef.name] = agentDef._modelArray;
+
+  // 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 agentDefs: ReturnType<typeof createAgents>;
+  let agents: ReturnType<typeof getAgentConfigs>;
+  let mcps: ReturnType<typeof createBuiltinMcps>;
+  let modelArrayMap: Record<string, Array<{ id: string; variant?: string }>>;
+  let runtimeChains: Record<string, string[]>;
+  let multiplexerConfig: MultiplexerConfig;
+  let multiplexerEnabled: boolean;
+  let backgroundManager: BackgroundTaskManager;
+  let multiplexerSessionManager: MultiplexerSessionManager;
+  let autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook>;
+  let phaseReminderHook: ReturnType<typeof createPhaseReminderHook>;
+  let filterAvailableSkillsHook: ReturnType<
+    typeof createFilterAvailableSkillsHook
+  >;
+  let sessionAgentMap: Map<string, string>;
+  let postFileToolNudgeHook: ReturnType<typeof createPostFileToolNudgeHook>;
+  let chatHeadersHook: ReturnType<typeof createChatHeadersHook>;
+  let delegateTaskRetryHook: ReturnType<typeof createDelegateTaskRetryHook>;
+  let applyPatchHook: ReturnType<typeof createApplyPatchHook>;
+  let jsonErrorRecoveryHook: ReturnType<typeof createJsonErrorRecoveryHook>;
+  let foregroundFallback: ForegroundFallbackManager;
+  let todoContinuationHook: ReturnType<typeof createTodoContinuationHook>;
+  let interviewManager: ReturnType<typeof createInterviewManager>;
+  let backgroundTools: ReturnType<typeof createBackgroundTools>;
+  let councilTools: Record<string, unknown>;
+  let webfetch: ReturnType<typeof createWebfetchTool>;
+
+  // Counters for post-init health check (set inside try, checked outside)
+  let toolCount = 0;
+
+  try {
+    config = loadPluginConfig(ctx.directory);
+    agentDefs = createAgents(config);
+    agents = getAgentConfigs(config);
+
+    // Build a map of agent name → priority model array for runtime
+    // fallback. Populated when the user configures model as an array in
+    // their plugin config.
+    modelArrayMap = {} as Record<
+      string,
+      Array<{ id: string; variant?: string }>
+    >;
+    for (const agentDef of agentDefs) {
+      if (agentDef._modelArray && agentDef._modelArray.length > 0) {
+        modelArrayMap[agentDef.name] = agentDef._modelArray;
+      }
     }
-  }
-  // Build runtime fallback chains for all foreground agents.
-  // Each chain is an ordered list of model strings to try when the current
-  // model is rate-limited. Seeds from _modelArray entries (when the user
-  // configures model as an array), then appends fallback.chains entries.
-  const runtimeChains: Record<string, string[]> = {};
-  for (const agentDef of agentDefs) {
-    if (agentDef._modelArray?.length) {
-      runtimeChains[agentDef.name] = agentDef._modelArray.map((m) => m.id);
+    // Build runtime fallback chains for all foreground agents. Each chain
+    // is an ordered list of model strings to try when the current model is
+    // rate-limited. Seeds from _modelArray entries (when the user
+    // configures model as an array), then appends fallback.chains entries.
+    runtimeChains = {} as Record<string, string[]>;
+    for (const agentDef of agentDefs) {
+      if (agentDef._modelArray?.length) {
+        runtimeChains[agentDef.name] = agentDef._modelArray.map((m) => m.id);
+      }
     }
-  }
-  if (config.fallback?.enabled !== false) {
-    const chains =
-      (config.fallback?.chains as Record<string, string[] | undefined>) ?? {};
-    for (const [agentName, chainModels] of Object.entries(chains)) {
-      if (!chainModels?.length) continue;
-      const existing = runtimeChains[agentName] ?? [];
-      const seen = new Set(existing);
-      for (const m of chainModels) {
-        if (!seen.has(m)) {
-          seen.add(m);
-          existing.push(m);
+    if (config.fallback?.enabled !== false) {
+      const chains =
+        (config.fallback?.chains as Record<string, string[] | undefined>) ?? {};
+      for (const [agentName, chainModels] of Object.entries(chains)) {
+        if (!chainModels?.length) continue;
+        const existing = runtimeChains[agentName] ?? [];
+        const seen = new Set(existing);
+        for (const m of chainModels) {
+          if (!seen.has(m)) {
+            seen.add(m);
+            existing.push(m);
+          }
         }
+        runtimeChains[agentName] = existing;
       }
-      runtimeChains[agentName] = existing;
     }
-  }
-
-  // Parse multiplexer config with defaults
-  const multiplexerConfig: MultiplexerConfig = {
-    type: config.multiplexer?.type ?? 'none',
-    layout: config.multiplexer?.layout ?? 'main-vertical',
-    main_pane_size: config.multiplexer?.main_pane_size ?? 60,
-  };
 
-  // Get multiplexer instance for capability checks
-  const multiplexer = getMultiplexer(multiplexerConfig);
-  const multiplexerEnabled =
-    multiplexerConfig.type !== 'none' && multiplexer !== null;
-
-  log('[plugin] initialized with multiplexer config', {
-    multiplexerConfig,
-    enabled: multiplexerEnabled,
-    directory: ctx.directory,
-  });
-
-  // Start background availability check if enabled
-  if (multiplexerEnabled) {
-    startAvailabilityCheck(multiplexerConfig);
-  }
+    // 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,
+    };
+
+    // Get multiplexer instance for capability checks
+    const multiplexer = getMultiplexer(multiplexerConfig);
+    multiplexerEnabled =
+      multiplexerConfig.type !== 'none' && multiplexer !== null;
+
+    log('[plugin] initialized with multiplexer config', {
+      multiplexerConfig,
+      enabled: multiplexerEnabled,
+      directory: ctx.directory,
+    });
+
+    // Start background availability check if enabled
+    if (multiplexerEnabled) {
+      startAvailabilityCheck(multiplexerConfig);
+    }
 
-  const backgroundManager = new BackgroundTaskManager(
-    ctx,
-    multiplexerConfig,
-    config,
-  );
-  const backgroundTools = createBackgroundTools(
-    ctx,
-    backgroundManager,
-    multiplexerConfig,
-    config,
-  );
-
-  // Initialize council tools (only when council is configured)
-  const councilTools = config.council
-    ? createCouncilTool(
-        ctx,
-        new CouncilManager(
+    backgroundManager = new BackgroundTaskManager(
+      ctx,
+      multiplexerConfig,
+      config,
+    );
+    backgroundTools = createBackgroundTools(
+      ctx,
+      backgroundManager,
+      multiplexerConfig,
+      config,
+    );
+
+    // Initialize council tools (only when council is configured)
+    councilTools = config.council
+      ? createCouncilTool(
           ctx,
-          config,
-          backgroundManager.getDepthTracker(),
-          multiplexerEnabled,
-        ),
-      )
-    : {};
-
-  const mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
-  const webfetch = createWebfetchTool(ctx);
-
-  // Initialize MultiplexerSessionManager to handle OpenCode's built-in Task tool sessions
-  const multiplexerSessionManager = new MultiplexerSessionManager(
-    ctx,
-    multiplexerConfig,
-  );
-
-  // Initialize auto-update checker hook
-  const autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
-    showStartupToast: config.showStartupToast ?? true,
-    autoUpdate: true,
-  });
-
-  // Initialize phase reminder hook for workflow compliance
-  const phaseReminderHook = createPhaseReminderHook();
-
-  // Initialize available skills filter hook
-  const filterAvailableSkillsHook = createFilterAvailableSkillsHook(
-    ctx,
-    config,
-  );
-
-  // Track session → agent mapping for serve-mode system prompt injection
-  const sessionAgentMap = new Map<string, string>();
-
-  // Initialize post-file-tool nudge hook
-  const postFileToolNudgeHook = createPostFileToolNudgeHook({
-    shouldInject: (sessionID) =>
-      sessionAgentMap.get(sessionID) === 'orchestrator',
-  });
-
-  const chatHeadersHook = createChatHeadersHook(ctx);
-
-  // Initialize delegate-task retry guidance hook
-  const delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
-
-  const applyPatchHook = createApplyPatchHook(ctx);
-  // Initialize JSON parse error recovery hook
-  const jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
+          new CouncilManager(
+            ctx,
+            config,
+            backgroundManager.getDepthTracker(),
+            multiplexerEnabled,
+          ),
+        )
+      : {};
+
+    mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
+    webfetch = createWebfetchTool(ctx);
+
+    // Initialize MultiplexerSessionManager to handle OpenCode's built-in
+    // Task tool sessions
+    multiplexerSessionManager = new MultiplexerSessionManager(
+      ctx,
+      multiplexerConfig,
+    );
+
+    // Initialize auto-update checker hook
+    autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
+      showStartupToast: config.showStartupToast ?? true,
+      autoUpdate: true,
+    });
+
+    // Initialize phase reminder hook for workflow compliance
+    phaseReminderHook = createPhaseReminderHook();
+
+    // Initialize available skills filter hook
+    filterAvailableSkillsHook = createFilterAvailableSkillsHook(ctx, config);
+
+    // Track session → agent mapping for serve-mode system prompt injection
+    sessionAgentMap = new Map<string, string>();
+
+    // Initialize post-file-tool nudge hook
+    postFileToolNudgeHook = createPostFileToolNudgeHook({
+      shouldInject: (sessionID) =>
+        sessionAgentMap.get(sessionID) === 'orchestrator',
+    });
+
+    chatHeadersHook = createChatHeadersHook(ctx);
+
+    // Initialize delegate-task retry guidance hook
+    delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
+
+    applyPatchHook = createApplyPatchHook(ctx);
+    // Initialize JSON parse error recovery hook
+    jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
+
+    // Initialize foreground fallback manager for runtime model switching
+    foregroundFallback = new ForegroundFallbackManager(
+      ctx.client,
+      runtimeChains,
+      config.fallback?.enabled !== false &&
+        Object.keys(runtimeChains).length > 0,
+    );
+
+    // Initialize todo-continuation hook (opt-in auto-continue for
+    // incomplete todos)
+    todoContinuationHook = createTodoContinuationHook(ctx, {
+      maxContinuations: config.todoContinuation?.maxContinuations ?? 5,
+      cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
+      autoEnable: config.todoContinuation?.autoEnable ?? false,
+      autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
+    });
+    interviewManager = createInterviewManager(ctx, config);
+
+    toolCount =
+      Object.keys(backgroundTools).length +
+      Object.keys(councilTools).length +
+      Object.keys(todoContinuationHook.tool).length +
+      1 + // webfetch
+      4 + // lsp_goto_definition, lsp_find_references, lsp_diagnostics, lsp_rename
+      2; // ast_grep_search, ast_grep_replace
+  } 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;
+  }
 
-  // Initialize foreground fallback manager for runtime model switching
-  const foregroundFallback = new ForegroundFallbackManager(
-    ctx.client,
-    runtimeChains,
-    config.fallback?.enabled !== false && Object.keys(runtimeChains).length > 0,
-  );
+  // ── 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;
+
+  if (
+    agentCount < HEALTH_CHECK.minAgents ||
+    toolCount < HEALTH_CHECK.minTools ||
+    mcpCount < mcpThreshold
+  ) {
+    const msg = [
+      'Health check: registrations suspiciously low.',
+      `  agents: ${agentCount} (expected >=${HEALTH_CHECK.minAgents})`,
+      `  tools:  ${toolCount} (expected >=${HEALTH_CHECK.minTools})`,
+      `  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,
+    });
+  }
 
-  // Initialize todo-continuation hook (opt-in auto-continue for incomplete todos)
-  const todoContinuationHook = createTodoContinuationHook(ctx, {
-    maxContinuations: config.todoContinuation?.maxContinuations ?? 5,
-    cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
-    autoEnable: config.todoContinuation?.autoEnable ?? false,
-    autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
+  // ── 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(() => {});
+    }
   });
-  const interviewManager = createInterviewManager(ctx, config);
 
   return {
     name: 'oh-my-opencode-slim',
@@ -248,12 +392,13 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
       const configAgent = opencodeConfig.agent as Record<string, unknown>;
 
-      // Model resolution for foreground agents: combine _modelArray entries
-      // with fallback.chains config, then pick the first model in the
-      // effective array for startup-time selection.
+      // Model resolution for foreground agents: combine _modelArray
+      // entries with fallback.chains config, then pick the first model in
+      // the effective array for startup-time selection.
       //
-      // Runtime failover on API errors (e.g. rate limits mid-conversation)
-      // is handled separately by ForegroundFallbackManager via the event hook.
+      // Runtime failover on API errors (e.g. rate limits
+      // mid-conversation) is handled separately by
+      // ForegroundFallbackManager via the event hook.
       const fallbackChainsEnabled = config.fallback?.enabled !== false;
       const fallbackChains = fallbackChainsEnabled
         ? ((config.fallback?.chains as Record<string, string[] | undefined>) ??
@@ -276,8 +421,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         if (!chainModels || chainModels.length === 0) continue;
 
         if (!effectiveArrays[agentName]) {
-          // Agent has no _modelArray — seed from its current string model so
-          // the fallback chain appends after it rather than replacing it.
+          // Agent has no _modelArray — seed from its current string model
+          // so the fallback chain appends after it rather than replacing
+          // it.
           const entry = configAgent[agentName] as
             | Record<string, unknown>
             | undefined;
@@ -301,12 +447,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         for (const [agentName, modelArray] of Object.entries(effectiveArrays)) {
           if (modelArray.length === 0) continue;
 
-          // Use the first model in the effective 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
+          // Use the first model in the effective 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 = modelArray[0];
           const entry = configAgent[agentName] as
@@ -318,7 +464,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
               entry.variant = chosen.variant;
             }
           } else {
-            // Agent exists in slim but not in opencodeConfig.agent — create entry
+            // 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 } : {}),
@@ -487,18 +634,22 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
     },
 
-    // Best-effort rescue only for stale apply_patch input before native execution
+    // Best-effort rescue only for stale apply_patch input before native
+    // execution
     'tool.execute.before': async (input, output) => {
       await applyPatchHook['tool.execute.before'](
         input as {
           tool: string;
           directory?: string;
         },
-        output as { args?: { patchText?: unknown; [key: string]: unknown } },
+        output as {
+          args?: { patchText?: unknown; [key: string]: unknown };
+        },
       );
     },
 
-    // Direct interception of /auto-continue command — bypasses LLM round-trip
+    // Direct interception of /auto-continue command — bypasses LLM
+    // round-trip
     'command.execute.before': async (input, output) => {
       await todoContinuationHook.handleCommandExecuteBefore(
         input as {
@@ -521,7 +672,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     'chat.headers': chatHeadersHook['chat.headers'],
 
-    // Track which agent each session uses (needed for serve-mode prompt injection)
+    // Track which agent each session uses (needed for serve-mode prompt
+    // injection)
     'chat.message': async (
       input: { sessionID: string; agent?: string },
       output?: { message?: { agent?: string } },
@@ -548,11 +700,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       });
     },
 
-    // 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.
+    // 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[] },
@@ -568,10 +721,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
             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.
+          // 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',
           );
@@ -592,19 +747,22 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       );
 
       // Collapse to single system message for provider compatibility.
-      // Some providers (e.g. Qwen3.5 via 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.
+      // Some providers (e.g. Qwen3.5 via 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.
       const joined = output.system.join('\n\n');
       output.system = joined ? [joined] : [];
     },
 
-    // Inject phase reminder and filter available skills before sending to API (doesn't show in UI)
+    // 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> => {
-      // Type assertion since we know the structure matches MessageWithParts[]
+      // Type assertion since we know the structure matches
+      // MessageWithParts[]
       const typedOutput = output as {
         messages: Array<{
           info: { role: string; agent?: string; sessionID?: string };
@@ -628,10 +786,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         }
       }
 
-      // 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.
+      // 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,
@@ -652,7 +811,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       );
     },
 
-    // Post-tool hooks: retry guidance for delegation errors + file-tool nudge
+    // Post-tool hooks: retry guidance for delegation errors + file-tool
+    // nudge
     'tool.execute.after': async (input, output) => {
       await delegateTaskRetryHook['tool.execute.after'](
         input as { tool: string },