Răsfoiți Sursa

merge master and fix remaining grep review issues

dhaern 3 luni în urmă
părinte
comite
e39b672cdc

+ 9 - 0
.all-contributorsrc

@@ -353,6 +353,15 @@
       "contributions": [
         "code"
       ]
+    },
+    {
+      "login": "ZenStudioLab",
+      "name": "Nguyen Canh Toan",
+      "avatar_url": "https://avatars.githubusercontent.com/u/10528635?v=4",
+      "profile": "https://zenstudio.cv/",
+      "contributions": [
+        "code"
+      ]
     }
   ],
   "commitConvention": "angular"

+ 30 - 1
.github/workflows/ci.yml

@@ -36,4 +36,33 @@ jobs:
         run: bun test
 
       - name: Build
-        run: bun run build
+        run: bun run build
+
+  package-smoke:
+    runs-on: ${{ matrix.os }}
+
+    strategy:
+      matrix:
+        os: [ubuntu-latest, macos-latest]
+        bun-version: [latest]
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Setup Bun
+        uses: oven-sh/setup-bun@v2
+        with:
+          bun-version: ${{ matrix.bun-version }}
+
+      - name: Install dependencies
+        run: bun install --frozen-lockfile
+
+      - name: Build
+        run: bun run build
+
+      - name: Verify packaged release artifact
+        run: bun run verify:release
+
+      - name: Verify OpenCode host smoke test
+        run: bun run verify:host-smoke

+ 2 - 0
.gitignore

@@ -56,6 +56,8 @@ GOAL.md
 GOALS.md
 PR-NOTES.md
 REVIEW.md
+docs/plans
+docs/superpowers
 
 # Python
 __pycache__/

+ 63 - 56
README.md

@@ -2,7 +2,7 @@
   <img src="img/team.png" alt="Pantheon agents" style="border-radius: 10px;" width="620">
   <p><i>Seven divine beings emerged from the dawn of code, each an immortal master of their craft await your command to forge order from chaos and build what was once thought impossible.</i></p>
   <p><b>Open Multi Agent Suite</b> · Mix any models · Auto delegate tasks</p>
-  <p><a href="https://moltfounders.com/jobs/09d1c6e7-9e0e-4683-8d78-e2376aaa2333"><img src="https://moltfounders.com/badges/4.png" alt="MoltFounders" height="30"></a></p>
+
   <p><sub>by <b>Boring Dystopia Development</b></sub></p>
   <p>
     <a href="https://boringdystopia.ai/"><img src="https://img.shields.io/badge/boringdystopia.ai-111111?style=for-the-badge&logo=vercel&logoColor=white" alt="boringdystopia.ai"></a>&nbsp;
@@ -13,7 +13,11 @@
 
 ---
 
-## 📦 Installation
+## What's This Plugin
+
+oh-my-opencode-slim is an agent orchestration plugin for OpenCode. It includes a built-in team of specialized agents that can scout a codebase, look up fresh documentation, review architecture, handle UI work, and execute well-scoped implementation tasks under one orchestrator.
+
+The main idea is simple: instead of forcing one model to do everything, the plugin routes each part of the job to the agent best suited for it, balancing **quality, speed and cost**.
 
 ### Quick Start
 
@@ -21,49 +25,50 @@
 bunx oh-my-opencode-slim@latest install
 ```
 
-The installer generates an OpenAI configuration by default (using `gpt-5.4` and `gpt-5.4-mini`). No provider questions asked.
-
-For non-interactive mode:
+### Getting Started
 
-```bash
-bunx oh-my-opencode-slim@latest install --no-tui --tmux=yes --skills=yes
-```
+The installer generates an OpenAI preset by default, using `openai/gpt-5.4` for the higher-judgment agents and `openai/gpt-5.4-mini` for the faster scoped agents.
 
-To force overwrite of an existing configuration:
-```bash
-bunx oh-my-opencode-slim@latest install --reset
-```
+Then:
 
-### For Alternative Providers
+1. **Log in to the providers you want to use if you haven't already**:
 
-The default configuration uses OpenAI. To use Kimi, GitHub Copilot, or ZAI Coding Plan, see **[Provider Configurations](docs/provider-configurations.md)** for step-by-step instructions and config examples.
+   ```bash
+   opencode auth login
+   ```
+2. **Refresh and list the models OpenCode can see**:
 
-> [!TIP]
-> Want to see the latest models OpenCode knows about? Run `opencode models --refresh` to refresh the cache and list currently available models.
+   ```bash
+   opencode models --refresh
+   ```
+3. **Open your plugin config** at `~/.config/opencode/oh-my-opencode-slim.json`
 
-### JSON Schema
+4. **Update the models you want for each agent**
 
-An official JSON Schema is included in the package for editor validation and autocomplete. Add a `$schema` reference to your config file:
+The default generated configuration looks like this:
 
 ```jsonc
 {
   "$schema": "https://unpkg.com/oh-my-opencode-slim@latest/oh-my-opencode-slim.schema.json",
-  // your config...
+  "preset": "openai",
+  "presets": {
+    "openai": {
+      "orchestrator": { "model": "openai/gpt-5.4", "variant": "high", "skills": ["*"], "mcps": ["*"] },
+      "oracle": { "model": "openai/gpt-5.4", "variant": "high", "skills": [], "mcps": [] },
+      "librarian": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": ["websearch", "context7", "grep_app"] },
+      "explorer": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [] },
+      "designer": { "model": "openai/gpt-5.4-mini", "variant": "medium", "skills": ["agent-browser"], "mcps": [] },
+      "fixer": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [] }
+    }
+  }
 }
 ```
 
-This enables autocomplete and inline validation in VS Code, Neovim, and other editors that support JSON Schema.
-
-### For LLM Agents
-
-Paste this into any coding agent:
+### For Alternative Providers
 
-```
-Install and configure by following the instructions here:
-https://raw.githubusercontent.com/alvinunreal/oh-my-opencode-slim/refs/heads/master/README.md
-```
+To use Kimi, GitHub Copilot, ZAI Coding Plan, or a mixed-provider setup, see **[Provider Configurations](docs/provider-configurations.md)** for step-by-step config examples. If you want a ready-made starting point, check the **[Author's Preset](docs/authors-preset.md)** and **[$30 Preset](docs/thirty-dollars-preset.md)** - the `$30` preset is the best cheap setup.
 
-**Detailed installation guide:** [docs/installation.md](docs/installation.md)
+You can also mix and match any models per agent. For model suggestions, see the **Recommended Models** listed under each agent below.
 
 ### ✅ Verify Your Setup
 
@@ -348,7 +353,7 @@ If any agent fails to respond, check your provider authentication and config fil
 ### 07. Observer: The Silent Witness
 
 > [!NOTE]
-> **Why a separate agent?** Not all models support vision. Your strongest coding model (e.g. for design decisions) may not be able to read images, while a vision-capable model may not be the best for reasoning. Observer solves this by having its **own model** — configure a vision-capable model for it while keeping Designer on your strongest reasoning model. Disabled by default; enable via `disabled_agents: []` in config.
+> **Why a separate agent?** If your Orchestrator model is not multimodal, you can enable this agent. Observer which is diabled by default, can be used by Orchestrator for multimodal file reading. Enable via setting `disabled_agents: []` in your config.
 
 <table>
   <tr>
@@ -382,39 +387,40 @@ If any agent fails to respond, check your provider authentication and config fil
 
 ## 📚 Documentation
 
-### 🚀 Getting Started
+Use this section as a map: start with installation, then jump to features, configuration, or example presets depending on what you need.
 
-| Doc | Contents |
-|-----|----------|
-| **[Installation Guide](docs/installation.md)** | CLI flags, `--reset`, auth, troubleshooting |
-| **[Provider Configurations](docs/provider-configurations.md)** | OpenAI, Kimi, Copilot, ZAI, Fireworks AI — mixing providers, fallback chains |
+### 🚀 Start Here
 
-### ✨ Features
+| Doc | What it covers |
+|-----|----------------|
+| **[Installation Guide](docs/installation.md)** | Install the plugin, use CLI flags, reset config, and troubleshoot setup |
+| **[Provider Configurations](docs/provider-configurations.md)** | Configure OpenAI, Kimi, GitHub Copilot, ZAI, Fireworks AI, or mixed-provider presets |
 
-| Feature | Doc | What it does |
-|---------|-----|--------------|
-| **Council** | [council.md](docs/council.md) | Run N models in parallel, synthesize one answer (`@council`) |
-| **Interview** | [interview.md](docs/interview.md) | Browser-based Q&A flow for turning rough ideas into a live markdown spec |
-| **Multiplexer Integration** | [multiplexer-integration.md](docs/multiplexer-integration.md) | Watch agents work in real-time with auto-spawned panes (Tmux/Zellij) |
-| **Cartography Skill** | [cartography.md](docs/cartography.md) | Auto-generate hierarchical codemaps for any codebase |
+### ✨ Features & Workflows
 
-### ⚙️ Config & Reference
+| Doc | What it covers |
+|-----|----------------|
+| **[Council](docs/council.md)** | Run multiple models in parallel and synthesize a single answer with `@council` |
+| **[Interview](docs/interview.md)** | Turn rough ideas into a structured markdown spec through a browser-based Q&A flow |
+| **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux or Zellij panes |
+| **[Todo Continuation](docs/todo-continuation.md)** | Auto-continue orchestrator sessions with cooldowns and safety checks |
+| **[Cartography](docs/cartography.md)** | Generate hierarchical codemaps to understand large codebases faster |
 
-| Doc | Contents |
-|-----|----------|
-| **[Skills](docs/skills.md)** | `simplify`, `agent-browser`, `cartography` — assignment syntax |
-| **[MCPs](docs/mcps.md)** | `websearch`, `context7`, `grep_app` — permissions per agent |
-| **[Tools](docs/tools.md)** | Background tasks, LSP, local `grep`, AST-grep, formatters |
-| **[Configuration](docs/configuration.md)** | Config files, prompt overriding, JSONC, full option reference |
+### ⚙️ Config & Reference
 
-Slim only intercepts `apply_patch` before native execution. It rewrites recoverable stale patches, canonizes safe tolerant matches against the real file when unicode/trim drift is the only mismatch, keeps the authored `new_lines` bytes intact, preserves existing file EOL/final-newline state for updates, validates malformed patches strictly before helper execution, uses a conservative bounded LCS fallback, supports sequential `Update File` hunks on the same path through accumulated helper state, and blocks `apply_patch` before the native tool runs if any patch path falls outside the allowed root/worktree. This rescue does not extend to `edit` or `write`.
+| Doc | What it covers |
+|-----|----------------|
+| **[Configuration](docs/configuration.md)** | Config file locations, JSONC support, prompt overrides, and full option reference |
+| **[Skills](docs/skills.md)** | Built-in and recommended skills such as `simplify`, `agent-browser`, and `cartography` |
+| **[MCPs](docs/mcps.md)** | `websearch`, `context7`, `grep_app`, and how MCP permissions work per agent |
+| **[Tools](docs/tools.md)** | Built-in tool capabilities like background tasks, `webfetch`, local `grep`, AST-grep, LSP tools, code search, and formatters |
 
-### 💡 Presets
+### 💡 Example Presets
 
-| Doc | Contents |
-|-----|----------|
-| **[Author's Preset](docs/authors-preset.md)** | The exact config the author runs daily — OpenAI Pro + GitHub Copilot |
-| **[$30 Preset](docs/thirty-dollars-preset.md)** | A mixed setup using Codex Plus ($20) + GitHub Copilot Pro ($10) for about $30/month total |
+| Doc | What it covers |
+|-----|----------------|
+| **[Author's Preset](docs/authors-preset.md)** | The author's daily mixed-provider setup |
+| **[$30 Preset](docs/thirty-dollars-preset.md)** | A budget mixed-provider setup for around $30/month |
 
 ---
 
@@ -425,7 +431,7 @@ Slim only intercepts `apply_patch` before native execution. It rewrites recovera
   <p><sub>Every merged contribution leaves a mark on the realm.</sub></p>
 
   <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
-[![All Contributors](https://img.shields.io/badge/all_contributors-38-orange.svg?style=flat-square)](#contributors-)
+[![All Contributors](https://img.shields.io/badge/all_contributors-39-orange.svg?style=flat-square)](#contributors-)
 <!-- ALL-CONTRIBUTORS-BADGE:END -->
 </div>
 
@@ -487,6 +493,7 @@ Slim only intercepts `apply_patch` before native execution. It rewrites recovera
     <tr>
       <td align="center" valign="top" width="16.66%"><a href="https://github.com/dkovacevic15"><img src="https://avatars.githubusercontent.com/u/24757821?v=4?s=100" width="100px;" alt="Dusan Kovacevic"/><br /><sub><b>Dusan Kovacevic</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=dkovacevic15" title="Code">💻</a></td>
       <td align="center" valign="top" width="16.66%"><a href="https://github.com/jwcrystal"><img src="https://avatars.githubusercontent.com/u/121911854?v=4?s=100" width="100px;" alt="jwcrystal"/><br /><sub><b>jwcrystal</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=jwcrystal" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://zenstudio.cv/"><img src="https://avatars.githubusercontent.com/u/10528635?v=4?s=100" width="100px;" alt="Nguyen Canh Toan"/><br /><sub><b>Nguyen Canh Toan</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=ZenStudioLab" title="Code">💻</a></td>
     </tr>
   </tbody>
 </table>

+ 30 - 0
docs/configuration.md

@@ -86,9 +86,11 @@ All config files support **JSONC** (JSON with Comments):
 | `presets.<name>.<agent>.model` | string | — | Model ID in `provider/model` format |
 | `presets.<name>.<agent>.temperature` | number | — | Temperature (0–2) |
 | `presets.<name>.<agent>.variant` | string | — | Reasoning effort: `"low"`, `"medium"`, `"high"` |
+| `presets.<name>.<agent>.displayName` | string | — | Custom user-facing alias for the agent (e.g. `"advisor"` for `oracle`) |
 | `presets.<name>.<agent>.skills` | string[] | — | Skills the agent can use (`"*"`, `"!item"`, explicit list) |
 | `presets.<name>.<agent>.mcps` | string[] | — | MCPs the agent can use (`"*"`, `"!item"`, explicit list) |
 | `presets.<name>.<agent>.options` | object | — | Provider-specific model options passed to the AI SDK (e.g., `textVerbosity`, `thinking` budget) |
+| `agents.<agent>.displayName` | string | — | Custom user-facing alias for the agent in the active config |
 | `tmux.enabled` | boolean | `false` | Enable tmux pane spawning |
 | `tmux.layout` | string | `"main-vertical"` | Layout: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical` |
 | `tmux.main_pane_size` | number | `60` | Main pane size as percentage (20–80) |
@@ -122,3 +124,31 @@ All config files support **JSONC** (JSON with Comments):
 | `interview.autoOpenBrowser` | boolean | `true` | Automatically open the interview UI in your default browser |
 | `interview.port` | integer | `0` | Interview server port (0–65535). `0` = OS-assigned random port (per-session mode). Any value > 0 enables [dashboard mode](interview.md#dashboard-mode) |
 | `interview.dashboard` | boolean | `false` | Enable [dashboard mode](interview.md#dashboard-mode) on the default port (43211). Setting `port` > 0 also enables dashboard mode. If both are set, `port` takes precedence |
+
+### Agent Display Names
+
+Use `displayName` to give an agent a user-facing alias while keeping the
+internal agent name unchanged.
+
+```jsonc
+{
+  "agents": {
+    "oracle": {
+      "displayName": "advisor"
+    },
+    "explorer": {
+      "displayName": "researcher"
+    }
+  }
+}
+```
+
+With this config, users can refer to `@advisor` and `@researcher`, while the
+plugin still routes them to `oracle` and `explorer` internally.
+
+Notes:
+
+- `displayName` works in both top-level `agents` overrides and inside `presets`
+- `@` prefixes and surrounding whitespace are normalized automatically
+- Display names must be unique
+- Display names cannot conflict with internal agent names like `oracle` or `explorer`

+ 1 - 0
docs/quick-reference.md

@@ -16,6 +16,7 @@
 | [Council Agent](council.md) | Multi-LLM consensus, presets, role prompts, timeouts |
 | [Interview](interview.md) | `/interview` command, browser UI, dashboard mode, multi-session coordination |
 | [Multiplexer Integration](multiplexer-integration.md) | Real-time pane monitoring, layouts, troubleshooting |
+| [Todo Continuation](todo-continuation.md) | `auto_continue`, `/auto-continue`, cooldowns, safety gates |
 | [Cartography Skill](cartography.md) | Hierarchical codemap generation |
 
 ## ⚙️ Config & Reference

+ 45 - 0
docs/todo-continuation.md

@@ -0,0 +1,45 @@
+# Todo Continuation
+
+Auto-continue the orchestrator when it stops with incomplete todos. Opt-in only — nothing resumes automatically unless you enable it.
+
+## Controls
+
+| Tool / Command | Description |
+|----------------|-------------|
+| `auto_continue` | Toggle auto-continuation. Call with `{ enabled: true }` to activate, `{ enabled: false }` to disable |
+| `/auto-continue` | Slash command shortcut. Accepts `on`, `off`, or toggles with no argument |
+
+## How It Works
+
+1. When the orchestrator goes idle with incomplete todos, a countdown notification appears
+2. After the cooldown (default 3s), a continuation prompt is injected and the orchestrator resumes work
+3. Press Esc×2 during the cooldown or after injection to stop it
+
+## Safety Gates
+
+All of these must pass before continuation happens:
+
+- Auto-continue is enabled
+- The session is the orchestrator
+- Incomplete todos exist
+- The last assistant message is not a question
+- The consecutive continuation count is under the limit
+- The session is not in the post-abort suppress window (5s)
+- No pending injection is already in flight
+
+## Configuration
+
+Configure it in `~/.config/opencode/oh-my-opencode-slim.json` or `~/.config/opencode/oh-my-opencode-slim.jsonc`:
+
+```jsonc
+{
+  "todoContinuation": {
+    "maxContinuations": 5,      // Max consecutive auto-continuations (1–50)
+    "cooldownMs": 3000,         // Delay before each continuation (0–30000)
+    "autoEnable": false,        // Auto-enable when session has enough todos
+    "autoEnableThreshold": 4    // Number of todos to trigger auto-enable
+  }
+}
+```
+
+> See [Configuration](configuration.md) for the full option reference.

+ 3 - 37
docs/tools.md

@@ -94,40 +94,6 @@ Includes Prettier, Biome, `gofmt`, `rustfmt`, `ruff`, and 20+ others.
 
 ## Todo Continuation
 
-Auto-continue the orchestrator when it stops with incomplete todos. Opt-in — no automatic behavior unless enabled.
-
-| Tool / Command | Description |
-|----------------|-------------|
-| `auto_continue` | Toggle auto-continuation. Call with `{ enabled: true }` to activate, `{ enabled: false }` to disable |
-| `/auto-continue` | Slash command shortcut. Accepts `on`, `off`, or toggles with no argument |
-
-**How it works:**
-
-1. When the orchestrator goes idle with incomplete todos, a countdown notification appears
-2. After the cooldown (default 3s), a continuation prompt is injected — the orchestrator resumes work
-3. Press Esc×2 during cooldown or after injection to stop
-
-**Safety gates** (all must pass before continuation):
-
-- Auto-continue is enabled
-- Session is the orchestrator
-- Incomplete todos exist
-- Last assistant message is not a question
-- Consecutive continuation count is under the limit
-- Not in post-abort suppress window (5s)
-- No pending injection already in flight
-
-**Configuration** in `oh-my-opencode-slim.json`:
-
-```jsonc
-{
-  "todoContinuation": {
-    "maxContinuations": 5,      // Max consecutive auto-continuations (1–50)
-    "cooldownMs": 3000,         // Delay before each continuation (0–30000)
-    "autoEnable": false,        // Auto-enable when session has enough todos
-    "autoEnableThreshold": 4    // Number of todos to trigger auto-enable
-  }
-}
-```
-
-> See [Configuration](configuration.md) for the full option reference.
+Auto-continue has its own guide now:
+
+- [Todo Continuation](todo-continuation.md) — controls, safety gates, behavior, and config

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

@@ -265,6 +265,10 @@
                 "type": "string"
               },
               "additionalProperties": {}
+            },
+            "displayName": {
+              "type": "string",
+              "minLength": 1
             }
           }
         }
@@ -335,6 +339,10 @@
               "type": "string"
             },
             "additionalProperties": {}
+          },
+          "displayName": {
+            "type": "string",
+            "minLength": 1
           }
         }
       }

+ 2 - 0
package.json

@@ -44,6 +44,8 @@
     "contributors:check": "all-contributors check",
     "contributors:generate": "all-contributors generate",
     "generate-schema": "bun run scripts/generate-schema.ts",
+    "verify:release": "bun run scripts/verify-release-artifact.ts",
+    "verify:host-smoke": "bun run scripts/verify-opencode-host-smoke.ts",
     "typecheck": "tsc --noEmit",
     "test": "bun test",
     "lint": "biome lint .",

+ 306 - 0
scripts/verify-opencode-host-smoke.ts

@@ -0,0 +1,306 @@
+import { spawn, spawnSync } from 'node:child_process';
+import {
+  copyFileSync,
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import { createServer } from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(__dirname, '..');
+const distEntry = path.join(repoRoot, 'dist', 'index.js');
+
+function fail(message: string): never {
+  throw new Error(message);
+}
+
+function run(
+  command: string,
+  args: string[],
+  options: { cwd?: string; env?: Record<string, string> } = {},
+) {
+  const result = spawnSync(command, args, {
+    cwd: options.cwd ?? repoRoot,
+    env: {
+      ...process.env,
+      ...options.env,
+    },
+    encoding: 'utf8',
+    stdio: ['ignore', 'pipe', 'pipe'],
+  });
+
+  if (result.status !== 0) {
+    const detail = [result.stdout, result.stderr].filter(Boolean).join('\n');
+    fail(
+      `Command failed: ${command} ${args.join(' ')}${detail ? `\n${detail}` : ''}`,
+    );
+  }
+
+  return result.stdout.trim();
+}
+
+function parsePackJson(output: string) {
+  const start = output.indexOf('[');
+  const end = output.lastIndexOf(']');
+
+  if (start === -1 || end === -1 || end < start) {
+    fail(`Could not locate npm pack JSON output:\n${output}`);
+  }
+
+  return JSON.parse(output.slice(start, end + 1)) as Array<{
+    filename?: string;
+  }>;
+}
+
+function packArtifact() {
+  const output = run('npm', ['pack', '--json', '--ignore-scripts']);
+  const parsed = parsePackJson(output);
+  const tarball = parsed[0]?.filename;
+  if (!tarball) fail(`npm pack did not return a tarball filename:\n${output}`);
+  return path.join(repoRoot, tarball);
+}
+
+async function getFreePort() {
+  const server = createServer();
+  return await new Promise<number>((resolve, reject) => {
+    server.once('error', reject);
+    server.listen(0, '127.0.0.1', () => {
+      const address = server.address();
+      if (!address || typeof address === 'string') {
+        server.close();
+        reject(new Error('Failed to allocate free port'));
+        return;
+      }
+      const { port } = address;
+      server.close((error) => {
+        if (error) reject(error);
+        else resolve(port);
+      });
+    });
+  });
+}
+
+async function waitForHealth(url: string, timeoutMs: number) {
+  const deadline = Date.now() + timeoutMs;
+  let lastError = 'health check did not succeed';
+
+  while (Date.now() < deadline) {
+    try {
+      const response = await fetch(url);
+      if (response.ok) return;
+      lastError = `health check returned ${response.status}`;
+    } catch (error) {
+      lastError = error instanceof Error ? error.message : String(error);
+    }
+    await new Promise((resolve) => setTimeout(resolve, 250));
+  }
+
+  fail(`OpenCode server did not become healthy: ${lastError}`);
+}
+
+async function stopProcess(child: ReturnType<typeof spawn>) {
+  if (child.exitCode !== null) return;
+
+  child.kill('SIGTERM');
+  const exited = await Promise.race([
+    new Promise<boolean>((resolve) => child.once('exit', () => resolve(true))),
+    new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 5000)),
+  ]);
+
+  if (!exited && child.exitCode === null) {
+    child.kill('SIGKILL');
+    await new Promise((resolve) => child.once('exit', resolve));
+  }
+}
+
+function assertNoPluginLoadErrors(logs: string) {
+  const badPatterns = [
+    /failed to load plugin/i,
+    /cannot find module/i,
+    /error=.*failed to load plugin/i,
+  ];
+
+  const match = badPatterns.find((pattern) => pattern.test(logs));
+  if (!match) return;
+
+  const relevantLines = logs
+    .split(/\r?\n/)
+    .filter((line) =>
+      /plugin|failed to load|cannot find module|error=/i.test(line),
+    )
+    .slice(-20)
+    .join('\n');
+
+  fail(
+    `OpenCode logs contain plugin load errors:${relevantLines ? `\n${relevantLines}` : ''}`,
+  );
+}
+
+async function verifyHostSmoke(tarballPath: string) {
+  const tempRoot = mkdtempSync(path.join(tmpdir(), 'omos-opencode-smoke-'));
+  const homeDir = path.join(tempRoot, 'home');
+  const configDir = path.join(tempRoot, 'config');
+  const cacheDir = path.join(tempRoot, 'cache');
+  const dataDir = path.join(tempRoot, 'data');
+  const hostDir = path.join(tempRoot, 'host');
+  const workspaceDir = path.join(tempRoot, 'workspace');
+  const tarballTarget = path.join(tempRoot, path.basename(tarballPath));
+  const port = await getFreePort();
+
+  try {
+    console.log('Packing plugin tarball into isolated test root...');
+    copyFileSync(tarballPath, tarballTarget);
+
+    for (const dir of [
+      homeDir,
+      configDir,
+      cacheDir,
+      dataDir,
+      hostDir,
+      workspaceDir,
+    ]) {
+      mkdirSync(dir, { recursive: true });
+    }
+
+    const pluginDir = path.join(configDir, 'plugins');
+    mkdirSync(pluginDir, { recursive: true });
+
+    writeFileSync(
+      path.join(hostDir, 'package.json'),
+      JSON.stringify(
+        { name: 'verify-opencode-host-smoke', private: true },
+        null,
+        2,
+      ),
+    );
+
+    console.log('Installing opencode-ai into isolated test root...');
+    run('bun', ['add', 'opencode-ai@latest'], { cwd: hostDir });
+
+    const opencodeBin = path.join(hostDir, 'node_modules', '.bin', 'opencode');
+    if (!existsSync(opencodeBin)) {
+      fail(`Expected opencode binary at ${opencodeBin}`);
+    }
+
+    writeFileSync(
+      path.join(configDir, 'package.json'),
+      JSON.stringify(
+        {
+          type: 'module',
+          dependencies: {
+            'oh-my-opencode-slim': `file:${tarballTarget}`,
+          },
+        },
+        null,
+        2,
+      ),
+    );
+    writeFileSync(
+      path.join(pluginDir, 'load-oh-my-opencode-slim.js'),
+      "export { default } from 'oh-my-opencode-slim';\n",
+    );
+
+    const config = JSON.stringify({
+      $schema: 'https://opencode.ai/config.json',
+      autoupdate: false,
+      share: 'disabled',
+      snapshot: false,
+    });
+
+    const env = {
+      HOME: homeDir,
+      XDG_CONFIG_HOME: configDir,
+      XDG_CACHE_HOME: cacheDir,
+      XDG_DATA_HOME: dataDir,
+      OPENCODE_CONFIG_DIR: configDir,
+      OPENCODE_CONFIG_CONTENT: config,
+      OPENCODE_DISABLE_AUTOUPDATE: 'true',
+      OPENCODE_DISABLE_MODELS_FETCH: 'true',
+      OPENCODE_DISABLE_DEFAULT_PLUGINS: 'true',
+    };
+
+    console.log('Starting opencode serve with packaged plugin...');
+    const child = spawn(
+      opencodeBin,
+      [
+        'serve',
+        '--print-logs',
+        '--log-level',
+        'DEBUG',
+        '--hostname',
+        '127.0.0.1',
+        '--port',
+        String(port),
+      ],
+      {
+        cwd: workspaceDir,
+        env: {
+          ...process.env,
+          ...env,
+        },
+        stdio: ['ignore', 'pipe', 'pipe'],
+      },
+    );
+
+    let stdout = '';
+    let stderr = '';
+    child.stdout?.on('data', (chunk) => {
+      stdout += String(chunk);
+    });
+    child.stderr?.on('data', (chunk) => {
+      stderr += String(chunk);
+    });
+
+    const exitPromise = new Promise<never>((_, reject) => {
+      child.once('exit', (code, signal) => {
+        reject(
+          new Error(
+            `opencode serve exited before smoke test completed (code=${code}, signal=${signal})\n${stdout}\n${stderr}`,
+          ),
+        );
+      });
+    });
+
+    await Promise.race([
+      waitForHealth(`http://127.0.0.1:${port}/health`, 30000),
+      exitPromise,
+    ]);
+
+    await new Promise((resolve) => setTimeout(resolve, 1500));
+    assertNoPluginLoadErrors(`${stdout}\n${stderr}`);
+
+    await stopProcess(child);
+  } finally {
+    rmSync(tempRoot, { recursive: true, force: true });
+  }
+}
+
+function cleanupTarball(tarballPath: string) {
+  rmSync(tarballPath, { force: true });
+}
+
+async function main() {
+  if (!existsSync(distEntry)) {
+    fail(
+      'dist/index.js is missing. Run `bun run build` before verify:host-smoke.',
+    );
+  }
+
+  const tarballPath = packArtifact();
+  try {
+    await verifyHostSmoke(tarballPath);
+  } finally {
+    cleanupTarball(tarballPath);
+  }
+
+  console.log('OpenCode host smoke verification passed.');
+}
+
+await main();
+process.exit(0);

+ 195 - 0
scripts/verify-release-artifact.ts

@@ -0,0 +1,195 @@
+import { spawnSync } from 'node:child_process';
+import {
+  copyFileSync,
+  mkdirSync,
+  mkdtempSync,
+  readdirSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(__dirname, '..');
+const distDir = path.join(repoRoot, 'dist');
+
+const suspiciousPathPatterns = [
+  /\/Users\/[^\s'"`]+(?:node_modules|oh-my-opencode-slim)[^\s'"`]*/,
+  /\/home\/[^\s'"`]+(?:node_modules|oh-my-opencode-slim)[^\s'"`]*/,
+];
+
+const packagedRequiredFiles = [
+  'package.json',
+  'README.md',
+  'LICENSE',
+  'dist/index.js',
+  'dist/index.d.ts',
+  'dist/cli/index.js',
+  'oh-my-opencode-slim.schema.json',
+  'src/skills/cartography/SKILL.md',
+];
+
+function fail(message: string): never {
+  throw new Error(message);
+}
+
+function run(command: string, args: string[], options: { cwd?: string } = {}) {
+  const result = spawnSync(command, args, {
+    cwd: options.cwd ?? repoRoot,
+    encoding: 'utf8',
+    stdio: ['ignore', 'pipe', 'pipe'],
+  });
+
+  if (result.status !== 0) {
+    const detail = [result.stdout, result.stderr].filter(Boolean).join('\n');
+    fail(
+      `Command failed: ${command} ${args.join(' ')}${detail ? `\n${detail}` : ''}`,
+    );
+  }
+
+  return result.stdout.trim();
+}
+
+function parsePackJson(output: string) {
+  const start = output.indexOf('[');
+  const end = output.lastIndexOf(']');
+
+  if (start === -1 || end === -1 || end < start) {
+    fail(`Could not locate npm pack JSON output:\n${output}`);
+  }
+
+  return JSON.parse(output.slice(start, end + 1)) as Array<{
+    filename?: string;
+    files?: Array<{ path: string }>;
+  }>;
+}
+
+function walkFiles(dir: string): string[] {
+  const entries = readdirSync(dir, { withFileTypes: true });
+  return entries.flatMap((entry) => {
+    const fullPath = path.join(dir, entry.name);
+    if (entry.isDirectory()) return walkFiles(fullPath);
+    return [fullPath];
+  });
+}
+
+function verifyDistHasNoLeakedPaths() {
+  console.log('Checking dist for leaked machine paths...');
+  const files = walkFiles(distDir).filter((file) =>
+    /\.(?:js|d\.ts|map|json)$/.test(file),
+  );
+
+  const leaks: string[] = [];
+  for (const file of files) {
+    const content = readFileSync(file, 'utf8');
+    for (const pattern of suspiciousPathPatterns) {
+      const match = content.match(pattern);
+      if (!match) continue;
+      leaks.push(`${path.relative(repoRoot, file)}: ${match[0]}`);
+    }
+  }
+
+  if (leaks.length > 0) {
+    fail(
+      `Built artifact contains machine-specific paths:\n${leaks.join('\n')}`,
+    );
+  }
+}
+
+function packArtifact() {
+  console.log('Packing npm artifact...');
+  const output = run('npm', ['pack', '--json', '--ignore-scripts'], {
+    cwd: repoRoot,
+  });
+  const parsed = parsePackJson(output);
+  const tarball = parsed[0]?.filename;
+
+  if (!tarball) {
+    fail(`npm pack did not return a tarball filename:\n${output}`);
+  }
+
+  const packagedFiles = new Set(
+    (parsed[0]?.files ?? []).map((file) => file.path),
+  );
+  for (const requiredFile of packagedRequiredFiles) {
+    if (!packagedFiles.has(requiredFile)) {
+      fail(`npm pack artifact is missing required file: ${requiredFile}`);
+    }
+  }
+
+  return path.join(repoRoot, tarball);
+}
+
+function verifyFreshInstall(tarballPath: string) {
+  const tempRoot = mkdtempSync(path.join(tmpdir(), 'omos-release-'));
+
+  try {
+    console.log('Installing packed artifact into clean temp project...');
+    const installDir = path.join(tempRoot, 'install');
+    const tarballTarget = path.join(tempRoot, path.basename(tarballPath));
+
+    copyFileSync(tarballPath, tarballTarget);
+    mkdirSync(installDir, { recursive: true });
+    writeFileSync(
+      path.join(installDir, 'package.json'),
+      JSON.stringify(
+        { name: 'verify-release-artifact', private: true },
+        null,
+        2,
+      ),
+    );
+    run('bun', ['add', '--ignore-scripts', tarballTarget], {
+      cwd: installDir,
+    });
+
+    const installedEntry = path.join(
+      installDir,
+      'node_modules',
+      'oh-my-opencode-slim',
+      'dist',
+      'index.js',
+    );
+    const installedEntryContent = readFileSync(installedEntry, 'utf8');
+    for (const pattern of suspiciousPathPatterns) {
+      const match = installedEntryContent.match(pattern);
+      if (match) {
+        fail(
+          `Installed package still contains machine-specific path: ${match[0]}`,
+        );
+      }
+    }
+
+    const smokeScript = [
+      "import pkg from 'oh-my-opencode-slim';",
+      "if (typeof pkg !== 'function') throw new Error('default export is not a function');",
+      "console.log('package loads');",
+      'process.exit(0);',
+    ].join('\n');
+    console.log('Importing installed package entrypoint...');
+    run('node', ['--input-type=module', '--eval', smokeScript], {
+      cwd: installDir,
+    });
+  } finally {
+    rmSync(tempRoot, { recursive: true, force: true });
+  }
+}
+
+function cleanupTarball(tarballPath: string) {
+  rmSync(tarballPath, { force: true });
+}
+
+function main() {
+  verifyDistHasNoLeakedPaths();
+  const tarballPath = packArtifact();
+  try {
+    verifyFreshInstall(tarballPath);
+  } finally {
+    cleanupTarball(tarballPath);
+  }
+  console.log('Release artifact verification passed.');
+}
+
+main();

+ 203 - 0
src/agents/display-name.test.ts

@@ -0,0 +1,203 @@
+import { describe, expect, test } from 'bun:test';
+import type { PluginConfig } from '../config';
+import { createAgents, getAgentConfigs } from './index';
+
+describe('displayName', () => {
+  test('stores displayName on agent when configured', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: 'researcher' },
+      },
+    };
+
+    const agents = createAgents(config);
+    const explorer = agents.find((a) => a.name === 'explorer');
+    expect(explorer?.displayName).toBe('researcher');
+
+    const sdkConfigs = getAgentConfigs(config);
+    expect((sdkConfigs.explorer as { displayName?: string }).displayName).toBe(
+      'researcher',
+    );
+  });
+
+  test('injects configured displayName into orchestrator prompt mentions', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: 'researcher' },
+      },
+    };
+
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    const prompt = orchestrator?.config.prompt ?? '';
+
+    expect(prompt).toContain('@researcher');
+    expect(prompt).not.toMatch(/@explorer\b/);
+  });
+
+  test('normalizes @-prefixed displayName in prompt injection', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: '@researcher' },
+      },
+    };
+
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    const prompt = orchestrator?.config.prompt ?? '';
+
+    expect(prompt).toContain('@researcher');
+    expect(prompt).not.toContain('@@researcher');
+    expect(prompt).not.toMatch(/@explorer\b/);
+  });
+
+  test('normalizes whitespace-padded displayName in prompt injection', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: '  researcher  ' },
+      },
+    };
+
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    const prompt = orchestrator?.config.prompt ?? '';
+
+    expect(prompt).toContain('@researcher');
+    expect(prompt).not.toContain('@ researcher ');
+    expect(prompt).not.toMatch(/@explorer\b/);
+  });
+
+  test('throws when duplicate displayName is assigned', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: 'helper' },
+        librarian: { displayName: 'helper' },
+      },
+    };
+
+    expect(() => createAgents(config)).toThrow(
+      "Duplicate displayName 'helper' assigned to multiple agents",
+    );
+  });
+
+  test('throws when normalized duplicate displayName is assigned', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: 'advisor' },
+        librarian: { displayName: ' @advisor ' },
+      },
+    };
+
+    expect(() => createAgents(config)).toThrow(
+      "Duplicate displayName 'advisor' assigned to multiple agents",
+    );
+  });
+
+  test('throws when displayName conflicts with internal agent name', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: 'oracle' },
+      },
+    };
+
+    expect(() => createAgents(config)).toThrow(
+      "displayName 'oracle' conflicts with internal agent name",
+    );
+  });
+
+  test('throws when normalized displayName conflicts with internal agent name', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: ' @oracle ' },
+      },
+    };
+
+    expect(() => createAgents(config)).toThrow(
+      "displayName 'oracle' conflicts with internal agent name",
+    );
+  });
+
+  test('throws when orchestrator displayName conflicts with internal agent name', () => {
+    const config: PluginConfig = {
+      agents: {
+        orchestrator: { displayName: 'oracle' },
+      },
+    };
+
+    expect(() => createAgents(config)).toThrow(
+      /displayName.*conflicts with internal agent name/,
+    );
+  });
+
+  test('resolves legacy alias for explorer displayName override', () => {
+    const config: PluginConfig = {
+      agents: {
+        explore: { displayName: 'researcher' },
+      },
+    };
+
+    const agents = createAgents(config);
+    const explorer = agents.find((a) => a.name === 'explorer');
+
+    expect(explorer?.displayName).toBe('researcher');
+  });
+
+  test('uses displayName as host-facing registry key with hidden internal alias', () => {
+    const config: PluginConfig = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    };
+
+    const sdkConfigs = getAgentConfigs(config) as Record<
+      string,
+      { hidden?: boolean; mode?: string }
+    >;
+
+    expect(sdkConfigs.advisor).toBeDefined();
+    expect(sdkConfigs.advisor.mode).toBe('subagent');
+    expect(sdkConfigs.advisor.hidden).toBeUndefined();
+
+    expect(sdkConfigs.oracle).toBeDefined();
+    expect(sdkConfigs.oracle.mode).toBe('subagent');
+    expect(sdkConfigs.oracle.hidden).toBe(true);
+  });
+
+  test('uses orchestrator displayName as host-facing key with hidden internal alias', () => {
+    const config: PluginConfig = {
+      agents: {
+        orchestrator: { displayName: 'engineer' },
+      },
+    };
+
+    const sdkConfigs = getAgentConfigs(config) as Record<
+      string,
+      { hidden?: boolean; mode?: string }
+    >;
+
+    expect(sdkConfigs.engineer).toBeDefined();
+    expect(sdkConfigs.engineer.mode).toBe('primary');
+    expect(sdkConfigs.engineer.hidden).toBeUndefined();
+
+    expect(sdkConfigs.orchestrator).toBeDefined();
+    expect(sdkConfigs.orchestrator.mode).toBe('primary');
+    expect(sdkConfigs.orchestrator.hidden).toBe(true);
+  });
+
+  test('keeps internal-only council agents hidden even with displayName configured', () => {
+    const config: PluginConfig = {
+      disabled_agents: [],
+      agents: {
+        councillor: { displayName: 'reviewer' },
+        'council-master': { displayName: 'arbiter' },
+      },
+    };
+
+    const sdkConfigs = getAgentConfigs(config);
+
+    expect(sdkConfigs.reviewer).toBeUndefined();
+    expect(sdkConfigs.arbiter).toBeUndefined();
+    expect(sdkConfigs.councillor?.hidden).toBe(true);
+    expect(sdkConfigs['council-master']?.hidden).toBe(true);
+  });
+});

+ 118 - 25
src/agents/index.ts

@@ -32,6 +32,11 @@ type AgentFactory = (
   customAppendPrompt?: string,
 ) => AgentDefinition;
 
+function normalizeDisplayName(displayName: string): string {
+  const trimmed = displayName.trim();
+  return trimmed.startsWith('@') ? trimmed.slice(1) : trimmed;
+}
+
 // Agent Configuration Helpers
 
 /**
@@ -63,6 +68,27 @@ function applyOverrides(
       ...override.options,
     };
   }
+  if (override.displayName) {
+    agent.displayName = override.displayName;
+  }
+}
+
+function injectDisplayNames(
+  orchestrator: AgentDefinition,
+  nameMap: Map<string, string>,
+): void {
+  if (nameMap.size === 0) return;
+  let prompt = orchestrator.config.prompt;
+  if (!prompt) return;
+
+  for (const [internalName, displayName] of nameMap) {
+    prompt = prompt.replace(
+      new RegExp(`@${internalName}\\b`, 'g'),
+      `@${normalizeDisplayName(displayName)}`,
+    );
+  }
+
+  orchestrator.config.prompt = prompt;
 }
 
 /**
@@ -207,6 +233,39 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
     applyOverrides(orchestrator, orchestratorOverride);
   }
 
+  // Collect all display names from orchestrator and all subagents
+  const displayNameMap = new Map<string, string>();
+  if (orchestrator.displayName) {
+    displayNameMap.set('orchestrator', orchestrator.displayName);
+  }
+  for (const agent of allSubAgents) {
+    if (agent.displayName) {
+      displayNameMap.set(agent.name, agent.displayName);
+    }
+  }
+
+  // Validate display names
+  const usedDisplayNames = new Set<string>();
+  for (const [, displayName] of displayNameMap) {
+    const normalizedDisplayName = normalizeDisplayName(displayName);
+    if (usedDisplayNames.has(normalizedDisplayName)) {
+      throw new Error(
+        `Duplicate displayName '${normalizedDisplayName}' assigned to multiple agents`,
+      );
+    }
+    usedDisplayNames.add(normalizedDisplayName);
+  }
+  for (const displayName of usedDisplayNames) {
+    if ((ALL_AGENT_NAMES as readonly string[]).includes(displayName)) {
+      throw new Error(
+        `displayName '${displayName}' conflicts with internal agent name`,
+      );
+    }
+  }
+
+  // Inject display names into orchestrator prompt (complete map)
+  injectDisplayNames(orchestrator, displayNameMap);
+
   return [orchestrator, ...allSubAgents];
 }
 
@@ -221,32 +280,66 @@ export function getAgentConfigs(
   config?: PluginConfig,
 ): Record<string, SDKAgentConfig> {
   const agents = createAgents(config);
-  return Object.fromEntries(
-    agents.map((a) => {
-      const sdkConfig: SDKAgentConfig & { mcps?: string[] } = {
-        ...a.config,
-        description: a.description,
-        mcps: getAgentMcpList(a.name, config),
-      };
-
-      // Apply classification-based visibility and mode
-      if (a.name === 'council') {
-        // Council is callable both as a primary agent (user-facing)
-        // and as a subagent (orchestrator can delegate to it)
-        sdkConfig.mode = 'all';
-      } else if (a.name === 'councillor' || a.name === 'council-master') {
-        // Internal agents — subagent mode, hidden from @ autocomplete
-        sdkConfig.mode = 'subagent';
-        sdkConfig.hidden = true;
-      } else if (isSubagent(a.name)) {
-        sdkConfig.mode = 'subagent';
-      } else if (a.name === 'orchestrator') {
-        sdkConfig.mode = 'primary';
-      }
 
-      return [a.name, sdkConfig];
-    }),
-  );
+  const applyClassification = (
+    name: string,
+    sdkConfig: SDKAgentConfig & {
+      mcps?: string[];
+      displayName?: string;
+      hidden?: boolean;
+    },
+  ): void => {
+    if (name === 'council') {
+      // Council is callable both as a primary agent (user-facing)
+      // and as a subagent (orchestrator can delegate to it)
+      sdkConfig.mode = 'all';
+    } else if (name === 'councillor' || name === 'council-master') {
+      // Internal agents — subagent mode, hidden from @ autocomplete
+      sdkConfig.mode = 'subagent';
+      sdkConfig.hidden = true;
+    } else if (isSubagent(name)) {
+      sdkConfig.mode = 'subagent';
+    } else if (name === 'orchestrator') {
+      sdkConfig.mode = 'primary';
+    }
+  };
+
+  const isInternalOnly = (name: string): boolean =>
+    name === 'councillor' || name === 'council-master';
+
+  const entries: Array<[string, SDKAgentConfig]> = [];
+
+  for (const a of agents) {
+    const sdkConfig: SDKAgentConfig & {
+      mcps?: string[];
+      displayName?: string;
+      hidden?: boolean;
+    } = {
+      ...a.config,
+      description: a.description,
+      mcps: getAgentMcpList(a.name, config),
+    };
+
+    if (a.displayName) {
+      sdkConfig.displayName = a.displayName;
+    }
+
+    applyClassification(a.name, sdkConfig);
+
+    const normalizedDisplayName = a.displayName
+      ? normalizeDisplayName(a.displayName)
+      : undefined;
+
+    if (normalizedDisplayName && !isInternalOnly(a.name)) {
+      entries.push([normalizedDisplayName, sdkConfig]);
+      entries.push([a.name, { ...sdkConfig, hidden: true }]);
+      continue;
+    }
+
+    entries.push([a.name, sdkConfig]);
+  }
+
+  return Object.fromEntries(entries);
 }
 
 /**

+ 1 - 0
src/agents/orchestrator.ts

@@ -2,6 +2,7 @@ import type { AgentConfig } from '@opencode-ai/sdk/v2';
 
 export interface AgentDefinition {
   name: string;
+  displayName?: string;
   description?: string;
   config: AgentConfig;
   /** Priority-ordered model entries for runtime fallback resolution. */

+ 19 - 0
src/background/background-manager.test.ts

@@ -1,4 +1,5 @@
 import { describe, expect, mock, test } from 'bun:test';
+import type { PluginConfig } from '../config';
 import { SLIM_INTERNAL_INITIATOR_MARKER } from '../utils';
 import { BackgroundTaskManager } from './background-manager';
 
@@ -166,6 +167,24 @@ describe('BackgroundTaskManager', () => {
       expect(['pending', 'starting']).toContain(task2.status);
       expect(['pending', 'starting']).toContain(task3.status);
     });
+
+    test('resolves displayName alias to internal agent name on launch', () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx, undefined, {
+        agents: {
+          oracle: { displayName: 'advisor' },
+        },
+      });
+
+      const task = manager.launch({
+        agent: 'advisor',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'parent-123',
+      });
+
+      expect(task.agent).toBe('oracle');
+    });
   });
 
   describe('handleSessionStatus', () => {

+ 5 - 2
src/background/background-manager.ts

@@ -26,6 +26,7 @@ import {
   applyAgentVariant,
   createInternalAgentTextPart,
   resolveAgentVariant,
+  resolveRuntimeAgentName,
 } from '../utils';
 import { log } from '../utils/logger';
 import {
@@ -185,11 +186,13 @@ export class BackgroundTaskManager {
    * @returns The created background task with pending status
    */
   launch(opts: LaunchOptions): BackgroundTask {
+    const resolvedAgent = resolveRuntimeAgentName(this.config, opts.agent);
+
     const task: BackgroundTask = {
       id: generateTaskId(),
       sessionId: undefined,
       description: opts.description,
-      agent: opts.agent,
+      agent: resolvedAgent,
       status: 'pending',
       startedAt: new Date(),
       config: {
@@ -205,7 +208,7 @@ export class BackgroundTaskManager {
     this.enqueueStart(task);
 
     log(`[background-manager] task launched: ${task.id}`, {
-      agent: opts.agent,
+      agent: resolvedAgent,
       description: opts.description,
     });
 

+ 3 - 0
src/cli/config-io.test.ts

@@ -216,6 +216,9 @@ describe('config-io', () => {
     expect(result.success).toBe(true);
 
     const saved = JSON.parse(readFileSync(litePath, 'utf-8'));
+    expect(saved.$schema).toBe(
+      'https://unpkg.com/oh-my-opencode-slim@latest/oh-my-opencode-slim.schema.json',
+    );
     expect(saved.preset).toBe('openai');
     expect(saved.presets.openai).toBeDefined();
     expect(saved.tmux.enabled).toBe(true);

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

@@ -14,8 +14,12 @@ describe('providers', () => {
       hasTmux: false,
       installSkills: false,
       installCustomSkills: false,
+      reset: false,
     });
 
+    expect(config.$schema).toBe(
+      'https://unpkg.com/oh-my-opencode-slim@latest/oh-my-opencode-slim.schema.json',
+    );
     expect(config.preset).toBe('openai');
     const agents = (config.presets as any).openai;
     expect(agents).toBeDefined();
@@ -30,6 +34,7 @@ describe('providers', () => {
       hasTmux: false,
       installSkills: false,
       installCustomSkills: false,
+      reset: false,
     });
 
     const agents = (config.presets as any).openai;
@@ -51,6 +56,7 @@ describe('providers', () => {
       hasTmux: true,
       installSkills: false,
       installCustomSkills: false,
+      reset: false,
     });
 
     expect(config.tmux).toBeDefined();
@@ -63,6 +69,7 @@ describe('providers', () => {
       hasTmux: false,
       installSkills: true,
       installCustomSkills: false,
+      reset: false,
     });
 
     const agents = (config.presets as any).openai;
@@ -81,6 +88,7 @@ describe('providers', () => {
       hasTmux: false,
       installSkills: false,
       installCustomSkills: false,
+      reset: false,
     });
 
     const agents = (config.presets as any).openai;
@@ -95,6 +103,7 @@ describe('providers', () => {
       hasTmux: false,
       installSkills: false,
       installCustomSkills: false,
+      reset: false,
     });
 
     const agents = (config.presets as any).openai;

+ 4 - 0
src/cli/providers.ts

@@ -2,6 +2,9 @@ import { DEFAULT_AGENT_MCPS } from '../config/agent-mcps';
 import { RECOMMENDED_SKILLS } from './skills';
 import type { InstallConfig } from './types';
 
+const SCHEMA_URL =
+  'https://unpkg.com/oh-my-opencode-slim@latest/oh-my-opencode-slim.schema.json';
+
 // Model mappings by provider - only 4 supported providers
 export const MODEL_MAPPINGS = {
   openai: {
@@ -45,6 +48,7 @@ export function generateLiteConfig(
   installConfig: InstallConfig,
 ): Record<string, unknown> {
   const config: Record<string, unknown> = {
+    $schema: SCHEMA_URL,
     preset: 'openai',
     presets: {},
   };

+ 1 - 0
src/config/schema.ts

@@ -99,6 +99,7 @@ export const AgentOverrideConfigSchema = z.object({
   skills: z.array(z.string()).optional(), // skills this agent can use ("*" = all, "!item" = exclude)
   mcps: z.array(z.string()).optional(), // MCPs this agent can use ("*" = all, "!item" = exclude)
   options: z.record(z.string(), z.unknown()).optional(), // provider-specific model options (e.g., textVerbosity, thinking budget)
+  displayName: z.string().min(1).optional(),
 });
 
 // Multiplexer type options

+ 93 - 26
src/hooks/image-hook.ts

@@ -3,14 +3,15 @@ import {
   existsSync,
   mkdirSync,
   readdirSync,
+  rmdirSync,
   statSync,
   unlinkSync,
   writeFileSync,
 } from 'node:fs';
-import { join } from 'node:path';
+import { basename, extname, join } from 'node:path';
 
-// Debounce: only run cleanup every 10 minutes
-let lastCleanup = 0;
+// Debounce: only run cleanup every 10 minutes per directory
+const lastCleanupByDir = new Map<string, number>();
 const CLEANUP_INTERVAL = 10 * 60 * 1000; // 10 minutes
 
 interface ImagePart {
@@ -66,6 +67,71 @@ function extFromMime(mime: string): string {
   return map[mime] ?? '.png';
 }
 
+function sanitizeFilename(name: string): string {
+  return name.replace(/[^a-zA-Z0-9._-]/g, '_');
+}
+
+function cleanupOldImages(dir: string, saveDir: string): void {
+  const now = Date.now();
+  const lastCleanup = lastCleanupByDir.get(dir) ?? 0;
+  if (now - lastCleanup < CLEANUP_INTERVAL) return;
+  lastCleanupByDir.set(dir, now);
+
+  try {
+    const maxAge = 60 * 60 * 1000;
+    for (const f of readdirSync(dir)) {
+      const fp = join(dir, f);
+      try {
+        if (now - statSync(fp).mtimeMs > maxAge) unlinkSync(fp);
+      } catch {}
+    }
+    // Remove empty session subdirectory and prune its debounce entry
+    if (dir !== saveDir) {
+      try {
+        rmdirSync(dir);
+        lastCleanupByDir.delete(dir);
+      } catch {}
+    }
+  } catch {}
+}
+
+function writeUniqueFile(
+  dir: string,
+  name: string,
+  data: Buffer,
+  log: (msg: string) => void,
+): string | null {
+  const ext = extname(name);
+  const base = basename(name, ext) || name;
+  let candidate = join(dir, name);
+  let counter = 0;
+
+  const MAX_ATTEMPTS = 1000;
+  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
+    try {
+      writeFileSync(candidate, data, { flag: 'wx' });
+      return candidate;
+    } catch (e) {
+      if (
+        e instanceof Error &&
+        (e as NodeJS.ErrnoException).code === 'EEXIST'
+      ) {
+        counter += 1;
+        candidate = join(dir, `${base}-${counter}${ext}`);
+        continue;
+      }
+
+      log(`[image-hook] failed to save image: ${e}`);
+      return null;
+    }
+  }
+
+  log(
+    `[image-hook] failed to save image: max attempts (${MAX_ATTEMPTS}) reached`,
+  );
+  return null;
+}
+
 export function processImageAttachments(args: {
   messages: MessageWithParts[];
   workDir: string;
@@ -88,26 +154,23 @@ export function processImageAttachments(args: {
     log(`[image-hook] failed to create image directory: ${e}`);
   }
 
-  // Clean up images older than 1 hour (debounced: only check every 10 minutes)
-  const now = Date.now();
-  if (now - lastCleanup > CLEANUP_INTERVAL) {
-    lastCleanup = now;
-    try {
-      const maxAge = 60 * 60 * 1000;
-      for (const f of readdirSync(saveDir)) {
-        const fp = join(saveDir, f);
-        try {
-          if (now - statSync(fp).mtimeMs > maxAge) unlinkSync(fp);
-        } catch {}
-      }
-    } catch {}
-  }
-
   for (const msg of messages) {
     if (msg.info.role !== 'user') continue;
     const imageParts = msg.parts.filter(isImagePart);
     if (imageParts.length === 0) continue;
 
+    const sessionSubdir = msg.info.sessionID
+      ? sanitizeFilename(msg.info.sessionID)
+      : undefined;
+    const targetDir = sessionSubdir ? join(saveDir, sessionSubdir) : saveDir;
+    try {
+      mkdirSync(targetDir, { recursive: true });
+    } catch (e) {
+      log(`[image-hook] failed to create target image directory: ${e}`);
+    }
+
+    cleanupOldImages(targetDir, saveDir);
+
     // Save each image to .opencode/images/ and collect paths
     const savedPaths: string[] = [];
     for (const p of imageParts) {
@@ -121,14 +184,18 @@ export function processImageAttachments(args: {
             .update(decoded.data)
             .digest('hex')
             .slice(0, 8);
-          const name = filename ?? `image-${hash}${extFromMime(decoded.mime)}`;
-          const filePath = join(saveDir, name);
-          try {
-            writeFileSync(filePath, decoded.data);
-            savedPaths.push(filePath);
-          } catch (e) {
-            log(`[image-hook] failed to save image: ${e}`);
-          }
+          const sanitizedFilename = filename
+            ? sanitizeFilename(filename)
+            : undefined;
+          const baseName = sanitizedFilename
+            ? sanitizedFilename.replace(/\.[^.]+$/, '') || 'image'
+            : 'image';
+          const ext = sanitizedFilename
+            ? extname(sanitizedFilename) || extFromMime(decoded.mime)
+            : extFromMime(decoded.mime);
+          const name = `${baseName}-${hash}${ext}`;
+          const filePath = writeUniqueFile(targetDir, name, decoded.data, log);
+          if (filePath) savedPaths.push(filePath);
         }
       }
     }

+ 26 - 1
src/index.ts

@@ -34,6 +34,7 @@ import {
   lsp_rename,
   setUserLspConfig,
 } from './tools';
+import { resolveRuntimeAgentName, rewriteDisplayNameMentions } from './utils';
 import { initLogger, log } from './utils/logger';
 
 const OhMyOpenCodeLite: Plugin = async (ctx) => {
@@ -529,7 +530,19 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       input: { sessionID: string; agent?: string },
       output?: { message?: { agent?: string } },
     ) => {
-      const agent = input.agent ?? output?.message?.agent;
+      const rawAgent = input.agent ?? output?.message?.agent;
+      const agent = rawAgent
+        ? resolveRuntimeAgentName(config, rawAgent)
+        : undefined;
+
+      if (
+        agent &&
+        output?.message &&
+        typeof output.message.agent === 'string'
+      ) {
+        output.message.agent = agent;
+      }
+
       if (agent) {
         sessionAgentMap.set(input.sessionID, agent);
       }
@@ -595,6 +608,18 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         }>;
       };
 
+      for (const message of typedOutput.messages) {
+        if (message.info.role !== 'user') {
+          continue;
+        }
+        for (const part of message.parts) {
+          if (part.type !== 'text' || typeof part.text !== 'string') {
+            continue;
+          }
+          part.text = rewriteDisplayNameMentions(config, part.text);
+        }
+      }
+
       // Strip image parts from orchestrator messages when @observer is available.
       // When the orchestrator's model doesn't support image input, the API call
       // fails before the LLM can respond. We replace image bytes with a text

+ 100 - 0
src/tools/background.test.ts

@@ -0,0 +1,100 @@
+import { describe, expect, mock, test } from 'bun:test';
+import type { PluginConfig } from '../config';
+import { createBackgroundTools } from './background';
+
+function createMockManager() {
+  return {
+    isAgentAllowed: mock(() => true),
+    getAllowedSubagents: mock(() => ['oracle']),
+    launch: mock(
+      (opts: {
+        agent: string;
+        prompt: string;
+        description: string;
+        parentSessionId: string;
+      }) => ({
+        id: 'bg_test1234',
+        sessionId: undefined,
+        description: opts.description,
+        agent: opts.agent,
+        status: 'pending',
+        startedAt: new Date(),
+        config: { maxConcurrentStarts: 10 },
+        parentSessionId: opts.parentSessionId,
+        prompt: opts.prompt,
+      }),
+    ),
+    getResult: mock(() => null),
+    waitForCompletion: mock(async () => null),
+    cancel: mock(() => 0),
+  };
+}
+
+describe('createBackgroundTools displayName runtime aliasing', () => {
+  test('resolves displayName alias for background_task direct invocation', async () => {
+    const manager = createMockManager();
+    const config: PluginConfig = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    };
+
+    const tools = createBackgroundTools(
+      {} as any,
+      manager as any,
+      undefined,
+      config,
+    );
+
+    const result = await tools.background_task.execute(
+      {
+        agent: 'advisor',
+        prompt: 'Analyze this architecture',
+        description: 'Architecture analysis',
+      },
+      { sessionID: 'session-1' } as any,
+    );
+
+    expect(manager.isAgentAllowed).toHaveBeenCalledWith('session-1', 'oracle');
+    expect(manager.launch).toHaveBeenCalledWith({
+      agent: 'oracle',
+      prompt: 'Analyze this architecture',
+      description: 'Architecture analysis',
+      parentSessionId: 'session-1',
+    });
+    expect(result).toContain('Agent: oracle');
+  });
+
+  test('keeps internal agent names working for background_task', async () => {
+    const manager = createMockManager();
+    const config: PluginConfig = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    };
+
+    const tools = createBackgroundTools(
+      {} as any,
+      manager as any,
+      undefined,
+      config,
+    );
+
+    await tools.background_task.execute(
+      {
+        agent: 'oracle',
+        prompt: 'Analyze this architecture',
+        description: 'Architecture analysis',
+      },
+      { sessionID: 'session-1' } as any,
+    );
+
+    expect(manager.isAgentAllowed).toHaveBeenCalledWith('session-1', 'oracle');
+    expect(manager.launch).toHaveBeenCalledWith({
+      agent: 'oracle',
+      prompt: 'Analyze this architecture',
+      description: 'Architecture analysis',
+      parentSessionId: 'session-1',
+    });
+  });
+});

+ 2 - 1
src/tools/background.ts

@@ -8,6 +8,7 @@ import type { BackgroundTaskManager } from '../background';
 import type { PluginConfig } from '../config';
 import { SUBAGENT_NAMES } from '../config';
 import type { MultiplexerConfig } from '../config/schema';
+import { resolveRuntimeAgentName } from '../utils';
 
 const z = tool.schema;
 
@@ -55,7 +56,7 @@ Key behaviors:
         throw new Error('Invalid toolContext: missing sessionID');
       }
 
-      const agent = String(args.agent);
+      const agent = resolveRuntimeAgentName(_pluginConfig, String(args.agent));
       const prompt = String(args.prompt);
       const description = String(args.description);
       const parentSessionId = (toolContext as { sessionID: string }).sessionID;

+ 0 - 5
src/tools/grep/aggregate.ts

@@ -159,11 +159,6 @@ export class GrepAggregator {
 
     const fileState = this.getFileState(pathInfo);
 
-    if (this.totalMatches >= this.input.maxResults) {
-      this.limitReached = true;
-      return;
-    }
-
     const before =
       this.input.beforeContext > 0
         ? fileState.beforeBuffer

+ 102 - 50
src/tools/grep/fallback.ts

@@ -37,6 +37,7 @@ import {
 import type {
   GrepContextLine,
   GrepFileMatch,
+  GrepMatch,
   GrepSearchResult,
   NormalizedGrepInput,
 } from './types';
@@ -321,46 +322,36 @@ function toContextLine(record: ParsedContentRecord): GrepContextLine {
   };
 }
 
-function appendContentGroup(
-  files: Map<string, GrepFileMatch>,
-  records: ParsedContentRecord[],
-  input: Pick<NormalizedGrepInput, 'cwd' | 'worktree'>,
+function pushRollingContext(
+  target: GrepContextLine[],
+  line: GrepContextLine,
+  maxItems: number,
 ): void {
-  for (let index = 0; index < records.length; index += 1) {
-    const record = records[index] as ParsedContentRecord;
-    if (!record.isMatch) {
-      continue;
-    }
-
-    const before: GrepContextLine[] = [];
-    const after: GrepContextLine[] = [];
+  if (maxItems <= 0) {
+    return;
+  }
 
-    for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
-      const entry = records[cursor] as ParsedContentRecord;
-      if (entry.isMatch || entry.filePath !== record.filePath) {
-        break;
-      }
-      before.unshift(toContextLine(entry));
-    }
+  target.push(line);
+  if (target.length > maxItems) {
+    target.splice(0, target.length - maxItems);
+  }
+}
 
-    for (let cursor = index + 1; cursor < records.length; cursor += 1) {
-      const entry = records[cursor] as ParsedContentRecord;
-      if (entry.isMatch || entry.filePath !== record.filePath) {
-        break;
-      }
-      after.push(toContextLine(entry));
-    }
+function appendTrailingContext(
+  match: GrepMatch | undefined,
+  line: GrepContextLine,
+  maxItems: number,
+): void {
+  if (!match || maxItems <= 0 || match.after.length >= maxItems) {
+    return;
+  }
 
-    const file = ensureFileMatch(files, record.filePath, input);
-    file.matchCount += 1;
-    file.matches.push({
-      lineNumber: record.lineNumber,
-      lineText: record.text,
-      submatches: [],
-      before,
-      after,
-    });
+  const last = match.after[match.after.length - 1];
+  if (last?.lineNumber === line.lineNumber && last.text === line.text) {
+    return;
   }
+
+  match.after.push(line);
 }
 
 async function consumeNullPrefixedLinesStream(
@@ -439,25 +430,32 @@ async function consumeContentOutput(
 }> {
   const files = new Map<string, GrepFileMatch>();
   const withContext = input.beforeContext > 0 || input.afterContext > 0;
-  let group: ParsedContentRecord[] = [];
   let skippedLines = 0;
   let visibleMatches = 0;
   let limitReached = false;
+  let beforeBuffer: GrepContextLine[] = [];
+  let lastMatch: GrepMatch | undefined;
+  let lastMatchFilePath: string | undefined;
+
+  const resetGroupState = () => {
+    beforeBuffer = [];
+    lastMatch = undefined;
+    lastMatchFilePath = undefined;
+  };
 
-  const stopForLimit = async () => {
+  const stopForLimit = () => {
     limitReached = true;
     killProcess(proc);
   };
 
   await consumeNullPrefixedLinesStream(stdout, (record) => {
     if (withContext && record === '--') {
-      appendContentGroup(files, group, input);
-      group = [];
-      visibleMatches = countVisibleMatches([...files.values()]);
-      if (visibleMatches >= input.maxResults) {
-        void stopForLimit();
+      if (limitReached) {
+        stopForLimit();
         return false;
       }
+
+      resetGroupState();
       return true;
     }
 
@@ -476,7 +474,67 @@ async function consumeContentOutput(
     }
 
     if (withContext) {
-      group.push(parsed);
+      if (lastMatchFilePath && parsed.filePath !== lastMatchFilePath) {
+        if (limitReached) {
+          stopForLimit();
+          return false;
+        }
+
+        resetGroupState();
+      }
+
+      if (parsed.isMatch) {
+        if (limitReached) {
+          stopForLimit();
+          return false;
+        }
+
+        const file = ensureFileMatch(files, parsed.filePath, input);
+        const match: GrepMatch = {
+          lineNumber: parsed.lineNumber,
+          lineText: parsed.text,
+          submatches: [],
+          before:
+            input.beforeContext > 0
+              ? beforeBuffer.slice(-input.beforeContext)
+              : [],
+          after: [],
+        };
+
+        file.matchCount += 1;
+        file.matches.push(match);
+        visibleMatches += 1;
+        lastMatch = match;
+        lastMatchFilePath = parsed.filePath;
+        beforeBuffer = [];
+
+        if (visibleMatches >= input.maxResults) {
+          limitReached = true;
+          if (input.afterContext <= 0) {
+            stopForLimit();
+            return false;
+          }
+        }
+
+        return true;
+      }
+
+      const contextLine = toContextLine(parsed);
+      if (lastMatch && parsed.filePath === lastMatchFilePath) {
+        appendTrailingContext(lastMatch, contextLine, input.afterContext);
+      }
+      pushRollingContext(beforeBuffer, contextLine, input.beforeContext);
+
+      if (
+        limitReached &&
+        (!lastMatch ||
+          parsed.filePath !== lastMatchFilePath ||
+          lastMatch.after.length >= input.afterContext)
+      ) {
+        stopForLimit();
+        return false;
+      }
+
       return true;
     }
 
@@ -491,18 +549,12 @@ async function consumeContentOutput(
     });
     visibleMatches += 1;
     if (visibleMatches >= input.maxResults) {
-      void stopForLimit();
+      stopForLimit();
       return false;
     }
     return true;
   });
 
-  if (group.length > 0) {
-    appendContentGroup(files, group, input);
-    visibleMatches = countVisibleMatches([...files.values()]);
-    limitReached = limitReached || visibleMatches >= input.maxResults;
-  }
-
   return {
     files: [...files.values()],
     skippedLines,

+ 1 - 20
src/tools/grep/runner.ts

@@ -91,26 +91,7 @@ async function resolveCliForExecution(
     throw new AbortWaitError('Search was cancelled before execution started.');
   }
 
-  let abortCleanup: (() => void) | undefined;
-
-  try {
-    return await Promise.race([
-      resolveGrepCliWithAutoInstall({}, signal),
-      new Promise<never>((_, reject) => {
-        const onAbort = () => {
-          reject(
-            new AbortWaitError(
-              'Search was cancelled before execution started.',
-            ),
-          );
-        };
-        signal.addEventListener('abort', onAbort, { once: true });
-        abortCleanup = () => signal.removeEventListener('abort', onAbort);
-      }),
-    ]);
-  } finally {
-    abortCleanup?.();
-  }
+  return resolveGrepCliWithAutoInstall({}, signal);
 }
 
 export const runRipgrep: GrepRunner = async (input, signal) => {

+ 104 - 0
src/utils/agent-variant.test.ts

@@ -4,6 +4,8 @@ import {
   applyAgentVariant,
   normalizeAgentName,
   resolveAgentVariant,
+  resolveRuntimeAgentName,
+  rewriteDisplayNameMentions,
 } from './agent-variant';
 
 describe('normalizeAgentName', () => {
@@ -100,6 +102,108 @@ describe('resolveAgentVariant', () => {
     } as PluginConfig;
     expect(resolveAgentVariant(config, 'oracle')).toBeUndefined();
   });
+
+  test('resolves displayName alias to internal agent for variant lookup', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor', variant: 'high' },
+      },
+    } as PluginConfig;
+    expect(resolveAgentVariant(config, '@advisor')).toBe('high');
+  });
+});
+
+describe('resolveRuntimeAgentName', () => {
+  test('keeps internal agent names unchanged', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(resolveRuntimeAgentName(config, 'oracle')).toBe('oracle');
+  });
+
+  test('resolves displayName to internal name', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(resolveRuntimeAgentName(config, 'advisor')).toBe('oracle');
+  });
+
+  test('resolves displayName with @ prefix and whitespace', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(resolveRuntimeAgentName(config, '  @advisor  ')).toBe('oracle');
+  });
+
+  test('resolves displayName configured via legacy alias key', () => {
+    const config = {
+      agents: {
+        explore: { displayName: 'researcher' },
+      },
+    } as PluginConfig;
+
+    expect(resolveRuntimeAgentName(config, 'researcher')).toBe('explorer');
+  });
+
+  test('returns normalized name when no displayName match exists', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(resolveRuntimeAgentName(config, '  @unknown  ')).toBe('unknown');
+  });
+});
+
+describe('rewriteDisplayNameMentions', () => {
+  test('rewrites displayName mentions to internal names for direct invocation', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(rewriteDisplayNameMentions(config, 'ask @advisor about this')).toBe(
+      'ask @oracle about this',
+    );
+  });
+
+  test('keeps internal mentions working while rewriting aliases', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(
+      rewriteDisplayNameMentions(config, 'compare @advisor with @oracle'),
+    ).toBe('compare @oracle with @oracle');
+  });
+
+  test('does not rewrite embedded text such as email addresses', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(
+      rewriteDisplayNameMentions(
+        config,
+        'email foo@advisor.com and ask @advisor directly',
+      ),
+    ).toBe('email foo@advisor.com and ask @oracle directly');
+  });
 });
 
 describe('applyAgentVariant', () => {

+ 80 - 3
src/utils/agent-variant.ts

@@ -1,4 +1,8 @@
-import type { PluginConfig } from '../config';
+import {
+  ALL_AGENT_NAMES,
+  getAgentOverride,
+  type PluginConfig,
+} from '../config';
 import { log } from './logger';
 
 /**
@@ -36,8 +40,8 @@ export function resolveAgentVariant(
   config: PluginConfig | undefined,
   agentName: string,
 ): string | undefined {
-  const normalized = normalizeAgentName(agentName);
-  const rawVariant = config?.agents?.[normalized]?.variant;
+  const normalized = resolveRuntimeAgentName(config, agentName);
+  const rawVariant = getAgentOverride(config, normalized)?.variant;
 
   if (typeof rawVariant !== 'string') {
     return undefined;
@@ -52,6 +56,79 @@ export function resolveAgentVariant(
   return trimmed;
 }
 
+/**
+ * Resolve a runtime-provided agent name to an internal agent name.
+ *
+ * Supports:
+ * - internal names (e.g. "oracle")
+ * - @-prefixed names (e.g. "@oracle")
+ * - displayName aliases (e.g. "advisor" -> "oracle")
+ */
+export function resolveRuntimeAgentName(
+  config: PluginConfig | undefined,
+  agentName: string,
+): string {
+  const normalized = normalizeAgentName(agentName);
+  if (!normalized) {
+    return normalized;
+  }
+
+  if ((ALL_AGENT_NAMES as readonly string[]).includes(normalized)) {
+    return normalized;
+  }
+
+  for (const internalName of ALL_AGENT_NAMES) {
+    const displayName = getAgentOverride(config, internalName)?.displayName;
+    if (!displayName) {
+      continue;
+    }
+
+    if (normalizeAgentName(displayName) === normalized) {
+      return internalName;
+    }
+  }
+
+  return normalized;
+}
+
+function escapeRegExp(value: string): string {
+  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * Rewrites user-facing display-name mentions (e.g. @advisor) into internal
+ * agent mentions (e.g. @oracle) for runtime routing.
+ */
+export function rewriteDisplayNameMentions(
+  config: PluginConfig | undefined,
+  text: string,
+): string {
+  if (!text.includes('@')) {
+    return text;
+  }
+
+  let rewritten = text;
+
+  for (const internalName of ALL_AGENT_NAMES) {
+    const displayName = getAgentOverride(config, internalName)?.displayName;
+    if (!displayName) {
+      continue;
+    }
+
+    const normalizedDisplayName = normalizeAgentName(displayName);
+    if (!normalizedDisplayName || normalizedDisplayName === internalName) {
+      continue;
+    }
+
+    rewritten = rewritten.replace(
+      new RegExp(`(^|[^\\w.])@${escapeRegExp(normalizedDisplayName)}\\b`, 'g'),
+      `$1@${internalName}`,
+    );
+  }
+
+  return rewritten;
+}
+
 /**
  * Applies a variant to a request body if the body doesn't already have one.
  *