Brand oModel · command
omodel· Python packageomodel· repo~/proj/oModelSelf-contained: a published Python package that needs only Python + theopencodeCLI at runtime.
what omo suggests + what you already have → pick one → save a clean config.
Per agent/category you see omo's fallback chain, filtered to what you can actually run —
each recommended model you have (exactly, or via a same-line substitute like glm-5 → glm-5.1),
resolved to a provider you're connected to. You make one small decision (pick a model), and oModel fills in
the fiddly parts for you: the correct provider/ prefix and a valid variant (both overridable, and
it never blocks you — just ⚠-warns). See your options, choose, done. Everything below is just the
detail that makes those three steps reliable.
~/.config/opencode/oh-my-openagent.jsonc sets a model (and optional variant) per agent
(sisyphus, hephaestus, oracle, …) and per category (deep, quick, writing, …), plus nested
sub-models like sisyphus.ultrawork. Today the file carries a big hand-curated palette of
commented-out alternatives; switching means hand-editing JSONC and remembering the right
provider/ prefix and the right variant. That manual edit is the pain.
Goal: a TUI that, per agent/category, shows the current model and a candidate list built from what omo suggests + what you actually have + free text, applies the correct provider prefix and a valid variant, and saves a clean config.
- Python ≥ 3.9 (
importlib.resources.files). Pin Textual to a release whose ownrequires-python≤ our floor (verify at lock time, else bump floor to 3.10). opencodeCLI onPATH— the source of "what you have". Degrades gracefully if missing or failing.- No dependency on a local omo checkout or omo cache at runtime.
bun(NOT node) is required only for the optionalomodel --refresh-omo— see §Refresh. Verified:node --experimental-strip-typescannot run omo's modules (extensionless relative imports →ERR_MODULE_NOT_FOUND); bun resolves them.
| # | Decision | Choice |
|---|---|---|
| 1 | Stack | Python ≥3.9 + Textual. Self-contained; no runtime coupling to omo source or cache. |
| 2 | Save format | Edit-in-place: only agents/categories are rewritten clean; everything else — other keys, formatting, comments, commented-out config — is preserved byte-for-byte (render() splices just those two spans). Timestamped backup each save (.backup/<ts>.jsonc). |
| 3 | Picker | One pick list = the fallbackChain, filtered to models you have (exact, else newest same-line detect_family substitute; unavailable entries hidden), expanded to one row per serving provider — dedicated (single-vendor) before aggregator/gateway. enter to pick (the row's prefix is what saves); a + add model… row (a) types anything off-chain. Suggested variant. |
| 4 | Layout | Two-pane list-detail. |
| 5 | Availability flagging | Invalid variant: warn but allow (saves with ⚠). Unavailable fallbackChain entries: hidden from the pick list (decision #3) — a model you can't run isn't offered; a user-typed + add model… that's unavailable still ⚠-warns and saves. |
| 6 | Agent coverage | omo-specific only (11 with requirements). |
| 7 | Categories | omo's known set only (8 with requirements). |
| 8 | Prefix rule | Dedicated-first. A provider is a gateway if its opencode models set spans ≥2 vendors; single-vendor providers are dedicated. The pick list shows every serving provider, dedicated before gateway (first-seen within each tier — _ordered_providers), so you choose the prefix by picking the row. (resolve_prefix still auto-prefixes a bare id typed in the add-model modal: dedicated[0], else a gateway via providers order then first-seen.) |
| 9 | Suggestion data | Bundled in the wheel (importlib.resources); user-override dir supported. |
| 10 | Availability source | Live opencode models CLI — not omo's cache, not auth list (see §Data sources). |
| 11 | Refresh | omodel --refresh-omo regenerates the suggestion JSON via bun + an omo checkout. |
| 12 | Distribution | GitHub-only (no PyPI): PyInstaller binary + install.sh primary; pipx/uvx from git secondary. |
| 13 | First save | Deletes the commented-out palette inside agents/categories (those spans are rewritten clean); comments / commented-out config outside them are kept verbatim. The whole original is pinned verbatim as .backup/original.jsonc (never pruned). |
| 14 | Variant validity (pickers) | opencode --verbose (cached) is the source of truth for the add-model + v pickers (Catalog.variants_for): per-(provider, model) variants keys; prefer the first NON-EMPTY set across the picked provider then the gateway (dedicated providers report {}); empty everywhere / uncached → offer nothing, no heuristic fallback (kimi, glm-5 → no variant step). A none in that set is dropped as a duplicate of the synthetic (none) clear row (_is_no_variant) — never offered, never written (none ≡ (none) ≡ no variant key). The bundled family registry stays the source for detect_family/substitution; the omo-suggestion ⚠ warn (resolve._variant_warn) also prefers --verbose now — the heuristic family variants is its fallback only when opencode is silent (dedicated {} / uncached) — but the registry is never the source for what the pickers offer. (Reverses the old "registry only, never --verbose" rule.) |
| 15 | Availability cache | opencode CLI output cached 24h at ~/.cache/omodel/ (flat: models.json, verbose-<prov>.json); read-through in catalog. r / --refresh-models bust + rebuild it. Detail fetch is off the UI thread and capped to one concurrent (each opencode call is ~3s / ~320 MB). See §cache.py. |
| 16 | Undo | In-session undo/redo of every edit (u / ctrl+r) for mis-press recovery — a snapshot stack of cfg states (history.py), separate from the on-disk .backup/ (decision #2). Each edit (set/clear/variant/add-model/add-sub/delete-sub) records a labelled snapshot; dirtiness is computed (serialize(cfg) vs last-saved text), so undo-to-saved reads clean. See §history.py. |
- What you have (runtime): parse
opencode models→ linesprovider/model(split on the first/). Group →available = {provider: [model_ids]}(first-seen order);connected = list(prefixes)(first-seen order, never a set). Verified prefixes:opencode deepseek moonshotai-cn openai zhipuai(79 models today — count varies; tests must not hard-assert it). Error rule (one definition, used bycatalog.loadtoo):opencodenot onPATH→ banner + suggestions/add-model only; else exit code ≠ 0 or zeroprovider/modellines parsed → raiseCatalogUnavailable→ banner "couldn't read models", offer retry (r), degrade. (There is no other "partial" state.)opencode models --refreshis exposed asomodel --refresh-models, which also rebuilds the local cache (§cache.py). - Why not
opencode auth list: it prints provider display names ("Moonshot AI (China)", notmoonshotai-cn) wrapped in box-drawing/ANSI with no--json/plain flag (verified) — fragile, and would need a name→ID map.opencode modelsalready yields the usable provider set as clean IDs in one call (a provider appears only if it can serve models = exactly "usable"). oModel never callsauth list;connected(above) is the logged-in/usable set, shown as anoModel:header line. (auth list's only extra info — api/oauth method, and providers logged-in-but-serving- zero-models — isn't needed for resolution or flags.) - Per-model detail (on demand):
opencode models <provider> --verboseemits, per model, a bareprovider/modelheader line followed by a multi-line pretty-printed (2-space) JSON block (~80 lines, incl. a nestedvariantsmap). Parser: a header line matches^(?P<prov>[a-z0-9_-]+)/(?P<model>\S+)$at column 0; brace-count each following block andjson.loadsit. (Verified: bareprovider/modelstrings never appear at column 0 inside a block — they're always quoted values — so brace-counting from each header is unambiguous.) Uselimit.context,cost.input/output(may also carrycost.cache.{read,write}; free models show$0),capabilities.reasoning,capabilities.input.imagefor the detail pane display only.--verbose.variants(a per-model object whose KEYS are the variant names) is the variant source of truth for the model pickers (Catalog.variants_for, decision #14) — read it;--verbose.familyis still never read (family stays heuristic). Caveat that shapesvariants_for: the object is empty ({}) for the dedicated providers (zhipuai, moonshotai-cn) while populated by the gateway (opencode) and openai — sovariants_forprefers the first NON-EMPTY set across the picked provider then others, treating{}as "ask another endpoint", and offers nothing only when it is empty everywhere (kimi) or uncached. - What omo suggests (bundled, build-time):
omo-suggestions.json, generated from~/source/oh-my-openagent/packages/model-core/src/(verified importable & serializable under bun: 11 agents, 8 categories, 15 families, 9 variants). Schema the app consumes:{ "meta": {"omoVersion":"","omoCommit":"","generatedAt":""}, "agents": {"<name>": {"fallbackChain":[{"providers":[],"model":"","variant":""}], "variant":"", "requiresProvider":[], "requiresModel":"", "requiresAnyModel":false}}, "categories":{"<name>": {"fallbackChain":[], "variant":""}}, "families": [{"family":"","pattern":"<RegExp.source|null>","includes":[],"variants":[], "reasoningEfforts":[],"reasoningEffortAliases":{},"supportsThinking":false}], "knownVariants": ["low","medium","high","xhigh","max","minimal","none","auto","thinking"] }requiresProvider/requiresModel/requiresAnyModelare carried but IGNORED (they gate omo's auto-activation; oModel is a manual picker).patternis stored as a string andre.compiled at load (negative-lookaheads likek2(?![-.]?p\d)verified to compile under Pythonre). - Your config (runtime):
$XDG_CONFIG_HOME/opencode/oh-my-openagent.jsonc(fallback~/.config/...),--configoverride; scaffold a bundled starter if missing.
omodel # launch the TUI
omodel --config PATH # use a specific config file
omodel --restore # list recent backups (newest 10) and restore one
omodel --refresh-omo [--omo-src P] # regenerate bundled suggestion data from an omo checkout (bun required)
omodel --print # print current resolved agent/category models, no UI
omodel --check # dry-run: resolve candidate lists for every target, exit 0 (CI-safe; degrades to suggestions-only if `opencode` absent)
omodel --refresh-models # force `opencode models --refresh` + rebuild the ~/.cache/omodel cache
omodel --version
CLI error behavior: a malformed (unparseable) config makes the TUI launch and --print exit 1 with
a one-line friendly message + a "fix the file or omodel --restore" hint (ConfigParseError from
config_io.load_config) — never a raw json5 traceback. (--check never parses the config, so it
stays exit-0/CI-safe regardless.) --restore's interactive prompt treats Ctrl-D/Ctrl-C as
"Cancelled." (exit 1) rather than crashing. --config accepts a bare relative filename (scaffold
resolves the parent via abspath — dirname("x.jsonc") == "" used to crash makedirs).
oModel: opencode · deepseek · moonshotai-cn · openai · zhipuai
┌────────────────────┐┌────────────────────────────────────────────┐
│ AGENTS ││ sisyphus │
│ > sisyphus kimi ││ model: moonshotai-cn/kimi-k2.7-code │
│ ↳ ultrawork opus ││ variant: — ctx 256k · $0.6/$2.5 │
│ hephaestus gpt │└────────────────────────────────────────────┘
│ oracle gpt │┌────────────────────────────────────────────┐
│ momus gpt ││ opencode/claude-opus-4-7 (max) │
│ ... ││ openai/gpt-5.5 (medium) │
│ CATEGORIES ││ opencode/gpt-5.5 (medium) │
│ deep gpt ││● zhipuai/glm-5.1 (≈ omo glm-5) │
│ quick mini ││ + add model… │
└────────────────────┘└────────────────────────────────────────────┘
s save · ? help · q quit v0.1.0
Each region is a bordered card; the focused pane's border brightens to $primary, while blurred
panes use a muted $surface-lighten-3 border — a theme token (not a literal), chosen over
$border-blurred, which the default textual-dark theme renders near-black on a dark terminal.
Static#providers / Static#hints-bar are full-width bars (not cards) with a neutral $surface-lighten-1
fill (deliberately not the blue-gray $panel), and Static#detail is display-only — it shows the
frame but never the focus highlight (Statics never receive focus; only #targets and #candidates do).
Color depth: the CLI pins TEXTUAL_COLOR_SYSTEM=256 (in cli._default_color_system, set
before app imports Textual) so the palette is consistent across terminals — a terminal with no
$COLORTERM and a bare TERM=xterm is otherwise auto-detected as only 16 colors and the UI
collapses to its ANSI slots, looking nothing like a xterm-256color session. Overridable:
TEXTUAL_COLOR_SYSTEM=truecolor for 24-bit, =auto to restore Textual's own detection.
The bottom hint bar (Static#hints-bar) is minimal and static: the keys s save · ? help · q quit sit
at the left, the app version (v<version>, a right-aligned Static#hints-version) at the tail — the
three keys you won't discover by convention and that act regardless of focus. It never tracks
pane / row / undo state. Every other key lives in the ? help overlay (HelpModal), a
scrollable full-reference modal. See §Textual contract.
oModel/
pyproject.toml # hatchling; [project.scripts] omodel = "omodel.cli:main"
README.md LICENSE NOTICE CHANGELOG.md
install.sh # curl|sh: detect os/arch → download release binary → ~/.local/bin
src/omodel/
__init__.py
cli.py # argparse: default → TUI; --config/--restore/--print/--check/--refresh-omo/--refresh-models
app.py # Textual two-pane App (see §Textual contract)
catalog.py # availability via `opencode models`; verbose-record parser; providers_for(); refresh()
cache.py # 24h on-disk cache of opencode stdout (~/.cache/omodel); read-through by catalog
suggestions.py # load bundled/override omo-suggestions.json; detect_family(); variants
resolve.py # prefix (prefer-dedicated), variant defaulting/validation, candidate assembly
config_io.py # read jsonc (json5) → dict; serialize(); diff+confirm save; .bak; scaffold
history.py # in-session undo/redo: snapshot stack of cfg states (u / ctrl+r)
refresh.py # locate omo src + bun; run extractor; write repo or user-data override
data/
omo-suggestions.json # BUNDLED, committed (regenerated by --refresh-omo)
default-config.jsonc # BUNDLED starter — oModel's OWN minimal template (not vendored)
tools/
snapshot_omo.ts # BUNDLED extractor (oModel's own code; imports omo at maintainer time)
tests/
test_catalog_parse.py # mocked `opencode models` + multi-block `--verbose` records
test_resolve.py # prefer-dedicated order, variant validity, ⚠ flags
test_detect_family.py # parity vs omo (kimi vs k2p#, opus vs non-opus, gpt-5 vs o-series)
test_config_io.py # clean rewrite preserves non-model sections; .bak; comment loss
test_history.py # undo/redo stack: change detection, deep-copy isolation, cap
test_app_pilot.py # Textual App.run_test() set + save + undo/redo via queryable IDs
.github/workflows/
ci.yml # lint + tests (opencode + bun mocked; no omo source needed)
release.yml # on tag → PyInstaller one-file binary → attach to GitHub Release
refresh-suggestions.yml # checkout omo @ pinned tag → bun extractor → PR on change
targetid (string):"agent:<name>","agent:<name>.ultrawork","agent:<name>.compaction", or"cat:<name>"— identical to the §TextualOptionList#targetsoption IDs.source(string enum):"omo"(afallbackChainentry — exact or same-line substitute) ·"add"(typed in the add-model modal). ("mine"retired — no connected-model dump.)- candidate row — dict yielded by
candidates()and rendered byapp.py:The value written to config is{ "source": "omo" | "add", "model": "glm-5.1", # RESOLVED bare id actually used (the substitute, # for a same-line stand-in), no prefix "provider": "zhipuai", # one serving provider; candidates() emits one # ROW PER provider, dedicated-first (non-empty str) "variant": "max" | None, # per precedence; None = unset "entry": {...} | None, # the omo fallbackChain entry; None for 'add' "substitute_for": None | "glm-5", # None = exact; else the omo id this same-line row fills "warn": [] | ["variant"], # 'omo' rows: variant only; 'add' rows may add "unavailable" }f"{provider}/{model}"plusvariant(omitted whenNone).
load():opencode models→available(dict) +connected(list, first-seen order — never a set). Per the §Data sources error rule: exit code ≠ 0 or zeroprovider/modellines parsed → raiseCatalogUnavailable(UI shows banner + retry);opencodenot onPATH→ empty + banner.providers_for(model_id)→ connected providers that have it, in first-seen order.detail(model_id, provider=None): query<provider>= the requestedproviderwhen it serves the model — the detail pane passes the current assignment's provider, so anopencode/xassignment shows the gateway's record (cost can differ per provider), never silently the dedicated provider's — else the model's resolved provider (first ofproviders_for(model_id)); runopencode models <provider> --verbose; split records on header lines^(?P<prov>[a-z0-9_-]+)/(?P<model>\S+)$(col 0), brace-count each block,json.loads, and pick the record whose header ==<provider>/<model_id>→{context, cost, reasoning, image}for the detail pane (display only). This is a ~3s subprocess, soapp.pycalls it from a background worker (cached per(provider, model), debounced) — never on the UI thread (see §Textual two-pane contract).
- Both opencode subprocesses (
opencode models~3s, andopencode models <prov> --verbose~3s / ~320 MB RSS) are cached 24h under~/.cache/omodel/($OMODEL_CACHE_DIR→$XDG_CACHE_HOME/omodel→~/.cache/omodel), flat:models.json+ oneverbose-<provider>.jsonper provider. Each file wraps stdout as{version, fetched_at, args, stdout}— explicitfetched_at(not mtime; survives copies) and aversionthat auto-invalidates on format change. Reads tolerate missing/corrupt/expired (→ miss); writes are atomic (os.replace) and swallow errors, so a non-writable cache never breaks the app.clear()removes only*.json(+ orphaned*.tmp-*), never foreign files. catalog.load()/detail()read through it (use_cache=True). opencode presence is checked first, so "not onPATH→ empty" (above) is unchanged — the cache is a perf layer, not an availability fallback. A live, successful run rewrites the cache; every opencode call carries atimeout=.catalog.refresh()— therkey /omodel --refresh-models— runsopencode models --refresh(network re-fetch), clears the cache, and rewritesmodels.jsonfrom the result. The TUI runs it in a worker (off the UI thread);ris documented in the?help overlay (theoModel:header shows only the connected list — no cache-age suffix).- Memory safety (load-bearing): a spawned opencode subprocess can't be killed, so the detail fetch
is capped to one concurrent (a
_detail_fetchinggate; on completion the worker re-renders the current target, which schedules the next — "chase the cursor"). Uncapped/un-stubbed, stacked ~320 MB--verboseprocesses OOM'd a machine; a refresh bumps a generation counter so an in-flight fetch discards its now-stale result. Tests stubsubprocess.runand isolate the cache dir (tests/conftest.py→$OMODEL_CACHE_DIR). - Quit never blocks on a subprocess: both the detail fetch and the
rrefresh run their blocking call via_to_thread_daemon(app.py) — a daemon-thread analogue ofasyncio.to_thread.to_thread's non-daemon executor threads are joined at interpreter shutdown, which madeqhang until the in-flight call finished (up to its 20s/90s timeout); a daemon thread lets the process exit immediately, and the orphaned opencode child just finishes on its own (nothing awaits it).ris additionally single-flight (_refresh_inflight): a second press notifies "already running" instead of spawning a second worker —@work(exclusive=True)only cancels the prior asyncio task, not the subprocess, so two live--refreshruns would racecache.clear()/write()(last finisher wins).
- Load order: explicit
patharg →$OMODEL_SUGGESTIONS(both unconditional) → the newer of$XDG_DATA_HOME/omodel/omo-suggestions.json(from a past--refresh-omo) and the bundledimportlib.resources.files("omodel.data")/"omo-suggestions.json", compared bymeta.generatedAt(ISO-8601 string compare; missing/unparseable/unreadable → oldest; ties → bundled) — so a stale user-local snapshot can never shadow newer bundled data after an app upgrade. detect_family(model_id)— faithful port ofdetectHeuristicModelFamily: ordered iteration offamilies,patterntested beforeincludeswithin each entry, first match wins; runnormalize_model_idfirst (re.sub(r"\.(\d+)", r"-\1", s).lower()→kimi-k2.7→kimi-k2-7). Patterns pre-re.compiled. (Parity matters:openai-reasoningbeforegpt-5,kimi-thinkingbeforekimi,claude-opusbeforeclaude-non-opus.)- Entry shape retained: each
fallbackChainitem keeps{providers[], model, variant?, …}— theprovidersarray (omo's per-model preference order) is kept for the gateway tie-break inresolve_prefix. FAMILY_VENDOR— hardcoded dict insuggestions.py(NOT from omo; omo has no such table). The complete 15-family → vendor map used byvendors_served. The authoritative table is theFAMILY_VENDORdict insrc/omodel/suggestions.py— read it there; not duplicated here (it drifts).vendor(family) = FAMILY_VENDOR.get(family)→Nonefor unknown/None. Models whosedetect_familyisNone(opencode'sbig-pickle,*-free,nemotron-*— no omo family; note omo 4.13 added aqwenfamily, soqwen3.x-plusnow detectsqwen→alibabaand is no longerNone) contribute no vendor and are skipped invendors_served; do not invent a family for them.
- Gateway detection (
vendors_served): for each connected providerp,vendors_served(p) = len({ vendor(detect_family(m)) for m in available[p] } - {None})using the completeFAMILY_VENDORmap (§suggestions.py).pis a gateway iffvendors_served(p) ≥ 2, else dedicated;gateways = {p for p in connected if vendors_served(p) >= 2}is computed once at load. Data-driven, no hardcoded provider list —opencode/openrouter/vercel/github-copilot(and any future) self-classify;openai's three families all map to vendoropenaiso it counts as one = dedicated. Verified live:opencode→8 vendors→gateway;openai/zhipuai/moonshotai-cn/deepseek→1→dedicated. resolve_prefix(model_id, source, entry=None)(dedicated-first): mine → its provider; elsecands = providers_for(model_id);dedicated = [p for p in cands if p not in gateways]→ pickdedicated[0](first-seen) if any; else only gateways serve it → walkentry.providersand pick the first that is incands, elsecands[0]. NB:entry.providersare omo-world IDs (anthropic,github-copilot,vercel,zai-coding-plan, …) that rarely intersect the user'sconnectedset, so thecands[0]first-seen fallback is the common path; both branches range overproviders_for(availability IDs), never raw omo IDs.candidates()no longer calls this — it lists every serving provider (_ordered_providers);resolve_prefixnow only auto-prefixes a bare id typed in the add-model modal. Verified:gpt-5.5→openai/…,claude-opus-4-7→opencode/…(only gateway has it),kimi-k2.5→moonshotai-cn/…,glm-5→zhipuai/…. (kimi-k2.5/2.6andglm-5/5.1exist under both opencode and a dedicated provider — dedicated heads the list; add a second gateway likeopenrouterand it appears as just another row after the dedicated one.)_ordered_providers(model_id)→ list: every connected provider serving the model, dedicated (single-vendor) before aggregator/gateway, first-seen within each tier ([]if none).candidates()emits one row per provider in this order —glm-5→zhipuai/glm-5thenopencode/glm-5;gpt-5.5→openai/gpt-5.5thenopencode/gpt-5.5— so the prefix is chosen by picking the row (nop-cycling).candidates(target): one pick list — a single filtered pass over thefallbackChain, in chain (priority) order. For each entry: (1) exact — a connected provider serves the entry's model, tolerating./-spelling and a trailing date stamp / sub-version tag (a provider'sclaude-haiku-4-5-20251001orclaude-sonnet-4-8-jibaofills the bareclaude-haiku-4-5/claude-sonnet-4-8) → that concrete available id (substitute_for=None). A real modifier token omo itself uses (mini/fast/nano/flash/…, derived from the chain ids) is not stripped, and a short trailing digit stays a version (glm-5.1≠glm-5); (2) same-line — else the newest connected model of the samedetect_family(version-agnostic:glm-5→glm-5.1; "newest" = highest digit-tuple, ties → first-seen) — except within the coarseclaude-non-opusfamily (haiku, sonnet, fable, mythos, …) the substitute must also share the product-line token, so a haiku slot is never filled by a sonnet (nor a fable by a mythos) — (substitute_for=<omo id>); if that newest same-line model is itself an exactly-available chain entry, this entry is skipped (deferred to that model's own exact row) — never demoted to an older same-line model (so an unavailableminimax-m3resolves to the newestminimax-m2.7you have, not an olderminimax-m2.5); (3) else hidden (neither exact nor same-line connected — a model you can't run isn't offered). Each entry id first passes through a hardcoded omo-id alias (_OMO_MODEL_ALIASES, oModel-only — omo has no such table):k2p5(a provider's dot-free spelling of kimi-k2.5) is treated as exactlykimi-k2.5, overriding omo's heuristic that would file thep<digit>suffix under the kimi-thinking family and pull in a kimi-k2-thinking model. The alias acts only here incandidates();detect_family/normalize_model_idstay a faithful port. Each resolved model expands to one row per serving provider (dedicated-first,_ordered_providers); rows are then deduped by resolvedprovider/model(higher-priority entry/provider wins). Variant precedence: entryvariant→ requirement top-levelvariant→ none (the family registry only validates variants — designates no default — so an unspecified variant stays unset; set one viav). (Top-level requirementvariantis presently always empty in omo, so exercise that tier with a synthetic fixture, not a real ID.) Last row is+ add model…(cand:add) for off-chain picks;enteron any non-addrow stages it. Flag:⚠ variant(variant unsupported for the row's (provider, model): checked against opencode--verbosewhen it lists a non-empty set, else the bundled familyvariants). (Unavailable entries are hidden, not flagged — decision #5.) Current pick (●): the row whose resolvedprovider/modelequals the target's current assignment inself.cfg— at launch that's whatoh-my-openagent.jsonchas on disk, and it follows your selection as you stage edits — is prefixed●; all other rows get a 2-space prefix. If the current model isn't in the (chain-only) list (an off-chain hand-pick — a custom model set in a prior session / by hand, or one that has dropped off the chain),app.pysurfaces it as its own row just before+ add model…(built fromself.cfg; ⚠-flaggedunavailableonly when the catalog is readable and the assigned provider doesn't serve the model — suppressed in degraded mode, where availability is unknown) so the configured model is always shown and re-selectable, and that row carries the●(see_build_rows). The picker proper stays chain-only; this single extra row is the current assignment, never a connected-model dump.- GPT-only agents (Hephaestus): omo's
no-hephaestus-non-gpthook makes Hephaestus GPT-exclusive (isGptModel= model name after the last/, lowercased, contains "gpt"; a non-GPT model reassigns the session to Sisyphus). oModel mirrors this foragent:hephaestus[.sub]: the+ add model…row stays, but the add modal is gated — a non-GPT model is blocked (enter disabled,⚠ Hephaestus is GPT-only), so you can pick any GPT model you have but can't footgun a non-GPT one; the detail pane shows a⚑ GPT-onlytip. Encoded as_GPT_ONLY_AGENTS+_is_gpt_modelinapp.py(matching omo's hard-coded agent key, not a data field —requires*are activation flags, not user-choice restrictions).
- Read
json5.load→ ordered dict;agents/categoriesare editable, all other top-level keys (claude_code,experimental,team_mode,$schema, future) pass through. The on-disk write is text-preserving (render, below): only theagents/categoriesvalue spans are rewritten; the rest of the file — other keys, formatting, and any comments / commented-out config outside those two (e.g. a//"skills": falseline withinclaude_code, or a parked top-level block) — is kept byte-for-byte. The commented palette inside agents/categories is still dropped (those spans are rewritten clean); only.backup/original.jsoncretains it. serialize(cfg) -> str(exact): the canonical clean form — used for dirtiness (_is_dirty=serialize(cfg) != _saved_text, both sides this function, never the on-disk bytes) and as the from-scratch / fallback writer; the actual on-disk write goes throughrender. (1) build an ordered dict preserving on-disk key order, but force$schemato position 0 if present; (2) withinagents/categories, a freshly-added sub-key (ultrawork/compaction) is appended to the end of its parent object, a cleared field is deleted; (3)body = json.dumps(cfg, indent=2, ensure_ascii=False)— notejson.dumpscannot emit comments, do not try; (4) return"// Generated by oModel — edit via \omodel`\n"+ body + "\n"(single trailing newline). Editable units: each agent'smodel/variant, itsultrawork/compaction{model,variant}; each category'smodel/variant`. Example output head:render(cfg, base_text) -> str(the write form): returnsbase_textwith only the top-levelagentsandcategoriesvalue spans replaced by their clean form (json.dumps, comment-free,_clean_agents-cleaned), re-indented under the key. Everything else splices through verbatim — comments, commented-out config, other keys, key order, formatting. A small JSONC-aware scanner (_top_level_value_span, honoring strings /////* *// nesting, so a}or"agents"inside a string never fools it) locates the two spans; the later span is replaced first so offsets stay valid. Falls back toserialize(cfg)whenbase_textis empty/blank or either key is not a direct root member (non-omo / hand-broken file — splice unsafe).renderis idempotent (rendering its own output reproduces it byte-for-byte → an unchanged save is a no-op). It does not inject the// Generated by oModelheader (that would touch outside agents/categories); the header is emitted only by theserializefrom-scratch / fallback path.- Save flow: diff
render(cfg, on-disk)vs the on-disk file → confirm modal showing the diff (exactly what changes — agents/categories only, comments outside intact; the diff body is scrollable — ↑↓/j``k, PageUp/PageDown, Home/End — since a real config diff easily exceeds the modal's height, while the Yes button keeps focus so Enter still confirms) → on accept, snapshot the current on-disk file to<config_dir>/.backup/<ts>.jsonc(verbatim byte copy — preserves comments), then atomic temp+rename ofrender(cfg, on-disk). No diff → "nothing to save". - Backups & rollback:
<config_dir>/.backup/(next to the config;<config_dir>= dir of the active config, default~/.config/opencode/). Exact save order (this sequence): (1) if.backup/original.jsoncdoes not exist, copy the current on-disk config to it (verbatim); (2) write the verbatim timestamped snapshotYYYYMMDD-HHMMSS[.mmm].jsonc(UTC, sorts lexicographically;.mmmavoids same-second collisions); (3) prune only timestamped snapshots —glob("[0-9]*.jsonc"), which excludesoriginal.jsonc— to the newest 20. Sooriginal.jsoncis written once, never overwritten, never pruned, and never counts toward the 20 (your pristine pre-oModel palette).omodel --restore(and a TUI key) lists the pinnedoriginal.jsonc+ the newest 10 timestamped (each with timestamp + size / short diff); items 11–20 are an unlisted on-disk buffer. Restoring first snapshots the current file (so restore is itself undoable), then copies the chosen backup to the config path. - ⚠ First save drops the palette inside agents/categories: the live config is comment-dense
(3–6 commented alternatives per agent), and those live inside the
agents/categoriesobjects, whichrenderrewrites clean — so the first save deletes that palette (decision #13). Comments / commented-out config outside those two are preserved verbatim. The whole pre-oModel file is also pinned as.backup/original.jsonc(never pruned, always restorable) — surface this in the first confirm modal. - Missing config → scaffold oModel's own minimal
default-config.jsonc, then open it. Template (the$schemais a literal hardcoded string committed indefault-config.jsonc; nothing in the refresh path writes it):{ "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": {}, "categories": {} }— valid and minimal; the left pane is populated from the bundled snapshot, so empty maps still show all 11 agents / 8 categories as unset, and only what you set gets written.
- Purpose: recover from a mis-press within a session, before/independent of saving — a
wrong pick, a fat-fingered
x(clear), an accidentalasub-target. This is distinct from the on-disk.backup/rollback (decision #2 / §config_io.py): that is cross-session file history (--restore); this is the live edit stack. - Model:
Historyholds a linear list of cfg snapshots with labels; a cursor marks the current one. Entry 0 is the loaded cfg.push(state, label)appends a deep copy (and is a no-op whenstate == current, so a re-pick of the same model makes no junk entry), truncating any redo tail first (standard undo semantics).undo()/redo()move the cursor and return(state, label). Each entry also carries an optionalauxcompanion snapshot (push(state, label, aux=), read back viacurrent_aux()) for state that must move with the entry but isn't cfg — app.py stores_custom_rowsthere;clear_aux()wipes it across all entries on a refresh. Change detection stays cfg-only (auxnever on its own makes an entry). Alimit(200) caps memory for long sessions. Snapshots are deep-copied in and out so the app's live cfg and history never alias. Pure data, no Textual — unit-tested in isolation. - App integration (
app.py): every cfg mutation routes through one chokepoint —_record(and_stage_row, which calls it) — so set / clear / variant / add-model / add-sub / delete-sub are all undoable.u→action_undo,ctrl+r→action_redo(vim-style; distinct fromrrefresh), both gated to the base screen viacheck_action(a modal owns its own keys — e.g. AddSubModal bindsu)._restore_stateswaps in the snapshot and re-renders both panes (a sub-target row appears/vanishes on the left; the●current-pick follows cfg on the right; a vanished sub-target falls back to its parent agent, repopulated via_populate_targets( select=)so no stale intermediate highlight fires). The per-target row cache is dropped and rebuilt (like a refresh);_cand_choice/_detail_cacheare kept._custom_rows(off-chain typed models, merged into_build_rows) is snapshotted into the history alongside cfg (each entry'saux, via_record) and restored here, so it moves in lockstep with undo/redo — undoing an add-model drops its typed row and redoing brings it back, not just the bare cfg value. A refresh still clears it (and the storedauxsnapshots, viaclear_aux()), since the stored availability ⚠ is now stale. - Dirtiness is computed, not flagged:
_is_dirty()=serialize(cfg) != _saved_text(the text last written/loaded). So undo back to the saved state quits without a prompt, and an emptyultrawork/compactionsub-object — whichserialize()drops — is undoable but not dirty (nothing to save). The undo history is preserved across a save (re-baselines_saved_textonly), so a just-saved edit can still be undone (then re-saved).
- Locate omo src:
--omo-src|$OMO_SRC|~/source/oh-my-openagent(needspackages/model-core/src). Runner: bun only (no node fallback — verified broken). - Run bundled
tools/snapshot_omo.ts→ JSON (RegExp→.source, Set→array, +meta). The bun run carries a 300s timeout (non-fatal on expiry — every subprocess call in the repo carries one); a temp-materialized copy of the.ts(frozen/zipimport case) is removed afterwards. - Write target: frozen (PyInstaller) build → always
$XDG_DATA_HOME/omodel/omo-suggestions.json(under--onefile,__file__'s siblingdata/dir lives in the ephemeral_MEIPASSextraction tempdir — writable but deleted on exit, so writing "to the checkout" there silently loses the output;sys.frozengates the branch); else writable repo checkout (src/omodel/data/) → write there (maintainer commits); else$XDG_DATA_HOME/omodel/omo-suggestions.json(user override — read back bysuggestions.load()'s newest-wins rule, §suggestions.py). - Missing omo src or bun → non-fatal: print current bundled
meta, keep bundled data.
Real source: src/omodel/tools/snapshot_omo.ts — read it there; not inlined here (it drifts). Design
contract: at maintainer time it dynamically imports omo's packages/model-core/src modules
(model-capability-heuristics, agent-model-requirements, category-model-requirements,
known-variants) — bun resolves omo's extensionless .ts, node can't (see §Runtime requirements) —
and prints JSON matching the §Data sources "what omo suggests" schema: each RegExp pattern →
.source string (e.g. claude(?:-\d+(?:-\d+)*)?-opus), Set → array, plus a meta block
(omoVersion from omo's package.json, omoCommit from git rev-parse, generatedAt). refresh.py
runs bun run <this file> <omo-src> and writes stdout to the data file.
- Header
Static#providers: one lineoModel: <id · id · …>fromcatalog.connectedin its first-seen order (per §Data sources; e.g.opencode · deepseek · moonshotai-cn · openai · zhipuai) — so you see what's available at a glance; doubles as the ⚠-unavailable explainer ("no listed provider serves this"). Just the connected list — no cache-age suffix (dropped as clutter; the served list is bounded-fresh since an expired cache auto-refetches, andrrefresh is documented in the?overlay). OnCatalogUnavailableit shows the banner +rretry instead. - Left
OptionList#targets: AGENTS then CATEGORIES; option IDsagent:<name>,agent:<name>.ultrawork/.compaction(indented sub-rows, shown when present in config or added viaa),cat:<name>. Sub-target set per agent ={model}∪ present{ultrawork, compaction}.compactionis valid on every agent;ultraworkis Sisyphus-only (omo'sultrawork/ulwkeyword swaps the model only on Sisyphus — on any other agent it's dead config omo never reads) (_ULTRAWORK_AGENTS/_subkinds_forinapp.py, hard-coded like_GPT_ONLY_AGENTS). So only Sisyphus has a choice of sub-kind:athere opens a chooser modal (below) — naming each kind- what it's for rather than blindly cycling. Every other agent has the single kind
compaction, soaadds it directly (no modal — there's nothing to choose).
- what it's for rather than blindly cycling. Every other agent has the single kind
- Right:
Static#detail(current model/variant +catalog.detailline) andOptionList#candidates(IDscand:<i>, last =cand:add— the+ add model…row). Thecand:<i>row matching the current assignment (at launch the on-disk model; follows your pick) is prefixed●(others); an off-chain assignment not otherwise in the list gets its owncand:<i>row just beforecand:addso it's shown + re-selectable (see §resolve.py"Current pick"). The highlighted (cursor) row is remembered per target — keyed by the row'sprovider/modelidentity, not its index — and restored on every re-render, so the cursor returns to your last position when you revisit a target and afterrrefresh (a refresh re-resolves the chain against new availability and reorders rows; identity-keying survives that, an index wouldn't). It's the one per-session cache a refresh deliberately does not clear. Thecatalog.detailline is a ~3s / ~320 MB subprocess, so it is fetched in a background worker (cached per model, debounced ~0.2s, and capped to one fetch at a time — §cache.py) and appears when ready; the rest of the pane renders instantly so highlighting is never blocked. - Hint bar
Static#hints-bar(bottom row): minimal and static — keyss save · ? help · q quitat the left, app versionv<version>(Static#hints-version) right-aligned at the tail (the_HINT_BARconstant), set once inon_mountand never re-rendered. It advertises only the must-have keys:s(the app's whole point),?(the overlay that documents everything else), andq. All the pane/row-contextual keys (enter,v,x,a) andu/⌃rundo/redo andrrefresh moved into the?help overlay (HelpModal), so the bar never grows past one line and never has to track focus/row/undo state. This is deliberately not a full reference — the bar is a floor (save/help/quit);?is the ceiling. Modals carry their own one-line hint (Static.modal-hints) instead. (ris also still advertised in the#providersheader.) - Help overlay
HelpModal(?): a read-only, scrollableModalScreen(same body pattern asConfirmModal) listing every key, grouped (Navigate / Edit / Undo / Models & file / In dialogs). Base-screen-only (gated incheck_actionalongside focus + undo/redo —?over a modal is pointless;esccloses the modal first). Closes with?(toggles),esc, orq. Its_BODYtext and_HINT_BARare the only two places keys are advertised — keep them and the module KEYS docstring in sync. - Events: highlight on
#targets→ repopulate detail+candidates for that target;enteron#candidatesdispatches by row: oncand:add→ open the add-model modal (below); on any othercand:<i>→ set that model (+ default variant) on the in-memory target;v→ pushOptionListof the family's valid variants +(none);a→ pane-contextual: opens the add/edit-model modal (below) from #candidates and from a #targets category row (enteroncand:addalso opens it), or adds a sub-target from a #targets agent row (chooser on Sisyphus, direct on every other agent — below);x→ clear the assignment (on an ultrawork/compaction sub-target row → delete the whole row, parent agent regains focus — clear == delete since an empty sub-object serializes away);u→ undo /ctrl+r→ redo the last edit (in-session snapshot stack, §history.py — gated to the base screen viacheck_action, so they don't reach through a modal that bindsuitself);s→ diff+confirm save;r→ refresh (off-threadopencode models --refresh+ rebuild cache; also retries afterCatalogUnavailable);q→ quit (confirm if dirty);?→ open theHelpModalfull-key overlay (base-screen-only, samecheck_actiongate as focus + undo/redo);←/→→ focus the targets / candidates pane (gated to the base screen viacheck_action, so it never grabs focus from under a modal; the add-modelInputkeeps its cursor arrows). Vim aliases:h/lmirror←/→(the same gated focus actions);j/kmirror↓/↑within whatever list is focused — bound on theVimOptionListevery list uses (so they also work in the variant / add-sub modals), while a focusedInputstill takesh/j/k/las literal text (printable keys reach a widget before its bindings). The vim keys — like every non-must-have key — are absent from the (static, one-line) hint bar; they're documented in the?overlay instead. Pilot tests drive these via the stable IDs. - Add-model modal (
a/cand:add): a two-phase picker (IDs#add-input,#add-candidates,#add-variants,#add-title,#add-preview,#add-hints). Model phase — theInput(#add-input) fuzzy-filters#add-candidates, a list of theprovider/modelpairs you actually have (catalog.available), dedicated-first (single-vendor before gateway, then first-seen). The fuzzy engine istextual.fuzzy.Matcher, scored on the fullprovider/modelstring (so you can filter by either side). It is type-to-search: the modal opens with no list (the empty-query browse dump is intentionally not rendered — building/ laying out every available pair, which a gateway can make hundreds, lagged the open), and the list appears only once you type. Results are capped (_MAX_CANDIDATES) so a broad one-letter query can't reintroduce that lag — type more to narrow.Matcher("")is never constructed (it raises). A typed query auto-highlights the top match for quick-select; with the list empty (right after opening, or a query that matches nothing) nothing is staged, so a reflexiveenteris a no-op — you never commit a model you didn't choose.↑/↓(or emacsCtrl-P/Ctrl-N) move the list while theInputkeeps focus (driven from screen bindings; the list iscan_focus=False).Ctrl-Pis normally the App's priority command-palette binding, soOModelApp.check_actionsuppresses the palette while this modal is open (the only way to gate a priority binding — it is checked App-down before the key reaches the modal).Tabfills the highlighted pair into theInput(intercepted inon_key, before focus traversal);enterchooses the highlighted/staged pair, or — when the list is empty — the validated typed text;esccancels. A fullprovider/model→ used verbatim (split on the first/); a bare id → auto-prefixed viaresolve_prefixif available, else⚠ unknown — add a provider/andenteris blocked; a typed full id that fuzzy-matches nothing appears as a synthetic "use as typed" row (so custom /⚠ unavailableids still work — warn-but-allow, decision #5; theunavailablewarn is suppressed in degraded mode — emptyconnected, availability unknown — mirroring the off-chain row's rule, and recomputed against the live catalog on accept in case anrrefresh landed while the modal was open). A half-typed fragment that still fuzzy-matches (e.g. a Tab-filled id after a backspace —zhipuai/glm-⊂zhipuai/glm-5) falls back to those matches rather than leading with that ⚠-unavailable synth row. (Trade-off: the synth row is offered only when nothing fuzzy-matches, so the rare custom id that is itself a subsequence of a longer available pair — e.g.openrouter/claude⊂openrouter/anthropic-claude-…— can't be committed as-typed; it shows the fuzzy matches instead. Accepted to kill the mid-edit footgun: a longer/distinct custom id is never a subsequence of a shorter available one, so the common "add a model I don't have yet" path is unaffected.) A GPT-only target (Hephaestus) filters the list to GPT models and still blocks a typed non-GPT id. Variant phase — iff opencode reports variants for the chosen(provider, model)(Catalog.variants_for— the cached--verbosemap, decision #14),#add-variants(aVimOptionList, IDsvar:<v>/var:__none__) lets you pick one or(none)⇒variant=None(a fresh add, notVariantModal's''clear sentinel); a model opencode lists with no variants (kimi, glm-5) — or whose verbose isn't cached anywhere — skips it and adds immediately. Anoneopencode may list is dropped as a duplicate of the synthetic(none)(_is_no_variant) — never offered, never written; picking(none)(or anone) removes thevariantkey.escreturns to the model phase. The post-hocvkey (action_variant/VariantModal) now reads the samevariants_forsource — the oldknown_variants"always offer something" fallback is gone;von a model with no reported variants just bells. The result dismisses one candidate-row dict (source"add"); it's just another pickable row. - Add-sub (
aon an agent): an agent supportscompactionalways +ultraworkonly on Sisyphus (_subkinds_for). Only Sisyphus has a choice, so only there doesaopen a chooser modal: anOptionList(#sub-list, IDssub:ultrawork/sub:compaction) with one row per valid kind, each naming the kind + a one-line description of what omo uses it for (ultrawork = model swapped in on anultrawork/ulwmessage; compaction = model for auto summaries). A kind already on the agent is disabled (✓ added); theu/cshortcut orenterpicks one (→ empty sub-row, not dirty until a model is staged),esccancels. Every non-Sisyphus agent has the single kindcompaction, soaskips the modal and adds it directly — there's nothing to choose. Either way, every supported kind already present →ajust bells (nothing to add). Replaces the old blind add-next cycle so the choice — and what each kind means — is explicit for newcomers, without making single-kind agents click through a one-option modal.
pyproject.toml(hatchling, src-layout):[tool.hatch.build.targets.wheel] packages = ["src/omodel"]. The non-Python payload (data/*.json,*.jsonc+tools/*.ts) ships automatically because it lives under the package tree — do NOT add aforce-include(it duplicates the path and fails the wheel build).data/andtools/each carry an__init__.pyso they are regular packages:importlib.resources.files("omodel.data" / "omodel.tools")only resolves on a regular package under the 3.9 floor (namespace-packagefiles()support landed in 3.10) — without it, every bundled-data read raisesTypeError: … not NoneTypeon 3.9.requires-python = ">=3.9"; depstextual(pinned),json5. Entry point[project.scripts] omodel = "omodel.cli:main".- Primary — standalone binary + installer (GitHub Releases): PyInstaller one-file build,
pyinstaller --onefile --name omodel --collect-data omodel src/omodel/__main__.py(bundlesdata/+tools/;importlib.resourcesreads them from the frozen package). CIrelease.ymlbuilds on tag push (matrix: linux-x64ubuntu-latest, darwin-arm64macos-latest), gated by: a tag↔version check (tag must equal__init__.__version__and pyproject'sversion), the full pytest suite, and a smoke test running--versionand--check(--checkimports the data-loading path, so a broken--collect-databundle fails the release —--versionalone returns before touching bundled data and can't catch it). It attachesomodel-<os>-<arch>(bare binary),omodel-<os>-<arch>.tar.gz(binary + LICENSE + NOTICE — the tarball is the canonical asset; NOTICE must ship with the omo-derived bundled data), and….tar.gz.sha256to the Release. (Intel-macmacos-13was dropped — GitHub is retiring those runners and they queue for hours; Intel macs install via pipx.)install.shdetects OS/arch (linux-x64,darwin-arm64), downloads the tarball, verifies the published sha256 when present (hard-fails on mismatch; warns-and-continues when absent, e.g. an older release), extracts, and installsomodelto~/.local/bin:curl -fsSL https://raw.githubusercontent.com/zhoufanscut/oModel/main/install.sh | sh. The linux binary needs a glibc ≥ theubuntu-latestbuilder's (documented in README; older distros → pipx/uvx path). - Secondary — pip/pipx/uvx straight from GitHub (no PyPI):
pipx install git+https://github.com/<you>/oModel·uvx --from git+https://github.com/<you>/oModel omodel·uv tool install git+https://github.com/<you>/oModel. - Maintainer:
git clone … && uv pip install -e .; refresh data withOMO_SRC=~/source/oh-my-openagent omodel --refresh-omo, commitsrc/omodel/data/omo-suggestions.json;git tag vX.Y.Z && git push --tags→release.ymlbuilds and publishes the binary. - ⚠ Licensing: the bundled
omo-suggestions.jsonis data derived from omo source (Sustainable Use License — satisfied while oModel stays free/non-commercial), redistributed in the repo, the wheel, and the binary. Attribution lives inNOTICE; the wheel carries it via hatchling'sdist-info/licenses/, and each release tarball shipsLICENSE+NOTICEnext to the binary (the bare-binary asset alone carries none — the tarball is the compliant artifact). KeepNOTICEintact when redistributing.default-config.jsoncis oModel's own (not copied) to avoid this.
- Build/install:
pipx install .(andpipx install git+https://…once pushed);omodel --version;omodel --checkruns with no omo source. Then a PyInstaller one-file build → run the binary'somodel --version/--checkto confirm bundleddata/loads viaimportlib.resources;install.shplaces it on PATH. - Availability + prefix (unit, mocked
opencode models):vendors_servedclassifiesopencode/openrouter→gateway andopenai/zhipuai/moonshotai-cn/deepseek→dedicated.providers_for("gpt-5.5") == ["opencode","openai"]→ list showsopenai/gpt-5.5thenopencode/gpt-5.5(dedicated-first);claude-opus-4-7→["opencode"]→opencode/claude-opus-4-7;kimi-k2.5→moonshotai-cn/kimi-k2.5;glm-5→zhipuai/glm-5. A chain entry with no connected provider and no same-line relative is omitted fromcandidates(); with onlyglm-5.1connected, theglm-5entry resolves to azhipuai/glm-5.1substitute row (substitute_for="glm-5").glm + maxrenders ⚠ variant but accepts. Withopenrouteralso connected, a both-gateways-only model lists both gateway rows in first-seen order;resolve_prefix(add-modal single pick) still tie-breaks viaentry.providers-then-first-seen. - Verbose parsing (unit): feed a captured multi-record
--verboseblob → N records withlimit.context/cost/capabilitiesextracted; confirm variant logic does NOT read it. - detect_family parity:
kimi-k2.5→kimi(nomax),k2p5→kimi-thinking,claude-opus-4-7→claude-opus(hasmax),gpt-5.5→gpt-5(xhigh),glm-5→glm(nomax),deepseek-v4-pro→deepseek(hasmax). - Bundled suggestions:
importlib.resourcesloads with no omo checkout; 11 agents, 8 categories. - Refresh: checkout +
OMO_SRC+ bun → rewrites data file (meta bumped); no omo/bun → non-fatal. - Headless UI (Pilot): select
agent:sisyphus, setcand:*→deepseek/deepseek-v4-pro,s, confirm → re-json5.load: model updated,team_mode/experimental/claude_codeunchanged by value, the palette inside agents/categories gone but comments outside them preserved verbatim, a.backup/<ts>.jsoncsnapshot exists (verbatim original); a second save adds a second snapshot and--restorelists them newest-first. - Live: machine with
opencode, no omo source →omodellaunches, lists fromopencode models, edits + saves a clean file OMO reloads.
When ready to build, fan out as 6 specialists + a lead, contract-first (model tier = "Safety"). Launch trigger: the user says "go" (say "lean" to drop the QA agent → 5; "cost-lean" to re-tier Config/TUI to Haiku).
| Role | Owns | Model |
|---|---|---|
| Lead / Integrator | §Data contracts + module signatures; repo scaffold; generate real bundled data (snapshot_omo.ts→omo-suggestions.json) + default-config.jsonc; wire app.py↔modules; final integration |
Opus |
| Core logic | catalog.py · suggestions.py (detect_family, FAMILY_VENDOR) · resolve.py · tools/snapshot_omo.ts |
Sonnet |
| Config I/O | config_io.py (serialize, backups/restore, scaffold) |
Sonnet |
| TUI | app.py two-pane + variant/add-model/diff modals + keybindings |
Opus |
| CLI + packaging | cli.py · refresh.py · pyproject.toml · install.sh · .github/workflows/* · README/LICENSE/NOTICE |
Sonnet |
| QA / verification | all 5 tests/test_* authored from this spec, independent of the implementations + runs the 8 §Verification checks as the merge gate |
Sonnet |
- Lead (blocking): freeze §Data contracts (
targetid,sourceenum, candidate-row dict) + each module's public signatures; scaffold the repo (pyproject, package dirs, stub modules); generate the realomo-suggestions.json(bun + omo checkout) and hand-writedefault-config.jsonc. Unblocks all. - Fan out in parallel (isolated git worktrees): Core, Config, TUI, CLI+packaging, QA each in their own worktree against the frozen interfaces. QA writes tests from the spec + stable widget IDs in parallel (not blocked on implementations).
- Integrate (lead): merge tracks, wire
app.pyto catalog/suggestions/resolve/config_io, reconcile any interface drift against the §Data contracts. - Gate (QA + lead): QA's
test_*green and all 8 §Verification checks pass (incl. a liveopencoderun + a Pilot save round-trip). Nothing ships until green.
- Integration risk concentrates at
app.py(it consumes all four modules); the §Data-contracts block is what lets it be built in parallel against frozen shapes. Lead owns final wiring. - Dependencies:
resolve.py→suggestions.py+catalog.py;refresh.py→snapshot_omo.ts.config_io.pyand CLI+packaging are near-independent; everything else parallelizes once contracts are frozen.