diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 4cb6ead058c..95926d210fb 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -21,11 +21,17 @@ file, and removes it again. Seventeen clients work this way, each with a switch: | ZCode | `~/.zcode/v2/config.json` | JSON | on restart | loopback placeholder | | Aside | `~/.aside/u//models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only | -| omo | `~/.omo/agent/models.json` | JSON | new sessions | loopback placeholder | +| omo (Pi / senpi) | `~/.omo/agent/models.json` | JSON | new sessions | loopback placeholder | | Cline CLI | `~/.cline/data/settings/providers.json` and sibling `models.json` | JSON pair | after stopping and restarting Cline | loopback placeholder | | Kilo | first existing `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json`, or `config.json` under `~/.config/kilo` | JSONC | new sessions | `OPENCODEX_KILO_API_KEY` | | Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | JSON | immediately via file watching | none — keyless loopback | +"omo" names three products that share the `~/.omo` folder. The **omo** tab manages Pi-based omo +(the senpi engine) through `~/.omo/agent/models.json`, as in the table above. Codex-based omo +(LazyCodex) gets its own controls on the Codex tab, described in +[omo (Codex / LazyCodex) role models](#omo-codex--lazycodex-role-models). OpenCode-based omo +(oh-my-opencode) keeps its own config under OpenCode; opencodex does not read or write it. + Generated catalogs include only enabled models from each provider selection. This applies to both downloads and managed integrations, including Pi and Aside. The management model list still shows the full roster so you can enable additional models. @@ -521,6 +527,34 @@ key) in the app's API key field. The app sends it as `Authorization: Bearer`, wh `/v1/chat/completions` accepts as proxy admission and never forwards upstream; see the [authentication matrix](/reference/proxy-formats/#authentication-matrix). +## omo (Codex / LazyCodex) role models + +When LazyCodex is installed, the Codex tab shows an **omo (Codex / LazyCodex)** section listing +every Codex agent role found in `$CODEX_HOME/agents/*.toml`, with the model each one is pinned +to. Codex runs a role on that pin no matter which model the parent asks for, so this is where a +role's model is actually decided. LazyCodex counts as installed when the `omo@sisyphuslabs` +Codex plugin is enabled in `$CODEX_HOME/config.toml` and an installed copy under +`$CODEX_HOME/plugins/cache/sisyphuslabs/omo/` carries its `lazycodex-install.json`. A `~/.omo` +folder on its own does not count, because Pi-based omo creates it too. Without LazyCodex the +section is hidden and the command line reports it as not installed. Pick a model on a row and +press Save: + +- opencodex rewrites only the root `model = "..."` line of that role's file. The role's + instructions, comments, and other keys are left exactly as they were. A role with no pin gets + one added near the top of the file. +- The same value is written to `codex.agents..model` in `~/.omo/omo.jsonc`, which + LazyCodex 5.1.1 and later reads. If that file does not exist it is not created. If it contains + comments it is left untouched, because saving would remove them; the tab says so, and you can + set the value there by hand. + +Nothing happens until you press Save; syncing or restarting opencodex never changes a role file. +New Codex sessions pick up the change. The same controls exist on the command line: + +```bash +ocx agent roles +ocx agent roles set explorer xai/grok-4.5 +``` + ## Kilo Kilo CLI, VS Code, and JetBrains share one global config. This integration writes diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 933e2fc6baa..0f14578987d 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -7,7 +7,7 @@ These commands control agent policy and routing, inspect the live proxy, and con ## Agent policy -### `ocx agent ...` +### `ocx agent ...` Manage the headless multi-agent roster, effort caps, prompt injection, fallback, and sidecar settings. Use `status` for the current policy. See [Sub-agent surfaces](/guides/sub-agent-surface/) for how @@ -17,6 +17,17 @@ surface modes, delegation, effort, and fallback behavior fit together. ocx agent subagents set ark/model-a,openai/gpt-5.5 ``` +`ocx agent roles` is for omo (Codex / LazyCodex). It lists each Codex agent role in +`$CODEX_HOME/agents` with its model pin and whether `~/.omo/omo.jsonc` can be updated, or says +LazyCodex is not installed, in which case `set` is refused. `ocx agent roles set ` rewrites only +that role's root `model` line and mirrors the value into omo.jsonc at +`codex.agents..model`. A missing omo.jsonc, or one containing comments, is left unchanged +and the command says so. See [omo (Codex / LazyCodex) role models](/guides/integrations/#omo-codex--lazycodex-role-models). + +```bash +ocx agent roles set explorer xai/grok-4.5 +``` + `ocx agent sidecar web --list` and `ocx agent sidecar vision --list` print the models the server currently offers for each sidecar — the exact filtered set the dashboard picker shows (picker-visible rows plus the login-entitled Luna/Haiku auth slots, intersected with executor diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a8eeaab750e..633545a16c3 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1666,6 +1666,24 @@ export const de: Record = { "integrations.semantics.aside": "Verwaltet nur providers.opencodex in der ~/.aside/u//models.json dieses Profils. Andere Provider bleiben unverändert. Beende Aside nach dem Anwenden vollständig und öffne es erneut.", "integrations.semantics.raycast": "Fügt einen OpenCodex-Provider-Eintrag in die providers.yaml von Raycast ein, damit jedes geroutete Modell in der Modellauswahl von Raycast AI erscheint. Raycast Pro erforderlich.", "integrations.semantics.omo": "Verwaltet ausschließlich providers.opencodex in der models.json von omo — ~/.omo/agent, sofern nicht OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR oder PI_CODING_AGENT_DIR sie umleitet. Ihre übrigen Provider bleiben unverändert. Gilt ab neuen Sitzungen.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Modelle der Codex-Agentenrollen", + "integrations.lazycodexRoles.hint": "Wählen Sie das Modell, mit dem Codex jede Agentenrolle ausführt. Beim Speichern ändert sich nur die model-Zeile in der Datei dieser Rolle unter $CODEX_HOME/agents. In omo.jsonc wird die Auswahl für LazyCodex nur übernommen, wenn sich die Datei sicher neu schreiben lässt.", + "integrations.lazycodexRoles.role": "Rolle", + "integrations.lazycodexRoles.current": "Aktuelles Modell", + "integrations.lazycodexRoles.model": "Neues Modell", + "integrations.lazycodexRoles.modelFor": "Modell für {role}", + "integrations.lazycodexRoles.none": "Kein Modell festgelegt", + "integrations.lazycodexRoles.choose": "Modell wählen", + "integrations.lazycodexRoles.empty": "Keine Codex-Agentenrollen gefunden.", + "integrations.lazycodexRoles.loadFailed": "Codex-Agentenrollen konnten nicht geladen werden.", + "integrations.lazycodexRoles.saveFailed": "Das Modell für {role} konnte nicht gespeichert werden.", + "integrations.lazycodexRoles.saved": "{role} läuft jetzt mit {model}.", + "integrations.lazycodexRoles.omoAbsent": "{role} läuft jetzt mit {model}. omo.jsonc wurde nicht gefunden, daher wurde nur die Rollendatei geändert.", + "integrations.lazycodexRoles.omoComments": "{role} läuft laut Rollendatei jetzt mit {model}. omo.jsonc blieb unverändert, weil das Speichern seine Kommentare entfernen würde; setzen Sie codex.agents.{role}.model dort von Hand.", + "integrations.lazycodexRoles.omoInvalid": "{role} läuft laut Rollendatei jetzt mit {model}. omo.jsonc blieb unverändert, weil es kein JSON-Objekt mit einem codex.agents-Objekt ist.", + "integrations.lazycodexRoles.omoFailed": "{role} läuft laut Rollendatei jetzt mit {model}, aber omo.jsonc konnte nicht geschrieben werden.", + "integrations.lazycodexRoles.retryMirror": "omo.jsonc erneut versuchen", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc enthält Kommentare, daher ändert Speichern hier nur die Rollendateien. Passen Sie codex.agents in omo.jsonc von Hand an, damit beide übereinstimmen.", "integrations.semantics.cline": "Verwaltet OpenCodex in providers.json und models.json der Cline CLI. Beenden Sie Cline vor Änderungen oder Synchronisierung und starten Sie es danach neu. Rückgängig stellt beide Originaldateien wieder her. Ihr Standardanbieter bleibt unverändert; wählen Sie OpenCodex in Cline.", "integrations.semantics.kilo": "Verwaltet nur provider.opencodex in Kilos globaler Konfiguration — die erste vorhandene Datei unter kilo.jsonc, kilo.json, opencode.jsonc, opencode.json oder config.json in ~/.config/kilo (XDG_CONFIG_HOME verschiebt dieses Verzeichnis; ist keine vorhanden, wird kilo.jsonc erstellt). Andere Schlüssel bleiben unverändert. Anwenden schreibt die ganze Datei neu, daher bleiben Kommentare und nachgestellte Kommas nicht erhalten. Wählen Sie opencodex/ in Kilo.", "integrations.semantics.droid": "Fügt Factory Droid in settings.json OpenCodex-Modelle hinzu. Deaktivieren entfernt nur verwaltete Einträge; Rückgängig stellt die gespeicherte Datei wieder her.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c536e5b1d71..bca24862237 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2269,6 +2269,24 @@ export const en = { "integrations.semantics.aside": "Manages only providers.opencodex in this profile’s ~/.aside/u//models.json. Your other providers stay unchanged. Fully quit and reopen Aside after applying.", "integrations.semantics.raycast": "Adds an OpenCodex provider entry to Raycast's providers.yaml so every routed model appears in the Raycast AI model picker. Raycast Pro required.", "integrations.semantics.omo": "Manages only providers.opencodex in omo's models.json — ~/.omo/agent unless OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR or PI_CODING_AGENT_DIR redirects it. Your other providers stay unchanged. Applies to new sessions.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Codex agent role models", + "integrations.lazycodexRoles.hint": "Pick the model Codex runs for each agent role. Saving changes only the model line in that role's file under $CODEX_HOME/agents, and mirrors the choice into omo.jsonc for LazyCodex only when that file can be safely rewritten.", + "integrations.lazycodexRoles.role": "Role", + "integrations.lazycodexRoles.current": "Current model", + "integrations.lazycodexRoles.model": "New model", + "integrations.lazycodexRoles.modelFor": "Model for {role}", + "integrations.lazycodexRoles.none": "No model pin", + "integrations.lazycodexRoles.choose": "Choose a model", + "integrations.lazycodexRoles.empty": "No Codex agent roles were found.", + "integrations.lazycodexRoles.loadFailed": "Could not load Codex agent roles.", + "integrations.lazycodexRoles.saveFailed": "Could not save the model for {role}.", + "integrations.lazycodexRoles.saved": "{role} now runs on {model}.", + "integrations.lazycodexRoles.omoAbsent": "{role} now runs on {model}. omo.jsonc was not found, so only the role file changed.", + "integrations.lazycodexRoles.omoComments": "{role} now runs on {model} in its role file. omo.jsonc was left unchanged because saving would remove its comments; set codex.agents.{role}.model there by hand.", + "integrations.lazycodexRoles.omoInvalid": "{role} now runs on {model} in its role file. omo.jsonc was left unchanged because it is not a JSON object with a codex.agents object.", + "integrations.lazycodexRoles.omoFailed": "{role} now runs on {model} in its role file, but omo.jsonc could not be written.", + "integrations.lazycodexRoles.retryMirror": "Retry omo.jsonc", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc contains comments, so saving here changes only the role files. Edit codex.agents in omo.jsonc by hand to keep it in step.", "integrations.semantics.cline": "Manages OpenCodex in Cline CLI providers.json and models.json. Stop Cline before changing or syncing these files, then restart. Undo restores both originals. Your default provider stays unchanged; select OpenCodex in Cline.", "integrations.semantics.kilo": "Manages only provider.opencodex in Kilo's global config — the first existing file among kilo.jsonc, kilo.json, opencode.jsonc, opencode.json, or config.json under ~/.config/kilo (XDG_CONFIG_HOME relocates that directory; kilo.jsonc is created when none exist). Other keys stay unchanged. Apply rewrites the whole file, so comments and trailing commas are not preserved. Select opencodex/ in Kilo.", "integrations.semantics.droid": "Adds OpenCodex custom models to Factory Droid settings.json. Disable removes only managed rows; undo restores the saved file.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 3c8c3f78301..e65285be7ab 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2196,6 +2196,24 @@ export const fr: Record = { "integrations.semantics.aside": "Gère uniquement providers.opencodex dans le fichier ~/.aside/u//models.json de ce profil. Vos autres fournisseurs restent inchangés. Quittez complètement Aside et relancez-le après application.", "integrations.semantics.raycast": "Ajoute une entrée de fournisseur OpenCodex dans le providers.yaml de Raycast afin que chaque modèle routé apparaisse dans le sélecteur de modèles de Raycast AI. Raycast Pro requis.", "integrations.semantics.omo": "Gère uniquement providers.opencodex dans le models.json d'omo — ~/.omo/agent, sauf redirection par OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR ou PI_CODING_AGENT_DIR. Vos autres fournisseurs restent inchangés. S'applique aux nouvelles sessions.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Modèles des rôles d’agent Codex", + "integrations.lazycodexRoles.hint": "Choisissez le modèle que Codex utilise pour chaque rôle d’agent. L’enregistrement ne modifie que la ligne model du fichier de ce rôle dans $CODEX_HOME/agents, et ne reporte le choix dans omo.jsonc pour LazyCodex que si ce fichier peut être réécrit sans risque.", + "integrations.lazycodexRoles.role": "Rôle", + "integrations.lazycodexRoles.current": "Modèle actuel", + "integrations.lazycodexRoles.model": "Nouveau modèle", + "integrations.lazycodexRoles.modelFor": "Modèle pour {role}", + "integrations.lazycodexRoles.none": "Aucun modèle épinglé", + "integrations.lazycodexRoles.choose": "Choisir un modèle", + "integrations.lazycodexRoles.empty": "Aucun rôle d’agent Codex trouvé.", + "integrations.lazycodexRoles.loadFailed": "Impossible de charger les rôles d’agent Codex.", + "integrations.lazycodexRoles.saveFailed": "Impossible d’enregistrer le modèle de {role}.", + "integrations.lazycodexRoles.saved": "{role} utilise désormais {model}.", + "integrations.lazycodexRoles.omoAbsent": "{role} utilise désormais {model}. omo.jsonc est introuvable, seul le fichier du rôle a donc été modifié.", + "integrations.lazycodexRoles.omoComments": "{role} utilise désormais {model} dans son fichier de rôle. omo.jsonc n’a pas été modifié, car l’enregistrement supprimerait ses commentaires ; définissez codex.agents.{role}.model à la main.", + "integrations.lazycodexRoles.omoInvalid": "{role} utilise désormais {model} dans son fichier de rôle. omo.jsonc n’a pas été modifié, car ce n’est pas un objet JSON contenant un objet codex.agents.", + "integrations.lazycodexRoles.omoFailed": "{role} utilise désormais {model} dans son fichier de rôle, mais omo.jsonc n’a pas pu être écrit.", + "integrations.lazycodexRoles.retryMirror": "Réessayer omo.jsonc", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc contient des commentaires : l’enregistrement ici ne modifie donc que les fichiers de rôle. Modifiez codex.agents dans omo.jsonc à la main pour rester synchronisé.", "integrations.semantics.cline": "Gère OpenCodex dans providers.json et models.json de Cline CLI. Quittez Cline avant toute modification ou synchronisation, puis redémarrez-le. Annuler restaure les deux fichiers originaux. Votre fournisseur par défaut reste inchangé ; sélectionnez OpenCodex dans Cline.", "integrations.semantics.kilo": "Gère uniquement provider.opencodex dans la configuration globale de Kilo — le premier fichier existant parmi kilo.jsonc, kilo.json, opencode.jsonc, opencode.json ou config.json sous ~/.config/kilo (XDG_CONFIG_HOME déplace ce répertoire ; kilo.jsonc est créé si aucun n'existe). Les autres clés restent inchangées. Appliquer réécrit tout le fichier : commentaires et virgules finales ne sont pas conservés. Sélectionnez opencodex/ dans Kilo.", "integrations.semantics.droid": "Ajoute les modèles personnalisés OpenCodex au settings.json de Factory Droid. Désactiver retire seulement les entrées gérées ; Annuler restaure le fichier sauvegardé.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index adf978cd787..7fa9b120c4e 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2099,6 +2099,24 @@ export const ja: Record = { "integrations.semantics.aside": "このプロファイルの ~/.aside/u//models.json 内の providers.opencodex のみを管理します。他のプロバイダーは変更しません。適用後は Aside を完全に終了してから開き直してください。", "integrations.semantics.raycast": "Raycast の providers.yaml に OpenCodex のプロバイダーエントリを追加し、ルーティングされたすべてのモデルを Raycast AI のモデル選択に表示します。Raycast Pro が必要です。", "integrations.semantics.omo": "omo の models.json にある providers.opencodex のみを管理します。場所は ~/.omo/agent で、OMO_CODING_AGENT_DIR・SENPI_CODING_AGENT_DIR・PI_CODING_AGENT_DIR のいずれかが設定されている場合はそちらが優先されます。他のプロバイダーは変更しません。新しいセッションから適用されます。", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Codex エージェントロールのモデル", + "integrations.lazycodexRoles.hint": "Codex が各エージェントロールで使うモデルを選びます。保存すると $CODEX_HOME/agents にあるそのロールのファイルの model 行だけが変わり、omo.jsonc を安全に書き換えられる場合に限り LazyCodex 用に同じ値を反映します。", + "integrations.lazycodexRoles.role": "ロール", + "integrations.lazycodexRoles.current": "現在のモデル", + "integrations.lazycodexRoles.model": "新しいモデル", + "integrations.lazycodexRoles.modelFor": "{role} のモデル", + "integrations.lazycodexRoles.none": "モデル指定なし", + "integrations.lazycodexRoles.choose": "モデルを選択", + "integrations.lazycodexRoles.empty": "Codex エージェントロールが見つかりません。", + "integrations.lazycodexRoles.loadFailed": "Codex エージェントロールを読み込めませんでした。", + "integrations.lazycodexRoles.saveFailed": "{role} のモデルを保存できませんでした。", + "integrations.lazycodexRoles.saved": "{role} は {model} で動作します。", + "integrations.lazycodexRoles.omoAbsent": "{role} は {model} で動作します。omo.jsonc が見つからないため、ロールファイルだけを変更しました。", + "integrations.lazycodexRoles.omoComments": "ロールファイルでは {role} が {model} で動作します。保存するとコメントが消えるため omo.jsonc は変更していません。codex.agents.{role}.model を手動で設定してください。", + "integrations.lazycodexRoles.omoInvalid": "ロールファイルでは {role} が {model} で動作します。omo.jsonc は codex.agents オブジェクトを持つ JSON オブジェクトではないため変更していません。", + "integrations.lazycodexRoles.omoFailed": "ロールファイルでは {role} が {model} で動作しますが、omo.jsonc に書き込めませんでした。", + "integrations.lazycodexRoles.retryMirror": "omo.jsonc を再試行", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc にコメントがあるため、ここで保存するとロールファイルだけが変わります。omo.jsonc の codex.agents は手動で合わせてください。", "integrations.semantics.cline": "Cline CLI の providers.json と models.json の OpenCodex 項目を管理します。変更・同期前に Cline を終了し、完了後に再起動してください。元に戻すと両方の元ファイルが復元されます。既定のプロバイダーは変わりません。Cline で OpenCodex を選択してください。", "integrations.semantics.kilo": "Kilo のグローバル設定(~/.config/kilo 配下で最初に存在する kilo.jsonc、kilo.json、opencode.jsonc、opencode.json、config.json。XDG_CONFIG_HOME はこのディレクトリを移動し、いずれも存在しなければ kilo.jsonc が作成されます)の provider.opencodex のみを管理します。他のキーは変更しません。適用時にファイル全体を書き直すため、コメントと末尾カンマは保持されません。Kilo で opencodex/<モデル> を選択してください。", "integrations.semantics.droid": "Factory Droid の settings.json に OpenCodex カスタムモデルを追加します。無効化すると管理対象の項目だけが削除され、元に戻すと保存したファイルが復元されます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1c4f368389b..f30627e4474 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1702,6 +1702,24 @@ export const ko: Record = { "integrations.semantics.aside": "이 프로필의 ~/.aside/u//models.json에서 providers.opencodex만 관리합니다. 다른 프로바이더는 그대로 유지됩니다. 적용 후 Aside를 완전히 종료하고 다시 여세요.", "integrations.semantics.raycast": "Raycast의 providers.yaml에 OpenCodex 프로바이더 항목을 추가해 라우팅된 모든 모델이 Raycast AI 모델 선택기에 표시되도록 합니다. Raycast Pro가 필요합니다.", "integrations.semantics.omo": "omo의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.omo/agent이며 OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR, PI_CODING_AGENT_DIR 중 설정된 값이 있으면 그쪽이 우선합니다. 다른 프로바이더는 그대로 유지됩니다. 새 세션부터 적용됩니다.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Codex 에이전트 역할 모델", + "integrations.lazycodexRoles.hint": "Codex가 에이전트 역할마다 쓰는 모델을 고릅니다. 저장하면 $CODEX_HOME/agents에 있는 그 역할 파일의 model 줄만 바뀌고, omo.jsonc를 안전하게 다시 쓸 수 있을 때만 LazyCodex용으로 같은 값을 반영합니다.", + "integrations.lazycodexRoles.role": "역할", + "integrations.lazycodexRoles.current": "현재 모델", + "integrations.lazycodexRoles.model": "새 모델", + "integrations.lazycodexRoles.modelFor": "{role} 모델", + "integrations.lazycodexRoles.none": "지정된 모델 없음", + "integrations.lazycodexRoles.choose": "모델 선택", + "integrations.lazycodexRoles.empty": "Codex 에이전트 역할을 찾지 못했습니다.", + "integrations.lazycodexRoles.loadFailed": "Codex 에이전트 역할을 불러오지 못했습니다.", + "integrations.lazycodexRoles.saveFailed": "{role}의 모델을 저장하지 못했습니다.", + "integrations.lazycodexRoles.saved": "이제 {role}은(는) {model}로 실행됩니다.", + "integrations.lazycodexRoles.omoAbsent": "이제 {role}은(는) {model}로 실행됩니다. omo.jsonc가 없어서 역할 파일만 바꿨습니다.", + "integrations.lazycodexRoles.omoComments": "역할 파일에서 {role}은(는) 이제 {model}로 실행됩니다. 저장하면 주석이 사라지므로 omo.jsonc는 바꾸지 않았습니다. 그곳의 codex.agents.{role}.model은 직접 설정하세요.", + "integrations.lazycodexRoles.omoInvalid": "역할 파일에서 {role}은(는) 이제 {model}로 실행됩니다. omo.jsonc가 codex.agents 객체를 가진 JSON 객체가 아니어서 바꾸지 않았습니다.", + "integrations.lazycodexRoles.omoFailed": "역할 파일에서 {role}은(는) 이제 {model}로 실행되지만, omo.jsonc에는 쓰지 못했습니다.", + "integrations.lazycodexRoles.retryMirror": "omo.jsonc 다시 시도", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc에 주석이 있어서 여기서 저장하면 역할 파일만 바뀝니다. omo.jsonc의 codex.agents는 직접 맞춰 주세요.", "integrations.semantics.cline": "Cline CLI의 providers.json과 models.json에서 OpenCodex 항목을 관리합니다. 변경·동기화 전에 Cline을 종료하고 완료 후 다시 실행하세요. 되돌리기는 두 파일의 원본을 복원합니다. 기본 프로바이더는 유지되므로 Cline에서 OpenCodex를 선택하세요.", "integrations.semantics.kilo": "Kilo 전역 설정(~/.config/kilo에서 먼저 존재하는 kilo.jsonc, kilo.json, opencode.jsonc, opencode.json, config.json. XDG_CONFIG_HOME는 이 디렉터리를 이동하며, 아무것도 없으면 kilo.jsonc가 생성됨)의 provider.opencodex만 관리합니다. 다른 키는 그대로 둡니다. 적용 시 파일 전체를 다시 쓰므로 주석과 후행 쉼표는 보존되지 않습니다. Kilo에서 opencodex/<모델>을 선택하세요.", "integrations.semantics.droid": "Factory Droid의 settings.json에 OpenCodex 사용자 지정 모델을 추가합니다. 비활성화하면 관리 항목만 제거하고, 되돌리기는 저장한 파일을 복원합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 5b75977f922..67c2fb4ac7c 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2190,6 +2190,24 @@ export const ru: Record = { "integrations.semantics.aside": "Управляет только providers.opencodex в файле ~/.aside/u//models.json этого профиля. Другие провайдеры остаются без изменений. После применения полностью закройте и снова откройте Aside.", "integrations.semantics.raycast": "Добавляет запись провайдера OpenCodex в providers.yaml Raycast, чтобы каждая маршрутизируемая модель появилась в выборе моделей Raycast AI. Требуется Raycast Pro.", "integrations.semantics.omo": "Управляет только providers.opencodex в models.json omo — ~/.omo/agent, если только OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR или PI_CODING_AGENT_DIR не перенаправляет путь. Остальные провайдеры остаются без изменений. Применяется к новым сессиям.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Модели ролей агентов Codex", + "integrations.lazycodexRoles.hint": "Выберите модель, на которой Codex запускает каждую роль агента. При сохранении меняется только строка model в файле этой роли в $CODEX_HOME/agents, а в omo.jsonc для LazyCodex выбор копируется, только если этот файл можно безопасно перезаписать.", + "integrations.lazycodexRoles.role": "Роль", + "integrations.lazycodexRoles.current": "Текущая модель", + "integrations.lazycodexRoles.model": "Новая модель", + "integrations.lazycodexRoles.modelFor": "Модель для {role}", + "integrations.lazycodexRoles.none": "Модель не закреплена", + "integrations.lazycodexRoles.choose": "Выберите модель", + "integrations.lazycodexRoles.empty": "Роли агентов Codex не найдены.", + "integrations.lazycodexRoles.loadFailed": "Не удалось загрузить роли агентов Codex.", + "integrations.lazycodexRoles.saveFailed": "Не удалось сохранить модель для {role}.", + "integrations.lazycodexRoles.saved": "{role} теперь работает на {model}.", + "integrations.lazycodexRoles.omoAbsent": "{role} теперь работает на {model}. Файл omo.jsonc не найден, поэтому изменён только файл роли.", + "integrations.lazycodexRoles.omoComments": "В файле роли {role} теперь работает на {model}. omo.jsonc не изменён, потому что сохранение удалило бы его комментарии; задайте codex.agents.{role}.model вручную.", + "integrations.lazycodexRoles.omoInvalid": "В файле роли {role} теперь работает на {model}. omo.jsonc не изменён: это не JSON-объект с объектом codex.agents.", + "integrations.lazycodexRoles.omoFailed": "В файле роли {role} теперь работает на {model}, но записать omo.jsonc не удалось.", + "integrations.lazycodexRoles.retryMirror": "Повторить запись omo.jsonc", + "integrations.lazycodexRoles.omoCommentsState": "В omo.jsonc есть комментарии, поэтому сохранение здесь меняет только файлы ролей. Поправьте codex.agents в omo.jsonc вручную, чтобы они совпадали.", "integrations.semantics.cline": "Управляет OpenCodex в файлах providers.json и models.json Cline CLI. Закройте Cline перед изменением или синхронизацией и запустите снова после завершения. Отмена восстанавливает оба исходных файла. Провайдер по умолчанию не меняется; выберите OpenCodex в Cline.", "integrations.semantics.kilo": "Управляет только provider.opencodex в глобальной конфигурации Kilo — первый существующий файл среди kilo.jsonc, kilo.json, opencode.jsonc, opencode.json или config.json в ~/.config/kilo (XDG_CONFIG_HOME переносит этот каталог; если файла нет, создаётся kilo.jsonc). Остальные ключи не меняются. Применение перезаписывает весь файл, поэтому комментарии и завершающие запятые не сохраняются. Выберите opencodex/<модель> в Kilo.", "integrations.semantics.droid": "Добавляет модели OpenCodex в settings.json Factory Droid. Отключение удаляет только управляемые записи; отмена восстанавливает сохранённый файл.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 6d7a0507041..d345f352f75 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2208,6 +2208,24 @@ export const tr: Record = { "integrations.semantics.aside": "Yalnızca bu profilin ~/.aside/u//models.json dosyasındaki providers.opencodex bölümünü yönetir. Diğer sağlayıcılarınız değişmez. Uyguladıktan sonra Aside’ı tamamen kapatıp yeniden açın.", "integrations.semantics.raycast": "Raycast'in providers.yaml dosyasına bir OpenCodex sağlayıcı girdisi ekler; böylece yönlendirilen her model Raycast AI model seçicisinde görünür. Raycast Pro gerekir.", "integrations.semantics.omo": "Yalnızca omo'nun models.json dosyasındaki providers.opencodex girdisini yönetir — OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR veya PI_CODING_AGENT_DIR yönlendirmediği sürece ~/.omo/agent. Diğer sağlayıcılarınız değişmeden kalır. Yeni oturumlardan itibaren geçerlidir.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Codex ajan rolü modelleri", + "integrations.lazycodexRoles.hint": "Codex'in her ajan rolünde kullanacağı modeli seçin. Kaydetmek yalnızca $CODEX_HOME/agents altındaki o rol dosyasının model satırını değiştirir ve omo.jsonc güvenle yeniden yazılabiliyorsa seçimi LazyCodex için oraya da yansıtır.", + "integrations.lazycodexRoles.role": "Rol", + "integrations.lazycodexRoles.current": "Geçerli model", + "integrations.lazycodexRoles.model": "Yeni model", + "integrations.lazycodexRoles.modelFor": "{role} için model", + "integrations.lazycodexRoles.none": "Sabitlenmiş model yok", + "integrations.lazycodexRoles.choose": "Bir model seçin", + "integrations.lazycodexRoles.empty": "Codex ajan rolü bulunamadı.", + "integrations.lazycodexRoles.loadFailed": "Codex ajan rolleri yüklenemedi.", + "integrations.lazycodexRoles.saveFailed": "{role} için model kaydedilemedi.", + "integrations.lazycodexRoles.saved": "{role} artık {model} ile çalışıyor.", + "integrations.lazycodexRoles.omoAbsent": "{role} artık {model} ile çalışıyor. omo.jsonc bulunamadığı için yalnızca rol dosyası değişti.", + "integrations.lazycodexRoles.omoComments": "{role} rol dosyasında artık {model} ile çalışıyor. Kaydetmek yorumlarını sileceği için omo.jsonc değiştirilmedi; codex.agents.{role}.model değerini orada elle ayarlayın.", + "integrations.lazycodexRoles.omoInvalid": "{role} rol dosyasında artık {model} ile çalışıyor. omo.jsonc, codex.agents nesnesi içeren bir JSON nesnesi olmadığı için değiştirilmedi.", + "integrations.lazycodexRoles.omoFailed": "{role} rol dosyasında artık {model} ile çalışıyor, ancak omo.jsonc yazılamadı.", + "integrations.lazycodexRoles.retryMirror": "omo.jsonc'yi yeniden dene", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc yorum içerdiği için burada kaydetmek yalnızca rol dosyalarını değiştirir. Eşleşmeleri için omo.jsonc içindeki codex.agents değerini elle düzenleyin.", "integrations.semantics.cline": "Cline CLI providers.json ve models.json dosyalarındaki OpenCodex girdilerini yönetir. Değişiklik veya eşitleme öncesinde Cline’ı kapatın, ardından yeniden başlatın. Geri al iki özgün dosyayı da geri yükler. Varsayılan sağlayıcınız değişmez; Cline içinde OpenCodex’i seçin.", "integrations.semantics.kilo": "Yalnızca Kilo’nun genel yapılandırmasındaki provider.opencodex öğesini yönetir — ~/.config/kilo altındaki ilk bulunan kilo.jsonc, kilo.json, opencode.jsonc, opencode.json veya config.json (XDG_CONFIG_HOME bu dizini taşır; hiçbiri yoksa kilo.jsonc oluşturulur). Diğer anahtarlar değişmez. Uygula dosyanın tamamını yeniden yazar; yorumlar ve sondaki virgüller korunmaz. Kilo’da opencodex/ seçin.", "integrations.semantics.droid": "Factory Droid settings.json dosyasına OpenCodex özel modellerini ekler. Devre dışı bırakma yalnızca yönetilen girdileri kaldırır; Geri al kaydedilen dosyayı geri yükler.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 1b34d3866d7..76946aba691 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2224,6 +2224,24 @@ export const vi: Record = { "integrations.semantics.aside": "Chỉ quản lý providers.opencodex trong ~/.aside/u//models.json của profile này. Các provider khác của bạn không thay đổi. Hãy thoát hoàn toàn và mở lại Aside sau khi áp dụng.", "integrations.semantics.raycast": "Thêm một provider OpenCodex vào providers.yaml của Raycast để mọi model được định tuyến xuất hiện trong bộ chọn model AI của Raycast. Yêu cầu Raycast Pro.", "integrations.semantics.omo": "Chỉ quản lý providers.opencodex trong models.json của omo — ~/.omo/agent, trừ khi OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR hoặc PI_CODING_AGENT_DIR chuyển hướng. Các nhà cung cấp khác của bạn không thay đổi. Áp dụng cho phiên mới.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Mô hình cho vai trò tác tử Codex", + "integrations.lazycodexRoles.hint": "Chọn mô hình Codex dùng cho từng vai trò tác tử. Khi lưu, chỉ dòng model trong tệp của vai trò đó ở $CODEX_HOME/agents thay đổi; lựa chọn chỉ được ghi sang omo.jsonc cho LazyCodex khi tệp đó có thể được ghi lại an toàn.", + "integrations.lazycodexRoles.role": "Vai trò", + "integrations.lazycodexRoles.current": "Mô hình hiện tại", + "integrations.lazycodexRoles.model": "Mô hình mới", + "integrations.lazycodexRoles.modelFor": "Mô hình cho {role}", + "integrations.lazycodexRoles.none": "Chưa ghim mô hình", + "integrations.lazycodexRoles.choose": "Chọn mô hình", + "integrations.lazycodexRoles.empty": "Không tìm thấy vai trò tác tử Codex nào.", + "integrations.lazycodexRoles.loadFailed": "Không tải được các vai trò tác tử Codex.", + "integrations.lazycodexRoles.saveFailed": "Không lưu được mô hình cho {role}.", + "integrations.lazycodexRoles.saved": "{role} giờ chạy trên {model}.", + "integrations.lazycodexRoles.omoAbsent": "{role} giờ chạy trên {model}. Không tìm thấy omo.jsonc nên chỉ tệp vai trò được thay đổi.", + "integrations.lazycodexRoles.omoComments": "Trong tệp vai trò, {role} giờ chạy trên {model}. omo.jsonc được giữ nguyên vì lưu sẽ xóa các chú thích của nó; hãy tự đặt codex.agents.{role}.model ở đó.", + "integrations.lazycodexRoles.omoInvalid": "Trong tệp vai trò, {role} giờ chạy trên {model}. omo.jsonc được giữ nguyên vì nó không phải đối tượng JSON có đối tượng codex.agents.", + "integrations.lazycodexRoles.omoFailed": "Trong tệp vai trò, {role} giờ chạy trên {model}, nhưng không ghi được omo.jsonc.", + "integrations.lazycodexRoles.retryMirror": "Thử lại omo.jsonc", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc có chú thích nên lưu ở đây chỉ thay đổi các tệp vai trò. Hãy tự sửa codex.agents trong omo.jsonc để hai nơi khớp nhau.", "integrations.semantics.cline": "Quản lý OpenCodex trong providers.json và models.json của Cline CLI. Hãy dừng Cline trước khi thay đổi hoặc đồng bộ các tệp này, rồi khởi động lại. Hoàn tác sẽ khôi phục cả hai bản gốc. Nhà cung cấp mặc định của bạn không thay đổi; hãy chọn OpenCodex trong Cline.", "integrations.semantics.kilo": "Chỉ quản lý provider.opencodex trong cấu hình toàn cục của Kilo — tệp tồn tại đầu tiên trong kilo.jsonc, kilo.json, opencode.jsonc, opencode.json hoặc config.json dưới ~/.config/kilo (XDG_CONFIG_HOME chuyển thư mục này; nếu không tệp nào tồn tại, kilo.jsonc sẽ được tạo). Các khóa khác giữ nguyên. Áp dụng ghi lại toàn bộ tệp nên chú thích và dấu phẩy cuối không được giữ. Chọn opencodex/ trong Kilo.", "integrations.semantics.droid": "Thêm các mô hình OpenCodex vào settings.json của Factory Droid. Tắt chỉ xóa các mục được quản lý; Hoàn tác khôi phục tệp đã lưu.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 9d86169c28d..2816ff3b5c8 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3001,6 +3001,24 @@ export const zhTW: Record = { "integrations.semantics.aside": "僅管理此設定檔的 ~/.aside/u//models.json 中的 providers.opencodex。其他供應商維持不變。套用後請完全結束並重新開啟 Aside。", "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中新增一個 OpenCodex 供應商項目,讓所有已路由的模型出現在 Raycast AI 模型選擇器中。需要 Raycast Pro。", "integrations.semantics.omo": "僅管理 omo 的 models.json 中的 providers.opencodex,路徑為 ~/.omo/agent,若設定了 OMO_CODING_AGENT_DIR、SENPI_CODING_AGENT_DIR 或 PI_CODING_AGENT_DIR 則以其為準。你的其他供應商維持不變。對新工作階段生效。", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Codex 代理角色模型", + "integrations.lazycodexRoles.hint": "為每個代理角色選擇 Codex 使用的模型。儲存時只會修改 $CODEX_HOME/agents 中該角色檔案的 model 行;只有在 omo.jsonc 可以安全改寫時,才會把選擇同步到供 LazyCodex 讀取的 omo.jsonc。", + "integrations.lazycodexRoles.role": "角色", + "integrations.lazycodexRoles.current": "目前模型", + "integrations.lazycodexRoles.model": "新模型", + "integrations.lazycodexRoles.modelFor": "{role} 的模型", + "integrations.lazycodexRoles.none": "未固定模型", + "integrations.lazycodexRoles.choose": "選擇模型", + "integrations.lazycodexRoles.empty": "找不到 Codex 代理角色。", + "integrations.lazycodexRoles.loadFailed": "無法載入 Codex 代理角色。", + "integrations.lazycodexRoles.saveFailed": "無法儲存 {role} 的模型。", + "integrations.lazycodexRoles.saved": "{role} 現在使用 {model}。", + "integrations.lazycodexRoles.omoAbsent": "{role} 現在使用 {model}。找不到 omo.jsonc,因此只修改了角色檔案。", + "integrations.lazycodexRoles.omoComments": "角色檔案中 {role} 現在使用 {model}。儲存會刪除 omo.jsonc 的註解,因此未修改它;請手動設定其中的 codex.agents.{role}.model。", + "integrations.lazycodexRoles.omoInvalid": "角色檔案中 {role} 現在使用 {model}。omo.jsonc 不是包含 codex.agents 物件的 JSON 物件,因此未修改。", + "integrations.lazycodexRoles.omoFailed": "角色檔案中 {role} 現在使用 {model},但無法寫入 omo.jsonc。", + "integrations.lazycodexRoles.retryMirror": "重試 omo.jsonc", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 含有註解,因此在這裡儲存只會修改角色檔案。請手動調整 omo.jsonc 中的 codex.agents 以保持一致。", "integrations.semantics.cline": "管理 Cline CLI 的 providers.json 和 models.json 中的 OpenCodex 項目。修改或同步前請結束 Cline,完成後重新啟動。復原會還原兩個檔案的原始內容。預設供應商保持不變,請在 Cline 中選擇 OpenCodex。", "integrations.semantics.kilo": "僅管理 Kilo 全域設定——~/.config/kilo 下最先存在的 kilo.jsonc、kilo.json、opencode.jsonc、opencode.json 或 config.json(XDG_CONFIG_HOME 會移動該目錄;若都不存在則建立 kilo.jsonc)——中的 provider.opencodex。其他鍵保持不變。套用會重寫整個檔案,因此不會保留註解與尾隨逗號。請在 Kilo 中選擇 opencodex/<模型>。", "integrations.semantics.droid": "將 OpenCodex 自訂模型加入 Factory Droid 的 settings.json。停用只移除受管理項目;復原會還原已儲存的檔案。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index f9d094e834b..f6ef57c06c0 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1683,6 +1683,24 @@ export const zh: Record = { "integrations.semantics.aside": "仅管理此配置文件的 ~/.aside/u//models.json 中的 providers.opencodex。其他提供商保持不变。应用后请完全退出并重新打开 Aside。", "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中添加一个 OpenCodex 提供商条目,让所有已路由的模型出现在 Raycast AI 模型选择器中。需要 Raycast Pro。", "integrations.semantics.omo": "仅管理 omo 的 models.json 中的 providers.opencodex,路径为 ~/.omo/agent,若设置了 OMO_CODING_AGENT_DIR、SENPI_CODING_AGENT_DIR 或 PI_CODING_AGENT_DIR 则以其为准。你的其他提供商保持不变。对新会话生效。", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Codex 智能体角色模型", + "integrations.lazycodexRoles.hint": "为每个智能体角色选择 Codex 使用的模型。保存时只修改 $CODEX_HOME/agents 中该角色文件的 model 行;只有在 omo.jsonc 可以安全改写时,才会把选择同步到供 LazyCodex 读取的 omo.jsonc。", + "integrations.lazycodexRoles.role": "角色", + "integrations.lazycodexRoles.current": "当前模型", + "integrations.lazycodexRoles.model": "新模型", + "integrations.lazycodexRoles.modelFor": "{role} 的模型", + "integrations.lazycodexRoles.none": "未固定模型", + "integrations.lazycodexRoles.choose": "选择模型", + "integrations.lazycodexRoles.empty": "未找到 Codex 智能体角色。", + "integrations.lazycodexRoles.loadFailed": "无法加载 Codex 智能体角色。", + "integrations.lazycodexRoles.saveFailed": "无法保存 {role} 的模型。", + "integrations.lazycodexRoles.saved": "{role} 现在使用 {model}。", + "integrations.lazycodexRoles.omoAbsent": "{role} 现在使用 {model}。未找到 omo.jsonc,因此只修改了角色文件。", + "integrations.lazycodexRoles.omoComments": "角色文件中 {role} 现在使用 {model}。保存会删除 omo.jsonc 的注释,因此未修改它;请手动设置其中的 codex.agents.{role}.model。", + "integrations.lazycodexRoles.omoInvalid": "角色文件中 {role} 现在使用 {model}。omo.jsonc 不是包含 codex.agents 对象的 JSON 对象,因此未修改。", + "integrations.lazycodexRoles.omoFailed": "角色文件中 {role} 现在使用 {model},但无法写入 omo.jsonc。", + "integrations.lazycodexRoles.retryMirror": "重试 omo.jsonc", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 含有注释,因此在这里保存只会修改角色文件。请手动调整 omo.jsonc 中的 codex.agents 以保持一致。", "integrations.semantics.cline": "管理 Cline CLI 的 providers.json 和 models.json 中的 OpenCodex 项目。修改或同步前请退出 Cline,完成后重新启动。撤销会恢复两个文件的原始内容。默认提供商保持不变,请在 Cline 中选择 OpenCodex。", "integrations.semantics.kilo": "仅管理 Kilo 全局配置——~/.config/kilo 下最先存在的 kilo.jsonc、kilo.json、opencode.jsonc、opencode.json 或 config.json(XDG_CONFIG_HOME 会移动该目录;若都不存在则创建 kilo.jsonc)——中的 provider.opencodex。其他键保持不变。应用会重写整个文件,因此不会保留注释和尾随逗号。请在 Kilo 中选择 opencodex/<模型>。", "integrations.semantics.droid": "将 OpenCodex 自定义模型添加到 Factory Droid 的 settings.json。停用仅移除托管条目;撤销会恢复已保存的文件。", diff --git a/gui/src/main.tsx b/gui/src/main.tsx index a0ee419b435..49db60f99e5 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -15,6 +15,7 @@ import "./styles/sidebar-brand.css"; import "./styles/fast-rows-setting.css"; import "./styles/claude-desktop-mode-picker.css"; import "./styles/claude-first-party-bindings.css"; +import "./styles/lazycodex-role-models.css"; import "./styles/claude-desktop-picker.css"; import "./styles/anthropic-reset-grants.css"; import "./styles/star-onboarding.css"; diff --git a/gui/src/pages/Integrations.tsx b/gui/src/pages/Integrations.tsx index b7d7d86fc23..afa63151f70 100644 --- a/gui/src/pages/Integrations.tsx +++ b/gui/src/pages/Integrations.tsx @@ -9,6 +9,7 @@ import Grok from "./Grok"; import CursorIntegrationPage from "./integrations/CursorIntegrationPage"; import IntegrationsOverview from "./integrations/IntegrationsOverview"; import AsideProfilesPage from "./integrations/AsideProfilesPage"; +import LazyCodexRoleModels from "./integrations/LazyCodexRoleModels"; import FileIntegrationPage, { type FileIntegrationClientId, } from "./integrations/FileIntegrationPage"; @@ -192,6 +193,7 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec > {t("integrations.codex.openService")} + )} {definition.id === "claude" && } diff --git a/gui/src/pages/integrations/LazyCodexRoleModels.tsx b/gui/src/pages/integrations/LazyCodexRoleModels.tsx new file mode 100644 index 00000000000..892a7132009 --- /dev/null +++ b/gui/src/pages/integrations/LazyCodexRoleModels.tsx @@ -0,0 +1,169 @@ +import { useCallback, useState } from "react"; +import { useDataSurface } from "../../data-surface"; +import { readJsonOrThrow } from "../../fetch-json"; +import { useT, type TKey } from "../../i18n/shared"; +import { Notice, Select, type SelectOption } from "../../ui"; + +interface RoleRow { + role: string; + model: string | null; + omoJsoncModel: string | null; +} + +type OmoFileState = "absent" | "comments" | "invalid" | "present"; +type OmoWriteStatus = "written" | "unchanged" | "absent" | "skipped_comments" | "invalid" | "write_failed"; + +interface RoleModels { + detected: boolean; + roles: RoleRow[]; + omoJsonc: { state: OmoFileState } | null; + available: string[]; +} + +const OMO_WRITE_NOTICE: Partial> = { + absent: "integrations.lazycodexRoles.omoAbsent", + skipped_comments: "integrations.lazycodexRoles.omoComments", + invalid: "integrations.lazycodexRoles.omoInvalid", + write_failed: "integrations.lazycodexRoles.omoFailed", +}; + +export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: string; active: boolean }) { + const t = useT(); + const [drafts, setDrafts] = useState>({}); + const [mirrorRetries, setMirrorRetries] = useState>({}); + const [pending, setPending] = useState(null); + const [result, setResult] = useState<{ tone: "ok" | "warn" | "err"; text: string } | null>(null); + + const load = useCallback(async (signal: AbortSignal): Promise => { + const roles = await readJsonOrThrow<{ lazycodex?: { detected?: boolean }; roles?: RoleRow[]; omoJsonc?: { state: OmoFileState } | null }>( + await fetch(`${apiBase}/api/codex-agent-roles`, { signal }), + t("integrations.lazycodexRoles.loadFailed"), + ); + if (roles?.lazycodex?.detected !== true) return { detected: false, roles: [], omoJsonc: null, available: [] }; + const models = await readJsonOrThrow<{ available?: string[] }>( + await fetch(`${apiBase}/api/subagent-models`, { signal }), + t("integrations.lazycodexRoles.loadFailed"), + ); + return { detected: true, roles: roles.roles ?? [], omoJsonc: roles.omoJsonc ?? null, available: models?.available ?? [] }; + }, [apiBase, t]); + + const resource = useDataSurface(`lazycodex-role-models:${apiBase}`, [apiBase], load, { + isEmpty: data => data.roles.length === 0, + enabled: active, + }); + const data = resource.state.data; + + const save = async (role: string, model: string) => { + if (pending !== null) return; + setPending(role); + setResult(null); + try { + const response = await fetch(`${apiBase}/api/codex-agent-roles/${encodeURIComponent(role)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + }); + const payload = await readJsonOrThrow<{ omoJsonc?: { status?: OmoWriteStatus } }>( + response, + t("integrations.lazycodexRoles.saveFailed", { role }), + ); + const omoStatus = payload?.omoJsonc?.status; + const notice = omoStatus ? OMO_WRITE_NOTICE[omoStatus] : undefined; + setResult(notice + ? { tone: "warn", text: t(notice, { role, model }) } + : { tone: "ok", text: t("integrations.lazycodexRoles.saved", { role, model }) }); + setDrafts(current => Object.fromEntries(Object.entries(current).filter(([key]) => key !== role))); + setMirrorRetries(current => { + const { [role]: _previous, ...rest } = current; + return omoStatus === "write_failed" ? { ...rest, [role]: model } : rest; + }); + await resource.refresh(); + } catch { + setResult({ tone: "err", text: t("integrations.lazycodexRoles.saveFailed", { role }) }); + } finally { + setPending(null); + } + }; + + const optionsFor = (row: RoleRow, draft: string): SelectOption[] => { + const values = [...new Set([...(row.model ? [row.model] : []), ...(data?.available ?? [])])]; + return [ + ...(draft === "" ? [{ value: "", label: t("integrations.lazycodexRoles.choose") }] : []), + ...values.map(value => ({ value, label: value })), + ]; + }; + + if (resource.state.kind === "failed-cold") { + return ( + + {t("integrations.lazycodexRoles.loadFailed")}{" "} + + + ); + } + if (!data?.detected) return null; + + return ( +
+

{t("integrations.lazycodexRoles.title")}

+

{t("integrations.lazycodexRoles.hint")}

+ {data.omoJsonc?.state === "comments" && {t("integrations.lazycodexRoles.omoCommentsState")}} + {result && {result.text}} + {data.roles.length === 0 ? ( +

{t("integrations.lazycodexRoles.empty")}

+ ) : ( +
+ + + + + + + + + + {data.roles.map(row => { + const draft = drafts[row.role] ?? row.model ?? ""; + const changed = draft !== "" && draft !== row.model; + const retryMirror = !changed && draft !== "" && mirrorRetries[row.role] === draft; + return ( + + + + + + ); + })} + +
{t("integrations.lazycodexRoles.role")}{t("integrations.lazycodexRoles.current")}{t("integrations.lazycodexRoles.model")}
{row.role} + {row.model + ? {row.model} + : {t("integrations.lazycodexRoles.none")}} + +
+
+
+ )} +
+ ); +} diff --git a/gui/src/styles/lazycodex-role-models.css b/gui/src/styles/lazycodex-role-models.css new file mode 100644 index 00000000000..47de491d670 --- /dev/null +++ b/gui/src/styles/lazycodex-role-models.css @@ -0,0 +1,10 @@ +/* omo (Codex / LazyCodex) role models on the Codex integration tab. Existing tokens only. */ +.lazycodex-role-models { margin: 14px 0; } +.lazycodex-role-models .tbl td { vertical-align: middle; } +.lazycodex-role-models-edit { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.lazycodex-role-models-edit > :first-child { flex: 1 1 220px; min-width: 0; } +/* Overrides the shared 460px .tbl floor, which pushed the picker off-screen; the picker shows the pin. */ +@media (max-width: 760px) { + .lazycodex-role-models .tbl { min-width: 0; } + .lazycodex-role-models .tbl :is(th, td):nth-child(2) { display: none; } +} diff --git a/gui/tests/lazycodex-role-models.test.tsx b/gui/tests/lazycodex-role-models.test.tsx new file mode 100644 index 00000000000..ec4b72c574e --- /dev/null +++ b/gui/tests/lazycodex-role-models.test.tsx @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; + +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; +let mountCount = 0; +let apiBase = ""; +let puts: Array<{ url: string; body: unknown }> = []; +let omoWriteStatus = "written"; +let putFailure: { status: number; body: unknown } | null = null; +let rolesFailure: { status: number; body: unknown } | null = null; +let rolesBody: unknown; +let fetched: string[] = []; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#integrations/omo" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mountCount += 1; + apiBase = `http://ocx-lazycodex-roles-${mountCount}.invalid`; + puts = []; + fetched = []; + omoWriteStatus = "written"; + putFailure = null; + rolesFailure = null; + rolesBody = { + lazycodex: { detected: true, pluginEnabled: true, pluginInstalled: true }, + omoJsonc: { state: "present" }, + roles: [ + { role: "explorer", model: "gpt-5.5", omoJsoncModel: null }, + { role: "librarian", model: null, omoJsoncModel: null }, + ], + }; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + fetched.push(url); + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)); + puts.push({ url, body }); + if (putFailure) return json(putFailure.body, putFailure.status); + const role = decodeURIComponent(url.slice(url.lastIndexOf("/") + 1)); + const current = rolesBody as { roles: Array<{ role: string; model: string | null }> }; + rolesBody = { ...current, roles: current.roles.map(row => row.role === role ? { ...row, model: body.model } : row) }; + return json({ ok: true, toml: { status: "written" }, omoJsonc: { status: omoWriteStatus } }); + } + if (url.endsWith("/api/subagent-models")) return json({ available: ["gpt-5.5", "xai/grok-4.5"] }); + if (rolesFailure) return json(rolesFailure.body, rolesFailure.status); + return json(rolesBody); + }) as typeof fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: mockFetch }); + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function settle(): Promise { + await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 30)); }); +} + +async function mount(): Promise { + const [{ createRoot }, { LanguageProvider }, { default: LazyCodexRoleModels }] = await Promise.all([ + import("react-dom/client"), + import("../src/i18n/provider"), + import("../src/pages/integrations/LazyCodexRoleModels"), + ]); + await act(async () => { + root = createRoot(container); + root.render(); + }); + await settle(); +} + +function saveButton(role: string): HTMLButtonElement { + const row = [...container.querySelectorAll("tbody tr")].find(tr => tr.textContent?.includes(role)); + return row!.querySelector("button.btn-primary") as HTMLButtonElement; +} + +async function pick(role: string, model: string): Promise { + const trigger = testWindow.document.querySelector(`[aria-label="Model for ${role}"]`) as unknown as HTMLButtonElement; + await act(async () => { trigger.click(); }); + const option = [...testWindow.document.querySelectorAll('[role="option"]')].find(node => node.textContent === model) as unknown as HTMLElement; + await act(async () => { option.click(); }); +} + +test("lists roles with their pins and saves a picked model for one row", async () => { + await mount(); + expect(container.textContent).toContain("omo (Codex / LazyCodex)"); + expect(container.textContent).toContain("explorer"); + expect(container.textContent).toContain("gpt-5.5"); + expect(container.textContent).toContain("No model pin"); + expect(saveButton("explorer").disabled).toBe(true); + await pick("explorer", "xai/grok-4.5"); + expect(saveButton("explorer").disabled).toBe(false); + await act(async () => { saveButton("explorer").click(); }); + await settle(); + expect(puts).toEqual([{ url: `${apiBase}/api/codex-agent-roles/explorer`, body: { model: "xai/grok-4.5" } }]); + expect(container.textContent).toContain("explorer now runs on xai/grok-4.5."); +}); + +test("renders nothing and loads no model list when LazyCodex is not detected", async () => { + rolesBody = { lazycodex: { detected: false, pluginEnabled: false, pluginInstalled: false }, omoJsonc: null, roles: [] }; + await mount(); + expect(container.innerHTML).toBe(""); + expect(fetched).toEqual([`${apiBase}/api/codex-agent-roles`]); +}); + +test("a failed role load shows the localized error with a retry that recovers the table", async () => { + rolesFailure = { status: 500, body: { error: "EACCES: permission denied, open '/secret/path'" } }; + await mount(); + expect(container.textContent).toContain("Could not load Codex agent roles."); + expect(container.textContent).not.toContain("EACCES"); + expect(container.querySelector("table")).toBeNull(); + const retry = [...container.querySelectorAll("button")].find(button => button.textContent === "Retry") as HTMLButtonElement; + expect(retry).toBeDefined(); + rolesFailure = null; + await act(async () => { retry.click(); }); + await settle(); + expect(container.textContent).not.toContain("Could not load Codex agent roles."); + expect(container.textContent).toContain("explorer"); +}); + +test("says when omo.jsonc was skipped because of its comments", async () => { + rolesBody = { ...(rolesBody as object), omoJsonc: { state: "comments" } }; + omoWriteStatus = "skipped_comments"; + await mount(); + expect(container.textContent).toContain("omo.jsonc contains comments"); + await pick("librarian", "gpt-5.5"); + await act(async () => { saveButton("librarian").click(); }); + await settle(); + expect(container.textContent).toContain("saving would remove its comments"); +}); + +test("a failed omo.jsonc mirror can be retried with the same model", async () => { + omoWriteStatus = "write_failed"; + await mount(); + await pick("explorer", "xai/grok-4.5"); + await act(async () => { saveButton("explorer").click(); }); + await settle(); + expect(container.textContent).toContain("omo.jsonc could not be written"); + expect(container.textContent).toContain("xai/grok-4.5"); + expect(saveButton("explorer").disabled).toBe(false); + expect(saveButton("explorer").textContent).toBe("Retry omo.jsonc"); + omoWriteStatus = "written"; + await act(async () => { saveButton("explorer").click(); }); + await settle(); + expect(puts.map(entry => entry.body)).toEqual([{ model: "xai/grok-4.5" }, { model: "xai/grok-4.5" }]); + expect(container.textContent).toContain("explorer now runs on xai/grok-4.5."); + expect(saveButton("explorer").disabled).toBe(true); + expect(saveButton("explorer").textContent).toBe("Save"); +}); + +test("a failed save shows the localized message rather than the server text", async () => { + putFailure = { status: 500, body: { error: "EACCES: permission denied, open '/secret/path'", code: "write_failed" } }; + await mount(); + await pick("explorer", "xai/grok-4.5"); + await act(async () => { saveButton("explorer").click(); }); + await settle(); + expect(container.textContent).toContain("Could not save the model for explorer."); + expect(container.textContent).not.toContain("EACCES"); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 527be267500..0e5fcab057c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1664,6 +1664,10 @@ "subagent-fallback-preview-sites.test.ts": "routing", "subagent-model-fallback-api.test.ts": "routing", "subagent-model-fallback.test.ts": "routing", + "codex-agent-role-models.test.ts": "routing", + "omo-role-models.test.ts": "clients", + "lazycodex-detection.test.ts": "clients", + "codex-agent-role-routes.test.ts": "server", "subagent-roster-retention.test.ts": "routing", "sync-client-integrations.test.ts": "clients", "synthetic-tool.test.ts": "images", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 15c52cd6c71..3ad167b6ef0 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -1184,6 +1184,25 @@ JSON mode: `payload`. - A bare invocation reads and never writes. +### `ocx agent roles` + +omo (Codex / LazyCodex): show each Codex agent role's model pin, or set one role's model in its TOML and in omo.jsonc. + +| Method | Route | +|---|---| +| GET | `/api/codex-agent-roles` | +| PUT | `/api/codex-agent-roles/{role}` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the role list or the write result as JSON. | + +JSON mode: `payload`. + +- A bare invocation reads and never writes. +- Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles and set is refused. +- set rewrites only the root model value of $CODEX_HOME/agents/.toml; omo.jsonc is skipped when absent or when it contains comments. + ### `ocx api policy` Read the protocol policy, or change the Messages surface, unrepresentable policy and rollout switches. @@ -1207,6 +1226,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 67 -- of those, state-changing: 37 +- declared capabilities: 68 +- of those, state-changing: 38 - head-resolved invocations: 2 diff --git a/src/cli/agent.ts b/src/cli/agent.ts index db3ce47b746..e651559c133 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -28,6 +28,7 @@ const USAGE = `Usage: ocx agent effort [--main ] [--subagent ] [--json] ocx agent subagents [model,model...] [--json] ocx agent fallback [model,model...] [--poll-ms <5000-600000>] [--json] + ocx agent roles [status|set ] [--json] ocx agent sidecar [--list] [--model ] [--backend web: vision:] [--reasoning ] [--max-descriptions ] [--enabled ] [--json] @@ -236,6 +237,50 @@ async function sidecar(argv: string[], deps: RuntimeApiDeps): Promise { printData(result, wantsJson, lines); } +interface CodexAgentRolesStatus { + lazycodex?: { detected?: boolean }; + omoJsonc?: { state?: string } | null; + roles?: Array<{ role: string; model: string | null; omoJsoncModel: string | null }>; +} + +async function roles(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const action = (args.shift() ?? "status").toLowerCase(); + if (action === "status") { + rejectArgs(args, USAGE); + const result = await runtimeRequest("/api/codex-agent-roles", {}, deps); + if (result.lazycodex?.detected !== true) { + printData(result, wantsJson, ["omo (Codex / LazyCodex) is not installed in this CODEX_HOME; role models are managed only with it."]); + return; + } + const rows = result.roles ?? []; + printData(result, wantsJson, [ + "omo (Codex / LazyCodex): detected", + ...(rows.length === 0 ? ["No Codex agent roles found."] : rows.map(row => `${row.role}: ${row.model ?? "(no model pin)"}`)), + `omo.jsonc: ${result.omoJsonc?.state ?? "unknown"}`, + ]); + return; + } + if (action !== "set") throw new CliUsageError(`unknown roles action ${action}`, USAGE); + const role = args.shift(); + const model = args.shift(); + if (!role || !model) throw new CliUsageError("a role and a model are required", USAGE); + rejectArgs(args, USAGE); + const result = await runtimeRequest<{ toml?: { status?: string }; omoJsonc?: { status?: string } }>( + `/api/codex-agent-roles/${encodeURIComponent(role)}`, + { method: "PUT", body: JSON.stringify({ model }) }, + deps, + ); + const omo = result.omoJsonc?.status; + printData(result, wantsJson, [ + `${role}: ${model} (role TOML ${result.toml?.status ?? "unknown"})`, + omo === "skipped_comments" + ? "omo.jsonc: not written, because it contains comments that a rewrite would lose." + : `omo.jsonc: ${omo ?? "unknown"}`, + ]); +} + export async function handleAgentCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { return runCliAction(async () => { const [sub = "status", ...rest] = argv; @@ -244,6 +289,7 @@ export async function handleAgentCommand(argv: string[], deps: RuntimeApiDeps = else if (sub === "effort") await effort(rest, deps); else if (sub === "subagents" || sub === "roster") await subagents(rest, deps); else if (sub === "fallback") await fallback(rest, deps); + else if (sub === "roles") await roles(rest, deps); else if (sub === "sidecar") await sidecar(rest, deps); // Lives here rather than as a top-level verb because it is an agent-behavior feature flag: // it controls whether default mode may ask the operator a question mid-task. diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 484454a802f..0c4e4a1c853 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -1060,6 +1060,22 @@ export const CAPABILITIES: readonly Capability[] = [ json: "payload", details: ["A bare invocation reads and never writes."], }, + { + command: ["agent", "roles"], + summary: "omo (Codex / LazyCodex): show each Codex agent role's model pin, or set one role's model in its TOML and in omo.jsonc.", + routes: [ + { method: "GET", path: "/api/codex-agent-roles" }, + { method: "PUT", path: "/api/codex-agent-roles/{role}" }, + ], + flags: [{ name: "--json", value: "boolean", summary: "Emit the role list or the write result as JSON." }], + mutates: true, + json: "payload", + details: [ + "A bare invocation reads and never writes.", + "Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles and set is refused.", + "set rewrites only the root model value of $CODEX_HOME/agents/.toml; omo.jsonc is skipped when absent or when it contains comments.", + ], + }, { command: ["api", "protocols"], summary: "Read the protocol contract version, API surfaces, protocol settings and feature vocabulary.", diff --git a/src/clients/lazycodex.ts b/src/clients/lazycodex.ts new file mode 100644 index 00000000000..0e6662a19f0 --- /dev/null +++ b/src/clients/lazycodex.ts @@ -0,0 +1,48 @@ +/** + * Detection of Codex-based omo (LazyCodex), the variant that reads Codex role files and + * `codex.agents..model` in `~/.omo/omo.jsonc`. + * + * "omo" names three products. Pi-based omo (senpi) owns `~/.omo/agent` and is the omo file + * integration in `src/integrations/registry.ts`; OpenCode-based omo (oh-my-opencode) keeps its + * own config under OpenCode. Neither proves LazyCodex is installed, and `~/.omo` exists for all + * of them, so this reads only LazyCodex's footprint inside CODEX_HOME: the Codex plugin + * `omo@sisyphuslabs` enabled in config.toml (the key LazyCodex's own doctor checks) and an + * installed copy of it carrying the `lazycodex-install.json` receipt its installer writes. + */ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export const LAZYCODEX_PLUGIN_ID = "omo@sisyphuslabs"; + +export interface LazyCodexDetection { + readonly detected: boolean; + readonly pluginEnabled: boolean; + readonly pluginInstalled: boolean; +} + +function pluginEnabled(codexHome: string): boolean { + let parsed: unknown; + try { + parsed = Bun.TOML.parse(readFileSync(join(codexHome, "config.toml"), "utf8")); + } catch { + return false; + } + const plugins = (parsed as { plugins?: Record } | null)?.plugins; + return plugins?.[LAZYCODEX_PLUGIN_ID]?.enabled === true; +} + +function pluginInstalled(codexHome: string): boolean { + const root = join(codexHome, "plugins", "cache", "sisyphuslabs", "omo"); + try { + return readdirSync(root, { withFileTypes: true }) + .some(entry => entry.isDirectory() && existsSync(join(root, entry.name, "lazycodex-install.json"))); + } catch { + return false; + } +} + +export function detectLazyCodex(codexHome: string): LazyCodexDetection { + const enabled = pluginEnabled(codexHome); + const installed = pluginInstalled(codexHome); + return { detected: enabled && installed, pluginEnabled: enabled, pluginInstalled: installed }; +} diff --git a/src/clients/omo-role-models.ts b/src/clients/omo-role-models.ts new file mode 100644 index 00000000000..1a94853e14d --- /dev/null +++ b/src/clients/omo-role-models.ts @@ -0,0 +1,111 @@ +/** + * omo (Codex / LazyCodex)'s per-role model setting, `codex.agents..model` in `~/.omo/omo.jsonc`. + * + * LazyCodex 5.1.1 and later reads it, and callers reach this only after `detectLazyCodex` + * says LazyCodex is installed. The file is omo's, so this writes only on an explicit + * dashboard or CLI pick, never creates the file, and refuses a file with comments: the write + * re-serializes JSON, and a comment the user wrote would be lost without a word. + */ +import { lstatSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write"; +import { assertIntegrationWriteOwnership } from "../integrations/config-io"; +import { hasJsoncComments, parseJsonc } from "../lib/jsonc"; + +export type OmoRoleModelsState = + | { readonly state: "absent" } + | { readonly state: "comments" } + | { readonly state: "invalid" } + | { readonly state: "unreadable" } + | { readonly state: "present"; readonly models: Readonly> }; + +export type OmoRoleModelWriteStatus = "written" | "unchanged" | "absent" | "skipped_comments" | "invalid"; + +/** omo resolves its home from HOME, then USERPROFILE, then the OS home; this follows it. */ +export function omoJsoncPath(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string { + const base = env.HOME?.trim() || env.USERPROFILE?.trim() || home; + return join(base, ".omo", "omo.jsonc"); +} + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +type Loaded = + | { kind: "absent" | "comments" | "invalid" } + | { kind: "document"; text: string; doc: JsonObject }; + +function load(path: string): Loaded { + let text: string; + try { + if (!lstatSync(path).isFile()) return { kind: "invalid" }; + text = readFileSync(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" }; + throw error; + } + if (hasJsoncComments(text)) return { kind: "comments" }; + try { + const doc = parseJsonc(text.replace(/^\ufeff/, "")); + return isObject(doc) ? { kind: "document", text, doc } : { kind: "invalid" }; + } catch { + return { kind: "invalid" }; + } +} + +function agentsOf(doc: JsonObject): JsonObject | null | undefined { + const codex = doc.codex; + if (codex === undefined) return undefined; + if (!isObject(codex)) return null; + const agents = codex.agents; + if (agents === undefined) return undefined; + return isObject(agents) ? agents : null; +} + +export function readOmoRoleModels(path: string = omoJsoncPath()): OmoRoleModelsState { + let loaded: Loaded; + try { + loaded = load(path); + } catch { + return { state: "unreadable" }; + } + if (loaded.kind !== "document") return { state: loaded.kind }; + const agents = agentsOf(loaded.doc); + if (agents === null) return { state: "invalid" }; + const models: Record = {}; + for (const [role, entry] of Object.entries(agents ?? {})) { + if (isObject(entry) && typeof entry.model === "string") models[role] = entry.model; + } + return { state: "present", models }; +} + +function serialize(text: string, doc: JsonObject): string { + const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? " "; + let out = JSON.stringify(doc, null, indent); + if (/\r?\n$/.test(text)) out += "\n"; + if (text.includes("\r\n")) out = out.replace(/\n/g, "\r\n"); + return text.startsWith("\ufeff") ? `\ufeff${out}` : out; +} + +export function writeOmoRoleModel(role: string, model: string, path: string = omoJsoncPath()): OmoRoleModelWriteStatus { + const loaded = load(path); + if (loaded.kind !== "document") return loaded.kind === "comments" ? "skipped_comments" : loaded.kind; + const { doc, text } = loaded; + // Only an absent key is created; an explicit null is a value the reader calls invalid. + const codex = doc.codex === undefined ? {} : doc.codex; + if (!isObject(codex)) return "invalid"; + const agents = codex.agents === undefined ? {} : codex.agents; + if (!isObject(agents)) return "invalid"; + const entry = agents[role] === undefined ? {} : agents[role]; + if (!isObject(entry)) return "invalid"; + if (entry.model === model) return "unchanged"; + agents[role] = { ...entry, model }; + codex.agents = agents; + doc.codex = codex; + assertIntegrationWriteOwnership(path); + atomicWriteFileNoFollowUnclaimed(path, serialize(text, doc)); + return "written"; +} diff --git a/src/codex/agent-role-models.ts b/src/codex/agent-role-models.ts new file mode 100644 index 00000000000..cce3152b433 --- /dev/null +++ b/src/codex/agent-role-models.ts @@ -0,0 +1,112 @@ +/** + * The dashboard's write of one Codex agent role's model. + * + * Codex overrides the spawn-time model with the root `model` pin in + * `$CODEX_HOME/agents/.toml`, so that pin is what decides a child's model. This module + * edits exactly that value and nothing else: every other byte of the file, including the + * instructions multiline string that usually mentions `model =` in prose, is left as it was. + * It runs only on an explicit user action; no sync or startup path calls it. + */ +import { lstatSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write"; +import { assertIntegrationWriteOwnership } from "../integrations/config-io"; +import { encodeBasicString, findInvalidCharacter } from "./prompt-layers/encoding"; +import { dominantEol } from "./prompt-layers/toml-edit"; +import { listCodexAgentRoles, locateTomlModelKey } from "./subagent-model-fallback"; + +export type AgentRoleModelErrorCode = "unknown_role" | "invalid_model" | "unsupported_model_value" | "unsafe_target"; + +export class AgentRoleModelError extends Error { + constructor(readonly code: AgentRoleModelErrorCode, message: string) { + super(message); + } +} + +export interface CodexAgentRoleModel { + readonly role: string; + readonly model: string | null; +} + +export function validateAgentRoleModel(model: unknown): string { + if (typeof model !== "string") throw new AgentRoleModelError("invalid_model", "model must be a string"); + const trimmed = model.trim(); + if (trimmed === "") throw new AgentRoleModelError("invalid_model", "model must not be empty"); + if (trimmed.includes("\n") || findInvalidCharacter(trimmed) !== null) { + throw new AgentRoleModelError("invalid_model", "model must not contain control characters"); + } + return trimmed; +} + +/** + * Set the root `model` value. An existing value is replaced inside its quotes and keeps its + * quote style when the new value allows it; a missing key is inserted after the leading + * comment block with the file's own line ending. + */ +export function setTomlRootModel(content: string, model: string): string { + const location = locateTomlModelKey(content); + if (location?.inRootTable) { + if (!location.span) { + throw new AgentRoleModelError("unsupported_model_value", "the existing model value is not a one-line string"); + } + const lines = content.split("\n"); + const line = lines[location.line]!; + const literal = line[location.span.start] === "'" && !model.includes("'") + ? `'${model}'` + : encodeBasicString(model); + lines[location.line] = line.slice(0, location.span.start) + literal + line.slice(location.span.end); + return lines.join("\n"); + } + const eol = dominantEol(content); + const bom = content.startsWith("\ufeff") ? 1 : 0; + const lines = content.slice(bom).split("\n"); + let offset = bom; + for (const line of lines) { + if (!/^\s*#/.test(line)) break; + offset += line.length + 1; + } + const assignment = `model = ${encodeBasicString(model)}`; + if (offset > content.length) return `${content}${eol}${assignment}${eol}`; + return `${content.slice(0, offset)}${assignment}${eol}${content.slice(offset)}`; +} + +function roleFile(role: string, codexHome: string): string { + return join(codexHome, "agents", `${role}.toml`); +} + +function requireKnownRole(role: string, codexHome: string): void { + // Membership in the directory listing is the whole path check: a name with a separator or + // `..` can never equal a listed file stem. + if (!listCodexAgentRoles(codexHome).includes(role)) { + throw new AgentRoleModelError("unknown_role", `no Codex agent role named ${JSON.stringify(role)}`); + } +} + +export function listCodexAgentRoleModels(codexHome: string): CodexAgentRoleModel[] { + return listCodexAgentRoles(codexHome).sort().map(role => { + let model: string | null = null; + try { + const location = locateTomlModelKey(readFileSync(roleFile(role, codexHome), "utf8")); + model = location?.inRootTable ? location.value : null; + } catch { /* an unreadable role reports no pin */ } + return { role, model }; + }); +} + +export function writeCodexAgentRoleModel( + role: string, + model: string, + codexHome: string, +): { status: "written" | "unchanged" } { + requireKnownRole(role, codexHome); + const path = roleFile(role, codexHome); + if (!lstatSync(path).isFile()) { + throw new AgentRoleModelError("unsafe_target", `${role}.toml is not a regular file; edit it where it points`); + } + const before = readFileSync(path, "utf8"); + const after = setTomlRootModel(before, validateAgentRoleModel(model)); + if (after === before) return { status: "unchanged" }; + assertIntegrationWriteOwnership(path); + atomicWriteFileNoFollowUnclaimed(path, after); + return { status: "written" }; +} diff --git a/src/codex/prompt-layers/toml-edit.ts b/src/codex/prompt-layers/toml-edit.ts index 4895e18a09b..5579b391f1a 100644 --- a/src/codex/prompt-layers/toml-edit.ts +++ b/src/codex/prompt-layers/toml-edit.ts @@ -3,7 +3,7 @@ import { encodeBasicString } from "./encoding"; import { TABLE_HEADER, ANY_DEV_INSTRUCTIONS, DEV_INSTRUCTIONS_KEY } from "./toml-read"; /** Line editing, not re-serialization: the user's comments and layout survive. */ -function dominantEol(content: string): "\r\n" | "\n" { +export function dominantEol(content: string): "\r\n" | "\n" { const crlf = (content.match(/\r\n/g) ?? []).length; if (crlf === 0) return "\n"; const bareLf = (content.match(/\n/g) ?? []).length - crlf; diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 86e8be0b143..bd9832812b9 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -41,6 +41,7 @@ import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import { codexAccountNamespaceForModel } from "./account-namespace-match"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_MAIN_DRAIN_SENTINEL_MODELS } from "./catalog/native-models"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; +import { decodeTomlBasicString } from "./prompt-layers/encoding"; import { getUpstreamHostHealth, normalizeUpstreamHostCircuitThreshold, @@ -771,10 +772,14 @@ function findTomlMultilineStringEnd(text: string, from: number, quote: string): const delimiter = quote.repeat(3); let index = text.indexOf(delimiter, from); while (index !== -1) { - if (quote === "'") return index; let backslashes = 0; - for (let j = index - 1; j >= 0 && text[j] === "\\"; j--) backslashes += 1; - if (backslashes % 2 === 0) return index; + if (quote === '"') for (let j = index - 1; j >= 0 && text[j] === "\\"; j--) backslashes += 1; + if (backslashes % 2 === 0) { + // TOML lets one or two quotes sit right before the closing delimiter, so """a""""" ends on its last three. + let extra = 0; + while (extra < 2 && text[index + 3 + extra] === quote) extra += 1; + return index + extra; + } index = text.indexOf(delimiter, index + 1); } return -1; @@ -918,28 +923,35 @@ export function scanCodexAgentRolesWithTomlModelFallback( } } -const TOML_MODEL_KEY = /^\s*(?:model|"model"|'model')\s*=/; +const TOML_ROOT_KEY = /^\s*(?:([A-Za-z0-9_-]+)|("(?:\\.|[^"\\])*")|'([^'\n]*)')\s*=/; +const TOML_TABLE_HEADER = /^\s*\[/; + +function tomlKeyName(match: RegExpMatchArray): string | undefined { + if (match[1] !== undefined) return match[1]; + if (match[3] !== undefined) return match[3]; + return decodeTomlBasicString(match[2]!) ?? undefined; +} + +export interface TomlModelKeyLocation { + /** Index into `content.split("\n")`; a trailing `\r` stays on the line. */ + readonly line: number; + readonly inRootTable: boolean; + /** Column span of the quoted value on `line`; null when it is not a one-line string. */ + readonly span: { readonly start: number; readonly end: number } | null; + readonly value: string | null; +} /** - * TOML-aware read of the root `model` pin, or null when there is none. - * - * Distinct from {@link readCodexAgentModel}, which matches one exact unindented double-quoted - * line. That is fine for resolving a fallback chain opencodex itself wrote, but it is the wrong - * question for a diagnostic: the file being judged was written by somebody else, so `model = 'x'`, - * an indented key, or a trailing comment are all valid TOML that Codex honours and that a - * stricter matcher would report as unpinned. + * Locate the first `model` key outside multiline strings and arrays. * - * It shares the scanner used for `model_fallback` for the reason that matters here: an imported - * role file keeps its instructions in a multiline string, and that string contains the very words - * this scan looks for. A line matcher would read a key out of prose. - * - * Table context is not tracked, matching the `model_fallback` parse. A `model` key under a later - * table header would be read as the root pin; Codex role files are flat in practice, and for a - * warning the conservative direction is to stay quiet. + * The one scan behind both the read ({@link parseTomlModelPin}) and the dashboard's write of a + * role's model, so the line the writer edits is the line the reader reports. */ -function parseTomlModelPin(content: string): string | null { +export function locateTomlModelKey(content: string): TomlModelKeyLocation | null { const lines = content.split(/\r?\n/); const state: TomlScanState = { inMultilineString: null, arrayDepth: 0 }; + let inRootTable = true; + let undecodedRootKeyLine: number | null = null; for (let i = 0; i < lines.length; i += 1) { const line = lines[i]!; if (state.inMultilineString) { @@ -950,19 +962,52 @@ function parseTomlModelPin(content: string): string | null { continue; } if (state.arrayDepth === 0) { - const key = line.match(TOML_MODEL_KEY); - if (key) { + if (TOML_TABLE_HEADER.test(line)) inRootTable = false; + const key = line.match(TOML_ROOT_KEY); + const name = key ? tomlKeyName(key) : null; + if (key && name === undefined && inRootTable) undecodedRootKeyLine ??= i; + if (key && name === "model") { const rest = `${line.slice(key[0].length)}\n${lines.slice(i + 1).join("\n")}`; let at = 0; while (at < rest.length && (rest[at] === " " || rest[at] === "\t")) at += 1; - if (rest[at] !== '"' && rest[at] !== "'") return null; - const value = parseTomlStringAt(rest, at)?.value.trim() ?? ""; - return value === "" ? null : value; + if (rest[at] !== '"' && rest[at] !== "'") return { line: i, inRootTable, span: null, value: null }; + const parsed = parseTomlStringAt(rest, at); + const value = parsed?.value.trim() ?? ""; + const onLine = parsed !== null && parsed.end <= line.length - key[0].length; + return { + line: i, + inRootTable, + span: onLine ? { start: key[0].length + at, end: key[0].length + parsed.end } : null, + value: value === "" ? null : value, + }; } } scanTomlLine(line, state); } - return null; + // A root key that might spell model cannot be ruled out, so a writer must not add a second one. + return undecodedRootKeyLine === null ? null : { line: undecodedRootKeyLine, inRootTable: true, span: null, value: null }; +} + +/** + * TOML-aware read of the root `model` pin, or null when there is none. + * + * Distinct from {@link readCodexAgentModel}, which matches one exact unindented double-quoted + * line. That is fine for resolving a fallback chain opencodex itself wrote, but it is the wrong + * question for a diagnostic: the file being judged was written by somebody else, so `model = 'x'`, + * an indented key, or a trailing comment are all valid TOML that Codex honours and that a + * stricter matcher would report as unpinned. + * + * It shares the scanner used for `model_fallback` for the reason that matters here: an imported + * role file keeps its instructions in a multiline string, and that string contains the very words + * this scan looks for. A line matcher would read a key out of prose. + * + * The scan reports whether the key sits in the root table, and this read ignores that flag on + * purpose: a `model` key under a later table header still counts as a pin, because for a warning + * the conservative direction is to stay quiet. The role listing and the writer in + * `src/codex/agent-role-models.ts` do gate on it, since they show and edit only the root value. + */ +function parseTomlModelPin(content: string): string | null { + return locateTomlModelKey(content)?.value ?? null; } /** Filename prefix opencodex gives the Claude agents it generates. */ @@ -981,7 +1026,9 @@ const OPENCODEX_DERIVED_ROLE_MARKERS = ["generated-by: opencodex", "ocx-route:"] /** * Roles that look opencodex-derived but pin no model, so Codex runs them on the parent model. * - * opencodex does not write Codex role TOMLs. These arrive when the Codex desktop external-agent + * opencodex does not create, repair, or remove Codex role TOMLs. Its one write into them is the + * root `model` value, and only when a user picks a model for that role in the dashboard or with + * `ocx agent roles set` (`src/codex/agent-role-models.ts`). These arrive when the Codex desktop external-agent * import converts `~/.claude/agents/ocx-*.md` into `$CODEX_HOME/agents/ocx-*.toml`, dropping the * `model:` frontmatter because a `claude-ocx-native--` id is not a Codex model and keeping only the * instructions. The surviving `ocx-route` directive cannot make up the difference: it is honoured diff --git a/src/lib/jsonc.ts b/src/lib/jsonc.ts index dda3c392eb7..8d96b5106e3 100644 --- a/src/lib/jsonc.ts +++ b/src/lib/jsonc.ts @@ -108,3 +108,16 @@ export function canonicalizeJsonc(text: string): string { export function parseJsonc(text: string): unknown { return JSON.parse(canonicalizeJsonc(text)); } + +/** + * True when the text carries a `//` or block comment outside a string. A writer that + * re-serializes the document would drop those, so it checks this before writing. + * An unterminated block comment counts as a comment. + */ +export function hasJsoncComments(text: string): boolean { + try { + return stripJsonComments(text) !== text; + } catch { + return true; + } +} diff --git a/src/server/management-api.ts b/src/server/management-api.ts index da36c718634..9cf119b0c8e 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -166,6 +166,12 @@ async function handleWorkflowBudgetRoutesOnDemand(ctx: ManagementContext): Promi return handleWorkflowBudgetRoutes(ctx); } +async function handleCodexAgentRoleRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/codex-agent-roles")) return null; + const { handleCodexAgentRoleRoutes } = await import("./management/codex-agent-role-routes"); + return handleCodexAgentRoleRoutes(ctx); +} + /** * Lazy like the Lab and routing-profile handlers: the protocol planner reaches the router and * the ingress eligibility rules, which no other dashboard request needs. @@ -336,6 +342,7 @@ export async function handleManagementAPI( ?? (await handleQuotaResetRoutesOnDemand(ctx)) ?? (await handleLowQuotaRoutesOnDemand(ctx)) ?? (await handleWorkflowBudgetRoutesOnDemand(ctx)) + ?? (await handleCodexAgentRoleRoutesOnDemand(ctx)) ?? (await handleProtocolRoutesOnDemand(ctx)) ?? (await handleGrokCouponRoutesOnDemand(ctx)) ?? (await handleAnthropicResetGrantRoutesOnDemand(ctx)) diff --git a/src/server/management/codex-agent-role-routes.ts b/src/server/management/codex-agent-role-routes.ts new file mode 100644 index 00000000000..0c46543ffc8 --- /dev/null +++ b/src/server/management/codex-agent-role-routes.ts @@ -0,0 +1,83 @@ +/** + * Per-role model for omo (Codex / LazyCodex): `$CODEX_HOME/agents/.toml` plus LazyCodex's + * `codex.agents..model` mirror in omo.jsonc. Both halves exist only when LazyCodex is + * detected; Pi-based and OpenCode-based omo are never read here. + * + * Loaded on demand from `src/server/management-api.ts`, like the quota-reset handler, so a + * dashboard request that never opens the omo tab loads neither writer. + */ +import { jsonResponse } from "../auth-cors"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import type { ManagementContext } from "./context"; + +const ROLE_PATH_PREFIX = "/api/codex-agent-roles/"; + +export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promise { + const { req, url, config } = ctx; + const [{ getCodexHome }, roles, omo, { detectLazyCodex }] = await Promise.all([ + import("../../codex/paths"), + import("../../codex/agent-role-models"), + import("../../clients/omo-role-models"), + import("../../clients/lazycodex"), + ]); + + if (url.pathname === "/api/codex-agent-roles" && req.method === "GET") { + const codexHome = getCodexHome(); + const lazycodex = detectLazyCodex(codexHome); + if (!lazycodex.detected) return jsonResponse({ lazycodex, omoJsonc: null, roles: [] }, 200, req, config); + const omoState = omo.readOmoRoleModels(omo.omoJsoncPath()); + const omoModels = omoState.state === "present" ? omoState.models : {}; + return jsonResponse({ + lazycodex, + omoJsonc: { state: omoState.state }, + roles: roles.listCodexAgentRoleModels(codexHome).map(entry => ({ + ...entry, + omoJsoncModel: omoModels[entry.role] ?? null, + })), + }, 200, req, config); + } + + if (!url.pathname.startsWith(ROLE_PATH_PREFIX) || req.method !== "PUT") return null; + const codexHome = getCodexHome(); + if (!detectLazyCodex(codexHome).detected) { + return jsonResponse({ + error: "omo (Codex / LazyCodex) is not installed in this CODEX_HOME", + code: "lazycodex_not_detected", + }, 409, req, config); + } + let role: string; + try { + role = decodeURIComponent(url.pathname.slice(ROLE_PATH_PREFIX.length)); + } catch { + return jsonResponse({ error: "role must be URL-encoded", code: "invalid_role" }, 400, req, config); + } + let body: { model?: unknown }; + try { + body = await readManagementJsonBody(req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400, req, config); + } + + let model: string; + let toml: { status: "written" | "unchanged" }; + try { + model = roles.validateAgentRoleModel(body?.model); + toml = roles.writeCodexAgentRoleModel(role, model, codexHome); + } catch (error) { + if (error instanceof roles.AgentRoleModelError) { + const status = error.code === "unknown_role" ? 404 : error.code === "invalid_model" ? 400 : 409; + return jsonResponse({ error: error.message, code: error.code }, status, req, config); + } + return jsonResponse({ error: error instanceof Error ? error.message : String(error), code: "write_failed" }, 500, req, config); + } + + // The role TOML is what Codex obeys, so its write stands even when the omo mirror cannot follow. + let omoStatus: ReturnType | "write_failed"; + try { + omoStatus = omo.writeOmoRoleModel(role, model, omo.omoJsoncPath()); + } catch { + omoStatus = "write_failed"; + } + return jsonResponse({ ok: true, role, model, toml, omoJsonc: { status: omoStatus } }, 200, req, config); +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index f41692503fe..cd48d44e31f 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -166,6 +166,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "PUT", path: "/api/subagent-model-fallback", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/subagent-models", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/v2", module: "server/management/agent-settings-routes", mutates: true }, + // server/management/codex-agent-role-routes + { method: "GET", path: "/api/codex-agent-roles", module: "server/management/codex-agent-role-routes", mutates: false }, + { method: "PUT", path: "/api/codex-agent-roles/{role}", module: "server/management/codex-agent-role-routes", mutates: true, mechanism: "prefix-decode" }, // server/management/aside-profile-routes { method: "GET", path: "/api/client-integrations/aside", module: "server/management/aside-profile-routes", mutates: false, mechanism: "path-constant", exempt: { reason: "compatibility-alias", why: "Legacy Aside status alias; the current CLI reads the same aggregate through GET /api/client-integrations/aside/profiles." } }, { method: "PUT", path: "/api/client-integrations/aside", module: "server/management/aside-profile-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "compatibility-alias", why: "Legacy Aside toggle alias; the current CLI uses PUT /api/client-integrations/aside/profiles so older servers cannot mistake a bulk request for a current-account toggle." } }, diff --git a/src/server/management/sibling-guard.ts b/src/server/management/sibling-guard.ts index 7c2f46473c8..a03bb52e740 100644 --- a/src/server/management/sibling-guard.ts +++ b/src/server/management/sibling-guard.ts @@ -34,6 +34,7 @@ export const SIBLING_REFUSED_MANAGEMENT_PATHS: readonly { readonly path: string; { path: "/api/grok/apply", children: false }, { path: "/api/grok/selection", children: false }, { path: "/api/codex-prompt", children: true }, + { path: "/api/codex-agent-roles", children: true }, { path: CODEX_RESTART_PATH, children: false }, { path: "/api/link/join", children: false }, { path: "/api/native-main-profiles", children: true }, diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 117a1fc8b67..89212326e84 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -422,6 +422,41 @@ sibling policy. Profile journal views retain source-store provenance for older l The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). +## omo variants + +"omo" names three products, and each surface here serves exactly one of them: + +| Variant | Its own evidence | opencodex surface | +| --- | --- | --- | +| Pi-based omo (senpi engine) | `~/.omo/agent`, or `OMO_CODING_AGENT_DIR` / `SENPI_CODING_AGENT_DIR` / `PI_CODING_AGENT_DIR` (`omoAgentDir()`) | the `omo` file integration and tab, `providers.opencodex` in `models.json` | +| Codex-based omo (LazyCodex) | `[plugins."omo@sisyphuslabs"] enabled = true` in `$CODEX_HOME/config.toml` plus `lazycodex-install.json` in an installed copy under `$CODEX_HOME/plugins/cache/sisyphuslabs/omo//` (`detectLazyCodex()` in `src/clients/lazycodex.ts`) | role model pins and the omo.jsonc mirror below, on the Codex tab | +| OpenCode-based omo (oh-my-opencode) | its config under OpenCode | none; nothing here reads or writes it | + +`~/.omo` alone identifies none of them: Pi-based omo and LazyCodex both use it. + +### omo (Codex / LazyCodex) role models + +Separate from the `models.json` provider integration above, and only when `detectLazyCodex()` +reports LazyCodex installed, `src/clients/omo-role-models.ts` +mirrors a dashboard or `ocx agent roles set` pick into `codex.agents..model` of +`~/.omo/omo.jsonc`, which LazyCodex 5.1.1 and later reads. The home is resolved the way omo +resolves it: `HOME`, then `USERPROFILE`, then the OS home. This write has no ownership record, +snapshot, or journal. It changes one value the user just chose and leaves every other key as it +was, re-serialized with the file's indentation, line endings, and BOM. + +It never creates the file and never writes one it would damage: a missing file reports +`absent`, a document that is not an object or whose `codex`, `codex.agents`, or role entry is +not an object reports `invalid`, and a file containing any `//` or block comment reports +`skipped_comments`, because re-serializing JSONC would drop those comments. The management +response carries that status and the dashboard shows it; the role TOML write described in +[subagents](../subagents.md#per-role-model-pins) is not rolled back by a skipped mirror. +An explicit `null` in any of those three places counts as not an object. A file that exists but +cannot be read lists as `unreadable`, so the role table still loads, and a save reports +`write_failed` for the mirror. +Without LazyCodex, `GET /api/codex-agent-roles` answers `lazycodex.detected: false` with no roles +and without opening omo.jsonc, and `PUT` answers 409 `lazycodex_not_detected` before touching a +role file. + ## Kilo global JSONC Kilo owns only `provider.opencodex` in the first existing global file among `kilo.jsonc`, diff --git a/structure/subagents.md b/structure/subagents.md index fb66cfc95f6..9525b95fab4 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -313,6 +313,33 @@ availability on a bounded interval (default 60 s, `src/codex/subagent-model-fall the requested model id only; effort remains owned by the caps described under [Ultra reasoning level](catalog.md#ultra-reasoning-level). +### Per-role model pins + +Codex overrides a child's spawn-time model with the root `model` key of +`$CODEX_HOME/agents/.toml`, so that pin decides which model a role runs on. +`src/codex/agent-role-models.ts` is opencodex's only writer into those files, and it writes only +that one key, only when a user picks a role's model in the dashboard's omo (Codex / LazyCodex) +section on the Codex tab (`PUT /api/codex-agent-roles/{role}`) or with `ocx agent roles set`, +and only while LazyCodex is detected. No sync, startup, or +catalog path calls it, and opencodex never creates, repairs, or removes a role file. + +- The key is located by the same TOML-aware scan the pin reader uses + (`locateTomlModelKey` in `src/codex/subagent-model-fallback.ts`), so a `model =` line inside + the instructions multiline string is never read or edited. A key under a table header is not + the root pin. +- An existing one-line string value is replaced inside its span; every other byte, including + quote style when the new value allows it, trailing comments, line endings, and a leading BOM, + is kept. A missing key is inserted after the leading comment block. A non-string value is + refused rather than duplicated. +- The role name must equal a listed `*.toml` stem, which is also the path-traversal check. The + target must be a regular file owned by the running user; the replacement is atomic and does + not follow a symbolic link. +- The same pick is mirrored into LazyCodex's `codex.agents..model`; that half belongs to + [client integrations](clients/integrations.md#omo-codex-lazycodex-role-models). The role file is written first + and stands even when the mirror is skipped. + +Sibling instances refuse the write, because it reaches the shared `CODEX_HOME`. + `injectionModel` and `injectionEffort` are shared selections with two independent consumers. `multiAgentGuidanceEnabled` controls only OpenCodex-authored delegation guidance. `syncCodexSubagentDefaults` is a separate, default-off opt-in that applies the selected values to diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 460fb292c96..b252eea4430 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -526,6 +526,7 @@ describe("headless GUI parity CLI", () => { ["/api/combos", "ocx combo"], ["/api/client-config", "ocx export"], ["/api/client-integrations", "ocx integration client"], + ["/api/codex-agent-roles", "ocx agent roles"], // #2463: both read and write reach the CLI. `ocx alias list` reads /api/aliases, // `ocx alias defaults` writes /api/default-aliases, and the per-provider writes sit // under /api/providers/:name/alias, already covered by the /api/providers prefix. @@ -945,6 +946,21 @@ describe("headless GUI parity CLI", () => { ]); }); + test("agent roles reads and sets through the role-model routes, --json in any position", async () => { + const runtime = fakeRuntime(req => req.method === "GET" ? { lazycodex: { detected: true }, omoJsonc: { state: "absent" }, roles: [] } : undefined); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleAgentCommand(["roles", "--json"], runtime.deps)).toBe(0); + expect(await handleAgentCommand(["roles", "set", "ocx explorer", "xai/grok-4.5", "--json"], runtime.deps)).toBe(0); + } finally { + logSpy.mockRestore(); + } + expect(runtime.requests).toEqual([ + { path: "/api/codex-agent-roles", method: "GET", body: null }, + { path: "/api/codex-agent-roles/ocx%20explorer", method: "PUT", body: { model: "xai/grok-4.5" } }, + ]); + }); + test("API key create returns the one-time key through the access command", async () => { const runtime = fakeRuntime((req) => new URL(req.url).pathname === "/api/keys" ? { id: "key-1", name: "deploy", key: "ocx_secret" } diff --git a/tests/clients/lazycodex-detection.test.ts b/tests/clients/lazycodex-detection.test.ts new file mode 100644 index 00000000000..e51c7ca31dd --- /dev/null +++ b/tests/clients/lazycodex-detection.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { detectLazyCodex } from "../../src/clients/lazycodex"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const ENABLED = '[plugins."omo@sisyphuslabs"]\nenabled = true\n'; +let root = ""; +let codexHome = ""; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-lazycodex-detect-")); + codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); +}); + +afterEach(() => removeTreeWithRetry(root)); + +function install(version = "5.1.1"): void { + const dir = join(codexHome, "plugins", "cache", "sisyphuslabs", "omo", version); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "lazycodex-install.json"), '{ "version": "5.1.1" }'); +} + +describe("detectLazyCodex", () => { + test("needs the omo@sisyphuslabs Codex plugin both enabled and installed", () => { + writeFileSync(join(codexHome, "config.toml"), ENABLED); + install(); + expect(detectLazyCodex(codexHome)).toEqual({ detected: true, pluginEnabled: true, pluginInstalled: true }); + }); + + test("an enabled entry without an installed copy, or a copy that is disabled, is not LazyCodex", () => { + writeFileSync(join(codexHome, "config.toml"), ENABLED); + expect(detectLazyCodex(codexHome)).toEqual({ detected: false, pluginEnabled: true, pluginInstalled: false }); + install(); + writeFileSync(join(codexHome, "config.toml"), '[plugins."omo@sisyphuslabs"]\nenabled = false\n'); + expect(detectLazyCodex(codexHome)).toEqual({ detected: false, pluginEnabled: false, pluginInstalled: true }); + }); + + test("a plugin directory without the LazyCodex install receipt does not count", () => { + writeFileSync(join(codexHome, "config.toml"), ENABLED); + mkdirSync(join(codexHome, "plugins", "cache", "sisyphuslabs", "omo", "5.1.1"), { recursive: true }); + expect(detectLazyCodex(codexHome).detected).toBe(false); + }); + + test("Pi-based and OpenCode-based omo footprints alone are not LazyCodex", () => { + const home = join(root, "home"); + mkdirSync(join(home, ".omo", "agent"), { recursive: true }); + writeFileSync(join(home, ".omo", "agent", "models.json"), "{}"); + writeFileSync(join(home, ".omo", "omo.jsonc"), '{ "codex": { "agents": {} } }'); + mkdirSync(join(home, ".config", "opencode"), { recursive: true }); + writeFileSync(join(home, ".config", "opencode", "oh-my-opencode.jsonc"), "{}"); + expect(detectLazyCodex(codexHome).detected).toBe(false); + expect(detectLazyCodex(join(root, "missing")).detected).toBe(false); + }); + + test("an unparsable config.toml reads as not enabled", () => { + writeFileSync(join(codexHome, "config.toml"), "[plugins\n"); + install(); + expect(detectLazyCodex(codexHome)).toEqual({ detected: false, pluginEnabled: false, pluginInstalled: true }); + }); +}); diff --git a/tests/clients/omo-role-models.test.ts b/tests/clients/omo-role-models.test.ts new file mode 100644 index 00000000000..2115ddaedc8 --- /dev/null +++ b/tests/clients/omo-role-models.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { omoJsoncPath, readOmoRoleModels, writeOmoRoleModel } from "../../src/clients/omo-role-models"; +import { hasJsoncComments } from "../../src/lib/jsonc"; + +let dir: string | null = null; +afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = null; +}); + +function file(text?: string): string { + dir = mkdtempSync(join(tmpdir(), "ocx-omo-roles-")); + const path = join(dir, "omo.jsonc"); + if (text !== undefined) writeFileSync(path, text); + return path; +} + +describe("omo role models", () => { + test("writes codex.agents..model and keeps sibling keys and indentation", () => { + const path = file('{\n "agents": { "sisyphus": { "model": "a" } },\n "codex": { "agents": { "explorer": { "reasoningEffort": "high" } } }\n}\n'); + expect(writeOmoRoleModel("explorer", "gpt-5.6-sol", path)).toBe("written"); + const written = readFileSync(path, "utf8"); + expect(JSON.parse(written)).toEqual({ + agents: { sisyphus: { model: "a" } }, + codex: { agents: { explorer: { reasoningEffort: "high", model: "gpt-5.6-sol" } } }, + }); + expect(written.startsWith('{\n "agents"')).toBe(true); + expect(written.endsWith("}\n")).toBe(true); + expect(readOmoRoleModels(path)).toEqual({ state: "present", models: { explorer: "gpt-5.6-sol" } }); + expect(writeOmoRoleModel("explorer", "gpt-5.6-sol", path)).toBe("unchanged"); + }); + + test("creates the codex block in a file that has none", () => { + const path = file("{}"); + expect(writeOmoRoleModel("librarian", "m", path)).toBe("written"); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ codex: { agents: { librarian: { model: "m" } } } }); + }); + + test("a file with comments is reported and left byte for byte", () => { + const text = '{\n // pick carefully\n "codex": {}\n}\n'; + const path = file(text); + expect(writeOmoRoleModel("explorer", "m", path)).toBe("skipped_comments"); + expect(readOmoRoleModels(path)).toEqual({ state: "comments" }); + expect(readFileSync(path, "utf8")).toBe(text); + }); + + test("a missing file is reported absent and not created", () => { + const path = file(); + expect(writeOmoRoleModel("explorer", "m", path)).toBe("absent"); + expect(readOmoRoleModels(path)).toEqual({ state: "absent" }); + expect(() => readFileSync(path)).toThrow(); + }); + + test("a codex value of the wrong shape is invalid rather than overwritten", () => { + const text = '{ "codex": { "agents": ["explorer"] } }'; + const path = file(text); + expect(writeOmoRoleModel("explorer", "m", path)).toBe("invalid"); + expect(readFileSync(path, "utf8")).toBe(text); + }); + + test("an explicit null codex, agents, or role entry is invalid and left byte for byte", () => { + for (const text of ['{ "codex": null }', '{ "codex": { "agents": null } }', '{ "codex": { "agents": { "explorer": null } } }']) { + const path = file(text); + expect(writeOmoRoleModel("explorer", "m", path)).toBe("invalid"); + expect(readFileSync(path, "utf8")).toBe(text); + rmSync(dir!, { recursive: true, force: true }); + } + }); + + test("an unreadable file reads as unreadable, and the write still reports the failure", () => { + const path = file('{ "codex": {} }'); + const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); + const spy = spyOn(fs, "readFileSync").mockImplementation((() => { throw denied; }) as never); + try { + expect(readOmoRoleModels(path)).toEqual({ state: "unreadable" }); + expect(() => writeOmoRoleModel("explorer", "m", path)).toThrow("EACCES"); + } finally { + spy.mockRestore(); + } + }); + + test("comment detection ignores comment markers inside strings", () => { + expect(hasJsoncComments('{ "url": "https://example.com/*x*/" }')).toBe(false); + expect(hasJsoncComments('{ "a": 1 /* note */ }')).toBe(true); + expect(hasJsoncComments('{ "a": 1 /* open')).toBe(true); + }); + + test("the path follows HOME before USERPROFILE", () => { + expect(omoJsoncPath({ HOME: "/h", USERPROFILE: "/u" }, "/os")).toBe(join("/h", ".omo", "omo.jsonc")); + expect(omoJsoncPath({ USERPROFILE: "/u" }, "/os")).toBe(join("/u", ".omo", "omo.jsonc")); + expect(omoJsoncPath({}, "/os")).toBe(join("/os", ".omo", "omo.jsonc")); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5667fc9d41e..431e3a01b2b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1680,6 +1680,10 @@ "subagent-fallback-preview-sites.test.ts": "routing", "subagent-model-fallback-api.test.ts": "routing", "subagent-model-fallback.test.ts": "routing", + "codex-agent-role-models.test.ts": "routing", + "omo-role-models.test.ts": "clients", + "lazycodex-detection.test.ts": "clients", + "codex-agent-role-routes.test.ts": "server", "subagent-roster-retention.test.ts": "routing", "sync-client-integrations.test.ts": "clients", "synthetic-tool.test.ts": "images", diff --git a/tests/routing/codex-agent-role-models.test.ts b/tests/routing/codex-agent-role-models.test.ts new file mode 100644 index 00000000000..27eaf2f4e16 --- /dev/null +++ b/tests/routing/codex-agent-role-models.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AgentRoleModelError, + listCodexAgentRoleModels, + setTomlRootModel, + writeCodexAgentRoleModel, +} from "../../src/codex/agent-role-models"; + +const IMPORTED_ROLE = [ + "# imported by Codex", + 'name = "explorer"', + 'developer_instructions = """', + "Never trust a line like", + 'model = "x"', + "inside prose.", + '"""', + 'model = "gpt-5.5" # pinned', + 'model_reasoning_effort = "high"', + "", +].join("\n"); + +describe("setTomlRootModel", () => { + test("replaces only the root value, leaving the instructions string untouched", () => { + const next = setTomlRootModel(IMPORTED_ROLE, "anthropic/claude-sonnet-5"); + expect(next).toBe(IMPORTED_ROLE.replace('model = "gpt-5.5" # pinned', 'model = "anthropic/claude-sonnet-5" # pinned')); + }); + + test("inserts a missing key after the leading comments, with the file's CRLF", () => { + const unpinned = IMPORTED_ROLE.replace('model = "gpt-5.5" # pinned\n', "").replace(/\n/g, "\r\n"); + const next = setTomlRootModel(unpinned, "gpt-5.6-sol"); + expect(next).toBe(unpinned.replace("# imported by Codex\r\n", '# imported by Codex\r\nmodel = "gpt-5.6-sol"\r\n')); + expect(setTomlRootModel(next, "gpt-5.6-sol")).toBe(next); + }); + + test("keeps a literal-string pin literal and preserves spacing", () => { + expect(setTomlRootModel("model = 'a' \n", "b")).toBe("model = 'b' \n"); + expect(setTomlRootModel("model = 'a'\n", "it's")).toBe('model = "it\'s"\n'); + }); + + test("a model key under a table is not the root pin", () => { + const tabled = '[profile]\nmodel = "inner"\n'; + expect(setTomlRootModel(tabled, "outer")).toBe(`model = "outer"\n${tabled}`); + }); + + test("a leading BOM stays at byte 0", () => { + expect(setTomlRootModel('\ufeffname = "r"\n', "m")).toBe('\ufeffmodel = "m"\nname = "r"\n'); + }); + + test("a non-string model value is refused rather than duplicated", () => { + expect(() => setTomlRootModel("model = 5\n", "m")).toThrow(AgentRoleModelError); + }); + + test("an escaped quoted model key is replaced rather than duplicated", () => { + const escaped = 'name = "r"\n"mod\\u0065l" = "old" # pinned\nkeep = 1\n'; + const next = setTomlRootModel(escaped, "new"); + expect(next).toBe('name = "r"\n"mod\\u0065l" = "new" # pinned\nkeep = 1\n'); + expect(Bun.TOML.parse(next)).toEqual({ name: "r", model: "new", keep: 1 }); + expect(setTomlRootModel("'model' = 'old'\n", "new")).toBe("'model' = 'new'\n"); + }); + + test("a quoted root key that cannot be decoded refuses the write", () => { + expect(() => setTomlRootModel('"mo\\x64el" = "old"\n', "new")).toThrow(AgentRoleModelError); + }); + + test("a multiline model value closing on extra quotes is replaced whole", () => { + for (const pinned of ['model = """old""""\nkeep = 1\n', "model = '''old''''\nkeep = 1\n", 'model = """old"""""\nkeep = 1\n']) { + const next = setTomlRootModel(pinned, "new"); + expect(next).toBe(pinned.startsWith("model = '") ? "model = 'new'\nkeep = 1\n" : 'model = "new"\nkeep = 1\n'); + expect(Bun.TOML.parse(next)).toEqual({ model: "new", keep: 1 }); + } + }); +}); + +describe("writeCodexAgentRoleModel", () => { + let home: string | null = null; + afterEach(() => { + if (home) rmSync(home, { recursive: true, force: true }); + home = null; + }); + + function codexHome(): string { + home = mkdtempSync(join(tmpdir(), "ocx-agent-roles-")); + mkdirSync(join(home, "agents")); + writeFileSync(join(home, "agents", "explorer.toml"), IMPORTED_ROLE); + writeFileSync(join(home, "outside.toml"), 'model = "keep"\n'); + return home; + } + + test("writes the pin and reports it back through the listing", () => { + const dir = codexHome(); + expect(writeCodexAgentRoleModel("explorer", " xai/grok-4.5 ", dir)).toEqual({ status: "written" }); + expect(listCodexAgentRoleModels(dir)).toEqual([{ role: "explorer", model: "xai/grok-4.5" }]); + expect(writeCodexAgentRoleModel("explorer", "xai/grok-4.5", dir)).toEqual({ status: "unchanged" }); + }); + + test("refuses a role that is not a listed file, including traversal", () => { + const dir = codexHome(); + for (const role of ["../outside", "missing", "agents/explorer"]) { + expect(() => writeCodexAgentRoleModel(role, "m", dir)).toThrow(AgentRoleModelError); + } + expect(readFileSync(join(dir, "outside.toml"), "utf8")).toBe('model = "keep"\n'); + }); + + test("refuses an empty or multi-line model without touching the file", () => { + const dir = codexHome(); + for (const model of ["", "a\nb", 5]) { + expect(() => writeCodexAgentRoleModel("explorer", model as string, dir)).toThrow(AgentRoleModelError); + } + expect(readFileSync(join(dir, "agents", "explorer.toml"), "utf8")).toBe(IMPORTED_ROLE); + }); +}); diff --git a/tests/server/codex-agent-role-routes.test.ts b/tests/server/codex-agent-role-routes.test.ts new file mode 100644 index 00000000000..989dbf6f4ff --- /dev/null +++ b/tests/server/codex-agent-role-routes.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; +import { ManagementRequest as Request } from "../helpers/management-auth"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const ROLE = 'name = "explorer"\ndeveloper_instructions = """\nmodel = "x"\n"""\nmodel = "gpt-5.5"\n'; +const saved = { CODEX_HOME: process.env.CODEX_HOME, HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; +let root = ""; + +function restore(name: keyof typeof saved): void { + if (saved[name] === undefined) delete process.env[name]; + else process.env[name] = saved[name]; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-agent-role-routes-")); + mkdirSync(join(root, "codex", "agents"), { recursive: true }); + mkdirSync(join(root, "home", ".omo"), { recursive: true }); + writeFileSync(join(root, "codex", "agents", "explorer.toml"), ROLE); + installLazyCodex(); + process.env.CODEX_HOME = join(root, "codex"); + process.env.HOME = join(root, "home"); + process.env.USERPROFILE = join(root, "home"); +}); + +afterEach(() => { + restore("CODEX_HOME"); + restore("HOME"); + restore("USERPROFILE"); + removeTreeWithRetry(root); +}); + +const config = { port: 10100, providers: {}, defaultProvider: "openai" } as unknown as OcxConfig; +const DETECTED = { detected: true, pluginEnabled: true, pluginInstalled: true }; + +function installLazyCodex(): void { + const plugin = join(root, "codex", "plugins", "cache", "sisyphuslabs", "omo", "5.1.1"); + mkdirSync(plugin, { recursive: true }); + writeFileSync(join(plugin, "lazycodex-install.json"), "{}"); + writeFileSync(join(root, "codex", "config.toml"), '[plugins."omo@sisyphuslabs"]\nenabled = true\n'); +} + +async function call(path: string, init?: RequestInit): Promise<{ status: number; body: Record }> { + const response = await handleManagementAPI(new Request(`http://localhost${path}`, init), new URL(`http://localhost${path}`), config); + expect(response).not.toBeNull(); + return { status: response!.status, body: await response!.json() as Record }; +} + +function put(role: string, model: unknown) { + return call(`/api/codex-agent-roles/${role}`, { method: "PUT", body: JSON.stringify({ model }) }); +} + +describe("/api/codex-agent-roles", () => { + test("round-trips a role model through the TOML and omo.jsonc", async () => { + writeFileSync(join(root, "home", ".omo", "omo.jsonc"), '{ "codex": {} }\n'); + expect((await call("/api/codex-agent-roles")).body).toEqual({ + lazycodex: DETECTED, + omoJsonc: { state: "present" }, + roles: [{ role: "explorer", model: "gpt-5.5", omoJsoncModel: null }], + }); + const saved = await put("explorer", "xai/grok-4.5"); + expect(saved.status).toBe(200); + expect(saved.body).toEqual({ ok: true, role: "explorer", model: "xai/grok-4.5", toml: { status: "written" }, omoJsonc: { status: "written" } }); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toBe(ROLE.replace('model = "gpt-5.5"', 'model = "xai/grok-4.5"')); + expect((await call("/api/codex-agent-roles")).body.roles).toEqual([{ role: "explorer", model: "xai/grok-4.5", omoJsoncModel: "xai/grok-4.5" }]); + }); + + test("without LazyCodex it lists nothing, writes nothing, and never opens omo.jsonc", async () => { + writeFileSync(join(root, "codex", "config.toml"), ""); + const omoPath = join(root, "home", ".omo", "omo.jsonc"); + writeFileSync(omoPath, '{ "codex": {} }\n'); + const nativeRead = fs.readFileSync; + const reads: string[] = []; + const spy = spyOn(fs, "readFileSync").mockImplementation(((path: fs.PathOrFileDescriptor, options?: unknown) => { + reads.push(String(path)); + return nativeRead(path, options as BufferEncoding); + }) as never); + try { + expect((await call("/api/codex-agent-roles")).body).toEqual({ + lazycodex: { detected: false, pluginEnabled: false, pluginInstalled: true }, + omoJsonc: null, + roles: [], + }); + const refused = await put("explorer", "m"); + expect(refused.status).toBe(409); + expect(refused.body.code).toBe("lazycodex_not_detected"); + } finally { + spy.mockRestore(); + } + expect(reads).not.toContain(omoPath); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toBe(ROLE); + expect(readFileSync(omoPath, "utf8")).toBe('{ "codex": {} }\n'); + }); + + test("reports an absent or commented omo.jsonc without writing it", async () => { + expect((await put("explorer", "m1")).body.omoJsonc).toEqual({ status: "absent" }); + const commented = '{ // mine\n "codex": {} }\n'; + writeFileSync(join(root, "home", ".omo", "omo.jsonc"), commented); + expect((await call("/api/codex-agent-roles")).body.omoJsonc).toEqual({ state: "comments" }); + const result = await put("explorer", "m2"); + expect(result.body.toml).toEqual({ status: "written" }); + expect(result.body.omoJsonc).toEqual({ status: "skipped_comments" }); + expect(readFileSync(join(root, "home", ".omo", "omo.jsonc"), "utf8")).toBe(commented); + }); + + test("rejects unknown roles, traversal, and bad models", async () => { + expect((await put("missing", "m")).status).toBe(404); + expect((await put("..%2Fconfig", "m")).status).toBe(404); + expect((await put("%E0%A4%A", "m")).status).toBe(400); + expect((await put("explorer", "")).status).toBe(400); + expect((await put("explorer", 7)).status).toBe(400); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toBe(ROLE); + }); + + test("an unreadable omo.jsonc still lists the roles", async () => { + const omoPath = join(root, "home", ".omo", "omo.jsonc"); + writeFileSync(omoPath, '{ "codex": {} }\n'); + const nativeRead = fs.readFileSync; + const spy = spyOn(fs, "readFileSync").mockImplementation(((path: fs.PathOrFileDescriptor, options?: unknown) => { + if (path === omoPath) throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); + return nativeRead(path, options as BufferEncoding); + }) as never); + try { + const listed = await call("/api/codex-agent-roles"); + expect(listed.status).toBe(200); + expect(listed.body).toEqual({ + lazycodex: DETECTED, + omoJsonc: { state: "unreadable" }, + roles: [{ role: "explorer", model: "gpt-5.5", omoJsoncModel: null }], + }); + const saved = await put("explorer", "m3"); + expect(saved.body.toml).toEqual({ status: "written" }); + expect(saved.body.omoJsonc).toEqual({ status: "write_failed" }); + } finally { + spy.mockRestore(); + } + }); +});