# omo.json Configuration Reference `omo.json` (or `omo.jsonc`) is the single harness-spanning configuration surface owned by [`@oh-my-opencode/omo-config-core`](../../packages/omo-config-core/AGENTS.md). It is the only config file read by the OpenCode plugin, by the Senpi adapter (task, config-watch), and by Codex. The legacy OpenCode-family files (`oh-my-openagent.json[c]` / `oh-my-opencode.json[c]`) and `~/.omo/config.jsonc` are read by nothing but the migration engine (see [Migration from legacy files](#migration-from-legacy-files)). Files may be JSONC: `//` comments and trailing commas are allowed. Strict typed blocks never silently accept a malformed value: the value is ignored and reported, and every valid key beside it keeps working. Unknown keys are ignored the same way with an `unknown-keys` diagnostic (see [Safety and failure handling](#file-locations-and-precedence)), so neither a retired or mistyped key nor one wrong value costs you the rest of the file. The `[opencode]` block is intentionally a freeform record so it can carry the full plugin configuration. ## File locations and precedence The loader resolves layers in `resolveOmoConfigPaths` and folds them lowest-to-highest, so the **last** layer merged wins (`packages/omo-config-core/src/loader/paths.ts`, `loader.ts`). 1. **User layer (lowest precedence).** `omo.jsonc`, falling back to `omo.json`, under `~/.omo` on every platform. This is the same root that already holds omo runtime state (`teams/`, `rules/`, `plans/`, `lsp-daemon/`), so there is one user-scope omo directory and one only. 2. **Project layers.** `.omo/omo.jsonc` (then `.omo/omo.json`) in every directory from the current working directory up to `$HOME`. Farther ancestors are merged first; the **nearest** project file has the highest precedence and beats the user layer. `$HOME` itself is skipped by this walk, because `~/.omo` is already the user layer and must not be counted twice. Merge rules (`loader/merge.ts`): - Plain objects deep-merge recursively. - Scalars and arrays replace the lower layer wholesale. - `__proto__`, `prototype`, and `constructor` keys are stripped from both merge keys and nested values (prototype-pollution guard). Safety and failure handling: - A symlinked project `.omo` directory or a symlinked project config file is skipped as a load source (`loader/paths.ts`). - A missing or unreadable file, a file that is not valid JSONC, or a file whose root is not an object becomes an entry in the result's `diagnostics` and is skipped; loading continues. - Unrecognized keys anywhere in a layer are ignored and reported through an `unknown-keys` diagnostic that names each dotted key path (for example `profiles.opus.retired_key`). - A malformed value in any section (the shared top level, `task`, `agents`, `categories`, `teams`, the `[native]` / `[senpi]` / `[codex]` blocks, `profiles`) is dropped on its own: the loader removes only the smallest failing subtree (the wrong value itself, or the object that lacks a required key), keeps every valid sibling at every depth, and reports each dropped key as its own `invalid-value` diagnostic (for example `task.host_engine_policy` or `teams.alpha.members.0.color`). `omo doctor` prints one line per dropped key, for example `WARN config: ~/.omo/omo.jsonc: task.host_engine_policy ignored (invalid value)`; the OpenCode edition's doctor lists the same line as a warning. The OpenCode plugin applies the same rule to the `[opencode]` block against its own schema. - A file with nothing valid left after pruning contributes nothing and keeps its `validation` diagnostic, as does a file carrying a `__proto__`, `prototype`, or `constructor` key at any depth (checked before anything is pruned). - If the merged config fails final validation (a partial team spec that no layer completes, for example), the failing merged values are dropped the same way with an `invalid-value` diagnostic whose path is `(merged omo config)`; only when that cannot settle does the loader return the all-default config plus one `validation` diagnostic instead of throwing (`loader/loader.ts`). ## `$schema` The root schema accepts an optional `$schema` string key (`packages/omo-config-core/src/schema/config.ts`, on both `OmoConfigSchema` and `OmoConfigLayerSchema`); both the per-layer parse (`OmoConfigLayerSchema.safeParse`) and the final merged parse (`OmoConfigSchema.safeParse` in `packages/omo-config-core/src/loader/loader.ts`) carry it through and otherwise ignore it, so an editor pointer is safe to add. A generated JSON schema artifact ships at `assets/omo.schema.json`, produced from `OmoConfigSchema` by the root `build:omo-schema` script (`script/build-omo-schema.ts`, `script/build-omo-schema-document.ts`); run `bun run build:omo-schema` to regenerate it. Point your editor at the raw dev-branch URL: ``` https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json ``` ## Resolution order After the file layers merge, each harness resolves its own view out of the merged document (`packages/omo-config-core/src/loader/resolution.ts`). Later layers win: 1. **Shared base keys** (every top-level key except `profiles` and the bracketed harness blocks). 2. **The `[harness]` block** for the current harness: `[opencode]`, `[native]`, or `[codex]`. 3. **`profiles.`** for the active profile. 4. **`profiles..[harness]`** for the active profile. Schema defaults apply once at the very end, after all four layers fold. Control keys (`profiles`, `[opencode]`, `[native]`, `[codex]`, and the legacy `[senpi]` spelling) never leak into the resolved view. Activating a profile that does not exist yields a `profile` diagnostic and the base configuration. ### Profile activation The active profile name comes from (`resolveOmoProfileName`), highest priority first: 1. `OMO_PROFILE` 2. `OCX_PROFILE` (set by `ocx oc -p `) 3. An `OPENCODE_CONFIG_DIR` whose lexical tail is `profiles/` 4. None (no profile layer applied) No default profiles ship. A profile exists only when you write one under `profiles.` or the migration derives one from a legacy OpenCode profile directory. ### Example ```json { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json", "categories": { "deep-low": { "description": "Deep analysis", "model": "anthropic/claude", "reasoning": "high" } }, "agents": { "reviewer": { "description": "Reviews code", "model": "openai/gpt-5", "execution_mode": "in-process" } }, "task": { "default_execution_mode": "auto", "default_concurrency": 5 }, "teams": { "builders": { "description": "Build team", "members": [ { "name": "quick-one", "kind": "category", "category": "quick", "prompt": "Help" } ] } } } ``` ## Top-level schema ```jsonc { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json", // optional editor pointer "categories": {}, // record "agents": {}, // record "task": {}, // task engine settings "teams": {}, // record "models": {}, // record, shared model catalog "model_profiles": {}, // record, named model chains picked by intent (Native harness) "model_profile": "", // active profile id or a literal provider/model pin (Native harness) "memory": {}, // MemorySettings (Native harness) "git_master": { "include_co_authored_by": true, "commit_footer": false }, // sisyphus-dev-ai co-author trailer (on by default) and opt-in commit footer (Native harness) "telemetry": { "enabled": true }, // telemetry (Native harness), enabled by default "disabled_skills": [], // skill names hidden on every harness, unioned across layers "exclude_models": [], // models no category, agent, profile or task pin may run, unioned across layers (Native harness) "[opencode]": {}, // OpenCode plugin config, freeform (see configuration.md) "[native]": {}, // OmO Native-only overrides, typed base keys "[codex]": {}, // Codex-only overrides, typed base keys "profiles": {}, // record, opt-in named profiles "_migrations": [], // applied migration ids, written by the migration engine "legacy_migrations": {} // imported legacy migration history, engine-managed } ``` Source: `packages/omo-config-core/src/schema/config.ts`. ### Harness blocks `[opencode]` is a freeform record: it carries the full OpenCode plugin configuration documented in [`docs/reference/configuration.md`](./configuration.md) (background tasks, tmux, hooks, skills, and every other plugin key), and the strict schema does not validate its contents. `[native]` and `[codex]` are typed blocks accepting the shared base keys (`categories`, `agents`, `git_master`, `task`, `teams`, `models`, `model_profiles`, `model_profile`, `memory`, `telemetry`, `disabled_skills`, `exclude_models`), so a harness-specific override stays schema-checked. `[senpi]` is the legacy spelling of `[native]`, kept working for one release line. It is canonicalized to `[native]` when the config is read, so a config that cannot be rewritten still applies every value it sets, and the startup migration rewrites the key in the file once, naming it in a notice. When a file carries both blocks, `[native]` wins and the ignored `[senpi]` block is reported as a deprecation diagnostic. ### `disabled_skills` (every harness) The one supported way to turn a skill off. A name listed here is absent from the run: on OmO Native / Senpi it never enters the `` index, the `/skill:` commands, or `get_commands`; on the OpenCode plugin it is dropped from the builtin set and the skill tool. On OmO Native it also covers the skills a feature contributes on its own, `computer-use` and `x-search`: the skill is gone and its tool stays. Unlike other arrays, layers are unioned: the shared base, the `[harness]` block, the user file, the project file, and the active profile all add names, and a project cannot re-enable a skill the user file disabled by omitting it. ```jsonc // ~/.omo/omo.jsonc { "disabled_skills": ["frontend", "visual-qa"] } ``` ### `exclude_models` (Native harness) Keeps a model out of every chain OmO Native picks from. A matching model is treated as if its provider had no login: a category's builtin rungs, its `model` / `models` / `fallback_models`, an agent's chain, an agent that borrows a category, memory children (reflection, dream and facts, including the session model they would otherwise inherit), the main session's `model_profile` walk (a literal pin included), an explicit `model` on a task call, a task child respawned or resumed on its recorded model (its recorded fallback chain is filtered the same way, so a model excluded after the child was planned never runs), and the `-fast` sibling a fast parent would upgrade a child to (the child keeps its planned model instead). A task call that pins an excluded model fails with `model_unavailable` and names the entry that matched. A category or agent whose every rung is excluded becomes unavailable, exactly like one with no connected provider. - `provider/model` matches that provider's copy only: `"opencode/claude-fable-5-1"`. - A bare model id matches it from any provider, including a gateway that nests the vendor in its id (`openrouter/anthropic/claude-fable-5-1`): `"claude-fable-5-1"`. A `vendor/model` entry such as `"anthropic/claude-fable-5-1"` also matches that nested id. - `*` matches any run of characters, and matching ignores case: `"claude-fable-*"` keeps every Fable build out. Like `disabled_skills`, layers are unioned: a project file cannot let back in a model the user file keeps out. Senpi's own mid-session retry chains for the main session (`retry.fallbackChains` in the engine settings) are not covered. ```jsonc // ~/.omo/omo.jsonc { "exclude_models": ["claude-fable-*"] } ``` To see the effect, run `omo doctor --models`. It prints, for every category and task agent, the model a spawn runs first, the runtime retry chain in order, and each declared rung with its source (`built-in`, `omo.json`, or `category:` for an agent that borrows a category) and its state (`runs first`, `fallback N`, `excluded by exclude_models "..."`, `no login`, `not reached`), then the main session's `model_profile` lane. Add `--json` for tooling. The answer comes from the same resolvers spawns use, so it cannot drift from what runs. Logins are read offline and credentials are not probed. Security invariant: the OpenCode plugin honors `mcp_env_allowlist` and `browser_automation_engine.playwright_mcp_args` only from the user layer, including the user layer's own active profile block. Project layers cannot extend them. ### `telemetry` (Native harness) The optional `telemetry` block controls OmO Native product telemetry in Senpi. `telemetry.enabled` is a boolean and defaults to `true`, so telemetry ships enabled. Set it to `false` to turn telemetry off. This setting applies only to OmO Native. `telemetry.rawTurnSampleRate` is a number from `0` to `1`: the share of sessions that also send one `turn_completed` event per assistant turn. Every session always sends its exact totals in `session_summary`. When unset it is `0.05`; the `OMO_TELEMETRY_RAW_TURN_SAMPLE_RATE` environment variable overrides it, and a value outside `0..1` is ignored. Turns a session does not send are never recoverable, so lower it only on purpose. ```jsonc { "[native]": { "telemetry": { "enabled": false } } } ``` The block may also appear at the shared top level or in profile layers and follows the normal resolution order. Because typed config objects are strict, an older `@oh-my-opencode/omo-config-core` version that predates this key rejects a file containing `telemetry` instead of ignoring it. See [Mixed-version compatibility](#mixed-version-compatibility) before sharing one config across versions. ### `memory` (Native harness) The optional `memory` block configures the Senpi memory subsystem (`schema/memory.ts` `OmoMemorySettingsSchema`). Keys: `enabled` (default `true`), `agent` (default `"auto"`), `auto_identity` (default `"path"`; `"git"` makes the auto identity of a linked git worktree the main checkout's, so every worktree of one clone shares that memory - set it in `~/.omo/omo.jsonc` when worktrees live outside the main checkout), the sub-blocks `reflection`, `nudge`, `recall` (the resident, read-only Kibitzer sidecar behind `recalled memory:` notices - one per main session, prompt and tool-call triggered, nudge-only output, no memory writes: `enabled` as the only off switch, `max_items` per wake, `category` defaulting to `quick`, `event_caps` defaulting to `{ tool_args: 400, result_head: 600, assistant: 1500, prompt: 4000 }` for its redacted event feed, `sidecar_max_tokens` defaulting to `48000` with a proactive reseed at 60%, `max_concurrent_wakes` defaulting to `2` as the machine-wide wake lease, `tool_budget` defaulting to `8` read-only tool calls per wake, and `query_expansion` defaulting to `false` for letting the sidecar add discounted synonyms, keywords and related terms to its own memory searches), `facts`, `dream`, `people`, `soul`, `write_notice`, `sync`, `search`, plus `compile_warn_tokens` and per-agent overrides under `agents`. These recall keys can be set at the shared root, harness/profile layer, or per-agent override; layer values are deep-partial and later layers win. Reflection children start without extensions so they stay fast. When the reflection model belongs to a provider that only an extension registers (a custom gateway declared under `packages` in `settings.json`, for example), omo detects that the model is missing from the extension-free model list and starts that child with extensions loaded instead; the child still keeps its own memory component off. If no child can see the model even with extensions loaded, automatic reflection pauses after that first failure and shows one notice. To fix it, point `categories..model` (or `memory.reflection.category`) at a model from a core provider, or set `memory.reflection.enabled` to `false`. `/reflect` retries at once, and the paused state is probed again every six hours. Facts extraction does not launch or drain pending work at startup or relaunch. Automatic memory jobs wait for an accepted, non-extension user prompt in a top-level session in the current engine run; persisted transcript history and task/worker prompts do not grant that permission. Any pending identity-wide queue observed before a bound session's first real settle drains after that turn, regardless of `debounce_settles`. If that queue was empty before the first settle, fresh accumulation keeps the configured debounce. Explicit commands such as `/reflect`, `/dream`, and facts unpark remain user actions. `memory.facts.model` optionally selects a fully qualified `provider/model` for extraction, with a per-identity override at `memory.agents..facts.model`. Without it, an explicitly configured `categories.quick` model or model list keeps its existing meaning for facts; otherwise extraction inherits the live session's selected model. Facts never use built-in quick candidates or arbitrary registry fallbacks. A missing or unavailable selection skips extraction with one warning and leaves the queue pending without adding a failure streak. `memory.project_scope` has two independent switches, `nudge` and `recall`, both defaulting to `true`. A successful read, edit or write under a child project with existing memory lists its note paths and descriptions once per project, re-armed after accepted compaction. Kibitzer ranks up to three recently touched projects separately from the own corpus, with reserved project seats and the same persisted term store per repository. Project notes are read-only; git metadata, `system/`, writes and enumeration stay denied. The switches accept harness/profile layers but no per-agent override. See [Memory from projects the session touches](configuration.md#memory-from-projects-the-session-touches) for scope and symlink rules. ### `git_master` (Native harness) The optional `git_master` block controls how the `git-master` skill runs git in Senpi (`schema/git-master.ts`). When the agent works with the skill — reading it in the main session or loading it into a task child via `load_skills` — omo adds a mandatory "GIT COMMAND PREFIX" section and prefixes every `git` command in the skill's bash blocks with `git_env_prefix`, so your own git hooks can tell git-master is acting (for example, a hook that checks `GIT_MASTER`). The prefix always uses the bash form (`GIT_MASTER=1 git ...`), because Senpi's bash tool runs bash (Git Bash on Windows) whatever your login shell is. It also appends a commit-attribution directive: the co-author trailer by default, plus the body footer when you opt in. Commit identity: commits omo causes in your repository carry your own git `user.name` / `user.email` as author and committer. By default git-master also adds `Co-authored-by: sisyphus-dev-ai ` once per commit, which GitHub shows as a co-author; set `include_co_authored_by: false` to leave it out. | Field | Type | Default | Notes | |-------|------|---------|-------| | `commit_footer` | boolean \| string | `false` | Opt in to the "Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)" footer in the commit body. A string replaces the builtin footer text. | | `include_co_authored_by` | boolean | `true` | Adds `Co-authored-by: sisyphus-dev-ai ` to every commit git-master writes. It goes through `git -c trailer.ifexists=addIfDifferent commit --trailer`, so a commit message that already carries it (an amend or a reworded commit) keeps a single copy; after a squash git-master rewrites the joined message once so it keeps one. Needs git 2.32 or newer (`git commit --trailer`). `false` leaves it out. | | `git_env_prefix` | string | `"GIT_MASTER=1"` | Space-separated `NAME=value` assignments (letters, digits, `_`, `-`) put before every git command git-master runs. `""` turns the prefix off. Same key and default as the OpenCode plugin's `git_master.git_env_prefix`. | To opt in to the body footer: ```jsonc { "git_master": { "commit_footer": true } } ``` To leave the co-author trailer out: ```jsonc { "git_master": { "include_co_authored_by": false } } ``` The block may live at the shared top level, in `[native]`, or in profile layers, and follows the normal resolution order. The OpenCode plugin keeps its own `git_master` key inside the freeform `[opencode]` block (see [configuration.md](./configuration.md)); this typed section applies to the Native harness. ### `side_panel` (Senpi harness) The optional `side_panel` block controls the omo side panel (`schema/side-panel.ts`): a right-hand column in the Senpi TUI carrying session, goal, context, usage, subagent, tool, git and memory state. The transcript reflows into the remaining width instead of being covered. The column needs the **fullscreen TUI** (`--tui-mode fullscreen`, or `tuiMode: "fullscreen"` in senpi's settings); in the default regular mode the same sections render as a block above the editor instead. The panel is **off by default** because it rearranges the whole screen. | Field | Type | Default | Notes | |-------|------|---------|-------| | `enabled` | boolean | `false` | Render the panel. The side-by-side column needs the fullscreen TUI; in the default regular mode the sections render as a block above the editor. | | `width` | number \| string | `"26%"` | Column count, or a percentage of the terminal width between `10%` and `50%`. Clamped to 32-80 columns, and further reduced so the transcript keeps at least 60 columns. | | `min_columns` | integer | `120` | In fullscreen mode, terminals narrower than this keep the classic single-column layout; the panel hides itself rather than squeezing the transcript. | | `clickable` | boolean | `true` | Paint file and subagent rows as OSC 8 links, so a mouse click opens the same viewer a command would. Set it to `false` on a terminal that mangles hyperlinks. | | `usage_poll_seconds` | integer | `150` | Retired. The engine now reads subscription usage itself, on each provider's own cadence, so this value is ignored. It is still accepted so an existing file keeps loading. | | `sections` | object | all `true` except `usage` | Per-section switches: `session`, `goal`, `context`, `usage`, `agents`, `tools`, `files`, `memory`. `usage` is `false` by default (see below). | Rows are clickable because the fullscreen renderer already captures the mouse and activates OSC 8 hyperlinks; the panel paints its rows as links to a private scheme and claims the renderer's URL callback while it is mounted, handing every other URL straight back. Clicking a file opens its diff; clicking a subagent opens its card followed by everything that child recorded, rendered by the task engine itself - the same text `task_output` would give you. `/side-panel-diff` reaches the file viewer by name; it is registered only when the panel is enabled for the run. A child the engine is holding rather than running - a host session whose daemon went away, one caught by a draining host, or simply one the engine detached when its session ended - is counted and drawn as `parked` rather than as running or done, and its card names the reason, because a dead daemon and a draining host ask different things of you. The ordinary detach names no reason at all and shows up only as a residency that is no longer resident, so the column reads both: a child whose record still says `running` while nothing holds it would otherwise sit there with its timer climbing. The card also names the lane a child runs in - a session of the shared daemon, a child process, or the parent's own process - because those three fail in different ways. The viewer scrolls with the wheel as well as with the arrow keys, and while it is open the wheel belongs to it: the host routes a wheel event to whatever sits under the pointer in its layout, and an overlay is not part of that layout, so without this the wheel would scroll the transcript behind the popup instead. Text selection is untouched either way, because the renderer activates a link only on a press and release inside one cell with no drag - dragging still selects, and double or triple clicks still take a word or a line. The `goal` section is drawn only while a goal is registered, so an ordinary session loses no rows to it. It carries the objective, the goal's status, the time and tokens it has spent, how much of a token budget is gone when one is set, and the continuation count once the loop has run unattended. Clicking the objective opens the whole text, which is the point of that row: the column has space for one cut line of it. The block follows the panel's ordinary refresh instead of subscribing, because a goal publishes no event an extension can listen for; an unchanged store costs one `stat` and no parse at all. The `memory` section names the memory identity this session writes to - one machine carries many, and the wrong one is invisible otherwise - and then reports only what is wrong or waiting. It warns when automatic reflection is **parked**: repeated failures trip a circuit breaker that stops reflection until a half-open probe, so the row says when that probe is due, and clicking it opens the whole failure - reason, fingerprint and the host's own detail - which is what tells you whether to run `/reflect` or fix a sandbox. It also carries the resident kibitzer, the sidecar that decides what memory to surface: how many wakes it has settled for this session, how long ago the last one was, and a warning when that last wake failed - a kibitzer failing every wake is memory that quietly stopped being updated. Whether it is awake *right now* is deliberately not shown: that state lives in the sidecar process and is never written down, so the column reports what it did rather than guessing at what it is doing. Below that it counts the facts batches queued but not yet applied, and what recall is holding for this session: nudges waiting for the next prompt, and memory paths already surfaced in it. Healthy memory with an empty backlog is a single line. The block is read on a five-second floor rather than a watcher, because a park takes three failed reflection runs and the queue drains per reflection - nothing here can change between two tool calls of one turn. The `usage` section is **off by default**. Turning it on (`"sections": { "usage": true }`) draws the subscription limits the engine already keeps: senpi reads each signed-in Claude and ChatGPT subscription's usage endpoint itself (about every five minutes for Claude and two and a half for ChatGPT, one request per account per machine however many sessions are open) and updates the same numbers from every turn, so they are there before the first message. The panel makes no network request of its own. It reads `auth.json` and `credential-pool-state.json` from the agent directory only to decide which pooled account is serving, so the numbers it shows belong to that account. On an engine too old to report limits the section stays empty. ```jsonc { "side_panel": { "enabled": true, "width": "24%", "sections": { "usage": true } } } ``` `--omo-side-panel` forces the panel on for one run. It cannot force it off: senpi sets a boolean extension flag to `true` whatever value follows it (`--omo-side-panel=false` still turns it on) and rejects a `--no-` form as an unknown option, so `enabled` in `omo.json` is the switch that can say no. The reflowing column needs the fullscreen TUI (`--tui-mode fullscreen`, or `tuiMode: "fullscreen"` in senpi's settings): only that renderer owns a layout root to wrap. In the default regular mode - and on any host that does not expose the layout seam - the same rows render as a block above the editor instead, and a headless run renders nothing. The `files` section lists the working copy as `git status` sees it, both status columns included. Clicking a row opens that file's diff in a scrollable read-only viewer, and `/side-panel-diff` reaches the same viewer by name - for keyboards, and for a host that hands out no URL hook. No keyboard chord is registered by default. The block may live at the shared top level, in `[native]`, or in profile layers, and follows the normal resolution order. ### `models` (shared catalog) A record of short name to catalog entry (`schema/model-catalog.ts`). The canonical strict shape is `{ model, reasoning? }`. Deprecated `variant` and `reasoningEffort` inputs remain accepted and are normalized to `reasoning`; other tuning fields are not catalog-entry keys. ```jsonc { "models": { "opus": { "model": "anthropic/claude-opus-5-5", "reasoning": "max" }, "fast": { "model": "anthropic/claude-haiku-5-5" } }, "categories": { "deep-low": { "model": "opus" }, // resolves to anthropic/claude-opus-5-5 at reasoning max "quick": { "model": "fast", "reasoning": "high" } // site tuning wins over the entry } } ``` When an agent or category `model` string matches a catalog key, resolution (`models/model-reference-resolution.ts`) swaps in the entry's model id and fills any unset `reasoning` from the entry. Tuning written at the use site always wins. A `[harness]` block (or a profile) can override individual catalog entries for its own view. Catalog cycles are detected and reported as `model_catalog_cycle` diagnostics instead of looping. ### Model profiles (Native harness) A model profile is a named, ordered model chain you pick by lane (Daily / Obsessive × Normal / Heavy) instead of by model id. Two keys drive it (`schema/model-profile.ts`, `schema/config.ts`): | Key | Type | Notes | |-----|------|-------| | `model_profiles` | record | Named chains. `models` entries are the same shape as a category chain: a bare string (`provider/model`, a bare model id, either with an optional `:level` reasoning suffix) or `{ model, reasoning?, ... }`. Fields are optional; the object is strict. `family` / `tier` are metadata only. | | `model_profile` | string | Which chain drives the main session model. Either a lane id (`daily-normal`) or a literal `provider/model` (`anthropic/claude-opus-5-5`). A value containing `/` is a pin, so the pin and the profile share one key. | This is not the `profiles` key. `profiles.` is a config-layer overlay activated by `OMO_PROFILE` (see [Profile activation](#profile-activation)): it changes which configuration is loaded. `model_profiles` and `model_profile` are ordinary base keys inside that configuration: they change which model the main session starts on. A `profiles.` layer may set `model_profile` like any other key, which is the one way the two meet. Four builtin lanes ship (`packages/omo-senpi/src/components/model-profile/builtin-profiles.ts`). Each rung lists every provider that serves the model, so a Copilot-only or gateway-only setup still resolves: | Id | Display name | Chain | |----|--------------|-------| | `daily-normal` | Daily · Normal | `claude-opus-5-5` (medium) -> `kimi-k3` (max) -> `glm-5.3` (max) | | `daily-heavy` | Daily · Heavy | `claude-fable-5-1` (xhigh) | | `geeky-normal` | Obsessive · Normal | `gpt-6-astra` (medium, ChatGPT subscription/API/Copilot/OpenCode), then `gpt-6.1-sol-fast` (medium, ChatGPT subscription/API), then `gpt-6.1-sol` (medium, ChatGPT subscription/API), then `gpt-5.6-sol` (medium, ChatGPT subscription/API/Copilot/OpenCode) | | `geeky-heavy` | Obsessive · Heavy | `gpt-6-astra` (high) | GPT profiles use the same provider coverage as the corresponding task lanes: ChatGPT subscription takes priority over the `openai` API/proxy lane. A user profile can replace the candidate order and reasoning. Provider-qualified user candidates stay on the named provider; an unavailable one advances only to the next user-listed candidate, while a bare model id may match any provider. What happens at session start (`packages/omo-senpi/src/components/model-profile/index.ts`, `resolve.ts`) in OmO Desktop and headless sessions. The interactive TUI does not apply `model_profile` yet; it keeps the model it started with. - `model_profile` unset: Daily · Normal is applied on a fresh session. When none of its rungs resolves on the connected providers, the first of Daily · Heavy, Obsessive · Normal and Obsessive · Heavy that does is applied instead (a ChatGPT-only setup starts on Obsessive · Normal); when none resolves, the notice names Daily · Normal and the current model stays. The apply is not written back to config. Builtin rungs are served only by their ranked providers, so a gateway aggregator's copy of a model (for example OpenGateway's `anthropic/claude-opus-5-5`) is never picked. A saved `model_profile: "recommended"` (retired) starts this same default with one log line and is never reported as unknown, unless your own `model_profiles` defines `recommended`. - A literal `provider/model`: that exact model is looked up in the live registry and applied. - A profile id: the builtin table is overlaid with `model_profiles`, and the first rung the live registry can serve is applied. The notice names the lane, the pick, and the thinking level, for example `OmO Native: model profile "daily-normal" (Daily · Normal) selected anthropic-subscription/claude-opus-5-5 medium; mid-session fallback follows senpi's retry chains`. - A rung counts for a profile only when its first turn could use it, which is resolved at session start: the provider's stored credential (per account when the provider holds several and the engine may rotate them - a pinned account is the only one that counts; with `providers..credentials.rotation: false` in `models.json` or a runtime API key only the default credential counts) and then the model's own request configuration. A saved login that can no longer be refreshed is skipped with the whole provider; a model whose configured headers do not resolve is skipped alone, and its siblings stay eligible. The notice names each skipped provider or model and the recovery for the current surface (`re-authenticate in Provider authentication settings` on the desktop, `/login ` in an interactive session for a headless run); the raw error is never shown, `details.authFailed` carries `{ provider, model, reason }` with `reason` one of `refresh`, `credentials`, `request`. Each attempted account costs one credential resolution, sequentially (a rejected refresh waits for the provider's exchange timeout). A literal `provider/model` is applied without this check. - No rung resolves: a notice lists the chain against this session's model registry and Senpi's default model stays. Absence from the registry is not reported as disconnected auth. - Unknown id: `model_profile "" is not defined; known profiles: ...`. Retired ids (`capable`, `deep-work`, `simple-work`) take this path; there is no alias. The profile is applied only to a fresh session (`reason` is `startup` or `new`) whose model wasn't set explicitly: a `--model` flag, a scoped model, a resumed session, and a fork all keep their own model. Apply is session-scoped; it never writes `settings.json` or `omo.json`. Mid-session model failures follow Senpi's own `retry.fallbackChains`, not the profile chain. Override semantics: a `model_profiles.` entry that matches a builtin replaces it wholesale, with no per-field merge. `"daily-normal": { "display_name": "Best" }` therefore yields a profile with no models, reported at runtime as `defines no models`, rather than the builtin chain under a new label. Any other name adds a profile. Chain entries may name a `models.` entry and expand through the same `resolveModelReferences` path as category chains; a profile named like a catalog entry gets a `shadows a model catalog entry` diagnostic. A bare string in `categories.*.models` or `agents.*.models` that equals a profile id gets a `splicing a profile into a category chain is not supported yet` diagnostic: profiles pick the main session model and never enter a delegated child's chain. ```jsonc { "models": { "opus": { "model": "anthropic/claude-opus-5-5", "reasoning": "max" } }, "model_profiles": { "office": { "display_name": "Office hours", "models": ["opus", "openai/gpt-5.6-sol:medium"] // catalog alias, then a literal with a reasoning suffix } }, "model_profile": "office" // or "daily-normal", or a pin such as "anthropic/claude-opus-5-5" } ``` ### `agents` A record of agent name to definition (`schema/agent.ts`). | Field | Type | Notes | |-------|------|-------| | `description` | string | | | `prompt` | string | | | `model` | string | Sugar for a single-entry `models` list. | | `models` | model entries | Ordered chain; each entry is a bare string or `{ model, reasoning?, temperature?, top_p?, max_tokens?, provider_options? }`. | | `reasoning` | `off \| minimal \| low \| medium \| high \| xhigh \| max \| auto` \| string | Canonical reasoning field. | | `tools` | record | | | `execution_mode` | `in-process \| process` | overrides `task.default_execution_mode`; curated builtin agents remain in-process | | `background` | boolean | | | `max_depth` | int >= 0 | | | `allowed_subagents` | string[] | | | `disallowed_tools` | string[] | | | `max_turns` | int >= 0 | | | `temperature` | number 0..2 | | | `disable` | boolean | | Deprecated keys accepted for back-compat and rewritten by migration: | Old key | Replacement | Notes | |---------|-------------|-------| | `variant` | `reasoning` | Reasoning level or harness-native preset token. | | `reasoningEffort` | `reasoning` | `none` normalizes to `off`. | These are the only deprecated keys the strict agent schema accepts. `textVerbosity`, `fallback_models`, `thinking`, and `maxTokens` are category / model-entry keys, not agent keys (see [Model references and model strings](#model-references-and-model-strings)). #### Codex managed agent roles In the Codex edition, `[codex].agents.` (and `profiles.

.[codex].agents.`) sets the model of a LazyCodex-managed agent role such as `explorer`, `librarian`, `plan`, `metis`, `momus`, or `lazycodex-worker-medium`. Every install and marketplace bootstrap writes `model` and `reasoning` into `$CODEX_HOME/agents/.toml` (`reasoning` becomes `model_reasoning_effort`; `off` becomes `none`, `auto` keeps the bundled effort; a `gpt-6-luna:low` suffix is split the same way). Only the `[codex]` block counts: shared base `agents` hold OpenCode model ids and never reach Codex. Removing an entry returns the role to the bundled default on the next sync, and a role name LazyCodex does not manage produces a warning. ```jsonc { "[codex]": { "agents": { "explorer": { "model": "gpt-6-luna", "reasoning": "low" }, "librarian": { "model": "gpt-6-luna" } } } } ``` #### Builtin agents The Senpi task engine ships four builtin curated agents: `explore` and `librarian` are always spawnable through the task tool with zero configuration, for example `task(subagent_type: "explore", ...)`, while `plan-consultant` and `plan-reviewer` are plan-gated: spawnable only after the user requests the `ulw-plan` workflow, a `.omo/plans/*.md` artifact was touched, and `ulw-execute` was never invoked. They are read-only research and review specialists; implementation and orchestration agents stay category-routed (architecture consults go through `task(category: "architect")`). > **Removed**: `agents.metis` / `agents.momus` and `subagent_type: "metis"|"momus"` no longer resolve to `plan-consultant` / `plan-reviewer`. The one-release alias window closed after 5.0.0-beta.51: a retired id is now an ordinary agent name, so a config key defines a custom agent and an undefined `subagent_type` fails as unknown. Rename them to the canonical ids. | Name | Purpose | |------|---------| | `explore` | Codebase search specialist. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Supports thoroughness levels from quick to very thorough. | | `librarian` | Remote codebase and documentation research: searches open-source repositories, retrieves official documentation, and finds implementation examples via the GitHub CLI and direct documentation retrieval. | | `plan-consultant` | Pre-planning consultant that analyzes requests to surface hidden intentions, ambiguities, and AI failure points. | | `plan-reviewer` | Expert reviewer that evaluates work plans against clarity, verifiability, and completeness standards. | Each builtin carries its own persona prompt, a read-only tool policy, and a per-agent model fallback chain, and is pinned to `execution_mode: "in-process"`. The nine-name allowlist includes a curated `bash` override, but it is not Senpi's general shell: it directly runs only validated read-only `gh` queries and HTTPS `curl` retrievals, with no shell parsing, redirects, output files, uploads, request bodies, or mutating HTTP methods. Direct `edit`, `write`, and mutating LSP tools are excluded. Overriding a builtin. An `agents.` entry matching a builtin overlays the builtin definition field by field: only the fields you set replace the builtin values, and every unset field keeps the builtin default. Names that do not match a builtin are appended as user-defined agents. To pin `explore` to a different model while keeping its builtin prompt and tool policy: ```jsonc { "agents": { "explore": { "model": "anthropic/claude-sonnet-4-5" } } } ``` To hide a builtin from the task tool description and from spawn resolution, disable it: ```jsonc { "agents": { "plan-reviewer": { "disable": true } } } ``` Overriding `execution_mode` on a curated agent is ignored. All other configured fields retain normal field-level overlay behavior, but curated agents remain in-process because the process runner cannot carry their persona instructions or tool policy. User-defined agents keep the configured execution mode. Curated agents and teams. A team member spec naming a curated read-only agent (`kind: "subagent_type"`) is rejected at member validation with this error: ``` curated read-only agent "plan-reviewer" cannot be a team member; delegate via the task tool instead ``` Team members always spawn in `process` mode, which cannot carry the curated persona or tool policy, so delegate to these agents through the task tool instead of naming them as team members. ### `task` Task engine settings. The whole object is optional, but `provider_concurrency`, `model_concurrency`, `state_dir`, `host_idle_exit_ms`, and `reattach_on_reconcile` are optional and remain unset when omitted (`schema/task.ts`). OpenGateway has a built-in unlimited provider concurrency default, so it does not inherit `default_concurrency`. Explicit `model_concurrency` and `provider_concurrency` settings still take precedence. OpenGateway tasks still consume slots under `global_concurrency`. `default_execution_mode: "auto"` (the default) defers the in-process/process choice to the session's own engine host: children run as sessions of that host when the platform is not Windows, `process_runner` is `host`, and the ensured host advertises `session_context` + `generation_handoff`; otherwise they run in-process. The decision is made ONCE per parent session, so a child's mode never changes because the host died later, and an explicit `in-process`/`process` always wins over it. Curated read-only agents (`explore`, `librarian`, `plan-consultant`, `plan-reviewer`) stay in-process regardless. `process_runner: "child-process"` keeps every process child in its own OS process (the only behaviour on Windows), `host_engine_policy` decides whether a daemon running a different engine build is handed over (`upgrade`) or left alone while children run as their own processes (`fallback`), and `host_idle_exit_ms` overrides the idle lifetime of a daemon this client starts. There is no socket key: each session's host socket is derived from the session (`/rpc/shards/p-.sock`, or under `OMO_RPC_SHARD_ROOT`), and `rpc.sock` stays the operator endpoint; see [omo daemon](./omo-daemon.md). | Field | Type | Default | |-------|------|---------| | `default_execution_mode` | `auto \| in-process \| process` | `auto` | | `process_runner` | `host \| child-process` | `host` | | `host_engine_policy` | `upgrade \| fallback` | `upgrade` | | `host_idle_exit_ms` | positive int | unset (the launch profile's tunable) | | `default_concurrency` | non-negative int (0 = unlimited) | `5` | | `provider_concurrency` | record | unset | | `model_concurrency` | record | unset | | `global_concurrency` | non-negative int (0 = unlimited) | effective default `max(8, availableParallelism() * 2)` | | `max_depth` | int >= 0 | `1` | | `residency_max_children` | non-negative int or `"unlimited"` (0 = unlimited) | `"unlimited"`: a parent keeps every child it started. Set a number to cap how many children one parent session keeps resident; at the cap the oldest finished idle child is evicted, and a spawn is refused only when every resident is still running. | | `ttl_ms` | positive int | `86400000` (24h) | | `state_dir` | string | unset (runtime uses `/projects/-/senpi-task`) | | `reattach_on_reconcile` | boolean | unset | | `resume_children` | boolean | `true` | | `warnings.unavailable_categories` | boolean | `true` | | `wait.min_ms` | positive int | `5000` | | `wait.default_ms` | positive int | `60000` | | `wait.max_ms` | positive int | `600000` | | `team.max_members` | int 1..8 | `8` | | `team.max_parallel_members` | int 1..8 | `4` | | `team.max_wall_clock_minutes` | positive int | `120` | | `dag` | object | unset | `task.dag` is an optional block bounding the DAG orchestration subsystem (`schema/task.ts`): `max_nodes_per_run` (`64`), `max_runs_per_session` (`16`, counting only runs that are still active: finished, failed and cancelled runs free their slot), `subscriber_ring` (`1000`), `heartbeat_ms` (`15000`), `history_default_limit` (`256`), `history_max_limit` (`1000`), `retention_days` (`7`), `max_prompt_bytes` (`262144`). `global_concurrency` caps how many tasks run at once per senpi process, across all model and provider lanes combined. It applies only to the senpi engine; OpenCode `background_task` is unaffected (parity is a follow-up). The cap is per process, not cross-process or machine-wide: two senpi processes each get their own budget. A task spills to a later entry in its fallback chain whenever admission cannot seat it in the preferred model's lane — the per-model/provider/default lane limit is reached or its queue is occupied (the common case), or this global cap is full — even though the preferred model never failed. That later entry can be a DIFFERENT provider, with different pricing and different data handling. If that matters to you, remove cross-provider entries from your fallback chains or use single-model chains. No new storage or telemetry is introduced by this setting. `state_dir` defaults to `/projects/-/senpi-task` when unset, so task state never lands in the project's `git status`; a project that already has a `.omo/senpi-task` directory from an earlier release keeps using it (`packages/senpi-task/src/store/project-state-directory.ts`). Completion delivery is not configurable: every child completion is batched with any other ready notifications and steered into the parent's running turn at the next tool-call boundary; see the completion routing table in [`packages/senpi-task/AGENTS.md`](../../packages/senpi-task/AGENTS.md). ### `teams` A record of team name to spec (`schema/team.ts`). Each spec: | Field | Type | Notes | |-------|------|-------| | `version` | literal `1` | default `1` | | `name` | string matching `^[a-z0-9-]+$` | optional | | `description` | string | | | `createdAt` | positive int | epoch ms | | `leadAgentId` | string | required when `members` has more than one entry | | `teamAllowedPaths` | string[] | | | `sessionPermission` | string | | | `members` | 1..8 members | discriminated on `kind` | Each member shares a base (`name` matching `^[a-z0-9-]+$`, optional `cwd`, `worktreePath`, `subscriptions`, `color`, `isActive` default `true`, `backendType` default `in-process`) and one of two `kind`s: - `kind: "category"` requires `category` and `prompt`. - `kind: "subagent_type"` requires `subagent_type`; `prompt` is optional. ### `profiles` A record of profile name to a partial view (`schema/config.ts` `OmoConfigProfileSchema`). Each profile accepts the shared base keys (`categories`, `agents`, `task`, `teams`, `models`, `model_profiles`, `model_profile`, `memory`, `telemetry`) plus `[opencode]`, `[native]`, and `[codex]` blocks of its own: ```jsonc { "profiles": { "kimi": { "categories": { "deep-low": { "model": "kimi-for-coding/kimi-k3" } }, "[native]": { "agents": { "plan-reviewer": { "model": "kimi-for-coding/kimi-k3" } } } } } } ``` Profiles are inert until activated (see [Profile activation](#profile-activation)). When active, the profile's base keys fold over the shared base, and the profile's harness block folds over the top-level harness block. A config profile is a different thing from a model profile: see [Model profiles](#model-profiles-native-harness). ### Model references and model strings `model` accepts either a catalog alias or a provider-prefixed string. Reasoning levels can be written inline with a `:level` suffix, for example `openai/gpt-6-astra:xhigh`. The suffix is canonical; the older `model(xhigh)` and `model xhigh` forms remain accepted during the back-compat window and are normalized by migration. `models` is the shared ordered chain shape used by categories, agents, and harness blocks. Each entry may be a string or a model object. Object entries use the canonical fields documented above, including `reasoning` and `provider_options`. Deprecated chain keys are still accepted for now, but they map to `models` and the canonical `reasoning` field: | Old key | Replacement | |---------|-------------| | `fallback_models` | `models` | | `variant` | `reasoning` | | `reasoningEffort` | `reasoning` | | `thinking` | `reasoning` + `provider_options` | | `textVerbosity` | `provider_options.textVerbosity` | | `maxTokens` | `max_tokens` | The migration engine rewrites the persisted config in place, and doctor reports any leftover deprecated keys with their exact file and key path. ## Example ```jsonc // .omo/omo.jsonc { "task": { "default_execution_mode": "auto", "default_concurrency": 4, "wait": { "default_ms": 90000 } }, "categories": { "deep-low": { "models": [ { "model": "anthropic/claude-opus-5-5", "reasoning": "high" }, "anthropic/claude-sonnet-4-5" ] } }, "agents": { "researcher": { "description": "Read-only investigator", "execution_mode": "process", "tools": { "task": false } } }, "teams": { "reviewers": { "leadAgentId": "lead", "members": [ { "kind": "category", "name": "quick", "category": "deep-low", "prompt": "Review the diff." } ] } } } ``` ## Migration from legacy files Before the unification, the OpenCode plugin read a walked `oh-my-openagent.json[c]` / `oh-my-opencode.json[c]` chain and the Codex/Senpi harnesses read `~/.omo/config.jsonc`. Those files are history: a lock-and-journal migration engine imports them into `omo.jsonc` once, and nothing reads them at runtime afterward. - The legacy OpenCode user file imports into `~/.omo/omo.jsonc` under `[opencode]`; each legacy `profiles//` directory becomes `profiles.."[opencode]"` holding only the keys that differ from the user file; project `.opencode/` files import into that project's `.omo/omo.jsonc`. - `~/.omo/config.jsonc` imports its `[opencode]` / `[codex]` blocks; a legacy `[omo]` block maps to `[native]`. - No-clobber: a value already present in the target wins, and skipped legacy values surface as diagnostics. Legacy migration history is preserved under `legacy_migrations`, and applied migrations are marked in the target's `_migrations` array (`2026-07-opencode-config-unification` for the `oh-my-*` files, `2026-07-codex-config-jsonc` for `~/.omo/config.jsonc`, and `2026-08-reasoning-unification` for persisted model and reasoning fields). - Sources move to `~/.omo/migration-backup--opencode-config/` (project sources to `/.omo/migration-backup-/`). - Triggers: OpenCode plugin startup, Senpi startup, and install run both migration groups; Codex startup runs only the `config.jsonc` group; `oh-my-openagent config migrate` runs both on demand (`--dry-run`, `--json`). Full user-facing detail: [`docs/reference/configuration.md`](./configuration.md#migration). ## Mixed-version compatibility The unified file is read starting with oh-my-openagent 5.0.0 (current `5.0.0-beta.13`): the OpenCode plugin, the Senpi adapter, and the Codex plugin at 5.0.0 or later all load `~/.omo/omo.jsonc` plus walked project `.omo/omo.jsonc` and nothing else. Harnesses from 4.x still read the legacy files, which the migration has moved into the backup directory. One sharp edge when mixing versions: every schema object is `.strict()`. A pre-unification copy of `@oh-my-opencode/omo-config-core` rejects an `omo.jsonc` that contains keys it does not know, which includes `models`, `profiles`, and the `[opencode]` / `[native]` / `[codex]` harness blocks. An older strict core handed a newer unified file fails validation on those keys instead of ignoring them. To downgrade: 1. Quit every running harness so no migration or config write is in flight. 2. Restore the legacy files from the newest `~/.omo/migration-backup--opencode-config/` directory (and `/.omo/migration-backup-/` for project files): each backup holds the legacy sources at their original relative paths, so copy them back to where the backup tree mirrors them. 3. Remove or rename `~/.omo/omo.jsonc` if the older harness must not see it, then install the older version. To re-upgrade later, delete the restored legacy files or let the migration re-import them; existing values in `omo.jsonc` still win under the no-clobber policy. ## Follow-ups - `member.backendType: "tmux"` and non-project (user-global) team storage are schema-level only and are not exercised by the current Senpi runtime; use `in-process` members in project `.omo/` teams.