Jelajahi Sumber

Merge pull request #877 from mhenke/omos/fix-image-routing-notifications

fix(image-routing): notify user when images are dropped, correct conflicting defaults
Alvin 3 minggu lalu
induk
melakukan
3ec1f499f7

+ 12 - 127
CONTEXT.md

@@ -4,155 +4,57 @@ A glossary of the terms used in this project's domain. Definitions describe what
 
 ## Agents
 
-Core agent roles and classifications:
-
-The agent system defines the fundamental building blocks for AI-powered work delegation. Each agent type serves a specific purpose in the orchestration ecosystem. Agents are the unit of work — the orchestrator delegates bounded tasks to specialists rather than doing everything itself, which keeps prompts short and costs predictable.
-
-**When to use which agent:** Explorer for codebase search, Librarian for external docs, Oracle for architecture/review, Designer for UI/UX, Fixer for implementation, Observer for visual analysis.
-
-**Core agent types:**
-
 - **Agent** — A named LLM role with a defined lane (permissions, tools, prompt); the unit of work delegation in the system.
 - **Orchestrator** — The primary agent. Plans work, delegates to subagents, monitors them, and reconciles their results. One per session; cannot be disabled.
 - **Subagent** — A specialist agent the orchestrator delegates bounded work to.
-
-**Specialist subagents:**
-
 - **Explorer** — Subagent for fast codebase search and pattern matching.
 - **Librarian** — Subagent for external documentation and library research.
 - **Oracle** — Subagent for architecture, debugging strategy, and code review.
 - **Designer** — Subagent for UI/UX design and visual polish.
 - **Fixer** — Subagent for bounded implementation and execution.
-- **Observer** — Subagent for visual/media analysis (images, PDFs, diagrams). Disabled by default because it requires a vision-capable model and adds cost; enable it with `"disabled_agents": []` when working with screenshots or diagrams.
-
-**Multi-LLM systems:**
-
+- **Observer** — Subagent for visual/media analysis (images, PDFs, diagrams). Disabled by default.
 - **Council** — A multi-LLM agent that runs several councillors and synthesizes their views.
 - **Councillor** — A read-only LLM advisor dispatched as a subagent by the orchestrator. Each councillor is registered as `councillor-<name>` from the council preset. Not hidden; visible in the TUI as panes.
-
-**Agent classification and configuration:**
-
 - **Agent mode** — SDK classification of an agent: `primary` (orchestrator), `subagent` (specialist), or `all` (council, both user-facing and delegatable).
 - **Protected agent** — An agent that cannot be disabled (orchestrator).
 - **Custom agent** — A user-defined agent supplied via config, distinct from the built-ins.
-
-**Naming and identification:**
-
+- **ACP agent** — An external agent defined via the Agent Communication Protocol, run through `acp_run`.
 - **Display name** — A user-assignable name shown in @-mentions; may differ from the internal agent name.
 - **Agent alias** — A legacy or alternate name that maps to a built-in agent. Rejected synonyms: `explore` (use `explorer`), `frontend-ui-ux-engineer` (use `designer`).
-- **Agent permission** — A per-agent `permission` field that sets deterministic, tool-level rules (`ask` / `allow` / `deny`, with pattern support) enforced by the OpenCode SDK. Distinct from prompt instructions, which the model can ignore.
 
 ## Council
 
-Council-specific concepts:
-
-The council system enables multi-LLM consensus and collaborative decision-making. Use council when a single model's judgment is insufficient — architectural decisions, code review, or any question where multiple perspectives reduce risk. Each councillor runs as an independent subagent; the orchestrator synthesizes their responses into a consensus rating.
-
-**When to use:** High-stakes decisions, conflicting requirements, or when you need a second opinion on architecture/code. **When not to use:** Simple tasks that a single specialist can handle — council adds latency and cost.
-
 - **Consensus** — The synthesized conclusion of a council run, rated `unanimous`, `majority`, or `split`.
 - **Council preset** — A named lineup of councillor configurations used for a council run. Plugin config uses `preset` for the selected agent-override set; council config uses `default_preset` for the selected councillor lineup — the `default_` prefix disambiguates the active selection from the preset list within the council sub-object.
-- **Council timeouts / execution mode / retries** — No longer config keys. Per-councillor timeout, serial-vs-parallel execution, and empty-response retries are now handled by the orchestrator's council-mode prompt instructions (see `src/agents/council.ts`).
 
 ## Multiplexer & Sessions
 
-Terminal and session management:
-
-The multiplexer system manages terminal backends and agent session lifecycle. When enabled, each child agent gets its own terminal pane so you can see and interact with running work in real time. Disabled by default (`multiplexer.type: "none"`) — enable it when you want visibility into parallel work.
-
-**When to use:** Multi-agent work where you want to watch specialists run side-by-side. **When to leave off:** Single-threaded workflows or headless environments. See [Configuration Reference — Multiplexer](docs/configuration.md#multiplexer) for setup.
-
-- **Multiplexer** — A terminal backend (tmux, zellij, herdr, cmux, or kitty) that hosts child agent panes. Set via `multiplexer.type`, which also accepts `auto` (auto-detect) and `none` (disabled).
-- **Multiplexer type** — The selected backend: `auto`, `tmux`, `zellij`, `herdr`, `cmux`, `kitty`, or `none`.
+- **Multiplexer** — A terminal backend (tmux, zellij, herdr, or kitty) that hosts child agent panes. Set via \`multiplexer.type\`, which also accepts \`auto\` (auto-detect) and \`none\` (disabled).
+- **Multiplexer type** — The selected backend: `auto`, `tmux`, `zellij`, `herdr`, `kitty`, or `none`.
 - **Pane** — A terminal region spawned by the multiplexer to run a child agent session.
 - **Child session** — A background agent session hosted in a multiplexer pane and tracked by the session manager.
 - **Session manager** — Tracks child sessions, spawns and closes multiplexer panes, and reacts to session lifecycle events. Note: `TmuxSessionManager` is a deprecated alias — use `MultiplexerSessionManager`.
-- **Close reason** — Why a pane is closed: `idle` or `deleted`. The cmux backend adds a third value: `cleanup`.
+- **Close reason** — Why a pane is closed: `idle` or `deleted`.
 
 ## Background Jobs
 
-Asynchronous job lifecycle management:
-
-The background job system tracks and manages delegated specialist tasks. Every subagent launch creates a job; the orchestrator references the job board when planning follow-up work. For full configuration options, see [Configuration Reference — Background Job Management](docs/configuration.md#background-job-management) and [Background Orchestration](docs/background-orchestration.md).
-
-**Why this exists:** Without the board, the orchestrator would lose track of parallel work and re-delegate already-running tasks. The board is the single source of truth for "what's running."
-
 - **Background job** — A delegated specialist task that runs asynchronously; tracked until its result is reconciled into the orchestrator's response.
 - **Background Job Board** — The store of background job state and metadata.
 - **Background Job Coordinator** — The layer that owns background-job lifecycle policy and deferred-close state, writing through the board.
-- **Background Job Store** — Interface (`src/utils/background-job-store.ts`) that both `BackgroundJobBoard` and `BackgroundJobCoordinator` implement.
 - **Job state** — A background job's status: `running`, `completed`, `error`, `cancelled`, or `reconciled`. `reconciled` is a distinct post-consumption phase marking that a terminal job's result has been folded into the orchestrator's response; it is not a terminal outcome itself.
 - **Job alias** — A short human-readable identifier for a background job (e.g., `fix-1`, `exp-2`).
 - **Terminal state** — A job state from which no further transition occurs (`completed`, `error`, `cancelled`).
-- **Board snapshot** — A formatted rendering of the Background Job Board injected into the orchestrator's prompt. Retention is bounded by `backgroundJobs.maxRetainedSnapshots` per checkpoint cache epoch.
-- **Checkpoint cache epoch** — A span of turns during which the same set of board snapshots is reused for prompt-cache hits. Adding a snapshot beyond the retention limit starts a new epoch with only the current snapshot, intentionally causing one cache miss.
-- **Board injection strategy** — How the board is written into the prompt: `latest` (strip-and-replace every turn) or `checkpoint-compatible` (append only when the formatted board changes, retaining snapshots per epoch to preserve cache hits).
-- **Incomplete-todo continuation nudge** — A beta opt-in (`backgroundJobs.continueOnIdle`) that lets idle orchestrator sessions with incomplete todos receive one automatic hidden continuation prompt. Off by default.
 
 ## Skills
 
-Plugin capabilities and workflows:
-
-The skills system provides bundled, self-contained workflows and capabilities for the plugin. Skills are invoked by the orchestrator when it detects a matching task pattern — they encode best practices for specific work types so the model doesn't reinvent the approach each time.
-
-**When to use:** Invoke a skill when starting work that matches its pattern (e.g., `/codemap` for new repo exploration, `/verification-planning` before non-trivial implementation).
-
-- **Skill** — A bundled, self-contained workflow or capability shipped with the plugin. Bundled skills: codemap, clonedeps, simplify, deepwork, reflect, worktrees, oh-my-opencode-slim, verification-planning. Note: `loop-engineering` exists on disk but is not registered as a bundled skill.
-- **Verification-planning** — An orchestrator-only skill for designing project-specific evidence paths before non-trivial implementation.
+- **Skill** — A bundled, self-contained workflow or capability shipped with the plugin. Bundled skills: codemap, clonedeps, simplify, deepwork, reflect, worktrees, oh-my-opencode-slim. Note: `loop-engineering` exists on disk but is not registered as a bundled skill.
 
 ## Hooks
 
-OpenCode lifecycle extension points:
-
-The hooks system provides extension points for OpenCode lifecycle events.
-
 - **Hook** — A plugin extension point that reacts to OpenCode lifecycle events (e.g., apply-patch, filter-available-skills, loop-command, session-lifecycle).
 
-## Companion
-
-Desktop visual companion:
-
-The companion provides a visual status overlay showing running and active agents.
-
-- **Companion** — A native desktop mascot that reflects agent activity; launched and tracked by the companion manager.
-
-## Cache Safety
-
-Prompt cache infrastructure and safety:
-
-The cache safety system ensures prompt cache hits and LLM cost optimization. Provider prompt caches are exact byte-prefix matches — any earlier change invalidates the entire suffix. Cache safety enforces stable byte prefixes so repeated orchestrator turns reuse the same cached prompt. See `AGENTS.md` "Prompt Cache Safety" for rules.
-
-**Why this matters:** Without cache hits, every orchestrator turn re-pays the full prefix cost. A 50K-token prefix at $3/M tokens, hit once per turn vs always full, saves ~$0.15/turn at scale.
-
-- **Cache safety** / **prompt cache safety** — Major concept with extensive infrastructure: `cache-safe-injection.ts` for deterministic content injection, `cache-monitor/` for runtime watchdog, `cache-safety.property.test.ts` for prefix-stability properties, `cache-payload.snapshot.test.ts` for golden snapshots, `cache-safety-tripwire.test.ts` for volatile-input pattern bans, and AGENTS.md "Prompt Cache Safety" section. Critical for provider prompt cache hits and LLM cost optimization.
-
-## ACP
-
-Agent Communication Protocol integration:
-
-The ACP system enables external Agent Client Protocol servers as optional OpenCode subagents. Each ACP agent is wrapped in a lightweight local subagent that calls `acp_run`; the external process runs the actual work. See [ACP Agents](docs/acp-agents.md) for setup.
-
-**When to use:** Connecting to external agent CLIs (Claude Code, Gemini, etc.) that speak ACP. **When not to use:** If the work can be done by a built-in specialist — ACP adds subprocess overhead.
-
-- **ACP agent** — An external agent defined via the Agent Communication Protocol, run through `acp_run`.
-- **ACP wrapper agent** — Lightweight local subagent that calls `acp_run` on behalf of the external ACP process. Distinct from the ACP agent itself — the wrapper handles protocol execution while the external agent provides the actual functionality.
-
-## Multiplexer
-
-Advanced multiplexer backend details:
-
-The cmux multiplexer provides advanced session handling with configurable timeouts and cleanup policies.
-
-- **cmux** — Multiplexer backend with idle-session lifecycle management, grace periods, and its own close-reason variant (`cleanup`). Supports advanced session handling with configurable timeouts and cleanup policies.
-
 ## Loop
 
-Auto-iterative execution and verification:
-
-The loop system enables auto-iterative work execution with verification. A loop runs an execute agent, verifies output against success criteria, and repeats until done or escalated. Use it for tasks that have a clear pass/fail check (tests, builds, lint).
-
-**When to use:** Well-defined tasks with objective success criteria (fix a failing test, implement a function with tests). **When not to use:** Open-ended work where "done" is subjective — loops will thrash without a clear signal.
-
 - **Loop** — An auto-iterative run that executes work with an agent, verifies it against success criteria, and repeats until done or escalated.
 - **Loop session** — The state of one loop run (goal, current phase, attempts, history).
 - **Loop phase** — A stage of a loop: `executing`, `verifying`, `done`, `escalated`, or `cancelled`.
@@ -162,43 +64,26 @@ The loop system enables auto-iterative work execution with verification. A loop
 
 ## Interview
 
-Specification document generation:
-
-The interview system builds persistent specification documents from ideas through question/answer flows. The orchestrator asks structured questions, you answer, and the result is a persistent spec file. See [Interview](docs/interview.md) for the full workflow.
-
-**When to use:** Starting a new project, designing a feature, or capturing requirements before implementation. **When not to use:** Trivial changes or well-defined tasks that don't need upfront design.
-
 - **Interview** — A question/answer flow that builds a persistent specification document from an idea.
 - **Spec block** — A named section within a generated specification document.
 - **Interview dashboard** — The web UI for managing an interview and entering answers.
 
-## Config
-
-Configuration concepts and terminology:
+## Companion
 
-The configuration system provides user-facing configuration for the plugin. For the complete configuration reference with all options, defaults, and examples, see [Configuration Reference](docs/configuration.md). Most users only need `preset`, `presets.<name>.<agent>.model`, and maybe `disabled_agents` — the rest is for advanced tuning.
+- **Companion** — A native desktop mascot that reflects agent activity; launched and tracked by the companion manager.
 
-**Layering order:** `~/.config/opencode/oh-my-opencode-slim.jsonc` is the user base; `.opencode/oh-my-opencode-slim.json` (project-local) overrides user config; CLI flags and runtime `/model` commands override config entirely. See [Project-local Customization](docs/project-local-customization.md) for precedence details.
+## Config
 
 - **Plugin config** — The user-facing configuration loaded from `oh-my-opencode-slim.jsonc`.
 - **Preset** — A named set of per-agent overrides. The same word also names council councillor lineups (see Flagged).
 - **Model entry** — A normalized model reference with an optional variant, used in fallback chains.
-- **Variant** — An optional per-agent model qualifier that sets reasoning effort. Common values are `"low"`, `"medium"`, `"high"`, and `"max"` (provider-specific). Applied via `presets.<name>.<agent>.variant` or `council.presets.<name>.<councillor>.variant`. The string is unvalidated — any value is accepted, but the documented values are the expected ones.
+- **Variant** — An optional model qualifier (e.g., a preview build) used in fallback resolution.
 - **Fallback / failover** — The mechanism that switches models when a call is rate-limited or returns empty.
-- **Fallback max retries** — `fallback.maxRetries`: maximum failover attempts before giving up (default `3`).
-- **Runtime override** — `fallback.runtimeOverride`: deprecated, accepted for backward compatibility but no longer affects runtime behavior. Fallback is now always disabled when a user explicitly selects a model via `/model`.
-- **Strip orchestrator model** — `stripOrchestratorModel`: opt-in that preserves a runtime `/model` selection for the orchestrator after subagent dispatch by omitting its configured model from the SDK config. Exception: if the active preset defines `orchestrator.model`, stripping is skipped and the preset's model is used.
-- **Image routing** — `image_routing`: optional top-level setting (`"auto"` or `"direct"`). When omitted, images are intercepted only when Observer is enabled; `"auto"` requires Observer and saves attachments to disk before nudging delegation; `"direct"` always passes images to the orchestrator.
-- **Disabled agents** — Agents turned off globally via the `disabled_agents` config array; `observer` is disabled by default. This is global, not per-preset.
+- **Disabled agents** — Agents turned off via config; `observer` is disabled by default.
 
 ## Flagged
 
-Known terminology collisions and historical drift:
-
-These terms have genuine but non-blocking collisions or historical drift. Noted for awareness; no change required:
+Terms with genuine but non-blocking collisions or historical drift. Noted for awareness; no change required:
 
 - **"Presets" means two things** — A plugin *preset* is a set of agent overrides; a council *preset* is a lineup of councillor models. Same word, different JSON paths and types; no structural conflict, but easy to confuse.
 - **Config naming convention** — Config keys mix snake_case (`disabled_agents`, `main_pane_size`) with camelCase (`autoUpdate`, `backgroundJobs`) with no documented rule. Historical drift; `disabled_*` keys are uniformly snake_case while the rest is mixed even within sub-objects.
-- **`council.master*` fields removed** — Legacy `council.master*` keys were removed; a deprecation warning is logged this release only if a config contains the exact `council.master` key. Other `master_*` variants (e.g., `council.master_timeout`, `council.master_fallback`) are silently dropped without warning. Do not use them in new configs.
-- **Agent alias vs Display name** — Legacy agent aliases (`explore` → `explorer`, `frontend-ui-ux-engineer` → `designer`) provide backward compatibility at the code level, while `displayName` offers user-facing aliases (`advisor` → `oracle`). Both concepts coexist but serve different purposes.
-- **Closed vs close reason terminology** — Internal `CloseReason` enum uses `idle`/`deleted`/`cleanup` (cmux), while users see simplified "idle"/"deleted" in logs. The cmux-specific `cleanup` reason is invisible to end users.

+ 5 - 37
docs/companion.md

@@ -1,6 +1,6 @@
 # Desktop Companion App
 
-The desktop companion is a floating status overlay that shows running and active agents.
+The desktop companion app provides a floating status overlay showing running and active agents.
 
 ## How to Enable in Configuration
 
@@ -74,7 +74,8 @@ During interactive installation, the installer asks whether to download and
 enable the native Companion binary. The prompt defaults to `no`, so pressing
 Enter skips it.
 
-On niri, Companion installs and works once enabled.
+On niri, Companion can install normally when enabled now that the native binary
+is fixed.
 
 Companion installation is best-effort. If the binary cannot be downloaded or
 installed, the installer prints a warning and continues installing the core
@@ -118,40 +119,6 @@ oh-my-opencode-slim doctor
 
 ---
 
-## Known Limitations
-
-### Wayland Click-Through (GNOME, KDE)
-
-On Wayland compositors, the companion window captures mouse clicks in its
-bounding box, preventing interaction with windows underneath. This affects
-GNOME, KDE Plasma, and other Wayland desktops that do not implement the
-`zwlr-layer-shell` protocol with `pass_through_pointer` support.
-
-**Affected environments:** Ubuntu 24.04+ (GNOME Wayland), KDE Plasma on Wayland,
-most non-wlroots compositors.
-
-**Workaround:** Disable the companion on Wayland desktops that do not support
-layer-shell input pass-through:
-
-```jsonc
-{
-  "companion": {
-    "enabled": false
-  }
-}
-```
-
-On wlroots-based compositors (Sway, Hyprland, labwc) and Smithay-based
-compositors (niri), use compositor window rules to prevent the companion from
-grabbing focus. See the niri section above for an example.
-
-On macOS and X11, click-through works without issues.
-
-Upstream needs to add native Wayland input region support before this can be
-fixed in the companion.
-
----
-
 ## Expected Binary Install Path
 
 The runtime looks for the companion binary at:
@@ -191,7 +158,8 @@ Custom binaries configured with `companion.binaryPath` are never overwritten.
 
 ## V2 Release Strategy
 
-The release workflow follows the V2 distribution plan:
+For the desktop companion app, the release workflow follows the V2 distribution
+plan:
 
 1. **GitHub Release Assets**: companion binaries are uploaded to the
    `companion-v0.1.3` GitHub release.

+ 1 - 1
docs/configuration.md

@@ -136,7 +136,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `acpAgents.<name>.permissionMode` | string | `ask` | How ACP permission requests are handled: `ask`, `allow`, or `reject` See [ACP-connected agents](#acp-connected-agents). |
 | `acpAgents.<name>.timeoutMs` | integer | `0` | Timeout for a single ACP run in milliseconds. `0` disables the timeout so external agents can run indefinitely. Finite values can be up to `2147483647`ms (~24.8 days) See [ACP-connected agents](#acp-connected-agents). |
 | `disabled_agents` | string[] | `["observer"]` | Agent names to disable globally. Set to `[]` to enable Observer; this is global, not per-preset See [Custom Agents](#custom-agents). |
-| `image_routing` | `"auto"` \| `"direct"` | omitted (legacy conditional) | Optional. When omitted, images are intercepted only when Observer is enabled, preserving existing behavior. Explicit `"auto"` requires Observer enabled and saves image attachments to disk before nudging delegation to @observer. `"direct"`: always pass images to the orchestrator. |
+| `image_routing` | `"auto"` \| `"direct"` | omitted (legacy conditional) | Optional. When omitted, resolves to `"auto"` if Observer is enabled, otherwise `"direct"`. Explicit `"auto"` requires Observer enabled and saves image attachments to disk before nudging delegation to @observer. `"direct"`: always pass images to the orchestrator. |
 | `autoUpdate` | boolean | `true` | Automatically install plugin updates in the background; set to `false` for notification-only mode |
 | `multiplexer.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, `herdr`, `cmux`, `kitty`, or `none` See [Multiplexer Integration](multiplexer-integration.md). |
 | `multiplexer.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical`. Tmux applies full layouts; Zellij and Herdr map supported layouts to split directions; cmux maintains a right-hand agent column See [Multiplexer Integration](multiplexer-integration.md). |

+ 0 - 2
docs/installation.md

@@ -27,8 +27,6 @@ Or use non-interactive mode:
 bunx oh-my-opencode-slim@latest install --no-tui --skills=yes --background-subagents=yes
 ```
 
-> **Running in Docker or a sandbox?** Install inside the container with `--no-tui` (headless environments can't run the interactive TUI), and mount `~/.config/opencode` for persistence. Pass `--companion=no` to skip the desktop companion (it requires a display server). Auto-updates write to `~/.cache/opencode/packages/`, which is separate from config — mount that too if you want updates to survive container restarts.
-
 ### Configuration Options
 
 The installer supports the following options:

+ 8 - 7
src/config/constants.ts

@@ -83,6 +83,9 @@ export const COUNCILLOR_STAGGER_MS = 250;
 // Polling stability
 export const STABLE_POLLS_THRESHOLD = 3;
 
+// Toast duration (ms) used by all OMOS toasts
+export const TOAST_DURATION_MS = 10_000;
+
 /** 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'];
@@ -103,14 +106,12 @@ export const DEFAULT_MAX_SESSION_METADATA_ENTRIES = 1000;
 
 export type ImageRouting = 'auto' | 'direct';
 
-/**
- * Used when image_routing is omitted, preserving legacy conditional Observer
- * routing. Explicit "auto" is validated separately after config layers merge.
- */
-export const DEFAULT_IMAGE_ROUTING: ImageRouting = 'auto';
-
 export function resolveImageRouting(
   imageRouting: ImageRouting | undefined,
+  observerEnabled: boolean,
 ): ImageRouting {
-  return imageRouting ?? DEFAULT_IMAGE_ROUTING;
+  // Explicit value: use it
+  if (imageRouting !== undefined) return imageRouting;
+  // Legacy conditional: intercept only when observer is enabled
+  return observerEnabled ? 'auto' : 'direct';
 }

+ 5 - 0
src/config/loader.ts

@@ -446,6 +446,11 @@ export function loadPluginConfig(
     projectConfigPath ?? userConfigPath ?? '',
     options,
   );
+  // Note: we intentionally do NOT override image_routing to 'direct' here.
+  // The observer-disabled guard in processImageAttachments handles the
+  // auto+observer-disabled case by returning true, which triggers the
+  // debounced toast in index.ts. Overriding to 'direct' here would prevent
+  // processImageAttachments from returning true and suppress the toast.
 
   // Normalize disabled_* config keys to ensure they are arrays or undefined.
   // This loop is currently unreachable via the normal file-loading path:

+ 2 - 1
src/hooks/auto-update-checker/index.ts

@@ -4,6 +4,7 @@ import {
   ensureCompanionVersion,
   loadCompanionManifestFromPackageRoot,
 } from '../../companion/updater';
+import { TOAST_DURATION_MS } from '../../config/constants';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
 import {
@@ -434,7 +435,7 @@ function showToast(
   title: string,
   message: string,
   variant: 'info' | 'success' | 'error' = 'info',
-  duration = 3000,
+  duration = TOAST_DURATION_MS,
 ): void {
   ctx.client.tui
     .showToast({

+ 1 - 1
src/hooks/cache-safety-harness.test.ts

@@ -112,7 +112,7 @@ export function createPipeline(options: PipelineOptions = {}): Pipeline {
     processImageAttachments({
       messages: output.messages as MessageWithParts[],
       workDir: '/tmp/cache-safety-fixture',
-      imageRouting: resolveImageRouting(undefined),
+      imageRouting: resolveImageRouting(undefined, true),
       disabledAgents: new Set(),
       log: noopLog,
     });

+ 79 - 5
src/hooks/image-hook.test.ts

@@ -89,25 +89,27 @@ describe('image-hook catch logging', () => {
 describe('processImageAttachments image routing', () => {
   it('direct mode leaves image parts untouched', () => {
     const message = makeUserMsg([IMG]);
-    processImageAttachments({
+    const result = processImageAttachments({
       messages: [message],
       workDir: path.join(TEST_DIR, 'direct'),
       imageRouting: 'direct',
       disabledAgents: new Set<string>(),
       log: () => {},
     });
+    expect(result).toBe(false);
     expect(imagePartCount(message)).toBe(1);
   });
 
   it('auto mode saves image parts and adds an @observer nudge', () => {
     const message = makeUserMsg([IMG]);
-    processImageAttachments({
+    const result = processImageAttachments({
       messages: [message],
       workDir: path.join(TEST_DIR, 'auto'),
       imageRouting: 'auto',
       disabledAgents: new Set<string>(),
       log: () => {},
     });
+    expect(result).toBe(false);
     expect(imagePartCount(message)).toBe(0);
     const textParts = message.parts.filter((part) => part.type === 'text');
     expect(textParts).toHaveLength(1);
@@ -119,7 +121,7 @@ describe('processImageAttachments image routing', () => {
     processImageAttachments({
       messages: [message],
       workDir: path.join(TEST_DIR, 'omitted-routing'),
-      imageRouting: resolveImageRouting(undefined),
+      imageRouting: resolveImageRouting(undefined, true),
       disabledAgents: new Set<string>(),
       log: () => {},
     });
@@ -127,18 +129,72 @@ describe('processImageAttachments image routing', () => {
     expect(message.parts.some((part) => part.type === 'text')).toBe(true);
   });
 
-  it('keeps images when auto mode has observer disabled', () => {
+  it('returns true when observer disabled and message has images', () => {
     const message = makeUserMsg([IMG]);
-    processImageAttachments({
+    const result = processImageAttachments({
       messages: [message],
       workDir: path.join(TEST_DIR, 'disabled'),
       imageRouting: 'auto',
       disabledAgents: new Set(['observer']),
       log: () => {},
     });
+    expect(result).toBe(true);
     expect(imagePartCount(message)).toBe(1);
   });
 
+  it('returns false when observer disabled but no images present', () => {
+    const message = makeUserMsg([{ type: 'text', text: 'hello' }]);
+    const result = processImageAttachments({
+      messages: [message],
+      workDir: path.join(TEST_DIR, 'disabled-noimg'),
+      imageRouting: 'auto',
+      disabledAgents: new Set(['observer']),
+      log: () => {},
+    });
+    expect(result).toBe(false);
+  });
+
+  it('returns true when observer disabled and an earlier (non-last) user message has images', () => {
+    const earlierMsg = makeUserMsg([IMG]);
+    const lastMsg = makeUserMsg([{ type: 'text', text: 'follow-up question' }]);
+    const result = processImageAttachments({
+      messages: [earlierMsg, lastMsg],
+      workDir: path.join(TEST_DIR, 'earlier-image'),
+      imageRouting: 'auto',
+      disabledAgents: new Set(['observer']),
+      log: () => {},
+    });
+    expect(result).toBe(true);
+  });
+
+  it('does not re-trigger on text-only messages after image was processed', () => {
+    // Regression test: Greptile #1 fix checked ALL messages, causing the hook
+    // to fire on every transform once an image was in the conversation history.
+    const workDir = path.join(TEST_DIR, 'no-rere-trigger');
+    const imageMsg = makeUserMsg([IMG]);
+    const textMsg = makeUserMsg([{ type: 'text', text: 'follow-up' }]);
+
+    // First call: image present → should return true
+    const result1 = processImageAttachments({
+      messages: [imageMsg, textMsg],
+      workDir,
+      imageRouting: 'auto',
+      disabledAgents: new Set(['observer']),
+      log: () => {},
+    });
+    expect(result1).toBe(true);
+
+    // Second call: same messages, no new image → should return false
+    const result2 = processImageAttachments({
+      messages: [imageMsg, textMsg],
+      workDir,
+      imageRouting: 'auto',
+      disabledAgents: new Set(['observer']),
+      log: () => {},
+    });
+    expect(result2).toBe(false);
+  });
+
   it('keeps images when auto mode cannot save them', () => {
     const message = makeUserMsg([
       { type: 'image', url: 'https://example.com/image.png' },
@@ -207,3 +263,21 @@ describe('processImageAttachments image routing', () => {
     expect(assistant.parts).toHaveLength(1);
   });
 });
+
+describe('resolveImageRouting', () => {
+  it('returns auto when omitted and observer enabled', () => {
+    expect(resolveImageRouting(undefined, true)).toBe('auto');
+  });
+
+  it('returns direct when omitted and observer disabled', () => {
+    expect(resolveImageRouting(undefined, false)).toBe('direct');
+  });
+
+  it('preserves explicit auto even when observer disabled', () => {
+    expect(resolveImageRouting('auto', false)).toBe('auto');
+  });
+
+  it('preserves explicit direct even when observer enabled', () => {
+    expect(resolveImageRouting('direct', true)).toBe('direct');
+  });
+});

+ 53 - 4
src/hooks/image-hook.ts

@@ -16,6 +16,14 @@ import { isUserMessageWithParts, type MessageWithParts } from './types';
 const lastCleanupByDir = new Map<string, number>();
 const CLEANUP_INTERVAL = 10 * 60 * 1000; // 10 minutes
 
+// Track how many user messages we've already checked for images per directory.
+// Without this, the observer-disabled guard re-checks ALL messages on every
+// transform. Once an image is sent, it stays in the messages array forever,
+// causing the hook to fire on every subsequent text-only message. This
+// suppresses duplicate toasts while still catching images in non-last messages
+// (Greptile #1 fix).
+const lastProcessedUserMsgCountByDir = new Map<string, number>();
+
 interface ImagePart {
   type: string;
   url?: string;
@@ -169,17 +177,57 @@ export function processImageAttachments(args: {
   imageRouting: 'auto' | 'direct';
   disabledAgents: Set<string>;
   log: (msg: string) => void;
-}): void {
+}): boolean {
   const { messages, workDir, imageRouting, disabledAgents, log } = args;
 
   // direct mode: never intercept attachments; the orchestrator handles them
   // inline. @observer remains available for manual delegation.
-  if (imageRouting === 'direct') return;
+  if (imageRouting === 'direct') {
+    return false;
+  }
 
   // auto mode: observer must be enabled (enforced at config load). Retain
   // this guard as defense-in-depth in case validation is bypassed.
   const observerEnabled = !disabledAgents.has('observer');
-  if (!observerEnabled) return;
+  if (!observerEnabled) {
+    // Check only NEW user messages for images. We track how many user messages
+    // we've already processed per session. Without this, the guard re-checks
+    // ALL messages on every transform — once an image is sent, it stays in the
+    // messages array forever, causing the hook to fire on every subsequent
+    // text-only message (regression from Greptile #1 fix).
+    //
+    // Keyed by workDir:sessionID so multiple sessions in the same project
+    // don't collide (Greptile P1: "Scope tracking by conversation").
+    const firstUserMsg = messages.find(isUserMessageWithParts);
+    const sessionId = firstUserMsg?.info.sessionID ?? 'default';
+    const counterKey = `${workDir}:${sessionId}`;
+    const userMsgCount = messages.filter(isUserMessageWithParts).length;
+    let lastProcessed = lastProcessedUserMsgCountByDir.get(counterKey) ?? 0;
+    // ponytail: reset after history compaction; re-checking old messages is harmless
+    if (userMsgCount < lastProcessed) {
+      lastProcessed = 0;
+      lastProcessedUserMsgCountByDir.set(counterKey, 0);
+    }
+    if (userMsgCount > lastProcessed) {
+      // Check only the new user messages (those we haven't seen yet)
+      let userIndex = 0;
+      for (const msg of messages) {
+        if (!isUserMessageWithParts(msg)) continue;
+        if (userIndex >= lastProcessed) {
+          // This is a new user message — check for images
+          if (msg.parts.some(isImagePart)) {
+            log('[image-hook] dropped images: observer disabled');
+            lastProcessedUserMsgCountByDir.set(counterKey, userMsgCount);
+            return true;
+          }
+        }
+        userIndex++;
+      }
+      // No images in new messages — update counter so we don't re-check them
+      lastProcessedUserMsgCountByDir.set(counterKey, userMsgCount);
+    }
+    return false;
+  }
 
   const messagesWithImages: Array<{
     msg: MessageWithParts;
@@ -200,7 +248,7 @@ export function processImageAttachments(args: {
 
   if (messagesWithImages.length === 0) {
     if (existsSync(saveDir)) cleanupAllSessions(saveDir);
-    return;
+    return false;
   }
 
   const gitignorePath = join(workDir, '.opencode', '.gitignore');
@@ -279,4 +327,5 @@ export function processImageAttachments(args: {
         },
       ]);
   }
+  return false;
 }

+ 34 - 5
src/index.ts

@@ -24,6 +24,7 @@ import {
   DEFAULT_READ_CONTEXT_MAX_FILES,
   DEFAULT_READ_CONTEXT_MIN_LINES,
   resolveImageRouting,
+  TOAST_DURATION_MS,
 } from './config/constants';
 import {
   getActiveRuntimePreset,
@@ -103,6 +104,10 @@ async function appLog(
   }
 }
 
+// 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;
+
 /**
  * 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,
@@ -1249,9 +1254,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
             typeof orchestratorDef?.config?.prompt === 'string'
               ? orchestratorDef.config.prompt
               : buildOrchestratorPrompt(disabledAgents);
-          output.system[0] =
-            (output.system[0] || '') +
-            `\n\n${orchestratorPrompt}`;
+          output.system[0] = `${output.system[0] || ''}\n\n${orchestratorPrompt}`;
         }
       }
 
@@ -1291,13 +1294,39 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       // 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({
+      const imageResult = processImageAttachments({
         messages: typedOutput.messages,
         workDir: ctx.directory,
-        imageRouting: resolveImageRouting(config.image_routing),
+        imageRouting: resolveImageRouting(
+          config.image_routing,
+          !disabledAgents.has('observer'),
+        ),
         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'](