Browse Source

Merge pull request #379 from ReqX/feat/351-preset-slash-command

feat(preset): add /preset slash command for runtime agent preset switching
Alvin 3 months ago
parent
commit
54aabe6d97

+ 1 - 0
README.md

@@ -473,6 +473,7 @@ Use this section as a map: start with installation, then jump to features, confi
 | **[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 |
+| **[Preset Switching](docs/preset-switching.md)** | Switch agent model presets at runtime with `/preset` |
 | **[Codemap](docs/codemap.md)** | Generate hierarchical codemaps to understand large codebases faster |
 
 ### ⚙️ Config & Reference

+ 4 - 0
docs/configuration.md

@@ -82,6 +82,10 @@ All config files support **JSONC** (JSON with Comments):
 | Option | Type | Default | Description |
 |--------|------|---------|-------------|
 | `preset` | string | — | Active preset name (e.g. `"openai"`, `"best"`) |
+
+### Runtime Preset Switching
+
+Presets can also be switched at runtime without restarting using the `/preset` command. See [Preset Switching](preset-switching.md) for details.
 | `presets` | object | — | Named preset configurations |
 | `presets.<name>.<agent>.model` | string | — | Model ID in `provider/model` format |
 | `presets.<name>.<agent>.temperature` | number | — | Temperature (0–2) |

+ 97 - 0
docs/preset-switching.md

@@ -0,0 +1,97 @@
+# Preset Switching
+
+Switch agent model presets at runtime without restarting OpenCode using the `/preset` slash command.
+
+## Controls
+
+| Command | Description |
+|---------|-------------|
+| `/preset` | List available presets (highlights the active one) |
+| `/preset <name>` | Switch to the named preset immediately |
+
+## How It Works
+
+1. Define named presets in `oh-my-opencode-slim.jsonc` under the `presets` field
+2. Run `/preset <name>` to switch. The plugin calls the OpenCode SDK's `config.update()` method, which triggers a server-side cache invalidation
+3. The next LLM call uses the new models and settings
+
+## Example Configuration
+
+```jsonc
+{
+  "presets": {
+    "cheap": {
+      "orchestrator": { "model": "anthropic/claude-3.5-haiku" },
+      "explorer": { "model": "openai/gpt-5.4-mini" },
+      "oracle": { "model": "anthropic/claude-sonnet-4-6" }
+    },
+    "powerful": {
+      "orchestrator": { "model": "openai/gpt-5.4" },
+      "oracle": { "model": "anthropic/claude-opus-4-6" },
+      "librarian": { "model": "anthropic/claude-sonnet-4-6" }
+    },
+    "thinking": {
+      "oracle": {
+        "model": "anthropic/claude-sonnet-4-6",
+        "variant": "thinking",
+        "options": { "thinking": { "type": "enabled", "budgetTokens": 10000 } }
+      }
+    }
+  }
+}
+```
+
+## Supported Fields
+
+The following fields are forwarded to the OpenCode SDK at runtime:
+
+| Field | Description |
+|-------|-------------|
+| `model` | Model ID in `provider/model` format. Array form (fallback chains) is resolved to the first entry |
+| `temperature` | Inference temperature (0-2) |
+| `variant` | Model variant (e.g. `"thinking"`) |
+| `options` | Provider-specific options (e.g. thinking budget) |
+
+Fields not forwarded (require restart): `prompt`, `skills`, `mcps`, `displayName`.
+
+## Startup Preset vs Runtime Switching
+
+There are two ways to activate a preset:
+
+| Method | How | Persists? |
+|--------|-----|-----------|
+| Config file | Set `"preset": "cheap"` in `oh-my-opencode-slim.jsonc` | Yes, across restarts |
+| `/preset` command | Run `/preset cheap` during a session | No, reverts on restart |
+
+On restart, the plugin's `config()` hook re-applies the preset from the config file, overwriting any runtime switch. To make a runtime switch permanent, update the `"preset"` field in your config file.
+
+## Example Output
+
+```
+/preset
+```
+
+```
+Available presets:
+  cheap ← active
+    orchestrator → anthropic/claude-3.5-haiku
+    explorer → openai/gpt-5.4-mini
+    oracle → anthropic/claude-sonnet-4-6
+  powerful
+    orchestrator → openai/gpt-5.4
+    oracle → anthropic/claude-opus-4-6
+
+Usage: /preset <name> to switch.
+```
+
+```
+/preset powerful
+```
+
+```
+Switched to preset "powerful":
+orchestrator → model: openai/gpt-5.4
+oracle → model: anthropic/claude-opus-4-6
+```
+
+> See [Configuration](configuration.md) for the full preset option reference.

+ 1 - 0
docs/quick-reference.md

@@ -16,6 +16,7 @@
 | [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 |
+| [Preset Switching](preset-switching.md) | `/preset` command for runtime agent model switching |
 | [Codemap Skill](codemap.md) | Hierarchical codemap generation |
 
 ## ⚙️ Config & Reference

+ 13 - 0
src/index.ts

@@ -28,6 +28,7 @@ import {
   ast_grep_replace,
   ast_grep_search,
   createCouncilTool,
+  createPresetManager,
   createWebfetchTool,
 } from './tools';
 import { resolveRuntimeAgentName, rewriteDisplayNameMentions } from './utils';
@@ -109,6 +110,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let foregroundFallback: ForegroundFallbackManager;
   let todoContinuationHook: ReturnType<typeof createTodoContinuationHook>;
   let interviewManager: ReturnType<typeof createInterviewManager>;
+  let presetManager: ReturnType<typeof createPresetManager>;
   let councilTools: Record<string, unknown>;
   let webfetch: ReturnType<typeof createWebfetchTool>;
 
@@ -249,6 +251,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
     });
     interviewManager = createInterviewManager(ctx, config);
+    presetManager = createPresetManager(ctx, config);
 
     toolCount =
       Object.keys(councilTools).length +
@@ -520,6 +523,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
 
       interviewManager.registerCommand(opencodeConfig);
+      presetManager.registerCommand(opencodeConfig);
     },
 
     event: async (input) => {
@@ -625,6 +629,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
         output as { parts: Array<{ type: string; text?: string }> },
       );
+
+      await presetManager.handleCommandExecuteBefore(
+        input as {
+          command: string;
+          sessionID: string;
+          arguments: string;
+        },
+        output as { parts: Array<{ type: string; text?: string }> },
+      );
     },
 
     'chat.headers': chatHeadersHook['chat.headers'],

+ 2 - 0
src/tools/index.ts

@@ -1,4 +1,6 @@
 // AST-grep tools
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createCouncilTool } from './council';
+export type { PresetManager } from './preset-manager';
+export { createPresetManager } from './preset-manager';
 export { createWebfetchTool } from './smartfetch';

+ 576 - 0
src/tools/preset-manager.test.ts

@@ -0,0 +1,576 @@
+import { describe, expect, mock, test } from 'bun:test';
+import type { PluginConfig } from '../config';
+import { createPresetManager } from './preset-manager';
+
+function createMockContext() {
+  const configUpdate = mock(async () => ({}));
+  return {
+    client: {
+      config: {
+        update: configUpdate,
+      },
+    },
+    directory: '/tmp/test',
+  } as any;
+}
+
+function createOutput() {
+  return { parts: [] as Array<{ type: string; text?: string }> };
+}
+
+function getOutputText(output: ReturnType<typeof createOutput>): string {
+  return output.parts
+    .filter((p) => p.type === 'text')
+    .map((p) => p.text ?? '')
+    .join('\n');
+}
+
+describe('createPresetManager', () => {
+  describe('handleCommandExecuteBefore', () => {
+    test('ignores non-preset commands', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {};
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'auto-continue', sessionID: 's1', arguments: 'on' },
+        output,
+      );
+
+      expect(output.parts).toHaveLength(0);
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+    });
+
+    test('lists available presets when no argument given', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          cheap: {
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
+          },
+          powerful: {
+            orchestrator: { model: 'openai/gpt-5.4' },
+          },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: '' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('cheap');
+      expect(text).toContain('powerful');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+    });
+
+    test('lists presets with active marker when preset is set', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        preset: 'cheap',
+        presets: {
+          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+          powerful: { orchestrator: { model: 'openai/gpt-5.4' } },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: '' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('← active');
+    });
+
+    test('shows no-presets message when none configured', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {};
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: '' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('No presets configured');
+    });
+
+    test('switches preset and calls config.update', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          cheap: {
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
+            explorer: { model: 'openai/gpt-5.4-mini' },
+          },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('Switched to preset "cheap"');
+      expect(text).toContain('orchestrator');
+      expect(text).toContain('anthropic/claude-3.5-haiku');
+      expect(text).toContain('explorer');
+      expect(ctx.client.config.update).toHaveBeenCalledTimes(1);
+      expect(ctx.client.config.update).toHaveBeenCalledWith({
+        body: {
+          agent: {
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
+            explorer: { model: 'openai/gpt-5.4-mini' },
+          },
+        },
+      });
+    });
+
+    test('passes temperature in config update', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          precise: {
+            orchestrator: { model: 'openai/o3', temperature: 0.1 },
+          },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'precise' },
+        output,
+      );
+
+      expect(ctx.client.config.update).toHaveBeenCalledWith({
+        body: {
+          agent: {
+            orchestrator: { model: 'openai/o3', temperature: 0.1 },
+          },
+        },
+      });
+    });
+
+    test('passes variant in config update', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          thinker: {
+            oracle: {
+              model: 'anthropic/claude-sonnet-4-6',
+              variant: 'thinking',
+            },
+          },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'thinker' },
+        output,
+      );
+
+      expect(ctx.client.config.update).toHaveBeenCalledWith({
+        body: {
+          agent: {
+            oracle: {
+              model: 'anthropic/claude-sonnet-4-6',
+              variant: 'thinking',
+            },
+          },
+        },
+      });
+    });
+
+    test('shows error for unknown preset name', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'nonexistent' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('not found');
+      expect(text).toContain('cheap');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+    });
+
+    test('shows error when no presets configured but argument given', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {};
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('not found');
+      expect(text).toContain('No presets configured');
+    });
+
+    test('handles config.update error gracefully', async () => {
+      const ctx = createMockContext();
+      ctx.client.config.update = mock(async () => {
+        throw new Error('Server unavailable');
+      });
+      const config: PluginConfig = {
+        presets: {
+          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('Failed to switch preset');
+      expect(text).toContain('Server unavailable');
+    });
+
+    test('shows empty preset message when preset has no valid overrides', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          empty: {
+            orchestrator: {},
+          },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'empty' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('empty');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+    });
+
+    test('forwards options field in config update', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          thinker: {
+            oracle: {
+              model: 'anthropic/claude-sonnet-4-6',
+              options: {
+                thinking: { type: 'enabled', budgetTokens: 10000 },
+              },
+            },
+          },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'thinker' },
+        output,
+      );
+
+      expect(ctx.client.config.update).toHaveBeenCalledWith({
+        body: {
+          agent: {
+            oracle: {
+              model: 'anthropic/claude-sonnet-4-6',
+              options: {
+                thinking: { type: 'enabled', budgetTokens: 10000 },
+              },
+            },
+          },
+        },
+      });
+    });
+
+    test('trims whitespace from preset name argument', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: '  cheap  ' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('Switched to preset "cheap"');
+      expect(ctx.client.config.update).toHaveBeenCalledTimes(1);
+    });
+
+    test('shows suggestion for multi-word arguments', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'cheap powerful' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('cannot contain spaces');
+      expect(text).toContain('/preset cheap');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+    });
+
+    test('catches tab-separated arguments', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'cheap\tpowerful' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('cannot contain spaces');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+    });
+
+    test('skips agents with empty overrides in mixed preset', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          mixed: {
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
+            explorer: {},
+            oracle: { temperature: 0.3 },
+          },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'mixed' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('Switched to preset "mixed"');
+      // Only orchestrator and oracle should be forwarded
+      expect(ctx.client.config.update).toHaveBeenCalledWith({
+        body: {
+          agent: {
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
+            oracle: { temperature: 0.3 },
+          },
+        },
+      });
+    });
+
+    test('resolves array-form model to first entry', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          fallback: {
+            orchestrator: {
+              model: ['anthropic/claude-3.5-haiku', 'openai/gpt-5.4'],
+            },
+          },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'fallback' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('Switched to preset "fallback"');
+      expect(ctx.client.config.update).toHaveBeenCalledWith({
+        body: {
+          agent: {
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
+          },
+        },
+      });
+    });
+
+    test('resolves array-form model with object entries', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          thinker: {
+            oracle: {
+              model: [
+                { id: 'anthropic/claude-sonnet-4-6', variant: 'thinking' },
+                { id: 'openai/o3' },
+              ],
+            },
+          },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'thinker' },
+        output,
+      );
+
+      expect(ctx.client.config.update).toHaveBeenCalledWith({
+        body: {
+          agent: {
+            oracle: {
+              model: 'anthropic/claude-sonnet-4-6',
+              variant: 'thinking',
+            },
+          },
+        },
+      });
+    });
+
+    test('shows variant and options in switch summary', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          thinker: {
+            oracle: {
+              model: 'anthropic/claude-sonnet-4-6',
+              variant: 'thinking',
+              options: { thinking: { type: 'enabled', budgetTokens: 10000 } },
+            },
+          },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'thinker' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('variant: thinking');
+      expect(text).toContain('options: yes');
+    });
+
+    test('tracks active preset after switch', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+          powerful: { orchestrator: { model: 'openai/gpt-5.4' } },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+
+      // Switch to cheap
+      const output1 = createOutput();
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
+        output1,
+      );
+      expect(getOutputText(output1)).toContain('Switched');
+
+      // List presets should now show cheap as active
+      const output2 = createOutput();
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: '' },
+        output2,
+      );
+      expect(getOutputText(output2)).toContain('cheap ← active');
+
+      // Switch to powerful
+      const output3 = createOutput();
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'powerful' },
+        output3,
+      );
+      expect(getOutputText(output3)).toContain('Switched to preset "powerful"');
+
+      // List should now show powerful as active
+      const output4 = createOutput();
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: '' },
+        output4,
+      );
+      expect(getOutputText(output4)).toContain('powerful ← active');
+    });
+  });
+
+  describe('registerCommand', () => {
+    test('registers preset command when not present', () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {};
+      const manager = createPresetManager(ctx, config);
+      const opencodeConfig: Record<string, unknown> = {};
+
+      manager.registerCommand(opencodeConfig);
+
+      const command = (opencodeConfig.command as Record<string, unknown>)
+        .preset as { template: string; description: string };
+      expect(command).toBeDefined();
+      expect(command.template).toContain('presets');
+      expect(command.description).toContain('/preset');
+    });
+
+    test('does not overwrite existing preset command', () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {};
+      const manager = createPresetManager(ctx, config);
+      const existing = { template: 'custom', description: 'custom' };
+      const opencodeConfig: Record<string, unknown> = {
+        command: { preset: existing },
+      };
+
+      manager.registerCommand(opencodeConfig);
+
+      expect((opencodeConfig.command as Record<string, unknown>).preset).toBe(
+        existing,
+      );
+    });
+  });
+});

+ 280 - 0
src/tools/preset-manager.ts

@@ -0,0 +1,280 @@
+import type { PluginInput } from '@opencode-ai/plugin';
+import type {
+  AgentOverrideConfig,
+  ModelEntry,
+  PluginConfig,
+  Preset,
+} from '../config';
+import { createInternalAgentTextPart } from '../utils';
+
+const COMMAND_NAME = 'preset';
+
+/**
+ * Creates a preset manager for the /preset slash command.
+ *
+ * Uses the OpenCode SDK's client.config.update() to change agent models
+ * and temperatures without restarting. The server invalidates its agent
+ * cache and re-reads config on the next prompt.
+ *
+ * Note: activePreset is tracked in-memory only and resets on plugin reload.
+ * If the user manually edits config or another mechanism changes agents,
+ * this tracker may become stale until the next /preset call.
+ */
+export function createPresetManager(ctx: PluginInput, config: PluginConfig) {
+  let activePreset: string | null = config.preset ?? null;
+
+  /**
+   * Handle the /preset command from command.execute.before hook.
+   *
+   * - No arguments: list available presets
+   * - With argument: switch to the named preset
+   */
+  async function handleCommandExecuteBefore(
+    input: {
+      command: string;
+      sessionID: string;
+      arguments: string;
+    },
+    output: { parts: Array<{ type: string; text?: string }> },
+  ): Promise<void> {
+    if (input.command !== COMMAND_NAME) {
+      return;
+    }
+
+    // Clear the template so OpenCode doesn't send it to the LLM
+    output.parts.length = 0;
+
+    const arg = input.arguments.trim();
+    const presets = config.presets ?? {};
+
+    if (!arg) {
+      // List available presets
+      output.parts.push(createInternalAgentTextPart(formatPresetList(presets)));
+      return;
+    }
+
+    // Guard against multi-word arguments
+    if (/\s/.test(arg)) {
+      const suggestion = arg.split(/\s+/)[0];
+      output.parts.push(
+        createInternalAgentTextPart(
+          `Preset names cannot contain spaces. Did you mean: /preset ${suggestion}?`,
+        ),
+      );
+      return;
+    }
+
+    // Switch to named preset
+    await switchPreset(arg, presets, output);
+  }
+
+  /**
+   * Register the /preset command in the OpenCode config.
+   */
+  function registerCommand(opencodeConfig: Record<string, unknown>): void {
+    const configCommand = opencodeConfig.command as
+      | Record<string, unknown>
+      | undefined;
+    if (!configCommand?.[COMMAND_NAME]) {
+      if (!opencodeConfig.command) {
+        opencodeConfig.command = {};
+      }
+      (opencodeConfig.command as Record<string, unknown>)[COMMAND_NAME] = {
+        template: 'List available presets and switch between them',
+        description:
+          'Switch agent presets at runtime (e.g., /preset cheap, /preset powerful)',
+      };
+    }
+  }
+
+  /**
+   * Switch to the given preset name by calling client.config.update().
+   */
+  async function switchPreset(
+    presetName: string,
+    presets: Record<string, Preset>,
+    output: { parts: Array<{ type: string; text?: string }> },
+  ): Promise<void> {
+    const preset = presets[presetName];
+    if (!preset) {
+      const available = Object.keys(presets);
+      const hint =
+        available.length > 0
+          ? `Available presets: ${available.join(', ')}`
+          : 'No presets configured. Define presets in oh-my-opencode-slim.jsonc.';
+      output.parts.push(
+        createInternalAgentTextPart(
+          `Preset "${presetName}" not found. ${hint}`,
+        ),
+      );
+      return;
+    }
+
+    // Build the agent config overrides from the preset.
+    // Each preset value is { agentName: AgentOverrideConfig }.
+    // We need to convert to SDK AgentConfig format:
+    // { agent: { agentName: { model, temperature, ... } } }
+    const agentUpdates: Record<
+      string,
+      {
+        model?: string;
+        temperature?: number;
+        variant?: string;
+        options?: Record<string, unknown>;
+      }
+    > = {};
+    for (const [agentName, override] of Object.entries(preset)) {
+      const agentConfig = mapOverrideToAgentConfig(override);
+      if (Object.keys(agentConfig).length > 0) {
+        agentUpdates[agentName] = agentConfig;
+      }
+    }
+
+    if (Object.keys(agentUpdates).length === 0) {
+      output.parts.push(
+        createInternalAgentTextPart(
+          `Preset "${presetName}" is empty (no agent overrides defined).`,
+        ),
+      );
+      return;
+    }
+
+    try {
+      await ctx.client.config.update({
+        body: { agent: agentUpdates },
+      });
+
+      activePreset = presetName;
+
+      const summary = Object.entries(agentUpdates)
+        .map(([name, cfg]) => {
+          const parts: string[] = [name];
+          if (cfg.model) parts.push(`model: ${cfg.model}`);
+          if (cfg.variant) parts.push(`variant: ${cfg.variant}`);
+          if (cfg.temperature !== undefined)
+            parts.push(`temp: ${cfg.temperature}`);
+          if (cfg.options) parts.push('options: yes');
+          return parts.join(' → ');
+        })
+        .join('\n');
+
+      output.parts.push(
+        createInternalAgentTextPart(
+          `Switched to preset "${presetName}":\n${summary}`,
+        ),
+      );
+    } catch (err) {
+      output.parts.push(
+        createInternalAgentTextPart(
+          `Failed to switch preset "${presetName}": ${String(err)}`,
+        ),
+      );
+    }
+  }
+
+  /**
+   * Map an AgentOverrideConfig (from plugin config) to the subset of
+   * SDK AgentConfig fields that client.config.update() can apply at runtime.
+   *
+   * Excluded fields and why:
+   * - prompt, orchestratorPrompt: require restart (resolved at init by config() hook)
+   * - skills, mcps: plugin-level concern, not part of SDK AgentConfig
+   * - displayName: plugin-level concern, not part of SDK AgentConfig
+   */
+  function mapOverrideToAgentConfig(override: AgentOverrideConfig): {
+    model?: string;
+    temperature?: number;
+    variant?: string;
+    options?: Record<string, unknown>;
+  } {
+    const agentConfig: {
+      model?: string;
+      temperature?: number;
+      variant?: string;
+      options?: Record<string, unknown>;
+    } = {};
+
+    if (typeof override.model === 'string') {
+      agentConfig.model = override.model;
+    } else if (Array.isArray(override.model) && override.model.length > 0) {
+      // Array-form model (fallback chain): pick the first entry.
+      // The full chain resolution only happens at init time via config() hook,
+      // so at runtime we use the primary model from the array.
+      const first = override.model[0];
+      agentConfig.model = typeof first === 'string' ? first : first.id;
+      if (typeof first !== 'string' && first.variant) {
+        agentConfig.variant = first.variant;
+      }
+    }
+
+    if (typeof override.temperature === 'number') {
+      agentConfig.temperature = override.temperature;
+    }
+
+    if (typeof override.variant === 'string') {
+      agentConfig.variant = override.variant;
+    }
+
+    if (
+      override.options &&
+      typeof override.options === 'object' &&
+      !Array.isArray(override.options)
+    ) {
+      agentConfig.options = override.options;
+    }
+
+    return agentConfig;
+  }
+
+  /**
+   * Format the list of available presets with the active one highlighted.
+   */
+  function formatPresetList(presets: Record<string, Preset>): string {
+    const names = Object.keys(presets);
+    if (names.length === 0) {
+      return 'No presets configured. Define presets in oh-my-opencode-slim.jsonc under the "presets" field.';
+    }
+
+    const lines = ['Available presets:'];
+    for (const name of names) {
+      const marker = name === activePreset ? ' ← active' : '';
+      const preset = presets[name];
+      const agentNames = Object.keys(preset);
+      const models = agentNames
+        .map((a) => {
+          const cfg = preset[a];
+          const modelStr =
+            typeof cfg.model === 'string'
+              ? cfg.model
+              : Array.isArray(cfg.model) && cfg.model.length > 0
+                ? resolveFirstModel(cfg.model)
+                : undefined;
+          return modelStr ? `    ${a} → ${modelStr}` : `    ${a}`;
+        })
+        .join('\n');
+      lines.push(`  ${name}${marker}`);
+      lines.push(models);
+    }
+    lines.push('\nUsage: /preset <name> to switch.');
+
+    return lines.join('\n');
+  }
+
+  /**
+   * Resolve the first model from an array-form model entry.
+   */
+  function resolveFirstModel(
+    models: Array<string | ModelEntry>,
+  ): string | undefined {
+    if (models.length === 0) return undefined;
+    const first = models[0];
+    return typeof first === 'string' ? first : first.id;
+  }
+
+  return {
+    handleCommandExecuteBefore,
+    registerCommand,
+  };
+}
+
+export type PresetManager = ReturnType<typeof createPresetManager>;