# Tech Spec: OpenCode Plugin Install & Update Flow See `specs/APP-3690/PRODUCT.md` for the product spec. ## 1. Problem `plugin_manager_for(CLIAgent::OpenCode)` returns `None` (`plugin_manager/mod.rs:90`), so OpenCode sessions never show the install/update chip. We need an `OpenCodePluginManager` that implements the `CliAgentPluginManager` trait and wires into the existing footer/modal infrastructure. Unlike Claude Code, OpenCode has no CLI for plugin management, so this implementation is manual-only (instructions modal, no auto-install). This requires a small trait refactor to make auto-operations optional and to generalize the per-agent minimum version. ## 2. Relevant Code - `plugin_manager/mod.rs` — trait definition, `plugin_manager_for()` factory, `PluginInstructions` types - `plugin_manager/claude.rs` — reference implementation; owns `compare_versions` and `MINIMUM_PLUGIN_VERSION` - `agent_input_footer/mod.rs (677-744)` — `plugin_chip_kind()` determines which chip to show - `agent_input_footer/mod.rs (748-758)` — `should_use_manual_mode()` determines auto vs modal - `agent_input_footer/mod.rs (760-849)` — `handle_plugin_operation()` shared async handler (unused by OpenCode) - `agent_input_footer/mod.rs (978-999)` — render logic matching `(chip_kind, manual)` to buttons - `workspace/view/plugin_install_modal.rs` — generic instructions modal (already agent-agnostic, takes `&'static PluginInstructions`) - `cli_agent_sessions/mod.rs (86-104)` — `CLIAgentSession` struct with `plugin_version`, `listener` - `terminal/view.rs:10732-10771` — `register_cli_agent_listener()` uses `MINIMUM_PLUGIN_VERSION` from `claude.rs` - `settings/ai.rs:1093-1105` — `plugin_chip_dismissed_for_version` setting ## 3. Current State The plugin manager system works well for Claude Code: - The trait has six required methods, all implemented by `ClaudeCodePluginManager`. - The footer routes between auto-install and manual-modal based on `should_use_manual_mode()`. - The modal is already generic (takes `&'static PluginInstructions`, not a `CLIAgent`). Three things need generalizing: 1. **`compare_versions` and `MINIMUM_PLUGIN_VERSION`** live in `claude.rs` but are imported by `agent_input_footer/mod.rs:65` and `terminal/view.rs:134`. With two agents needing different minimum versions, these should come from the trait. 2. **All trait methods are required.** OpenCode doesn't need `install()`, `update()`, `is_installed()`, or `needs_update()`. These should have sensible defaults. 3. **Install chip flicker.** Claude avoids flicker by checking `is_installed()` on the filesystem. OpenCode has no filesystem checks, so the install chip would flash on session start before the plugin connects. A debounce is needed. ## 4. Proposed Changes ### 4a. Trait Refactor (`plugin_manager/mod.rs`) **Move `compare_versions` from `claude.rs` to `mod.rs`.** It's a generic semver utility, not Claude-specific. Same signature, just a new home. **Add two new required methods to the trait:** ```rust fn minimum_plugin_version(&self) -> &'static str; fn can_auto_install(&self) -> bool; ``` `minimum_plugin_version()` replaces the hardcoded `MINIMUM_PLUGIN_VERSION` constant at all call sites. Each agent returns its own constant. `can_auto_install()` tells the footer whether this agent can auto-install/update. Claude returns `true`, OpenCode returns `false`. **Add default implementations for the four auto-operation methods:** ```rust fn is_installed(&self) -> bool { false } fn needs_update(&self) -> bool { false } async fn install(&self) -> Result<(), PluginInstallError> { Err(PluginInstallError { message: "Auto-install not supported for this agent".to_owned(), log: String::new(), }) } async fn update(&self) -> Result<(), PluginInstallError> { Err(PluginInstallError { message: "Auto-update not supported for this agent".to_owned(), log: String::new(), }) } ``` Claude overrides all four. OpenCode uses the defaults. The defaults return `Err` — the caller (`handle_plugin_operation`) already logs errors from failed operations. These should never be reached since `should_use_manual_mode()` returns `true` for agents where `can_auto_install()` is `false`. ### 4b. OpenCode Plugin Manager (`plugin_manager/opencode.rs`) New file. Minimal implementation — just the two required methods plus instructions: - `can_auto_install()` → `false` - `minimum_plugin_version()` → `"0.1.0"` (keep in sync with `opencode-warp` npm package version) - `install_instructions()` → static `PluginInstructions` with title "Install Warp Plugin for OpenCode", steps to add `"opencode-warp"` to the `plugin` array in `opencode.json` + restart - `update_instructions()` → static `PluginInstructions` with title "Update Warp Plugin for OpenCode", steps to `rm -rf ~/.cache/opencode/node_modules/opencode-warp` + restart All auto-operation methods (`is_installed`, `needs_update`, `install`, `update`) use the trait defaults. ### 4c. Factory Registration (`plugin_manager/mod.rs`) ```rust CLIAgent::OpenCode => Some(Box::new(opencode::OpenCodePluginManager)), ``` ### 4d. Footer: Generalize `MINIMUM_PLUGIN_VERSION` (`agent_input_footer/mod.rs`) Replace the import of `claude::{compare_versions, MINIMUM_PLUGIN_VERSION}` with: - `use super::compare_versions` (from `mod.rs`) - All references to `MINIMUM_PLUGIN_VERSION` become `manager.minimum_plugin_version()` This affects three places in `plugin_chip_kind()`: - `agent_input_footer/mod.rs:704` — version comparison for connected plugin - `agent_input_footer/mod.rs:712` — dismissed version comparison - `agent_input_footer/mod.rs:732` — dismissed version comparison (filesystem fallback path) And one place in the dismiss handler: - `agent_input_footer/mod.rs:1583` — storing the dismissed version For the dismiss handler, the manager needs to be resolved to get its `minimum_plugin_version()`. Since `plugin_chip_kind()` already resolves the manager, and the dismiss handler knows whether it's dismissing an update chip, this is straightforward — resolve the manager from the session's agent. ### 4e. Footer: `should_use_manual_mode()` (`agent_input_footer/mod.rs:748`) Add a check for `can_auto_install()` before the existing conditions: ```rust fn should_use_manual_mode(&self, app: &AppContext) -> bool { let sessions_model = CLIAgentSessionsModel::as_ref(app); let session = match sessions_model.session(self.terminal_view_id) { Some(s) => s, None => return false, }; if let Some(manager) = plugin_manager_for(session.agent) { if !manager.can_auto_install() { return true; } } if session.is_remote() { return true; } sessions_model.has_plugin_auto_failed(session.agent, &session.remote_host) } ``` For OpenCode, this always returns `true`, so the footer always opens the modal instead of attempting auto-install. The `handle_plugin_operation` / `handle_install_plugin` / `handle_update_plugin` code paths are never reached. ### 4e-ii. Footer: Chip Button Labels The install chip buttons are created at construction time with hardcoded labels: - `install_plugin_button`: "Enable Claude Code notifications" — Claude-specific - `plugin_instructions_button`: "Notifications setup instructions" - `update_plugin_button`: "Update Warp plugin" - `update_instructions_button`: "Plugin update instructions" The auto-install button label hardcodes "Claude Code". Make it dynamic using the agent's `display_name()`: format as `"Enable {name} notifications"` where `name` comes from `session.agent.display_name()` (e.g., "Enable Claude Code notifications", "Enable OpenCode notifications"). Since the button is created once at construction, update the label via `set_label` in the render path (or when the session changes), the same way the compose button label is already updated dynamically (`agent_input_footer/mod.rs:374`). ### 4f. `terminal/view.rs`: Generalize `register_cli_agent_listener` `terminal/view.rs:10753` hardcodes `MINIMUM_PLUGIN_VERSION` from `claude.rs`. Replace with: ```rust let plugin_version = plugin_manager_for(agent) .map(|m| m.minimum_plugin_version().to_owned()); ``` This returns the correct minimum version for whichever agent is active, and `None` if the agent has no plugin manager (which preserves the existing wasm fallback behavior). ### 4g. Install Chip Debounce (`agent_input_footer/mod.rs`) **Problem:** For agents without filesystem checks (OpenCode), `plugin_chip_kind()` would show the install chip immediately on session start, before the plugin has time to connect and send `SessionStart`. **Solution:** Add `plugin_chip_ready: bool` to `AgentInputFooter`. It starts `false` and is set to `true` after a debounce timer fires. When a `Started` event fires for a non-auto-install agent, spawn a one-shot timer via `ctx.spawn(Timer::after(PLUGIN_CHIP_DEBOUNCE), ...)`. When the timer fires, set `plugin_chip_ready = true` and call `ctx.notify()` to trigger a re-render. In the timer callback, check whether a listener has connected in the meantime — if so, skip setting the flag. In `plugin_chip_kind()`, the "no listener" branch checks: ```rust if !manager.can_auto_install() && !self.plugin_chip_ready { return None; } ``` Reset `plugin_chip_ready = false` when a session ends or when a listener connects. For Claude (which has `can_auto_install() == true`), this check is skipped — Claude uses `is_installed()` instead. `PLUGIN_CHIP_DEBOUNCE` is a constant in `agent_input_footer/mod.rs` (`Duration::from_secs(3)`). ### 4h. Claude Module Cleanup (`plugin_manager/claude.rs`) - Remove `compare_versions` (moved to `mod.rs`) - Add `minimum_plugin_version()` returning `MINIMUM_PLUGIN_VERSION` - Add `can_auto_install()` returning `true` - Keep `MINIMUM_PLUGIN_VERSION` as a module-level constant (still useful for the `needs_update()` and `update()` implementations within this module) ## 5. End-to-End Flow ### Install 1. User starts OpenCode in Warp. Command detection creates a `CLIAgentSession`. The footer's `Started` subscription fires, spawning a debounce timer (`plugin_chip_ready` starts `false`). 2. Footer renders. `plugin_chip_kind()` finds `plugin_manager_for(OpenCode)` = `Some`. No listener. `can_auto_install()` is `false`. `plugin_chip_ready` is `false` → returns `None`. No chip. 3. If plugin is installed: `SessionStart` arrives within ~1s, listener is created, `plugin_version` is set, `plugin_chip_ready` reset to `false`. Footer re-renders. Chip never appears. 4. If plugin is not installed: timer fires after 3s, sets `plugin_chip_ready = true`, calls `ctx.notify()`. `plugin_chip_kind()` returns `Install`. `should_use_manual_mode()` returns `true`. Footer shows the instructions chip. 5. User clicks → modal opens with install steps (add to `opencode.json`, restart). 6. User follows steps, restarts OpenCode. Plugin connects, sends `SessionStart`. Chip disappears. ### Update 1. We bump `MINIMUM_PLUGIN_VERSION` in `opencode.rs` to `"0.2.0"`. 2. Plugin connects with `plugin_version: "0.1.0"`. 3. `plugin_chip_kind()`: listener present, `compare_versions("0.1.0", "0.2.0")` is `Less` → `Update`. 4. `should_use_manual_mode()` returns `true`. Footer shows update instructions chip. 5. User clicks → modal shows cache-clear + restart steps. 6. User follows steps. On restart, Bun re-resolves `opencode-warp` from npm (cache was cleared), installs latest. Plugin connects with `"0.2.0"`. Chip disappears. ## 6. Risks and Mitigations **Risk: `PluginInstructionStep.command` contains JSON, not a shell command.** **Risk: `PluginInstructionStep.command` contains JSON, not a shell command.** The install modal's copy button copies the `command` field to clipboard. For OpenCode install, this will be a JSON snippet. **Mitigation:** Already fine — the modal copies any string. A JSON snippet is useful to copy even if it's not a terminal command. **Risk: Stale `MINIMUM_PLUGIN_VERSION`.** The minimum version is compiled into the Warp binary. **Mitigation:** Same as Claude — by design. We only prompt updates when Warp needs new plugin behavior. ## 7. Testing and Validation ### Unit tests (`plugin_manager/opencode_tests.rs`) - `opencode_manager_can_auto_install_is_false` - `opencode_manager_returns_install_instructions` — non-empty steps - `opencode_manager_returns_update_instructions` — non-empty steps - `opencode_manager_minimum_version` — returns expected value ### Unit tests (`plugin_manager/mod_tests.rs`) - Update `returns_none_for_unsupported_agents` — remove `CLIAgent::OpenCode` from assertion - Add `returns_manager_for_opencode` - Move `compare_versions` tests here from `claude_tests.rs` ### Unit tests (`plugin_manager/claude_tests.rs`) - Remove `compare_versions` tests (moved) - Add `claude_manager_can_auto_install_is_true` - Add `claude_manager_minimum_version` ### Manual testing - Start an OpenCode session → install chip appears after ~3s debounce (not immediately) - If plugin is installed: chip never appears (plugin connects before debounce) - Click install chip → modal opens with correct OpenCode-specific instructions - Follow install steps, restart → chip disappears - Bump minimum version locally → update chip appears - Click update chip → modal shows cache-clear instructions - Dismiss install chip → stays hidden - Dismiss update chip → stays hidden; bump minimum → reappears - Claude flow unchanged — verify no regressions ## 8. Follow-Ups - **Auto-install:** If OpenCode adds a plugin management CLI, implement `install()` and `update()` on `OpenCodePluginManager` and flip `can_auto_install()` to `true`. - **Publish `opencode-warp` to npm:** Must happen before this feature ships. ## 9. Files Changed - **New:** `plugin_manager/opencode.rs` — `OpenCodePluginManager`, `MINIMUM_PLUGIN_VERSION`, install/update instructions - **New:** `plugin_manager/opencode_tests.rs` - **Modified:** `plugin_manager/mod.rs` — `compare_versions` moved here, `minimum_plugin_version()` + `can_auto_install()` added to trait, default impls for `is_installed`/`needs_update`/`install`/`update`, factory wires OpenCode - **Modified:** `plugin_manager/claude.rs` — remove `compare_versions` (moved), add `minimum_plugin_version()` + `can_auto_install()` overrides - **Modified:** `plugin_manager/claude_tests.rs` — remove `compare_versions` tests (moved), add new trait method tests - **Modified:** `plugin_manager/mod_tests.rs` — add OpenCode factory test, receive `compare_versions` tests - **Modified:** `agent_input_footer/mod.rs` — add `plugin_chip_ready: bool` + debounce timer, update imports (`compare_versions` from `mod.rs`), `plugin_chip_kind()` uses `manager.minimum_plugin_version()` + `plugin_chip_ready` guard, `should_use_manual_mode()` checks `can_auto_install()`, dismiss handler resolves minimum version from manager, rename auto-install chip label to be agent-generic - **Modified:** `terminal/view.rs` — `register_cli_agent_listener()` uses `plugin_manager_for(agent)?.minimum_plugin_version()` instead of Claude's constant