Browse Source

Merge master into fix/preset-tui-slash-command

Resolve src/tui.ts import conflict by keeping both sides:
- master adds @opentui/core (ColorInput, parseColor, RGBA) for getContrastForeground
- PR adds TuiCommand, TuiPluginApi to @opencode-ai/plugin/tui imports for /preset

Verified: tsc --noEmit clean, tui+preset-switch tests pass (41/0).
Qesire 2 weeks ago
parent
commit
363b981e64

+ 12 - 7
README.md

@@ -260,7 +260,7 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
   <tr>
     <td colspan="2">
-      <b>Recommended Models:</b> <code>openai/gpt-5.6-terra (medium)</code> <code>anthropic/claude-fable-5</code> <code>anthropic/claude-opus-4-8</code>
+      <b>Recommended Models:</b> <code>claude-fable-5</code> <code>claude-opus-4-8</code> <code>glm-5.2</code> <code>gpt-5.6-terra</code> <code>mimo-v2.5</code> <code>minimax-m3</code> <code>qwen3.7-plus</code>
     </td>
   </tr>
   <tr>
@@ -301,7 +301,7 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
   <tr>
     <td colspan="2">
-      <b>Recommended Models:</b> <code>openai/gpt-5.3-codex</code> <code>cerebras/zai-glm-4.7</code> <code>fireworks-ai/accounts/fireworks/routers/kimi-k2p6-turbo</code>
+      <b>Recommended Models:</b> <code>deepseek-v4-flash</code> <code>gpt-5.3-codex</code>
     </td>
   </tr>
   <tr>
@@ -342,7 +342,7 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
   <tr>
     <td colspan="2">
-      <b>Recommended Models:</b> <code>openai/gpt-5.6-sol (xhigh)</code> <code>anthropic/claude-fable-5</code> <code>anthropic/claude-opus-4-8 (xhigh)</code>
+      <b>Recommended Models:</b> <code>claude-fable-5</code> <code>claude-opus-4-8</code> <code>deepseek-v4-pro</code> <code>glm-5.2</code> <code>gpt-5.6-sol</code> <code>qwen3.7-max</code>
     </td>
   </tr>
   <tr>
@@ -432,7 +432,7 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
   <tr>
     <td colspan="2">
-      <b>Recommended Models:</b> <code>openai/gpt-5.3-codex</code> <code>cerebras/zai-glm-4.7</code> <code>fireworks-ai/accounts/fireworks/routers/kimi-k2p6-turbo</code>
+      <b>Recommended Models:</b> <code>deepseek-v4-flash</code> <code>gpt-5.3-codex</code> <code>mimo-v2.5</code> <code>minimax-m2.7</code>
     </td>
   </tr>
   <tr>
@@ -473,7 +473,7 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
   <tr>
     <td colspan="2">
-      <b>Recommended Models:</b> <code>google/gemini-3.5-flash</code> <code>moonshotai/kimi-k2.7-code</code>
+      <b>Recommended Models:</b> <code>gemini-3.5-flash</code> <code>kimi-k2.7-code</code> <code>minimax-m3</code>
     </td>
   </tr>
   <tr>
@@ -509,12 +509,12 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
   <tr>
     <td colspan="2">
-      <b>Default Model:</b> <code>openai/gpt-5.6-luna (medium)</code>
+      <b>Default Model:</b> <code>openai/gpt-5.6-luna</code>
     </td>
   </tr>
   <tr>
     <td colspan="2">
-      <b>Recommended Models:</b> <code>openai/gpt-5.6-luna (medium)</code> <code>anthropic/claude-sonnet-4-6</code>
+      <b>Recommended Models:</b> <code>claude-sonnet-4-6</code> <code>deepseek-v4-flash</code> <code>gpt-5.6-luna</code> <code>kimi-k2.7-code</code>
     </td>
   </tr>
   <tr>
@@ -559,6 +559,11 @@ If any agent fails to respond, check your provider authentication and config fil
       <b>Default Model:</b> <code>openai/gpt-5.6-luna</code> - <i>configure a vision-capable model to enable</i>
     </td>
   </tr>
+  <tr>
+    <td colspan="2">
+      <b>Recommended Models:</b> <code>mimo-v2.5</code> <code>qwen3.5-plus</code>
+    </td>
+  </tr>
   <tr>
     <td colspan="2">
       <b>Model Guidance:</b> Choose a vision-capable model if you want the agent to read screenshots, images, PDFs, and other visual files.

+ 131 - 0
docs/agents/build-agent-empty-input-diagnosis.md

@@ -0,0 +1,131 @@
+# Diagnosis: "build agent empty input" after orchestrator output
+
+**Status:** Diagnosis only — no code change yet.
+**Date:** 2026-07-19
+**Related PR:** #818 (`fix/preset-tui-slash-command`) — same root class as the original `/preset` fix.
+**Suspected sibling bug reported by user:** During `superpowers` / `brainstorm` skill conversations, when the orchestrator asks for confirmation or work is interrupted (subagent completes, background task finishes), a `build` agent turn sometimes appears with an empty user input.
+
+## TL;DR
+
+The `build` agent turn with empty input is **the same class of bug** as the original `/preset` issue fixed in #818: a plugin hook calls `sessionSdk.promptAsync({ body: { parts: [createInternalAgentTextPart(...)] } })` **without specifying an `agent` field**. opencode then resolves the agent via `agents.defaultInfo()`, which falls back to the built-in `build` agent whenever `default_agent` is unset, user-overridden, or not effectively applied. The `synthetic: true` flag hides the injected text from the TUI, so the user perceives the `build` turn as having "empty input."
+
+## Root cause (causal chain, cross-validated)
+
+1. **Orchestrator enters input-wait.** After emitting a confirmation question (skill flow), the assistant turn finishes. opencode's per-session Runner transitions to `Idle` (`packages/opencode/src/effect/runner.ts:115-138`, `packages/opencode/src/session/run-state.ts:60-63`). The session is no longer "busy" from the Runner's perspective.
+
+2. **Plugin hook fires `promptAsync` with a synthetic part and no `agent` field.** Two call sites in omos do this:
+   - `src/hooks/task-session-manager/index.ts:398-402` — `CONTINUATION_NUDGE` injection, fires on `session.idle` / `session.status(idle)` when the orchestrator session has incomplete todos (matches "subagent completes" / "background task finishes").
+   - `src/interview/service.ts:622, 871, 933, 1007` — interview/skill flow injections (matches "brainstorm skill flow").
+
+3. **opencode does not guard `promptAsync` against busy/input-wait state.** The HTTP handler at `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:311-329` does not call `assertNotBusy` and does not consult the Question service. It proceeds straight to `promptSvc.prompt`. The Runner, being `Idle`, immediately `startRun`s the new turn (`runner.ts:131-134`). No queue, no reject, no cancellation of the pending question.
+
+4. **Agent resolves to `build`.** `packages/opencode/src/session/prompt.ts:636-637`:
+   ```ts
+   const agentName = input.agent
+   const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
+   ```
+   When `input.agent` is omitted, opencode uses `agents.defaultInfo()` (`packages/opencode/src/agent/agent.ts:328-340`), which returns the first visible `mode: "primary"` agent — **`build`** (declared first in the agent registry, `agent.ts:141-155`). omos attempts to set `default_agent = "orchestrator"` via its `config` hook (`src/index.ts:546-551`), but only when `default_agent` is absent. `build` is selected whenever:
+   - `config.setDefaultAgent === false` (plugin config disables it)
+   - The user's `opencode.json` already sets a different `default_agent`
+   - The `config` hook didn't run or didn't apply (SDK/runtime version skew: plugin built against `@opencode-ai/sdk` v1.4.3, installed runtime v1.18.3)
+   - The orchestrator agent isn't registered at config-load time
+
+5. **"Empty input" is the synthetic flag's visual effect.** `synthetic: true` only controls TUI visibility. `packages/opencode/src/session/message-v2.ts:206` still includes synthetic text parts in model messages (the only filters are `!part.ignored` and `part.text !== ""`; there is no `synthetic` filter when building model messages). The user sees the `build` agent respond to a turn with no visible user message — perceived as "empty input." (`createInternalAgentTextPart` appends a `\n<!-- SLIM_INTERNAL_INITIATOR -->` marker, so the text is non-empty from the LLM's perspective.)
+
+6. **Session agent is durably corrupted.** `packages/opencode/src/session/prompt.ts:672-689` compares `current.agent !== info.agent` and, if they differ, calls `sessions.setAgentModel({ agent: info.agent, ... })`. A single agent-less `promptAsync` that resolves to `build` **permanently rewrites the session's agent to `build`** — every subsequent turn also routes to `build` until explicitly reset.
+
+## Confirmed-affected call sites
+
+| File:line | Trigger | Body omits `agent`? | Gate |
+|---|---|---|---|
+| `src/hooks/task-session-manager/index.ts:398-402` | `session.idle` / `session.status(idle)` on orchestrator session with incomplete todos | **Yes** | `continuationConsumed`, `hasInputWait` (3×: lines 282, 362, 386), `isCurrentContinuation`, `isFallbackInProgress`, `backgroundJobBoard.hasTerminalUnreconciled` |
+| `src/interview/service.ts:622` | User submits interview dashboard input | **Yes** | `sessionBusy` lock, interview active state |
+| `src/interview/service.ts:871` | User submits interview chat | **Yes** | same |
+| `src/interview/service.ts:933` | User submits interview answer | **Yes** | same |
+| `src/interview/service.ts:1007` | User submits interview comment | **Yes** | same |
+| `src/interview/service.ts:504` | Interview URL notification | **Yes** (but `noReply: true`, non-synthetic text) | none |
+| `src/tools/smartfetch/secondary-model.ts:252` | Smartfetch secondary model query | **Yes** | none |
+
+## Correct pattern (for comparison)
+
+`src/hooks/foreground-fallback/index.ts:635-639` explicitly includes the agent:
+```ts
+const promptBody = {
+  parts: lastUser.parts,
+  model: ref,
+  ...(agentName ? { agent: agentName } : {}),
+};
+```
+This is the pattern every `promptAsync` caller in omos should follow.
+
+## Why the `hasInputWait` gate in task-session-manager is not sufficient
+
+The gate exists and works in the common case (`task-session-manager/index.ts:282, 362, 386`, with tests at `index.test.ts:2772-2858, 3013-3048`). But:
+
+1. **Documented race window.** `IDLE_RECONCILE_DELAY_MS = 2_000` (line 54). The comment at lines 49-53 admits: "Completions arriving after the window are still dropped (the race is reduced, not eliminated)." If `session.idle` fires and the 2s timer expires before `question.asked` is delivered, and the 3 SDK calls (`todo`/`children`/`status`) in `evaluateContinuation` all resolve before `question.asked` arrives, the nudge fires.
+
+2. **Input-wait is not the only trigger.** The interview/skill path (`src/interview/service.ts`) does **not** consult `hasInputWait` at all — it injects on user dashboard actions, which can happen while the orchestrator is mid-question.
+
+3. **The gate does not address the missing `agent` field.** Even when the nudge legitimately fires (no input-wait, real incomplete todos), the resulting turn still routes to `build` if `default_agent` is unset. The gate prevents *some* unwanted injections; it does not prevent *misrouting* when injection happens.
+
+## Why this is the same class as the #818 `/preset` fix
+
+#818's original bug: `/preset` used `createInternalAgentTextPart()` to trigger an LLM turn that was invisible in the TUI (`synthetic: true`). The fix moved `/preset` to pure TUI dialogs (`src/tui-preset.ts` uses only `api.ui.dialog` / `DialogSelect` / `DialogPrompt` / `DialogConfirm` — no `promptAsync`).
+
+This bug: other hooks still use the same `createInternalAgentTextPart` + `promptAsync` pattern, and additionally omit the `agent` field, so the invisible turn routes to `build` instead of the orchestrator. Same shape: a synthetic part starting an invisible turn. Different symptom: `build` agent instead of orchestrator.
+
+## Fix directions (not implemented — awaiting decision)
+
+### Minimal fix
+Add `agent: 'orchestrator'` to the `promptAsync` body at all four affected call sites:
+- `src/hooks/task-session-manager/index.ts:398-402`
+- `src/interview/service.ts:622, 871, 933, 1007`
+
+This ensures the continuation nudge and interview injections always route to the orchestrator regardless of opencode's `default_agent` resolution, eliminating the path to `build`.
+
+### Hardening (optional, larger scope)
+1. **Input-wait guard on the interview/skill path.** Consult `hasInputWait` (or an equivalent signal) before injecting in `src/interview/service.ts`. Do not inject while the orchestrator is waiting for user input.
+2. **Post-injection agent assertion.** After each `promptAsync`, assert `current.agent` was not changed out from under the orchestrator; if it was, restore it via `setAgentModel`.
+3. **Investigate the `default_agent` application reliability** on opencode v1.18.x. The plugin was built against `@opencode-ai/sdk` v1.4.3; the installed runtime is v1.18.3. The `config` hook's `default_agent = 'orchestrator'` mutation may not be applied reliably under this skew. (Note: #799 tracks the package upgrade.)
+4. **Shrink or eliminate the `IDLE_RECONCILE_DELAY_MS` race** for sessions that have a pending `question.asked` / `permission.asked`.
+
+## Evidence index
+
+### omos source
+- **Missing `agent` field (the bug):** `src/hooks/task-session-manager/index.ts:398-402`
+- **Missing `agent` field (skill flow):** `src/interview/service.ts:622, 871, 933, 1007`
+- **Correct pattern for comparison:** `src/hooks/foreground-fallback/index.ts:635-639`
+- **omos sets `default_agent` only when absent:** `src/index.ts:546-551`
+- **`createInternalAgentTextPart` produces `synthetic: true`:** `src/utils/internal-initiator.ts:9-21`
+- **`CONTINUATION_NUDGE` is non-empty:** `src/hooks/task-session-manager/index.ts:56-57`
+- **`hasInputWait` gate (3 checks):** `src/hooks/task-session-manager/index.ts:282, 362, 386`
+- **`IDLE_RECONCILE_DELAY_MS` race window:** `src/hooks/task-session-manager/index.ts:54` (admission at lines 49-53)
+- **`disableDefaultAgents` preserves `build` and `plan`:** `src/cli/config-io.ts:564-600`
+
+### opencode source (`anomalyco/opencode` @ `dev`)
+- **`promptAsync` HTTP handler (no busy/input-wait guard):** `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:311-329`
+- **`prompt` HTTP handler (no guard):** same file, `:295-309`
+- **Internal `prompt` always starts a new turn:** `packages/opencode/src/session/prompt.ts:1052-1071`
+- **Agent selection: `input.agent ?? defaultInfo()`:** `packages/opencode/src/session/prompt.ts:636-637`
+- **`defaultInfo()` → `default_agent` or first visible primary:** `packages/opencode/src/agent/agent.ts:328-340`
+- **`build` agent definition (default primary, first in registry):** `packages/opencode/src/agent/agent.ts:141-155`
+- **Destructive `setAgentModel` overwrite on agent change:** `packages/opencode/src/session/prompt.ts:672-689`
+- **Synthetic parts included in model messages:** `packages/opencode/src/session/message-v2.ts:206`
+- **Runner `ensureRunning` (Idle = run now, no queue):** `packages/opencode/src/effect/runner.ts:115-138`
+- **Runner Idle transition on turn end:** `packages/opencode/src/session/run-state.ts:60-63`
+- **`assertNotBusy` (NOT used by prompt/promptAsync):** `packages/opencode/src/session/run-state.ts:71-75`
+
+### SDK types
+- **`default_agent` doc: "Falls back to 'build' if not set or invalid":** `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:1255-1257`
+- **`SessionPromptAsyncData.body.agent?` is optional:** `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:3241-3269`
+- **`build` is a built-in agent:** `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:1273-1279`
+
+## Open questions for the fix
+
+1. Should the fix be a new PR, or amended into #818? (#818 is currently `MERGEABLE` / `CLEAN` / CI green; amending widens its scope and may delay merge.)
+2. Is the interview/skill path expected to always route to the orchestrator, or could it intentionally target a different agent in some flows?
+3. Should we also harden the `default_agent` application (fix direction #3) as part of this work, or track it separately under #799?
+
+---
+
+This report is diagnostic only. No code was changed. The fix awaits the user's decision on scope and PR strategy.

+ 2 - 2
docs/configuration.md

@@ -227,8 +227,8 @@ subprocess.
   `presets.<name>.council.model`.
 - The **councillor models** are configured separately under
   `council.presets.<name>.<councillor>.model`.
-- Deprecated `council.master*` fields are legacy compatibility aliases only;
-  do not use them in new configs.
+- `council.master*` fields have been removed. A deprecation warning is
+  logged this release if a config still contains them.
 
 ### Manual Update Mode
 

+ 4 - 7
docs/council.md

@@ -348,14 +348,11 @@ A footer tracks participation:
 
 ## Compatibility Notes
 
-### Deprecated `master` fields
+### Removed `master` fields
 
-Older configs used `council.master` and several other `master`-prefixed
-fields. These fields are deprecated and ignored.
-
-`master.model` is still accepted as a temporary fallback for the **Council
-agent model only** when no explicit `council` agent model is configured
-elsewhere.
+The `council.master` field and other `master`-prefixed fields have been
+removed. A deprecation warning is logged this release if a config still
+contains them, but they no longer have any effect.
 
 Prefer this instead:
 

+ 2 - 30
oh-my-opencode-slim.schema.json

@@ -973,32 +973,6 @@
         }
       }
     },
-    "tmux": {
-      "type": "object",
-      "properties": {
-        "enabled": {
-          "default": false,
-          "type": "boolean"
-        },
-        "layout": {
-          "default": "main-vertical",
-          "type": "string",
-          "enum": [
-            "main-horizontal",
-            "main-vertical",
-            "tiled",
-            "even-horizontal",
-            "even-vertical"
-          ]
-        },
-        "main_pane_size": {
-          "default": 60,
-          "type": "number",
-          "minimum": 20,
-          "maximum": 80
-        }
-      }
-    },
     "websearch": {
       "type": "object",
       "properties": {
@@ -1127,14 +1101,12 @@
         "default_preset": {
           "default": "default",
           "type": "string"
-        },
-        "master": {
-          "description": "DEPRECATED - ignored. Council agent synthesizes directly."
         }
       },
       "required": [
         "presets"
-      ]
+      ],
+      "additionalProperties": {}
     },
     "companion": {
       "type": "object",

+ 1 - 2
src/agents/council-agents.test.ts

@@ -5,7 +5,7 @@ import { buildCouncillorAgents } from './council-agents';
 /**
  * Build a minimal CouncilConfig for use in tests.
  * We cast through `unknown` to avoid repeating the full post-transform shape
- * which includes `_deprecated` / `_legacyMasterModel` and optional fields.
+ * which includes `_deprecated` and optional fields.
  */
 function makeConfig(overrides: Record<string, unknown>): PluginConfig {
   return {
@@ -13,7 +13,6 @@ function makeConfig(overrides: Record<string, unknown>): PluginConfig {
       presets: {},
       default_preset: 'default',
       _deprecated: undefined,
-      _legacyMasterModel: undefined,
       ...overrides,
     },
   } as unknown as PluginConfig;

+ 6 - 41
src/agents/index.test.ts

@@ -536,41 +536,7 @@ describe('council agent model resolution', () => {
     expect(councillor?.config.model).toBe(DEFAULT_MODELS.councillor);
   });
 
-  test('council falls back to legacy master.model when no preset override', () => {
-    // Simulates a pre-1.0.0 config with council.master.model but no council
-    // entry in the agent preset - the exact scenario from issue #369.
-    const config: PluginConfig = {
-      agents: {
-        oracle: { model: 'openai/gpt-5.6' },
-      },
-      council: {
-        ...councilConfig(),
-        _legacyMasterModel: 'anthropic/claude-opus-4-6',
-      },
-    };
-    const agents = createAgents(config);
-    const council = agents.find((a) => a.name === 'council');
-    expect(council?.config.model).toBe('anthropic/claude-opus-4-6');
-  });
-
-  test('council preset override takes precedence over legacy master.model', () => {
-    // If user has explicit council in preset, that wins - legacy is ignored.
-    const config: PluginConfig = {
-      agents: {
-        council: { model: 'google/gemini-3-pro' },
-      },
-      council: {
-        ...councilConfig(),
-        _legacyMasterModel: 'anthropic/claude-opus-4-6',
-      },
-    };
-    const agents = createAgents(config);
-    const council = agents.find((a) => a.name === 'council');
-    expect(council?.config.model).toBe('google/gemini-3-pro');
-  });
-
-  test('council uses default when no legacy master and no preset override', () => {
-    // No legacy master, no preset override → standard default
+  test('council uses default when no preset override', () => {
     const config: PluginConfig = {
       council: councilConfig(),
     };
@@ -579,10 +545,8 @@ describe('council agent model resolution', () => {
     expect(council?.config.model).toBe(DEFAULT_MODELS.council);
   });
 
-  test('end-to-end: raw master.model config flows through schema to council agent', () => {
-    // Integration test: start from raw user config with deprecated master.model,
-    // parse through CouncilConfigSchema, then pass to createAgents.
-    // This validates the full seam between schema transform and agent resolution.
+  test('deprecated council.master field is ignored', () => {
+    // Verify that the deprecated master field is reported but not applied.
     const rawCouncilConfig = {
       master: { model: 'anthropic/claude-opus-4-6' },
       presets: {
@@ -596,13 +560,14 @@ describe('council agent model resolution', () => {
     expect(parsed.success).toBe(true);
 
     if (parsed.success) {
+      expect(parsed.data._deprecated).toEqual(['master']);
       const config: PluginConfig = {
         council: parsed.data,
       };
       const agents = createAgents(config);
       const council = agents.find((a) => a.name === 'council');
-      // Legacy master.model should flow through schema → agent
-      expect(council?.config.model).toBe('anthropic/claude-opus-4-6');
+      // Master is deprecated and no longer used for model fallback
+      expect(council?.config.model).toBe(DEFAULT_MODELS.council);
     }
   });
 });

+ 0 - 15
src/agents/index.ts

@@ -477,21 +477,6 @@ export function createAgents(
     return agent;
   });
 
-  // 2b. Backward compat: if council has no preset override and still uses the
-  // hardcoded default model, fall back to the deprecated council.master.model.
-  // See https://github.com/alvinunreal/oh-my-opencode-slim/issues/369
-  const legacyMasterModel = config?.council?._legacyMasterModel;
-  if (legacyMasterModel) {
-    const councilAgent = builtInSubAgents.find((a) => a.name === 'council');
-    if (
-      councilAgent &&
-      !getAgentOverride(config, 'council')?.model &&
-      councilAgent.config.model === DEFAULT_MODELS.council
-    ) {
-      councilAgent.config.model = legacyMasterModel;
-    }
-  }
-
   const customSubAgents = protoCustomAgents.map((agent) => {
     const override = getAgentOverride(config, agent.name);
     if (override) {

+ 8 - 0
src/cache-safety-tripwire.test.ts

@@ -66,6 +66,14 @@ const ALLOWLIST = new Map<string, string>([
     'hooks/image-hook.ts',
     'Date.now() throttles temp-image cleanup; extracted image paths are deterministic per part id.',
   ],
+  [
+    'hooks/auto-update-checker/cache.ts',
+    'Date.now() and process.pid name an on-disk quarantine directory during the atomic publish transaction; the path is filesystem bookkeeping, never serialized into prompt content.',
+  ],
+  [
+    'hooks/auto-update-checker/checker.ts',
+    'Date.now()/Math.random() compose a per-run temp token for install bookkeeping; it names local directories and never reaches the prompt prefix.',
+  ],
 ]);
 
 async function scanForViolations(): Promise<string[]> {

+ 0 - 2
src/cli/background-subagents.test.ts

@@ -187,7 +187,6 @@ describe('configureBackgroundSubagents', () => {
 
     try {
       const result = await configureBackgroundSubagents({
-        hasTmux: false,
         installCustomSkills: false,
         promptForStar: false,
         reset: false,
@@ -221,7 +220,6 @@ describe('configureBackgroundSubagents', () => {
 
     try {
       const result = await configureBackgroundSubagents({
-        hasTmux: false,
         installCustomSkills: false,
         promptForStar: false,
         reset: false,

+ 70 - 7
src/cli/config-io.test.ts

@@ -46,11 +46,14 @@ describe('config-io', () => {
     mock.restore();
   });
 
-  function writePackageJson(dir: string): void {
+  function writePackageJson(dir: string, version?: string): void {
     mkdirSync(dir, { recursive: true });
     writeFileSync(
       join(dir, 'package.json'),
-      JSON.stringify({ name: 'oh-my-opencode-slim' }),
+      JSON.stringify({
+        name: 'oh-my-opencode-slim',
+        ...(version ? { version } : {}),
+      }),
     );
   }
 
@@ -181,6 +184,51 @@ describe('config-io', () => {
     expect(saved.plugin).toEqual(['oh-my-opencode-slim']);
   });
 
+  test('addPluginToOpenCodeConfig leaves @latest bunx invocations unpinned', async () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@latest',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    paths.ensureConfigDir();
+    writeFileSync(configPath, JSON.stringify({ plugin: [] }));
+    writePackageJson(packageRoot, '1.2.3');
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+    const result = await addPluginToOpenCodeConfig();
+
+    expect(result.success).toBe(true);
+    const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
+    expect(saved.plugin).toEqual(['oh-my-opencode-slim']);
+  });
+
+  test('addPluginToOpenCodeConfig writes the resolved version as an installer-managed tuple', async () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@beta',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    paths.ensureConfigDir();
+    writeFileSync(configPath, JSON.stringify({ plugin: [] }));
+    writePackageJson(packageRoot, '1.2.3');
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+    const result = await addPluginToOpenCodeConfig();
+
+    expect(result.success).toBe(true);
+    const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
+    expect(saved.plugin).toEqual([
+      [
+        'oh-my-opencode-slim@1.2.3',
+        { __ohMyOpencodeSlimManagedByInstaller: true },
+      ],
+    ]);
+  });
+
   test('addPluginToOpenCodeConfig stores local repo path for local dev paths', async () => {
     const configPath = join(tmpDir, 'opencode', 'opencode.json');
     const packageRoot = join(tmpDir, 'repo');
@@ -424,7 +472,6 @@ describe('config-io', () => {
     paths.ensureConfigDir();
 
     const result = writeLiteConfig({
-      hasTmux: true,
       installCustomSkills: false,
       reset: false,
     });
@@ -437,7 +484,6 @@ describe('config-io', () => {
     expect(saved.preset).toBe('openai');
     expect(saved.presets.openai).toBeDefined();
     expect(saved.presets['opencode-go']).toBeDefined();
-    expect(saved.tmux.enabled).toBe(true);
   });
 
   test('writeLiteConfig writes selected preset', () => {
@@ -445,7 +491,6 @@ describe('config-io', () => {
     paths.ensureConfigDir();
 
     const result = writeLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       preset: 'opencode-go',
       reset: false,
@@ -568,7 +613,6 @@ describe('config-io', () => {
             librarian: { model: 'zai-coding-plan/glm-4.7' },
           },
         },
-        tmux: { enabled: true },
       }),
     );
 
@@ -579,7 +623,26 @@ describe('config-io', () => {
     expect(detected.hasAnthropic).toBe(true);
     expect(detected.hasCopilot).toBe(true);
     expect(detected.hasZaiPlan).toBe(true);
-    expect(detected.hasTmux).toBe(true);
+  });
+
+  test('detectCurrentConfig detects installed status for installer-managed tuple', () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    paths.ensureConfigDir();
+
+    writeFileSync(
+      configPath,
+      JSON.stringify({
+        plugin: [
+          [
+            'oh-my-opencode-slim@1.2.3',
+            { __ohMyOpencodeSlimManagedByInstaller: true },
+          ],
+        ],
+      }),
+    );
+
+    const detected = detectCurrentConfig();
+    expect(detected.isInstalled).toBe(true);
   });
 
   test('detectCurrentConfig detects provider models in arrays', () => {

+ 34 - 24
src/cli/config-io.ts

@@ -10,6 +10,10 @@ import {
 } from 'node:fs';
 import { homedir } from 'node:os';
 import { dirname, join } from 'node:path';
+import {
+  INSTALLER_MANAGED_PLUGIN_OPTION,
+  type PluginEntry,
+} from '../plugin-entry';
 import { crossSpawn } from '../utils/compat';
 import {
   ensureConfigDir,
@@ -51,10 +55,6 @@ function getPlugins(config: OpenCodeConfig): unknown[] {
   return Array.isArray(config.plugin) ? config.plugin : [];
 }
 
-function getPluginEntries(config: OpenCodeConfig): string[] {
-  return getPlugins(config).filter(isString);
-}
-
 function getPluginSpec(entry: unknown): string | undefined {
   if (isString(entry)) return entry;
   if (!Array.isArray(entry)) return undefined;
@@ -136,7 +136,7 @@ function isMatchingPluginEntry(entry: unknown): boolean {
   return spec ? isPluginEntry(spec) : false;
 }
 
-function getPluginEntry(): string {
+function getPluginEntry(): PluginEntry {
   const cliEntryPath = process.argv[1];
 
   if (!cliEntryPath) {
@@ -146,10 +146,22 @@ function getPluginEntry(): string {
   try {
     const packageRoot = findPackageRoot(cliEntryPath);
 
-    if (!packageRoot || isPackageManagerInstall(packageRoot)) {
+    if (!packageRoot) {
       return PACKAGE_NAME;
     }
 
+    if (isPackageManagerInstall(packageRoot)) {
+      const version = getVersionFromPackageRoot(packageRoot);
+      const requestedTag = getRequestedPackageTag(packageRoot);
+      if (!version || !requestedTag || requestedTag === 'latest') {
+        return PACKAGE_NAME;
+      }
+      return [
+        `${PACKAGE_NAME}@${version}`,
+        { [INSTALLER_MANAGED_PLUGIN_OPTION]: true },
+      ];
+    }
+
     return packageRoot;
   } catch {
     return PACKAGE_NAME;
@@ -161,19 +173,22 @@ function getPluginEntry(): string {
  * Returns the version string (e.g. "1.2.3") if pinned, or undefined
  * if the plugin is unpinned (bare name or @latest).
  */
-function getPinnedVersionFromConfig(): string | undefined {
+function getConfiguredExactVersion(): string | undefined {
   try {
     const { config } = parseConfig(getExistingConfigPath());
     if (!config) return undefined;
+    let version: string | undefined;
     for (const entry of getPlugins(config)) {
       const spec = getPluginSpec(entry);
       if (!spec) continue;
-      if (spec === PACKAGE_NAME) return undefined;
-      if (spec.startsWith(`${PACKAGE_NAME}@`)) {
-        const version = spec.slice(PACKAGE_NAME.length + 1);
-        if (version && version !== 'latest') return version;
+      if (spec === PACKAGE_NAME) {
+        version = undefined;
+      } else if (spec.startsWith(`${PACKAGE_NAME}@`)) {
+        const candidate = spec.slice(PACKAGE_NAME.length + 1);
+        version = candidate && candidate !== 'latest' ? candidate : undefined;
       }
     }
+    return version;
   } catch {}
   return undefined;
 }
@@ -311,10 +326,10 @@ export async function warmOpenCodePluginCache(): Promise<ConfigMergeResult | nul
     return null;
   }
 
-  const pinnedVersion = getPinnedVersionFromConfig();
+  const configuredVersion = getConfiguredExactVersion();
   const runningVersion = getVersionFromPackageRoot(packageRoot);
   const requestedTag = getRequestedPackageTag(packageRoot);
-  const cacheVersion = pinnedVersion ?? requestedTag ?? runningVersion;
+  const cacheVersion = configuredVersion ?? requestedTag ?? runningVersion;
   const cacheDir = getOpenCodePluginCacheDir(cacheVersion);
 
   try {
@@ -654,17 +669,17 @@ export function detectCurrentConfig(): DetectedConfig {
     hasAntigravity: false,
     hasChutes: false,
     hasOpencodeZen: false,
-    hasTmux: false,
   };
 
   const { config } = parseConfig(getExistingConfigPath());
   if (!config) return result;
 
-  const plugins = getPluginEntries(config);
-  result.isInstalled = plugins.some((p) => isPluginEntry(p));
-  result.hasAntigravity = plugins.some((p) =>
-    p.startsWith('opencode-antigravity-auth'),
-  );
+  const plugins = getPlugins(config);
+  result.isInstalled = plugins.some((p) => isMatchingPluginEntry(p));
+  result.hasAntigravity = plugins.some((p) => {
+    const spec = getPluginSpec(p);
+    return spec?.startsWith('opencode-antigravity-auth') ?? false;
+  });
 
   // Check for providers
   const providers = config.provider as Record<string, unknown> | undefined;
@@ -703,11 +718,6 @@ export function detectCurrentConfig(): DetectedConfig {
         result.hasChutes = true;
       }
     }
-
-    if (configObj.tmux && typeof configObj.tmux === 'object') {
-      const tmuxConfig = configObj.tmux as { enabled?: boolean };
-      result.hasTmux = tmuxConfig.enabled === true;
-    }
   }
 
   return result;

+ 0 - 1
src/cli/install.test.ts

@@ -147,7 +147,6 @@ mock.module('./paths', () => {
 
 function baseConfig(): InstallConfig {
   return {
-    hasTmux: false,
     installCustomSkills: false,
     forceSkillSync: false,
     reset: false,

+ 0 - 1
src/cli/install.ts

@@ -565,7 +565,6 @@ async function runInstall(config: InstallConfig): Promise<number> {
 
 export async function install(args: InstallArgs): Promise<number> {
   const config: InstallConfig = {
-    hasTmux: false,
     installCustomSkills: args.skills === 'yes' || args.skills === 'force',
     forceSkillSync: args.skills === 'force',
     preset: args.preset,

+ 0 - 25
src/cli/providers.test.ts

@@ -17,7 +17,6 @@ describe('providers', () => {
 
   test('generateLiteConfig defaults to openai and includes generated presets', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
@@ -42,7 +41,6 @@ describe('providers', () => {
 
   test('generateLiteConfig uses correct OpenAI models', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
@@ -64,7 +62,6 @@ describe('providers', () => {
 
   test('generateLiteConfig can set opencode-go as active preset', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       preset: 'opencode-go',
       backgroundSubagents: 'no',
@@ -95,7 +92,6 @@ describe('providers', () => {
   test('generateLiteConfig rejects unsupported preset', () => {
     expect(() =>
       generateLiteConfig({
-        hasTmux: false,
         installCustomSkills: false,
         preset: 'not-real',
         backgroundSubagents: 'no',
@@ -107,7 +103,6 @@ describe('providers', () => {
   test('generateLiteConfig rejects non-generated model mappings as active presets', () => {
     expect(() =>
       generateLiteConfig({
-        hasTmux: false,
         installCustomSkills: false,
         preset: 'kimi',
         backgroundSubagents: 'no',
@@ -119,7 +114,6 @@ describe('providers', () => {
   test('generateLiteConfig rejects inherited property names as presets', () => {
     expect(() =>
       generateLiteConfig({
-        hasTmux: false,
         installCustomSkills: false,
         preset: 'toString',
         backgroundSubagents: 'no',
@@ -128,22 +122,8 @@ describe('providers', () => {
     ).toThrow('Unsupported preset "toString"');
   });
 
-  test('generateLiteConfig enables tmux when requested', () => {
-    const config = generateLiteConfig({
-      hasTmux: true,
-      installCustomSkills: false,
-      backgroundSubagents: 'no',
-      reset: false,
-    });
-
-    expect(config.tmux).toBeDefined();
-    expect((config.tmux as any).enabled).toBe(true);
-    expect((config.tmux as any).layout).toBe('main-vertical');
-  });
-
   test('generateLiteConfig companion: yes', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
@@ -158,7 +138,6 @@ describe('providers', () => {
 
   test('generateLiteConfig companion: no or omitted', () => {
     const configYes = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
@@ -167,7 +146,6 @@ describe('providers', () => {
     expect(configYes.companion).toBeUndefined();
 
     const configOmitted = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
@@ -177,7 +155,6 @@ describe('providers', () => {
 
   test('generateLiteConfig includes default skills', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
@@ -205,7 +182,6 @@ describe('providers', () => {
 
   test('generateLiteConfig includes mcps field', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
@@ -220,7 +196,6 @@ describe('providers', () => {
 
   test('generateLiteConfig openai includes correct mcps', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,

+ 0 - 8
src/cli/providers.ts

@@ -136,14 +136,6 @@ export function generateLiteConfig(
     presets[presetName] = buildPreset(presetName);
   }
 
-  if (installConfig.hasTmux) {
-    config.tmux = {
-      enabled: true,
-      layout: 'main-vertical',
-      main_pane_size: 60,
-    };
-  }
-
   if (installConfig.companion === 'yes') {
     config.companion = {
       enabled: true,

+ 0 - 2
src/cli/types.ts

@@ -22,7 +22,6 @@ export interface OpenCodeConfig {
 }
 
 export interface InstallConfig {
-  hasTmux: boolean;
   installCustomSkills: boolean;
   forceSkillSync: boolean;
   preset?: string;
@@ -50,5 +49,4 @@ export interface DetectedConfig {
   hasAntigravity: boolean;
   hasChutes?: boolean;
   hasOpencodeZen: boolean;
-  hasTmux: boolean;
 }

+ 0 - 41
src/config/council-schema.test.ts

@@ -60,8 +60,6 @@ describe('CouncillorConfigSchema', () => {
       // Deprecated fields are stripped but reported via _deprecated
       expect(result.data._deprecated).toEqual(['master']);
       expect(Object.keys(result.data.presets.default)).toEqual(['alpha']);
-      // Legacy master.model is extracted for backward-compat fallback
-      expect(result.data._legacyMasterModel).toBe('anthropic/claude-opus-4-6');
     }
   });
 
@@ -79,7 +77,6 @@ describe('CouncillorConfigSchema', () => {
 
     if (result.success) {
       expect(result.data._deprecated).toBeUndefined();
-      expect(result.data._legacyMasterModel).toBeUndefined();
     }
   });
 });
@@ -163,44 +160,6 @@ test('deprecated master with non-standard model ID still parses', () => {
 
   if (result.success) {
     expect(result.data._deprecated).toEqual(['master']);
-    // Even non-standard model IDs are extracted as-is for backward compat
-    expect(result.data._legacyMasterModel).toBe('claude-opus-4-6');
-  }
-});
-
-test('legacyMasterModel undefined when master.model is not a string', () => {
-  const config = {
-    master: { model: 42 }, // not a string
-    presets: {
-      default: {
-        alpha: { model: 'openai/gpt-5.6-luna' },
-      },
-    },
-  };
-
-  const result = CouncilConfigSchema.safeParse(config);
-  expect(result.success).toBe(true);
-
-  if (result.success) {
-    expect(result.data._legacyMasterModel).toBeUndefined();
-  }
-});
-
-test('legacyMasterModel undefined when master is not an object', () => {
-  const config = {
-    master: 'oops', // not an object
-    presets: {
-      default: {
-        alpha: { model: 'openai/gpt-5.6-luna' },
-      },
-    },
-  };
-
-  const result = CouncilConfigSchema.safeParse(config);
-  expect(result.success).toBe(true);
-
-  if (result.success) {
-    expect(result.data._legacyMasterModel).toBeUndefined();
   }
 });
 

+ 2 - 21
src/config/council-schema.ts

@@ -153,36 +153,17 @@ export const CouncilConfigSchema = z
   .object({
     presets: z.record(z.string(), CouncilPresetSchema),
     default_preset: z.string().default('default'),
-    // Deprecated fields - accepted for backward compatibility but ignored.
-    // The council agent now synthesizes directly; no separate master session.
-    // Uses permissive schemas since the values are discarded - strict
-    // validation would break old configs with non-standard model IDs.
-    master: z
-      .unknown()
-      .optional()
-      .describe('DEPRECATED - ignored. Council agent synthesizes directly.'),
   })
+  .passthrough()
   .transform((data) => {
     // Detect deprecated fields and attach warning for consumers
     const deprecated: string[] = [];
-    if (data.master !== undefined) deprecated.push('master');
-
-    // Backward compat: extract master.model so the council agent can use it
-    // as a fallback when no explicit council entry exists in the active preset.
-    // See https://github.com/alvinunreal/oh-my-opencode-slim/issues/369
-    const legacyMasterModel: string | undefined =
-      typeof data.master === 'object' &&
-      data.master !== null &&
-      'model' in data.master &&
-      typeof (data.master as { model: unknown }).model === 'string'
-        ? (data.master as { model: string }).model
-        : undefined;
+    if ('master' in data) deprecated.push('master');
 
     return {
       presets: data.presets,
       default_preset: data.default_preset,
       _deprecated: deprecated.length > 0 ? deprecated : undefined,
-      _legacyMasterModel: legacyMasterModel,
     };
   });
 

+ 88 - 69
src/config/loader.test.ts

@@ -438,6 +438,94 @@ describe('onWarning callback', () => {
     expect(config.agents?.oracle?.model).toBe('valid/model');
   });
 
+  test('deprecated tmux key calls onWarning with invalid-schema and still loads', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        tmux: { enabled: true, layout: 'main-vertical' },
+        agents: { oracle: { model: 'valid/model' } },
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('invalid-schema');
+    expect(warnings[0]?.message).toContain('Deprecated tmux config key');
+    expect(config.agents?.oracle?.model).toBe('valid/model');
+  });
+
+  test('deprecated council.master key calls onWarning with invalid-schema and still loads', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        council: {
+          master: { model: 'openai/gpt-5.6' },
+          presets: {
+            default: {
+              alpha: { model: 'openai/gpt-5.6-luna' },
+            },
+          },
+        },
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('invalid-schema');
+    expect(warnings[0]?.message).toContain(
+      'Deprecated council.master config key',
+    );
+    expect(config.council?.presets?.default?.alpha?.model).toBe(
+      'openai/gpt-5.6-luna',
+    );
+  });
+
+  test('both deprecated keys fire two warnings', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        tmux: { enabled: true },
+        council: {
+          master: { model: 'openai/gpt-5.6' },
+          presets: {
+            default: {
+              alpha: { model: 'openai/gpt-5.6-luna' },
+            },
+          },
+        },
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(2);
+    const messages = warnings.map((w) => w.message);
+    expect(messages.some((m) => m.includes('Deprecated tmux'))).toBe(true);
+    expect(messages.some((m) => m.includes('Deprecated council.master'))).toBe(
+      true,
+    );
+  });
+
   test('no options object does not break loadPluginConfig', () => {
     const projectDir = path.join(tempDir, 'project');
     const projectConfigDir = path.join(projectDir, '.opencode');
@@ -513,69 +601,6 @@ describe('deepMerge behavior', () => {
     expect(config.agents?.designer?.model).toBe('project/designer-model');
   });
 
-  test('merges nested tmux configs', () => {
-    const userOpencodeDir = path.join(userConfigDir, 'opencode');
-    fs.mkdirSync(userOpencodeDir, { recursive: true });
-    fs.writeFileSync(
-      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
-      JSON.stringify({
-        tmux: {
-          enabled: true,
-          layout: 'main-vertical',
-          main_pane_size: 60,
-        },
-      }),
-    );
-
-    const projectDir = path.join(tempDir, 'project');
-    const projectConfigDir = path.join(projectDir, '.opencode');
-    fs.mkdirSync(projectConfigDir, { recursive: true });
-    fs.writeFileSync(
-      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
-      JSON.stringify({
-        tmux: {
-          enabled: false, // Override enabled
-          layout: 'tiled', // Override layout
-        },
-      }),
-    );
-
-    const config = loadPluginConfig(projectDir);
-
-    expect(config.tmux?.enabled).toBe(false); // From project (override)
-    expect(config.tmux?.layout).toBe('tiled'); // From project
-    expect(config.tmux?.main_pane_size).toBe(60); // From user (preserved)
-  });
-
-  test("preserves user tmux.enabled when project doesn't specify", () => {
-    const userOpencodeDir = path.join(userConfigDir, 'opencode');
-    fs.mkdirSync(userOpencodeDir, { recursive: true });
-    fs.writeFileSync(
-      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
-      JSON.stringify({
-        tmux: {
-          enabled: true,
-          layout: 'main-vertical',
-        },
-      }),
-    );
-
-    const projectDir = path.join(tempDir, 'project');
-    const projectConfigDir = path.join(projectDir, '.opencode');
-    fs.mkdirSync(projectConfigDir, { recursive: true });
-    fs.writeFileSync(
-      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
-      JSON.stringify({
-        agents: { oracle: { model: 'test' } }, // No tmux override
-      }),
-    );
-
-    const config = loadPluginConfig(projectDir);
-
-    expect(config.tmux?.enabled).toBe(true); // Preserved from user
-    expect(config.tmux?.layout).toBe('main-vertical'); // Preserved from user
-  });
-
   test('project config overrides top-level arrays', () => {
     const userOpencodeDir = path.join(userConfigDir, 'opencode');
     fs.mkdirSync(userOpencodeDir, { recursive: true });
@@ -1240,10 +1265,6 @@ describe('JSONC config support', () => {
             "explorer": { "model": "dev-explorer", },
           },
         },
-        "tmux": {
-          "enabled": true, // Enable tmux
-          "layout": "main-vertical",
-        },
       }`,
     );
 
@@ -1251,8 +1272,6 @@ describe('JSONC config support', () => {
     expect(config.preset).toBe('dev');
     expect(config.agents?.oracle?.model).toBe('dev-oracle');
     expect(config.agents?.explorer?.model).toBe('dev-explorer');
-    expect(config.tmux?.enabled).toBe(true);
-    expect(config.tmux?.layout).toBe('main-vertical');
   });
 });
 

+ 43 - 35
src/config/loader.ts

@@ -83,6 +83,48 @@ function loadConfigFromPath(
       }
       return null;
     }
+    // Warn about deprecated tmux key
+    if (
+      typeof rawConfig === 'object' &&
+      rawConfig !== null &&
+      'tmux' in (rawConfig as Record<string, unknown>)
+    ) {
+      const tmuxMsg =
+        'Deprecated tmux config key found and ignored. Use multiplexer config instead.';
+      options?.onWarning?.({
+        path: configPath,
+        kind: 'invalid-schema' as ConfigLoadWarningKind,
+        message: tmuxMsg,
+      });
+      if (!options?.silent) {
+        console.warn(`[oh-my-opencode-slim] ${tmuxMsg}`);
+      }
+    }
+
+    // Warn about deprecated council.master key
+    if (
+      typeof rawConfig === 'object' &&
+      rawConfig !== null &&
+      typeof (rawConfig as Record<string, unknown>).council === 'object' &&
+      (rawConfig as Record<string, unknown>).council !== null &&
+      'master' in
+        ((rawConfig as Record<string, unknown>).council as Record<
+          string,
+          unknown
+        >)
+    ) {
+      const masterMsg =
+        'Deprecated council.master config key found and ignored. Configure council agents via presets instead.';
+      options?.onWarning?.({
+        path: configPath,
+        kind: 'invalid-schema' as ConfigLoadWarningKind,
+        message: masterMsg,
+      });
+      if (!options?.silent) {
+        console.warn(`[oh-my-opencode-slim] ${masterMsg}`);
+      }
+    }
+
     const result = PluginConfigSchema.safeParse(rawConfig);
 
     if (!result.success) {
@@ -223,7 +265,6 @@ export function mergePluginConfigs(
     ...override,
     agents: deepMerge(base.agents, override.agents),
     presets: deepMerge(base.presets, override.presets),
-    tmux: deepMerge(base.tmux, override.tmux),
     multiplexer: deepMerge(base.multiplexer, override.multiplexer),
     interview: deepMerge(base.interview, override.interview),
     backgroundJobs: deepMerge(base.backgroundJobs, override.backgroundJobs),
@@ -285,7 +326,7 @@ export function deepMerge<T extends Record<string, unknown>>(
  * 2. Project config: <directory>/.opencode/oh-my-opencode-slim.jsonc or .json
  *
  * JSONC format is preferred over JSON (allows comments and trailing commas).
- * Project config takes precedence over user config. Nested objects (agents, tmux) are
+ * Project config takes precedence over user config. Nested objects (agents, multiplexer) are
  * deep-merged, while top-level arrays are replaced entirely by project config.
  *
  * @param directory - Project directory to search for .opencode config
@@ -310,9 +351,6 @@ export function loadPluginConfig(
     config = mergePluginConfigs(config, projectConfig);
   }
 
-  // Migrate legacy tmux config to multiplexer config for backward compatibility
-  config = migrateTmuxToMultiplexer(config);
-
   // Override preset from environment variable if set
   const envPreset = process.env.OH_MY_OPENCODE_SLIM_PRESET;
   if (envPreset) {
@@ -460,33 +498,3 @@ export function loadAgentPrompt(
 
   return result;
 }
-
-/**
- * Migrate legacy tmux config to multiplexer config for backward compatibility.
- * If tmux.enabled is true and no multiplexer config is set, creates a multiplexer
- * config from the tmux settings.
- *
- * @param config - Plugin config to migrate
- * @returns Config with multiplexer settings applied
- */
-function migrateTmuxToMultiplexer(config: PluginConfig): PluginConfig {
-  // If multiplexer is already configured, use it as-is
-  if (config.multiplexer?.type && config.multiplexer.type !== 'none') {
-    return config;
-  }
-
-  // If tmux is enabled, migrate to multiplexer
-  if (config.tmux?.enabled) {
-    return {
-      ...config,
-      multiplexer: {
-        type: 'tmux',
-        layout: config.tmux.layout ?? 'main-vertical',
-        main_pane_size: config.tmux.main_pane_size ?? 60,
-        zellij_pane_mode: 'agent-tab',
-      },
-    };
-  }
-
-  return config;
-}

+ 1 - 18
src/config/schema.ts

@@ -152,10 +152,6 @@ export type MultiplexerLayout = z.infer<typeof MultiplexerLayoutSchema>;
 export const ZellijPaneModeSchema = z.enum(['agent-tab', 'current-tab']);
 export type ZellijPaneMode = z.infer<typeof ZellijPaneModeSchema>;
 
-// Legacy Tmux layout options (for backward compatibility)
-export const TmuxLayoutSchema = MultiplexerLayoutSchema;
-export type TmuxLayout = MultiplexerLayout;
-
 // Multiplexer integration configuration (new unified config)
 export const MultiplexerConfigSchema = z.object({
   type: MultiplexerTypeSchema.default('none'),
@@ -166,16 +162,6 @@ export const MultiplexerConfigSchema = z.object({
 
 export type MultiplexerConfig = z.infer<typeof MultiplexerConfigSchema>;
 
-// Legacy Tmux integration configuration (for backward compatibility)
-// When tmux.enabled is true, it's equivalent to multiplexer.type = 'tmux'
-export const TmuxConfigSchema = z.object({
-  enabled: z.boolean().default(false),
-  layout: TmuxLayoutSchema.default('main-vertical'),
-  main_pane_size: z.number().min(20).max(80).default(60), // percentage for main pane
-});
-
-export type TmuxConfig = z.infer<typeof TmuxConfigSchema>;
-
 export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>;
 
 /** Normalized model entry with optional per-model variant. */
@@ -406,11 +392,8 @@ export const PluginConfigSchema = z
       .describe(
         'Skill names to disable completely. Disabled skills are not granted to agents, even when referenced by presets or agent overrides.',
       ),
-    // Multiplexer config (new unified config - preferred)
+    // Multiplexer config
     multiplexer: MultiplexerConfigSchema.optional(),
-    // Legacy tmux config (for backward compatibility)
-    // When tmux.enabled is true, it's equivalent to multiplexer.type = 'tmux'
-    tmux: TmuxConfigSchema.optional(),
     websearch: WebsearchConfigSchema.optional(),
     interview: InterviewConfigSchema.optional(),
     backgroundJobs: BackgroundJobsConfigSchema.optional(),

+ 139 - 10
src/hooks/auto-update-checker/cache.test.ts

@@ -1,5 +1,7 @@
 import { describe, expect, mock, spyOn, test } from 'bun:test';
 import * as fs from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
 
 // Mock logger to avoid noise
 mock.module('../../utils/logger', () => ({
@@ -102,6 +104,10 @@ describe('auto-update-checker/cache', () => {
         },
       );
       const rmSyncSpy = spyOn(fs, 'rmSync').mockReturnValue(undefined);
+      const mkdirSyncSpy = spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
+      const mkdtempSyncSpy = spyOn(fs, 'mkdtempSync').mockReturnValue(
+        '/home/user/.cache/opencode/packages/.oh-my-opencode-slim@0.9.11.staging-test',
+      );
       const { preparePackageUpdate } = await import(
         `./cache?test=${importCounter++}`
       );
@@ -112,15 +118,15 @@ describe('auto-update-checker/cache', () => {
         '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim/package.json',
       );
 
-      expect(result).toBe(
-        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest',
-      );
-      expect(rmSyncSpy).toHaveBeenCalledWith(
-        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim',
-        { recursive: true, force: true },
-      );
+      expect(result).toEqual({
+        stagingDir:
+          '/home/user/.cache/opencode/packages/.oh-my-opencode-slim@0.9.11.staging-test',
+        targetDir:
+          '/home/user/.cache/opencode/packages/oh-my-opencode-slim@0.9.11',
+      });
       expect(writtenData.length).toBeGreaterThan(0);
       expect(JSON.parse(writtenData[0])).toEqual({
+        private: true,
         dependencies: {
           'oh-my-opencode-slim': '0.9.11',
         },
@@ -130,6 +136,8 @@ describe('auto-update-checker/cache', () => {
       readSpy.mockRestore();
       writeSpy.mockRestore();
       rmSyncSpy.mockRestore();
+      mkdirSyncSpy.mockRestore();
+      mkdtempSyncSpy.mockRestore();
     });
 
     test('keeps working when dependency is already on target version', async () => {
@@ -153,9 +161,8 @@ describe('auto-update-checker/cache', () => {
 
       const result = preparePackageUpdate('1.0.1', 'oh-my-opencode-slim', null);
 
-      expect(result?.endsWith('/.cache/opencode')).toBe(true);
-      expect(writeSpy).not.toHaveBeenCalled();
-      expect(rmSyncSpy).toHaveBeenCalled();
+      expect(result).not.toBeNull();
+      expect(writeSpy).toHaveBeenCalled();
 
       existsSpy.mockRestore();
       readSpy.mockRestore();
@@ -163,4 +170,126 @@ describe('auto-update-checker/cache', () => {
       rmSyncSpy.mockRestore();
     });
   });
+
+  describe('publishPackageUpdate transaction', () => {
+    function createPackage(dir: string, version: string): void {
+      const packageDir = join(dir, 'node_modules', 'oh-my-opencode-slim');
+      fs.mkdirSync(packageDir, { recursive: true });
+      fs.writeFileSync(
+        join(packageDir, 'package.json'),
+        JSON.stringify({ name: 'oh-my-opencode-slim', version }),
+      );
+    }
+
+    function createPrepared(root: string, version: string) {
+      const parent = join(root, 'packages');
+      fs.mkdirSync(parent, { recursive: true });
+      const stagingDir = fs.mkdtempSync(join(parent, '.staging-'));
+      return {
+        stagingDir,
+        targetDir: join(parent, `oh-my-opencode-slim@${version}`),
+      };
+    }
+
+    test('publishes a verified staged package atomically', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.stagingDir, '1.2.4');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBe(prepared.targetDir);
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      expect(fs.existsSync(join(prepared.targetDir, 'node_modules'))).toBe(
+        true,
+      );
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+
+    test('cleans staging when a valid concurrent target already exists', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.stagingDir, '1.2.4');
+      createPackage(prepared.targetDir, '1.2.4');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBe(prepared.targetDir);
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      expect(
+        fs
+          .readdirSync(join(root, 'packages'))
+          .some((name) => name.includes('invalid-')),
+      ).toBe(false);
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+
+    test('replaces an invalid target and removes its quarantine', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.stagingDir, '1.2.4');
+      fs.mkdirSync(prepared.targetDir, { recursive: true });
+      fs.writeFileSync(join(prepared.targetDir, 'package.json'), '{}');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBe(prepared.targetDir);
+      expect(
+        fs
+          .readdirSync(join(root, 'packages'))
+          .some((name) => name.includes('invalid-')),
+      ).toBe(false);
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+
+    test('removes an unverifiable freshly published target and staging', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.stagingDir, '1.2.3');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBeNull();
+      expect(fs.existsSync(prepared.targetDir)).toBe(false);
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+
+    test('restores the prior usable target when replacement verification fails', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.targetDir, '1.2.3');
+      createPackage(prepared.stagingDir, '1.2.3');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBeNull();
+      expect(
+        JSON.parse(
+          fs.readFileSync(
+            join(
+              prepared.targetDir,
+              'node_modules',
+              'oh-my-opencode-slim',
+              'package.json',
+            ),
+            'utf-8',
+          ),
+        ),
+      ).toEqual({ name: 'oh-my-opencode-slim', version: '1.2.3' });
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      expect(
+        fs
+          .readdirSync(join(root, 'packages'))
+          .some((name) => name.includes('invalid-')),
+      ).toBe(false);
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+  });
 });

+ 103 - 119
src/hooks/auto-update-checker/cache.ts

@@ -1,114 +1,30 @@
 import * as fs from 'node:fs';
 import * as path from 'node:path';
-import { stripJsonComments } from '../../cli/config-manager';
 import { log } from '../../utils/logger';
 import { getCurrentRuntimePackageJsonPath } from './checker';
 import { CACHE_DIR, PACKAGE_NAME } from './constants';
 
-interface BunLockfile {
-  workspaces?: {
-    ''?: {
-      dependencies?: Record<string, string>;
-    };
-  };
-  packages?: Record<string, unknown>;
-}
-
 interface AutoUpdateInstallContext {
   installDir: string;
   packageJsonPath: string;
 }
 
-/**
- * Removes a package from the bun.lock file if it's in JSON format.
- * Note: Newer Bun versions (1.1+) use a custom text format for bun.lock.
- * This function handles JSON-based lockfiles gracefully.
- */
-function removeFromBunLock(installDir: string, packageName: string): boolean {
-  const lockPath = path.join(installDir, 'bun.lock');
-  if (!fs.existsSync(lockPath)) return false;
-
-  try {
-    const content = fs.readFileSync(lockPath, 'utf-8');
-    let lock: BunLockfile;
-
-    try {
-      lock = JSON.parse(stripJsonComments(content)) as BunLockfile;
-    } catch {
-      // If it's not valid JSON(C), it might be the new Bun text format or binary format.
-      // For now, we only support JSON-based lockfile manipulation.
-      return false;
-    }
-
-    let modified = false;
-
-    if (lock.workspaces?.['']?.dependencies?.[packageName]) {
-      delete lock.workspaces[''].dependencies[packageName];
-      modified = true;
-    }
-
-    if (lock.packages?.[packageName]) {
-      delete lock.packages[packageName];
-      modified = true;
-    }
-
-    if (modified) {
-      fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2));
-      log(`[auto-update-checker] Removed from bun.lock: ${packageName}`);
-    }
-
-    return modified;
-  } catch (err) {
-    log(`[auto-update-checker] Failed to process bun.lock:`, err);
-    return false;
-  }
+interface PreparedPackageUpdate {
+  stagingDir: string;
+  targetDir: string;
 }
 
-function ensureDependencyVersion(
-  packageJsonPath: string,
-  packageName: string,
+function getTargetInstallContext(
+  installContext: AutoUpdateInstallContext,
   version: string,
-): boolean {
-  if (!fs.existsSync(packageJsonPath)) return false;
-
-  try {
-    const content = fs.readFileSync(packageJsonPath, 'utf-8');
-    const pkgJson = JSON.parse(stripJsonComments(content)) as {
-      dependencies?: Record<string, string>;
-      [key: string]: unknown;
-    };
-
-    const dependencies = { ...(pkgJson.dependencies ?? {}) };
-    if (dependencies[packageName] === version) {
-      return true;
-    }
-
-    dependencies[packageName] = version;
-    pkgJson.dependencies = dependencies;
-    fs.writeFileSync(packageJsonPath, JSON.stringify(pkgJson, null, 2));
-    log(
-      `[auto-update-checker] Updated dependency in package.json: ${packageName} → ${version}`,
-    );
-    return true;
-  } catch (err) {
-    log(
-      `[auto-update-checker] Failed to update package.json dependency for auto-update:`,
-      err,
-    );
-    return false;
-  }
-}
-
-function removeInstalledPackage(
-  installDir: string,
-  packageName: string,
-): boolean {
-  const pkgDir = path.join(installDir, 'node_modules', packageName);
-  if (!fs.existsSync(pkgDir)) return false;
-
-  fs.rmSync(pkgDir, { recursive: true, force: true });
-  log(`[auto-update-checker] Package removed: ${pkgDir}`);
-  return true;
+): AutoUpdateInstallContext {
+  const installParent = path.dirname(installContext.installDir);
+  const parentDir =
+    path.basename(installParent) === 'packages'
+      ? installParent
+      : path.join(CACHE_DIR, 'packages');
+  const installDir = path.join(parentDir, `${PACKAGE_NAME}@${version}`);
+  return { installDir, packageJsonPath: path.join(installDir, 'package.json') };
 }
 
 export function resolveInstallContext(
@@ -148,7 +64,9 @@ export function preparePackageUpdate(
   version: string,
   packageName: string = PACKAGE_NAME,
   runtimePackageJsonPath: string | null = getCurrentRuntimePackageJsonPath(),
-): string | null {
+  cacheIdentity: string = version,
+): PreparedPackageUpdate | null {
+  let stagingDir: string | null = null;
   try {
     const installContext = resolveInstallContext(runtimePackageJsonPath);
     if (!installContext) {
@@ -156,33 +74,99 @@ export function preparePackageUpdate(
       return null;
     }
 
-    const dependencyReady = ensureDependencyVersion(
-      installContext.packageJsonPath,
-      packageName,
-      version,
+    const targetContext = getTargetInstallContext(
+      installContext,
+      cacheIdentity,
     );
-    if (!dependencyReady) {
-      return null;
-    }
-
-    const packageRemoved = removeInstalledPackage(
-      installContext.installDir,
-      packageName,
+    const targetParent = path.dirname(targetContext.installDir);
+    fs.mkdirSync(targetParent, { recursive: true });
+    stagingDir = fs.mkdtempSync(
+      path.join(targetParent, `.${PACKAGE_NAME}@${cacheIdentity}.staging-`),
     );
-    const lockRemoved = removeFromBunLock(
-      installContext.installDir,
-      packageName,
+    fs.writeFileSync(
+      path.join(stagingDir, 'package.json'),
+      JSON.stringify({
+        private: true,
+        dependencies: { [packageName]: version },
+      }),
     );
 
-    if (!packageRemoved && !lockRemoved) {
-      log(
-        `[auto-update-checker] No cached package artifacts removed for ${packageName}; continuing with updated dependency spec`,
-      );
-    }
-
-    return installContext.installDir;
+    return { stagingDir, targetDir: targetContext.installDir };
   } catch (err) {
+    if (stagingDir) fs.rmSync(stagingDir, { recursive: true, force: true });
     log('[auto-update-checker] Failed to prepare package update:', err);
     return null;
   }
 }
+
+export function discardPreparedPackageUpdate(
+  prepared: PreparedPackageUpdate,
+): void {
+  fs.rmSync(prepared.stagingDir, { recursive: true, force: true });
+}
+
+export function publishPackageUpdate(
+  prepared: PreparedPackageUpdate,
+  version: string,
+): string | null {
+  try {
+    if (fs.existsSync(prepared.targetDir)) {
+      if (verifyInstalledPackage(prepared.targetDir, version)) {
+        discardPreparedPackageUpdate(prepared);
+        return prepared.targetDir;
+      }
+      const quarantineDir = `${prepared.targetDir}.invalid-${process.pid}-${Date.now()}`;
+      fs.renameSync(prepared.targetDir, quarantineDir);
+      try {
+        fs.renameSync(prepared.stagingDir, prepared.targetDir);
+        if (verifyInstalledPackage(prepared.targetDir, version)) {
+          fs.rmSync(quarantineDir, { recursive: true, force: true });
+          return prepared.targetDir;
+        }
+        fs.rmSync(prepared.targetDir, { recursive: true, force: true });
+        fs.renameSync(quarantineDir, prepared.targetDir);
+        return null;
+      } catch {
+        if (fs.existsSync(prepared.targetDir)) {
+          if (verifyInstalledPackage(prepared.targetDir, version)) {
+            discardPreparedPackageUpdate(prepared);
+            fs.rmSync(quarantineDir, { recursive: true, force: true });
+            return prepared.targetDir;
+          }
+        }
+      }
+      if (!fs.existsSync(prepared.targetDir)) {
+        fs.renameSync(quarantineDir, prepared.targetDir);
+      }
+      discardPreparedPackageUpdate(prepared);
+      return null;
+    }
+    fs.renameSync(prepared.stagingDir, prepared.targetDir);
+    if (verifyInstalledPackage(prepared.targetDir, version)) {
+      return prepared.targetDir;
+    }
+    fs.rmSync(prepared.targetDir, { recursive: true, force: true });
+    return null;
+  } catch {
+    discardPreparedPackageUpdate(prepared);
+    return null;
+  }
+}
+
+export function verifyInstalledPackage(
+  installDir: string,
+  version: string,
+  packageName: string = PACKAGE_NAME,
+): boolean {
+  try {
+    const packageJson = JSON.parse(
+      fs.readFileSync(
+        path.join(installDir, 'node_modules', packageName, 'package.json'),
+        'utf-8',
+      ),
+    ) as { name?: string; version?: string };
+    return packageJson.name === packageName && packageJson.version === version;
+  } catch {
+    return false;
+  }
+}

+ 143 - 0
src/hooks/auto-update-checker/checker.test.ts

@@ -12,6 +12,8 @@ mock.module('../../cli/config-manager', () => ({
     '/mock/config/opencode.json',
     '/mock/config/opencode.jsonc',
   ],
+  getTuiConfig: () => '/mock/config/tui.json',
+  getTuiConfigJsonc: () => '/mock/config/tui.jsonc',
 }));
 
 // Cache buster for dynamic imports
@@ -155,6 +157,147 @@ describe('auto-update-checker/checker', () => {
       existsSpy.mockRestore();
       readSpy.mockRestore();
     });
+
+    test('treats only installer-managed exact tuples as updateable', async () => {
+      const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) =>
+        String(p).includes('opencode.json'),
+      );
+      const readSpy = spyOn(fs, 'readFileSync').mockReturnValue(
+        JSON.stringify({
+          plugin: [
+            'oh-my-opencode-slim@1.2.3',
+            [
+              'oh-my-opencode-slim@1.2.3',
+              { __ohMyOpencodeSlimManagedByInstaller: true },
+            ],
+          ],
+        }),
+      );
+      const { findPluginEntry } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const entry = findPluginEntry('/test');
+      expect(entry?.isPinned).toBe(false);
+      expect(entry?.isInstallerManaged).toBe(true);
+
+      const managedReadSpy = spyOn(fs, 'readFileSync').mockReturnValue(
+        JSON.stringify({
+          plugin: [
+            [
+              'oh-my-opencode-slim@1.2.3',
+              { __ohMyOpencodeSlimManagedByInstaller: true },
+            ],
+          ],
+        }),
+      );
+      const managedEntry = findPluginEntry('/test');
+      expect(managedEntry?.isPinned).toBe(false);
+      expect(managedEntry?.isInstallerManaged).toBe(true);
+
+      existsSpy.mockRestore();
+      readSpy.mockRestore();
+      managedReadSpy.mockRestore();
+    });
+  });
+
+  describe('updateInstallerManagedVersions', () => {
+    test('structurally rewrites managed tuples in OpenCode and TUI configs only', async () => {
+      const files = new Map<string, string>([
+        [
+          '/mock/config/opencode.json',
+          `{
+  // preserve this comment
+  "note": "{ [ ] }",
+  "other": { "plugin": [["oh-my-opencode-slim@0.1.0", { "__ohMyOpencodeSlimManagedByInstaller": true }]] },
+  "plugin": [["oh-my-opencode-slim@0.2.0", { "__ohMyOpencodeSlimManagedByInstaller": true }]],
+  "plugin": [
+    [ /* tuple comment */ "oh-my-opencode-slim@1.2.3", { "__ohMyOpencodeSlimManagedByInstaller": true, "keep": "[{}]" } ],
+    ["oh-my-opencode-slim@1.2.3", { "__ohMyOpencodeSlimManagedByInstaller": false, "__ohMyOpencodeSlimManagedByInstaller": true }],
+    ["oh-my-opencode-slim@1.2.3", { "__ohMyOpencodeSlimManagedByInstaller": "true" }],
+    ["oh-my-opencode-slim\\u00401.2.3", { "__ohMyOpencodeSlimManagedByInstall\\u0065r": true }],
+    "oh-my-opencode-slim@1.2.3",
+    ["oh-my-opencode-slim@1.2.3", { "nested": { "__ohMyOpencodeSlimManagedByInstaller": true } }]
+  ]
+}`,
+        ],
+        [
+          '/mock/config/tui.json',
+          JSON.stringify({
+            plugin: [
+              [
+                'oh-my-opencode-slim@1.2.3',
+                { __ohMyOpencodeSlimManagedByInstaller: true },
+              ],
+            ],
+          }),
+        ],
+      ]);
+      const existsSpy = spyOn(fs, 'existsSync').mockImplementation((path) =>
+        files.has(String(path)),
+      );
+      const readSpy = spyOn(fs, 'readFileSync').mockImplementation(
+        (path) => files.get(String(path)) ?? '',
+      );
+      const writeSpy = spyOn(fs, 'writeFileSync').mockImplementation(
+        (path, data) => files.set(String(path), String(data)),
+      );
+      const renameSpy = spyOn(fs, 'renameSync').mockImplementation(
+        (from, to) => {
+          files.set(String(to), files.get(String(from)) ?? '');
+        },
+      );
+      const { updateInstallerManagedVersions } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const previousTuiConfig = process.env.OPENCODE_TUI_CONFIG;
+      process.env.OPENCODE_TUI_CONFIG = '/mock/config/tui.json';
+      expect(updateInstallerManagedVersions('/project', '1.2.4')).toBe(true);
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        'oh-my-opencode-slim@1.2.4',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"oh-my-opencode-slim@1.2.4", { "__ohMyOpencodeSlimManagedByInstall\\u0065r": true }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"oh-my-opencode-slim@0.1.0", { "__ohMyOpencodeSlimManagedByInstaller": true }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"oh-my-opencode-slim@0.2.0", { "__ohMyOpencodeSlimManagedByInstaller": true }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"oh-my-opencode-slim@1.2.3", { "__ohMyOpencodeSlimManagedByInstaller": "true" }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"keep": "[{}]"',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"note": "{ [ ] }"',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        'oh-my-opencode-slim@1.2.3',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"nested": { "__ohMyOpencodeSlimManagedByInstaller": true }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '// preserve this comment',
+      );
+      expect(files.get('/mock/config/tui.json')).toContain(
+        'oh-my-opencode-slim@1.2.4',
+      );
+      if (previousTuiConfig === undefined) {
+        delete process.env.OPENCODE_TUI_CONFIG;
+      } else {
+        process.env.OPENCODE_TUI_CONFIG = previousTuiConfig;
+      }
+
+      existsSpy.mockRestore();
+      readSpy.mockRestore();
+      writeSpy.mockRestore();
+      renameSpy.mockRestore();
+    });
   });
 
   describe('getLatestCompatibleVersion', () => {

+ 254 - 42
src/hooks/auto-update-checker/checker.ts

@@ -1,7 +1,12 @@
 import * as fs from 'node:fs';
 import * as path from 'node:path';
 import { fileURLToPath } from 'node:url';
-import { stripJsonComments } from '../../cli/config-manager';
+import {
+  getOpenCodeConfigPaths,
+  stripJsonComments,
+} from '../../cli/config-manager';
+import { getTuiConfig, getTuiConfigJsonc } from '../../cli/paths';
+import { INSTALLER_MANAGED_PLUGIN_OPTION } from '../../plugin-entry';
 import { log } from '../../utils/logger';
 import {
   INSTALLED_PACKAGE_JSON,
@@ -32,8 +37,182 @@ function isString(value: unknown): value is string {
   return typeof value === 'string';
 }
 
-function getPluginEntries(config: OpencodeConfig): string[] {
-  return Array.isArray(config.plugin) ? config.plugin.filter(isString) : [];
+function getPluginEntries(config: OpencodeConfig): unknown[] {
+  return Array.isArray(config.plugin) ? config.plugin : [];
+}
+
+function getPluginSpec(entry: unknown): string | null {
+  if (isString(entry)) return entry;
+  return Array.isArray(entry) && isString(entry[0]) ? entry[0] : null;
+}
+
+function isInstallerManagedEntry(entry: unknown): boolean {
+  return (
+    Array.isArray(entry) &&
+    entry.length >= 2 &&
+    entry[1] !== null &&
+    typeof entry[1] === 'object' &&
+    !Array.isArray(entry[1]) &&
+    (entry[1] as Record<string, unknown>)[INSTALLER_MANAGED_PLUGIN_OPTION] ===
+      true
+  );
+}
+
+type JsoncToken = {
+  kind: 'string' | 'literal' | 'punctuation';
+  value: string;
+  start: number;
+  end: number;
+};
+
+function tokenizeJsonc(content: string): JsoncToken[] {
+  const tokens: JsoncToken[] = [];
+  for (let index = 0; index < content.length; ) {
+    const char = content[index];
+    if (/\s/.test(char)) index++;
+    else if (content.startsWith('//', index)) {
+      index = content.indexOf('\n', index);
+      if (index === -1) break;
+    } else if (content.startsWith('/*', index)) {
+      index = content.indexOf('*/', index + 2);
+      if (index === -1) break;
+      index += 2;
+    } else if ('[]{}:,'.includes(char)) {
+      tokens.push({
+        kind: 'punctuation',
+        value: char,
+        start: index,
+        end: ++index,
+      });
+    } else if (char === '"') {
+      const start = index++;
+      while (index < content.length) {
+        if (content[index] === '\\') index += 2;
+        else if (content[index++] === '"') break;
+      }
+      const raw = content.slice(start, index);
+      try {
+        tokens.push({
+          kind: 'string',
+          value: JSON.parse(raw) as string,
+          start,
+          end: index,
+        });
+      } catch {
+        return [];
+      }
+    } else {
+      const start = index;
+      while (index < content.length && !/\s|[[\]{}:,]/.test(content[index]))
+        index++;
+      tokens.push({
+        kind: 'literal',
+        value: content.slice(start, index),
+        start,
+        end: index,
+      });
+    }
+  }
+  return tokens;
+}
+
+function matchingToken(
+  tokens: JsoncToken[],
+  start: number,
+  open: string,
+  close: string,
+): number {
+  let depth = 0;
+  for (let index = start; index < tokens.length; index++) {
+    if (tokens[index].kind === 'punctuation' && tokens[index].value === open)
+      depth++;
+    if (
+      tokens[index].kind === 'punctuation' &&
+      tokens[index].value === close &&
+      --depth === 0
+    )
+      return index;
+  }
+  return -1;
+}
+
+function hasDirectInstallerMarker(
+  tokens: JsoncToken[],
+  objectStart: number,
+): boolean {
+  const objectEnd = matchingToken(tokens, objectStart, '{', '}');
+  if (objectEnd === -1) return false;
+  let depth = 1;
+  let markerValue = false;
+  for (let index = objectStart + 1; index < objectEnd; index++) {
+    const value = tokens[index].value;
+    if (tokens[index].kind === 'punctuation' && value === '{') depth++;
+    else if (tokens[index].kind === 'punctuation' && value === '}') depth--;
+    else if (
+      depth === 1 &&
+      tokens[index].kind === 'string' &&
+      value === INSTALLER_MANAGED_PLUGIN_OPTION &&
+      tokens[index + 1]?.kind === 'punctuation' &&
+      tokens[index + 1]?.value === ':' &&
+      tokens[index + 2]
+    ) {
+      markerValue =
+        tokens[index + 2].kind === 'literal' &&
+        tokens[index + 2].value === 'true';
+    }
+  }
+  return markerValue;
+}
+
+function findManagedSpecifierRanges(content: string): Array<[number, number]> {
+  const tokens = tokenizeJsonc(content);
+  const rootStart = tokens.findIndex(
+    (token) => token.kind === 'punctuation' && token.value === '{',
+  );
+  if (rootStart === -1) return [];
+  const rootEnd = matchingToken(tokens, rootStart, '{', '}');
+  if (rootEnd === -1) return [];
+  let objectDepth = 1;
+  let plugin = -1;
+  for (let index = rootStart + 1; index < rootEnd; index++) {
+    const value = tokens[index].value;
+    if (tokens[index].kind === 'punctuation' && value === '{') objectDepth++;
+    else if (tokens[index].kind === 'punctuation' && value === '}')
+      objectDepth--;
+    else if (
+      objectDepth === 1 &&
+      value === 'plugin' &&
+      tokens[index + 1]?.kind === 'punctuation' &&
+      tokens[index + 1]?.value === ':' &&
+      tokens[index + 2]?.kind === 'punctuation' &&
+      tokens[index + 2]?.value === '['
+    ) {
+      plugin = index;
+    }
+  }
+  if (plugin === -1) return [];
+  const arrayStart = plugin + 2;
+  const arrayEnd = matchingToken(tokens, arrayStart, '[', ']');
+  if (arrayEnd === -1) return [];
+  const ranges: Array<[number, number]> = [];
+  for (let index = arrayStart + 1; index < arrayEnd; index++) {
+    if (tokens[index].kind !== 'punctuation' || tokens[index].value !== '[')
+      continue;
+    const tupleEnd = matchingToken(tokens, index, '[', ']');
+    if (tupleEnd === -1) break;
+    const specifier = tokens[index + 1];
+    if (
+      specifier?.value.startsWith(`${PACKAGE_NAME}@`) &&
+      tokens[index + 2]?.kind === 'punctuation' &&
+      tokens[index + 2]?.value === ',' &&
+      tokens[index + 3]?.kind === 'punctuation' &&
+      tokens[index + 3]?.value === '{' &&
+      hasDirectInstallerMarker(tokens, index + 3)
+    )
+      ranges.push([specifier.start + 1, specifier.end - 1]);
+    index = tupleEnd;
+  }
+  return ranges;
 }
 
 /**
@@ -153,10 +332,10 @@ export function extractChannel(version: string | null): string {
  */
 function getConfigPaths(directory: string): string[] {
   return [
-    path.join(directory, '.opencode', 'opencode.json'),
-    path.join(directory, '.opencode', 'opencode.jsonc'),
     USER_OPENCODE_CONFIG,
     USER_OPENCODE_CONFIG_JSONC,
+    path.join(directory, '.opencode', 'opencode.json'),
+    path.join(directory, '.opencode', 'opencode.jsonc'),
   ];
 }
 
@@ -172,11 +351,13 @@ function getLocalDevPath(directory: string): string | null {
       const plugins = getPluginEntries(config);
 
       for (const entry of plugins) {
-        if (entry.startsWith('file://') && entry.includes(PACKAGE_NAME)) {
+        const spec = getPluginSpec(entry);
+        if (!spec) continue;
+        if (spec.startsWith('file://') && spec.includes(PACKAGE_NAME)) {
           try {
-            return fileURLToPath(entry);
+            return fileURLToPath(spec);
           } catch {
-            return entry.replace('file://', '');
+            return spec.replace('file://', '');
           }
         }
       }
@@ -251,6 +432,7 @@ export function getCurrentRuntimePackageJsonPath(
  * Searches across all config locations to find the current installation entry for this plugin.
  */
 export function findPluginEntry(directory: string): PluginEntryInfo | null {
+  let selected: PluginEntryInfo | null = null;
   for (const configPath of getConfigPaths(directory)) {
     try {
       if (!fs.existsSync(configPath)) continue;
@@ -258,16 +440,27 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
       const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig;
       const plugins = getPluginEntries(config);
 
-      for (const entry of plugins) {
+      for (const rawEntry of plugins) {
+        const entry = getPluginSpec(rawEntry);
+        if (!entry) continue;
         if (entry === PACKAGE_NAME) {
-          return { entry, isPinned: false, pinnedVersion: null, configPath };
+          selected = {
+            entry,
+            isPinned: false,
+            isInstallerManaged: false,
+            pinnedVersion: null,
+            configPath,
+          };
+          continue;
         }
         if (entry.startsWith(`${PACKAGE_NAME}@`)) {
           const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1);
-          const isPinned = pinnedVersion !== 'latest';
-          return {
+          const isInstallerManaged = isInstallerManagedEntry(rawEntry);
+          const isPinned = pinnedVersion !== 'latest' && !isInstallerManaged;
+          selected = {
             entry,
             isPinned,
+            isInstallerManaged,
             pinnedVersion: isPinned ? pinnedVersion : null,
             configPath,
           };
@@ -275,7 +468,7 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
       }
     } catch {}
   }
-  return null;
+  return selected;
 }
 
 const _cachedLocalVersion: string | null = null;
@@ -324,43 +517,62 @@ export function getCachedVersion(): string | null {
  * Safely updates a pinned version in the configuration file.
  * It attempts to replace the exact plugin string to preserve comments and formatting.
  */
-export function updatePinnedVersion(
-  configPath: string,
-  oldEntry: string,
+export function updateInstallerManagedVersions(
+  directory: string,
   newVersion: string,
 ): boolean {
   try {
-    if (!fs.existsSync(configPath)) return false;
-
-    const content = fs.readFileSync(configPath, 'utf-8');
+    const paths = [
+      ...getConfigPaths(directory),
+      ...getOpenCodeConfigPaths(),
+      getTuiConfig(),
+      getTuiConfigJsonc(),
+    ]
+      .filter((value, index, values) => values.indexOf(value) === index)
+      .filter((configPath) => fs.existsSync(configPath));
     const newEntry = `${PACKAGE_NAME}@${newVersion}`;
-
-    // Check if the old entry actually exists as a quoted string
-    const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-    const entryRegex = new RegExp(`(["'])${escapedOldEntry}\\1`, 'g');
-
-    if (!entryRegex.test(content)) {
-      log(
-        `[auto-update-checker] Entry "${oldEntry}" not found in ${configPath}`,
-      );
-      return false;
-    }
-
-    // Perform the replacement
-    const updatedContent = content.replace(entryRegex, `$1${newEntry}$1`);
-
-    if (updatedContent === content) {
-      return false;
+    const updates = paths.flatMap((configPath) => {
+      const content = fs.readFileSync(configPath, 'utf-8');
+      const updated = findManagedSpecifierRanges(content)
+        .toReversed()
+        .reduce(
+          (result, [start, end]) =>
+            `${result.slice(0, start)}${newEntry}${result.slice(end)}`,
+          content,
+        );
+      const changed = updated !== content;
+      return changed
+        ? [
+            {
+              configPath,
+              content,
+              updated,
+            },
+          ]
+        : [];
+    });
+    if (updates.length === 0) return false;
+    const token = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
+    for (const update of updates)
+      fs.writeFileSync(`${update.configPath}.${token}.tmp`, update.updated);
+    const committed: typeof updates = [];
+    try {
+      for (const update of updates) {
+        fs.renameSync(`${update.configPath}.${token}.tmp`, update.configPath);
+        committed.push(update);
+      }
+    } catch (err) {
+      for (const update of committed) {
+        const restorePath = `${update.configPath}.${token}.restore`;
+        fs.writeFileSync(restorePath, update.content);
+        fs.renameSync(restorePath, update.configPath);
+      }
+      throw err;
     }
-
-    fs.writeFileSync(configPath, updatedContent, 'utf-8');
-    log(
-      `[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`,
-    );
     return true;
   } catch (err) {
     log(
-      `[auto-update-checker] Failed to update config file ${configPath}:`,
+      '[auto-update-checker] Failed to update installer-managed configs:',
       err,
     );
     return false;

+ 2 - 0
src/hooks/auto-update-checker/constants.ts

@@ -2,6 +2,8 @@ import * as os from 'node:os';
 import * as path from 'node:path';
 import { getOpenCodeConfigPaths } from '../../cli/config-manager';
 
+export { INSTALLER_MANAGED_PLUGIN_OPTION } from '../../plugin-entry';
+
 export const PACKAGE_NAME = 'oh-my-opencode-slim';
 export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
 export const NPM_PACKAGE_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`;

+ 19 - 3
src/hooks/auto-update-checker/index.test.ts

@@ -14,11 +14,15 @@ const checkerMocks = {
   getLatestVersion: mock(async () => null),
   getLocalDevVersion: mock(() => null),
   getCurrentRuntimePackageJsonPath: mock(() => null),
+  updateInstallerManagedVersions: mock(() => true),
 };
 
 const cacheMocks = {
   preparePackageUpdate: mock(() => '/tmp/opencode'),
+  discardPreparedPackageUpdate: mock(() => {}),
+  publishPackageUpdate: mock(() => '/tmp/opencode'),
   resolveInstallContext: mock(() => ({ installDir: '/tmp/opencode' })),
+  verifyInstalledPackage: mock(() => true),
 };
 
 const skillSyncMocks = {
@@ -122,13 +126,23 @@ describe('auto-update-checker/index', () => {
     checkerMocks.getLatestVersion.mockImplementation(async () => null);
     checkerMocks.getLocalDevVersion.mockReset();
     checkerMocks.getLocalDevVersion.mockImplementation(() => null);
+    checkerMocks.updateInstallerManagedVersions.mockReset();
+    checkerMocks.updateInstallerManagedVersions.mockImplementation(() => true);
     checkerMocks.getCurrentRuntimePackageJsonPath.mockReset();
     checkerMocks.getCurrentRuntimePackageJsonPath.mockImplementation(
       () => null,
     );
 
     cacheMocks.preparePackageUpdate.mockReset();
-    cacheMocks.preparePackageUpdate.mockImplementation(() => '/tmp/opencode');
+    cacheMocks.preparePackageUpdate.mockImplementation(() => ({
+      stagingDir: '/tmp/opencode-staging',
+      targetDir: '/tmp/opencode',
+    }));
+    cacheMocks.publishPackageUpdate.mockReset();
+    cacheMocks.publishPackageUpdate.mockImplementation(() => '/tmp/opencode');
+    cacheMocks.verifyInstalledPackage.mockReset();
+    cacheMocks.verifyInstalledPackage.mockImplementation(() => true);
+    cacheMocks.discardPreparedPackageUpdate.mockReset();
     cacheMocks.resolveInstallContext.mockReset();
     cacheMocks.resolveInstallContext.mockImplementation(() => ({
       installDir: '/tmp/opencode',
@@ -231,10 +245,12 @@ describe('auto-update-checker/index', () => {
     expect(cacheMocks.preparePackageUpdate).toHaveBeenCalledWith(
       '0.9.11',
       'oh-my-opencode-slim',
+      undefined,
+      'latest',
     );
     expect(crossSpawnMock).toHaveBeenCalledWith(
       ['bun', 'install'],
-      expect.objectContaining({ cwd: '/tmp/opencode' }),
+      expect.objectContaining({ cwd: '/tmp/opencode-staging' }),
     );
     expect(skillSyncMocks.syncBundledSkillsFromPackage).toHaveBeenCalledWith(
       '/tmp/opencode/node_modules/oh-my-opencode-slim',
@@ -775,7 +791,7 @@ describe('auto-update-checker/index', () => {
 
     expect(crossSpawnMock).toHaveBeenCalledWith(
       ['bun', 'install'],
-      expect.objectContaining({ cwd: '/tmp/opencode' }),
+      expect.objectContaining({ cwd: '/tmp/opencode-staging' }),
     );
     expect(skillSyncMocks.syncBundledSkillsFromPackage).not.toHaveBeenCalled();
     expect(showToast).toHaveBeenCalledWith({

+ 40 - 6
src/hooks/auto-update-checker/index.ts

@@ -6,7 +6,13 @@ import {
 } from '../../companion/updater';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
-import { preparePackageUpdate, resolveInstallContext } from './cache';
+import {
+  discardPreparedPackageUpdate,
+  preparePackageUpdate,
+  publishPackageUpdate,
+  resolveInstallContext,
+  verifyInstalledPackage,
+} from './cache';
 import {
   extractChannel,
   findPluginEntry,
@@ -14,6 +20,7 @@ import {
   getCurrentRuntimePackageJsonPath,
   getLatestCompatibleVersion,
   getLocalDevVersion,
+  updateInstallerManagedVersions,
 } from './checker';
 import { CACHE_DIR, PACKAGE_NAME } from './constants';
 import { syncBundledSkillsFromPackage } from './skill-sync';
@@ -209,8 +216,16 @@ async function runBackgroundUpdateCheck(
     return;
   }
 
-  const installDir = preparePackageUpdate(latestVersion, PACKAGE_NAME);
-  if (!installDir) {
+  const cacheIdentity = pluginInfo.isInstallerManaged
+    ? latestVersion
+    : 'latest';
+  const prepared = preparePackageUpdate(
+    latestVersion,
+    PACKAGE_NAME,
+    undefined,
+    cacheIdentity,
+  );
+  if (!prepared) {
     showToast(
       ctx,
       `OMO-Slim ${latestVersion}`,
@@ -223,9 +238,28 @@ async function runBackgroundUpdateCheck(
     return;
   }
 
-  const installSuccess = await runBunInstallSafe(installDir);
-
-  if (installSuccess) {
+  const installSuccess =
+    (await runBunInstallSafe(prepared.stagingDir)) &&
+    verifyInstalledPackage(prepared.stagingDir, latestVersion);
+  const installDir = installSuccess
+    ? publishPackageUpdate(prepared, latestVersion)
+    : null;
+  if (!installSuccess) discardPreparedPackageUpdate(prepared);
+
+  if (installDir) {
+    if (
+      pluginInfo.isInstallerManaged &&
+      !updateInstallerManagedVersions(ctx.directory, latestVersion)
+    ) {
+      showToast(
+        ctx,
+        `OMO-Slim ${latestVersion}`,
+        'Update installed in cache, but plugin configuration could not be updated.',
+        'error',
+        8000,
+      );
+      return;
+    }
     let installedSkills: string[] = [];
     let companionUpdated = false;
     let companionWillRetry = false;

+ 1 - 0
src/hooks/auto-update-checker/types.ts

@@ -36,6 +36,7 @@ export interface AutoUpdateCheckerOptions {
 export interface PluginEntryInfo {
   entry: string;
   isPinned: boolean;
+  isInstallerManaged: boolean;
   pinnedVersion: string | null;
   configPath: string;
 }

+ 4 - 1
src/hooks/task-session-manager/index.ts

@@ -397,7 +397,10 @@ export function createTaskSessionManagerHook(
       continuationConsumed.add(parentSessionID);
       await sessionSdk.promptAsync({
         path: { id: parentSessionID },
-        body: { parts: [createInternalAgentTextPart(CONTINUATION_NUDGE)] },
+        body: {
+          agent: 'orchestrator',
+          parts: [createInternalAgentTextPart(CONTINUATION_NUDGE)],
+        },
         throwOnError: true,
       });
     } catch (error) {

+ 26 - 12
src/index.ts

@@ -1,5 +1,10 @@
 import type { Plugin, ToolDefinition } from '@opencode-ai/plugin';
-import { createAgents, getAgentConfigs, getDisabledAgents } from './agents';
+import {
+  createAgents,
+  getAgentConfigs,
+  getDisabledAgents,
+  isSubagent,
+} from './agents';
 import { buildOrchestratorPrompt } from './agents/orchestrator';
 import { CompanionManager } from './companion/manager';
 import { ensureCompanionVersion } from './companion/updater';
@@ -540,14 +545,20 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     mcp: mcps,
 
     config: async (opencodeConfig: Record<string, unknown>) => {
-      // Only set default_agent if not already configured by the user
-      // and the plugin config doesn't explicitly disable this behavior
-      if (
-        config.setDefaultAgent !== false &&
-        !(opencodeConfig as { default_agent?: string }).default_agent
-      ) {
-        (opencodeConfig as { default_agent?: string }).default_agent =
-          'orchestrator';
+      // Force default_agent to 'orchestrator' when unset, and also when the
+      // user pointed it at an omos subagent name (opencode rejects subagent
+      // names as default_agent with "default agent must be a primary agent").
+      // Other values (opencode's built-in 'build'/'plan', or a user-defined
+      // primary agent) are respected. This guards against promptAsync calls
+      // that omit the `agent` field from falling back to 'build' when the
+      // orchestrator agent is temporarily unresolved.
+      if (config.setDefaultAgent !== false) {
+        const existing = (opencodeConfig as { default_agent?: string })
+          .default_agent;
+        if (!existing || isSubagent(existing)) {
+          (opencodeConfig as { default_agent?: string }).default_agent =
+            'orchestrator';
+        }
       }
 
       // Merge Agent configs - per-agent shallow merge to preserve
@@ -756,7 +767,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       const tuiAgentModels: Record<string, string> = {};
       const tuiAgentVariants: Record<string, string> = {};
       for (const agentDef of agentDefs) {
-        if (agentDef.name === 'councillor') continue;
+        if (
+          agentDef.name === 'council' ||
+          agentDef.name === 'councillor' ||
+          agentDef.name.startsWith('councillor-')
+        )
+          continue;
 
         const entry = configAgent[agentDef.name] as
           | Record<string, unknown>
@@ -1214,7 +1230,5 @@ export type {
   MultiplexerLayout,
   MultiplexerType,
   PluginConfig,
-  TmuxConfig,
-  TmuxLayout,
 } from './config';
 export type { RemoteMcpConfig } from './mcp';

+ 4 - 0
src/interview/service.ts

@@ -622,6 +622,7 @@ export function createInterviewService(
       await ctx.client.session.promptAsync({
         path: { id: interview.sessionID },
         body: {
+          agent: 'orchestrator',
           parts: [createInternalAgentTextPart(prompt)],
           ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
         },
@@ -871,6 +872,7 @@ export function createInterviewService(
       await ctx.client.session.promptAsync({
         path: { id: interview.sessionID },
         body: {
+          agent: 'orchestrator',
           parts: [createInternalAgentTextPart(prompt)],
           ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
         },
@@ -933,6 +935,7 @@ export function createInterviewService(
       await ctx.client.session.promptAsync({
         path: { id: interview.sessionID },
         body: {
+          agent: 'orchestrator',
           parts: [createInternalAgentTextPart(prompt)],
           ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
         },
@@ -1007,6 +1010,7 @@ export function createInterviewService(
       await ctx.client.session.promptAsync({
         path: { id: interview.sessionID },
         body: {
+          agent: 'orchestrator',
           parts: [createInternalAgentTextPart(prompt)],
           ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
         },

+ 6 - 0
src/plugin-entry.ts

@@ -0,0 +1,6 @@
+export const INSTALLER_MANAGED_PLUGIN_OPTION =
+  '__ohMyOpencodeSlimManagedByInstaller';
+
+export type PluginEntry =
+  | string
+  | [string, Record<string, unknown>, ...unknown[]];

+ 47 - 0
src/tui.test.ts

@@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
 import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
+import { RGBA } from '@opentui/core';
 import {
+  getContrastForeground,
   getSidebarAgentNames,
   readCompactSidebar,
   readConfigInvalid,
@@ -196,3 +198,48 @@ describe('tui plugin env disable', () => {
     expect(renderRequested).toBe(false);
   });
 });
+
+describe('getContrastForeground', () => {
+  const white = RGBA.fromInts(255, 255, 255);
+  const black = RGBA.fromInts(0, 0, 0);
+  const darkGray = RGBA.fromInts(30, 30, 30);
+  const transparent = RGBA.fromInts(0, 0, 0, 0);
+
+  test('returns theme text when fallback is triggered', () => {
+    expect(getContrastForeground(undefined, 'theme-text', 'theme-bg')).toBe(
+      'theme-text',
+    );
+  });
+
+  test('returns black on a light background', () => {
+    // White background -> black text
+    const result = getContrastForeground(white, white, black) as RGBA;
+    expect(result.toInts()).toEqual([0, 0, 0, 255]);
+  });
+
+  test('returns white on a dark background', () => {
+    // Black background -> white text
+    const result = getContrastForeground(black, white, black) as RGBA;
+    expect(result.toInts()).toEqual([255, 255, 255, 255]);
+  });
+
+  test('respects themeBackground if it is dark and solid when accent is light', () => {
+    const result = getContrastForeground(white, white, darkGray) as RGBA;
+    expect(result.toInts()).toEqual([30, 30, 30, 255]);
+  });
+
+  test('never returns transparent themeBackground even if accent is light', () => {
+    const result = getContrastForeground(white, white, transparent) as RGBA;
+    expect(result.toInts()).toEqual([0, 0, 0, 255]);
+  });
+
+  test('respects themeText if it is light when accent is dark', () => {
+    const result = getContrastForeground(black, white, black) as RGBA;
+    expect(result.toInts()).toEqual([255, 255, 255, 255]);
+  });
+
+  test('parses hex string colors correctly', () => {
+    const result = getContrastForeground('#ffffff', '#ffffff', '#1e1e1e');
+    expect(result).toBe('#1e1e1e');
+  });
+});

+ 68 - 4
src/tui.ts

@@ -3,6 +3,7 @@ import type {
   TuiPluginApi,
   TuiPluginModule,
 } from '@opencode-ai/plugin/tui';
+import { type ColorInput, parseColor, RGBA } from '@opentui/core';
 import type { JSX } from '@opentui/solid';
 import { createElement, insert, setProp } from '@opentui/solid';
 import { DEFAULT_DISABLED_AGENTS, SUBAGENT_NAMES } from './config/constants';
@@ -146,6 +147,61 @@ function compactAgentRow(
   );
 }
 
+export function getContrastForeground(
+  accent: unknown,
+  themeText: unknown,
+  themeBackground: unknown,
+): unknown {
+  if (!accent) return themeText;
+
+  let accentRgba: RGBA;
+  try {
+    accentRgba = parseColor(accent as ColorInput);
+  } catch {
+    return themeText;
+  }
+
+  // Calculate relative luminance: R, G, B are in range 0..1
+  const luminance =
+    0.299 * accentRgba.r + 0.587 * accentRgba.g + 0.114 * accentRgba.b;
+
+  if (luminance > 0.5) {
+    // Light accent bg -> we need a dark fg.
+    // Let's use themeBackground if it exists, is resolved, and not transparent.
+    if (themeBackground) {
+      try {
+        const bgRgba = parseColor(themeBackground as ColorInput);
+        if (bgRgba.a !== 0) {
+          const bgLum = 0.299 * bgRgba.r + 0.587 * bgRgba.g + 0.114 * bgRgba.b;
+          if (bgLum < 0.5) {
+            return themeBackground;
+          }
+        }
+      } catch {
+        // ignore and fallback
+      }
+    }
+    return RGBA.fromInts(0, 0, 0);
+  }
+
+  // Dark accent bg -> we need a light fg.
+  // Let's use themeText if it exists and is light.
+  if (themeText) {
+    try {
+      const textRgba = parseColor(themeText as ColorInput);
+      const textLum =
+        0.299 * textRgba.r + 0.587 * textRgba.g + 0.114 * textRgba.b;
+      if (textLum > 0.5) {
+        return themeText;
+      }
+    } catch {
+      // ignore and fallback
+    }
+  }
+
+  return RGBA.fromInts(255, 255, 255);
+}
+
 function renderSidebar(
   snapshot: TuiSnapshot,
   version: string,
@@ -182,10 +238,18 @@ function renderSidebar(
         [
           box(
             { paddingLeft: 1, paddingRight: 1, backgroundColor: theme.accent },
-            // Use theme.text, not theme.background: when the theme background is
-            // "none" (transparent) the foreground becomes RGBA(0,0,0,0) and the
-            // badge text vanishes. See #582.
-            [text({ fg: theme.text }, ['OMO-Slim'])],
+            [
+              text(
+                {
+                  fg: getContrastForeground(
+                    theme.accent,
+                    theme.text,
+                    theme.background,
+                  ),
+                },
+                ['OMO-Slim'],
+              ),
+            ],
           ),
           text({ fg: theme.textMuted }, [`v${version}`]),
         ],