This is the contract-first boundary. Read DESIGN.md in full first — it is the spec.
This file pins the shared shapes + ownership so the five tracks build in parallel and
integrate cleanly. Do not change a public signature or shared shape without the Lead
updating this file (others depend on it).
| Track | Owns (edit only these) |
|---|---|
| Core logic | src/omodel/catalog.py, src/omodel/cache.py, src/omodel/suggestions.py, src/omodel/resolve.py, src/omodel/tools/snapshot_omo.ts |
| Config I/O | src/omodel/config_io.py |
| TUI | src/omodel/app.py, src/omodel/history.py |
| CLI + packaging | src/omodel/cli.py, src/omodel/refresh.py, pyproject.toml, install.sh, .github/workflows/*, README.md, LICENSE, NOTICE, CHANGELOG.md |
| QA / verification | everything under tests/ (incl. conftest.py) |
Lead owns: __init__.py, __main__.py, data/*, this file, and ALL git operations + final wiring.
- Do NOT run any git command (no add/commit/branch/checkout). The Lead owns git and integration.
- Touch only your owned files. Read others freely; never edit them. If you believe a
frozen signature is wrong, leave a
# CONTRACT-QUESTION:comment in YOUR file and proceed against the current signature — the Lead reconciles at integration. - Python floor is 3.9. Put
from __future__ import annotationsat the top of every module (already present in stubs). No runtime PEP-604 unions (isinstance(x, A | B)) and no runtime PEP-585 generics; annotations-as-strings makedict | Nonein signatures fine. - REAL-CONFIG SAFETY (hard rule). The live
~/.config/opencode/oh-my-openagent.jsoncis the user's real file. Never read-then-write it in tests or examples. Every test passes an explicit temppath/--config. The Lead's gate enforces this. - Tests/imports run in a venv with
textual json5 pytestinstalled (PyPI reachable). Do not assume system-wide installs. - REAL-CACHE SAFETY (hard rule). The opencode-output cache lives at
~/.cache/omodel/($OMODEL_CACHE_DIR→$XDG_CACHE_HOME/omodel→~/.cache/omodel). Tests must never touch the real cache: the autouseconftest.pyfixture points$OMODEL_CACHE_DIRat a tmp dir, and any test exercising the TUI/catalog must stubsubprocess.run(no realopencode— each call is ~3s / ~320 MB RSS, and stacking them OOM'd a machine).
target id (string): "agent:<name>" · "agent:<name>.ultrawork" ·
"agent:<name>.compaction" · "cat:<name>" — identical to the OptionList#targets option IDs.
source enum (string): "omo" (a fallbackChain entry — exact or same-line substitute) ·
"add" (an off-chain pick — typed in the add-model modal, or the target's current off-chain
assignment surfaced by app.py from cfg as a cand:<i> row). ("mine" retired: candidates()
no longer dumps every connected model — off-chain picks go through the add-model modal.)
candidate-row dict — yielded by Resolver.candidates(), rendered by app.py:
{
"source": "omo" | "add",
"model": "glm-5.1", # RESOLVED bare model id actually used (the substitute,
# when this is a same-line stand-in), no prefix
"provider": "zhipuai", # one serving provider; candidates() emits one row PER
# serving provider, dedicated-first (a non-empty str —
# rows with no connected provider are dropped, never shown)
"variant": "max" | None, # per precedence; None = unset
"entry": {...} | None, # the omo fallbackChain entry; None for an 'add' row
"substitute_for": None | "glm-5", # None = exact id; else the omo id this same-line row fills
"warn": [] | ["variant"], # 'omo' rows: variant only ('unavailable' is skipped, not
# shown). 'add' rows may also carry ["unavailable"].
}Value written to config = f"{provider}/{model}" plus variant (omitted when None) — i.e.
the resolved substitute, not the omo id. substitute_for is display-only.
The shape is unchanged by the two-phase add-model modal (#add-input fuzzy provider/model
list #add-candidates, then the variant list #add-variants): variant was always a field — an
"add" row now carries the variant picked in the modal's variant phase (still None when opencode
reports no variants for the chosen (provider, model) via Catalog.variants_for), instead of being
forced to None.
The stub files ARE the signatures; implement their bodies. Summary:
catalog.py:class CatalogUnavailable(Exception);@dataclass Catalog(available: dict, connected: list)with.providers_for(model_id)->list,.detail(model_id, use_cache=True, provider=None)->dict|None(provider, when it serves the model, selects WHOSE record — the detail pane passes the assignment's provider; else first-of-providers_for as before),.variants_for(provider, model)->list(cached--verbosevariant keys for the model pickers — first non-empty across the picked provider then others, else[]; never a subprocess);load(opencode_bin="opencode", use_cache=True)->Catalog;refresh(opencode_bin="opencode")->Catalog(forceopencode models --refresh+ rebuild cache). All three opencode calls read through the on-disk cache (cache.py) and carry atimeout=.cache.py: on-disk cache of opencode stdout (24h TTL, flat, under~/.cache/omodel/).cache_dir()->str;read(key, ttl_seconds=None)->str|None;write(key, stdout, args=None)->None;age_seconds(key)->float|None;clear()->None;CACHE_VERSION. Best-effort: missing/corrupt/ expired → miss; write errors swallowed (a non-writable cache never breaks the caller).suggestions.py:FAMILY_VENDOR(frozen 15-map);@dataclass Family;@dataclass Suggestions(meta, agents, categories, families, known_variants)with.detect_family(id)-> Family|None,.vendor_for(id)->str|None;vendor(family)->str|None;normalize_model_id(s)->str;load(path=None)->Suggestions(no explicit path/env override → the NEWER of the$XDG_DATA_HOMEsnapshot and the bundled data, bymeta.generatedAt).resolve.py:@dataclass Resolver(catalog, suggestions, gateways, real_tokens)(gateways+real_tokensare computed inbuild()) with classmethodbuild(catalog, suggestions),.vendors_served(p)->int,.resolve_prefix(model_id, source, entry=None)->str|None,.candidates(target)->list[dict].config_io.py:config_path(cli_override=None)->str;load_config(path=None)->(cfg, path)(raisesConfigParseError(ValueError)— message carries the path — on malformed JSONC; cli.py catches it for a friendly exit-1 message on the TUI/--printpaths);serialize(cfg)->str(canonical clean form — dirtiness + from-scratch fallback; never required to equal the on-disk bytes);render(cfg, base_text)->str(text-preserving write form:base_textwith only the top-levelagents/categoriesvalue spans rewritten clean, everything else — incl. comments / commented-out config outside them — byte-for-byte; falls back toserialize(cfg)whenbase_textis empty or a key isn't a direct root member);diff_text(cfg, path)->strandsave(cfg, path)->SaveResultboth go throughrender;@dataclass SaveResult(changed, backup, original_created);@dataclass BackupInfo(name, path, is_original, size);list_backups(path)->list;restore(path, backup_name)->None.app.py:class OModelApp(App)(Textual) +create_app(config_path=None)->OModelApp(the testable construction half — builds catalog/suggestions/resolver/cfg; the resolver is built even in CatalogUnavailable degraded mode, over the empty catalog) +run_app(config_path=None)->None(==create_app(...).run()). Stable widget IDs as documented inapp.py's docstring. Every cfg mutation routes through_record/_stage_row(which push ontoHistory);uundo /ctrl+rredo; dirtiness is_is_dirty()(serialize vs_saved_text), not a flag.history.py:@dataclass HistoryEntry(state, label, aux=None);class History(initial, label="loaded", limit=200, aux=None)with.push(state, label, aux=None)->bool(no-op whenstateunchanged;auxrides along),.undo()/.redo()->(state, label)|None,.current_state()->dict,.current_aux()->dict(the cursor entry'saux,{}if none),.clear_aux()->None(drop all entries'aux),.matches_current(state)->bool, and thecan_undo/can_redo/undo_label/redo_labelproperties.auxis an out-of-cfg companion snapshot (app.py stores_custom_rows). Pure data; snapshots deep-copied in and out. Consumed only byapp.py.cli.py:main(argv=None)->int(console-script entrypoint).refresh.py:refresh(omo_src=None)->int(the--refresh-omoflag — bundled omo suggestion data; distinct fromcatalog.refresh(), which is opencode availability via--refresh-models).
resolve.py→suggestions.py+catalog.py.refresh.py→tools/snapshot_omo.ts.app.py→ all four modules +history.py(Lead wires final).config_io.py+ CLI+packaging are near-independent.history.pyis a pure leaf (no omodel imports).
data/omo-suggestions.json— omo v4.13.0 @ f31c735: 11 agents, 8 categories, 15 families, 9 knownVariants. Consume viasuggestions.load().data/default-config.jsonc— oModel's own minimal starter.