diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md
index b4d444bb5ec..6729dc75ad3 100644
--- a/docs-site/src/content/docs/guides/integrations.md
+++ b/docs-site/src/content/docs/guides/integrations.md
@@ -1,6 +1,6 @@
---
title: Integrations
-description: Connect opencodex to OpenCode, Pi, Gentle Shell, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI, Kilo, Command Code and Factory Droid from the dashboard — one switch per client, with a backup taken before every write.
+description: Connect opencodex to OpenCode, Pi, Gentle Shell, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Qoder, Cline CLI, Kilo, Command Code, Factory Droid and T3 Code from the dashboard — one switch per client, with a backup taken before every write.
---
The **Connect** page writes opencodex's provider block into a client's own config
@@ -31,6 +31,7 @@ file, and removes it again. Each client below has its own switch:
| Kilo | first existing `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json`, or `config.json` under `~/.config/kilo` | JSONC | new sessions | `OPENCODEX_KILO_API_KEY` |
| Command Code | `~/.commandcode/providers.json` (on Windows with the default home: use `HOME ?? USERPROFILE` only if nonblank and Windows-absolute, otherwise `homedir()`; a separately supplied home is preserved; append `.commandcode\providers.json`) | JSON | next launch of Command Code | none — keyless loopback (`apiKey: false`) |
| Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | JSON | immediately via file watching | none — keyless loopback |
+| T3 Code | `$T3CODE_HOME/userdata/settings.json` (default `~/.t3/userdata/settings.json`) | JSON | immediately — T3 Code watches the file | non-secret loopback marker |
## Gentle Shell
@@ -128,6 +129,48 @@ loopback listener is enabled, exports point Qoder at that listener. `ocx sync` a
changes refresh an already enabled Qoder integration like the other managed clients.
Model IDs containing `,` or `]` (for example `kimi/k3[1m]`) are exported like any other model.
+## T3 Code
+
+[T3 Code](https://github.com/pingdotgg/t3code) drives the Codex and Claude Code CLIs already
+installed on the machine. Its Codex threads route through opencodex once the
+[Codex integration](/guides/codex-integration/) is enabled, because T3 Code lists Codex models
+through `codex app-server`, which reads `$CODEX_HOME/config.toml` like every Codex client. Routed
+models then appear under Codex with no T3 Code switch, as long as T3 Code runs Codex with the same
+Codex home opencodex manages (`CODEX_HOME`, default `~/.codex`). A T3 Code Codex instance pointed at
+another home does not see the routed catalog.
+
+The **T3 Code** switch adds the Claude side. It needs the Messages API, which every Claude turn
+uses: while Messages is off on the API page, enable and refresh are refused, and disable still
+works.
+
+```bash
+ocx export --client t3code --json
+ocx integration client enable --client t3code
+ocx integration client disable --client t3code
+```
+
+Enable writes one provider instance, `providerInstances.opencodex`, into T3 Code's server settings
+and leaves every other instance and setting alone. It is a Claude instance named **opencodex** that
+runs your own `claude` binary with `ANTHROPIC_BASE_URL` set to the proxy, the non-secret
+`opencodex-proxy` marker as `ANTHROPIC_AUTH_TOKEN`, an explicitly empty `ANTHROPIC_API_KEY`, and
+`CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1` so a `settings.json` env block in your Claude config cannot
+re-point it. Each routed model is a custom model keyed by its opencodex selector
+(`provider/model`), which T3 Code sends verbatim. It appears in T3 Code's model picker under
+**opencodex**. T3 Code reloads the file on its own, so no restart is needed; catalog changes and
+`ocx sync` refresh the instance.
+
+T3 Code keeps its data in `T3CODE_HOME` when set, otherwise `~/.t3`. Set the same variable on the
+opencodex process if you relocate it; a relative value is refused. On Windows, use a fully qualified
+drive path (`C:\shared\t3`) or UNC path (`\\server\share\t3`); paths without a drive, such as
+`\shared\t3` or `/shared/t3`, are refused. `~` expands against the effective user home.
+The marker is not a credential, so the integration needs loopback access and refuses an authenticated non-loopback bind unless the
+unauthenticated loopback listener is enabled.
+
+Treat the instance as opencodex's. Marking one of its variables **Sensitive** in T3 Code, adding
+a custom model to it, or toggling it off rewrites the block. The switch then shows a conflict
+instead of overwriting your edit, and **Replace** restores the generated block. To use
+different settings, add your own Claude instance in T3 Code beside 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.
diff --git a/gui/public/provider-icons/README.md b/gui/public/provider-icons/README.md
index 334f3159569..792a545efd5 100644
--- a/gui/public/provider-icons/README.md
+++ b/gui/public/provider-icons/README.md
@@ -411,3 +411,11 @@ committing it.
The 40×40 artwork retains its green background, gradients and white mark;
render it as an image in either theme. Used to identify the `workbuddy-ai`
provider, with the same asset embedded by the native tray.
+## T3 Code (2026-10-22)
+
+- `t3code.svg` — `assets/prod/logo.svg` from `pingdotgg/t3code` (MIT, © T3 Tools Inc.), the
+ production app icon the project ships, read at commit `f054d1b8` and committed byte for byte
+ (1048 bytes, MD5 `a2254eaba3e380cbc0b0101301cb5c9f`). `viewBox="0 0 128 128"`, a black rounded
+ plate carrying a white "T3" glyph. **Not masked:** two neutral inks on an opaque plate, the
+ plated case `qoder.svg` and `omo.svg` already record, so masking would fill the tile and
+ discard the glyph. As an image it reads on both surfaces.
diff --git a/gui/public/provider-icons/t3code.svg b/gui/public/provider-icons/t3code.svg
new file mode 100644
index 00000000000..61b48db5af1
--- /dev/null
+++ b/gui/public/provider-icons/t3code.svg
@@ -0,0 +1,4 @@
+
diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts
index ce6a22fab16..2bc56d28991 100644
--- a/gui/src/app-routing.ts
+++ b/gui/src/app-routing.ts
@@ -152,6 +152,7 @@ export const INTEGRATION_TAB_HASHES = [
"integrations/qoder-cn",
"integrations/workbuddy",
"integrations/workbuddy-ai",
+ "integrations/t3code",
] as const;
/**
diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts
index 64703e980a8..3c7dd575445 100644
--- a/gui/src/components/apikeys-workspace/client-config-clients.ts
+++ b/gui/src/components/apikeys-workspace/client-config-clients.ts
@@ -8,7 +8,7 @@
* with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row
* until this tuple changes.
*/
-export const CLIENTS = ["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"] as const;
+export const CLIENTS = ["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell", "t3code"] as const;
export type ExportClientId = (typeof CLIENTS)[number];
export const CLIENT_LABEL_KEYS = {
@@ -35,6 +35,7 @@ export const CLIENT_LABEL_KEYS = {
cline: "api.clientConfig.clientCline",
kilo: "api.clientConfig.clientKilo",
droid: "api.clientConfig.clientDroid",
+ t3code: "api.clientConfig.clientT3code",
} as const;
/**
@@ -92,6 +93,9 @@ export const CLIENT_MARKS: Partial> = {
commandcode: "/provider-icons/commandcode-color.svg",
kilo: "/provider-icons/kilo.svg",
droid: "/provider-icons/factory-droid.svg",
+ // A black plate carrying a white "T3": the plated case `omo` and `qoder` set, so it
+ // stays an image — masking would paint the plate and discard the glyph.
+ t3code: "/provider-icons/t3code.svg",
};
/**
diff --git a/gui/src/components/integration-marks.ts b/gui/src/components/integration-marks.ts
index b641e8c0b5e..08006a3769c 100644
--- a/gui/src/components/integration-marks.ts
+++ b/gui/src/components/integration-marks.ts
@@ -69,6 +69,7 @@ export const INTEGRATION_MARKS: Record = {
"qoder-cn": CLIENT_MARKS["qoder-cn"] ?? null,
workbuddy: null,
"workbuddy-ai": null,
+ t3code: CLIENT_MARKS.t3code ?? null,
};
/**
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index 7b844cc15b8..1500d6346cd 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -1653,8 +1653,11 @@ export const de: Record = {
"integrations.tab.workbuddyAi": "WorkBuddy AI",
"integrations.semantics.workbuddyAi": "Verwaltet OpenCodex-Modelle in ~/.workbuddy-ai/models.json. Erfordert Loopback-Zugriff. Andere Einstellungen bleiben erhalten; Rückgängig stellt den Snapshot wieder her.",
"integrations.tab.qoder": "Qoder",
+ "integrations.tab.t3code": "T3 Code",
"api.clientConfig.clientQoder": "Qoder",
+ "api.clientConfig.clientT3code": "T3 Code",
"integrations.semantics.qoder": "Verwaltet OpenCodex-Anbieter und eigene Modelle in ~/.qoder/settings.json. Erfordert Loopback-Zugriff. Andere Einstellungen bleiben erhalten; Rückgängig stellt den Snapshot wieder her.",
+ "integrations.semantics.t3code": "Fügt eine opencodex-Claude-Anbieterinstanz zu ~/.t3/userdata/settings.json hinzu (T3CODE_HOME verschiebt sie). Geroutete Modelle erscheinen in der Modellauswahl von T3 Code; Codex-Threads nutzen den Proxy nur, wenn die Codex-Integration aktiviert ist und T3 Code dasselbe CODEX_HOME verwendet. Erfordert Loopback-Zugriff. Andere Einstellungen bleiben erhalten; Rückgängig stellt den Snapshot wieder her.",
"integrations.tab.cline": "Cline CLI",
"integrations.tab.kilo": "Kilo",
"integrations.tab.droid": "Factory Droid",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index b36affd1c25..0d88766db9c 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -2239,8 +2239,11 @@ export const en = {
"integrations.tab.workbuddyAi": "WorkBuddy AI",
"integrations.semantics.workbuddyAi": "Manages OpenCodex models in ~/.workbuddy-ai/models.json. Requires loopback access. Other settings are preserved; Undo restores the snapshot.",
"integrations.tab.qoder": "Qoder",
+ "integrations.tab.t3code": "T3 Code",
"api.clientConfig.clientQoder": "Qoder",
+ "api.clientConfig.clientT3code": "T3 Code",
"integrations.semantics.qoder": "Manages OpenCodex providers and custom models in ~/.qoder/settings.json. Requires loopback access. Other settings are preserved; Undo restores the snapshot.",
+ "integrations.semantics.t3code": "Adds an opencodex Claude provider instance to ~/.t3/userdata/settings.json (T3CODE_HOME relocates it). Routed models appear in T3 Code's model picker; Codex threads use the proxy only when the Codex integration is enabled and T3 Code shares the same CODEX_HOME. Requires loopback access. Other settings are preserved; Undo restores the snapshot.",
"integrations.tab.cline": "Cline CLI",
"integrations.tab.kilo": "Kilo",
"integrations.tab.droid": "Factory Droid",
diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts
index 9a56169459f..564c5a3423b 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -2211,8 +2211,11 @@ export const fr: Record = {
"integrations.tab.workbuddyAi": "WorkBuddy AI",
"integrations.semantics.workbuddyAi": "Gère les modèles OpenCodex dans ~/.workbuddy-ai/models.json. Accès local requis. Les autres réglages sont conservés ; Annuler restaure la sauvegarde.",
"integrations.tab.qoder": "Qoder",
+ "integrations.tab.t3code": "T3 Code",
"api.clientConfig.clientQoder": "Qoder",
+ "api.clientConfig.clientT3code": "T3 Code",
"integrations.semantics.qoder": "Gère les fournisseurs OpenCodex et les modèles personnalisés dans ~/.qoder/settings.json. Nécessite qu’opencodex soit joignable sur une adresse de bouclage. Les autres paramètres sont conservés ; Annuler restaure la sauvegarde.",
+ "integrations.semantics.t3code": "Ajoute une instance de fournisseur Claude opencodex à ~/.t3/userdata/settings.json (T3CODE_HOME la déplace). Les modèles routés apparaissent dans le sélecteur de modèles de T3 Code ; les fils Codex ne passent par le proxy que si l’intégration Codex est activée et que T3 Code utilise le même CODEX_HOME. Nécessite qu’opencodex soit joignable sur une adresse de bouclage. Les autres paramètres sont conservés ; Annuler restaure la sauvegarde.",
"integrations.tab.cline": "Cline CLI",
"integrations.tab.kilo": "Kilo",
"integrations.tab.droid": "Factory Droid",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index 5890d087134..b7268e5152f 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -2106,8 +2106,11 @@ export const ja: Record = {
"integrations.tab.workbuddyAi": "WorkBuddy AI",
"integrations.semantics.workbuddyAi": "~/.workbuddy-ai/models.json の OpenCodex モデルを管理します。ループバック接続が必要です。他の設定を保持し、元に戻すとスナップショットを復元します。",
"integrations.tab.qoder": "Qoder",
+ "integrations.tab.t3code": "T3 Code",
"api.clientConfig.clientQoder": "Qoder",
+ "api.clientConfig.clientT3code": "T3 Code",
"integrations.semantics.qoder": "~/.qoder/settings.json の OpenCodex プロバイダーとカスタムモデルを管理します。ループバック接続が必要です。他の設定は保持され、元に戻すとスナップショットが復元されます。",
+ "integrations.semantics.t3code": "~/.t3/userdata/settings.json に opencodex の Claude プロバイダーインスタンスを追加します(T3CODE_HOME で移動できます)。ルーティングされたモデルが T3 Code のモデル選択に表示されます。Codex 統合が有効で、T3 Code が同じ CODEX_HOME を使用している場合にのみ、Codex スレッドはプロキシを使用します。ループバック接続が必要です。他の設定は保持され、元に戻すとスナップショットが復元されます。",
"integrations.tab.cline": "Cline CLI",
"integrations.tab.kilo": "Kilo",
"integrations.tab.droid": "Factory Droid",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index 8ebb92b09c8..7475ce7972a 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -1697,8 +1697,11 @@ export const ko: Record = {
"integrations.tab.workbuddyAi": "WorkBuddy AI",
"integrations.semantics.workbuddyAi": "~/.workbuddy-ai/models.json에서 OpenCodex 모델을 관리합니다. 루프백 연결이 필요합니다. 다른 설정은 유지되며 실행 취소로 스냅샷을 복원합니다.",
"integrations.tab.qoder": "Qoder",
+ "integrations.tab.t3code": "T3 Code",
"api.clientConfig.clientQoder": "Qoder",
+ "api.clientConfig.clientT3code": "T3 Code",
"integrations.semantics.qoder": "~/.qoder/settings.json의 OpenCodex 공급자와 사용자 지정 모델을 관리합니다. 루프백 접근이 필요합니다. 다른 설정은 유지되며 실행 취소 시 스냅샷을 복원합니다.",
+ "integrations.semantics.t3code": "~/.t3/userdata/settings.json에 opencodex Claude 공급자 인스턴스를 추가합니다(T3CODE_HOME으로 위치 변경). 라우팅된 모델이 T3 Code 모델 선택기에 표시됩니다. Codex 통합이 활성화되어 있고 T3 Code가 동일한 CODEX_HOME을 사용하는 경우에만 Codex 스레드가 프록시를 사용합니다. 루프백 접근이 필요합니다. 다른 설정은 유지되며 실행 취소 시 스냅샷을 복원합니다.",
"integrations.tab.cline": "Cline CLI",
"integrations.tab.kilo": "Kilo",
"integrations.tab.droid": "Factory Droid",
diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts
index 3391e1c8b69..4ce0bf9c9e1 100644
--- a/gui/src/i18n/pt.ts
+++ b/gui/src/i18n/pt.ts
@@ -2225,6 +2225,7 @@ export const pt: Record = {
"integrations.tab.workbuddyAi": "WorkBuddy AI",
"integrations.semantics.workbuddyAi": "Gerencia modelos OpenCodex em ~/.workbuddy-ai/models.json. Requer acesso local. Outras configurações são preservadas; Desfazer restaura o snapshot.",
"integrations.tab.qoder": "Qoder",
+ "integrations.tab.t3code": "T3 Code",
"integrations.tab.droid": "Factory Droid",
"integrations.aside.profilesTitle": "Perfis do Aside",
"integrations.aside.profilesHint": "Escolha quais perfis recebem os modelos selecionados. O perfil ativo do Aside permanece inalterado.",
@@ -2542,6 +2543,7 @@ export const pt: Record = {
"integrations.lazycodexRoles.auto.moveDown": "Desça de nível se: {text}",
"integrations.semantics.cline": "Gerencia o OpenCodex no providers.json e no models.json do Cline CLI. Pare o Cline antes de alterar ou sincronizar esses arquivos e reinicie depois. Desfazer restaura os dois originais. Seu provedor padrão permanece inalterado; selecione o OpenCodex no Cline.",
"integrations.semantics.qoder": "Gerencia os provedores e modelos personalizados do OpenCodex em ~/.qoder/settings.json. Requer acesso por loopback. As demais configurações são preservadas; Desfazer restaura o snapshot.",
+ "integrations.semantics.t3code": "Adiciona uma instância de provedor Claude do opencodex a ~/.t3/userdata/settings.json (T3CODE_HOME a realoca). Os modelos roteados aparecem no seletor de modelos do T3 Code; os threads do Codex só usam o proxy quando a integração do Codex está ativada e o T3 Code usa o mesmo CODEX_HOME. Requer acesso por loopback. As demais configurações são preservadas; Desfazer restaura o snapshot.",
"integrations.semantics.commandcode": "Gerencia apenas a entrada do provedor OpenCodex em ~/.commandcode/providers.json. Seus outros provedores e configurações permanecem inalterados.",
"integrations.semantics.kilo": "Gerencia apenas provider.opencodex na configuração global do Kilo — o primeiro arquivo existente entre kilo.jsonc, kilo.json, opencode.jsonc, opencode.json ou config.json em ~/.config/kilo (XDG_CONFIG_HOME realoca esse diretório; kilo.jsonc é criado quando nenhum existe). As demais chaves permanecem inalteradas. Aplicar reescreve o arquivo inteiro, então comentários e vírgulas finais não são preservados. Selecione opencodex/ no Kilo.",
"integrations.semantics.droid": "Adiciona modelos personalizados do OpenCodex ao settings.json do Factory Droid. Desativar remove apenas as linhas gerenciadas; desfazer restaura o arquivo salvo.",
@@ -3107,6 +3109,7 @@ export const pt: Record = {
"api.clientConfig.clientCommandCode": "Command Code",
"api.clientConfig.clientKilo": "Kilo",
"api.clientConfig.clientQoder": "Qoder",
+ "api.clientConfig.clientT3code": "T3 Code",
"api.clientConfig.clientDroid": "Factory Droid",
"api.clientConfig.clineBundle": "Este é um pacote de dois arquivos, não um arquivo de configurações do Cline. Mescle settings em providers.json e catalog no models.json vizinho. Use o interruptor de integração para uma gravação com backup.",
"api.clientConfig.clineDownloaded": "{filename} baixado. Nada foi alterado ainda; settings e catalog pertencem a dois arquivos separados do Cline.",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 62b3cad4352..3f3449dc2a5 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -2197,8 +2197,11 @@ export const ru: Record = {
"integrations.tab.workbuddyAi": "WorkBuddy AI",
"integrations.semantics.workbuddyAi": "Управляет моделями OpenCodex в ~/.workbuddy-ai/models.json. Требуется loopback. Остальные настройки сохраняются; отмена восстанавливает снимок.",
"integrations.tab.qoder": "Qoder",
+ "integrations.tab.t3code": "T3 Code",
"api.clientConfig.clientQoder": "Qoder",
+ "api.clientConfig.clientT3code": "T3 Code",
"integrations.semantics.qoder": "Управляет провайдерами OpenCodex и пользовательскими моделями в ~/.qoder/settings.json. Требуется доступ через loopback. Остальные настройки сохраняются; отмена восстанавливает снимок.",
+ "integrations.semantics.t3code": "Добавляет экземпляр провайдера Claude от opencodex в ~/.t3/userdata/settings.json (T3CODE_HOME меняет расположение). Маршрутизируемые модели появляются в выборе моделей T3 Code; потоки Codex используют прокси только при включённой интеграции Codex и использовании T3 Code того же CODEX_HOME. Требуется доступ через loopback. Остальные настройки сохраняются; отмена восстанавливает снимок.",
"integrations.tab.cline": "Cline CLI",
"integrations.tab.kilo": "Kilo",
"integrations.tab.droid": "Factory Droid",
diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts
index dc30c7c6c2a..c026aa56962 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -2224,8 +2224,11 @@ export const tr: Record = {
"integrations.tab.workbuddyAi": "WorkBuddy AI",
"integrations.semantics.workbuddyAi": "~/.workbuddy-ai/models.json içindeki OpenCodex modellerini yönetir. Yerel bağlantı gerekir. Diğer ayarlar korunur; Geri Al anlık görüntüyü geri yükler.",
"integrations.tab.qoder": "Qoder",
+ "integrations.tab.t3code": "T3 Code",
"api.clientConfig.clientQoder": "Qoder",
+ "api.clientConfig.clientT3code": "T3 Code",
"integrations.semantics.qoder": "~/.qoder/settings.json içindeki OpenCodex sağlayıcılarını ve özel modelleri yönetir. Loopback erişimi gerektirir. Diğer ayarlar korunur; Geri al, anlık görüntüyü geri yükler.",
+ "integrations.semantics.t3code": "~/.t3/userdata/settings.json dosyasına bir opencodex Claude sağlayıcı örneği ekler (T3CODE_HOME konumunu değiştirir). Yönlendirilen modeller T3 Code model seçicisinde görünür; Codex iş parçacıkları proxy’yi yalnızca Codex entegrasyonu etkinse ve T3 Code aynı CODEX_HOME değerini kullanıyorsa kullanır. Loopback erişimi gerektirir. Diğer ayarlar korunur; Geri al, anlık görüntüyü geri yükler.",
"integrations.tab.cline": "Cline CLI",
"integrations.tab.kilo": "Kilo",
"integrations.tab.droid": "Factory Droid",
diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts
index 5dfb2f2765d..a76109ea435 100644
--- a/gui/src/i18n/vi.ts
+++ b/gui/src/i18n/vi.ts
@@ -2187,8 +2187,11 @@ export const vi: Record = {
"integrations.tab.workbuddyAi": "WorkBuddy AI",
"integrations.semantics.workbuddyAi": "Quản lý mô hình OpenCodex trong ~/.workbuddy-ai/models.json. Cần kết nối loopback. Giữ nguyên cài đặt khác; Hoàn tác khôi phục bản sao lưu.",
"integrations.tab.qoder": "Qoder",
+ "integrations.tab.t3code": "T3 Code",
"api.clientConfig.clientQoder": "Qoder",
+ "api.clientConfig.clientT3code": "T3 Code",
"integrations.semantics.qoder": "Quản lý nhà cung cấp OpenCodex và mô hình tùy chỉnh trong ~/.qoder/settings.json. Yêu cầu truy cập loopback. Giữ nguyên các cài đặt khác; Hoàn tác khôi phục bản chụp.",
+ "integrations.semantics.t3code": "Thêm một phiên bản nhà cung cấp Claude của opencodex vào ~/.t3/userdata/settings.json (T3CODE_HOME thay đổi vị trí). Các mô hình được định tuyến xuất hiện trong bộ chọn mô hình của T3 Code; luồng Codex chỉ dùng proxy khi tích hợp Codex được bật và T3 Code dùng cùng CODEX_HOME. Yêu cầu truy cập loopback. Giữ nguyên các cài đặt khác; Hoàn tác khôi phục bản chụp.",
"integrations.tab.cline": "Cline CLI",
"integrations.tab.kilo": "Kilo",
"integrations.tab.droid": "Factory Droid",
diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts
index 25251f41519..abf928f3a91 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -3144,8 +3144,11 @@ export const zhTW: Record = {
"integrations.tab.workbuddyAi": "WorkBuddy AI",
"integrations.semantics.workbuddyAi": "在 ~/.workbuddy-ai/models.json 中管理 OpenCodex 模型。需要本機回環連線。保留其他設定;復原可還原快照。",
"integrations.tab.qoder": "Qoder",
+ "integrations.tab.t3code": "T3 Code",
"api.clientConfig.clientQoder": "Qoder",
+ "api.clientConfig.clientT3code": "T3 Code",
"integrations.semantics.qoder": "管理 ~/.qoder/settings.json 中的 OpenCodex 供應商和自訂模型。需要回環存取。保留其他設定;復原會還原快照。",
+ "integrations.semantics.t3code": "在 ~/.t3/userdata/settings.json 中新增 opencodex Claude 供應商實例(T3CODE_HOME 可變更位置)。路由模型會出現在 T3 Code 的模型選擇器中;只有啟用 Codex 整合且 T3 Code 使用相同的 CODEX_HOME 時,Codex 對話才會透過代理。需要回環存取。保留其他設定;復原會還原快照。",
"integrations.tab.cline": "Cline CLI",
"integrations.tab.kilo": "Kilo",
"integrations.tab.droid": "Factory Droid",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index 33518d1ede8..5e4cebeecb6 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -1670,8 +1670,11 @@ export const zh: Record = {
"integrations.tab.workbuddyAi": "WorkBuddy AI",
"integrations.semantics.workbuddyAi": "在 ~/.workbuddy-ai/models.json 中管理 OpenCodex 模型。需要本机回环连接。保留其他设置;撤销可恢复快照。",
"integrations.tab.qoder": "Qoder",
+ "integrations.tab.t3code": "T3 Code",
"api.clientConfig.clientQoder": "Qoder",
+ "api.clientConfig.clientT3code": "T3 Code",
"integrations.semantics.qoder": "管理 ~/.qoder/settings.json 中的 OpenCodex 提供商和自定义模型。需要回环访问。保留其他设置;撤销会恢复快照。",
+ "integrations.semantics.t3code": "在 ~/.t3/userdata/settings.json 中添加 opencodex Claude 提供商实例(T3CODE_HOME 可更改位置)。路由模型会出现在 T3 Code 的模型选择器中;只有启用 Codex 集成且 T3 Code 使用相同的 CODEX_HOME 时,Codex 线程才会通过代理。需要回环访问。保留其他设置;撤销会恢复快照。",
"integrations.tab.cline": "Cline CLI",
"integrations.tab.kilo": "Kilo",
"integrations.tab.droid": "Factory Droid",
diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx
index fec85ff669c..51f78320e64 100644
--- a/gui/src/pages/integrations/FileIntegrationPage.tsx
+++ b/gui/src/pages/integrations/FileIntegrationPage.tsx
@@ -93,6 +93,7 @@ const SEMANTICS_KEY: Record = {
"qoder-cn": "integrations.semantics.qoderCn",
"workbuddy": "integrations.semantics.workbuddy",
"workbuddy-ai": "integrations.semantics.workbuddyAi",
+ t3code: "integrations.semantics.t3code",
};
const TAB_LABEL_KEY: Record = {
@@ -119,6 +120,7 @@ const TAB_LABEL_KEY: Record = {
"qoder-cn": "integrations.tab.qoderCn",
"workbuddy": "integrations.tab.workbuddy",
"workbuddy-ai": "integrations.tab.workbuddyAi",
+ t3code: "integrations.tab.t3code",
};
type FileIntegrationPageProps = {
diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts
index 2f01d0a9356..6de6b3387f9 100644
--- a/gui/src/pages/integrations/integration-api.ts
+++ b/gui/src/pages/integrations/integration-api.ts
@@ -25,6 +25,7 @@ export const FILE_INTEGRATION_CLIENTS = [
"kilo",
"droid",
"gentle-shell",
+ "t3code",
] as const;
export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number];
@@ -290,6 +291,7 @@ const PLAN_SCHEMA_PATHS = new Set([
"models.providers.opencodex",
"models.*",
"modelConfigs.customModels.*",
+ "providerInstances.opencodex",
"llm-pi-ai.providers.opencodex",
"custom_provider.opencodex",
"providers.[id=opencodex]",
diff --git a/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts
index 6a934215b83..54ee415be23 100644
--- a/gui/src/pages/integrations/integration-tabs.ts
+++ b/gui/src/pages/integrations/integration-tabs.ts
@@ -61,6 +61,7 @@ export const TABS: readonly TabDefinition[] = [
{ id: "cline", hash: "integrations/cline", labelKey: "integrations.tab.cline" },
{ id: "kilo", hash: "integrations/kilo", labelKey: "integrations.tab.kilo" },
{ id: "droid", hash: "integrations/droid", labelKey: "integrations.tab.droid" },
+ { id: "t3code", hash: "integrations/t3code", labelKey: "integrations.tab.t3code" },
] as const;
export const FILE_CLIENTS = new Set([
@@ -87,4 +88,5 @@ export const FILE_CLIENTS = new Set([
"qoder-cn",
"workbuddy",
"workbuddy-ai",
+ "t3code",
]);
diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts
index 8638624fa8e..42605b7f744 100644
--- a/gui/src/pages/integrations/overview-clients.ts
+++ b/gui/src/pages/integrations/overview-clients.ts
@@ -164,6 +164,7 @@ const FILE_LABEL_KEY: Record = {
"qoder-cn": "integrations.tab.qoderCn",
"workbuddy": "integrations.tab.workbuddy",
"workbuddy-ai": "integrations.tab.workbuddyAi",
+ t3code: "integrations.tab.t3code",
};
/** A file client's block is in the file for both `current` and `stale`. */
diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx
index dc96e5265b3..bf94a890cef 100644
--- a/gui/tests/client-config-panel.test.tsx
+++ b/gui/tests/client-config-panel.test.tsx
@@ -171,7 +171,7 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut
}
test("the API download surface includes DSH, MiniMax Code, Aside, Raycast and omo as clients", () => {
- expect(CLIENTS).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"]);
+ expect(CLIENTS).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell", "t3code"]);
expect(CLIENT_LABEL_KEYS["gentle-shell"]).toBe("integrations.tab.gentleShell");
expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh");
expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode");
diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts
index a0ffa94a0ca..7fb61407013 100644
--- a/gui/tests/fr-localization.test.ts
+++ b/gui/tests/fr-localization.test.ts
@@ -172,6 +172,9 @@ const INTENTIONAL_ENGLISH = new Set([
"integrations.tab.qoderCn",
"integrations.tab.workbuddy",
"integrations.tab.workbuddyAi",
+ // T3 Code is a product name, identical in every locale.
+ "integrations.tab.t3code",
+ "api.clientConfig.clientT3code",
"models.reasoningEffort.minimal",
"models.reasoningEffort.max",
"models.reasoningEffort.ultra",
diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts
index 9640fff43fc..1bdac17165b 100644
--- a/gui/tests/integrations-api.test.ts
+++ b/gui/tests/integrations-api.test.ts
@@ -21,7 +21,7 @@ const originalFetch = globalThis.fetch;
test("all registered export clients include Cline in file integrations", () => {
expect(FILE_INTEGRATION_CLIENTS).toEqual([
- "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell",
+ "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell", "t3code",
]);
});
diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts
index 3a391baead6..d51d5009810 100644
--- a/gui/tests/locale-parity.test.ts
+++ b/gui/tests/locale-parity.test.ts
@@ -179,6 +179,9 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([
"integrations.tab.qoderCn",
"integrations.tab.workbuddy",
"integrations.tab.workbuddyAi",
+ // T3 Code is a product name, identical in every locale.
+ "integrations.tab.t3code",
+ "api.clientConfig.clientT3code",
// Command Code is a product name, identical in every locale.
"integrations.tab.commandcode",
"api.clientConfig.clientCommandCode",
diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json
index 93d0a0f8034..11dae4c5c47 100644
--- a/scripts/test-layout/layout-clients.json
+++ b/scripts/test-layout/layout-clients.json
@@ -1,4 +1,5 @@
{
+ "t3code-client-lifecycle.test.ts": "clients",
"fix-jsonc-cr-comments.test.ts": "lib",
"fix-jsonc-empty-comma.test.ts": "lib",
"fix-plain-data-own-keys.test.ts": "lib",
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index cc2f14394fa..dd2a8940b88 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -117,7 +117,7 @@
"cli-kiro-auto-selection.test.ts": "cli", "codebuddy-live-models.test.ts": "providers", "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "management-provider-request-pacing.test.ts": "server",
"cli-restart-handoff.test.ts": "cli",
"restart-replacement.test.ts": "server", "deepseek-artifact-tool-schema.test.ts": "providers",
- "client-config-export-output-limit.test.ts": "config", "qoder-client.test.ts": "clients", "responses-skills-snapshot.test.ts": "responses", "responses-code-mode-mcp-direct.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "local-read-response-proof.test.ts": "server",
+ "client-config-export-output-limit.test.ts": "config", "qoder-client.test.ts": "clients", "t3code-client.test.ts": "clients", "responses-skills-snapshot.test.ts": "responses", "responses-code-mode-mcp-direct.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "local-read-response-proof.test.ts": "server",
"claude-desktop-system-proxy.test.ts": "claude-integration", "codex-config-drift-heal.test.ts": "codex-integration",
"openai-chat-audio.test.ts": "adapters/openai",
"openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai",
diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts
index 86b4738da59..fbf3addf8c1 100644
--- a/src/cli/dispatch.ts
+++ b/src/cli/dispatch.ts
@@ -547,7 +547,7 @@ const commandRunners: Record = {
},
config,
port: live.port,
- }, ["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes"]));
+ }, ["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes", "t3code"]));
} catch (error) {
console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`);
}
diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts
index cd1e7319efb..982dd647314 100644
--- a/src/cli/export-command.ts
+++ b/src/cli/export-command.ts
@@ -28,8 +28,8 @@
*/
import { writeFileSync } from "node:fs";
import { loadConfig } from "../config";
+import { ClientPathError } from "../clients/client-path";
import {
- ClientPathError,
EXPORT_CLIENTS,
EXPORT_CLIENT_IDS,
buildClientConfigText,
@@ -172,7 +172,7 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep
const spec = EXPORT_CLIENTS[client];
const root = await runtimeBaseUrl(deps);
let built: { document: unknown; text: string };
- if (client === "raycast" || client === "droid" || client === "qoder" || client === "qoder-cn" || client === "workbuddy" || client === "workbuddy-ai") {
+ if (client === "t3code" || client === "raycast" || client === "droid" || client === "qoder" || client === "qoder-cn" || client === "workbuddy" || client === "workbuddy-ai") {
// The dial address alone cannot distinguish a wildcard authenticated bind
// from loopback. Let the live server resolve its admission/listener policy;
// saved config can differ from the process serving this request.
diff --git a/src/cli/help.ts b/src/cli/help.ts
index 8015a8658ab..c498abab8c7 100644
--- a/src/cli/help.ts
+++ b/src/cli/help.ts
@@ -1,3 +1,4 @@
+import { EXPORT_CLIENT_IDS } from "../clients/client-ids";
import { resolveHelpPath } from "./help-catalog";
import type { Capability } from "./capabilities";
import { MODELS_CONTEXT_DETAILS, MODELS_CONTEXT_USAGE } from "./help-models-context";
@@ -94,7 +95,7 @@ Usage:
ocx api-key Alias of ocx access key
ocx access External API keys and endpoint information
ocx api Protocol paths: vocabulary, request-path preview, and policy
- ocx export --client Print a client config wired to the running proxy (23 clients)
+ ocx export --client Print a client config wired to the running proxy (${EXPORT_CLIENT_IDS.length} clients)
ocx integration client Enable, disable, inspect or roll back a client integration
ocx grok Grok Build model selection and apply
ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection
diff --git a/src/cli/integration-plan-dto.ts b/src/cli/integration-plan-dto.ts
index d1004dffce6..5c6451db4be 100644
--- a/src/cli/integration-plan-dto.ts
+++ b/src/cli/integration-plan-dto.ts
@@ -31,6 +31,7 @@ export const FILE_INTEGRATION_CLIENTS = [
"workbuddy",
"workbuddy-ai",
"gentle-shell",
+ "t3code",
] as const;
export const INTEGRATION_REFUSAL_REASONS: ReadonlySet = new Set([
@@ -72,6 +73,7 @@ const PLAN_SCHEMA_PATHS = new Set([
"catalog.providers.opencodex",
"customModels.*",
"modelConfigs.customModels.*",
+ "providerInstances.opencodex",
// ZCode reads its providers from a second file; a plan for it publishes that
// file's templates, and a path missing here is rejected as an invalid preview.
"config.providerConfigRules.providerRules.[providerId=opencodex]",
diff --git a/src/cli/registry.ts b/src/cli/registry.ts
index 9e4678206e7..6c642c45173 100644
--- a/src/cli/registry.ts
+++ b/src/cli/registry.ts
@@ -1,3 +1,5 @@
+import { EXPORT_CLIENT_IDS } from "../clients/client-ids";
+
export interface CliCommandEntry {
name: string;
aliases?: string[];
@@ -518,14 +520,15 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
},
{
name: "export",
- usage: "ocx export --client [--json] [--out ] [--force]",
- summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Command Code, Prime Agent, Aside, Raycast, omo, Cline, Kilo, Factory Droid, Qoder, Qoder CN, WorkBuddy, WorkBuddy AI, Gentle Shell) wired to the running proxy.",
+ usage: `ocx export --client <${EXPORT_CLIENT_IDS.join("|")}> [--json] [--out ] [--force]`,
+ summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Command Code, Prime Agent, Aside, Raycast, omo, Cline, Kilo, Factory Droid, Qoder, Qoder CN, WorkBuddy, WorkBuddy AI, Gentle Shell, T3 Code) wired to the running proxy.",
details: [
"--json prints the generated document as JSON on stdout; use --out for the client's native format.",
"--out writes the native config there and refuses to replace an existing file without --force.",
"Cline exports a two-document bundle: settings for providers.json and catalog for sibling models.json. Use integration client enable --client cline for a journaled write.",
"Droid exports documented customModels for settings.json; use integration client enable --client droid for a journaled write.",
"Gentle Shell uses its persistent selected home; use integration client enable --client gentle-shell for a journaled write, or Pi for link/shared homes.",
+ "T3 Code exports providerInstances.opencodex for settings.json, a Claude provider instance routed through the proxy; use integration client enable --client t3code for a journaled write.",
"The config never contains a real key; it carries a documented env reference or a non-secret loopback placeholder.",
"The destination path is printed for merging by hand — ocx never writes your real client config.",
],
diff --git a/src/clients/client-ids.ts b/src/clients/client-ids.ts
new file mode 100644
index 00000000000..9ea8009cc1d
--- /dev/null
+++ b/src/clients/client-ids.ts
@@ -0,0 +1,7 @@
+/** Shared, dependency-free export client roster for runtime types and CLI discovery. */
+export const EXPORT_CLIENT_IDS = [
+ "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes",
+ "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside",
+ "raycast", "omo", "cline", "kilo", "droid", "gentle-shell", "t3code",
+] as const;
+export type ExportClientId = (typeof EXPORT_CLIENT_IDS)[number];
diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts
index dc1171ad1d4..131b4d2e379 100644
--- a/src/clients/config-export.ts
+++ b/src/clients/config-export.ts
@@ -88,6 +88,8 @@ export { buildCommandCodeClientConfig, summarizeCommandCode, buildCommandCodeCon
import { agentConfigPath, buildQoderCnClientConfig, buildQoderCnContribution, buildWorkbuddyClientConfig, buildWorkbuddyContribution, summarizeWorkbuddy, workbuddyApplyRefusal } from "./config-export/agent-clients";
import { buildQoderClientConfig, summarizeQoder, buildQoderContribution, qoderConfigPath } from "./config-export/qoder";
export { buildQoderClientConfig, qoderConfigPath, qoderHomeDir, type QoderGeneratedConfig } from "./config-export/qoder";
+import { buildT3CodeClientConfig, summarizeT3Code, buildT3CodeContribution, t3codeApplyRefusal, t3codeConfigPath } from "./config-export/t3code";
+export { buildT3CodeClientConfig, t3codeConfigPath, t3codeHomeDir, type T3CodeGeneratedConfig } from "./config-export/t3code";
@@ -1615,11 +1617,8 @@ export const EXPORT_CLIENTS: Record = {
loopbackOnly: true,
},
/*
- * Appended rather than filed beside the other Pi-family clients on purpose.
- * `EXPORT_CLIENT_IDS` is `Object.keys(EXPORT_CLIENTS)`, so this object's
- * insertion order IS the public order, and three tests assert it exactly. The
- * existing sequence is landing order — `pi` second, `prime` eleventh — not a
- * grouping, so appending is the edit that leaves the other thirteen alone.
+ * The shared client roster preserves public landing order, not family grouping.
+ * Keep this registry in the same order so its iteration agrees with the roster.
*/
omo: {
id: "omo",
@@ -1697,9 +1696,22 @@ export const EXPORT_CLIENTS: Record = {
},
loopbackOnly: true,
},
+ t3code: {
+ id: "t3code",
+ filename: "t3code-settings.json",
+ destination: env => t3codeConfigPath(env),
+ apiKeyEnv: "",
+ exportHint: "Merge providerInstances.opencodex into T3 Code's settings.json; it reloads the file. Loopback needs no key.",
+ build: buildT3CodeClientConfig,
+ format: "json",
+ summarize: summarizeT3Code,
+ validateApply: t3codeApplyRefusal,
+ buildContribution: buildT3CodeContribution,
+ loopbackOnly: true,
+ },
};
-export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[];
+export { EXPORT_CLIENT_IDS } from "./client-ids";
export function isExportClientId(value: string): value is ExportClientId {
return Object.prototype.hasOwnProperty.call(EXPORT_CLIENTS, value);
diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts
index 17a5b4d07f4..1c417c0f946 100644
--- a/src/clients/config-export/contracts.ts
+++ b/src/clients/config-export/contracts.ts
@@ -1,4 +1,6 @@
// Shared client export contracts.
+import type { ExportClientId } from "../client-ids";
+export type { ExportClientId } from "../client-ids";
import { canonicalizeReasoningEfforts } from "../../reasoning-effort";
import type { OcxConfig } from "../../types";
import type { ConfigFormat } from "../../integrations/serialize";
@@ -214,31 +216,6 @@ export interface ExportContext {
/** Namespaced model selector to one of that model's declared reasoning efforts. */
export type DroidReasoningDefaults = Record;
-export type ExportClientId =
- | "qoder"
- | "qoder-cn"
- | "workbuddy"
- | "workbuddy-ai"
- | "opencode"
- | "pi"
- | "omp"
- | "hermes"
- | "openclaw"
- | "kimi"
- | "gajae"
- | "dsh"
- | "mcode"
- | "zcode"
- | "prime"
- | "aside"
- | "raycast"
- | "omo"
- | "cline"
- | "commandcode"
- | "kilo"
- | "droid"
- | "gentle-shell";
-
export interface ExportClientSpec {
id: ExportClientId;
/** Download filename; matches the destination file's own name (003 §5). */
diff --git a/src/clients/config-export/t3code.ts b/src/clients/config-export/t3code.ts
new file mode 100644
index 00000000000..00c703aadb1
--- /dev/null
+++ b/src/clients/config-export/t3code.ts
@@ -0,0 +1,141 @@
+import { homedir } from "node:os";
+import { join, win32 } from "node:path";
+import { PROXY_MARKER } from "../../claude/auth-detect";
+import { isLoopbackHostname, shouldInjectApiAuthHeader } from "../../codex/loopback-target";
+import { resolveApiSurfaceSettings } from "../../protocols/settings";
+import { absoluteClientPath, ClientPathError } from "../client-path";
+import type { ExportContext, ManagedContribution, OpencodeLaunchEnv } from "./contracts";
+import { OPENCODE_PROVIDER_ID } from "./constants";
+import { normalizeExportModels, singleFragment } from "./model-metadata";
+
+/**
+ * T3 Code's data directory: `T3CODE_HOME` when set, otherwise `~/.t3`
+ * (t3code `apps/server/src/os-jank.ts` `resolveBaseDir`). The desktop app and
+ * the `t3` server share it. A relative override is refused because opencodex
+ * and T3 Code would anchor it to different working directories or Windows drives.
+ */
+export function t3codeHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir(), platform: NodeJS.Platform = process.platform): string {
+ const override = env.T3CODE_HOME?.trim();
+ if (!override) return join(home, ".t3");
+ if (platform !== "win32") return absoluteClientPath(override, home, "T3CODE_HOME");
+ const expanded = override === "~" ? home
+ : override.startsWith("~/") || override.startsWith("~\\") ? win32.join(home, override.slice(2)) : override;
+ // isAbsolute also accepts \rooted paths, which still depend on the current drive.
+ if (!/^[A-Za-z]:[\\/]/.test(expanded) && !/^[\\/]{2}[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/.test(expanded)) {
+ throw new ClientPathError(
+ `T3CODE_HOME must be a fully qualified drive or UNC path, or start with ~; "${override}" depends on the working directory or drive, `
+ + "so opencodex and T3 Code would disagree about which file it names.",
+ );
+ }
+ return expanded;
+}
+
+/**
+ * Server settings live in `/userdata/settings.json`; T3 Code watches the file and
+ * reloads it, so a written provider instance appears without a restart.
+ */
+export function t3codeConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
+ return join(t3codeHomeDir(env, home), "userdata", "settings.json");
+}
+
+/** One T3 Code `ProviderInstanceEnvironmentVariable` (t3code `packages/contracts/src/providerInstance.ts`). */
+export interface T3CodeEnvironmentVariable {
+ name: string;
+ value: string;
+ sensitive: false;
+}
+
+/** One T3 Code `CustomModelEntry`: the slug is sent verbatim as the Claude model id. */
+export interface T3CodeCustomModel {
+ slug: string;
+ name: string;
+}
+
+/** The `providerInstances.opencodex` value opencodex owns in T3 Code's settings.json. */
+export interface T3CodeProviderInstance {
+ driver: "claudeAgent";
+ displayName: string;
+ environment: T3CodeEnvironmentVariable[];
+ config: { customModels: T3CodeCustomModel[] };
+}
+
+export interface T3CodeGeneratedConfig {
+ providerInstances: Record;
+}
+
+/**
+ * One `claudeAgent` provider instance that runs the user's own `claude` binary against
+ * the proxy's Messages surface.
+ *
+ * The environment matches a proxy-mode `ocx claude` launch on a loopback bind:
+ * the root origin (Claude Code appends `/v1/messages`) and the non-secret marker as
+ * `ANTHROPIC_AUTH_TOKEN`. `ANTHROPIC_API_KEY` is explicitly empty so a stored Anthropic
+ * key cannot outrank the marker, which is T3 Code's documented router recipe.
+ * `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST` stops a `settings.json` env block in the
+ * user's Claude config from re-pointing this instance away from the proxy.
+ *
+ * Every variable is `sensitive: false` on purpose: T3 Code moves sensitive values into
+ * its secret store and blanks them in settings.json, which would turn our own block into
+ * a foreign edit. None of these values is a credential.
+ *
+ * Models are `customModels` entries keyed by the proxy selector T3 Code sends verbatim
+ * as the Claude model id. The Codex driver needs no block: it lists models through
+ * `codex app-server`, which already reads the routed catalog opencodex writes into
+ * `CODEX_HOME`.
+ */
+function t3codeDocument(ctx: ExportContext): T3CodeGeneratedConfig {
+ const origin = new URL(ctx.baseUrl).origin;
+ const models = normalizeExportModels(ctx.models);
+ const variable = (name: string, value: string): T3CodeEnvironmentVariable => ({ name, value, sensitive: false });
+ return {
+ providerInstances: {
+ [OPENCODE_PROVIDER_ID]: {
+ driver: "claudeAgent",
+ displayName: "opencodex",
+ environment: [
+ variable("ANTHROPIC_BASE_URL", origin),
+ variable("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER),
+ variable("ANTHROPIC_API_KEY", ""),
+ variable("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "1"),
+ ],
+ config: {
+ customModels: models.map(model => ({
+ slug: model.namespaced,
+ name: model.displayName || model.namespaced,
+ })),
+ },
+ },
+ },
+ };
+}
+
+/** Refuses any listener that needs an admission header: T3 Code would send only the marker. */
+export function buildT3CodeClientConfig(ctx: ExportContext): T3CodeGeneratedConfig {
+ if (shouldInjectApiAuthHeader(ctx.config) || !isLoopbackHostname(new URL(ctx.baseUrl).hostname)) {
+ throw new Error("T3 Code's placeholder credential requires a loopback-only listener.");
+ }
+ return t3codeDocument(ctx);
+}
+
+export function summarizeT3Code(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
+ const models = (document as T3CodeGeneratedConfig | undefined)
+ ?.providerInstances?.[OPENCODE_PROVIDER_ID]?.config?.customModels ?? [];
+ return { modelCount: models.length, modelsWithoutLimits: 0 };
+}
+
+/**
+ * Refuses apply and refresh while the Messages surface is closed, explicitly or through
+ * the inherited `claudeCode.enabled`: every turn the instance sends would get a 403.
+ * Disable and Undo stay available.
+ */
+export function t3codeApplyRefusal(ctx: ExportContext): string | null {
+ if (resolveApiSurfaceSettings(ctx.config ?? {}).messages.enabled) return null;
+ return "T3 Code needs the Messages API; turn on Messages on the API page (apiSurfaces.messages.enabled) first.";
+}
+
+export function buildT3CodeContribution(ctx: ExportContext): ManagedContribution {
+ // The writer gates admission before applying. Classification and disable must
+ // still work after the operator changes the proxy to a non-loopback bind.
+ const doc = t3codeDocument(ctx);
+ return singleFragment("t3code", ["providerInstances", OPENCODE_PROVIDER_ID], doc.providerInstances[OPENCODE_PROVIDER_ID]);
+}
diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts
index 9533d5ec58e..5e3377de508 100644
--- a/src/integrations/catalog-refresh.ts
+++ b/src/integrations/catalog-refresh.ts
@@ -12,7 +12,7 @@ import {
/** Refresh only previously connected clients; a refused file never blocks its peers. */
export async function refreshOwnedCatalogIntegrations(
input: Omit,
- clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "gentle-shell", "hermes"],
+ clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "gentle-shell", "hermes", "t3code"],
options: { refreshOnly?: boolean; admit?: () => boolean } = {},
): Promise {
// Client files are shared with the live proxy a sibling instance runs beside; their entries
diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts
index 284b93dbe56..924ca810eef 100644
--- a/src/integrations/mutation-plan.ts
+++ b/src/integrations/mutation-plan.ts
@@ -16,7 +16,8 @@
import { createHash } from "node:crypto";
import { dirname } from "node:path";
import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership";
-import { ClientPathError, EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
+import { ClientPathError } from "../clients/client-path";
+import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
import { OPENCODE_PROVIDER_ID } from "../clients/config-export/constants";
import {
ZCODE_STORE_MODEL_RULES_PATH,
@@ -172,6 +173,7 @@ const CLIENT_MANAGED_PATHS = {
],
kilo: [["provider", OPENCODE_PROVIDER_ID]],
droid: [["customModels", DYNAMIC_SEGMENT]],
+ t3code: [["providerInstances", OPENCODE_PROVIDER_ID]],
} satisfies Record;
/** Not a configuration surface. Exported so a parity case can compare it against the shipped clients. */
diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts
index 4218e6e98b2..611662c3041 100644
--- a/src/integrations/registry.ts
+++ b/src/integrations/registry.ts
@@ -16,11 +16,13 @@ import { readPath } from "./merge";
import type { OwnershipRecord } from "./ownership";
import type { IntegrationIO } from "./config-io";
import { dirname, join } from "node:path";
+import { ClientPathError } from "../clients/client-path";
import {
- ClientPathError,
buildDroidContribution,
qoderConfigPath,
qoderHomeDir,
+ t3codeConfigPath,
+ t3codeHomeDir,
clineConfigPath,
clineSettingsDir,
droidConfigPath,
@@ -518,6 +520,13 @@ export const INTEGRATION_CLIENTS: Record gentleShellPaths(env, home, io),
unresolvedPathHint: (_env, home = homedir()) => join(home, ".gentle-shell", "agent", "models.json"),
},
+ t3code: {
+ id: "t3code",
+ configPath: (env = process.env, home = homedir()) => t3codeConfigPath(env, home),
+ // The state directory, not `~/.t3`: the server creates `userdata` on its first start,
+ // while `~/.t3` alone can be left behind by the installer's `bin`.
+ detectDir: (env = process.env, home = homedir()) => join(t3codeHomeDir(env, home), "userdata"),
+ },
};
export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts
index 46c61fdbfd7..ff3d31515eb 100644
--- a/src/server/management/config-routes.ts
+++ b/src/server/management/config-routes.ts
@@ -334,8 +334,8 @@ export async function syncEnabledClientIntegrations(
config,
port,
}, unattended
- ? ["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes"]
- : ["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes"],
+ ? ["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes", "t3code"]
+ : ["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes", "t3code"],
...unattended ? [{ refreshOnly: true, admit }] as const : []));
return out;
diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts
index 515f524ce1a..b5cd9520870 100644
--- a/src/server/management/model-routes.ts
+++ b/src/server/management/model-routes.ts
@@ -150,8 +150,8 @@ import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerS
import type { PersistedUsageAttempt } from "../../usage/log";
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO, corsHeaders } from "../auth-cors";
import { applySystemEnvToggle } from "../system-env";
+import { ClientPathError } from "../../clients/client-path";
import {
- ClientPathError,
EXPORT_CLIENTS,
EXPORT_CLIENT_IDS,
OPENCODE_PROVIDER_ID,
@@ -561,9 +561,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise[`clients/qoder.md`](clients/qoder.md) |
+| `src/clients/` | [`clients/integrations.md`](clients/integrations.md)
[`clients/qoder.md`](clients/qoder.md)
[`clients/t3code.md`](clients/t3code.md) |
| `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`subagent-capacity.md`](subagent-capacity.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) |
| `src/codex/account-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) |
| `src/codex/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) |
@@ -279,7 +280,7 @@ A source area can be described by more than one doc, because these docs are orga
| `src/grok/` | [`runtime.md`](runtime.md) |
| `src/images/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md) |
| `src/index.ts` | [`runtime.md`](runtime.md) |
-| `src/integrations/` | [`clients/integrations.md`](clients/integrations.md)
[`clients/hermes.md`](clients/hermes.md)
[`clients/qoder.md`](clients/qoder.md) |
+| `src/integrations/` | [`clients/integrations.md`](clients/integrations.md)
[`clients/hermes.md`](clients/hermes.md)
[`clients/qoder.md`](clients/qoder.md)
[`clients/t3code.md`](clients/t3code.md) |
| `src/lab/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) |
| `src/lib/` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-spend.md`](transports/responses-spend.md)
[`transports/inventory.md`](transports/inventory.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/integrations.md`](clients/integrations.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) |
| `src/lib/account-auto-pause-response.ts` | [`account-auto-pause.md`](account-auto-pause.md) |
diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md
index ba9150f4a5a..89637149d80 100644
--- a/structure/clients/integrations.md
+++ b/structure/clients/integrations.md
@@ -2,7 +2,7 @@
Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts.
-The client-integration subsystem writes one generated OpenCodex provider contribution into a third-party client's existing config without taking ownership of the rest of that file. Its core promise is reversibility: apply snapshots first, writes atomically, records exactly what it owns, and refuses refresh, disable, or restore when the current file cannot be classified safely. Qoder specifics live in [`clients/qoder.md`](qoder.md). Managed client targets are inspected without following a final symbolic link, and their atomic replacement addresses the named directory entry rather than resolving that link again at commit. Uninstall runs the same coordinated disable path for every strict ownership record before removing OpenCodex state, including the legacy Aside owner and every child profile store under `integrations/aside-profiles//`. `src/cli/uninstall-integrations.ts` validates all stores and registered Aside paths before mutation; `src/integrations/aside-profile-context.ts` supplies the guarded child stores. An unreadable record, conflict, or failed compensation aborts config removal and retains remaining recovery state. Earlier successful disables are not rolled back; failed compensation can leave an intermediate client file. Inspect the reported client files and retained snapshots before retrying; preserved recovery state does not prove restoration completed.
+The client-integration subsystem writes one generated OpenCodex provider contribution into a third-party client's existing config without taking ownership of the rest of that file. Its core promise is reversibility: apply snapshots first, writes atomically, records exactly what it owns, and refuses refresh, disable, or restore when the current file cannot be classified safely. Qoder specifics live in [`clients/qoder.md`](qoder.md); T3 Code specifics live in [`clients/t3code.md`](t3code.md). Managed client targets are inspected without following a final symbolic link, and their atomic replacement addresses the named directory entry rather than resolving that link again at commit. Uninstall runs the same coordinated disable path for every strict ownership record before removing OpenCodex state, including the legacy Aside owner and every child profile store under `integrations/aside-profiles//`. `src/cli/uninstall-integrations.ts` validates all stores and registered Aside paths before mutation; `src/integrations/aside-profile-context.ts` supplies the guarded child stores. An unreadable record, conflict, or failed compensation aborts config removal and retains remaining recovery state. Earlier successful disables are not rolled back; failed compensation can leave an intermediate client file. Inspect the reported client files and retained snapshots before retrying; preserved recovery state does not prove restoration completed.
> Decision record: [ADR-0107](../decisions/ADR-0107-uninstall-integration-recovery.md)
@@ -15,7 +15,7 @@ parsing and ownership rules below.
| Module | Responsibility |
| --- | --- |
-| `src/clients/config-export.ts` | Pure per-client config builders and the exact managed fragments each client receives. It never writes files. |
+| `src/clients/config-export.ts` | Pure per-client config builders and exact managed fragments; never writes files. The dependency-free `src/clients/client-ids.ts` roster defines the exhaustive `ExportClientId` union and `EXPORT_CLIENT_IDS`; CLI usage and its client count derive from it. |
| `src/clients/reasoning-controls.ts` | Ingress compatibility for effort-enum clients: `resolveThinkingEnabled(provider, modelId, options)` decodes toggle aliases into `thinkingEnabled` before strength policy runs, preserving an explicit thinking choice and canonical provider defaults/maps. `normalizeThinkingOptions` removes the alias when real effort is unsupported. |
| `src/integrations/registry.ts` | Canonical config/detection paths, current-provider-store declarations, source-preserving YAML declarations, writer-lock behavior, and client IDs. |
| `src/integrations/target.ts` | Which file one operation reads, writes and records, and whether a write there reaches the client. |
diff --git a/structure/clients/t3code.md b/structure/clients/t3code.md
new file mode 100644
index 00000000000..380e739e6dc
--- /dev/null
+++ b/structure/clients/t3code.md
@@ -0,0 +1,47 @@
+# T3 Code Client Integration
+
+T3 Code follows the shared [client-integration ownership contract](integrations.md); this page records only what is specific to it.
+
+## T3 Code settings ownership
+
+T3 Code (`pingdotgg/t3code`) keeps server settings in JSON at `userdata/settings.json` under
+`T3CODE_HOME`, default `~/.t3`. `t3codeHomeDir` in `src/clients/config-export/t3code.ts` resolves
+that directory once for export, writing and detection. POSIX overrides use `absoluteClientPath`;
+Windows overrides must have a fully qualified drive or UNC root after tilde expansion against
+the effective home. Drive-relative and drive-less rooted paths refuse with `ClientPathError`,
+because the proxy and T3 Code can run on different drives. Detection reads `/userdata`,
+which the T3 Code server creates on its first start.
+
+The managed contribution is one fragment, `providerInstances.opencodex`: a T3 Code provider
+instance with `driver: "claudeAgent"`. Its `environment` carries the proxy origin as
+`ANTHROPIC_BASE_URL` (no `/v1`; Claude Code appends `/v1/messages`), the non-secret
+`PROXY_MARKER` from `src/claude/auth-detect.ts` as `ANTHROPIC_AUTH_TOKEN`, an empty
+`ANTHROPIC_API_KEY`, and `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1`. That is the proxy-mode
+`ocx claude` environment on a loopback bind. Every variable is written `sensitive: false`,
+because T3 Code moves sensitive values into its secret store and blanks them in the file. None of
+these values is a credential. `config.customModels` holds one `{ slug, name }` entry per
+normalized export model, Fast rows included. The slug is the proxy selector, which T3 Code passes
+verbatim as the Claude model id and the Messages surface resolves like any other selector.
+
+No Codex block is written. T3 Code lists Codex models through `codex app-server`, which reads the
+routed catalog the Codex integration injects into `CODEX_HOME`, so Codex threads route only while
+that integration is enabled and T3 Code runs Codex with the same home.
+
+Every Claude turn the instance sends hits the Messages surface. `t3codeApplyRefusal`, the spec's
+`validateApply`, refuses apply and refresh while `resolveApiSurfaceSettings` reports that surface
+closed, whether by an explicit `apiSurfaces.messages.enabled` or the inherited
+`claudeCode.enabled`. Disable and Undo stay available.
+
+The fragment declares no refreshable paths. T3 Code rewrites an instance whenever a user edits it
+in its settings UI, by marking a variable sensitive, adding a custom model or toggling the
+instance. Each of those classifies as a foreign edit and refuses refresh and disable until the
+operator replaces or restores the block. Exports reject a listener that needs an admission header,
+because T3 Code would send only the marker. The writer's admission gate protects apply while still
+permitting cleanup after a bind change. `ocx export --client t3code` asks the live
+`/api/client-config` route, like Qoder, Raycast and Factory Droid. Owned-catalog refresh in
+`ocx sync`, `/api/sync` and catalog refresh includes T3 Code. The managed path template lives in
+`src/integrations/mutation-plan.ts`, and `tests/clients/t3code-client.test.ts` covers the
+contract. `tests/clients/t3code-client-lifecycle.test.ts` also covers relocated-home
+detection, apply and Undo, cross-platform Windows drive/UNC and tilde admission,
+shared `ClientPathError` refusal, owned cleanup after
+the bind changes to require admission, and the Messages-surface refusal.
diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md
index ff7dbf76deb..7f7de665540 100644
--- a/structure/dashboard-and-usage.md
+++ b/structure/dashboard-and-usage.md
@@ -21,7 +21,7 @@ Codex account panels expose no Spark quota toggle or setting and retain quota re
Dashboard localization uses the English `gui/src/i18n/en.ts` catalog as the complete key and
placeholder contract. Every registered locale, including Vietnamese and Brazilian Portuguese (`pt`, HTML `pt-BR`), supplies the same keys;
locale-specific Compatibility Lab, log-guard, routing, vision, status-code, and quota-formatting
-maps remain total rather than silently falling back to English. The Dashboard registers Qoder for export and managed integration under the [Qoder settings ownership contract](clients/qoder.md#qoder-settings-ownership).
+maps remain total rather than silently falling back to English. The Dashboard registers Qoder for export and managed integration under the [Qoder settings ownership contract](clients/qoder.md#qoder-settings-ownership). T3 Code is registered the same way under the [T3 Code settings ownership contract](clients/t3code.md#t3-code-settings-ownership).
The Models catalog names three distinct delivery states. A successful management mutation confirms
only that the catalog is saved on the hub. `gui/src/api-targets.ts` carries the local machine's
diff --git a/structure/manifest.json b/structure/manifest.json
index 0ee76914fb1..6977dd59553 100644
--- a/structure/manifest.json
+++ b/structure/manifest.json
@@ -931,6 +931,16 @@
"src/integrations/"
]
},
+ {
+ "path": "clients/t3code.md",
+ "tier": 5,
+ "title": "T3 Code Client Integration",
+ "scope": "T3 Code settings.json Claude provider-instance ownership, proxy environment, custom models and loopback marker rules.",
+ "documents": [
+ "src/clients/",
+ "src/integrations/"
+ ]
+ },
{
"path": "desktop-shell.md",
"tier": 6,
diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts
index 1203c1dc52e..bb1352e8fc7 100644
--- a/tests/cli/cli-dispatch.test.ts
+++ b/tests/cli/cli-dispatch.test.ts
@@ -182,7 +182,7 @@ describe("dispatchCommand exit codes", () => {
expect(code).toBe(0);
expect(order).toEqual(["catalog", "refresh"]);
expect(refresh).toHaveBeenCalledTimes(1);
- expect(refresh.mock.calls[0]![1]).toEqual(["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes"]);
+ expect(refresh.mock.calls[0]![1]).toEqual(["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes", "t3code"]);
} finally {
sync.mockRestore(); refresh.mockRestore(); aside.mockRestore(); log.mockRestore();
}
diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts
index 5dd335c2564..c554320ed3f 100644
--- a/tests/clients/integrations-state.test.ts
+++ b/tests/clients/integrations-state.test.ts
@@ -796,7 +796,7 @@ describe("installation detection is independent of config state", () => {
describe("the loopback-only set is one fact, read through one seam", () => {
test("identifies exactly the registered loopback-only clients", () => {
const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id));
- expect(loopbackOnly).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid", "gentle-shell"]);
+ expect(loopbackOnly).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid", "gentle-shell", "t3code"]);
});
test("the registry restates nothing — it reads the export spec", () => {
diff --git a/tests/clients/sync-client-integrations-unattended.test.ts b/tests/clients/sync-client-integrations-unattended.test.ts
index 3216f99c2f5..46882965d3c 100644
--- a/tests/clients/sync-client-integrations-unattended.test.ts
+++ b/tests/clients/sync-client-integrations-unattended.test.ts
@@ -79,7 +79,7 @@ describe("unattended native and file client admission", () => {
expect(await syncEnabledClientIntegrations(12345, config, deps(fetch), unattended)).toEqual([]);
expect(fetch).not.toHaveBeenCalled(); expect(writer).not.toHaveBeenCalled();
const args = refresh.mock.calls[0]!;
- expect(args[1]).toEqual(["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes"]);
+ expect(args[1]).toEqual(["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes", "t3code"]);
expect(args[2]?.refreshOnly).toBe(true); expect(args[2]?.admit?.()).toBe(true);
config.runtimeRole = "hub"; persist(); expect(args[2]?.admit?.()).toBe(false);
config.unauthenticatedLoopbackListener = { enabled: true, port: 12346 }; persist();
diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts
index fe6dd5c94de..e5534a264d7 100644
--- a/tests/clients/sync-client-integrations.test.ts
+++ b/tests/clients/sync-client-integrations.test.ts
@@ -65,7 +65,7 @@ describe("ocx sync fans out to enabled native clients and owned file integration
expect(fn).toContain("grokIntegrationEnabled(config)");
expect(fn).toContain("claudeDesktopIntegrationEnabled(config)");
- expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes"]');
+ expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes", "t3code"]');
expect(fn).toContain("refreshOwnedCatalogIntegrations");
// Native clients keep their catches; the owned catalog helper isolates file clients.
expect(fn.match(/catch \(error\)/g)?.length).toBe(2);
@@ -910,7 +910,7 @@ test("the direct ocx sync command refreshes owned clients including OpenCode and
expect(command).toContain("refreshOwnedCatalogIntegrations");
// Command Code joined this fan-out with its integration: a sync must refresh its
// provider block too, or the client keeps a stale catalog until the next apply.
- expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes"]');
+ expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "hermes", "t3code"]');
expect(command).toContain("refreshAsideProfilesThroughServer");
expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations"));
expect(command).toContain('synced.status !== "refused"');
diff --git a/tests/clients/t3code-client-lifecycle.test.ts b/tests/clients/t3code-client-lifecycle.test.ts
new file mode 100644
index 00000000000..45ba1287dd9
--- /dev/null
+++ b/tests/clients/t3code-client-lifecycle.test.ts
@@ -0,0 +1,129 @@
+import { afterEach, expect, test } from "bun:test";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join, win32 } from "node:path";
+import { ClientPathError } from "../../src/clients/client-path";
+import { t3codeConfigPath, t3codeHomeDir } from "../../src/clients/config-export/t3code";
+import { INTEGRATION_CLIENTS } from "../../src/integrations/registry";
+import { readIntegrationState } from "../../src/integrations/state";
+import { createIntegrationStateStore } from "../../src/integrations/store";
+import { applyIntegration, disableIntegration, refreshIntegration, restoreIntegration, type IntegrationWriteInput } from "../../src/integrations/writer";
+import type { OcxConfig } from "../../src/types";
+import { removeTreeWithRetry } from "../helpers/remove-tree";
+
+const roots: string[] = [];
+afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); });
+
+function fixture() {
+ const home = mkdtempSync(join(tmpdir(), "ocx-t3code-lifecycle-"));
+ roots.push(home);
+ const env = { T3CODE_HOME: "~/relocated-t3" };
+ const path = join(home, "relocated-t3", "userdata", "settings.json");
+ mkdirSync(dirname(path), { recursive: true });
+ const original = { theme: "dark", providerInstances: { personal: { driver: "codex" } } };
+ const before = JSON.stringify(original, null, 2) + "\n";
+ writeFileSync(path, before);
+ const input: IntegrationWriteInput = {
+ clientId: "t3code", home, env, port: 10100,
+ config: {
+ port: 10100, hostname: "127.0.0.1", defaultProvider: "mock",
+ providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } },
+ } as OcxConfig,
+ models: [{ namespaced: "mock/a", provider: "mock", id: "a" }],
+ store: createIntegrationStateStore(join(home, "store")),
+ };
+ return { input, path, before, original };
+}
+
+test("T3CODE_HOME binds detection, apply and Undo to the same relocated file", () => {
+ const f = fixture();
+ expect(t3codeConfigPath(f.input.env, f.input.home)).toBe(f.path);
+ expect(INTEGRATION_CLIENTS.t3code.detectDir(f.input.env, f.input.home)).toBe(dirname(f.path));
+ expect(readIntegrationState(f.input)).toMatchObject({ state: "absent", installed: true, configPath: f.path });
+ expect(applyIntegration(f.input)).toMatchObject({ ok: true, changed: true });
+ expect(JSON.parse(readFileSync(f.path, "utf8")).providerInstances.personal).toEqual(f.original.providerInstances.personal);
+ expect(existsSync(join(f.input.home!, ".t3", "userdata", "settings.json"))).toBe(false);
+ const opId = f.input.store!.listOperations("t3code")[0]!.opId;
+ expect(restoreIntegration({ ...f.input, opId }).ok).toBe(true);
+ expect(readFileSync(f.path, "utf8")).toBe(f.before);
+});
+
+test("a bind change refuses refresh but retains classification and owned cleanup", () => {
+ const f = fixture();
+ expect(applyIntegration(f.input).ok).toBe(true);
+ const applied = readFileSync(f.path, "utf8");
+ const remote = { ...f.input, config: { ...f.input.config, hostname: "0.0.0.0" } };
+ expect(readIntegrationState(remote).state).toBe("current");
+ expect(refreshIntegration(remote)).toMatchObject({ ok: false, reason: "non_loopback" });
+ expect(readFileSync(f.path, "utf8")).toBe(applied);
+ expect(disableIntegration(remote).ok).toBe(true);
+ expect(JSON.parse(readFileSync(f.path, "utf8"))).toEqual(f.original);
+});
+
+test("a relative T3CODE_HOME throws the shared leaf error before touching a file", () => {
+ const f = fixture();
+ expect(() => t3codeConfigPath({ T3CODE_HOME: "relative/t3" }, f.input.home)).toThrow(ClientPathError);
+ expect(readFileSync(f.path, "utf8")).toBe(f.before);
+});
+
+for (const override of ["\\shared\\t3", "/shared/t3", "C:relative", "relative/t3", "\\\\server"]) {
+ test(`Windows T3CODE_HOME refuses an unqualified path: ${override}`, () => {
+ expect(() => t3codeHomeDir({ T3CODE_HOME: override }, "D:\\Users\\test", "win32")).toThrow(ClientPathError);
+ expect(() => t3codeHomeDir({ T3CODE_HOME: override }, "D:\\Users\\test", "win32")).toThrow("T3CODE_HOME");
+ });
+}
+
+for (const override of ["C:\\abs", "C:/abs", "\\\\server\\share\\t3", "//server/share/t3"]) {
+ test(`Windows T3CODE_HOME accepts a fully qualified path: ${override}`, () => {
+ expect(t3codeHomeDir({ T3CODE_HOME: override }, "D:\\Users\\test", "win32")).toBe(override);
+ });
+}
+
+for (const [override, expected] of [["~", "D:\\Users\\test"], ["~/t3", win32.join("D:\\Users\\test", "t3")], ["~\\t3", win32.join("D:\\Users\\test", "t3")]] as const) {
+ test(`Windows T3CODE_HOME expands ${override} against the effective home`, () => {
+ expect(t3codeHomeDir({ T3CODE_HOME: override }, "D:\\Users\\test", "win32")).toBe(expected);
+ });
+}
+
+test("Windows T3CODE_HOME refuses a tilde expanded against a drive-less home", () => {
+ expect(() => t3codeHomeDir({ T3CODE_HOME: "~" }, "\\Users\\test", "win32")).toThrow(ClientPathError);
+});
+
+test("POSIX T3CODE_HOME keeps rooted paths and tilde expansion", () => {
+ expect(t3codeHomeDir({ T3CODE_HOME: "/shared/t3" }, "/home/test", "linux")).toBe("/shared/t3");
+ expect(t3codeHomeDir({ T3CODE_HOME: "~/t3" }, "/home/test", "linux")).toBe(join("/home/test", "t3"));
+ expect(() => t3codeHomeDir({ T3CODE_HOME: "relative/t3" }, "/home/test", "linux")).toThrow(ClientPathError);
+});
+
+test("an unqualified T3CODE_HOME surfaces as unsafe without changing client bytes", () => {
+ const f = fixture();
+ const overrides = process.platform === "win32" ? ["\\shared\\t3", "/shared/t3", "C:relative"] : ["relative/t3"];
+ for (const override of overrides) {
+ const input = { ...f.input, env: { T3CODE_HOME: override } };
+ expect(readIntegrationState(input)).toMatchObject({ state: "unsafe", reason: "unresolvable-path" });
+ for (const write of [applyIntegration, refreshIntegration]) {
+ expect(write(input)).toMatchObject({ ok: false, reason: "unsafe", message: expect.stringContaining("T3CODE_HOME") });
+ }
+ expect(readFileSync(f.path, "utf8")).toBe(f.before);
+ expect(f.input.store!.listOperations("t3code")).toEqual([]);
+ }
+});
+
+test("a closed Messages surface refuses apply and refresh but keeps disable", () => {
+ const f = fixture();
+ const explicit = { ...f.input, config: { ...f.input.config, apiSurfaces: { messages: { enabled: false } } } as OcxConfig };
+ const inherited = { ...f.input, config: { ...f.input.config, claudeCode: { enabled: false } } as OcxConfig };
+ for (const closed of [explicit, inherited]) {
+ expect(applyIntegration(closed)).toMatchObject({ ok: false, reason: "unsafe", message: expect.stringContaining("Messages API") });
+ expect(readFileSync(f.path, "utf8")).toBe(f.before);
+ }
+ const reopened = { ...inherited, config: { ...inherited.config, apiSurfaces: { messages: { enabled: true } } } as OcxConfig };
+ expect(applyIntegration(reopened).ok).toBe(true);
+ const applied = readFileSync(f.path, "utf8");
+ for (const closed of [explicit, inherited]) {
+ expect(refreshIntegration(closed)).toMatchObject({ ok: false, reason: "unsafe" });
+ expect(readFileSync(f.path, "utf8")).toBe(applied);
+ }
+ expect(disableIntegration(inherited).ok).toBe(true);
+ expect(JSON.parse(readFileSync(f.path, "utf8"))).toEqual(f.original);
+});
diff --git a/tests/clients/t3code-client.test.ts b/tests/clients/t3code-client.test.ts
new file mode 100644
index 00000000000..e928acccd2e
--- /dev/null
+++ b/tests/clients/t3code-client.test.ts
@@ -0,0 +1,175 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { buildT3CodeClientConfig, t3codeConfigPath, type ExportContext } from "../../src/clients/config-export";
+import { PROXY_MARKER } from "../../src/claude/auth-detect";
+import { handleExportCommand } from "../../src/cli/export-command";
+import { INTEGRATION_CLIENTS } from "../../src/integrations/registry";
+import { createIntegrationStateStore } from "../../src/integrations/store";
+import { applyIntegration, disableIntegration, refreshIntegration, restoreIntegration, type IntegrationWriteInput } from "../../src/integrations/writer";
+import { handleManagementAPI } from "../../src/server/management-api";
+import type { OcxConfig } from "../../src/types";
+import { removeTreeWithRetry } from "../helpers/remove-tree";
+
+const config = {
+ port: 10100, hostname: "127.0.0.1", defaultProvider: "mock",
+ providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } },
+} as OcxConfig;
+const context: ExportContext = {
+ baseUrl: "http://127.0.0.1:10100/v1", config,
+ models: [
+ { namespaced: "mock/b", provider: "mock", id: "b" },
+ { namespaced: "mock/a", provider: "mock", id: "a", displayName: "Model A", fastRowAvailable: true },
+ ],
+};
+const roots: string[] = [];
+afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); });
+
+/** A settings.json T3 Code wrote itself: its own Codex account beside unrelated settings. */
+function fixture() {
+ const home = mkdtempSync(join(tmpdir(), "ocx-t3code-test-"));
+ roots.push(home);
+ const path = t3codeConfigPath({}, home);
+ mkdirSync(dirname(path), { recursive: true });
+ const original = {
+ favorites: [{ instanceId: "codex", model: "gpt-5.5" }],
+ providerInstances: { codex_personal: { driver: "codex", config: { homePath: "~/.codex", shadowHomePath: "~/.codex_personal" } } },
+ };
+ const before = JSON.stringify(original, null, 2) + "\n";
+ writeFileSync(path, before);
+ const input: IntegrationWriteInput = {
+ clientId: "t3code", config, port: 10100, models: context.models,
+ home, env: {}, store: createIntegrationStateStore(join(home, "store")),
+ };
+ return { path, original, before, input, read: () => JSON.parse(readFileSync(path, "utf8")) };
+}
+
+describe("T3 Code client integration", () => {
+ test("the provider instance routes T3 Code's Claude driver through the proxy origin with a non-secret marker", () => {
+ const instance = buildT3CodeClientConfig(context).providerInstances.opencodex;
+ expect(instance.driver).toBe("claudeAgent");
+ // Claude Code appends /v1/messages itself, so the origin must not carry /v1.
+ expect(instance.environment).toEqual([
+ { name: "ANTHROPIC_BASE_URL", value: "http://127.0.0.1:10100", sensitive: false },
+ { name: "ANTHROPIC_AUTH_TOKEN", value: PROXY_MARKER, sensitive: false },
+ { name: "ANTHROPIC_API_KEY", value: "", sensitive: false },
+ { name: "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", value: "1", sensitive: false },
+ ]);
+ // Slugs are the proxy selectors T3 Code sends verbatim, in normalized order with Fast rows.
+ expect(instance.config.customModels).toEqual([
+ { slug: "mock/a", name: "Model A" },
+ { slug: "mock/a--fast", name: "Model A Fast" },
+ { slug: "mock/b", name: "mock/b" },
+ ]);
+ });
+
+ test("refuses any listener that would need an admission header", () => {
+ expect(() => buildT3CodeClientConfig({ ...context, config: { ...config, hostname: "0.0.0.0" } })).toThrow("loopback");
+ expect(() => buildT3CodeClientConfig({ ...context, baseUrl: "http://192.168.1.4:10100/v1" })).toThrow("loopback");
+ // The dedicated unauthenticated listener is what makes a wildcard bind exportable.
+ expect(buildT3CodeClientConfig({
+ ...context,
+ baseUrl: "http://127.0.0.1:10237/v1",
+ config: { ...config, hostname: "0.0.0.0", unauthenticatedLoopbackListener: { enabled: true, port: 10237 } },
+ }).providerInstances.opencodex.environment[0]!.value).toBe("http://127.0.0.1:10237");
+ });
+
+ test("T3CODE_HOME relocates the settings file and detection, and a relative value is refused", () => {
+ const home = join(tmpdir(), "ocx-t3code-path-home");
+ const relocated = join(tmpdir(), "ocx-t3code-path-override");
+ expect(t3codeConfigPath({ T3CODE_HOME: relocated }, home)).toBe(join(relocated, "userdata", "settings.json"));
+ expect(t3codeConfigPath({ T3CODE_HOME: "~/t3-work" }, home)).toBe(join(home, "t3-work", "userdata", "settings.json"));
+ expect(INTEGRATION_CLIENTS.t3code.detectDir({}, "/home/test")).toBe(join("/home/test", ".t3", "userdata"));
+ expect(() => t3codeConfigPath({ T3CODE_HOME: "relative/t3" }, "/home/test")).toThrow("T3CODE_HOME");
+ });
+
+ test("apply and refresh keep T3 Code's own instances; disable restores the file it wrote", () => {
+ const f = fixture();
+ expect(applyIntegration(f.input).ok).toBe(true);
+ const applied = f.read();
+ expect(applied.favorites).toEqual(f.original.favorites);
+ expect(applied.providerInstances.codex_personal).toEqual(f.original.providerInstances.codex_personal);
+ expect(applied.providerInstances.opencodex.config.customModels.map((model: { slug: string }) => model.slug))
+ .toEqual(["mock/a", "mock/a--fast", "mock/b"]);
+
+ const refreshed = refreshIntegration({ ...f.input, models: [{ namespaced: "mock/c", provider: "mock", id: "c" }] });
+ expect(refreshed.ok).toBe(true);
+ expect(f.read().providerInstances.opencodex.config.customModels).toEqual([{ slug: "mock/c", name: "mock/c" }]);
+ expect(f.read().providerInstances.codex_personal).toEqual(f.original.providerInstances.codex_personal);
+
+ expect(disableIntegration({ ...f.input, models: [{ namespaced: "mock/c", provider: "mock", id: "c" }] }).ok).toBe(true);
+ expect(f.read()).toEqual(f.original);
+ });
+
+ test("an edit T3 Code makes to our instance is a conflict, never silently overwritten", () => {
+ const f = fixture();
+ expect(applyIntegration(f.input).ok).toBe(true);
+ // Marking a variable sensitive moves its value into T3 Code's secret store and blanks it here.
+ const edited = f.read();
+ edited.providerInstances.opencodex.environment[1] = { name: "ANTHROPIC_AUTH_TOKEN", value: "", sensitive: true, valueRedacted: true };
+ writeFileSync(f.path, JSON.stringify(edited, null, 2) + "\n");
+ const refreshed = refreshIntegration(f.input);
+ expect(refreshed.ok).toBe(false);
+ expect(f.read()).toEqual(edited);
+ expect(disableIntegration(f.input).ok).toBe(false);
+ expect(f.read()).toEqual(edited);
+ });
+
+ test("restore recovers exact prior bytes, and a non-loopback apply writes nothing", () => {
+ const f = fixture();
+ const refused = applyIntegration({ ...f.input, config: { ...config, hostname: "0.0.0.0" } });
+ expect(refused.ok).toBe(false);
+ if (!refused.ok) expect(refused.reason).toBe("non_loopback");
+ expect(readFileSync(f.path, "utf8")).toBe(f.before);
+ expect(applyIntegration(f.input).ok).toBe(true);
+ const opId = f.input.store!.listOperations("t3code")[0]!.opId;
+ expect(restoreIntegration({ ...f.input, opId }).ok).toBe(true);
+ expect(readFileSync(f.path, "utf8")).toBe(f.before);
+ });
+
+ test("the export CLI asks the live server, which refuses a wildcard bind without a loopback listener", async () => {
+ const f = fixture();
+ const oldHome = process.env.OPENCODEX_HOME;
+ const oldCodexHome = process.env.CODEX_HOME;
+ process.env.OPENCODEX_HOME = join(f.input.home!, "ocx");
+ process.env.CODEX_HOME = join(f.input.home!, "codex");
+ mkdirSync(process.env.CODEX_HOME, { recursive: true });
+ const live = (overrides: Partial) => ({
+ ...config,
+ providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1", liveModels: false, models: ["a"] } },
+ ...overrides,
+ }) as OcxConfig;
+ let liveConfig = live({ hostname: "0.0.0.0", unauthenticatedLoopbackListener: { enabled: true, port: 10237 } });
+ const server = Bun.serve({
+ port: 0,
+ fetch: async req => await handleManagementAPI(req, new URL(req.url), liveConfig) ?? new Response("not found", { status: 404 }),
+ });
+ const log = console.log;
+ const error = console.error;
+ console.log = () => {};
+ console.error = () => {};
+ try {
+ const output = join(f.input.home!, "t3code-settings.json");
+ // The saved config contradicts the live listener port; the live server must win.
+ const deps = { baseUrl: `http://127.0.0.1:${server.port}`, configImpl: () => live({ unauthenticatedLoopbackListener: { enabled: true, port: 10999 } }) };
+ expect(await handleExportCommand(["--client", "t3code", "--out", output], deps)).toBe(0);
+ const exported = JSON.parse(readFileSync(output, "utf8"));
+ expect(exported.providerInstances.opencodex.environment[0].value).toBe("http://127.0.0.1:10237");
+ const before = readFileSync(output, "utf8");
+ liveConfig = live({ hostname: "0.0.0.0" });
+ expect(await handleExportCommand(["--client", "t3code", "--out", output, "--force"], deps)).not.toBe(0);
+ expect(readFileSync(output, "utf8")).toBe(before);
+ expect(existsSync(f.path)).toBe(true);
+ expect(readFileSync(f.path, "utf8")).toBe(f.before);
+ } finally {
+ console.log = log;
+ console.error = error;
+ server.stop(true);
+ if (oldHome === undefined) delete process.env.OPENCODEX_HOME;
+ else process.env.OPENCODEX_HOME = oldHome;
+ if (oldCodexHome === undefined) delete process.env.CODEX_HOME;
+ else process.env.CODEX_HOME = oldCodexHome;
+ }
+ });
+});
diff --git a/tests/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts
index 33b393b40c6..68f5e35930d 100644
--- a/tests/config/client-config-export-new-clients.test.ts
+++ b/tests/config/client-config-export-new-clients.test.ts
@@ -64,7 +64,7 @@ describe("no secret reaches a client config", () => {
// credential wiring is deliberately deferred from those initial generated
// integrations -- omo and Gentle Shell reuse Pi's builder, which emits no headers at all.
const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly);
- expect(loopbackOnly).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid", "gentle-shell"]);
+ expect(loopbackOnly).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid", "gentle-shell", "t3code"]);
});
test("every client that is not loopback-only carries the header on a remote bind", () => {
@@ -85,7 +85,7 @@ describe("no secret reaches a client config", () => {
test("even a non-loopback bind never serializes the key", () => {
for (const id of EXPORT_CLIENT_IDS) {
- if (id === "qoder" || id === "qoder-cn" || id === "workbuddy" || id === "workbuddy-ai") {
+ if (id === "t3code" || id === "qoder" || id === "qoder-cn" || id === "workbuddy" || id === "workbuddy-ai") {
expect(() => buildClientConfigText(id, ctx(REMOTE))).toThrow("loopback");
continue;
}
diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts
index 81bef88f1e9..5cf380d8e33 100644
--- a/tests/config/client-config-export.test.ts
+++ b/tests/config/client-config-export.test.ts
@@ -857,8 +857,8 @@ describe("hub-resolved Fast exports", () => {
});
describe("EXPORT_CLIENTS registry", () => {
- test("covers exactly the twenty-three file-toggle clients", () => {
- expect(EXPORT_CLIENT_IDS).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"]);
+ test("covers exactly the twenty-four file-toggle clients", () => {
+ expect(EXPORT_CLIENT_IDS).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell", "t3code"]);
for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true);
// The exception clients keep their own surfaces and are not export clients.
expect(isExportClientId("claude-desktop")).toBe(false);
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index f99b47f5b81..1f13c32b4a7 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -1,4 +1,6 @@
{ "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses",
+ "t3code-client.test.ts": "clients",
+ "t3code-client-lifecycle.test.ts": "clients",
"fix-jsonc-cr-comments.test.ts": "lib",
"fix-jsonc-empty-comma.test.ts": "lib",
"fix-search-frame-shapes.test.ts": "web-search",
diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts
index f2d3830c12f..ad5e947cb55 100644
--- a/tests/gui/integrations-invariants.test.ts
+++ b/tests/gui/integrations-invariants.test.ts
@@ -99,7 +99,7 @@ describe("the client registries cannot drift apart", () => {
const guiRouting = await import("../../gui/src/app-routing");
const expected = [...EXPORT_CLIENT_IDS].sort();
- expect(expected).toHaveLength(23);
+ expect(expected).toHaveLength(24);
expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected);
expect([...gui.CLIENTS].sort()).toEqual(expected);
@@ -110,7 +110,7 @@ describe("the client registries cannot drift apart", () => {
// remaining per-page Record maps are enforced by the GUI typecheck
// (Record is exhaustive).
const routedFileClients = guiRouting.INTEGRATION_TAB_HASHES
- .filter(hash => /^integrations\/[a-z-]+$/.test(hash))
+ .filter(hash => /^integrations\/[a-z0-9-]+$/.test(hash))
.map(hash => hash.split("/")[1]!)
.filter(id => (expected as string[]).includes(id))
.sort();
@@ -282,6 +282,8 @@ describe("every client survives a full lifecycle", () => {
// from the family resemblance (260912 plan unit, 001).
omo: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n',
kilo: '{\n "model": "keep-me",\n "provider": {\n "mine": { "npm": "keep-me" }\n }\n}\n',
+ // A second Codex account the user added in T3 Code sits beside ours under providerInstances.
+ t3code: '{"favorites":[],"providerInstances":{"codex_personal":{"driver":"codex","config":{"homePath":"~/.codex"}}}}\n',
};
/** Where the seed's user-owned entry lives when the seed is a sequence. */
const USER_ELEMENT: Partial> = {