Skip to content

Latest commit

 

History

History
732 lines (689 loc) · 61.9 KB

File metadata and controls

732 lines (689 loc) · 61.9 KB

oModel — a TUI to quickly set OMO models

Brand oModel · command omodel · Python package omodel · repo ~/proj/oModel Self-contained: a published Python package that needs only Python + the opencode CLI at runtime.

Core idea (in one breath)

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.

Problem

~/.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.

Runtime requirements

  • Python ≥ 3.9 (importlib.resources.files). Pin Textual to a release whose own requires-python ≤ our floor (verify at lock time, else bump floor to 3.10).
  • opencode CLI on PATH — 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 optional omodel --refresh-omo — see §Refresh. Verified: node --experimental-strip-types cannot run omo's modules (extensionless relative imports → ERR_MODULE_NOT_FOUND); bun resolves them.

Decisions (locked)

# 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.

Data sources

  • What you have (runtime): parse opencode models → lines provider/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 by catalog.load too): opencode not on PATH → banner + suggestions/add-model only; else exit code ≠ 0 or zero provider/model lines parsed → raise CatalogUnavailable → banner "couldn't read models", offer retry (r), degrade. (There is no other "partial" state.) opencode models --refresh is exposed as omodel --refresh-models, which also rebuilds the local cache (§cache.py).
  • Why not opencode auth list: it prints provider display names ("Moonshot AI (China)", not moonshotai-cn) wrapped in box-drawing/ANSI with no --json/plain flag (verified) — fragile, and would need a name→ID map. opencode models already yields the usable provider set as clean IDs in one call (a provider appears only if it can serve models = exactly "usable"). oModel never calls auth list; connected (above) is the logged-in/usable set, shown as an oModel: 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> --verbose emits, per model, a bare provider/model header line followed by a multi-line pretty-printed (2-space) JSON block (~80 lines, incl. a nested variants map). Parser: a header line matches ^(?P<prov>[a-z0-9_-]+)/(?P<model>\S+)$ at column 0; brace-count each following block and json.loads it. (Verified: bare provider/model strings never appear at column 0 inside a block — they're always quoted values — so brace-counting from each header is unambiguous.) Use limit.context, cost.input/output (may also carry cost.cache.{read,write}; free models show $0), capabilities.reasoning, capabilities.input.image for 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.family is still never read (family stays heuristic). Caveat that shapes variants_for: the object is empty ({}) for the dedicated providers (zhipuai, moonshotai-cn) while populated by the gateway (opencode) and openai — so variants_for prefers 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/requiresAnyModel are carried but IGNORED (they gate omo's auto-activation; oModel is a manual picker). pattern is stored as a string and re.compiled at load (negative-lookaheads like k2(?![-.]?p\d) verified to compile under Python re).
  • Your config (runtime): $XDG_CONFIG_HOME/opencode/oh-my-openagent.jsonc (fallback ~/.config/...), --config override; scaffold a bundled starter if missing.

CLI

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 abspathdirname("x.jsonc") == "" used to crash makedirs).

Layout (approved)

 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.

Repo layout (src-layout, PyPI-ready)

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

Components

Data contracts (shared shapes — fix once so resolve.py and app.py agree)

  • target id (string): "agent:<name>", "agent:<name>.ultrawork", "agent:<name>.compaction", or "cat:<name>" — identical to the §Textual OptionList#targets option IDs.
  • source (string enum): "omo" (a fallbackChain entry — 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 by app.py:
    {
      "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"
    }
    The value written to config is f"{provider}/{model}" plus variant (omitted when None).

catalog.py — availability from opencode

  • load(): opencode modelsavailable (dict) + connected (list, first-seen order — never a set). Per the §Data sources error rule: exit code ≠ 0 or zero provider/model lines parsed → raise CatalogUnavailable (UI shows banner + retry); opencode not on PATH → empty + banner.
  • providers_for(model_id) → connected providers that have it, in first-seen order.
  • detail(model_id, provider=None): query <provider> = the requested provider when it serves the model — the detail pane passes the current assignment's provider, so an opencode/x assignment shows the gateway's record (cost can differ per provider), never silently the dedicated provider's — else the model's resolved provider (first of providers_for(model_id)); run opencode 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, so app.py calls it from a background worker (cached per (provider, model), debounced) — never on the UI thread (see §Textual two-pane contract).

cache.py — on-disk opencode cache

  • Both opencode subprocesses (opencode models ~3s, and opencode 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 + one verbose-<provider>.json per provider. Each file wraps stdout as {version, fetched_at, args, stdout} — explicit fetched_at (not mtime; survives copies) and a version that 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 on PATH → 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 a timeout=.
  • catalog.refresh() — the r key / omodel --refresh-models — runs opencode models --refresh (network re-fetch), clears the cache, and rewrites models.json from the result. The TUI runs it in a worker (off the UI thread); r is documented in the ? help overlay (the oModel: 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_fetching gate; on completion the worker re-renders the current target, which schedules the next — "chase the cursor"). Uncapped/un-stubbed, stacked ~320 MB --verbose processes OOM'd a machine; a refresh bumps a generation counter so an in-flight fetch discards its now-stale result. Tests stub subprocess.run and isolate the cache dir (tests/conftest.py$OMODEL_CACHE_DIR).
  • Quit never blocks on a subprocess: both the detail fetch and the r refresh run their blocking call via _to_thread_daemon (app.py) — a daemon-thread analogue of asyncio.to_thread. to_thread's non-daemon executor threads are joined at interpreter shutdown, which made q hang 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). r is 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 --refresh runs would race cache.clear()/ write() (last finisher wins).

suggestions.py — bundled omo data

  • Load order: explicit path arg → $OMODEL_SUGGESTIONS (both unconditional) → the newer of $XDG_DATA_HOME/omodel/omo-suggestions.json (from a past --refresh-omo) and the bundled importlib.resources.files("omodel.data")/"omo-suggestions.json", compared by meta.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 of detectHeuristicModelFamily: ordered iteration of families, pattern tested before includes within each entry, first match wins; run normalize_model_id first (re.sub(r"\.(\d+)", r"-\1", s).lower()kimi-k2.7kimi-k2-7). Patterns pre-re.compiled. (Parity matters: openai-reasoning before gpt-5, kimi-thinking before kimi, claude-opus before claude-non-opus.)
  • Entry shape retained: each fallbackChain item keeps {providers[], model, variant?, …} — the providers array (omo's per-model preference order) is kept for the gateway tie-break in resolve_prefix.
  • FAMILY_VENDOR — hardcoded dict in suggestions.py (NOT from omo; omo has no such table). The complete 15-family → vendor map used by vendors_served. The authoritative table is the FAMILY_VENDOR dict in src/omodel/suggestions.py — read it there; not duplicated here (it drifts). vendor(family) = FAMILY_VENDOR.get(family)None for unknown/None. Models whose detect_family is None (opencode's big-pickle, *-free, nemotron-* — no omo family; note omo 4.13 added a qwen family, so qwen3.x-plus now detects qwenalibaba and is no longer None) contribute no vendor and are skipped in vendors_served; do not invent a family for them.

resolve.py — core logic

  • Gateway detection (vendors_served): for each connected provider p, vendors_served(p) = len({ vendor(detect_family(m)) for m in available[p] } - {None}) using the complete FAMILY_VENDOR map (§suggestions.py). p is a gateway iff vendors_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 vendor openai so 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; else cands = providers_for(model_id); dedicated = [p for p in cands if p not in gateways] → pick dedicated[0] (first-seen) if any; else only gateways serve it → walk entry.providers and pick the first that is in cands, else cands[0]. NB: entry.providers are omo-world IDs (anthropic, github-copilot, vercel, zai-coding-plan, …) that rarely intersect the user's connected set, so the cands[0] first-seen fallback is the common path; both branches range over providers_for (availability IDs), never raw omo IDs. candidates() no longer calls this — it lists every serving provider (_ordered_providers); resolve_prefix now only auto-prefixes a bare id typed in the add-model modal. Verified: gpt-5.5openai/…, claude-opus-4-7opencode/… (only gateway has it), kimi-k2.5moonshotai-cn/…, glm-5zhipuai/…. (kimi-k2.5/2.6 and glm-5/5.1 exist under both opencode and a dedicated provider — dedicated heads the list; add a second gateway like openrouter and 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-5zhipuai/glm-5 then opencode/glm-5; gpt-5.5openai/gpt-5.5 then opencode/gpt-5.5 — so the prefix is chosen by picking the row (no p-cycling).
  • candidates(target): one pick list — a single filtered pass over the fallbackChain, 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's claude-haiku-4-5-20251001 or claude-sonnet-4-8-jibao fills the bare claude-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.1glm-5); (2) same-line — else the newest connected model of the same detect_family (version-agnostic: glm-5glm-5.1; "newest" = highest digit-tuple, ties → first-seen) — except within the coarse claude-non-opus family (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 unavailable minimax-m3 resolves to the newest minimax-m2.7 you have, not an older minimax-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 exactly kimi-k2.5, overriding omo's heuristic that would file the p<digit> suffix under the kimi-thinking family and pull in a kimi-k2-thinking model. The alias acts only here in candidates(); detect_family/normalize_model_id stay a faithful port. Each resolved model expands to one row per serving provider (dedicated-first, _ordered_providers); rows are then deduped by resolved provider/model (higher-priority entry/provider wins). Variant precedence: entry variant → requirement top-level variantnone (the family registry only validates variants — designates no default — so an unspecified variant stays unset; set one via v). (Top-level requirement variant is 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; enter on any non-add row stages it. Flag: ⚠ variant (variant unsupported for the row's (provider, model): checked against opencode --verbose when it lists a non-empty set, else the bundled family variants). (Unavailable entries are hidden, not flagged — decision #5.) Current pick (): the row whose resolved provider/model equals the target's current assignment in self.cfg — at launch that's what oh-my-openagent.jsonc has 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.py surfaces it as its own row just before + add model… (built from self.cfg; ⚠-flagged unavailable only 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-gpt hook 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 for agent: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-only tip. Encoded as _GPT_ONLY_AGENTS + _is_gpt_model in app.py (matching omo's hard-coded agent key, not a data field — requires* are activation flags, not user-choice restrictions).

config_io.py — edit-in-place save

  • Read json5.load → ordered dict; agents/categories are 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 the agents/categories value spans are rewritten; the rest of the file — other keys, formatting, and any comments / commented-out config outside those two (e.g. a //"skills": false line within claude_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.jsonc retains 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 through render. (1) build an ordered dict preserving on-disk key order, but force $schema to position 0 if present; (2) within agents/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) — note json.dumps cannot 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, its ultrawork/compaction {model,variant}; each category's model/variant`. Example output head:
    // Generated by oModel — edit via `omodel`
    {
      "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
      "agents": {
        "sisyphus": {
          "model": "moonshotai-cn/kimi-k2.5"
        }
      },
      "categories": {}
    }
  • render(cfg, base_text) -> str (the write form): returns base_text with only the top-level agents and categories value 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 to serialize(cfg) when base_text is empty/blank or either key is not a direct root member (non-omo / hand-broken file — splice unsafe). render is idempotent (rendering its own output reproduces it byte-for-byte → an unchanged save is a no-op). It does not inject the // Generated by oModel header (that would touch outside agents/categories); the header is emitted only by the serialize from-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 of render(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.jsonc does not exist, copy the current on-disk config to it (verbatim); (2) write the verbatim timestamped snapshot YYYYMMDD-HHMMSS[.mmm].jsonc (UTC, sorts lexicographically; .mmm avoids same-second collisions); (3) prune only timestamped snapshots — glob("[0-9]*.jsonc"), which excludes original.jsonc — to the newest 20. So original.jsonc is 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 pinned original.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/categories objects, which render rewrites 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 $schema is a literal hardcoded string committed in default-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.

history.py — in-session undo/redo (decision #16)

  • Purpose: recover from a mis-press within a session, before/independent of saving — a wrong pick, a fat-fingered x (clear), an accidental a sub-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: History holds 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 when state == 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 optional aux companion snapshot (push(state, label, aux=), read back via current_aux()) for state that must move with the entry but isn't cfg — app.py stores _custom_rows there; clear_aux() wipes it across all entries on a refresh. Change detection stays cfg-only (aux never on its own makes an entry). A limit (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. uaction_undo, ctrl+raction_redo (vim-style; distinct from r refresh), both gated to the base screen via check_action (a modal owns its own keys — e.g. AddSubModal binds u). _restore_state swaps 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_cache are kept. _custom_rows (off-chain typed models, merged into _build_rows) is snapshotted into the history alongside cfg (each entry's aux, 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 stored aux snapshots, via clear_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 empty ultrawork/compaction sub-object — which serialize() drops — is undoable but not dirty (nothing to save). The undo history is preserved across a save (re-baselines _saved_text only), so a just-saved edit can still be undone (then re-saved).

refresh.pyomodel --refresh-omo

  • Locate omo src: --omo-src | $OMO_SRC | ~/source/oh-my-openagent (needs packages/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 sibling data/ dir lives in the ephemeral _MEIPASS extraction tempdir — writable but deleted on exit, so writing "to the checkout" there silently loses the output; sys.frozen gates 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 by suggestions.load()'s newest-wins rule, §suggestions.py).
  • Missing omo src or bun → non-fatal: print current bundled meta, keep bundled data.

tools/snapshot_omo.ts — the extractor (bun, maintainer-time)

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.

Textual two-pane contract (app.py)

  • Header Static#providers: one line oModel: <id · id · …> from catalog.connected in 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, and r refresh is documented in the ? overlay). On CatalogUnavailable it shows the banner + r retry instead.
  • Left OptionList#targets: AGENTS then CATEGORIES; option IDs agent:<name>, agent:<name>.ultrawork / .compaction (indented sub-rows, shown when present in config or added via a), cat:<name>. Sub-target set per agent = {model} ∪ present {ultrawork, compaction}. compaction is valid on every agent; ultrawork is Sisyphus-only (omo's ultrawork/ulw keyword swaps the model only on Sisyphus — on any other agent it's dead config omo never reads) (_ULTRAWORK_AGENTS / _subkinds_for in app.py, hard-coded like _GPT_ONLY_AGENTS). So only Sisyphus has a choice of sub-kind: a there opens a chooser modal (below) — naming each kind
    • what it's for rather than blindly cycling. Every other agent has the single kind compaction, so a adds it directly (no modal — there's nothing to choose).
  • Right: Static#detail (current model/variant + catalog.detail line) and OptionList#candidates (IDs cand:<i>, last = cand:add — the + add model… row). The cand:<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 own cand:<i> row just before cand:add so it's shown + re-selectable (see §resolve.py "Current pick"). The highlighted (cursor) row is remembered per target — keyed by the row's provider/model identity, not its index — and restored on every re-render, so the cursor returns to your last position when you revisit a target and after r refresh (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. The catalog.detail line 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 — keys s save · ? help · q quit at the left, app version v<version> (Static#hints-version) right-aligned at the tail (the _HINT_BAR constant), set once in on_mount and never re-rendered. It advertises only the must-have keys: s (the app's whole point), ? (the overlay that documents everything else), and q. All the pane/row-contextual keys (enter, v, x, a) and u/⌃r undo/redo and r refresh 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. (r is also still advertised in the #providers header.)
  • Help overlay HelpModal (?): a read-only, scrollable ModalScreen (same body pattern as ConfirmModal) listing every key, grouped (Navigate / Edit / Undo / Models & file / In dialogs). Base-screen-only (gated in check_action alongside focus + undo/redo — ? over a modal is pointless; esc closes the modal first). Closes with ? (toggles), esc, or q. Its _BODY text and _HINT_BAR are 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; enter on #candidates dispatches by row: on cand:add → open the add-model modal (below); on any other cand:<i> → set that model (+ default variant) on the in-memory target; v → push OptionList of the family's valid variants + (none); a → pane-contextual: opens the add/edit-model modal (below) from #candidates and from a #targets category row (enter on cand:add also 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 via check_action, so they don't reach through a modal that binds u itself); s → diff+confirm save; r → refresh (off-thread opencode models --refresh + rebuild cache; also retries after CatalogUnavailable); q → quit (confirm if dirty); ? → open the HelpModal full-key overlay (base-screen-only, same check_action gate as focus + undo/redo); / → focus the targets / candidates pane (gated to the base screen via check_action, so it never grabs focus from under a modal; the add-model Input keeps its cursor arrows). Vim aliases: h/l mirror / (the same gated focus actions); j/k mirror / within whatever list is focused — bound on the VimOptionList every list uses (so they also work in the variant / add-sub modals), while a focused Input still takes h/j/k/l as 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 — the Input (#add-input) fuzzy-filters #add-candidates, a list of the provider/model pairs you actually have (catalog.available), dedicated-first (single-vendor before gateway, then first-seen). The fuzzy engine is textual.fuzzy.Matcher, scored on the full provider/model string (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 reflexive enter is a no-op — you never commit a model you didn't choose. / (or emacs Ctrl-P/Ctrl-N) move the list while the Input keeps focus (driven from screen bindings; the list is can_focus=False). Ctrl-P is normally the App's priority command-palette binding, so OModelApp.check_action suppresses 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). Tab fills the highlighted pair into the Input (intercepted in on_key, before focus traversal); enter chooses the highlighted/staged pair, or — when the list is empty — the validated typed text; esc cancels. A full provider/model → used verbatim (split on the first /); a bare id → auto-prefixed via resolve_prefix if available, else ⚠ unknown — add a provider/ and enter is blocked; a typed full id that fuzzy-matches nothing appears as a synthetic "use as typed" row (so custom / ⚠ unavailable ids still work — warn-but-allow, decision #5; the unavailable warn is suppressed in degraded mode — empty connected, availability unknown — mirroring the off-chain row's rule, and recomputed against the live catalog on accept in case an r refresh 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/claudeopenrouter/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 phaseiff opencode reports variants for the chosen (provider, model) (Catalog.variants_for — the cached --verbose map, decision #14), #add-variants (a VimOptionList, IDs var:<v> / var:__none__) lets you pick one or (none)variant=None (a fresh add, not VariantModal'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. A none opencode may list is dropped as a duplicate of the synthetic (none) (_is_no_variant) — never offered, never written; picking (none) (or a none) removes the variant key. esc returns to the model phase. The post-hoc v key (action_variant/VariantModal) now reads the same variants_for source — the old known_variants "always offer something" fallback is gone; v on 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 (a on an agent): an agent supports compaction always + ultrawork only on Sisyphus (_subkinds_for). Only Sisyphus has a choice, so only there does a open a chooser modal: an OptionList (#sub-list, IDs sub: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 an ultrawork/ulw message; compaction = model for auto summaries). A kind already on the agent is disabled (✓ added); the u/c shortcut or enter picks one (→ empty sub-row, not dirty until a model is staged), esc cancels. Every non-Sisyphus agent has the single kind compaction, so a skips the modal and adds it directly — there's nothing to choose. Either way, every supported kind already present → a just 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.

Packaging & distribution (GitHub-only, no PyPI)

  • 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 a force-include (it duplicates the path and fails the wheel build). data/ and tools/ each carry an __init__.py so they are regular packages: importlib.resources.files("omodel.data" / "omodel.tools") only resolves on a regular package under the 3.9 floor (namespace-package files() support landed in 3.10) — without it, every bundled-data read raises TypeError: … not NoneType on 3.9. requires-python = ">=3.9"; deps textual (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 (bundles data/ + tools/; importlib.resources reads them from the frozen package). CI release.yml builds on tag push (matrix: linux-x64 ubuntu-latest, darwin-arm64 macos-latest), gated by: a tag↔version check (tag must equal __init__.__version__ and pyproject's version), the full pytest suite, and a smoke test running --version and --check (--check imports the data-loading path, so a broken --collect-data bundle fails the release — --version alone returns before touching bundled data and can't catch it). It attaches omodel-<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.sha256 to the Release. (Intel-mac macos-13 was dropped — GitHub is retiring those runners and they queue for hours; Intel macs install via pipx.) install.sh detects 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 installs omodel to ~/.local/bin: curl -fsSL https://raw.githubusercontent.com/zhoufanscut/oModel/main/install.sh | sh. The linux binary needs a glibc ≥ the ubuntu-latest builder'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 with OMO_SRC=~/source/oh-my-openagent omodel --refresh-omo, commit src/omodel/data/omo-suggestions.json; git tag vX.Y.Z && git push --tagsrelease.yml builds and publishes the binary.
  • Licensing: the bundled omo-suggestions.json is 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 in NOTICE; the wheel carries it via hatchling's dist-info/licenses/, and each release tarball ships LICENSE + NOTICE next to the binary (the bare-binary asset alone carries none — the tarball is the compliant artifact). Keep NOTICE intact when redistributing. default-config.jsonc is oModel's own (not copied) to avoid this.

Verification (fixtures use REAL omo suggestion IDs)

  1. Build/install: pipx install . (and pipx install git+https://… once pushed); omodel --version; omodel --check runs with no omo source. Then a PyInstaller one-file build → run the binary's omodel --version/--check to confirm bundled data/ loads via importlib.resources; install.sh places it on PATH.
  2. Availability + prefix (unit, mocked opencode models): vendors_served classifies opencode/openrouter→gateway and openai/zhipuai/moonshotai-cn/deepseek→dedicated. providers_for("gpt-5.5") == ["opencode","openai"] → list shows openai/gpt-5.5 then opencode/gpt-5.5 (dedicated-first); claude-opus-4-7["opencode"]opencode/claude-opus-4-7; kimi-k2.5moonshotai-cn/kimi-k2.5; glm-5zhipuai/glm-5. A chain entry with no connected provider and no same-line relative is omitted from candidates(); with only glm-5.1 connected, the glm-5 entry resolves to a zhipuai/glm-5.1 substitute row (substitute_for="glm-5"). glm + max renders ⚠ variant but accepts. With openrouter also connected, a both-gateways-only model lists both gateway rows in first-seen order; resolve_prefix (add-modal single pick) still tie-breaks via entry.providers-then-first-seen.
  3. Verbose parsing (unit): feed a captured multi-record --verbose blob → N records with limit.context/cost/capabilities extracted; confirm variant logic does NOT read it.
  4. detect_family parity: kimi-k2.5kimi (no max), k2p5kimi-thinking, claude-opus-4-7claude-opus (has max), gpt-5.5gpt-5 (xhigh), glm-5glm (no max), deepseek-v4-prodeepseek (has max).
  5. Bundled suggestions: importlib.resources loads with no omo checkout; 11 agents, 8 categories.
  6. Refresh: checkout + OMO_SRC + bun → rewrites data file (meta bumped); no omo/bun → non-fatal.
  7. Headless UI (Pilot): select agent:sisyphus, set cand:*deepseek/deepseek-v4-pro, s, confirm → re-json5.load: model updated, team_mode/experimental/claude_code unchanged by value, the palette inside agents/categories gone but comments outside them preserved verbatim, a .backup/<ts>.jsonc snapshot exists (verbatim original); a second save adds a second snapshot and --restore lists them newest-first.
  8. Live: machine with opencode, no omo source → omodel launches, lists from opencode models, edits + saves a clean file OMO reloads.

Execution playbook (team fan-out)

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).

Roster

Role Owns Model
Lead / Integrator §Data contracts + module signatures; repo scaffold; generate real bundled data (snapshot_omo.tsomo-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

Sequencing (contract-first)

  1. Lead (blocking): freeze §Data contracts (target id, source enum, candidate-row dict) + each module's public signatures; scaffold the repo (pyproject, package dirs, stub modules); generate the real omo-suggestions.json (bun + omo checkout) and hand-write default-config.jsonc. Unblocks all.
  2. 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).
  3. Integrate (lead): merge tracks, wire app.py to catalog/suggestions/resolve/config_io, reconcile any interface drift against the §Data contracts.
  4. Gate (QA + lead): QA's test_* green and all 8 §Verification checks pass (incl. a live opencode run + a Pilot save round-trip). Nothing ships until green.

Notes

  • 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.pysuggestions.py + catalog.py; refresh.pysnapshot_omo.ts. config_io.py and CLI+packaging are near-independent; everything else parallelizes once contracts are frozen.