Procházet zdrojové kódy

feat: add disabled_agents config + Observer agent (visual/multimodal analysis) (#307)

* feat: add disabled_agents config to completely disable specific agents

Add disabled_agents array to PluginConfigSchema, mirroring the existing
disabled_mcps pattern. Disabled agents are not instantiated, cannot be
delegated to via background_task, and are excluded from the Orchestrator
prompt's <Agents> section.

Key changes:
- Config: disabled_agents field with zod validation
- Constants: PROTECTED_AGENTS set for non-disableable agents
  (orchestrator, councillor, council-master)
- createAgents(): filters out disabled agents before instantiation
- orchestrator.ts: dynamic buildOrchestratorPrompt() that excludes
  disabled agent descriptions and delegation routing
- background-manager.ts: isAgentAllowed/getAllowedSubagents respect
  disabled_agents
- background.ts: tool description filters disabled agents from agent list
- Schema JSON: disabled_agents property added
- Tests: 7 new test cases covering disable, protection, and count

* feat: add looker agent (visual/multimodal analysis), disabled by default

Add a looker agent that interprets images, screenshots, PDFs, and diagrams,
extracting structured observations for the Orchestrator without loading raw
files into main context.

Key design decisions:
- looker is DISABLED BY DEFAULT via DEFAULT_DISABLED_AGENTS mechanism
- Users must set disabled_agents: [] (or omit looker from the list) and
  configure a vision-capable model to enable it
- This ensures no overhead for users without multimodal needs

Changes:
- src/agents/looker.ts: New agent factory (read-only, low temperature)
- src/config/constants.ts: looker in SUBAGENT_NAMES, ORCHESTRATABLE_AGENTS,
  SUBAGENT_DELEGATION_RULES, DEFAULT_MODELS; new DEFAULT_DISABLED_AGENTS
- src/agents/index.ts: createAgents() merges DEFAULT_DISABLED_AGENTS with
  user config (user config overrides defaults entirely)
- src/agents/orchestrator.ts: looker in AGENT_DESCRIPTIONS, VALIDATION_ROUTING,
  PARALLEL_DELEGATION_EXAMPLES
- src/config/agent-mcps.ts: looker: [] (no MCPs needed)
- src/config/schema.ts: Updated disabled_agents description
- src/agents/index.test.ts: 6 new looker tests + updated existing tests

* fix: foreground model resolution for agents not in opencodeConfig.agent

When a slim agent (like looker) has model configured via preset but
doesn't have a corresponding entry in OpenCode's opencodeConfig.agent,
the foreground model resolution loop would skip it entirely because
the entry was undefined.

Now automatically creates the agent entry in opencodeConfig.agent
when missing, ensuring the resolved model is applied correctly.

Also removes debug log added in previous iteration.

* refactor: strengthen looker delegation guidance in orchestrator prompt

* fix: update background-manager tests for looker agent order

* fix: resolve code review findings - disabled_agents consistency + serve-mode prompt + filter matchAll

* refactor: rename looker to multimodal agent per review feedback

- Rename looker.ts → multimodal.ts (agent name, factory, prompt, all refs)
- DEFAULT_DISABLED_AGENTS: ['multimodal'] (disabled by default)
- Add README section explaining why multimodal is a separate agent
- Update schema description
- All 855 tests pass

* fix: instruct orchestrator to pass file path when delegating to @multimodal

* feat: strip image bytes from orchestrator messages, auto-delegate to @multimodal

When the orchestrator model doesn't support image input, the API call
fails before the LLM can respond. This hook detects image parts in user
messages, replaces them with a text nudge to delegate to @multimodal.

Only active when @multimodal is enabled (not in disabled_agents).

* feat: save inline images to workspace, strip from orchestrator, auto-delegate to @multimodal

- Detect image file parts (type: 'file' + mime: 'image/*') in user messages
- Decode base64 data URL and save to .opencode/images/ in workspace
- Replace image bytes with text nudge containing file path
- Orchestrator sees path, delegates to @multimodal with file path
- Auto-cleanup: delete images older than 1 hour on each hook trigger
- Add .opencode/.gitignore to exclude from version control
- Only active when @multimodal is enabled (not in disabled_agents)
- Remove debug logs from image-hook

* refactor: improve multimodal prompt - anti-hallucination, OCR, multi-file

* fix: address code review findings — remove debug loop, fix unnamed image accumulation, DRY disabled-agent logic

* refactor: rename multimodal → observer, extract image hook to module, debounce cleanup

- Rename agent from multimodal to observer to match naming convention
  (explorer, librarian, oracle, designer, fixer, observer)
- Extract image interception logic from src/index.ts to src/hooks/image-hook.ts
- Debounce cleanup to run every 10 minutes instead of every transform
- Add error logging to mkdir/gitignore catch blocks
jwcrystal před 3 měsíci
rodič
revize
0e8cbc0043

+ 33 - 0
README.md

@@ -342,6 +342,39 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
 </table>
 
+### 07. Observer: The Silent Witness
+
+> [!NOTE]
+> **Why a separate agent?** Not all models support vision. Your strongest coding model (e.g. for design decisions) may not be able to read images, while a vision-capable model may not be the best for reasoning. Observer solves this by having its **own model** — configure a vision-capable model for it while keeping Designer on your strongest reasoning model. Disabled by default; enable via `disabled_agents: []` in config.
+
+<table>
+  <tr>
+    <td width="240" valign="top">
+      <b>Observer</b><br>
+      <i>Visual & binary analysis</i>
+    </td>
+    <td>
+
+**Read-only visual analysis** — interprets images, screenshots, PDFs, and diagrams. Returns structured observations to the orchestrator without loading raw file bytes into the main context window.
+
+- Images, screenshots, diagrams → `read` tool (native image support)
+- PDFs and binary documents → `read` tool (text + structure extraction)
+- **Disabled by default** — enable with `"disabled_agents": []` and configure a vision-capable model
+
+    </td>
+  </tr>
+  <tr>
+    <td colspan="2">
+      <b>Prompt:</b> <a href="src/agents/observer.ts"><code>observer.ts</code></a>
+    </td>
+  </tr>
+  <tr>
+    <td colspan="2">
+      <b>Default Model:</b> <code>openai/gpt-5.4-mini</code> — <i>configure a vision-capable model to enable</i>
+    </td>
+  </tr>
+</table>
+
 ---
 
 ## 📚 Documentation

+ 7 - 0
oh-my-opencode-slim.schema.json

@@ -339,6 +339,13 @@
         }
       }
     },
+    "disabled_agents": {
+      "description": "Agent names to disable completely. Disabled agents are not instantiated and cannot be delegated to. Orchestrator and council internal agents (councillor, council-master) cannot be disabled. By default, 'observer' is disabled. Remove it from this list and configure a vision-capable model to enable.",
+      "type": "array",
+      "items": {
+        "type": "string"
+      }
+    },
     "disabled_mcps": {
       "type": "array",
       "items": {

+ 131 - 3
src/agents/index.test.ts

@@ -2,10 +2,17 @@ import { describe, expect, test } from 'bun:test';
 import type { PluginConfig } from '../config';
 import {
   AgentOverrideConfigSchema,
+  DEFAULT_DISABLED_AGENTS,
   DEFAULT_MODELS,
   SUBAGENT_NAMES,
 } from '../config';
-import { createAgents, getAgentConfigs, isSubagent } from './index';
+import {
+  createAgents,
+  getAgentConfigs,
+  getDisabledAgents,
+  getEnabledAgentNames,
+  isSubagent,
+} from './index';
 
 describe('agent alias backward compatibility', () => {
   test("applies 'explore' config to 'explorer' agent", () => {
@@ -272,7 +279,8 @@ describe('agent classification', () => {
   });
 
   test('getAgentConfigs applies correct classification visibility and mode', () => {
-    const configs = getAgentConfigs();
+    // Enable all agents (including observer) for classification testing
+    const configs = getAgentConfigs({ disabled_agents: [] });
 
     // Primary agent
     expect(configs.orchestrator.mode).toBe('primary');
@@ -301,7 +309,7 @@ describe('createAgents', () => {
     expect(names).toContain('fixer');
   });
 
-  test('creates exactly 9 agents (1 primary + 8 subagents)', () => {
+  test('creates exactly 9 agents by default (1 orchestrator + 8 subagents, observer disabled)', () => {
     const agents = createAgents();
     expect(agents.length).toBe(9);
   });
@@ -549,3 +557,123 @@ describe('AgentOverrideConfigSchema options validation', () => {
     expect(result.success).toBe(false);
   });
 });
+
+describe('disabled_agents', () => {
+  test('disabled agents are not created', () => {
+    const config: PluginConfig = {
+      disabled_agents: ['designer', 'fixer'],
+    };
+    const agents = createAgents(config);
+    const names = agents.map((a) => a.name);
+    expect(names).not.toContain('designer');
+    expect(names).not.toContain('fixer');
+    expect(names).toContain('orchestrator');
+    expect(names).toContain('explorer');
+    expect(names).toContain('oracle');
+    expect(names).toContain('librarian');
+  });
+
+  test('protected agents cannot be disabled', () => {
+    const config: PluginConfig = {
+      disabled_agents: ['orchestrator', 'councillor', 'council-master'],
+    };
+    const agents = createAgents(config);
+    const names = agents.map((a) => a.name);
+    expect(names).toContain('orchestrator');
+    expect(names).toContain('councillor');
+    expect(names).toContain('council-master');
+  });
+
+  test('disabling council disables all council agents', () => {
+    const config: PluginConfig = {
+      disabled_agents: ['council'],
+    };
+    const agents = createAgents(config);
+    const names = agents.map((a) => a.name);
+    expect(names).not.toContain('council');
+    // councillor and council-master are protected, they stay
+    expect(names).toContain('councillor');
+    expect(names).toContain('council-master');
+  });
+
+  test('agent count decreases when agents are disabled', () => {
+    const agents = createAgents();
+    expect(agents.length).toBe(9); // 1 + 8 (observer disabled by default)
+
+    const disabledConfig: PluginConfig = {
+      disabled_agents: ['observer', 'designer'],
+    };
+    const disabledAgents = createAgents(disabledConfig);
+    expect(disabledAgents.length).toBe(8);
+  });
+
+  test('getDisabledAgents respects protection rules', () => {
+    const config: PluginConfig = {
+      disabled_agents: ['orchestrator', 'designer', 'councillor'],
+    };
+    const disabled = getDisabledAgents(config);
+    expect(disabled.has('designer')).toBe(true);
+    expect(disabled.has('orchestrator')).toBe(false);
+    expect(disabled.has('councillor')).toBe(false);
+  });
+
+  test('getEnabledAgentNames filters correctly', () => {
+    const config: PluginConfig = {
+      disabled_agents: ['designer', 'fixer'],
+    };
+    const enabled = getEnabledAgentNames(config);
+    expect(enabled).not.toContain('designer');
+    expect(enabled).not.toContain('fixer');
+    expect(enabled).toContain('orchestrator');
+    expect(enabled).toContain('explorer');
+  });
+
+  test('empty disabled_agents creates all agents including observer', () => {
+    const config: PluginConfig = {
+      disabled_agents: [],
+    };
+    const agents = createAgents(config);
+    expect(agents.length).toBe(10);
+    expect(agents.map((a) => a.name)).toContain('observer');
+  });
+});
+
+describe('observer agent', () => {
+  test('observer is disabled by default', () => {
+    const agents = createAgents();
+    const names = agents.map((a) => a.name);
+    expect(names).not.toContain('observer');
+  });
+
+  test('observer is enabled when removed from disabled_agents', () => {
+    const config: PluginConfig = {
+      disabled_agents: [],
+    };
+    const agents = createAgents(config);
+    const names = agents.map((a) => a.name);
+    expect(names).toContain('observer');
+  });
+
+  test('observer is disabled when explicitly listed', () => {
+    const config: PluginConfig = {
+      disabled_agents: ['observer'],
+    };
+    const agents = createAgents(config);
+    const names = agents.map((a) => a.name);
+    expect(names).not.toContain('observer');
+  });
+
+  test('observer can be enabled alongside other disabled agents', () => {
+    const config: PluginConfig = {
+      disabled_agents: ['designer'],
+    };
+    const agents = createAgents(config);
+    const names = agents.map((a) => a.name);
+    expect(names).toContain('observer');
+    expect(names).not.toContain('designer');
+  });
+
+  test('DEFAULT_DISABLED_AGENTS contains observer', () => {
+    expect(DEFAULT_DISABLED_AGENTS).toContain('observer');
+  });
+});

+ 42 - 8
src/agents/index.ts

@@ -2,10 +2,13 @@ import type { AgentConfig as SDKAgentConfig } from '@opencode-ai/sdk/v2';
 import { getSkillPermissionsForAgent } from '../cli/skills';
 import {
   type AgentOverrideConfig,
+  ALL_AGENT_NAMES,
+  DEFAULT_DISABLED_AGENTS,
   DEFAULT_MODELS,
   getAgentOverride,
   loadAgentPrompt,
   type PluginConfig,
+  PROTECTED_AGENTS,
   SUBAGENT_NAMES,
 } from '../config';
 import { getAgentMcpList } from '../config/agent-mcps';
@@ -17,6 +20,7 @@ import { createDesignerAgent } from './designer';
 import { createExplorerAgent } from './explorer';
 import { createFixerAgent } from './fixer';
 import { createLibrarianAgent } from './librarian';
+import { createObserverAgent } from './observer';
 import { createOracleAgent } from './oracle';
 import { type AgentDefinition, createOrchestratorAgent } from './orchestrator';
 
@@ -114,6 +118,7 @@ const SUBAGENT_FACTORIES: Record<SubagentName, AgentFactory> = {
   oracle: createOracleAgent,
   designer: createDesignerAgent,
   fixer: createFixerAgent,
+  observer: createObserverAgent,
   council: createCouncilAgent,
   councillor: createCouncillorAgent,
   'council-master': createCouncilMasterAgent,
@@ -129,6 +134,8 @@ const SUBAGENT_FACTORIES: Record<SubagentName, AgentFactory> = {
  * @returns Array of agent definitions (orchestrator first, then subagents)
  */
 export function createAgents(config?: PluginConfig): AgentDefinition[] {
+  const disabled = getDisabledAgents(config);
+
   // TEMP: If fixer has no config, inherit from librarian's model to avoid breaking
   // existing users who don't have fixer in their config yet
   const getModelForAgent = (name: SubagentName): string => {
@@ -161,14 +168,16 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   // 1. Gather all sub-agent definitions with custom prompts
   const protoSubAgents = (
     Object.entries(SUBAGENT_FACTORIES) as [SubagentName, AgentFactory][]
-  ).map(([name, factory]) => {
-    const customPrompts = loadAgentPrompt(name, config?.preset);
-    return factory(
-      getModelForAgent(name),
-      customPrompts.prompt,
-      customPrompts.appendPrompt,
-    );
-  });
+  )
+    .filter(([name]) => !disabled.has(name))
+    .map(([name, factory]) => {
+      const customPrompts = loadAgentPrompt(name, config?.preset);
+      return factory(
+        getModelForAgent(name),
+        customPrompts.prompt,
+        customPrompts.appendPrompt,
+      );
+    });
 
   // 2. Apply overrides and default permissions to each agent
   const allSubAgents = protoSubAgents.map((agent) => {
@@ -191,6 +200,7 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
     orchestratorModel,
     orchestratorPrompts.prompt,
     orchestratorPrompts.appendPrompt,
+    disabled,
   );
   applyDefaultPermissions(orchestrator, orchestratorOverride?.skills);
   if (orchestratorOverride) {
@@ -238,3 +248,27 @@ export function getAgentConfigs(
     }),
   );
 }
+
+/**
+ * Get the set of disabled agent names from config, applying protection rules.
+ */
+export function getDisabledAgents(config?: PluginConfig): Set<string> {
+  const userDisabled = config?.disabled_agents;
+  const disabledSource =
+    userDisabled !== undefined ? userDisabled : DEFAULT_DISABLED_AGENTS;
+  const disabled = new Set<string>();
+  for (const name of disabledSource) {
+    if (!PROTECTED_AGENTS.has(name)) {
+      disabled.add(name);
+    }
+  }
+  return disabled;
+}
+
+/**
+ * Get the list of enabled (non-disabled) agent names.
+ */
+export function getEnabledAgentNames(config?: PluginConfig): string[] {
+  const disabled = getDisabledAgents(config);
+  return ALL_AGENT_NAMES.filter((name) => !disabled.has(name));
+}

+ 44 - 0
src/agents/observer.ts

@@ -0,0 +1,44 @@
+import type { AgentDefinition } from './orchestrator';
+
+const OBSERVER_PROMPT = `You are Observer — a visual analysis specialist.
+
+**Role**: Interpret images, screenshots, PDFs, and diagrams. Extract structured observations for the Orchestrator to act on.
+
+**Behavior**:
+- Read the file(s) specified in the prompt
+- Analyze visual content — layouts, UI elements, text, relationships, flows
+- For screenshots with text/code/errors: extract the **exact text** via OCR — never paraphrase error messages or code
+- For multiple files: analyze each, then compare or relate as requested
+- Return ONLY the extracted information relevant to the goal
+- If the image is unclear, blurry, or partially visible: state what you CAN see and explicitly note what is uncertain — never guess or fabricate details
+
+**Constraints**:
+- READ-ONLY: Analyze and report, don't modify files
+- Save context tokens — the Orchestrator never processes the raw file
+- Match the language of the request
+- If info not found, state clearly what's missing`;
+
+export function createObserverAgent(
+  model: string,
+  customPrompt?: string,
+  customAppendPrompt?: string,
+): AgentDefinition {
+  let prompt = OBSERVER_PROMPT;
+
+  if (customPrompt) {
+    prompt = customPrompt;
+  } else if (customAppendPrompt) {
+    prompt = `${OBSERVER_PROMPT}\n\n${customAppendPrompt}`;
+  }
+
+  return {
+    name: 'observer',
+    description:
+      'Visual analysis. Use for interpreting images, screenshots, PDFs, and diagrams — extracts structured observations without loading raw files into main context. Requires a vision-capable model.',
+    config: {
+      model,
+      temperature: 0.1,
+      prompt,
+    },
+  };
+}

+ 86 - 30
src/agents/orchestrator.ts

@@ -23,59 +23,118 @@ export function resolvePrompt(
   return base;
 }
 
-export const ORCHESTRATOR_PROMPT = `<Role>
-You are an AI coding orchestrator that optimizes for quality, speed, cost, and reliability by delegating to specialists when it provides net efficiency gains.
-</Role>
-
-<Agents>
-
-@explorer
+// Agent descriptions for the orchestrator prompt
+const AGENT_DESCRIPTIONS: Record<string, string> = {
+  explorer: `@explorer
 - Role: Parallel search specialist for discovering unknowns across the codebase
 - Stats: 3x faster codebase search than orchestrator, 1/2 cost of orchestrator
 - Capabilities: Glob, grep, AST queries to locate files, symbols, patterns
 - **Delegate when:** Need to discover what exists before planning • Parallel searches speed discovery • Need summarized map vs full contents • Broad/uncertain scope
-- **Don't delegate when:** Know the path and need actual content • Need full file anyway • Single specific lookup • About to edit the file
+- **Don't delegate when:** Know the path and need actual content • Need full file anyway • Single specific lookup • About to edit the file`,
 
-@librarian
+  librarian: `@librarian
 - Role: Authoritative source for current library docs and API references
 - Stats: 10x better finding up-to-date library docs than orchestrator, 1/2 cost of orchestrator
 - Capabilities: Fetches latest official docs, examples, API signatures, version-specific behavior via grep_app MCP
 - **Delegate when:** Libraries with frequent API changes (React, Next.js, AI SDKs) • Complex APIs needing official examples (ORMs, auth) • Version-specific behavior matters • Unfamiliar library • Edge cases or advanced features • Nuanced best practices
 - **Don't delegate when:** Standard usage you're confident about (\`Array.map()\`, \`fetch()\`) • Simple stable APIs • General programming knowledge • Info already in conversation • Built-in language features
-- **Rule of thumb:** "How does this library work?" → @librarian. "How does programming work?" → yourself.
+- **Rule of thumb:** "How does this library work?" → @librarian. "How does programming work?" → yourself.`,
 
-@oracle
+  oracle: `@oracle
 - Role: Strategic advisor for high-stakes decisions and persistent problems, code reviewer
 - Stats: 5x better decision maker, problem solver, investigator than orchestrator, 0.8x speed of orchestrator, same cost.
 - Capabilities: Deep architectural reasoning, system-level trade-offs, complex debugging, code review, simplification, maintainability review
 - **Delegate when:** Major architectural decisions with long-term impact • Problems persisting after 2+ fix attempts • High-risk multi-system refactors • Costly trade-offs (performance vs maintainability) • Complex debugging with unclear root cause • Security/scalability/data integrity decisions • Genuinely uncertain and cost of wrong choice is high • When a workflow calls for a **reviewer** subagent • Code needs simplification or YAGNI scrutiny
 - **Don't delegate when:** Routine decisions you're confident about • First bug fix attempt • Straightforward trade-offs • Tactical "how" vs strategic "should" • Time-sensitive good-enough decisions • Quick research/testing can answer
-- **Rule of thumb:** Need senior architect review? → @oracle. Need code review or simplification? → @oracle. Just do it and PR? → yourself.
+- **Rule of thumb:** Need senior architect review? → @oracle. Need code review or simplification? → @oracle. Just do it and PR? → yourself.`,
 
-@designer
+  designer: `@designer
 - Role: UI/UX specialist for intentional, polished experiences
 - Stats: 10x better UI/UX than orchestrator
 - Capabilities: Visual relevant edits, interactions, responsive layouts, design systems with aesthetic intent, deep UI/UX knowledge; can edits files directly
 - **Delegate when:** User-facing interfaces needing polish • Responsive layouts • UX-critical components (forms, nav, dashboards) • Visual consistency systems • Animations/micro-interactions • Landing/marketing pages • Refining functional→delightful • Reviewing existing UI/UX quality
 - **Don't delegate when:** Backend/logic with no visual • Quick prototypes where design doesn't matter yet
-- **Rule of thumb:** Users see it and polish matters? → @designer. Headless/functional? → yourself.
+- **Rule of thumb:** Users see it and polish matters? → @designer. Headless/functional? → yourself.`,
 
-@fixer
+  fixer: `@fixer
 - Role: Fast execution specialist for well-defined tasks, which empowers orchestrator with parallel, speedy executions
 - Stats: 2x faster code edits, 1/2 cost of orchestrator, 0.8x quality of orchestrator
 - Tools/Constraints: Execution-focused—no research, no architectural decisions
 - **Delegate when:** For implementation work, think and triage first. If the change is non-trivial or multi-file, hand bounded execution to @fixer • Writing or updating tests • Tasks that touch test files, fixtures, mocks, or test helpers
 - **Don't delegate when:** Needs discovery/research/decisions • Single small change (<20 lines, one file) • Unclear requirements needing iteration • Explaining to fixer > doing • Tight integration with your current work • Sequential dependencies
-- **Rule of thumb:** Explaining > doing? → yourself. Test file modifications and bounded implementation work usually go to @fixer. Orchestrator paths selection is vastly improved by Fixer. eg it can reduce overall speed if Orchestrator splits what's usually a single task into multiple subtasks and parallelize it with fixer.
+- **Rule of thumb:** Explaining > doing? → yourself. Test file modifications and bounded implementation work usually go to @fixer. Orchestrator paths selection is vastly improved by Fixer. eg it can reduce overall speed if Orchestrator splits what's usually a single task into multiple subtasks and parallelize it with fixer.`,
 
-@council
+  council: `@council
 - Role: Multi-LLM consensus engine for high-confidence answers
 - Stats: 3x slower than orchestrator, 3x or more cost of orchestrator
 - Capabilities: Runs multiple models in parallel, synthesizes their responses via a council master
 - **Delegate when:** Critical decisions needing diverse model perspectives • High-stakes architectural choices where consensus reduces risk • Ambiguous problems where multi-model disagreement is informative • Security-sensitive design reviews
 - **Don't delegate when:** Straightforward tasks you're confident about • Speed matters more than confidence • Single-model answer is sufficient • Routine implementation work
 - **Result handling:** Present the council's synthesized response verbatim. Do not re-summarize — the council master has already produced the final answer.
-- **Rule of thumb:** Need second/third opinions from different models? → @council. One good answer enough? → yourself.
+- **Rule of thumb:** Need second/third opinions from different models? → @council. One good answer enough? → yourself.`,
+
+  observer: `@observer
+- Role: Visual analysis specialist for images, PDFs, and diagrams
+- Stats: Saves main context tokens — Observer processes raw files, returns structured observations
+- Capabilities: Interprets images, screenshots, PDFs, and diagrams via native read tool; extracts UI elements, layouts, text, relationships
+- **Delegate when:** Need to analyze a screenshot or image • Extract information from a PDF • Interpret a diagram or architecture drawing • Visual content needs structured description for downstream agents
+- **Don't delegate when:** Plain text files that Read can handle directly • Files that need editing afterward (need literal content from Read)
+- **Rule of thumb:** Even if your model supports vision, delegate visual analysis to @observer — it isolates large image/PDF bytes from your context window, returning only concise structured text. Need exact file contents for editing? → Read it yourself.
+- **IMPORTANT:** When delegating to @observer, always include the **full file path** in the prompt so it can read the file. Example: "Analyze the screenshot at /path/to/file.png — describe the UI elements and error messages."`,
+};
+
+// Validation routing lines that reference agents
+const VALIDATION_ROUTING = [
+  '- Route UI/UX validation and review to @designer',
+  '- Route code review, simplification, maintainability review, and YAGNI checks to @oracle',
+  '- Route test writing, test updates, and changes touching test files to @fixer',
+  '- Route visual/media analysis and interpretation to @observer',
+  '- If a request spans multiple lanes, delegate only the lanes that add clear value',
+];
+
+// Parallel delegation examples
+const PARALLEL_DELEGATION_EXAMPLES = [
+  '- Multiple @explorer searches across different domains?',
+  '- @explorer + @librarian research in parallel?',
+  '- Multiple @fixer instances for faster, scoped implementation?',
+  '- @observer + @explorer in parallel (visual analysis + code search)?',
+];
+
+/**
+ * Build the orchestrator prompt with dynamic agent filtering.
+ * @param disabledAgents - Set of disabled agent names to exclude from the prompt
+ * @returns The complete orchestrator prompt string
+ */
+export function buildOrchestratorPrompt(disabledAgents?: Set<string>): string {
+  // Filter agent descriptions
+  const enabledAgents = Object.entries(AGENT_DESCRIPTIONS)
+    .filter(([name]) => !disabledAgents?.has(name))
+    .map(([, desc]) => desc)
+    .join('\n\n');
+
+  // Filter validation routing lines — remove lines mentioning any disabled agent
+  const enabledValidationRouting = VALIDATION_ROUTING.filter((line) => {
+    const mentions = [...line.matchAll(/@(\w+)/g)].map((m) => m[1]);
+    if (mentions.length === 0) return true;
+    return mentions.every((name) => !disabledAgents?.has(name));
+  }).join('\n');
+
+  // Filter parallel delegation examples — remove lines mentioning any disabled agent
+  const enabledParallelExamples = PARALLEL_DELEGATION_EXAMPLES.filter(
+    (line) => {
+      const mentions = [...line.matchAll(/@(\w+)/g)].map((m) => m[1]);
+      if (mentions.length === 0) return true;
+      return mentions.every((name) => !disabledAgents?.has(name));
+    },
+  ).join('\n');
+
+  return `<Role>
+You are an AI coding orchestrator that optimizes for quality, speed, cost, and reliability by delegating to specialists when it provides net efficiency gains.
+</Role>
+
+<Agents>
+
+${enabledAgents}
 
 </Agents>
 
@@ -101,9 +160,7 @@ Choose the path that optimizes all four.
 
 ## 4. Split and Parallelize
 Can tasks be split into subtasks and run in parallel?
-- Multiple @explorer searches across different domains?
-- @explorer + @librarian research in parallel?
-- Multiple @fixer instances for faster, scoped implementation?
+${enabledParallelExamples}
 
 Balance: respect dependencies, avoid parallelizing what must be sequential.
 
@@ -123,10 +180,7 @@ When working through multi-step tasks, consider enabling auto-continue to avoid
 
 ### Validation routing
 - Validation is a workflow stage owned by the Orchestrator, not a separate specialist
-- Route UI/UX validation and review to @designer
-- Route code review, simplification, maintainability review, and YAGNI checks to @oracle
-- Route test writing, test updates, and changes touching test files to @fixer
-- If a request spans multiple lanes, delegate only the lanes that add clear value
+${enabledValidationRouting}
 
 ## 6. Verify
 - Run \`lsp_diagnostics\` for errors
@@ -168,17 +222,19 @@ When user's approach seems problematic:
 
 </Communication>
 `;
+}
+
+/** @deprecated Use buildOrchestratorPrompt() instead */
+export const ORCHESTRATOR_PROMPT = buildOrchestratorPrompt();
 
 export function createOrchestratorAgent(
   model?: string | Array<string | { id: string; variant?: string }>,
   customPrompt?: string,
   customAppendPrompt?: string,
+  disabledAgents?: Set<string>,
 ): AgentDefinition {
-  const prompt = resolvePrompt(
-    ORCHESTRATOR_PROMPT,
-    customPrompt,
-    customAppendPrompt,
-  );
+  const basePrompt = buildOrchestratorPrompt(disabledAgents);
+  const prompt = resolvePrompt(basePrompt, customPrompt, customAppendPrompt);
 
   const definition: AgentDefinition = {
     name: 'orchestrator',

+ 32 - 2
src/background/background-manager.test.ts

@@ -1556,6 +1556,7 @@ describe('BackgroundTaskManager', () => {
       if (!orchestratorSessionId)
         throw new Error('Expected sessionId to be defined');
 
+      // Default config: DEFAULT_DISABLED_AGENTS includes 'observer', so it's excluded
       expect(manager.getAllowedSubagents(orchestratorSessionId)).toEqual([
         'explorer',
         'librarian',
@@ -1581,7 +1582,7 @@ describe('BackgroundTaskManager', () => {
 
       expect(manager.getAllowedSubagents(fixerSessionId)).toEqual([]);
 
-      // Designer -> only explorer
+      // Designer -> empty
       const designerTask = manager.launch({
         agent: 'designer',
         prompt: 'test',
@@ -1615,7 +1616,7 @@ describe('BackgroundTaskManager', () => {
 
       expect(manager.getAllowedSubagents(explorerSessionId)).toEqual([]);
 
-      // Unknown session -> orchestrator (all subagents)
+      // Unknown session -> orchestrator (all subagents minus disabled)
       expect(manager.getAllowedSubagents('unknown-session')).toEqual([
         'explorer',
         'librarian',
@@ -1625,5 +1626,34 @@ describe('BackgroundTaskManager', () => {
         'council',
       ]);
     });
+
+    test('disabled_agents: [] enables all agents including observer', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = { disabled_agents: [] };
+      const manager = new BackgroundTaskManager(ctx, undefined, config);
+
+      const task = manager.launch({
+        agent: 'orchestrator',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const sessionId = task.sessionId;
+      if (!sessionId) throw new Error('Expected sessionId to be defined');
+
+      expect(manager.getAllowedSubagents(sessionId)).toEqual([
+        'explorer',
+        'librarian',
+        'oracle',
+        'designer',
+        'fixer',
+        'observer',
+        'council',
+      ]);
+    });
   });
 });

+ 12 - 1
src/background/background-manager.ts

@@ -14,6 +14,7 @@
  */
 
 import type { PluginInput } from '@opencode-ai/plugin';
+import { getDisabledAgents } from '../agents';
 import type { BackgroundTaskConfig, PluginConfig } from '../config';
 import {
   FALLBACK_FAILOVER_TIMEOUT_MS,
@@ -87,6 +88,7 @@ export class BackgroundTaskManager {
   private tmuxEnabled: boolean;
   private config?: PluginConfig;
   private backgroundConfig: BackgroundTaskConfig;
+  private disabledAgents: Set<string>;
 
   // Start queue
   private startQueue: BackgroundTask[] = [];
@@ -118,6 +120,7 @@ export class BackgroundTaskManager {
     };
     this.maxConcurrentStarts = this.backgroundConfig.maxConcurrentStarts;
     this.depthTracker = new SubagentDepthTracker();
+    this.disabledAgents = getDisabledAgents(config);
   }
 
   /**
@@ -140,6 +143,11 @@ export class BackgroundTaskManager {
    * @returns true if allowed, false if not
    */
   isAgentAllowed(parentSessionId: string, requestedAgent: string): boolean {
+    // Check if the requested agent is disabled
+    if (this.disabledAgents.has(requestedAgent)) {
+      return false;
+    }
+
     // Untracked sessions are the root orchestrator (created by OpenCode, not by us)
     const parentAgentName =
       this.agentBySessionId.get(parentSessionId) ?? 'orchestrator';
@@ -161,7 +169,10 @@ export class BackgroundTaskManager {
     const parentAgentName =
       this.agentBySessionId.get(parentSessionId) ?? 'orchestrator';
 
-    return this.getSubagentRules(parentAgentName);
+    const allowedSubagents = this.getSubagentRules(parentAgentName);
+
+    // Filter out disabled agents
+    return allowedSubagents.filter((name) => !this.disabledAgents.has(name));
   }
 
   /**

+ 1 - 0
src/config/agent-mcps.ts

@@ -14,6 +14,7 @@ export const DEFAULT_AGENT_MCPS: Record<AgentName, string[]> = {
   librarian: ['websearch', 'context7', 'grep_app'],
   explorer: [],
   fixer: [],
+  observer: [],
   council: [],
   councillor: [],
   'council-master': [],

+ 25 - 0
src/config/constants.ts

@@ -10,6 +10,7 @@ export const SUBAGENT_NAMES = [
   'oracle',
   'designer',
   'fixer',
+  'observer',
   'council',
   'councillor',
   'council-master',
@@ -36,9 +37,27 @@ export const ORCHESTRATABLE_AGENTS = [
   'oracle',
   'designer',
   'fixer',
+  'observer',
   'council',
 ] as const;
 
+/** Agents that cannot be disabled even if listed in disabled_agents config. */
+export const PROTECTED_AGENTS = new Set([
+  'orchestrator',
+  'councillor',
+  'council-master',
+]);
+
+/**
+ * Get the list of orchestratable agents, excluding any disabled agents.
+ * This is used for delegation validation at runtime.
+ */
+export function getOrchestratableAgents(
+  disabledAgents?: Set<string>,
+): string[] {
+  return ORCHESTRATABLE_AGENTS.filter((name) => !disabledAgents?.has(name));
+}
+
 export const SUBAGENT_DELEGATION_RULES: Record<AgentName, readonly string[]> = {
   orchestrator: ORCHESTRATABLE_AGENTS,
   fixer: [],
@@ -46,6 +65,7 @@ export const SUBAGENT_DELEGATION_RULES: Record<AgentName, readonly string[]> = {
   explorer: [],
   librarian: [],
   oracle: [],
+  observer: [],
   council: [],
   councillor: [],
   'council-master': [],
@@ -60,6 +80,7 @@ export const DEFAULT_MODELS: Record<AgentName, string | undefined> = {
   explorer: 'openai/gpt-5.4-mini',
   designer: 'openai/gpt-5.4-mini',
   fixer: 'openai/gpt-5.4-mini',
+  observer: 'openai/gpt-5.4-mini',
   council: 'openai/gpt-5.4-mini',
   councillor: 'openai/gpt-5.4-mini',
   'council-master': 'openai/gpt-5.4-mini',
@@ -91,3 +112,7 @@ export const COUNCILLOR_STAGGER_MS = 250;
 
 // Polling stability
 export const STABLE_POLLS_THRESHOLD = 3;
+
+/** Agents that are disabled by default. Users must explicitly enable them
+ *  by removing from disabled_agents and configuring an appropriate model. */
+export const DEFAULT_DISABLED_AGENTS: string[] = ['observer'];

+ 9 - 0
src/config/schema.ts

@@ -239,6 +239,15 @@ export const PluginConfigSchema = z.object({
   manualPlan: ManualPlanSchema.optional(),
   presets: z.record(z.string(), PresetSchema).optional(),
   agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
+  disabled_agents: z
+    .array(z.string())
+    .optional()
+    .describe(
+      'Agent names to disable completely. ' +
+        'Disabled agents are not instantiated and cannot be delegated to. ' +
+        'Orchestrator and council internal agents (councillor, council-master) cannot be disabled. ' +
+        "By default, 'observer' is disabled. Remove it from this list and configure a vision-capable model to enable.",
+    ),
   disabled_mcps: z.array(z.string()).optional(),
   // Multiplexer config (new unified config - preferred)
   multiplexer: MultiplexerConfigSchema.optional(),

+ 149 - 0
src/hooks/image-hook.ts

@@ -0,0 +1,149 @@
+import { createHash } from 'node:crypto';
+import {
+  existsSync,
+  mkdirSync,
+  readdirSync,
+  statSync,
+  unlinkSync,
+  writeFileSync,
+} from 'node:fs';
+import { join } from 'node:path';
+
+// Debounce: only run cleanup every 10 minutes
+let lastCleanup = 0;
+const CLEANUP_INTERVAL = 10 * 60 * 1000; // 10 minutes
+
+interface ImagePart {
+  type: string;
+  url?: string;
+  mime?: string;
+  filename?: string;
+  name?: string;
+  [key: string]: unknown;
+}
+
+interface MessageWithParts {
+  info: { role: string; agent?: string; sessionID?: string };
+  parts: Array<{
+    type: string;
+    text?: string;
+    [key: string]: unknown;
+  }>;
+}
+
+function isImagePart(p: ImagePart): boolean {
+  if (p.type === 'image') return true;
+  if (p.type === 'file') {
+    const mime = p.mime as string | undefined;
+    if (mime?.startsWith('image/')) return true;
+    const filename = p.filename as string | undefined;
+    const name = p.name as string | undefined;
+    const fileName = filename ?? name;
+    if (
+      fileName &&
+      /\.(png|jpg|jpeg|gif|bmp|webp|svg|ico|tiff?|heic)$/i.test(fileName)
+    )
+      return true;
+  }
+  return false;
+}
+
+function decodeDataUrl(url: string): { mime: string; data: Buffer } | null {
+  const match = url.match(/^data:([^;]+);base64,(.+)$/);
+  if (!match) return null;
+  return { mime: match[1], data: Buffer.from(match[2], 'base64') };
+}
+
+function extFromMime(mime: string): string {
+  const map: Record<string, string> = {
+    'image/png': '.png',
+    'image/jpeg': '.jpg',
+    'image/gif': '.gif',
+    'image/webp': '.webp',
+    'image/svg+xml': '.svg',
+    'image/bmp': '.bmp',
+  };
+  return map[mime] ?? '.png';
+}
+
+export function processImageAttachments(args: {
+  messages: MessageWithParts[];
+  workDir: string;
+  disabledAgents: Set<string>;
+  log: (msg: string) => void;
+}): void {
+  const { messages, workDir, disabledAgents, log } = args;
+
+  const observerEnabled = !disabledAgents.has('observer');
+  if (!observerEnabled) return;
+
+  // Save images inside the project's .opencode/images/ directory.
+  // This is within the workspace so the read tool won't require extra permissions.
+  const saveDir = join(workDir, '.opencode', 'images');
+  const gitignorePath = join(workDir, '.opencode', '.gitignore');
+  try {
+    mkdirSync(saveDir, { recursive: true });
+    if (!existsSync(gitignorePath)) writeFileSync(gitignorePath, '*\n');
+  } catch (e) {
+    log(`[image-hook] failed to create image directory: ${e}`);
+  }
+
+  // Clean up images older than 1 hour (debounced: only check every 10 minutes)
+  const now = Date.now();
+  if (now - lastCleanup > CLEANUP_INTERVAL) {
+    lastCleanup = now;
+    try {
+      const maxAge = 60 * 60 * 1000;
+      for (const f of readdirSync(saveDir)) {
+        const fp = join(saveDir, f);
+        try {
+          if (now - statSync(fp).mtimeMs > maxAge) unlinkSync(fp);
+        } catch {}
+      }
+    } catch {}
+  }
+
+  for (const msg of messages) {
+    if (msg.info.role !== 'user') continue;
+    const imageParts = msg.parts.filter(isImagePart);
+    if (imageParts.length === 0) continue;
+
+    // Save each image to .opencode/images/ and collect paths
+    const savedPaths: string[] = [];
+    for (const p of imageParts) {
+      const url = p.url as string | undefined;
+      const filename =
+        (p.filename as string | undefined) ?? (p.name as string | undefined);
+      if (url) {
+        const decoded = decodeDataUrl(url);
+        if (decoded) {
+          const hash = createHash('sha1')
+            .update(decoded.data)
+            .digest('hex')
+            .slice(0, 8);
+          const name = filename ?? `image-${hash}${extFromMime(decoded.mime)}`;
+          const filePath = join(saveDir, name);
+          try {
+            writeFileSync(filePath, decoded.data);
+            savedPaths.push(filePath);
+          } catch (e) {
+            log(`[image-hook] failed to save image: ${e}`);
+          }
+        }
+      }
+    }
+
+    const pathsText =
+      savedPaths.length > 0 ? ` Saved to: ${savedPaths.join(', ')}` : '';
+    log(`[image-hook] stripping image/file parts, saving to disk${pathsText}`);
+
+    msg.parts = msg.parts
+      .filter((p) => !isImagePart(p as ImagePart))
+      .concat([
+        {
+          type: 'text',
+          text: `[Image attachment detected.${pathsText} Your model may not support image input. Delegate to @observer with the file path(s) above so it can read the file with its read tool.]`,
+        },
+      ]);
+  }
+}

+ 1 - 0
src/hooks/index.ts

@@ -8,6 +8,7 @@ export {
   ForegroundFallbackManager,
   isRateLimitError,
 } from './foreground-fallback';
+export { processImageAttachments } from './image-hook';
 export { createJsonErrorRecoveryHook } from './json-error-recovery';
 export { createPhaseReminderHook } from './phase-reminder';
 export { createPostFileToolNudgeHook } from './post-file-tool-nudge';

+ 28 - 3
src/index.ts

@@ -1,5 +1,5 @@
 import type { Plugin } from '@opencode-ai/plugin';
-import { createAgents, getAgentConfigs } from './agents';
+import { createAgents, getAgentConfigs, getDisabledAgents } from './agents';
 import { BackgroundTaskManager, MultiplexerSessionManager } from './background';
 import { loadPluginConfig, type MultiplexerConfig } from './config';
 import { parseList } from './config/agent-mcps';
@@ -16,6 +16,7 @@ import {
   createTodoContinuationHook,
   ForegroundFallbackManager,
 } from './hooks';
+import { processImageAttachments } from './hooks/image-hook';
 import { createInterviewManager } from './interview';
 import { createBuiltinMcps } from './mcp';
 import { getMultiplexer, startAvailabilityCheck } from './multiplexer';
@@ -314,6 +315,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
             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,
@@ -546,9 +553,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         );
         if (!alreadyInjected) {
           // Prepend the orchestrator prompt to the system array
-          const { ORCHESTRATOR_PROMPT } = await import('./agents/orchestrator');
+          // Use buildOrchestratorPrompt with disabledAgents so the
+          // serve-mode prompt matches the interactive-mode prompt
+          const { buildOrchestratorPrompt } = await import(
+            './agents/orchestrator'
+          );
+          const disabledAgents = getDisabledAgents(config);
+          const orchestratorPrompt = buildOrchestratorPrompt(disabledAgents);
           output.system[0] =
-            ORCHESTRATOR_PROMPT +
+            orchestratorPrompt +
             (output.system[0] ? `\n\n${output.system[0]}` : '');
         }
       }
@@ -576,6 +589,18 @@ 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.
+      processImageAttachments({
+        messages: typedOutput.messages,
+        workDir: ctx.directory,
+        disabledAgents: getDisabledAgents(config),
+        log,
+      });
+
       await todoContinuationHook.handleMessagesTransform({
         messages: typedOutput.messages,
       });

+ 3 - 1
src/tools/background.ts

@@ -3,6 +3,7 @@ import {
   type ToolDefinition,
   tool,
 } from '@opencode-ai/plugin';
+import { getDisabledAgents } from '../agents';
 import type { BackgroundTaskManager } from '../background';
 import type { PluginConfig } from '../config';
 import { SUBAGENT_NAMES } from '../config';
@@ -24,7 +25,8 @@ export function createBackgroundTools(
   _multiplexerConfig?: MultiplexerConfig,
   _pluginConfig?: PluginConfig,
 ): Record<string, ToolDefinition> {
-  const agentNames = SUBAGENT_NAMES.join(', ');
+  const disabled = getDisabledAgents(_pluginConfig);
+  const agentNames = SUBAGENT_NAMES.filter((n) => !disabled.has(n)).join(', ');
 
   // Tool for launching agent tasks (fire-and-forget)
   const background_task = tool({