diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 380cdcd2b..6700c97f9 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -14,6 +14,14 @@ "default_run_agent": { "type": "string" }, + "agent_order": { + "maxItems": 64, + "type": "array", + "items": { + "type": "string", + "maxLength": 128 + } + }, "agent_definitions": { "type": "array", "items": { diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 9321b156e..95f430dec 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1,278 +1,1040 @@ # Configuration Reference -This reference documents the current runtime behavior for Oh My OpenAgent plugin config loading and validation. +Complete reference for Oh My OpenCode plugin configuration. During the rename transition, the runtime recognizes both `oh-my-openagent.json[c]` and legacy `oh-my-opencode.json[c]` files. -During the rename transition, both basenames are accepted: +--- -- Preferred: `oh-my-openagent.jsonc` or `oh-my-openagent.json` -- Legacy: `oh-my-opencode.jsonc` or `oh-my-opencode.json` +## Table of Contents -## Format and Naming Rules +- [Getting Started](#getting-started) + - [File Locations](#file-locations) + - [Quick Start Example](#quick-start-example) +- [Core Concepts](#core-concepts) + - [Agents](#agents) + - [Categories](#categories) + - [Model Resolution](#model-resolution) +- [Task System](#task-system) + - [Background Tasks](#background-tasks) + - [Sisyphus Agent](#sisyphus-agent) + - [Sisyphus Tasks](#sisyphus-tasks) +- [Features](#features) + - [Skills](#skills) + - [Hooks](#hooks) + - [Commands](#commands) + - [Browser Automation](#browser-automation) + - [Tmux Integration](#tmux-integration) + - [Git Master](#git-master) + - [Comment Checker](#comment-checker) + - [Notification](#notification) + - [MCPs](#mcps) + - [LSP](#lsp) +- [Advanced](#advanced) + - [Runtime Fallback](#runtime-fallback) + - [Model Capabilities](#model-capabilities) + - [Hashline Edit](#hashline-edit) + - [Experimental](#experimental) +- [Reference](#reference) + - [Environment Variables](#environment-variables) + - [Provider-Specific](#provider-specific) -- Config format: JSONC (`//` comments, `/* */` comments, trailing commas) -- Key style: `snake_case` -- Validation: Zod v4 schema validation -- Auto-migration: legacy keys and values are migrated by `migrateConfigFile()` +--- -Schema autocomplete: +## Getting Started + +### File Locations + +User config loads first. Project configs are discovered by walking from the working directory up to `$HOME`; closer configs win. If the working directory is outside `$HOME`, only that directory is checked. + +1. Walked configs: `.opencode/oh-my-openagent.json[c]` or legacy `.opencode/oh-my-opencode.json[c]` +2. User config (`.jsonc` preferred over `.json`): + +| Platform | Path candidates | +| ----------- | --------------- | +| macOS/Linux | `~/.config/opencode/oh-my-openagent.json[c]`, `~/.config/opencode/oh-my-opencode.json[c]` | +| Windows | `%APPDATA%\opencode\oh-my-openagent.json[c]`, `%APPDATA%\opencode\oh-my-opencode.json[c]` | + +**Security note:** `mcp_env_allowlist` is user-only. Walked configs cannot extend it. + +**Rename compatibility:** The published package and CLI binary remain `oh-my-opencode`. OpenCode plugin registration prefers `oh-my-openagent`, while legacy `oh-my-opencode` entries and config basenames still load during the transition. Config detection checks `oh-my-opencode` before `oh-my-openagent`, so if both plugin config basenames exist in the same directory, the legacy `oh-my-opencode.*` file currently wins. +JSONC supports `// line comments`, `/* block comments */`, and trailing commas. + +Enable schema autocomplete: + +```json +{ + "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-openagent.schema.json" +} +``` + +Run `bunx oh-my-opencode install` for guided setup. Run `opencode models` to list available models. + +### Quick Start Example + +Here's a practical starting configuration: ```jsonc { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-openagent.schema.json", -} -``` -## File Discovery and Merge Order - -Load order (`src/plugin-config.ts`): - -1. User config in OpenCode config dir (`~/.config/opencode` on macOS/Linux, `%APPDATA%\\opencode` on Windows) -2. Walked project configs from the current directory up to `$HOME` (closer directory wins) - -When current directory is outside `$HOME`, walking is pinned to that directory. - -### Merge semantics - -- Deep merge: `agents`, `categories`, `team_mode`, `claude_code` -- Set union (dedup arrays): - - `agent_definitions` - - `disabled_agents` - - `disabled_mcps` - - `disabled_hooks` - - `disabled_commands` - - `disabled_skills` - - `disabled_tools` - - `mcp_env_allowlist` (during merge phase) -- Override replace: all other keys - -Security rule: final `mcp_env_allowlist` is forced to user-config only. Walked/project configs cannot extend it. - -## Migration Behavior - -- Legacy basename can be migrated to canonical basename automatically. -- Config migrations are idempotent and tracked using migration sidecar state (plus legacy `_migrations` compatibility handling). -- When content changes, migration writes timestamped backups like `*.bak.`. - -## Top-level Key Reference - -Types/defaults below are from `assets/oh-my-openagent.schema.json`. - -| Key | Type | Default | -| --- | --- | --- | -| `$schema` | `string` | none | -| `_migrations` | `string[]` | none | -| `agent_definitions` | `string[]` | none | -| `agents` | `object` | none | -| `auto_update` | `boolean` | none | -| `babysitting` | `object` | none | -| `background_task` | `object` | none | -| `browser_automation_engine` | `object` | none | -| `categories` | `object` | none | -| `claude_code` | `object` | none | -| `comment_checker` | `object` | none | -| `default_run_agent` | `string` | none | -| `disabled_agents` | `string[]` | none | -| `disabled_commands` | `string[]` | none | -| `disabled_hooks` | `string[]` | none | -| `disabled_mcps` | `string[]` | none | -| `disabled_skills` | `string[]` | none | -| `disabled_tools` | `string[]` | none | -| `experimental` | `object` | none | -| `git_master` | `object` | `{ "commit_footer": true, "include_co_authored_by": true, "git_env_prefix": "GIT_MASTER=1" }` | -| `hashline_edit` | `boolean` | none | -| `keyword_detector` | `object` | none | -| `mcp_env_allowlist` | `string[]` | none | -| `model_capabilities` | `object` | none | -| `model_fallback` | `boolean` | none | -| `new_task_system_enabled` | `boolean` | none | -| `notification` | `object` | none | -| `openclaw` | `object` | none | -| `ralph_loop` | `object` | none | -| `runtime_fallback` | `boolean \| object` | none | -| `sisyphus` | `object` | none | -| `sisyphus_agent` | `object` | none | -| `skills` | `string[] \| object` | none | -| `start_work` | `object` | none | -| `team_mode` | `object` | none | -| `tmux` | `object` | none | -| `websearch` | `object` | none | - -## High-use Sections - -### `team_mode` (all fields) - -```jsonc -{ - "team_mode": { - "enabled": false, - "tmux_visualization": false, - "max_parallel_members": 4, - "max_members": 8, - "max_messages_per_run": 10000, - "max_wall_clock_minutes": 120, - "max_member_turns": 500, - "base_dir": "/custom/path", // optional - "message_payload_max_bytes": 32768, - "recipient_unread_max_bytes": 262144, - "mailbox_poll_interval_ms": 3000, - }, -} -``` - -| Field | Type | Default | Notes | -| --- | --- | --- | --- | -| `enabled` | `boolean` | `false` | Master switch | -| `tmux_visualization` | `boolean` | `false` | Visual tmux mode | -| `max_parallel_members` | `integer` | `4` | Range `1..8` | -| `max_members` | `integer` | `8` | Range `1..8` | -| `max_messages_per_run` | `integer` | `10000` | Minimum `1` | -| `max_wall_clock_minutes` | `integer` | `120` | Minimum `1` | -| `max_member_turns` | `integer` | `500` | Minimum `1` | -| `base_dir` | `string` | none | Optional override path | -| `message_payload_max_bytes` | `integer` | `32768` | Minimum `1024` | -| `recipient_unread_max_bytes` | `integer` | `262144` | Minimum `1024` | -| `mailbox_poll_interval_ms` | `integer` | `3000` | Minimum `500` | - -### `tmux` - -| Field | Type | Default | -| --- | --- | --- | -| `enabled` | `boolean` | `false` | -| `layout` | `string` | `"main-vertical"` | -| `main_pane_size` | `number` | `60` | -| `main_pane_min_width` | `number` | `120` | -| `agent_pane_min_width` | `number` | `40` | -| `isolation` | `string` | `"inline"` | - -### `background_task` - -| Field | Type | Default | -| --- | --- | --- | -| `defaultConcurrency` | `number` | none | -| `providerConcurrency` | `object` | none | -| `modelConcurrency` | `object` | none | -| `maxDepth` | `integer` | none | -| `staleTimeoutMs` | `number` | none | -| `messageStalenessTimeoutMs` | `number` | none | -| `taskTtlMs` | `number` | none | -| `sessionGoneTimeoutMs` | `number` | none | -| `syncPollTimeoutMs` | `number` | none | -| `maxToolCalls` | `integer` | none | -| `circuitBreaker` | `object` | none | - -### `experimental` - -| Field | Type | Default | -| --- | --- | --- | -| `aggressive_truncation` | `boolean` | none | -| `auto_resume` | `boolean` | none | -| `preemptive_compaction` | `boolean` | none | -| `truncate_all_tool_outputs` | `boolean` | none | -| `dynamic_context_pruning` | `object` | none | -| `task_system` | `boolean` | none | -| `plugin_load_timeout_ms` | `number` | none | -| `safe_hook_creation` | `boolean` | none | -| `disable_omo_env` | `boolean` | none | -| `hashline_edit` | `boolean` | none | -| `model_fallback_title` | `boolean` | none | -| `max_tools` | `integer` | none | - -### `openclaw` - -| Field | Type | Default | -| --- | --- | --- | -| `enabled` | `boolean` | `false` | -| `gateways` | `object` | `{}` | -| `hooks` | `object` | `{}` | -| `replyListener` | `object` | none | - -### `sisyphus_agent` - -| Field | Type | Default | -| --- | --- | --- | -| `disabled` | `boolean` | none | -| `default_builder_enabled` | `boolean` | none | -| `planner_enabled` | `boolean` | none | -| `replace_plan` | `boolean` | none | -| `tdd` | `boolean` | `true` | - -### `git_master` - -| Field | Type | Default | -| --- | --- | --- | -| `commit_footer` | `boolean \| string` | `true` | -| `include_co_authored_by` | `boolean` | `true` | -| `git_env_prefix` | `string` | `"GIT_MASTER=1"` | - -### `model_capabilities` - -| Field | Type | Default | -| --- | --- | --- | -| `enabled` | `boolean` | none | -| `auto_refresh_on_start` | `boolean` | none | -| `refresh_timeout_ms` | `integer` | none | -| `source_url` | `string` | none | - -### `browser_automation_engine` - -| Field | Type | Default | -| --- | --- | --- | -| `provider` | `string` | `"playwright"` | - -### `notification`, `comment_checker`, `keyword_detector`, `websearch`, `ralph_loop`, `babysitting`, `start_work` - -| Key | Field | Type | Default | -| --- | --- | --- | --- | -| `notification` | `force_enable` | `boolean` | none | -| `comment_checker` | `custom_prompt` | `string` | none | -| `keyword_detector` | `disabled_keywords` | `string[]` | none | -| `websearch` | `provider` | `string` | none | -| `ralph_loop` | `enabled` | `boolean` | `false` | -| `ralph_loop` | `default_max_iterations` | `number` | `100` | -| `ralph_loop` | `state_dir` | `string` | none | -| `ralph_loop` | `default_strategy` | `string` | `"continue"` | -| `babysitting` | `timeout_ms` | `number` | `120000` | -| `start_work` | `auto_commit` | `boolean` | `true` | - -## Agents, Categories, Skills - -- `agents`: per-agent overrides. Built-ins include `sisyphus`, `hephaestus`, `prometheus`, `oracle`, `librarian`, `explore`, `atlas`, `metis`, `momus`, `multimodal-looker`, `sisyphus-junior`. -- `categories`: category-level model and prompt routing overrides. -- `skills`: either array form or object form. -- `disabled_agents`, `disabled_skills`, `disabled_hooks`, `disabled_tools`, `disabled_commands`, `disabled_mcps`: string arrays. - -## Runtime and Model Fallback - -- `model_fallback`: global switch for proactive model fallback behavior. -- `runtime_fallback`: boolean or object config for reactive fallback behavior. -- Provider/model fallback chains are defined in code (`src/shared/model-requirements.ts`). - -## Verified JSONC Example - -```jsonc -{ - "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-openagent.schema.json", "agents": { - "sisyphus": { "model": "anthropic/claude-opus-4-7" }, + // Main orchestrator: Claude Opus or Kimi K2.5 work best + "sisyphus": { + "model": "kimi-for-coding/k2p5", + "ultrawork": { "model": "anthropic/claude-opus-4-7", "variant": "max" }, + }, + + // Research agents: cheap fast models are fine + "librarian": { "model": "google/gemini-3-flash" }, "explore": { "model": "github-copilot/grok-code-fast-1" }, + + // Architecture consultation: GPT-5.5 or Claude Opus + "oracle": { "model": "openai/gpt-5.5", "variant": "high" }, + + // Prometheus inherits sisyphus model; just add prompt guidance + "prometheus": { + "prompt_append": "Leverage deep & quick agents heavily, always in parallel.", + }, }, + "categories": { + // quick - trivial tasks "quick": { "model": "opencode/gpt-5-nano" }, - "deep": { "model": "openai/gpt-5.5" }, + + // unspecified-low - moderate tasks + "unspecified-low": { "model": "anthropic/claude-sonnet-4-6" }, + + // unspecified-high - complex work + "unspecified-high": { "model": "anthropic/claude-opus-4-7", "variant": "max" }, + + // writing - docs/prose + "writing": { "model": "google/gemini-3-flash" }, + + // visual-engineering - Gemini dominates visual tasks + "visual-engineering": { + "model": "google/gemini-3.1-pro", + "variant": "high", + }, + + // Custom category for git operations + "git": { + "model": "opencode/gpt-5-nano", + "description": "All git operations", + "prompt_append": "Focus on atomic commits, clear messages, and safe operations.", + }, }, - "disabled_hooks": ["startup-toast"], - "team_mode": { - "enabled": true, - "max_parallel_members": 4, - "max_members": 8, - "max_messages_per_run": 10000, - "max_wall_clock_minutes": 120, - "max_member_turns": 500, - "message_payload_max_bytes": 32768, - "recipient_unread_max_bytes": 262144, - "mailbox_poll_interval_ms": 3000, - "tmux_visualization": false, + + // Limit expensive providers; let cheap ones run freely + "background_task": { + "providerConcurrency": { + "anthropic": 3, + "openai": 3, + "opencode": 10, + "zai-coding-plan": 10, + }, + "modelConcurrency": { + "anthropic/claude-opus-4-7": 2, + "opencode/gpt-5-nano": 20, + }, }, + + "experimental": { "aggressive_truncation": true, "task_system": true }, "tmux": { "enabled": false }, } ``` + +--- + +## Core Concepts + +### Agents + +Override built-in agent settings. Available agents: `sisyphus`, `hephaestus`, `prometheus`, `oracle`, `librarian`, `explore`, `multimodal-looker`, `metis`, `momus`, `atlas`, `sisyphus-junior`. + +```json +{ + "agents": { + "explore": { "model": "anthropic/claude-haiku-4-5", "temperature": 0.5 }, + "multimodal-looker": { "disable": true } + } +} +``` + +Disable agents entirely: `{ "disabled_agents": ["oracle", "multimodal-looker"] }` + +Agent tab cycling defaults to Sisyphus, Hephaestus, Prometheus, Atlas. Override known agent ordering with `agent_order`; omitted core agents keep their default relative order. Unknown or duplicate names are ignored and reported with a config toast. + +```json +{ + "agent_order": ["hephaestus", "sisyphus", "prometheus", "atlas"] +} +``` + +#### Agent Options + +| Option | Type | Description | +| ----------------- | -------------- | --------------------------------------------------------------- | +| `model` | string | Model override (`provider/model`) | +| `fallback_models` | string\|array | Fallback models on API errors. Supports strings or mixed arrays of strings and object entries with per-model settings | +| `temperature` | number | Sampling temperature | +| `top_p` | number | Top-p sampling | +| `prompt` | string | Replace system prompt. Supports `file://` URIs | +| `prompt_append` | string | Append to system prompt. Supports `file://` URIs | +| `tools` | array | Allowed tools list | +| `disable` | boolean | Disable this agent | +| `mode` | string | Agent mode | +| `color` | string | UI color | +| `permission` | object | Per-tool permissions (see below) | +| `category` | string | Inherit model from category | +| `variant` | string | Model variant: `max`, `high`, `medium`, `low`, `xhigh`. Normalized to supported values | +| `maxTokens` | number | Max response tokens | +| `thinking` | object | Anthropic extended thinking | +| `reasoningEffort` | string | OpenAI reasoning: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. Normalized to supported values | +| `textVerbosity` | string | Text verbosity: `low`, `medium`, `high` | +| `providerOptions` | object | Provider-specific options | + +#### Anthropic Extended Thinking + +```json +{ + "agents": { + "oracle": { "thinking": { "type": "enabled", "budgetTokens": 200000 } } + } +} +``` + +#### Agent Permissions + +Control what tools an agent can use: + +```json +{ + "agents": { + "explore": { + "permission": { + "edit": "deny", + "bash": "ask", + "webfetch": "allow" + } + } + } +} +``` + +| Permission | Values | +| -------------------- | --------------------------------------------------------------------------- | +| `edit` | `ask` / `allow` / `deny` | +| `bash` | `ask` / `allow` / `deny` or per-command: `{ "git": "allow", "rm": "deny" }` | +| `webfetch` | `ask` / `allow` / `deny` | +| `doom_loop` | `ask` / `allow` / `deny` | +| `external_directory` | `ask` / `allow` / `deny` | + + +#### Fallback Models with Per-Model Settings + +`fallback_models` accepts either a single model string or an array. Array entries can be plain strings or objects with individual model settings: + +```jsonc +{ + "agents": { + "sisyphus": { + "model": "anthropic/claude-opus-4-7", + "fallback_models": [ + // Simple string fallback + "openai/gpt-5.5", + // Object with per-model settings + { + "model": "google/gemini-3.1-pro", + "variant": "high", + "temperature": 0.2 + }, + { + "model": "anthropic/claude-sonnet-4-6", + "thinking": { "type": "enabled", "budgetTokens": 64000 } + } + ] + } + } +} +``` + +Object entries support: `model`, `variant`, `reasoningEffort`, `temperature`, `top_p`, `maxTokens`, `thinking`. + +#### File URIs for Prompts + +Both `prompt` and `prompt_append` support loading content from files via `file://` URIs. Category-level `prompt_append` supports the same URI forms. + +```jsonc +{ + "agents": { + "sisyphus": { + "prompt_append": "file:///absolute/path/to/prompt.txt" + }, + "oracle": { + "prompt": "file://./relative/to/project/prompt.md" + }, + "explore": { + "prompt_append": "file://~/home/dir/prompt.txt" + } + }, + "categories": { + "custom": { + "model": "anthropic/claude-sonnet-4-6", + "prompt_append": "file://./category-context.md" + } + } +} +``` + +Paths can be absolute (`file:///abs/path`), relative to project root (`file://./rel/path`), or home-relative (`file://~/home/path`). If a file URI cannot be decoded, resolved, or read, OmO inserts a warning placeholder into the prompt instead of failing hard. + +### Categories + +Domain-specific model delegation used by the `task()` tool. When Sisyphus delegates work, it picks a category, not a model name. + +#### Built-in Categories + +| Category | Default Model | Description | +| -------------------- | ------------------------------- | ---------------------------------------------- | +| `visual-engineering` | `google/gemini-3.1-pro` (high) | Frontend, UI/UX, design, animation | +| `ultrabrain` | `openai/gpt-5.5` (xhigh) | Deep logical reasoning, complex architecture | +| `deep` | `openai/gpt-5.5` (medium) | Autonomous problem-solving, thorough research | +| `artistry` | `google/gemini-3.1-pro` (high) | Creative/unconventional approaches | +| `quick` | `openai/gpt-5.4-mini` | Trivial tasks, typo fixes, single-file changes | +| `unspecified-low` | `anthropic/claude-sonnet-4-6` | General tasks, low effort | +| `unspecified-high` | `anthropic/claude-opus-4-7` (max) | General tasks, high effort | +| `writing` | `google/gemini-3-flash` | Documentation, prose, technical writing | + +> **Note**: Built-in defaults only apply if the category is present in your config. Otherwise the system default model is used. + +#### Category Options + +| Option | Type | Default | Description | +| ------------------- | ------------- | ------- | ------------------------------------------------------------------- | +| `model` | string | - | Model override | +| `fallback_models` | string\|array | - | Fallback models on API errors. Supports strings or mixed arrays of strings and object entries with per-model settings | +| `temperature` | number | - | Sampling temperature | +| `top_p` | number | - | Top-p sampling | +| `maxTokens` | number | - | Max response tokens | +| `thinking` | object | - | Anthropic extended thinking | +| `reasoningEffort` | string | - | OpenAI reasoning effort. Unsupported values are normalized | +| `textVerbosity` | string | - | Text verbosity | +| `tools` | array | - | Allowed tools | +| `prompt_append` | string | - | Append to system prompt | +| `variant` | string | - | Model variant. Unsupported values are normalized | +| `description` | string | - | Shown in `task()` tool prompt | +| `is_unstable_agent` | boolean | `false` | Force background mode + monitoring. Auto-enabled for Gemini models. | + +Disable categories: `{ "disabled_categories": ["ultrabrain"] }` + +### Model Resolution + +Runtime priority: + +1. **UI-selected model** - model chosen in the OpenCode UI, for primary agents +2. **User override** - model set in config → used exactly as-is. Even on cold cache, explicit user configuration takes precedence over hardcoded fallback chains +3. **Category default** - model inherited from the assigned category config +4. **User `fallback_models`** - user-configured fallback list is tried before built-in fallback chains +5. **Provider fallback chain** - built-in provider/model chain from OmO source +6. **System default** - OpenCode's configured default model + +#### Model Settings Compatibility + +Model settings are compatibility-normalized against model capabilities instead of failing hard. + +Normalized fields: + +- `variant` - downgraded to the closest supported value +- `reasoningEffort` - downgraded to the closest supported value, or removed if unsupported +- `temperature` - removed if unsupported by the model metadata +- `top_p` - removed if unsupported by the model metadata +- `maxTokens` - capped to the model's reported max output limit +- `thinking` - removed if the target model does not support thinking + +Examples: +- Claude models do not support `reasoningEffort` - it is removed automatically +- GPT-4.1 does not support reasoning - `reasoningEffort` is removed +- o-series models support `none` through `high` - `xhigh` is downgraded to `high` +- GPT-5 supports `none`, `minimal`, `low`, `medium`, `high`, `xhigh` - all pass through + +Capability data comes from provider runtime metadata first. OmO also ships bundled models.dev-backed capability data, supports a refreshable local models.dev cache, and falls back to heuristic family detection plus alias rules when exact metadata is unavailable. `bunx oh-my-opencode doctor` surfaces capability diagnostics and warns when a configured model relies on compatibility fallback. + + +#### Agent Provider Chains + +| Agent | Default Model | Provider Priority | +| --------------------- | ------------------- | ---------------------------------------------------------------------------- | +| **Sisyphus** | `claude-opus-4-7` | `anthropic\|github-copilot\|opencode/claude-opus-4-7 (max)` → `opencode-go/kimi-k2.6` → `kimi-for-coding/k2p5` → `opencode\|moonshotai\|moonshotai-cn\|firmware\|ollama-cloud\|aihubmix/kimi-k2.5` → `openai\|github-copilot\|opencode/gpt-5.5 (medium)` → `zai-coding-plan\|opencode/glm-5` → `opencode/big-pickle` | +| **Hephaestus** | `gpt-5.5` | `gpt-5.5 (medium)` | +| **oracle** | `gpt-5.5` | `openai\|github-copilot\|opencode/gpt-5.5 (high)` → `google\|github-copilot\|opencode/gemini-3.1-pro (high)` → `anthropic\|github-copilot\|opencode/claude-opus-4-7 (max)` → `opencode-go/glm-5.1` | +| **librarian** | `gpt-5.4-mini-fast` | `openai/gpt-5.4-mini-fast` → `opencode-go/qwen3.5-plus` → `vercel/minimax-m2.7-highspeed` → `opencode-go\|vercel/minimax-m2.7` → `anthropic\|opencode\|vercel/claude-haiku-4-5` → `openai\|opencode\|vercel/gpt-5.4-nano` | +| **explore** | `gpt-5.4-mini-fast` | `openai/gpt-5.4-mini-fast` → `opencode-go/qwen3.5-plus` → `vercel/minimax-m2.7-highspeed` → `opencode-go\|vercel/minimax-m2.7` → `anthropic\|opencode\|vercel/claude-haiku-4-5` → `openai\|opencode\|vercel/gpt-5.4-nano` | +| **multimodal-looker** | `gpt-5.5` | `openai\|opencode/gpt-5.5 (medium)` → `opencode-go/kimi-k2.6` → `zai-coding-plan/glm-4.6v` → `openai\|github-copilot\|opencode/gpt-5-nano` | +| **Prometheus** | `claude-opus-4-7` | `anthropic\|github-copilot\|opencode/claude-opus-4-7 (max)` → `openai\|github-copilot\|opencode/gpt-5.5 (high)` → `opencode-go/glm-5.1` → `google\|github-copilot\|opencode/gemini-3.1-pro` | +| **Metis** | `claude-sonnet-4-6` | `anthropic\|github-copilot\|opencode/claude-sonnet-4-6` → `anthropic\|github-copilot\|opencode/claude-opus-4-7 (max)` → `openai\|github-copilot\|opencode/gpt-5.5 (high)` → `opencode-go/glm-5.1` → `kimi-for-coding/k2p5` | +| **Momus** | `gpt-5.5` | `openai\|github-copilot\|opencode/gpt-5.5 (xhigh)` → `anthropic\|github-copilot\|opencode/claude-opus-4-7 (max)` → `google\|github-copilot\|opencode/gemini-3.1-pro (high)` → `opencode-go/glm-5.1` | +| **Atlas** | `claude-sonnet-4-6` | `anthropic\|github-copilot\|opencode/claude-sonnet-4-6` → `opencode-go/kimi-k2.6` → `openai\|github-copilot\|opencode/gpt-5.5 (medium)` → `opencode-go/minimax-m2.7` | + +#### Category Provider Chains + +| Category | Default Model | Provider Priority | +| ---------------------- | ------------------- | -------------------------------------------------------------- | +| **visual-engineering** | `gemini-3.1-pro` | `google\|github-copilot\|opencode/gemini-3.1-pro (high)` → `zai-coding-plan\|opencode/glm-5` → `anthropic\|github-copilot\|opencode/claude-opus-4-7 (max)` → `opencode-go/glm-5.1` → `kimi-for-coding/k2p5` | +| **ultrabrain** | `gpt-5.5` | `openai\|opencode/gpt-5.5 (xhigh)` → `google\|github-copilot\|opencode/gemini-3.1-pro (high)` → `anthropic\|github-copilot\|opencode/claude-opus-4-7 (max)` → `opencode-go/glm-5.1` | +| **deep** | `gpt-5.5` | `openai\|github-copilot\|venice\|opencode/gpt-5.5 (medium)` → `anthropic\|github-copilot\|opencode/claude-opus-4-7 (max)` → `google\|github-copilot\|opencode/gemini-3.1-pro (high)` | +| **artistry** | `gemini-3.1-pro` | `google\|github-copilot\|opencode/gemini-3.1-pro (high)` → `anthropic\|github-copilot\|opencode/claude-opus-4-7 (max)` → `openai\|github-copilot\|opencode/gpt-5.5` | +| **quick** | `gpt-5.4-mini` | `openai\|github-copilot\|opencode/gpt-5.4-mini` → `anthropic\|github-copilot\|opencode/claude-haiku-4-5` → `google\|github-copilot\|opencode/gemini-3-flash` → `opencode-go/minimax-m2.7` → `opencode/gpt-5-nano` | +| **unspecified-low** | `claude-sonnet-4-6` | `anthropic\|github-copilot\|opencode/claude-sonnet-4-6` → `openai\|opencode/gpt-5.3-codex (medium)` → `opencode-go/kimi-k2.6` → `google\|github-copilot\|opencode/gemini-3-flash` → `opencode-go/minimax-m2.7` | +| **unspecified-high** | `claude-opus-4-7` | `anthropic\|github-copilot\|opencode/claude-opus-4-7 (max)` → `openai\|github-copilot\|opencode/gpt-5.5 (high)` → `zai-coding-plan\|opencode/glm-5` → `kimi-for-coding/k2p5` → `opencode-go/glm-5.1` → `opencode/kimi-k2.5` → `opencode\|moonshotai\|moonshotai-cn\|firmware\|ollama-cloud\|aihubmix/kimi-k2.5` | +| **writing** | `gemini-3-flash` | `google\|github-copilot\|opencode/gemini-3-flash` → `opencode-go/kimi-k2.6` → `anthropic\|github-copilot\|opencode/claude-sonnet-4-6` → `opencode-go/minimax-m2.7` | + +Run `bunx oh-my-opencode doctor --verbose` to see effective model resolution for your config. + +--- + +## Task System + +### Background Tasks + +Control parallel agent execution and concurrency limits. + +```json +{ + "background_task": { + "defaultConcurrency": 5, + "staleTimeoutMs": 180000, + "providerConcurrency": { "anthropic": 3, "openai": 5, "google": 10 }, + "modelConcurrency": { "anthropic/claude-opus-4-7": 2 } + } +} +``` + +| Option | Default | Description | +| --------------------- | -------- | --------------------------------------------------------------------- | +| `defaultConcurrency` | - | Max concurrent tasks (all providers) | +| `staleTimeoutMs` | `180000` | Interrupt tasks with no activity (min: 60000) | +| `providerConcurrency` | - | Per-provider limits (key = provider name) | +| `modelConcurrency` | - | Per-model limits (key = `provider/model`). Overrides provider limits. | + +Priority: `modelConcurrency` > `providerConcurrency` > `defaultConcurrency` + +### Sisyphus Agent + +Configure the main orchestration system. + +```json +{ + "sisyphus_agent": { + "disabled": false, + "default_builder_enabled": false, + "planner_enabled": true, + "replace_plan": true + } +} +``` + +| Option | Default | Description | +| ------------------------- | ------- | --------------------------------------------------------------- | +| `disabled` | `false` | Disable all Sisyphus orchestration, restore original build/plan | +| `default_builder_enabled` | `false` | Enable OpenCode-Builder agent (off by default) | +| `planner_enabled` | `true` | Enable Prometheus (Planner) agent | +| `replace_plan` | `true` | Demote default plan agent to subagent mode | + +Sisyphus agents can also be customized under `agents` using their names: `Sisyphus`, `OpenCode-Builder`, `Prometheus (Planner)`, `Metis (Plan Consultant)`. + +### Sisyphus Tasks + +File-based task persistence with dependency tracking, used for cross-session task management. The task system is controlled by `experimental.task_system` (defaults to `true` since v3.14). When enabled, `TodoWrite`/`TodoRead` are intercepted and replaced with the Task tools (`task_create`, `task_get`, `task_list`, `task_update`). + +The `sisyphus.tasks` section configures **storage options** only: + +```json +{ + "sisyphus": { + "tasks": { + "storage_path": ".sisyphus/tasks", + "claude_code_compat": false + } + } +} +``` + +| Option | Default | Description | +| -------------------- | ----------------- | ------------------------------------------ | +| `storage_path` | `.sisyphus/tasks` | Storage path (relative to project root) | +| `task_list_id` | - | Force task list ID (alternative to env `ULTRAWORK_TASK_LIST_ID`) | +| `claude_code_compat` | `false` | Enable Claude Code path compatibility mode | + +To disable the task system entirely, set `experimental.task_system` to `false`: + +```json +{ + "experimental": { "task_system": false } +} +``` + +--- + +## Features + +### Skills + +Skills bring domain-specific expertise and embedded MCPs. + +Built-in skills: `playwright`, `playwright-cli`, `agent-browser`, `dev-browser`, `git-master`, `frontend-ui-ux` + +Disable built-in skills: `{ "disabled_skills": ["playwright"] }` + +#### Skills Configuration + +```json +{ + "skills": { + "sources": [ + { "path": "./my-skills", "recursive": true }, + "https://example.com/skill.yaml" + ], + "enable": ["my-skill"], + "disable": ["other-skill"], + "my-skill": { + "description": "What it does", + "template": "Custom prompt template", + "from": "source-file.ts", + "model": "custom/model", + "agent": "custom-agent", + "subtask": true, + "argument-hint": "usage hint", + "license": "MIT", + "compatibility": ">= 3.0.0", + "metadata": { "author": "Your Name" }, + "allowed-tools": ["read", "bash"] + } + } +} +``` + +| `sources` option | Default | Description | +| ---------------- | ------- | ------------------------------- | +| `path` | - | Local path or remote URL | +| `recursive` | `false` | Recurse into subdirectories | +| `glob` | - | Glob pattern for file selection | + +### Hooks + +Disable built-in hooks via `disabled_hooks`: + +```json +{ "disabled_hooks": ["comment-checker"] } +``` + +Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `no-sisyphus-gpt`, `start-work`, `runtime-fallback` + +**Notes:** + +- `directory-agents-injector` - auto-disabled on OpenCode 1.1.37+ (native AGENTS.md support) +- `no-sisyphus-gpt` - **do not disable**. It blocks incompatible GPT models for Sisyphus while allowing the dedicated GPT-5.4 and GPT-5.5 prompt paths. +- `startup-toast` is a sub-feature of `auto-update-checker`. Disable just the toast by adding `startup-toast` to `disabled_hooks`. +- `session-recovery` - automatically recovers from recoverable session errors (missing tool results, unavailable tools, thinking block violations). Shows toast notifications during recovery. Enable `experimental.auto_resume` for automatic retry after recovery. + +### Commands + +Disable built-in commands via `disabled_commands`: + +```json +{ "disabled_commands": ["init-deep", "start-work"] } +``` + +Available commands: `init-deep`, `ralph-loop`, `ulw-loop`, `cancel-ralph`, `refactor`, `start-work`, `stop-continuation`, `handoff` + +### Browser Automation + +| Provider | Interface | Installation | +| ---------------------- | --------- | --------------------------------------------------- | +| `playwright` (default) | MCP tools | Auto-installed via npx | +| `agent-browser` | Bash CLI | `bun add -g agent-browser && agent-browser install` | + +Switch provider: + +```json +{ "browser_automation_engine": { "provider": "agent-browser" } } +``` + +### Tmux Integration + +Run background subagents in separate tmux panes. Requires running inside tmux with `opencode --port `. + +```json +{ + "tmux": { + "enabled": true, + "layout": "main-vertical", + "main_pane_size": 60, + "main_pane_min_width": 120, + "agent_pane_min_width": 40 + } +} +``` + +| Option | Default | Description | +| ---------------------- | --------------- | ----------------------------------------------------------------------------------- | +| `enabled` | `false` | Enable tmux pane spawning | +| `layout` | `main-vertical` | `main-vertical` / `main-horizontal` / `tiled` / `even-horizontal` / `even-vertical` | +| `main_pane_size` | `60` | Main pane % (20–80) | +| `main_pane_min_width` | `120` | Min main pane columns | +| `agent_pane_min_width` | `40` | Min agent pane columns | + +### Git Master + +Configure git commit behavior: + +```json +{ "git_master": { "commit_footer": true, "include_co_authored_by": true } } +``` + +### Comment Checker + +Customize the comment quality checker: + +```json +{ + "comment_checker": { + "custom_prompt": "Your message. Use {{comments}} placeholder." + } +} +``` + +### Notification + +Force-enable session notifications: + +```json +{ "notification": { "force_enable": true } } +``` + +`force_enable` (`false`) - force session-notification even if external notification plugins are detected. + +### MCPs + +Built-in MCPs (enabled by default): `websearch` (Exa AI), `context7` (library docs), `grep_app` (GitHub code search). + +```json +{ "disabled_mcps": ["websearch", "context7", "grep_app"] } +``` + +### LSP + +Configure Language Server Protocol integration: + +```json +{ + "lsp": { + "typescript-language-server": { + "command": ["typescript-language-server", "--stdio"], + "extensions": [".ts", ".tsx"], + "priority": 10, + "env": { "NODE_OPTIONS": "--max-old-space-size=4096" }, + "initialization": { + "preferences": { "includeInlayParameterNameHints": "all" } + } + }, + "pylsp": { "disabled": true } + } +} +``` + +| Option | Type | Description | +| ---------------- | ------- | ------------------------------------ | +| `command` | array | Command to start LSP server | +| `extensions` | array | File extensions (e.g. `[".ts"]`) | +| `priority` | number | Priority when multiple servers match | +| `env` | object | Environment variables | +| `initialization` | object | Init options passed to server | +| `disabled` | boolean | Disable this server | + +--- + +## Advanced + +### Runtime Fallback + +Auto-switches to backup models on API errors. + +**Simple configuration** (enable/disable with defaults): + +```json +{ "runtime_fallback": true } +``` + +```json +{ "runtime_fallback": false } +``` + +**Advanced configuration** (full control): + +```json +{ + "runtime_fallback": { + "enabled": true, + "retry_on_errors": [400, 429, 503, 529], + "max_fallback_attempts": 3, + "cooldown_seconds": 60, + "timeout_seconds": 30, + "notify_on_fallback": true + } +} +``` + +| Option | Default | Description | +| ----------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `enabled` | `false` | Enable runtime fallback | +| `retry_on_errors` | `[400,429,503,529]` | HTTP codes that trigger fallback. Also handles classified provider key errors. | +| `max_fallback_attempts` | `3` | Max fallback attempts per session (1–20) | +| `cooldown_seconds` | `60` | Seconds before retrying a failed model | +| `timeout_seconds` | `30` | Seconds before forcing next fallback. **Set to `0` to disable timeout-based escalation and provider retry message detection.** | +| `notify_on_fallback` | `true` | Toast notification on model switch | + +#### Speeding Up Fallback (Proxy APIs) + +If you are using a proxy API provider, they may return different error codes (e.g., `401`, `403`, `404`) for quota exhaustion or model unavailability. To make fallback trigger instantly without waiting for long timeouts: + +```jsonc +{ + "runtime_fallback": { + "enabled": true, + // Add your proxy's specific error codes to retry_on_errors + "retry_on_errors": [400, 401, 403, 404, 429, 500, 502, 503, 504], + "max_fallback_attempts": 3, + "cooldown_seconds": 15, // Shorter cooldown + "timeout_seconds": 10 // Detect hung proxy requests faster + } +} +``` + +Define `fallback_models` per agent or category: + +```json +{ + "agents": { + "sisyphus": { + "model": "anthropic/claude-opus-4-7", + "fallback_models": [ + "openai/gpt-5.5", + { + "model": "google/gemini-3.1-pro", + "variant": "high" + } + ] + } + } +} +``` + +`fallback_models` also supports object-style entries so you can attach settings to a specific fallback model: + +```json +{ + "agents": { + "sisyphus": { + "model": "anthropic/claude-opus-4-7", + "fallback_models": [ + "openai/gpt-5.5", + { + "model": "anthropic/claude-sonnet-4-6", + "variant": "high", + "thinking": { "type": "enabled", "budgetTokens": 12000 } + }, + { + "model": "openai/gpt-5.3-codex", + "reasoningEffort": "high", + "temperature": 0.2, + "top_p": 0.95, + "maxTokens": 8192 + } + ] + } + } +} +``` + +Mixed arrays are allowed, so string entries and object entries can appear together in the same fallback chain. + +#### Object-style `fallback_models` + +Object entries use the following shape: + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `model` | string | Fallback model ID. Provider prefix is optional when OmO can inherit the current/default provider. | +| `variant` | string | Explicit variant override for this fallback entry. | +| `reasoningEffort` | string | OpenAI reasoning effort override for this fallback entry. | +| `temperature` | number | Temperature applied if this fallback model becomes active. | +| `top_p` | number | Top-p applied if this fallback model becomes active. | +| `maxTokens` | number | Max response tokens applied if this fallback model becomes active. | +| `thinking` | object | Anthropic thinking config applied if this fallback model becomes active. | + +Per-model settings are **fallback-only**. They are promoted only when that specific fallback model is actually selected, so they do not override your primary model settings when the primary model resolves successfully. + +`thinking` uses the same shape as the normal agent/category option: + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `type` | string | `enabled` or `disabled` | +| `budgetTokens` | number | Optional Anthropic thinking budget | + +Object entries can also omit the provider prefix when OmO can infer it from the current/default provider. If you provide both inline variant syntax in `model` and an explicit `variant` field, the explicit `variant` field wins. + +#### Full examples + +**1. Simple string chain** + +Use strings when you only need an ordered fallback chain: + +```json +{ + "agents": { + "atlas": { + "model": "anthropic/claude-sonnet-4-6", + "fallback_models": [ + "anthropic/claude-haiku-4-5", + "openai/gpt-5.5", + "google/gemini-3.1-pro" + ] + } + } +} +``` + +**2. Same-provider shorthand** + +If the primary model already establishes the provider, fallback entries can omit the prefix: + +```json +{ + "agents": { + "atlas": { + "model": "openai/gpt-5.5", + "fallback_models": [ + "gpt-5.4-mini", + { + "model": "gpt-5.3-codex", + "reasoningEffort": "medium", + "maxTokens": 4096 + } + ] + } + } +} +``` + +In this example OmO treats `gpt-5.4-mini` and `gpt-5.3-codex` as OpenAI fallback entries because the current/default provider is already `openai`. + +**3. Mixed cross-provider chain** + +Mix string entries and object entries when only some fallback models need special settings: + +```json +{ + "agents": { + "sisyphus": { + "model": "anthropic/claude-opus-4-7", + "fallback_models": [ + "openai/gpt-5.5", + { + "model": "anthropic/claude-sonnet-4-6", + "variant": "high", + "thinking": { "type": "enabled", "budgetTokens": 12000 } + }, + { + "model": "google/gemini-3.1-pro", + "variant": "high" + } + ] + } + } +} +``` + +**4. Category-level fallback chain** + +`fallback_models` works the same way under `categories`: + +```json +{ + "categories": { + "deep": { + "model": "openai/gpt-5.3-codex", + "fallback_models": [ + { + "model": "openai/gpt-5.5", + "reasoningEffort": "xhigh", + "maxTokens": 12000 + }, + { + "model": "anthropic/claude-opus-4-7", + "variant": "max", + "temperature": 0.2 + }, + "google/gemini-3.1-pro(high)" + ] + } + } +} +``` + +**5. Full object entry with every supported field** + +This shows every supported object-style parameter in one place: + +```json +{ + "agents": { + "oracle": { + "model": "openai/gpt-5.5", + "fallback_models": [ + { + "model": "openai/gpt-5.3-codex(low)", + "variant": "xhigh", + "reasoningEffort": "high", + "temperature": 0.3, + "top_p": 0.9, + "maxTokens": 8192, + "thinking": { + "type": "disabled" + } + } + ] + } + } +} +``` + +In this example the explicit `"variant": "xhigh"` overrides the inline `(low)` suffix in `"model"`. + +This final example is a **complete shape reference**. In real configs, prefer provider-appropriate settings: + +- use `reasoningEffort` for OpenAI reasoning models +- use `thinking` for Anthropic thinking-capable models +- use `variant`, `temperature`, `top_p`, and `maxTokens` only when that fallback model supports them + +### Model Capabilities + +OmO can refresh a local models.dev capability snapshot on startup. This cache is controlled by `model_capabilities`. + +```jsonc +{ + "model_capabilities": { + "enabled": true, + "auto_refresh_on_start": true, + "refresh_timeout_ms": 5000, + "source_url": "https://models.dev/api.json" + } +} +``` + +| Option | Default behavior | Description | +| ------ | ---------------- | ----------- | +| `enabled` | enabled unless explicitly set to `false` | Master switch for model capability refresh behavior | +| `auto_refresh_on_start` | refresh on startup unless explicitly set to `false` | Refresh the local models.dev cache during startup checks | +| `refresh_timeout_ms` | `5000` | Timeout for the startup refresh attempt | +| `source_url` | `https://models.dev/api.json` | Override the models.dev source URL | + +Notes: + +- Startup refresh runs through the auto-update checker hook. +- Manual refresh is available via `bunx oh-my-opencode refresh-model-capabilities`. +- Provider runtime metadata still takes priority when OmO resolves capabilities for compatibility checks. + +### Hashline Edit + +Replaces the built-in `Edit` tool with a hash-anchored version using `LINE#ID` references to prevent stale-line edits. Disabled by default. + +```json +{ "hashline_edit": true } +``` + +When enabled, two companion hooks are active: `hashline-read-enhancer` (annotates Read output) and `hashline-edit-diff-enhancer` (shows diffs). Opt-in by setting `hashline_edit: true`. Disable the companion hooks individually via `disabled_hooks` if needed. + +### Experimental + +```json +{ + "experimental": { + "truncate_all_tool_outputs": false, + "aggressive_truncation": false, + "auto_resume": false, + "disable_omo_env": false, + "task_system": true, + "dynamic_context_pruning": { + "enabled": false, + "notification": "detailed", + "turn_protection": { "enabled": true, "turns": 3 }, + "protected_tools": [ + "task", + "todowrite", + "todoread", + "lsp_rename", + "session_read", + "session_write", + "session_search" + ], + "strategies": { + "deduplication": { "enabled": true }, + "supersede_writes": { "enabled": true, "aggressive": false }, + "purge_errors": { "enabled": true, "turns": 5 } + } + } + } +} +``` + +| Option | Default | Description | +| ---------------------------------------- | ---------- | ------------------------------------------------------------------------------------ | +| `truncate_all_tool_outputs` | `false` | Truncate all tool outputs (not just whitelisted) | +| `aggressive_truncation` | `false` | Aggressively truncate when token limit exceeded | +| `auto_resume` | `false` | Auto-resume after thinking block recovery | +| `disable_omo_env` | `false` | Disable auto-injected `` block (date/time/locale). Improves cache hit rate. | +| `task_system` | `false` | Enable Sisyphus task system | +| `dynamic_context_pruning.enabled` | `false` | Auto-prune old tool outputs to manage context window | +| `dynamic_context_pruning.notification` | `detailed` | Pruning notifications: `off` / `minimal` / `detailed` | +| `turn_protection.turns` | `3` | Recent turns protected from pruning (1–10) | +| `strategies.deduplication` | `true` | Remove duplicate tool calls | +| `strategies.supersede_writes` | `true` | Prune write inputs when file later read | +| `strategies.supersede_writes.aggressive` | `false` | Prune any write if ANY subsequent read exists | +| `strategies.purge_errors.turns` | `5` | Turns before pruning errored tool inputs | + +--- + +## Reference + +### Environment Variables + +| Variable | Description | +| --------------------- | ----------------------------------------------------------------- | +| `OPENCODE_CONFIG_DIR` | Override OpenCode config directory (useful for profile isolation) | +| `OMO_SEND_ANONYMOUS_TELEMETRY` | Set to `0`, `false`, or `no` to disable anonymous telemetry | +| `OMO_DISABLE_POSTHOG` | Legacy telemetry opt-out flag. Set to `1` or `true` to disable PostHog | +| `POSTHOG_API_KEY` | Optional override for the built-in PostHog project API key | +| `POSTHOG_HOST` | Override the PostHog ingestion host. Defaults to `https://us.i.posthog.com` | + +### Provider-Specific + +#### Google Auth + +Install [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) for Google Gemini. Provides multi-account load balancing, dual quota, and variant-based thinking. + +#### Ollama + +**Must** disable streaming to avoid JSON parse errors: + +```json +{ + "agents": { + "explore": { "model": "ollama/qwen3-coder" } + } +} +``` + +**Note:** The `stream` option should be configured in your OpenCode settings or via environment variables, not in the agent config. See [Ollama Troubleshooting](../troubleshooting/ollama.md) for details on disabling streaming. + +Common models: `ollama/qwen3-coder`, `ollama/ministral-3:14b`, `ollama/lfm2.5-thinking` + +See [Ollama Troubleshooting](../troubleshooting/ollama.md) for `JSON Parse error: Unexpected EOF` issues. diff --git a/docs/reference/features.md b/docs/reference/features.md index 2d6a3ad3f..965a1457b 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -90,7 +90,7 @@ When running inside tmux: - Watch multiple agents work in real-time - Each pane shows agent output live - Auto-cleanup when agents complete -- **Stable agent ordering**: core-agent tab cycling is deterministic via injected runtime order field (Sisyphus: 1, Hephaestus: 2, Prometheus: 3, Atlas: 4) +- **Stable agent ordering**: core-agent tab cycling defaults to Sisyphus, Hephaestus, Prometheus, Atlas, and can be customized with `agent_order` Customize agent models, prompts, and permissions in `oh-my-opencode.jsonc`. diff --git a/src/config/schema/oh-my-opencode-config.test.ts b/src/config/schema/oh-my-opencode-config.test.ts index 6fef426ac..eb3315fea 100644 --- a/src/config/schema/oh-my-opencode-config.test.ts +++ b/src/config/schema/oh-my-opencode-config.test.ts @@ -38,3 +38,58 @@ describe("OhMyOpenCodeConfigSchema team_mode", () => { } }) }) + +describe("OhMyOpenCodeConfigSchema agent_order", () => { + it("accepts string agent ordering when provided", () => { + // given + const rawConfig = { + agent_order: ["hephaestus", "sisyphus", "prometheus", "atlas"], + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig) + + // then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.agent_order).toEqual([ + "hephaestus", + "sisyphus", + "prometheus", + "atlas", + ]) + } + }) + + it("allows agent_order omission", () => { + // given + const rawConfig = {} + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig) + + // then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.agent_order).toBeUndefined() + } + }) + + it("rejects abusive agent_order string length and item count", () => { + // given + const tooLongName = "x".repeat(129) + const tooManyNames = Array.from({ length: 65 }, (_, index) => `agent-${index}`) + + // when + const tooLongResult = OhMyOpenCodeConfigSchema.safeParse({ + agent_order: [tooLongName], + }) + const tooManyResult = OhMyOpenCodeConfigSchema.safeParse({ + agent_order: tooManyNames, + }) + + // then + expect(tooLongResult.success).toBe(false) + expect(tooManyResult.success).toBe(false) + }) +}) diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index df7032514..197948bca 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -32,6 +32,8 @@ export const OhMyOpenCodeConfigSchema = z.object({ new_task_system_enabled: z.boolean().optional(), /** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */ default_run_agent: z.string().optional(), + /** Preferred display order for known agents. Invalid names are ignored with a toast warning. */ + agent_order: z.array(z.string().max(128)).max(64).optional(), /** Paths to external agent definition files (.md or .json) */ agent_definitions: AgentDefinitionsConfigSchema, disabled_mcps: z.array(AnyMcpNameSchema).optional(), diff --git a/src/index.test.ts b/src/index.test.ts index ba7be1363..715eeeb99 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -37,6 +37,8 @@ const mockCreateHooks = mock(() => ({ const mockCreatePluginInterface = mock(() => ({})) const mockInitializeOpenClaw = mock(async () => {}) const mockStartTmuxCheck = mock(() => {}) +const mockInstallAgentSortShim = mock(() => {}) +const mockSetAgentSortOrder = mock(() => {}) let pluginModule: (typeof import("./index"))["default"] @@ -95,6 +97,11 @@ function installIndexModuleMocks(): void { })), })) + mock.module("./shared/agent-sort-shim", () => ({ + installAgentSortShim: mockInstallAgentSortShim, + setAgentSortOrder: mockSetAgentSortOrder, + })) + mock.module("./openclaw", () => ({ initializeOpenClaw: mockInitializeOpenClaw, })) @@ -130,6 +137,8 @@ describe("oh-my-openagent plugin module", () => { mockCreatePluginInterface.mockClear() mockInitializeOpenClaw.mockClear() mockStartTmuxCheck.mockClear() + mockInstallAgentSortShim.mockClear() + mockSetAgentSortOrder.mockClear() }) afterEach(() => { diff --git a/src/index.ts b/src/index.ts index 5921bb73b..372143cd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ import { loadPluginConfig } from "./plugin-config" import { createModelCacheState } from "./plugin-state" import { createFirstMessageVariantGate } from "./shared/first-message-variant" import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared" -import { installAgentSortShim } from "./shared/agent-sort-shim" +import { installAgentSortShim, setAgentSortOrder } from "./shared/agent-sort-shim" import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector" import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash" @@ -34,6 +34,7 @@ const serverPlugin: Plugin = async (input, _options): Promise => { injectServerAuthIntoClient(input.client) const pluginConfig = loadPluginConfig(input.directory, input) + setAgentSortOrder(pluginConfig.agent_order) if (pluginConfig.openclaw) { await initializeOpenClaw(pluginConfig.openclaw) diff --git a/src/plugin-config.test.ts b/src/plugin-config.test.ts index ee2dfa8c4..c98ab715a 100644 --- a/src/plugin-config.test.ts +++ b/src/plugin-config.test.ts @@ -1,9 +1,10 @@ -import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { afterEach, describe, expect, it, mock } from "bun:test"; import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { mergeConfigs, parseConfigPartially } from "./plugin-config"; +import { loadConfigFromPath, mergeConfigs, parseConfigPartially } from "./plugin-config"; import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type TeamModeConfig } from "./config"; +import { clearConfigLoadErrors, getConfigLoadErrors } from "./shared/config-errors"; const tempDirs: string[] = [] type ConfigInput = Omit, "team_mode"> & { @@ -20,6 +21,7 @@ async function importFreshPluginConfigModule(): Promise { mock.restore() + clearConfigLoadErrors() delete process.env.OPENCODE_CONFIG_DIR for (const dir of tempDirs.splice(0)) { @@ -273,6 +275,35 @@ describe("parseConfigPartially", () => { expect(result!.agents).toBeUndefined(); }); + it("should preserve valid agent_order when another section is invalid", () => { + const rawConfig = { + agent_order: ["hephaestus", "sisyphus", "prometheus", "atlas"], + disabled_skills: [42], + }; + + const result = parseConfigPartially(rawConfig); + + expect(result?.agent_order).toEqual([ + "hephaestus", + "sisyphus", + "prometheus", + "atlas", + ]); + expect(result?.disabled_skills).toBeUndefined(); + }); + + it("should skip abusive agent_order when another section is valid", () => { + const rawConfig = { + agent_order: ["x".repeat(129)], + disabled_hooks: ["comment-checker"], + }; + + const result = parseConfigPartially(rawConfig); + + expect(result?.agent_order).toBeUndefined(); + expect(result?.disabled_hooks).toEqual(["comment-checker"]); + }); + it("should preserve valid agents when a non-agent section is invalid", () => { const rawConfig = { agents: { @@ -349,6 +380,51 @@ describe("parseConfigPartially", () => { }); }); +describe("loadConfigFromPath agent_order warnings", () => { + it("loads config and records warning for invalid agent_order entries", () => { + // given + const rootDir = mkdtempSync(join(tmpdir(), "agent-order-warning-")) + tempDirs.push(rootDir) + const configPath = join(rootDir, "oh-my-openagent.json") + writeJsonFile(configPath, { + agent_order: ["hephaestus", "not-real", "sisyphus", "hephaestus"], + }) + + // when + const result = loadConfigFromPath(configPath, {}) + + // then + expect(result?.agent_order).toEqual(["hephaestus", "not-real", "sisyphus", "hephaestus"]) + expect(getConfigLoadErrors()).toEqual([ + { + path: configPath, + error: 'agent_order warning - unknown agent names ignored: "not-real"; duplicate agent names ignored: "hephaestus"', + }, + ]) + }) + + it("sanitizes and caps invalid agent_order values before recording warnings", () => { + // given + const rootDir = mkdtempSync(join(tmpdir(), "agent-order-sanitize-")) + tempDirs.push(rootDir) + const configPath = join(rootDir, "oh-my-openagent.json") + writeJsonFile(configPath, { + agent_order: [ + "\u001B[31mbad\u001B[0m", + ...Array.from({ length: 11 }, (_, index) => `missing-${index}`), + ], + }) + + // when + loadConfigFromPath(configPath, {}) + + // then + expect(getConfigLoadErrors()[0]?.error).toBe( + 'agent_order warning - unknown agent names ignored: "[31mbad[0m", "missing-0", "missing-1", "missing-2", "missing-3", "missing-4", "missing-5", "missing-6", "missing-7", "missing-8", (+2 more)', + ) + }) +}) + describe("loadPluginConfig", () => { it("should only honor mcp_env_allowlist from user config", async () => { // given diff --git a/src/plugin-config.ts b/src/plugin-config.ts index cedb3ac2d..0914be7b8 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -16,6 +16,50 @@ import { } from "./shared"; import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file"; import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./shared/plugin-identity"; +import { validateAgentOrder } from "./shared/agent-ordering"; + +const CONTROL_CHARACTERS_REGEX = /[\u0000-\u001F\u007F-\u009F\u202A-\u202E\u2066-\u2069]/g; +const MAX_AGENT_ORDER_WARNING_VALUES = 10; +const MAX_AGENT_ORDER_WARNING_VALUE_LENGTH = 80; + +function formatAgentOrderWarningValues(values: readonly string[]): string { + const displayedValues = values.slice(0, MAX_AGENT_ORDER_WARNING_VALUES).map((value) => { + const sanitized = value.replace(CONTROL_CHARACTERS_REGEX, ""); + const truncated = sanitized.length > MAX_AGENT_ORDER_WARNING_VALUE_LENGTH + ? `${sanitized.slice(0, MAX_AGENT_ORDER_WARNING_VALUE_LENGTH)}...` + : sanitized; + return JSON.stringify(truncated); + }); + + const remaining = values.length - displayedValues.length; + if (remaining > 0) { + displayedValues.push(`(+${remaining} more)`); + } + + return displayedValues.join(", "); +} + +function addAgentOrderWarnings(configPath: string, agentOrder: string[] | undefined): void { + if (!agentOrder) return; + + const validation = validateAgentOrder(agentOrder); + const messages: string[] = []; + + if (validation.invalid.length > 0) { + messages.push(`unknown agent names ignored: ${formatAgentOrderWarningValues(validation.invalid)}`); + } + + if (validation.duplicates.length > 0) { + messages.push(`duplicate agent names ignored: ${formatAgentOrderWarningValues(validation.duplicates)}`); + } + + if (messages.length === 0) return; + + addConfigLoadError({ + path: configPath, + error: `agent_order warning - ${messages.join("; ")}`, + }); +} function resolveHomeDirectory(): string { // Read env vars directly to bypass os.homedir() caching. Bun caches the @@ -134,6 +178,7 @@ export function loadConfigFromPath( const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig); if (result.success) { + addAgentOrderWarnings(configPath, result.data.agent_order); log(`Config loaded from ${configPath}`, { agents: result.data.agents }); return result.data; } @@ -149,6 +194,7 @@ export function loadConfigFromPath( const partialResult = parseConfigPartially(rawConfig); if (partialResult) { + addAgentOrderWarnings(configPath, partialResult.agent_order); log(`Partial config loaded from ${configPath}`, { agents: partialResult.agents }); return partialResult; } diff --git a/src/plugin-handlers/AGENTS.md b/src/plugin-handlers/AGENTS.md index 1d550d3bc..d378f9391 100644 --- a/src/plugin-handlers/AGENTS.md +++ b/src/plugin-handlers/AGENTS.md @@ -4,11 +4,12 @@ ## CRITICAL: AGENT ORDERING -The canonical agent order is **sisyphus → hephaestus → prometheus → atlas**. +The default agent order is **sisyphus → hephaestus → prometheus → atlas**. User config may override it with `agent_order`; omitted core agents fall back to this default order. This order is enforced via two cooperating mechanisms: -1. `CANONICAL_CORE_AGENT_ORDER` in `agent-priority-order.ts` controls object key insertion order in the agent map produced by `applyAgentConfig`. -2. `installAgentSortShim()` in `src/shared/agent-sort-shim.ts` narrows `Array.prototype.toSorted` and `Array.prototype.sort` so that whenever the sorted array contains two or more agent objects whose `.name` matches a canonical core display name, OpenCode's `Agent.list()` (and any other sort site) returns the canonical order. The shim is installed once at plugin entry, before any agent registration. +1. `DEFAULT_AGENT_ORDER` in `src/shared/agent-ordering.ts` supplies the fallback order used when `agent_order` is absent or incomplete. +2. `reorderAgentsByPriority()` in `agent-priority-order.ts` controls object key insertion order in the agent map produced by `applyAgentConfig`. +3. `installAgentSortShim()` in `src/shared/agent-sort-shim.ts` narrows `Array.prototype.toSorted` and `Array.prototype.sort` so that whenever the sorted array contains two or more ranked agent objects, OpenCode's `Agent.list()` (and any other sort site) returns the active configured/default order. The shim is installed once at plugin entry, before any agent registration, and its rank map is updated after plugin config loads. ### Why a Sort Shim @@ -18,7 +19,7 @@ OpenCode 1.4.x sorts agents purely by `agent.name` via Remeda `sortBy`, which us - Removing the prefix and relying on insertion order alone falls back to alphabetical Atlas → Hephaestus → Prometheus → Sisyphus. The sort shim resolves this by intercepting only the narrow case it cares about, with strict activation guards to prevent collateral damage from a global prototype patch: -- The activation predicate (`isAgentArray`) requires `arr.length >= 2`, every element is a non-null object with a string `.name`, and at least 2 elements have a `.name` matching one of the four canonical core display names. This rejects mixed-type arrays (numbers, strings, plain objects without `.name`) so unrelated `.sort()` / `.toSorted()` calls execute native semantics. +- The activation predicate (`isAgentArray`) requires `arr.length >= 2`, every element is a non-null object with a string `.name`, and at least 2 elements have a `.name` ranked by the active order. This rejects mixed-type arrays (numbers, strings, plain objects without `.name`) so unrelated `.sort()` / `.toSorted()` calls execute native semantics. - The comparator never throws on mixed input — it defensively extracts `.name` and falls back to the user-supplied `compareFn`. - `installAgentSortShim()` is idempotent. @@ -34,7 +35,7 @@ Agent ordering has caused 15+ commits, 8+ PRs, and multiple reverts. Notable mil DO NOT introduce: - ZWSP, U+2060, U+00AD, ANSI escape, or any other invisible / control character in agent names, display names, or object keys. - ASCII spaces or other visible sort prefixes on agent names. -- Alternative ordering constants outside `CANONICAL_CORE_AGENT_ORDER`. +- Alternative ordering constants outside `DEFAULT_AGENT_ORDER` / `CANONICAL_CORE_AGENT_ORDER`, or ordering code that bypasses `validateAgentOrder`. - Object.entries() iteration-order dependencies. - Agent name string comparisons that skip `getAgentConfigKey` / `stripInvisibleAgentCharacters` (legacy ZWSP-baked data must keep resolving). diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index f4fcc8533..9d5c3b2ca 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -395,6 +395,7 @@ export async function applyAgentConfig(params: { ); params.config.agent = reorderAgentsByPriority( params.config.agent as Record, + params.pluginConfig.agent_order, ); } diff --git a/src/plugin-handlers/agent-priority-order.test.ts b/src/plugin-handlers/agent-priority-order.test.ts index d1af68a61..94a6581ea 100644 --- a/src/plugin-handlers/agent-priority-order.test.ts +++ b/src/plugin-handlers/agent-priority-order.test.ts @@ -65,6 +65,48 @@ describe("agent-priority-order", () => { expect(keys[3]).toBe(atlas) }) + test("#when custom agent order is provided #then follows configured core ordering", () => { + // given + const agents: Record = { + [atlas]: { name: "atlas" }, + [prometheus]: { name: "prometheus" }, + [hephaestus]: { name: "hephaestus" }, + [sisyphus]: { name: "sisyphus" }, + } + + // when + const result = reorderAgentsByPriority(agents, [ + "hephaestus", + "sisyphus", + "prometheus", + "atlas", + ]) + + // then + expect(Object.keys(result)).toEqual([hephaestus, sisyphus, prometheus, atlas]) + }) + + test("#when custom agent order contains invalid entries #then ignores them and keeps valid/default ordering", () => { + // given + const agents: Record = { + [atlas]: { name: "atlas" }, + [prometheus]: { name: "prometheus" }, + [hephaestus]: { name: "hephaestus" }, + [sisyphus]: { name: "sisyphus" }, + } + + // when + const result = reorderAgentsByPriority(agents, [ + "not-real", + "atlas", + "hephaestus", + "atlas", + ]) + + // then + expect(Object.keys(result)).toEqual([atlas, hephaestus, sisyphus, prometheus]) + }) + test("#when core agents mixed with non-core #then core agents come first in canonical order", () => { // given: mixed order with non-core agents interleaved const agents: Record = { @@ -199,6 +241,21 @@ describe("agent-priority-order", () => { expect(result[atlas]).toEqual({ name: "atlas", mode: "primary", order: 4 }) }) + test("#when custom agent order is provided #then injects matching order fields", () => { + // given + const agents: Record = { + [sisyphus]: { name: "sisyphus", mode: "primary" }, + [hephaestus]: { name: "hephaestus", mode: "primary" }, + } + + // when + const result = reorderAgentsByPriority(agents, ["hephaestus", "sisyphus"]) + + // then + expect(result[hephaestus]).toEqual({ name: "hephaestus", mode: "primary", order: 1 }) + expect(result[sisyphus]).toEqual({ name: "sisyphus", mode: "primary", order: 2 }) + }) + test("#when core agent is non-object #then leaves value unchanged", () => { // given const agents: Record = { diff --git a/src/plugin-handlers/agent-priority-order.ts b/src/plugin-handlers/agent-priority-order.ts index 711f6a58c..43becbf9d 100644 --- a/src/plugin-handlers/agent-priority-order.ts +++ b/src/plugin-handlers/agent-priority-order.ts @@ -1,35 +1,16 @@ -import { getAgentListDisplayName } from "../shared/agent-display-names" +import { DEFAULT_AGENT_ORDER, resolveAgentOrderDisplayNames } from "../shared/agent-ordering" /** - * CRITICAL: This is the ONLY source of truth for core agent ordering. - * The order is: sisyphus → hephaestus → prometheus → atlas + * Default source of truth for core agent ordering. + * The default order is: sisyphus → hephaestus → prometheus → atlas. * - * DO NOT CHANGE THIS ORDER. Any PR attempting to modify this order - * or introduce alternative ordering mechanisms (ZWSP prefixes, sort - * shims, etc.) will be rejected. + * User config may override the runtime order through `agent_order`; missing + * core agents still fall back to this default order. Do not reintroduce sort + * key prefixes or a second ordering constant. * * See: src/plugin-handlers/AGENTS.md for architectural context. */ -export const CANONICAL_CORE_AGENT_ORDER = [ - "sisyphus", - "hephaestus", - "prometheus", - "atlas", -] as const - -type CoreAgentName = (typeof CANONICAL_CORE_AGENT_ORDER)[number] - -const CORE_AGENT_ORDER: ReadonlyArray<{ - configKey: CoreAgentName - displayName: string - order: number -}> = CANONICAL_CORE_AGENT_ORDER.map((configKey, index) => ({ - configKey, - displayName: getAgentListDisplayName(configKey), - order: index + 1, -})) - -const CORE_DISPLAY_NAMES = new Set(CORE_AGENT_ORDER.map((a) => a.displayName)) +export const CANONICAL_CORE_AGENT_ORDER = DEFAULT_AGENT_ORDER function injectOrderField(agentConfig: unknown, order: number): unknown { if (typeof agentConfig === "object" && agentConfig !== null) { @@ -40,13 +21,15 @@ function injectOrderField(agentConfig: unknown, order: number): unknown { export function reorderAgentsByPriority( agents: Record, + agentOrder?: readonly string[], ): Record { const ordered: Record = {} const seen = new Set() + const orderedDisplayNames = resolveAgentOrderDisplayNames(agentOrder) - for (const { displayName, order } of CORE_AGENT_ORDER) { + for (const [index, displayName] of orderedDisplayNames.entries()) { if (Object.prototype.hasOwnProperty.call(agents, displayName)) { - ordered[displayName] = injectOrderField(agents[displayName], order) + ordered[displayName] = injectOrderField(agents[displayName], index + 1) seen.add(displayName) } } diff --git a/src/shared/agent-ordering.ts b/src/shared/agent-ordering.ts new file mode 100644 index 000000000..f1f621d67 --- /dev/null +++ b/src/shared/agent-ordering.ts @@ -0,0 +1,61 @@ +import { AGENT_DISPLAY_NAMES, getAgentConfigKey, getAgentListDisplayName } from "./agent-display-names" + +export const DEFAULT_AGENT_ORDER = [ + "sisyphus", + "hephaestus", + "prometheus", + "atlas", +] as const + +export type AgentOrderValidation = { + order: string[] + invalid: string[] + duplicates: string[] +} + +const KNOWN_AGENT_KEYS = new Set(Object.keys(AGENT_DISPLAY_NAMES)) + +function appendUnique(target: string[], value: string): void { + if (!target.includes(value)) { + target.push(value) + } +} + +export function validateAgentOrder(agentOrder: readonly string[] | undefined): AgentOrderValidation { + const order: string[] = [] + const invalid: string[] = [] + const duplicates: string[] = [] + const seen = new Set() + + for (const rawName of agentOrder ?? []) { + const trimmed = rawName.trim() + if (trimmed.length === 0) { + invalid.push(rawName) + continue + } + + const configKey = getAgentConfigKey(trimmed) + if (!KNOWN_AGENT_KEYS.has(configKey)) { + invalid.push(rawName) + continue + } + + if (seen.has(configKey)) { + duplicates.push(rawName) + continue + } + + seen.add(configKey) + order.push(configKey) + } + + for (const configKey of DEFAULT_AGENT_ORDER) { + appendUnique(order, configKey) + } + + return { order, invalid, duplicates } +} + +export function resolveAgentOrderDisplayNames(agentOrder: readonly string[] | undefined): string[] { + return validateAgentOrder(agentOrder).order.map((configKey) => getAgentListDisplayName(configKey)) +} diff --git a/src/shared/agent-sort-shim.test.ts b/src/shared/agent-sort-shim.test.ts index 647b86f80..47145924a 100644 --- a/src/shared/agent-sort-shim.test.ts +++ b/src/shared/agent-sort-shim.test.ts @@ -1,8 +1,8 @@ /// -import { beforeAll, describe, expect, test } from "bun:test" +import { afterEach, beforeAll, describe, expect, test } from "bun:test" -import { installAgentSortShim } from "./agent-sort-shim" +import { installAgentSortShim, setAgentSortOrder } from "./agent-sort-shim" import { AGENT_DISPLAY_NAMES } from "./agent-display-names" type AgentListItem = { @@ -10,15 +10,26 @@ type AgentListItem = { default_agent?: boolean } +declare global { + interface Array { + toSorted(compareFn?: (a: T, b: T) => number): T[] + } +} + describe("agent-sort-shim", () => { beforeAll(() => { installAgentSortShim() }) + afterEach(() => { + setAgentSortOrder(undefined) + }) + describe("#given an array of all 4 core agent objects in random order", () => { describe("#when toSorted with alphabetical compareFn", () => { test("#then returns canonical sisyphus->hephaestus->prometheus->atlas order", () => { // given + setAgentSortOrder(undefined) const sisyphus = { name: "Sisyphus - Ultraworker" } const hephaestus = { name: "Hephaestus - Deep Agent" } const prometheus = { name: "Prometheus - Plan Builder" } @@ -31,6 +42,22 @@ describe("agent-sort-shim", () => { // then expect(result).toEqual([sisyphus, hephaestus, prometheus, atlas]) }) + + test("#then follows configured core agent order", () => { + // given + setAgentSortOrder(["hephaestus", "sisyphus", "prometheus", "atlas"]) + const sisyphus = { name: "Sisyphus - Ultraworker" } + const hephaestus = { name: "Hephaestus - Deep Agent" } + const prometheus = { name: "Prometheus - Plan Builder" } + const atlas = { name: "Atlas - Plan Executor" } + const input = [atlas, prometheus, hephaestus, sisyphus] + + // when + const result = input.toSorted((a, b) => a.name.localeCompare(b.name)) + + // then + expect(result).toEqual([hephaestus, sisyphus, prometheus, atlas]) + }) }) }) diff --git a/src/shared/agent-sort-shim.ts b/src/shared/agent-sort-shim.ts index d040d660d..479a20719 100644 --- a/src/shared/agent-sort-shim.ts +++ b/src/shared/agent-sort-shim.ts @@ -3,10 +3,9 @@ * * OpenCode 1.4.x ignores the agent `order` field (sst/opencode#19127) and * sorts the agent list by `agent.name` via Remeda `sortBy(x => x.name, "asc")` - * at packages/opencode/src/agent/agent.ts. Without intervention, the four - * core agents collapse into Atlas -> Hephaestus -> Prometheus -> Sisyphus, - * which inverts the canonical sisyphus -> hephaestus -> prometheus -> atlas - * order this project ships. + * at packages/opencode/src/agent/agent.ts. Without intervention, core agents + * collapse into name order, which can invert the default sisyphus -> hephaestus + * -> prometheus -> atlas order or a user's configured `agent_order`. * * Earlier attempts to bias the sort key with invisible characters (ZWSP, * U+2060 WORD JOINER, U+00AD SOFT HYPHEN, ANSI escape) caused visible-gap @@ -17,22 +16,21 @@ * 1. `isAgentArray` rejects any array element that is null, non-object, or * lacks a string `name`, eliminating the throw-on-mixed-array failure * mode that closed the original PR. - * 2. The activation predicate requires >= 2 elements whose `.name` is one - * of the four canonical core display names, so unrelated `.sort()` and - * `.toSorted()` calls (string arrays, number arrays, generic objects) - * execute native behavior unchanged. + * 2. The activation predicate requires >= 2 elements whose `.name` is ranked + * by the active agent order, so unrelated `.sort()` and `.toSorted()` calls + * (string arrays, number arrays, generic objects) execute native behavior + * unchanged. * * Remove this shim once OpenCode honors the agent `order` field * (sst/opencode#19127). */ -import { CANONICAL_CORE_AGENT_ORDER } from "../plugin-handlers/agent-priority-order" -import { AGENT_DISPLAY_NAMES } from "./agent-display-names" +import { DEFAULT_AGENT_ORDER, resolveAgentOrderDisplayNames } from "./agent-ordering" +import { getAgentListDisplayName } from "./agent-display-names" -const AGENT_RANK: ReadonlyMap = new Map( - CANONICAL_CORE_AGENT_ORDER.map( - (configKey, index): [string, number] => [AGENT_DISPLAY_NAMES[configKey], index + 1], - ), +let agentRank: ReadonlyMap = createAgentRank(undefined) +const AGENT_ARRAY_SENTINELS = new Set( + DEFAULT_AGENT_ORDER.map((configKey) => getAgentListDisplayName(configKey)), ) const UNRANKED = Number.MAX_SAFE_INTEGER @@ -51,7 +49,7 @@ function isAgentArray(arr: ReadonlyArray): boolean { if (element === null || typeof element !== "object") return false const name = (element as { name?: unknown }).name if (typeof name !== "string") return false - if (AGENT_RANK.has(name)) rankedCount++ + if (AGENT_ARRAY_SENTINELS.has(name)) rankedCount++ } return rankedCount >= 2 @@ -62,8 +60,8 @@ function agentComparator( b: unknown, fallback: ((a: unknown, b: unknown) => number) | undefined, ): number { - const aRank = AGENT_RANK.get(extractAgentName(a)) ?? UNRANKED - const bRank = AGENT_RANK.get(extractAgentName(b)) ?? UNRANKED + const aRank = agentRank.get(extractAgentName(a)) ?? UNRANKED + const bRank = agentRank.get(extractAgentName(b)) ?? UNRANKED if (aRank !== bRank) return aRank - bRank if (fallback) return fallback(a, b) @@ -72,6 +70,18 @@ function agentComparator( let installed = false +function createAgentRank(agentOrder: readonly string[] | undefined): ReadonlyMap { + return new Map( + resolveAgentOrderDisplayNames(agentOrder).map( + (displayName, index): [string, number] => [displayName, index + 1], + ), + ) +} + +export function setAgentSortOrder(agentOrder: readonly string[] | undefined): void { + agentRank = createAgentRank(agentOrder) +} + export function installAgentSortShim(): void { if (installed) return