diff --git a/.agents/skills/testing-opencodex-management-api/SKILL.md b/.agents/skills/testing-opencodex-management-api/SKILL.md
new file mode 100644
index 00000000000..60f5453a2b6
--- /dev/null
+++ b/.agents/skills/testing-opencodex-management-api/SKILL.md
@@ -0,0 +1,120 @@
+---
+name: testing-opencodex-management-api
+description: Exercise the OpenCodex management API in a disposable, isolated development environment without touching personal client state.
+---
+
+# Testing the OpenCodex management API
+
+## Isolation is a prerequisite
+
+Use a disposable OS account, container, or VM with a disposable OS home. Do not run this
+recipe in your normal desktop account merely by changing `OPENCODEX_HOME`.
+That variable relocates OpenCodex state, not every client or shell integration.
+On macOS, even disabling `claudeCode.systemEnv` can remove an existing managed block
+from the OS home's `.zshrc`; `CLAUDE_CONFIG_DIR` does not redirect that file.
+Raycast integration can also update existing OpenCodex-owned entries under the OS home.
+A temporary client directory alone is therefore not a complete isolation boundary.
+
+Within the disposable environment, allocate a unique scratch directory and set `HOME`
+to a fresh directory inside it before startup. A `HOME` override in a normal desktop
+account is not a substitute for the disposable account, container, or VM. Set all of
+`OPENCODEX_HOME`, `CODEX_HOME`, `CODEX_SQLITE_HOME`, `GROK_HOME`, `CLAUDE_CONFIG_DIR`, and
+`OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR` to distinct directories inside it before startup.
+Confirm the effective OS home belongs to the disposable account. Do not copy personal
+tokens, client configuration, shell profiles, or keychain contents into this environment.
+Start from a clean environment or inspect inherited path overrides before launching.
+Codex SQLite state resolves in this order: a root `sqlite_home` key in
+`CODEX_HOME/config.toml`, then `CODEX_SQLITE_HOME`, then `CODEX_HOME` itself. Keep the
+scratch `CODEX_HOME/config.toml` free of an outside `sqlite_home`, resolve the effective
+SQLite home with that precedence, and abort unless it is inside the scratch directory.
+
+Save a scratch `config.json` under `OPENCODEX_HOME` with an unused loopback port:
+
+```json
+{
+ "port": 19100,
+ "hostname": "127.0.0.1",
+ "codexAutoStart": false,
+ "syncResumeHistory": false,
+ "clientIntegrations": {"codex": false, "grok": false, "claude-desktop": false},
+ "claudeCode": {"enabled": false, "injectAgents": false, "systemEnv": false}
+}
+```
+
+Use both redirected client homes and integration disables. Disabled integrations may
+still remove owned artifacts. `codexAutoStart` alone does not disable startup sync:
+desired-state checks also consider integration settings and the hub/loopback-listener
+role. Do not depend on any one flag as an isolation boundary.
+
+## Start and authenticate
+
+Install the repository's locked development dependencies and use the Bun version named
+by `package.json`. Check that the selected Bun executable is available in this shell;
+do not assume a particular developer's PATH layout. Start one foreground instance:
+
+```sh
+bun run src/cli/index.ts start --port 19100
+```
+
+Avoid `ensure`, tray, and service installation paths for this exercise: they can spawn
+detached processes or alter persistent service state. Do not enable live providers or
+submit billable traffic unless that separate test is explicitly authorized.
+
+Prefer reading the scratch instance's generated `admin-api-token` locally. Alternatively,
+provision a randomly generated `OPENCODEX_ADMIN_AUTH_TOKEN` used only for this test.
+It must differ from every data-plane API key; a collision makes management authentication
+unavailable. Never paste the token into a PR, screenshot, log, or tracked fixture.
+
+Management requests accept `x-opencodex-api-key: ` or
+`Authorization: Bearer `. Missing authorization is refused. A valid token
+does not bypass route-specific origin, session, or policy requirements. Keep requests
+loopback-only and do not follow redirects with credentials.
+
+## Focused Lab automation exercise
+
+Read `GET /api/lab/automation` for policy and live scheduler state; inspect recorded runs
+with `GET /api/lab/automation/runs`. Enabling automation is an explicit state change,
+not a requirement for a basic management-authentication test.
+
+A policy write uses `PUT /api/lab/automation`, for example:
+
+```json
+{"policy":{"enabled":true,"layers":{"protocolConformance":true}}}
+```
+
+Serialize policy writes. The read/merge and save do not share one lock, so concurrent
+writers can overwrite each other's changes even though publication itself is atomic.
+Re-read the policy after changing it.
+
+A fixture-only manual run uses `POST /api/lab/automation/run` with this request body:
+
+```json
+{"evidenceLayer":"protocol_conformance","scenarioId":"responses-core.protocol.request-shape"}
+```
+
+For `live_route_compatibility`, include `providerName` and `modelId` in the POST request
+body, not as substitute top-level configuration fields. The named provider must already
+exist in `config.providers`, and live calls require authorization and suitable test
+credentials. Lab must also be active at proxy startup: a later policy PUT alone does
+not register the live route executor. Enable automation in the disposable home, stop
+the foreground proxy, and start it again before a separately authorized live run.
+The fixture-only protocol exercise above does not need this restart. Consult
+`planManualLabRun` in `src/lab/automation/planner.ts` for accepted
+combinations instead of guessing a scenario or provider.
+
+The manual endpoint awaits dispatch and returns a run/trigger result. Inspect the returned
+status rather than assuming success or a terminal run. Scheduler work is separate and
+may not appear immediately; read the configured scheduler limits instead of sleeping for
+a hard-coded interval.
+
+## Stop and inspect
+
+Send one interrupt to the foreground process and let its bounded cleanup/drain finish.
+A clean shutdown exits with zero; cleanup or drain failures may exit nonzero. A second
+signal requests forced termination and is not proof of successful cleanup.
+Check that the test listener and any test-owned children have stopped before removing
+the exact scratch tree. Do not clean directories based on a name pattern or age.
+Capture only redacted status, exit code, exact test commands, and observed results.
+
+This is a development testing recipe. It does not replace the operating reference in
+`skills/ocx/` or the consent rules in `AGENTS_INSTALL.md`.
diff --git a/AGENTS.md b/AGENTS.md
index 64c6c3106fc..c91d1491740 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,8 +14,9 @@ Bun-native TypeScript with no separate server compile step.
- `src/` — proxy runtime: routing, provider adapters, config, management API.
- `tests/` — Bun tests in domain directories that mirror `src/`
(`tests//*.test.ts`; `providers/` and `adapters/` have one more
- level for the larger vendors). The map is `scripts/test-layout/layout.json`
- and `tests/test-layout.test.ts` enforces it: every file resolves to a
+ level for the larger vendors). The explicit map is
+ `scripts/test-layout/layout.json`, with regex seeds and migration state in
+ `scripts/test-layout/seeds.json`; `tests/test-layout.test.ts` enforces that every file resolves to a
domain and sits in it, and only the two layout guards live at the root.
Shared helpers in `tests/helpers/`, fixtures in `tests/fixtures/`, broader
scenarios in `tests/e2e-style/`. Source-oracle tests resolve the repository
@@ -24,7 +25,7 @@ Bun-native TypeScript with no separate server compile step.
test file lands in its domain directory and needs an entry in both
`layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`
(`tests/test-layout-tooling.test.ts` names the missing one); the regex
- seeds in `layout.json` place a conventionally named file until then.
+ seeds in `seeds.json` place a conventionally named file until then.
History: `devlog/_fin/260905_test_modularization_and_windows/`.
- `gui/` — React + Vite dashboard; packaged output is served from `gui/dist`.
- `app/` — native macOS WidgetKit extension bundled into the Tauri desktop app;
@@ -213,6 +214,9 @@ bun run skill:surface # regenerate after adding a capability
bun run skill:surface:check # what CI asserts
```
+For development tests of the management API, use the isolated
+[management API test recipe](./.agents/skills/testing-opencodex-management-api/SKILL.md).
+
`tests/ci-workflows/skill-ocx.test.ts` fails if the committed map drifts from `src/cli/capabilities.ts`, and
also if the hand-written pages name a command the registry does not have. That second check is not
hypothetical: it caught a documented `ocx request-history` that never existed.
diff --git a/devlog/_plan/260927_release_train_4/clients-proxy/000_plan.md b/devlog/_plan/260927_release_train_4/clients-proxy/000_plan.md
new file mode 100644
index 00000000000..773ba393739
--- /dev/null
+++ b/devlog/_plan/260927_release_train_4/clients-proxy/000_plan.md
@@ -0,0 +1,123 @@
+# Release train 4: clients and proxy lane
+
+At `origin/dev` `24b2f39b77` on 2026-09-27, this lane has a mix of useful client
+integrations, routing changes, and proposals whose current diffs are not safe to
+land. Carry the bounded changes through ordinary PRs to `dev`, correct the observed
+regressions, and leave concrete reasons on proposals that need a new contract.
+
+## Loop specification
+
+- Archetype: satisfy the release-train acceptance contract, one dependency-ordered
+ work phase per PABCD cycle.
+- Trigger: the release train 4 `clients-proxy` lane assignment.
+- Goal: land verified client/proxy changes that help the next release and record a
+ disposition for every assigned PR and issue.
+- Non-goals: `main`, `preview`, releases, version changes, other lanes, writes to
+ contributor forks, automatic third-party installer execution, and eager client
+ activation on the three core request paths.
+- Verifier: the focused commands in the decade docs, `bun run test:changed`,
+ `bun run typecheck`, `bun run structure:check`, `bun run privacy:scan`,
+ `bun run skill:surface:check` when CLI capabilities change, exact-head PR CI,
+ and a successful post-merge `dev` CI run. Conditional branches have explicit
+ activation cases in their phase documents.
+- Stop condition: every row below has a supported merge or hold decision, each
+ landed PR passed its actual required jobs, source PRs/issues received the
+ appropriate links and disposition, and the final `dev` run succeeded.
+- Memory artifact: this numbered unit, its phase evidence, and the lane's
+ session-bound goalplan/ledger.
+- Terminal outcomes: DONE means that stop condition holds; NOOP means no
+ candidate survived review; NEEDS_HUMAN means an external contract or approval
+ blocks a specific candidate; BLOCKED means repeated external failure prevents
+ all meaningful progress; UNSAFE means validation found an unresolved release
+ blocker. There is no user-specified token or wall-clock bound.
+- Escalation: a new scope, an unresolvable security boundary, or a required
+ external account decision goes to the coordinator. PR push/merge and issue/PR
+ disposition within this lane are already authorized.
+
+All source edits, Git operations, and tests use this lane's dedicated
+worktree checkout. The native
+session directory is used only for ignored FSM and goalplan state. Local full
+suite may be omitted due to seven concurrent lane worktrees; focused regressions
+remain mandatory, and each PR's Verification section will state the exact
+commands, results, and coverage left to CI.
+
+## Source ownership and selection
+
+`structure/clients/integrations.md:27-55` assigns pure client builders to
+`src/clients/config-export.ts`, detection paths to
+`src/integrations/registry.ts`, and snapshot/classification/writes to the shared
+integration modules. New clients stay explicit and use those seams. The three
+core request files (`src/router.ts`, `src/server/lifecycle.ts`,
+`src/server/responses/core.ts`) must retain the Lab import boundary enforced by
+`tests/lab/core-lab-boundary.test.ts`. `src/config/proxy-env.ts` owns process
+proxy activation (`structure/config-proxy.md:1-20`).
+
+| Item | Current head/state | Decision and evidence | Work phase |
+| --- | --- | --- | --- |
+| #6051 | `987b8097`, open | Carry the disposable-home management-API recipe with a discoverable contributor link; `.agents/skills/` has no existing entry point. | [010](010_recipe.md) |
+| #5893 / #5853 | `3743320a`, draft | Carry only when every macOS exception maps faithfully onto the bypass variables the active transports read, or discovery refuses before any environment write; an inherited SOCKS proxy keeps its existing path. | [020](020_macos_proxy.md) |
+| #5950 / #5660 | `ef03f5ab`, open | HOLD Qoder: opt-in config writes, restore, and path handling still need current-base revalidation (`src/clients/config-export/qoder.ts`, PR test). | [030](030_qoder.md) |
+| #5272 | `7dd796d7`, open | Carry Kilo after checking all merged config candidates; first-file-only selection can be overridden by a later legacy file (`src/clients/config-export/kilo.ts:57-63` in PR). | [040](040_kilo.md) |
+| #5193 | `91090f80`, open/conflicting | Reimplement a focused Droid slice on current `dev` only if its client contract and export provenance can be proven. The PR's broad rewrite changes shared loopback export behavior. | [050](050_droid.md) |
+| #5871 | `ba2d2600`, open/conflicting | Carry after conflict repair and an outbound decision-payload regression (`src/combos/jev.ts:588-595` in PR). | [060](060_jev.md) |
+| #5983 / #5982 | `cd45810f`, open | Carry with explicit non-memory metadata taking precedence over the subagent header fallback (`src/server/responses/memory-models.ts:65` in PR). | [070](070_memory.md) |
+| #5905 / #5679 | `19948a38`, draft | Hold: opening regular Cursor integration status can automatically fetch an external installer manifest. Decide explicit opt-in and cover timeout/status before carry. | [080](080_held_items.md) |
+| #3833 | `d47e376b`, draft | Hold: Command Code rejects the exported literal `apiKey` placeholder; the PR test only checks presence. Needs supported client credential form and live client proof. | [080](080_held_items.md) |
+| #4854 | open | Hold OpenScience until its actual config schema and ownership paths are established. Manual OpenAI-compatible endpoint is available. | [080](080_held_items.md) |
+| #3494 | open | Hold VS Code extension integration until one named extension's supported settings and reload lifecycle are verified. | [080](080_held_items.md) |
+| #1416 | open | Hold Orca launch manifest until the stopped-proxy, secret-free consumer contract is pinned; live-catalog config export is the wrong bootstrap path. | [080](080_held_items.md) |
+| #2811 | open | Design only: #5016 was closed because `plan` required `managed: true` that the production inspector never reports. A reachable provenance proof precedes apply. | [080](080_held_items.md) |
+
+## Dependency order and merge method
+
+`010` establishes the verification recipe, `020` owns outbound proxy activation,
+`030` proves the existing client path on current `dev`, and `040`/`050` reuse that
+verified roster with one client at a time. `060` precedes `070` because both touch
+`src/types/config.ts`; that is a merge-conflict dependency, not a runtime one.
+`080` records held items after each applicable outcome. [090](090_final_ci.md)
+checks the latest integrated tree. Each carried source PR becomes a new ordinary
+`dev` PR from this lane, with a `Co-authored-by` trailer in the PR description
+or branch commit. Git authorship alone does not satisfy the carry policy. A
+large or conflicted source diff is reduced before
+landing; the source PR is thanked, linked, and closed only once its replacement
+is merged. No GitHub native stack or tip-only CI exception is selected.
+
+For every batch, fetch `origin/dev` again, inspect the source PR's current head
+and diff, check the file-size ratchet and merged union/locale/count consumers,
+run focused tests and typecheck, perform explicit security review for any
+credential, proxy, installer, or authentication boundary, then inspect
+required CI at the exact new PR
+head before merging. GUI changes need a screenshot in the PR description from
+the separate `pr-assets` branch, never committed to the PR branch. Merge only
+when the new PR head contains the latest `origin/dev`; dispatch `ci.yml` on
+`dev` manually as specified in [090](090_final_ci.md) and inspect its exact
+head before the next batch.
+
+## Consultation and uncertainty
+
+The architect proposal: D1 existing
+integration ownership and D2 proxy ownership accepted; D3 Cursor discovery
+amended to hold pending opt-in; D4 managed clients accepted with #3833 held;
+D5 new client proposals held pending primary client contracts; D6 JEV then
+memory accepted; D7 Codex updater remains design-only. Four independent source
+PR reviewers examined the candidates. The architect's first reflection
+found three gaps: attribution trailer, explicit security review, and the
+recipe's OS-home isolation condition. All three were folded into this revision
+before independent audit. Their findings are proposals; each carry is
+rechecked on the actual integrated diff and current `dev`.
+
+Baseline verifier preflight on `24b2f39b77`: `bun run typecheck`,
+`bun run structure:check`, `bun run privacy:scan`,
+`bun run skill:surface:check`, and `bun test
+tests/lab/core-lab-boundary.test.ts` each exited 0; the Lab guard ran 25
+tests. These check the baseline and this planning tree only. New PR behavior
+still requires the phase-specific commands after the relevant diff is present.
+`bun run test:changed` on this docs-only staged diff selected zero tests and
+exited 1; it is not evidence of test passage. Docs checks and semantic audit
+cover the roadmap, and implementation batches rerun changed tests.
+
+The same architect rechecked the D2 safety amendment and returned ALIGNED.
+The independent A reviewer first
+reported six blockers, then one remaining test-layout blocker; every finding
+was folded into the relevant decade document and its final verdict was PASS.
+This closes the roadmap design review, not any proposed code change.
diff --git a/devlog/_plan/260927_release_train_4/clients-proxy/010_recipe.md b/devlog/_plan/260927_release_train_4/clients-proxy/010_recipe.md
new file mode 100644
index 00000000000..4af0bdb796b
--- /dev/null
+++ b/devlog/_plan/260927_release_train_4/clients-proxy/010_recipe.md
@@ -0,0 +1,95 @@
+# Phase 1: management API test recipe (#6051)
+
+The preceding D concluded that the reviewed roadmap is locked at `ce5a406862`;
+the next action is this bounded recipe carry. Depends on `000_plan.md`;
+docs-only carry, then one ordinary PR. The source PR
+adds `.agents/skills/testing-opencodex-management-api/SKILL.md` with a
+disposable-home setup and correct Lab requests. It needs a repository entry
+point before another agent can reliably discover it.
+
+## Exact change map
+
+- NEW `.agents/skills/testing-opencodex-management-api/SKILL.md`: carry the
+ 108-line source recipe from #6051 head `987b8097624e50e6c39b00aca145fe4755043c4b`
+ with source-verified isolation and activation corrections after checking every command and path against current
+ management routes. Preserve its disposable OS account/home, container, or
+ VM prerequisite; redirect client homes and disable integrations before a
+ smoke. `OPENCODEX_HOME` alone does not isolate client writes. Keep explicit
+ token read, bounded process cleanup, and authorization before live-provider
+ requests. No secret values or real account identifiers enter examples.
+- MODIFY `AGENTS.md` near the Commands and `skills/ocx/` guidance: add one
+ contributor-facing link to the test recipe. Before: only the runtime-control
+ `skills/ocx/` reference is discoverable (`AGENTS.md:207-209`). After: one
+ sentence identifies `.agents/skills/testing-opencodex-management-api/SKILL.md`
+ as the development test recipe, while `skills/ocx/` remains the operating
+ reference and `AGENTS_INSTALL.md` retains consent guidance. Do not change
+ runtime imports, CLI capabilities, or the generated operating-surface map.
+- MODIFY `010_recipe.md` with a short outcome addendum after validation,
+ naming the carried source SHA, attribution, and exact command results. The
+ carry commit or PR body
+ must contain `Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>`.
+
+## Acceptance and proof
+
+Read the recipe's executable examples against
+`src/server/management/lab-automation-routes.ts:119-180` and
+`src/lab/automation/planner.ts:278-340`; POST fields are body fields and PUT
+policy fields are supported there. `bun test
+tests/lab/lab-automation-management-http.test.ts` ran at the base and passed
+2 tests; it checks route cancel/pagination, not the prose or POST example.
+Run a smoke only in a disposable OS account/home,
+container, or VM with redirected client homes and integrations disabled;
+the current desktop account does not meet this precondition, so record the
+smoke as unrun. Confirm the carried file against the pinned PR head, its
+frontmatter and link target. Stage every changed and new file before running
+`git diff --cached --check` and `bun run privacy:scan`; the scan uses
+`git ls-files`, so an untracked skill would be invisible. After commit run
+`git diff origin/dev...HEAD --check`. Also run `bun run structure:check` and
+`bun run typecheck`. These commands protect
+the tree and paths; semantic correctness of the recipe needs source review.
+`bun run test:changed` can select zero tests for a docs-only diff and is then
+not passing evidence. A docs-only CI skip is recorded as skipped, not
+as a passing suite. PR template Summary/Verification/Checklist, source author
+credit, exact-head required checks, and post-merge `dev` CI still apply.
+
+The architect proposed D1-R (carry the
+isolation and route examples), D1-L (one AGENTS discovery link), and D1-V
+(attribution and exact gates). All three are accepted. Putting the recipe in
+`skills/ocx/` would confuse development tests with operating guidance; a
+PR-only link would not be durable.
+
+## Local carry outcome
+
+Imported the recipe from #6051 head
+`987b8097624e50e6c39b00aca145fe4755043c4b`; the initial `cmp`
+against that Git object exited 0 at 108 lines. C-phase implementation review
+then required two source-grounded corrections to the final copy: redirecting
+Codex's SQLite home and disabling resume-history sync in the disposable
+configuration, and activating Lab at startup before a separately authorized
+live-route run. The isolation instructions also require `HOME` to point into
+the disposable scratch root. The final recipe therefore intentionally differs from the
+source PR. `AGENTS.md:212` links it beside
+the operating reference. The same independent A reviewer first found that
+an unstaged whitespace check would miss a staged change and the privacy scan
+would miss an untracked skill; the plan now stages all files before both gates,
+and the reviewer returned PASS.
+
+After staging, `git diff --cached --check`, `bun run privacy:scan`,
+`bun run structure:check`, and `bun run typecheck` exited 0. `bun test
+tests/lab/lab-automation-management-http.test.ts
+tests/lab/lab-automation.test.ts` passed 24 tests with 0 failures. These tests
+cover the route and planner baseline, not the prose; route and planner source
+were read against the example fields. `bun run test:changed` exited 1 because
+the docs-only diff selected 0 tests. The live smoke was not run in this
+desktop account: it lacks the disposable OS-home prerequisite. Full local
+suite is omitted due to concurrent lane worktrees; CI remains the broader
+gate. PR-head and post-merge `dev` CI evidence are recorded after publication.
+
+After the C-phase corrections, the scratch `config.json` example parsed as
+JSON with `syncResumeHistory: false`; `git diff --cached --check` and
+`bun run privacy:scan` exited 0 on the staged revision. The independent
+implementation reviewer rechecked the SQLite and Lab startup paths and
+returned PASS. A separate token/isolation security reviewer also returned
+PASS on the amended recipe. Neither reviewer ran the live smoke, and the
+24-test route/planner run and typecheck predate only these documentation edits;
+no runtime source changed between those checks and this revision.
diff --git a/devlog/_plan/260927_release_train_4/clients-proxy/020_macos_proxy.md b/devlog/_plan/260927_release_train_4/clients-proxy/020_macos_proxy.md
new file mode 100644
index 00000000000..79b2cce96df
--- /dev/null
+++ b/devlog/_plan/260927_release_train_4/clients-proxy/020_macos_proxy.md
@@ -0,0 +1,62 @@
+# Phase 2: macOS system proxy discovery (#5893)
+
+Depends on `010_recipe.md` for lane order. Carry the small proxy change after
+rebasing its draft head onto current `dev`; do not change Windows discovery or
+explicit proxy precedence. `structure/config-proxy.md:1-20` owns the contract.
+
+## Exact change map
+
+- NEW `src/config/macos-system-proxy.ts`: observe and parse macOS system
+ proxy settings only on Darwin; return no discovery for disabled, malformed,
+ or unavailable settings. No caller imports this module on every request.
+- MODIFY `src/config/proxy-env.ts`: before, `proxy: "auto"` considers the
+ existing Windows path and merges loopback bypasses. After, Darwin discovery
+ is considered only for that explicit setting and when no inherited scheme
+ proxy wins. Translate macOS exceptions only when their matching semantics
+ are proven equivalent to Bun's `no_proxy` semantics. A bare name such as
+ `localhost` must not enter either effective proxy-bypass variable as a
+ suffix. If any system exception is not faithfully representable, refuse
+ macOS auto-discovery and leave process proxy variables unchanged with a
+ privacy-safe diagnostic. Preserve the address-only loopback bypass. Add
+ proven-safe entries to the bypass variable the selected HTTP(S) transport
+ actually reads. If inherited `ALL_PROXY`/`all_proxy` selects SOCKS,
+ macOS discovery must not add scheme proxies or discovered exceptions.
+ Preserve the inherited proxy path and assert both transports'
+ effective routes when the two bypass variables disagree. Redact
+ credential-bearing proxy URLs.
+- MODIFY `tests/server/proxy-env.test.ts`: retain source tests and add a case
+ with lowercase `no_proxy` distinct from uppercase `NO_PROXY`; assert the
+ effective bypass after activation. Drive a request to `localhost` and
+ `app.localhost` (or the proxy matcher used by that request) and prove that
+ the exception does not widen direct egress. An unrepresentable exception
+ must refuse discovery before the normal `mergeNoProxyEntries` tail; assert
+ a full byte-identical snapshot of `HTTP_PROXY`, `HTTPS_PROXY`, lowercase
+ equivalents, `ALL_PROXY`, `all_proxy`, `NO_PROXY`, and `no_proxy`. Cover
+ safe wildcard/IP entries, malformed/disabled `scutil` output, explicit
+ environment precedence, `proxy` unset, inherited SOCKS `ALL_PROXY` with
+ conflicting uppercase/lowercase bypass lists, and unchanged Windows
+ behavior. Tests use
+ a mocked system command; they do not claim a real macOS Settings session.
+- MODIFY `structure/config-proxy.md` and the English plus affected translated
+ `docs-site/src/content/docs/*/reference/configuration/server.md` pages to
+ state the actual opt-in/automatic precedence after code is verified.
+
+## Acceptance and proof
+
+Activation scenario: Darwin with `config.proxy: "auto"`, no inherited HTTP(S)
+or SOCKS proxy (`ALL_PROXY`/`all_proxy` included), and valid system settings
+sets the proxy and safely representable bypass list. Bypass precedence is
+asserted per transport: Bun's native HTTP(S) fetch reads a non-empty lowercase
+`no_proxy` before `NO_PROXY`, while `resolveProxyRoute` honors an explicitly
+defined uppercase `NO_PROXY`, including an empty value. Tests keep route
+assertions for both transports when the two variables disagree.
+Negative scenarios: unset `config.proxy` never reads macOS system settings or applies
+discovered routes, while the existing inherited-proxy loopback bypass remains;
+inherited HTTP(S) or SOCKS proxy wins without mixed bypass semantics,
+unrepresentable exceptions refuse before any environment write, disabled or
+bad system settings leave egress unchanged, and Windows keeps its prior route. Run
+`bun test tests/server/proxy-env.test.ts`, `bun run test:changed`, `bun run
+typecheck`, `bun run structure:check`, and `bun run privacy:scan`. Build
+`docs-site/` if docs change. `tests/lab/core-lab-boundary.test.ts` checks the
+core import rule. Perform explicit security review of credential-bearing
+proxy URL handling. Recheck exact-head CI and live `dev` CI before the next batch.
diff --git a/devlog/_plan/260927_release_train_4/clients-proxy/030_qoder.md b/devlog/_plan/260927_release_train_4/clients-proxy/030_qoder.md
new file mode 100644
index 00000000000..b80d8c1ba45
--- /dev/null
+++ b/devlog/_plan/260927_release_train_4/clients-proxy/030_qoder.md
@@ -0,0 +1,46 @@
+# Phase 3: opt-in Qoder client (#5950)
+
+Depends on the preceding lane batch's `dev` result; Qoder reuses the existing
+pure export, registry and journaled writer seams rather than adding request
+path code. The source PR touches 36 files, including GUI and translations;
+carry one coherent client slice and remove unrelated drift.
+
+## Exact change map
+
+- NEW `src/clients/config-export/qoder.ts`: build the documented provider
+ contribution and exact managed fragment paths. Loopback may use a
+ non-secret placeholder; remote bind must have a supported admission header
+ or refuse.
+- MODIFY `src/clients/config-export/contracts.ts` and
+ `src/clients/config-export.ts`: before, Qoder is absent from the export ID
+ union/registry. After, `qoder` is a named opt-in export with a derived
+ roster count, not a hand-written total.
+- MODIFY `src/integrations/registry.ts` and `mutation-plan.ts`: resolve a
+ Qoder-supported user path, validate file/directory safety, and use the
+ common status/preview/apply/disable/restore classifier. No automatic
+ detection write or core request-path import.
+- MODIFY `src/cli/help.ts`, `src/cli/registry.ts`, the GUI integration lists,
+ routing, marks, API IDs, and affected locales to expose the same client ID.
+ Update `docs-site/src/content/docs/guides/integrations.md`,
+ `structure/clients/integrations.md`, and
+ `structure/dashboard-and-usage.md` in the same change.
+- MODIFY/NEW tests under `tests/clients/`, `tests/config/`, `tests/gui/`, and
+ `gui/tests/` for exact generated shape, absent client, foreign keys,
+ symlink/unsafe path refusal, drift, snapshot-before-write, disable, and
+ byte-exact restore. Register new test names in both test-layout manifests.
+
+## Acceptance and proof
+
+Activation: an operator explicitly enables Qoder against a disposable config;
+the generated provider is present and a later disable/restore recovers prior
+bytes. A hostile or changed file refuses without overwrite. Windows path
+tests use a Windows-shaped home/env and confirm no POSIX-only assumption.
+Run `bun test tests/clients/qoder-client.test.ts
+tests/clients/integrations-state.test.ts
+tests/config/client-config-export-new-clients.test.ts`, relevant `gui/tests/`,
+`bun run test:changed`, `bun run typecheck`, `bun run lint:gui`,
+`bun run build:gui`, `bun run structure:check`, `bun run privacy:scan`,
+and `bun run skill:surface:check` if the capability registry changes.
+Perform explicit security review of admission and config serialization. Check
+the file-size ratchet, test-layout manifests, locale union, screenshot,
+exact-head required CI, and merged `dev` run.
diff --git a/devlog/_plan/260927_release_train_4/clients-proxy/040_kilo.md b/devlog/_plan/260927_release_train_4/clients-proxy/040_kilo.md
new file mode 100644
index 00000000000..06279c30c5e
--- /dev/null
+++ b/devlog/_plan/260927_release_train_4/clients-proxy/040_kilo.md
@@ -0,0 +1,43 @@
+# Phase 4: Kilo managed config (#5272)
+
+`030_qoder.md` remains held; this phase reconciles the shared export-client
+union and GUI roster against current `dev`. The PR's 57-file slice includes a JSONC
+writer extension; preserve unrelated parsed client state during apply and disable,
+and restore original comment-bearing bytes from the snapshot on undo.
+
+## Exact change map
+
+- NEW `src/clients/config-export/kilo.ts`: generate the documented Kilo
+ provider block and resolve the active global config path. Before, no Kilo
+ export exists. After, a config is selected only when later legacy files
+ cannot override its managed `provider.opencodex` block. If two candidate
+ files can supply that block, status and apply refuse with a clear conflict;
+ no first-file-wins write that appears successful but is ineffective.
+- MODIFY `src/clients/config-export.ts`, `contracts.ts`,
+ `src/integrations/registry.ts`, `target.ts`, `state.ts`, `writer.ts`,
+ `mutation-plan.ts`, `config-io.ts`, and `src/lib/jsonc.ts` only as required
+ for JSONC parsing and the common ownership contract. The parser must reject
+ non-roundtrippable syntax before mutation; the snapshot keeps original
+ comment-bearing bytes recoverable for undo.
+- MODIFY the CLI export/help/registry entries, GUI integration registry and
+ affected locale keys, public integration documentation, and
+ `structure/clients/integrations.md` for the actual Kilo path.
+- NEW/MODIFY `tests/clients/kilo-client.test.ts` and adjacent config/GUI
+ tests: add a two-file precedence conflict fixture with distinct provider
+ values, byte-exact restore of an initial comment-bearing file, unsafe path
+ refusal, and Windows-shaped home/path resolution. Register test files in
+ both test-layout manifests.
+
+## Acceptance and proof
+
+Activation: explicit apply to an unambiguous Kilo install writes only owned
+fields; disabling preserves unrelated parsed values; restoring returns the original bytes.
+Conflict activation: a later candidate file contains the same provider key;
+status and mutation both refuse before snapshot/write. Run
+`bun test tests/clients/kilo-client.test.ts
+tests/config/client-config-export.test.ts`, the relevant `gui/tests`,
+`bun run test:changed`, `bun run typecheck`, `bun run lint:gui`,
+`bun run build:gui`, `bun run structure:check`, `bun run privacy:scan`,
+and `bun run skill:surface:check` if capabilities change. Check screenshot,
+merged file-size cap, union/locale counts, explicit credential/path security
+review, exact-head CI, and post-merge `dev`.
diff --git a/devlog/_plan/260927_release_train_4/clients-proxy/050_droid.md b/devlog/_plan/260927_release_train_4/clients-proxy/050_droid.md
new file mode 100644
index 00000000000..c33d2aa7b26
--- /dev/null
+++ b/devlog/_plan/260927_release_train_4/clients-proxy/050_droid.md
@@ -0,0 +1,42 @@
+# Phase 5: focused Factory Droid integration (#5193)
+
+Depends on `040_kilo.md` for shared roster reconciliation. The source PR
+conflicts with current `dev` and changes 83 files, including broad export
+behavior and unrelated test harnesses. Reimplement the narrow client thesis;
+if the installed Droid contract cannot be verified, record a hold and leave
+the source PR open with a reason.
+
+## Exact change map if verified
+
+- NEW `src/clients/config-export/droid.ts`: build only Droid's documented
+ settings and per-model rows using the documented user settings path. Do not
+ add an automatic startup/config write or copy provider credentials.
+- MODIFY `src/clients/config-export.ts`, `contracts.ts`,
+ `src/integrations/registry.ts`, and `mutation-plan.ts` to add the typed ID
+ and exact managed fragments. Before, Droid is absent. After, explicit
+ export/enable uses the shared journal and restore path.
+- MODIFY `src/cli/export-command.ts` only if Droid needs a distinct
+ loopback catalog source. Preserve the existing catalog provenance for every
+ other loopback-only client; the source PR's all-client redirect is not
+ accepted without a separate proof. Update CLI help, GUI roster/locales,
+ `docs-site/src/content/docs/guides/integrations.md`, and
+ `structure/clients/integrations.md` for the verified client slice.
+- NEW `tests/clients/droid-client.test.ts`: assert exact client-consumed
+ settings, foreign model preservation, symlink/unsafe path refusal, Windows
+ path, drift, disable and exact-byte restore. MODIFY
+ `tests/cli/cli-export-command.test.ts` to prove existing clients retain
+ their old catalog/selection source. Register the new test in both manifests.
+
+## Acceptance and proof
+
+Activation: a disposable Droid config is explicitly enabled and subsequently
+restored. Negative: a changed user model or unsafe target refuses before
+overwrite, and a non-Droid loopback client exports the same catalog as before.
+Run `bun test tests/clients/droid-client.test.ts
+tests/cli/cli-export-command.test.ts`, relevant integration and GUI tests,
+`bun run test:changed`, `bun run typecheck`, `bun run lint:gui`,
+`bun run build:gui`, `bun run structure:check`, `bun run privacy:scan`, and
+surface check if needed. Do not claim live Droid behavior from a synthetic
+fixture alone; verify the documented client schema before committing the
+implementation. Explicit credential/path security review, a GUI screenshot,
+and exact-head CI precede merge.
diff --git a/devlog/_plan/260927_release_train_4/clients-proxy/060_jev.md b/devlog/_plan/260927_release_train_4/clients-proxy/060_jev.md
new file mode 100644
index 00000000000..1970ba9786e
--- /dev/null
+++ b/devlog/_plan/260927_release_train_4/clients-proxy/060_jev.md
@@ -0,0 +1,40 @@
+# Phase 6: operator JEV model profiles (#5871)
+
+Depends on the shared type/roster reconciliation from prior batches; the PR
+currently conflicts with `dev`. Keep profile settings optional and off the
+single-provider/no-profile request path.
+
+## Exact change map
+
+- MODIFY `src/types/config.ts` and `src/combos/types.ts`: add an optional
+ target-keyed profile shape. Trace creation in management input, persistence
+ through config serialization/deserialization, and consumption by JEV; a
+ missing profile retains the old request shape.
+- MODIFY `src/combos/jev.ts`: insert an operator-authored per-target note into
+ the outbound decision payload while retaining existing candidate bounds
+ and built-in profile behavior. Validate and bound that freeform text at the
+ input boundary, document that it is sent to the decision provider, and
+ review privacy/security implications explicitly. Do not widen the target
+ model set or create a new unsolicited model request.
+- MODIFY `src/server/management/combo-routes.ts`,
+ `src/server/responses/core-combo.ts`, GUI combo workspace controls/data,
+ relevant locales, `docs-site/src/content/docs/guides/combos.md`, and
+ `structure/providers-and-adapters.md` to expose and describe that same
+ optional shape. Keep locale keys exhaustive.
+- MODIFY `tests/routing/jev-decision.test.ts` to capture the actual outbound
+ request and assert the selected target note appears. Also test absent
+ profile, wrong target, and bound candidates; update management and GUI tests.
+
+## Acceptance and proof
+
+Activation: configure a note for target A, invoke JEV with A, and observe it in
+the outbound decision payload; invoke B/absent profile and observe the prior
+payload. Run `bun test tests/routing/jev-decision.test.ts
+tests/routing/combo-management-api.test.ts`, relevant `gui/tests`,
+`bun run test:changed`, `bun run typecheck`, `bun run lint:gui`,
+`bun run build:gui`, `bun run structure:check`, `bun run privacy:scan`,
+and `bun test tests/lab/core-lab-boundary.test.ts`. Recheck the current-base
+union/locale and file-size ratchet; inspect the actual transmitted note,
+its bounds, privacy handling, and user-facing disclosure in a security review.
+GUI screenshot and exact-head CI are
+required before merge.
diff --git a/devlog/_plan/260927_release_train_4/clients-proxy/070_memory.md b/devlog/_plan/260927_release_train_4/clients-proxy/070_memory.md
new file mode 100644
index 00000000000..ae1384f1629
--- /dev/null
+++ b/devlog/_plan/260927_release_train_4/clients-proxy/070_memory.md
@@ -0,0 +1,58 @@
+# Phase 7: selected models for Codex memory (#5983)
+
+Depends on `060_jev.md` for serial changes to `src/types/config.ts`. The source
+PR spans 51 files. The carried classifier treats explicit turn metadata as
+authoritative and consults the `x-openai-subagent` header only when that
+metadata is absent.
+
+## Exact change map
+
+- NEW `src/server/responses/memory-models.ts`: classify memory phases from
+ validated turn metadata. Before: no memory-specific target. After: an
+ explicit `none`/non-memory metadata result returns no memory route, and the
+ legacy subagent header is consulted only when metadata is absent. Reject
+ malformed target settings without silently selecting a different model.
+- MODIFY `src/server/responses/request-prepare.ts` and the related normalize,
+ options, availability, and config modules only to thread the selected
+ memory target through both HTTP and WebSocket admission. Do not import Lab
+ from `src/server/responses/core.ts`, `src/router.ts`, or
+ `src/server/lifecycle.ts`; do not add a timer for no-memory users.
+- MODIFY `src/types/config.ts`, `src/types/request.ts`, config schema/leaf
+ validation, CLI/config docs, management config route, and GUI Memory panel
+ and locale keys so input, persisted value, reload and consumers agree. No
+ hand-counted preset/capability totals.
+- REVIEW and MODIFY the relevant mapped source-of-truth documents:
+ `structure/config.md` for the persisted setting,
+ `structure/transports/responses.md` and
+ `structure/transports/responses-failover.md` for routing behavior,
+ `structure/gui-and-management-api.md` for settings exposure, and
+ `structure/providers-and-adapters.md` for `src/types/` ownership.
+ Check the other documents mapped to `src/server/` in
+ `structure/INDEX.md`; update any whose described contract changes.
+- NEW `tests/responses/responses-memory-models.test.ts`: send explicit
+ non-memory metadata plus a subagent header and assert the normal model
+ serves the request. Cover real memory metadata, absent metadata fallback,
+ unavailable selected target, HTTP and WebSocket entry, and no-memory
+ baseline. The WebSocket case must enter through actual WebSocket admission,
+ not merely call the classifier with `transport: "websocket"`.
+- NEW `tests/config/settings-memory-models.test.ts`: cover accepted and
+ rejected persisted memory targets, load degradation, and management-save
+ behavior without dropping unrelated config.
+- MODIFY relevant `gui/tests` for model selection and disabled/unknown
+ targets. Register both new test files in `scripts/test-layout/layout.json`
+ and `tests/fixtures/test-layout-expected.json`.
+
+## Acceptance and proof
+
+Activation: a memory-phase request with configured target routes there;
+explicit non-memory metadata never routes there even with the fallback header;
+no setting retains current behavior. Run `bun test
+tests/responses/responses-memory-models.test.ts
+tests/responses/responses-shadow-intercept.test.ts
+tests/config/settings-memory-models.test.ts`, a WebSocket entry-path
+regression, and the relevant `gui/tests`,
+`bun run test:changed`, `bun run typecheck`, `bun run lint:gui`,
+`bun run build:gui`, `bun run structure:check`, `bun run privacy:scan`,
+and `bun test tests/lab/core-lab-boundary.test.ts`. Inspect user-facing
+English/translated docs, current merged type unions and file-size caps.
+Require GUI screenshot and exact-head CI before merge.
diff --git a/devlog/_plan/260927_release_train_4/clients-proxy/080_held_items.md b/devlog/_plan/260927_release_train_4/clients-proxy/080_held_items.md
new file mode 100644
index 00000000000..dccbef8c54a
--- /dev/null
+++ b/devlog/_plan/260927_release_train_4/clients-proxy/080_held_items.md
@@ -0,0 +1,40 @@
+# Phase 8: source PR and issue disposition
+
+Depends on outcomes from `010`-`070`. This is a GitHub triage phase, not a
+product-code batch. It is complete only when each row has a current link and
+the correct open/closed state. Comments are in English and name the specific
+missing proof or replaced PR. Do not close a source PR until its replacement
+has merged into `dev`; leave a genuine enhancement open when held.
+
+## Exact external change map
+
+- COMMENT, then CLOSE replaced source PRs #6051, #5893, #5272,
+ #5193, #5871, #5983 only if the corresponding carried behavior actually
+ landed. Include the lane PR and merge SHA and thank the original author.
+- COMMENT, KEEP OPEN #5950 and linked #5660: Qoder is held because its opt-in
+ config-write, restore and path contracts have not been revalidated on this train.
+- COMMENT, KEEP OPEN #5905: opening Cursor status currently fetches a remote
+ installer manifest without a user action. Ask for an explicit discovery
+ policy and timeout/status regression. Keep draft and no installer launch.
+- COMMENT, KEEP OPEN #3833: the literal `apiKey` placeholder in its export is
+ rejected by Command Code; require a documented supported keyless/reference
+ form and client-side proof, then refresh against `dev` and security review.
+- COMMENT, KEEP OPEN #4854: require OpenScience config path/schema and
+ override/restore ownership evidence; the manual endpoint remains usable.
+- COMMENT, KEEP OPEN #3494: require one named VS Code extension's officially
+ supported settings, reload behavior, and per-scope ownership contract.
+- COMMENT, KEEP OPEN #1416: require a versioned, secret-free Orca launch
+ manifest that can be generated while the proxy is stopped; do not insert
+ it into live model export before the consumer schema is agreed.
+- COMMENT, KEEP OPEN #2811: record the design-only judgment. #5016 was
+ closed unmerged because `managed: true` was unreachable from the production
+ inspector. Establish a real provenance predicate and read-only plan before
+ considering an apply mutation.
+- CLOSE linked #5853 and #5982 only when the exact behavior is on
+ `dev`, with the lane merge link. #5679 remains open while #5905 is held.
+
+## Acceptance and proof
+
+Fetch each PR/issue after each comment/close and verify state and URL. Do not
+count a `gh` command's exit alone as proof. The issue-close list is conditional
+on actual merged outcomes. Re-read source authors for attribution trailers.
diff --git a/devlog/_plan/260927_release_train_4/clients-proxy/090_final_ci.md b/devlog/_plan/260927_release_train_4/clients-proxy/090_final_ci.md
new file mode 100644
index 00000000000..c80f76faf0d
--- /dev/null
+++ b/devlog/_plan/260927_release_train_4/clients-proxy/090_final_ci.md
@@ -0,0 +1,32 @@
+# Phase 9: final integration and CI
+
+Depends on all selected carries and triage. This phase writes no product code
+unless the last `dev` run exposes a lane-owned regression; any repair gets its
+own new PABCD work phase and ordinary PR.
+
+## Exact evidence map
+
+- MODIFY `devlog/_plan/260927_release_train_4/clients-proxy/000_plan.md`
+ disposition rows when outcomes change. Before: candidate judgments at
+ `origin/dev` `24b2f39b77`; after: each row names actual lane PR, merge SHA,
+ source PR/issue state, and any residual hold.
+- NEW a numbered outcome file under this unit, recording each PR head and
+ merge SHA, focused commands and their exits, required CI run IDs/URLs,
+ final `dev` run URL, and remaining cross-lane file overlaps.
+
+## Acceptance and proof
+
+Fetch latest `origin/dev`; for every lane PR, retain the exact pre-merge PR
+head SHA and required-job run IDs that passed before merge. `ci.yml` does
+not run on a push to `dev`, so after the merge resolve the integrated `dev`
+commit and explicitly dispatch `gh workflow run ci.yml -R
+lidge-jun/opencodex --ref dev -f lane=all`. Identify the resulting run by
+`workflow_dispatch` event and exact `headSha`, then record its run ID, URL,
+attempt, requested jobs and final conclusions. If `dev` moves before dispatch,
+refresh the head and verify the run covers that newer integrated tree instead
+of claiming evidence for an older SHA. Missing, skipped, cancelled, pending,
+failed, and wrong-head results do not count as passing for requested jobs.
+Compare changed paths
+against other lane overlap in the final report. `git status --short` must
+contain no unaccounted files, and each source PR/issue closure must point to
+the actual integrated SHA.
diff --git a/devlog/_plan/260927_release_train_4/clients-proxy/100_outcome.md b/devlog/_plan/260927_release_train_4/clients-proxy/100_outcome.md
new file mode 100644
index 00000000000..35f106ceb69
--- /dev/null
+++ b/devlog/_plan/260927_release_train_4/clients-proxy/100_outcome.md
@@ -0,0 +1,28 @@
+# Phase 10: lane outcome
+
+The clients/proxy lane lands through one integration PR, [#6124](https://github.com/lidge-jun/opencodex/pull/6124). It stacks the six reviewed lane PRs linearly, each with its commits and `Co-authored-by` trailers intact, plus two union commits. Batching replaced six sequential rebase-and-CI cycles on a congested Actions queue. Each lane PR passed its own exact-head `Cross-platform CI` run before batching:
+
+| Carry | Lane PR, reviewed head | PR CI run | Source PR | Review outcome |
+| --- | --- | --- | --- | --- |
+| Management API test recipe, roadmap | #6095 `fc6c06050e` | [36333525807](https://github.com/lidge-jun/opencodex/actions/runs/36333525807) | #6051 | PASS after folding pre-disclosure wording and agent ids |
+| JEV per-target notes | #6107 `186ed34fee` | [36333529243](https://github.com/lidge-jun/opencodex/actions/runs/36333529243) | #5871 | NEAR-PASS; control-character rule and error text folded |
+| Memory-phase model routing | #6109 `fd4087e9c6` | [36336008534](https://github.com/lidge-jun/opencodex/actions/runs/36336008534) | #5983 (#5982) | NEAR-PASS; malformed/null metadata folded; debug-log finding withdrawn |
+| macOS system proxy discovery | #6111 `001b83317b` | [36333558812](https://github.com/lidge-jun/opencodex/actions/runs/36333558812) | #5893 (#5853) | PASS after four rounds (defaults, noProxy, localhost) |
+| Kilo Code integration | #6114 `e54be87916` | [36336759783](https://github.com/lidge-jun/opencodex/actions/runs/36336759783) | #5272 | PASS; conflict naming and disable-under-conflict folded |
+| Factory Droid integration | #6115 `adbd3927b7` | [36338469971](https://github.com/lidge-jun/opencodex/actions/runs/36338469971) | #5193 | PASS after four rounds (legacy collisions, selectors, IPv6) |
+
+## Decisions that changed the roadmap
+
+- **Qoder (#5950, #5660): HOLD.** Qoder's own CLI documentation says not to configure BYOK manually in `settings.json` and documents no `providers`/`modelConfigs` schema. A writer could therefore target a file the client does not honour. Comments on #5950 and #5660 ask for a supported import path or official schema.
+- **macOS default exceptions (020 amendment).** The strict "refuse any unrepresentable exception" rule would never activate on a default macOS configuration (`*.local`, `169.254/16`). A Bun 1.4.0 probe showed `.local` matching `local` and its subdomains on label boundaries, while `*.local` and CIDR entries are ignored. So `*.` maps to `.`, and only the exact link-local ranges are dropped, with a notice. Any other CIDR, glob, or simple-host rule still refuses before an environment write.
+- **Kilo and Droid landed together** because Qoder was held. That made the client count seventeen, which needed one reconciliation commit.
+- **Test layout seeds moved.** The union of new test registrations brought `scripts/test-layout/layout.json` to exactly 2,000 lines, which is `NEW_OVERSIZED`. `keepAtRoot`, `domains`, and `migrated` moved into `seeds.json` beside it, and `explicit` stayed in `layout.json`. No cap or exemption changed.
+
+## Held items and triage comments
+
+The following stay open. Each has an English comment naming the missing proof: #5950 and #5660 (Qoder contract), #5905 and #5679 (remote installer lookup must follow an explicit user action), #3833 (literal `apiKey` is refused by Command Code; needs a documented key reference or `false`), #4854 (OpenScience schema and ownership), #3494 (a named VS Code extension's supported settings and reload lifecycle), #1416 (a versioned, secret-free Orca launch manifest), and #2811 (design only; needs a reachable provenance predicate before any apply).
+
+## Verification boundaries
+
+Local full root suites were not run. Seven lane worktrees share one Bun test lock and one machine, so hosted CI shards are the broad gate. Each lane PR's and the batch's Verification sections list the focused and GUI runs. Not exercised: a real macOS Settings session (`scutil` is mocked), live Kilo or Droid clients (schemas are checked against vendor documentation), and native Windows (Windows-shaped path tests only). The merge SHA and the post-merge `dev` CI run are recorded in the lane's final report and on #6124.
+
diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md
index d4438e568fb..7588d5526fe 100644
--- a/docs-site/src/content/docs/fr/guides/integrations.md
+++ b/docs-site/src/content/docs/fr/guides/integrations.md
@@ -1,10 +1,10 @@
---
title: Intégrations
-description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast et omo depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture.
+description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI, Kilo et Factory Droid depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture.
---
L'onglet **Intégrations** écrit le bloc fournisseur d'opencodex dans le fichier de configuration du client,
-puis peut le retirer. Quinze clients fonctionnent ainsi, chacun avec son propre commutateur :
+puis peut le retirer. Dix-sept clients fonctionnent ainsi, chacun avec son propre commutateur :
| Client | Fichier de configuration | Format | Prise d'effet de la modification | Identifiant |
|---|---|---|---|---|
@@ -23,6 +23,8 @@ puis peut le retirer. Quinze clients fonctionnent ainsi, chacun avec son propre
| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immédiatement à l'enregistrement — Raycast surveille le fichier | aucun — bouclage uniquement |
| omo | `~/.omo/agent/models.json` | JSON | nouvelles sessions | espace réservé de bouclage |
| Cline CLI | `~/.cline/data/settings/providers.json` + `models.json` | JSON | après arrêt et redémarrage | bouclage uniquement |
+| Kilo | premier fichier existant parmi `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json` ou `config.json` sous `~/.config/kilo` (`XDG_CONFIG_HOME` déplace ce répertoire ; `kilo.jsonc` est créé si aucun n'existe) | JSONC | nouvelles sessions | `OPENCODEX_KILO_API_KEY` |
+| Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` sous Windows) | JSON | dès la détection du fichier | boucle locale sans clé |
Les modèles GJC dotés d'une échelle d'effort de raisonnement prise en charge exportent `reasoning: true`, `thinking.levels` et `compat.supportsReasoningEffort`, afin que GJC propose le choix de l'effort. Les modèles Codex natifs reçoivent leur échelle standard même si le catalogue l'omet. Ces champs sont absents sans échelle connue ; `none` n'envoie pas d'effort et `ultra` devient `max` sur le réseau. Actualisez l'intégration pour mettre à jour ces options.
@@ -299,3 +301,17 @@ ocx integration client restore --op
```
[CLI / rollback / CLINE_PROVIDER_SETTINGS_PATH](/guides/integrations/#cline-cli).
+
+## Kilo
+
+Kilo n’écrit que `provider.opencodex` dans le premier fichier global existant sous `~/.config/kilo` (`XDG_CONFIG_HOME` déplace ce répertoire ; `kilo.jsonc` est créé si aucun candidat n’existe). Si un autre fichier candidat définit aussi `provider.opencodex`, l’état signale un conflit et Appliquer refuse. Les autres clés restent inchangées. Appliquer réécrit tout le fichier ; commentaires et virgules finales ne sont pas conservés. Sélectionnez `opencodex/` dans Kilo.
+
+Désactiver peut retirer le bloc appartenant à OpenCodex du fichier enregistré même si un autre candidat est en conflit ou ne peut pas être analysé ; cet autre fichier reste intact.
+
+```bash
+ocx integration client enable --client kilo
+```
+
+## Factory Droid
+
+Factory Droid utilise `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` sous Windows). Activez explicitement l’intégration avec `ocx integration client enable --client droid`, puis choisissez un modèle personnalisé dans `/model`. Les entrées gérées n’utilisent pas de clé et fonctionnent uniquement en boucle locale. La désactivation supprime ces entrées ; l’annulation restaure les octets sauvegardés. Si l’ancien `config.json` contient des entrées OpenCodex ou si `settings.local.json` remplace `customModels`, résolvez ce conflit avant l’activation. Consultez la [documentation Factory BYOK](https://docs.factory.ai/model-independence/byok).
diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md
index b63eb62f9c3..f9db378b53d 100644
--- a/docs-site/src/content/docs/fr/reference/cli/agents.md
+++ b/docs-site/src/content/docs/fr/reference/cli/agents.md
@@ -176,7 +176,7 @@ Gérez et appliquez la clôture du modèle Grok Build.
## Exportation de la configuration client
-### `ocx export --client `
+### `ocx export --client `
Imprimez une configuration client connectée au proxy en cours d'exécution. La commande sérialise le
bloc fournisseur `opencodex` — URL de base, liste de modèles et référence d’identifiant du client
@@ -187,7 +187,7 @@ les modèles Codex peuvent actuellement voir.
| Option | Actions |
| --- | --- |
-| `--client ` | Requis. Sélectionne le dialecte de configuration client. |
+| `--client ` | Requis. Sélectionne le dialecte de configuration client. |
| `--json` | Imprimez le document généré en tant que JSON sur la sortie standard pour les scripts. Il s'agit de JSON même lorsque le format natif du client sélectionné est YAML, TOML ou JSON5. |
| `--out ` | Écrivez le format de configuration natif du client dans ``. Refuse de remplacer un fichier existant. |
| `--force` | Autoriser `--out` à remplacer un fichier existant. |
@@ -220,6 +220,8 @@ propres valeurs par défaut à ces lignes.
| `aside` | `~/.aside/u//models.json` pour le compte que le fichier `accounts.json` d'Aside désigne comme courant ; un manifeste illisible est refusé plutôt que de retomber sur un compte | `aside-models.json` | aucun — espace réservé de bouclage |
| `raycast` | `~/.config/raycast/ai/providers.yaml`, sur macOS comme sur Windows (Raycast n'honore pas `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | aucun — bouclage uniquement, aucune entrée `api_keys` n'est écrite |
| `omo` | `~/.omo/agent/models.json` (`OMO_CODING_AGENT_DIR`, puis `SENPI_CODING_AGENT_DIR`, puis `PI_CODING_AGENT_DIR` l'emportent dans cet ordre une fois définis ; une valeur relative est refusée) | `omo-models.json` | aucun — espace réservé de bouclage |
+| `kilo` | premier fichier existant parmi `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json` ou `config.json` sous `~/.config/kilo` (`XDG_CONFIG_HOME` déplace ce répertoire) ; utilise `kilo.jsonc` si aucun n'existe | `kilo.jsonc` | `OPENCODEX_KILO_API_KEY` |
+| `droid` | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | `factory-settings.json` | boucle locale uniquement ; aucune variable d’environnement |
L'exportation Raycast est un document `providers.yaml` autonome contenant un seul élément `id: opencodex`
dans la séquence `providers` : `name: OpenCodex`, l'URL de base `/v1` du proxy et chaque modèle routé avec
diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md
index 3a9ca385079..6fe894b567f 100644
--- a/docs-site/src/content/docs/fr/reference/configuration/server.md
+++ b/docs-site/src/content/docs/fr/reference/configuration/server.md
@@ -273,4 +273,4 @@ compte et la charge de travail prévus.
## Diagnostic réseau des quotas Codex
-Le champ `quotaRefresh` de la ligne du compte Codex principal décrit la récupération du quota, pas le quota restant ni les droits d’accès au modèle. Il peut être absent lorsque les données sont en cache ou qu’aucune récupération n’a eu lieu. La requête utilise l’environnement du service proxy en cours d’exécution, pas celui du terminal interactif. Sans `proxy`, l’environnement existant est conservé ; `"auto"` lit uniquement le proxy statique Windows au démarrage. PAC/WPAD, les paramètres SOCKS seuls et les changements à chaud ne sont pas pris en compte automatiquement. Un succès avec TUN ne valide pas à lui seul le chemin du proxy HTTP. Consultez [les commandes et les états en anglais](/reference/configuration/server/#codex-quota-network-diagnostics).
+Le champ `quotaRefresh` de la ligne du compte Codex principal décrit la récupération du quota, pas le quota restant ni les droits d’accès au modèle. Il peut être absent lorsque les données sont en cache ou qu’aucune récupération n’a eu lieu. La requête utilise l’environnement du service proxy en cours d’exécution, pas celui du terminal interactif. Sans `proxy`, l’environnement existant est conservé ; `"auto"` lit les paramètres HTTP/HTTPS statiques de Windows ou macOS au démarrage. Sur macOS, un proxy hérité empêche cette lecture. Sur macOS, un motif valide `*.` devient `.` : `foo.local` contourne le proxy pour `*.local`, `xlocal` non, et le nom racine `local` le contourne aussi. Les plages exactes `169.254/16`, `169.254.0.0/16` et `fe80::/10` sont ignorées avec un diagnostic : les adresses IP link-local passent par le proxy. Les autres plages CIDR, motifs glob et exceptions de noms simples refusent la découverte sans modifier l’environnement. Les adresses IP et `*` restent acceptés. PAC/WPAD, les paramètres SOCKS seuls et les changements à chaud ne sont pas pris en compte automatiquement. Un succès avec TUN ne valide pas à lui seul le chemin du proxy HTTP. Consultez [les commandes et les états en anglais](/reference/configuration/server/#codex-quota-network-diagnostics).
diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md
index 6fb41ccf045..2049192ce2d 100644
--- a/docs-site/src/content/docs/guides/combos.md
+++ b/docs-site/src/content/docs/guides/combos.md
@@ -270,6 +270,16 @@ constrained by that target's advertised ladder. JEV is not asked again if the se
retryable failure—the existing Combo cooldown and fallback loop continues through the remaining
configured targets.
+For each JEV target, **Models → Combos → Config** has an optional **Additional model notes for JEV**
+field (up to 512 characters; line breaks and tabs are allowed, other control characters are rejected). It is stored as `targets[].modelProfile` in the combo config. The
+built-in target profile remains in the trusted `instructions.model_profiles`; a non-empty note is
+sent separately in the decision state's `operator_notes`, keyed by target, and supplements rather
+than replaces that built-in profile. Notes can describe operator-specific context or subscription
+allowances; do not confuse subscription allowances with public per-token API pricing. Blank notes
+are ignored. Operator notes are evidence for the decision, not commands, and cannot expand the
+target allowlist or reasoning-effort limits. Only put information there that may be disclosed to
+TypeSafe.
+
Each logical model call is decided on its own; there is no per-conversation pin. Consecutive turns of
one session can therefore land on different targets, and every switch starts a cold provider prompt
cache, so a mix of very different targets can cost more input tokens than it saves. Keep the
diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md
index 9d7d4509abc..4cb6ead058c 100644
--- a/docs-site/src/content/docs/guides/integrations.md
+++ b/docs-site/src/content/docs/guides/integrations.md
@@ -1,10 +1,10 @@
---
title: Integrations
-description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo and Cline CLI from the dashboard — one switch per client, with a backup taken before every write.
+description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI, Kilo and Factory Droid from the dashboard — one switch per client, with a backup taken before every write.
---
The **Integrations** tab writes opencodex's provider block into a client's own config
-file, and removes it again. Fifteen clients work this way, each with a switch:
+file, and removes it again. Seventeen clients work this way, each with a switch:
| Client | Config file | Format | When the change takes effect | Credential |
|---|---|---|---|---|
@@ -23,6 +23,8 @@ file, and removes it again. Fifteen clients work this way, each with a switch:
| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only |
| omo | `~/.omo/agent/models.json` | JSON | new sessions | loopback placeholder |
| Cline CLI | `~/.cline/data/settings/providers.json` and sibling `models.json` | JSON pair | after stopping and restarting Cline | loopback placeholder |
+| Kilo | first existing `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json`, or `config.json` under `~/.config/kilo` | JSONC | new sessions | `OPENCODEX_KILO_API_KEY` |
+| Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | JSON | immediately via file watching | none — keyless loopback |
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
@@ -518,3 +520,53 @@ listens on a non-loopback address, put a data-admission key (the token described
key) in the app's API key field. The app sends it as `Authorization: Bearer`, which
`/v1/chat/completions` accepts as proxy admission and never forwards upstream; see the
[authentication matrix](/reference/proxy-formats/#authentication-matrix).
+
+## Kilo
+
+Kilo CLI, VS Code, and JetBrains share one global config. This integration writes
+`provider.opencodex` into the first existing file among `kilo.jsonc`, `kilo.json`,
+`opencode.jsonc`, `opencode.json`, and `config.json` under `~/.config/kilo`
+(`XDG_CONFIG_HOME` relocates that directory). If none exist, the destination is
+`kilo.jsonc`. Project configs are never written.
+Kilo merges all of these global files. If another candidate also defines
+`provider.opencodex`, status names every competing file and Apply and Replace refuse;
+remove `provider.opencodex` from those files before enabling the integration. An unreadable or unsafe
+candidate also blocks the write. Disable can still remove a block owned in the recorded file
+while another candidate conflicts or cannot be parsed; the other candidate is left untouched.
+
+The owned fragment is only `provider.opencodex` (OpenCode V1 shape: `npm`, `options`,
+`models`). Kilo's published schema has no OpenCode V2 `providers` key, so that block is
+not emitted. `$schema`, `model`, `enabled_providers`, MCP, and other keys stay
+user-owned. Select `opencodex/` in Kilo after applying.
+
+Loopback uses `{env:OPENCODEX_KILO_API_KEY}` as `options.apiKey`. A non-loopback bind
+moves admission to `options.headers["x-opencodex-api-key"]` and never serializes a real
+key. Apply rewrites the whole global file as pretty JSON, so comments and trailing
+commas in other keys are not preserved. Kilo is not on the implicit catalog fan-out;
+refresh it explicitly after changing the routed model selection.
+
+```bash
+ocx integration client enable --client kilo
+ocx export --client kilo --out ./kilo.jsonc
+```
+
+## Factory Droid
+
+Run Droid once to create `~/.factory`, then explicitly enable this integration with
+`ocx integration client enable --client droid`. OpenCodex adds only documented
+`customModels` entries to your personal `settings.json`, using a keyless local
+Chat Completions endpoint. Choose a row from Droid's `/model` picker. Disable
+removes the managed rows; Undo restores the exact saved file. Other settings and
+custom models remain yours.
+
+Models whose IDs or display names contain `,` or `]` are skipped because the
+managed selector cannot address them safely; export and managed settings show
+the same rows. A nonempty catalog with no addressable models is refused.
+
+Droid also reads legacy `config.json` and local `settings.local.json`. Resolve
+legacy rows that use the OpenCodex endpoint, a generated model ID, or an
+`OpenCodex:` display name, and any local `customModels` override, before enabling;
+OpenCodex refuses those ambiguous settings. It also refuses an
+unsafe target or a row edited since apply. The integration is loopback only and
+never copies provider credentials. Factory documents the [BYOK schema](https://docs.factory.ai/model-independence/byok)
+and [personal settings path](https://docs.factory.ai/droid-cli/settings).
diff --git a/docs-site/src/content/docs/ja/guides/integrations.md b/docs-site/src/content/docs/ja/guides/integrations.md
index 5f1b21e521f..15e2f8ba560 100644
--- a/docs-site/src/content/docs/ja/guides/integrations.md
+++ b/docs-site/src/content/docs/ja/guides/integrations.md
@@ -1,9 +1,9 @@
---
title: クライアント統合
-description: ダッシュボードから opencodex を OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、gjc、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside、Raycast、omo、Cline CLI に接続します。クライアントごとにスイッチがあり、書き込み前には必ずバックアップを取ります。
+description: ダッシュボードから opencodex を OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、gjc、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside、Raycast、omo、Cline CLI、Kilo、Factory Droid に接続します。クライアントごとにスイッチがあり、書き込み前には必ずバックアップを取ります。
---
-**Integrations** タブは、各クライアントの設定ファイルに opencodex のプロバイダーブロックを書き込み、必要に応じて削除します。次の 15 クライアントは、それぞれのスイッチで管理できます。
+**Integrations** タブは、各クライアントの設定ファイルに opencodex のプロバイダーブロックを書き込み、必要に応じて削除します。次の 17 クライアントは、それぞれのスイッチで管理できます。
| クライアント | 設定ファイル | 形式 | 変更が反映される時点 | 認証情報 |
|---|---|---|---|---|
@@ -22,6 +22,8 @@ description: ダッシュボードから opencodex を OpenCode、Pi、OMP、Her
| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | 保存後すぐ。Raycast がファイルを監視 | なし。ループバックのみ |
| omo | `~/.omo/agent/models.json` | JSON | 新しいセッション | ループバック用プレースホルダー |
| Cline CLI | `~/.cline/data/settings/providers.json` と同階層の `models.json` | JSON のペア | Cline の停止と再起動後 | ループバック用プレースホルダー |
+| Kilo | `~/.config/kilo` 内で最初に存在する `kilo.jsonc`、`kilo.json`、`opencode.jsonc`、`opencode.json`、`config.json`(`XDG_CONFIG_HOME` でディレクトリを変更可能。どれもなければ `kilo.jsonc` を作成) | JSONC | 新しいセッション | `OPENCODEX_KILO_API_KEY` |
+| Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` Windows の場合) | JSON | ファイル変更を即時反映 | キー不要のループバック |
生成されるカタログには、各プロバイダーの選択で有効なモデルのみが含まれます。これはダウンロードと管理対象の統合の両方に適用され、Pi と Aside も対象です。管理画面のモデル一覧にはすべてのモデルが表示されるため、追加のモデルを有効にできます。
@@ -216,6 +218,21 @@ Undo は、もともと存在しなかったファイルも含め、**元の両
ダウンロードされる `cline-config-bundle.json` には、`providers.json` 用の `settings` と `models.json` 用の `catalog` という 2 つのネイティブ文書要素が含まれます。それ自体は Cline の設定ファイルではありません。ジャーナル付きのマージとロールバックには統合コマンドを使ってください。生成された統合はリモートの受け入れ認証に対応せず、認証不要のループバックアクセスが必要です。
+## Kilo
+
+Kilo CLI、VS Code、JetBrains は同じグローバル設定を共有します。この統合は `~/.config/kilo` 内の `kilo.jsonc`、`kilo.json`、`opencode.jsonc`、`opencode.json`、`config.json` のうち最初に存在するファイルに `provider.opencodex` を書き込みます。`XDG_CONFIG_HOME` でこのディレクトリを変更できます。候補がなければ `kilo.jsonc` を作成します。プロジェクト設定には書き込みません。
+
+Kilo はこれらのグローバルファイルをすべてマージします。別の候補も `provider.opencodex` を定義する場合、状態に競合ファイルが表示され、適用と置換は拒否されます。有効化する前に、そのファイルから `provider.opencodex` を削除してください。所有済みファイルの無効化は競合があっても実行できます。読み取れない候補や安全に扱えない候補も書き込みを妨げます。
+
+管理対象は OpenCode V1 形式の `provider.opencodex`(`npm`、`options`、`models`)だけです。OpenCode V2 の `providers` は出力しません。`$schema`、`model`、`enabled_providers`、MCP などのキーはユーザーが管理します。適用後、Kilo で `opencodex/` を選択してください。
+
+ループバックでは `options.apiKey` に `{env:OPENCODEX_KILO_API_KEY}` を使います。ループバック以外へのバインドでは認証を `options.headers["x-opencodex-api-key"]` に移し、実際のキーは保存しません。適用時はグローバルファイル全体を整形済み JSON として書き直すため、他のキーのコメントと末尾カンマは保持されません。Kilo は自動カタログ更新の対象外です。ルーティング対象のモデル選択を変更したら、明示的に更新してください。
+
+```bash
+ocx integration client enable --client kilo
+ocx export --client kilo --out ./kilo.jsonc
+```
+
## GitHub Copilot アプリ
GitHub Copilot デスクトップアプリでは、opencodex を OpenAI 互換のモデルプロバイダーとして利用できます。これは手動で設定するクライアントで、Integrations タブのスイッチはありません。また、opencodex がバックエンドとして Copilot サブスクリプションを使う上流の `github-copilot` プロバイダーとは別のものです。
@@ -240,3 +257,7 @@ GitHub Copilot デスクトップアプリでは、opencodex を OpenAI 互換
アプリはモデルの検出に `GET /v1/models`、リクエストの処理に `POST /v1/chat/completions` を使います。リクエストは opencodex の通常のモデルルーティングを通るため、他のクライアントと同じように、プロバイダーの認証情報、OAuth アカウント、コンボが適用されます。受け付けるリクエストフィールドは[プロキシ形式のリファレンス](/reference/proxy-formats/)を参照してください。
モデルが見つからないと表示される場合は、Base URL が `/v1/chat/completions` ではなく `/v1` で終わっていることと、`/v1/models` が空でない `data` 配列を返すことを確認してください。opencodex がループバック以外のアドレスで待ち受けている場合は、アプリの API key 欄にデータ受け入れキー([リモートアクセス](/reference/configuration/server/#remote-access)に記載されたトークン、またはダッシュボードで生成した `ocx_…` キー)を入力します。アプリはこれを `Authorization: Bearer` として送信します。`/v1/chat/completions` はこれをプロキシの受け入れ認証にだけ使い、上流には転送しません。詳しくは[認証マトリクス](/reference/proxy-formats/#authentication-matrix)を参照してください。
+
+## Factory Droid
+
+Factory Droid は `~/.factory/settings.json`(Windows では `%USERPROFILE%\.factory\settings.json`)を使用します。`ocx integration client enable --client droid` で明示的に有効化し、`/model` でカスタムモデルを選択します。管理対象の行はキーを使わず、ループバックでのみ動作します。無効化すると管理対象の行が削除され、Undo で保存済みのバイト列が復元されます。従来の `config.json` に OpenCodex の行がある場合や、`settings.local.json` が `customModels` を上書きする場合は、有効化する前に競合を解消してください。[Factory BYOK のドキュメント](https://docs.factory.ai/model-independence/byok)も参照してください。
diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md
index 20d62ca3eca..bf7b59fc1b3 100644
--- a/docs-site/src/content/docs/ja/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ja/reference/cli/agents.md
@@ -136,7 +136,7 @@ Grok Build モデル フェンスを管理および適用します。
## クライアント設定のエクスポート
-### `ocx export --client `
+### `ocx export --client `
実行中のプロキシに接続するクライアント設定を出力します。このコマンドは、ベース URL、モデル一覧、およびクライアントに応じた認証情報参照または `opencodex-loopback` プレースホルダーを含む `opencodex` プロバイダーブロックを、選択したクライアントのネイティブ形式でシリアル化します。
@@ -144,7 +144,7 @@ Grok Build モデル フェンスを管理および適用します。
|旗 |アクション |
| --- | --- |
-| `--client ` |必須。クライアントの設定形式を選択します。 |
+| `--client ` |必須。クライアントの設定形式を選択します。 |
| `--json` |構成 JSON のみを標準出力に出力するため、リダイレクトはバイト正確な出力をキャプチャします。 `--out` 書き込みメモを含むすべての診断は stderr に送られます。 |
| `--out ` |設定を `` に書き込みます。既存のファイルの置き換えを拒否します。 |
| `--force` | `--out` が既存のファイルを置き換えることを許可します。 |
@@ -174,6 +174,8 @@ ocx export --client opencode --out ~/opencodex-opencode.json
| `aside` | `~/.aside/u//models.json`。Aside 自身の `accounts.json` が現在のアカウントとして指す account を使います。マニフェストが読めない場合は、既定のアカウントに落とさず拒否します | `aside-models.json` | なし — loopback placeholder |
| `raycast` | `~/.config/raycast/ai/providers.yaml` (macOS と Windows で同じ。Raycast は `XDG_CONFIG_HOME` を尊重しません) | `raycast-providers.yaml` | なし — loopback のみ。`api_keys` エントリは書き込まれません |
| `omo` | `~/.omo/agent/models.json` (`OMO_CODING_AGENT_DIR`、次に `SENPI_CODING_AGENT_DIR`、次に `PI_CODING_AGENT_DIR` の順で設定時に優先。相対値は拒否されます) | `omo-models.json` | なし — loopback placeholder |
+| `kilo` | `~/.config/kilo` 配下で最初に存在する `kilo.jsonc`、`kilo.json`、`opencode.jsonc`、`opencode.json`、`config.json`(`XDG_CONFIG_HOME` が設定されていればその配下)。候補がなければ `kilo.jsonc` | `kilo.jsonc` | `OPENCODEX_KILO_API_KEY` |
+| `droid` | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | `factory-settings.json` | ループバックのみ・環境変数不要 |
Raycast のエクスポートは、`providers` シーケンスに `id: opencodex` 要素を 1 つだけ持つ独立した `providers.yaml` 文書です。内容は `name: OpenCodex`、プロキシの `/v1` ベース URL、および `abilities` 付きのルーティング済み全モデルです (`tools` と `system_message` は常にサポート、`vision` はカタログの入力モダリティから、`reasoning_effort` はモデルに effort ラダーがある場合、`temperature` は推論モデルではオフ)。Custom Providers は Raycast Pro の機能で、Raycast はこのファイルを監視しているため、保存した変更は再起動なしで反映されます。形式は [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) に記載されています。`api_keys` エントリは書き込まれないため、このエクスポートは loopback 専用で、loopback 以外のバインドは拒否されます。
diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md
index 27dbf08b986..7eeac7e4776 100644
--- a/docs-site/src/content/docs/ja/reference/configuration/server.md
+++ b/docs-site/src/content/docs/ja/reference/configuration/server.md
@@ -184,6 +184,6 @@ Anthropic OAuth サイドカーは、opencodex の既存のクロード コー
## Codex クォータのネットワーク診断
-メイン Codex アカウント行の `quotaRefresh` はクォータ取得の診断情報であり、残量やモデルへのアクセス権を示すものではありません。キャッシュ利用時や取得を行わない場合は省略されることがあります。取得には操作中のシェルではなく、実行中のプロキシサービスの環境が使われます。`proxy` 未設定では既存の環境を維持し、`"auto"` は起動時に Windows の静的プロキシ設定だけを読みます。PAC/WPAD、SOCKS のみの設定、実行中の変更は自動反映されません。TUN での成功だけでは HTTP プロキシ経路の正常性は確認できません。[コマンドと状態の説明(英語)](/reference/configuration/server/#codex-quota-network-diagnostics)を参照してください。
+メイン Codex アカウント行の `quotaRefresh` はクォータ取得の診断情報であり、残量やモデルへのアクセス権を示すものではありません。キャッシュ利用時や取得を行わない場合は省略されることがあります。取得には操作中のシェルではなく、実行中のプロキシサービスの環境が使われます。`proxy` 未設定では既存の環境を維持し、`"auto"` は起動時の Windows または macOS の静的 HTTP/HTTPS 設定を読みます。macOS では継承したプロキシがある場合、読み取りを行いません。macOS では有効な `*.` を `.` に変換します。`*.local` は `foo.local` と基底名 `local` を直接接続にしますが、`xlocal` は対象外です。`169.254/16`、`169.254.0.0/16`、`fe80::/10` は診断を出して省略し、リンクローカル IP アドレスはプロキシを使います。IP アドレスと `*` は受け入れますが、その他の CIDR、glob、単純ホスト名の例外では環境を変更せず検出を中止します。PAC/WPAD、SOCKS のみの設定、実行中の変更は自動反映されません。TUN での成功だけでは HTTP プロキシ経路の正常性は確認できません。[コマンドと状態の説明(英語)](/reference/configuration/server/#codex-quota-network-diagnostics)を参照してください。
`dropCodexSafetyBuffering`: プロバイダーの安全性の適用と拒否応答は変更しません。native `codex.response.metadata.headers` WebSocket メタデータと `/responses/compact` は対象外です。
diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md
index b1d4b990de0..e39df9ea9da 100644
--- a/docs-site/src/content/docs/ko/guides/integrations.md
+++ b/docs-site/src/content/docs/ko/guides/integrations.md
@@ -1,9 +1,9 @@
---
title: 연동
-description: 대시보드에서 OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI를 opencodex에 연결합니다. 클라이언트마다 스위치가 하나씩 있으며 기록 전마다 백업합니다.
+description: 대시보드에서 OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI, Kilo와 Factory Droid를 opencodex에 연결합니다. 클라이언트마다 스위치가 하나씩 있으며 기록 전마다 백업합니다.
---
-**Integrations** 탭은 클라이언트의 설정 파일에 opencodex 프로바이더 블록을 쓰고 다시 제거합니다. 다음 15개 클라이언트는 각각 스위치로 관리합니다.
+**Integrations** 탭은 클라이언트의 설정 파일에 opencodex 프로바이더 블록을 쓰고 다시 제거합니다. 다음 17개 클라이언트는 각각 스위치로 관리합니다.
| 클라이언트 | 설정 파일 | 형식 | 변경 적용 시점 | 자격 증명 |
|---|---|---|---|---|
@@ -22,6 +22,8 @@ description: 대시보드에서 OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code,
| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | 저장 즉시 — Raycast가 파일을 감시함 | 없음 — 루프백 전용 |
| omo | `~/.omo/agent/models.json` | JSON | 새 세션에서 | 루프백 자리표시자 |
| Cline CLI | `~/.cline/data/settings/providers.json` 및 같은 위치의 `models.json` | JSON 파일 쌍 | Cline을 중지하고 다시 시작한 뒤 | 루프백 자리표시자 |
+| Kilo | `~/.config/kilo`에서 먼저 존재하는 `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json`, `config.json` (`XDG_CONFIG_HOME`로 디렉터리 변경 가능, 모두 없으면 `kilo.jsonc` 생성) | JSONC | 새 세션에서 | `OPENCODEX_KILO_API_KEY` |
+| Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` Windows에서) | JSON | 파일 변경 시 즉시 | 키 없는 루프백 |
생성된 카탈로그에는 각 프로바이더 선택에서 활성화된 모델만 들어갑니다. Pi와 Aside를 포함한 다운로드와 관리형 연동 모두에 적용됩니다. 관리 모델 목록에는 전체 모델이 계속 표시되어 추가 모델을 활성화할 수 있습니다.
@@ -214,6 +216,21 @@ Undo는 원래 없던 파일까지 포함해 **두 원본 바이트 문자열
다운로드되는 `cline-config-bundle.json`에는 두 네이티브 문서 구성 요소가 있습니다. `providers.json`용 `settings`와 `models.json`용 `catalog`입니다. 번들 자체가 Cline 설정 파일은 아닙니다. 저널을 남기는 병합과 롤백에는 연동 명령을 권장합니다. 생성된 연동은 원격 수용 연결을 지원하지 않으며 인증이 없는 루프백 접근이 필요합니다.
+## Kilo
+
+Kilo CLI, VS Code, JetBrains는 전역 설정을 공유합니다. 이 연동은 `~/.config/kilo` 아래의 `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json`, `config.json` 중 먼저 존재하는 파일에 `provider.opencodex`를 씁니다. `XDG_CONFIG_HOME`로 이 디렉터리를 옮길 수 있습니다. 후보 파일이 없으면 `kilo.jsonc`를 만듭니다. 프로젝트 설정은 수정하지 않습니다.
+
+Kilo는 이 전역 파일을 모두 병합합니다. 다른 후보 파일에도 `provider.opencodex`가 있으면 상태에 충돌 파일을 표시하고 적용과 교체를 거부합니다. 연동을 켜기 전에 해당 파일에서 `provider.opencodex`를 제거하세요. 이미 소유한 파일의 블록은 충돌 중에도 비활성화할 수 있습니다. 읽을 수 없거나 안전하지 않은 후보 파일도 쓰기를 막습니다.
+
+관리하는 부분은 OpenCode V1 형식의 `provider.opencodex`(`npm`, `options`, `models`)뿐입니다. OpenCode V2의 `providers` 키는 내보내지 않습니다. `$schema`, `model`, `enabled_providers`, MCP 등의 키는 사용자가 관리합니다. 적용한 뒤 Kilo에서 `opencodex/`을 선택하세요.
+
+루프백에서는 `options.apiKey`로 `{env:OPENCODEX_KILO_API_KEY}`를 사용합니다. 루프백이 아닌 바인드에서는 인증을 `options.headers["x-opencodex-api-key"]`로 옮기며 실제 키를 저장하지 않습니다. 적용 시 전역 파일 전체를 보기 좋은 JSON으로 다시 쓰므로 다른 키의 주석과 후행 쉼표는 보존되지 않습니다. Kilo는 자동 카탈로그 갱신 대상이 아닙니다. 라우팅 모델 선택을 바꾼 뒤에는 명시적으로 갱신하세요.
+
+```bash
+ocx integration client enable --client kilo
+ocx export --client kilo --out ./kilo.jsonc
+```
+
## GitHub Copilot 앱
GitHub Copilot 데스크톱 앱에서 opencodex를 OpenAI 호환 모델 프로바이더로 사용할 수 있습니다. Integrations 탭의 스위치가 없는 수동 클라이언트 설정이며, opencodex의 백엔드로 Copilot 구독을 사용하는 upstream `github-copilot` 프로바이더와는 별개입니다.
@@ -238,3 +255,7 @@ GitHub Copilot 데스크톱 앱에서 opencodex를 OpenAI 호환 모델 프로
앱은 모델 검색에 `GET /v1/models`, 요청 처리에 `POST /v1/chat/completions`를 사용합니다. 요청은 opencodex의 일반 모델 라우팅을 거치므로 다른 클라이언트와 마찬가지로 프로바이더 자격 증명, OAuth 계정, 콤보가 적용됩니다. 허용되는 요청 필드는 [프록시 형식 레퍼런스](/reference/proxy-formats/)를 확인하세요.
모델이 없다고 표시되면 Base URL이 `/v1/chat/completions`가 아니라 `/v1`로 끝나는지, `/v1/models`가 비어 있지 않은 `data` 배열을 반환하는지 확인하세요. opencodex가 루프백이 아닌 주소에서 수신 대기한다면 앱의 API key 입력란에 데이터 수용 키([원격 액세스](/reference/configuration/server/#remote-access)에 설명된 토큰 또는 대시보드에서 생성한 `ocx_…` 키)를 입력하세요. 앱은 이를 `Authorization: Bearer`로 전송하며, `/v1/chat/completions`는 프록시 수용 인증에만 사용하고 upstream으로 전달하지 않습니다. 자세한 내용은 [인증 매트릭스](/reference/proxy-formats/#authentication-matrix)를 확인하세요.
+
+## Factory Droid
+
+Factory Droid는 `~/.factory/settings.json`(Windows에서는 `%USERPROFILE%\.factory\settings.json`)을 사용합니다. `ocx integration client enable --client droid`로 명시적으로 활성화한 다음 `/model`에서 사용자 지정 모델을 선택하세요. 관리되는 항목에는 키가 없으며 루프백에서만 동작합니다. 비활성화하면 관리되는 항목이 제거되고, Undo는 저장된 원본 바이트를 복원합니다. 기존 `config.json`에 OpenCodex 항목이 있거나 `settings.local.json`이 `customModels`를 덮어쓰면 활성화 전에 충돌을 해결하세요. [Factory BYOK 문서](https://docs.factory.ai/model-independence/byok)를 참고하세요.
diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md
index 8dec1675fc8..1a26b811bf9 100644
--- a/docs-site/src/content/docs/ko/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ko/reference/cli/agents.md
@@ -163,7 +163,7 @@ Grok Build model fence를 관리하고 적용합니다.
## 클라이언트 설정 내보내기
-### `ocx export --client `
+### `ocx export --client `
실행 중인 프록시에 연결할 client config를 출력합니다. 이 명령은 base URL, model list, 그리고 client에 따라 credential reference 또는 `opencodex-loopback` placeholder를 포함한 `opencodex` provider block을 선택한 client의 네이티브 형식으로 직렬화합니다.
@@ -171,7 +171,7 @@ Grok Build model fence를 관리하고 적용합니다.
| 플래그 | 동작 |
| --- | --- |
-| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. |
+| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. |
| `--json` | config JSON만 stdout에 출력하므로, redirect가 byte-exact 출력을 캡처합니다. `--out` write note를 포함한 모든 진단 메시지는 stderr로 갑니다. |
| `--out ` | config를 ``에 씁니다. 기존 파일이 있으면 덮어쓰지 않습니다. |
| `--force` | `--out`이 기존 파일을 덮어쓰도록 허용합니다. |
@@ -201,6 +201,8 @@ ocx export --client opencode --out ~/opencodex-opencode.json
| `aside` | `~/.aside/u//models.json`. Aside의 `accounts.json`이 현재 계정으로 지정한 account를 사용합니다. 매니페스트를 읽을 수 없으면 임의의 계정으로 넘어가지 않고 거부합니다 | `aside-models.json` | 없음 — loopback placeholder |
| `raycast` | `~/.config/raycast/ai/providers.yaml` (macOS와 Windows 모두 동일. Raycast는 `XDG_CONFIG_HOME`을 따르지 않습니다) | `raycast-providers.yaml` | 없음 — loopback 전용. `api_keys` 항목은 쓰지 않습니다 |
| `omo` | `~/.omo/agent/models.json` (`OMO_CODING_AGENT_DIR`, `SENPI_CODING_AGENT_DIR`, `PI_CODING_AGENT_DIR` 순서로 설정된 값이 우선. 상대 경로는 거부됩니다) | `omo-models.json` | 없음 — loopback placeholder |
+| `kilo` | `~/.config/kilo`에서 먼저 존재하는 `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json` 또는 `config.json` (`XDG_CONFIG_HOME`이 설정되면 해당 디렉터리 사용); 후보가 없으면 `kilo.jsonc` | `kilo.jsonc` | `OPENCODEX_KILO_API_KEY` |
+| `droid` | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | `factory-settings.json` | 루프백 전용, 환경 변수 불필요 |
Raycast 내보내기는 `providers` 시퀀스에 `id: opencodex` 요소 하나만 담은 독립 `providers.yaml` 문서입니다. 내용은 `name: OpenCodex`, proxy의 `/v1` base URL, 그리고 `abilities`가 붙은 라우팅된 모든 모델입니다(`tools`와 `system_message`는 항상 지원, `vision`은 카탈로그의 입력 모달리티를 따름, `reasoning_effort`는 모델에 effort 사다리가 있을 때, `temperature`는 추론 모델에서 꺼짐). Custom Providers는 Raycast Pro 기능이며, Raycast가 이 파일을 감시하므로 저장한 변경은 재시작 없이 적용됩니다. 형식은 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)에 문서화되어 있습니다. `api_keys` 항목은 쓰지 않으므로 이 내보내기는 loopback 전용이며, loopback이 아닌 bind는 거부됩니다.
diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md
index b2a71843a4e..e166d1e50ba 100644
--- a/docs-site/src/content/docs/ko/reference/configuration/server.md
+++ b/docs-site/src/content/docs/ko/reference/configuration/server.md
@@ -243,4 +243,4 @@ Anthropic OAuth 사이드카는 opencodex의 기존 Claude Code OAuth fingerprin
## Codex 할당량 네트워크 진단
-메인 Codex 계정 행의 `quotaRefresh`는 할당량 조회 결과를 분류하는 진단값입니다. 남은 할당량이나 모델 접근 권한을 뜻하지 않으며, 캐시를 쓰거나 조회하지 않았다면 생략될 수 있습니다. 요청은 명령을 입력한 터미널이 아니라 실행 중인 프록시 서비스의 환경을 따릅니다. `proxy`를 지정하지 않으면 기존 환경을 유지하고, `"auto"`는 시작할 때 Windows의 정적 프록시 설정만 읽습니다. PAC/WPAD, SOCKS 전용 설정과 실행 중 변경은 자동으로 반영하지 않습니다. TUN에서 성공했다고 HTTP 프록시 경로도 정상이라는 뜻은 아닙니다. 명령과 상태값은 [네트워크 진단(영문)](/reference/configuration/server/#codex-quota-network-diagnostics)에서 확인하세요.
+메인 Codex 계정 행의 `quotaRefresh`는 할당량 조회 결과를 분류하는 진단값입니다. 남은 할당량이나 모델 접근 권한을 뜻하지 않으며, 캐시를 쓰거나 조회하지 않았다면 생략될 수 있습니다. 요청은 명령을 입력한 터미널이 아니라 실행 중인 프록시 서비스의 환경을 따릅니다. `proxy`를 지정하지 않으면 기존 환경을 유지하고, `"auto"`는 시작 시 Windows 또는 macOS의 정적 HTTP/HTTPS 설정을 읽습니다. macOS에서는 상속된 프록시가 있으면 읽지 않습니다. macOS에서는 유효한 `*.`을 `.`으로 바꿉니다. `*.local`은 `foo.local`과 최상위 이름 `local`을 직접 연결하지만 `xlocal`은 제외합니다. `169.254/16`, `169.254.0.0/16`, `fe80::/10`은 진단 메시지와 함께 생략하므로 링크 로컬 IP 주소는 프록시를 사용합니다. IP 주소와 `*`는 허용하지만 다른 CIDR, glob, 단순 호스트명 예외는 환경 변경 전에 탐색을 거부합니다. PAC/WPAD, SOCKS 전용 설정과 실행 중 변경은 자동으로 반영하지 않습니다. TUN에서 성공했다고 HTTP 프록시 경로도 정상이라는 뜻은 아닙니다. 명령과 상태값은 [네트워크 진단(영문)](/reference/configuration/server/#codex-quota-network-diagnostics)에서 확인하세요.
diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md
index 3d2e1a57aa1..933e2fc6baa 100644
--- a/docs-site/src/content/docs/reference/cli/agents.md
+++ b/docs-site/src/content/docs/reference/cli/agents.md
@@ -283,7 +283,7 @@ Manage and apply the Grok Build model fence.
## Client config export
-### `ocx export --client `
+### `ocx export --client `
Print a client config wired to the running proxy. The command serializes the
`opencodex` provider block — base URL, model list, and the client's credential
@@ -294,7 +294,7 @@ models Codex can currently see.
| Flag | Action |
| --- | --- |
-| `--client ` | Required. Selects the client config dialect. |
+| `--client ` | Required. Selects the client config dialect. |
| `--json` | Print the generated document as JSON on stdout for scripts. This is JSON even when the selected client's native format is YAML, TOML, or JSON5. |
| `--out ` | Write the client's native config format to ``. Refuses to replace an existing file. |
| `--force` | Allow `--out` to replace an existing file. |
@@ -326,6 +326,8 @@ client applies its own defaults for those).
| `aside` | `~/.aside/u//models.json` for the account Aside's own `accounts.json` names as current; an unreadable manifest is refused rather than defaulting to an account | `aside-models.json` | none — loopback placeholder |
| `raycast` | `~/.config/raycast/ai/providers.yaml` on macOS and Windows alike (Raycast does not honor `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | none — loopback only, no `api_keys` entry is written |
| `omo` | `~/.omo/agent/models.json` (`OMO_CODING_AGENT_DIR`, then `SENPI_CODING_AGENT_DIR`, then `PI_CODING_AGENT_DIR` win in that order when set; a relative value is refused) | `omo-models.json` | none — loopback placeholder |
+| `kilo` | first existing `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json`, or `config.json` under `~/.config/kilo` (`XDG_CONFIG_HOME` relocates that directory); uses `kilo.jsonc` when none exists | `kilo.jsonc` | `OPENCODEX_KILO_API_KEY` |
+| `droid` | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | `factory-settings.json` | loopback only; no environment variable |
The managed DSH export requires DSH 0.1.0-rc.6 or newer and owns only
`llm-pi-ai.providers.opencodex`. DSH hot reloads that provider; the user's default model and
diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md
index 3a299fa7f98..909edc30e26 100644
--- a/docs-site/src/content/docs/reference/configuration.md
+++ b/docs-site/src/content/docs/reference/configuration.md
@@ -61,7 +61,7 @@ a known configured model id. Cursor may require a model-list refresh or restart
`fastRows` is an optional boolean and defaults to `true`. The raw OpenAI-style
`/v1/models` list, Claude Code discovery, and client config exports (including pi, OpenCode,
-OMP, Hermes, OpenClaw, Kimi, gjc, DSH, MCode, ZCode, Prime, Aside, Raycast, and omo) add a `--fast` selector for every model whose
+OMP, Hermes, OpenClaw, Kimi, gjc, DSH, MCode, ZCode, Prime, Aside, Raycast, omo, Cline, and Kilo) add a `--fast` selector for every model whose
resolved Fast policy is eligible. Selecting one routes the base model and requests the `priority`
service tier — the same Fast the Codex app exposes through its picker toggle. The base row stays
listed, so the row is an addition rather than a replacement.
diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md
index c8e9f967ffb..f0d92698dbf 100644
--- a/docs-site/src/content/docs/reference/configuration/server.md
+++ b/docs-site/src/content/docs/reference/configuration/server.md
@@ -12,7 +12,7 @@ runs helper features around provider requests.
| --- | --- | --- | --- |
| `port` | `number` | `10100` | Proxy listen port. |
| `hostname?` | `string` | `"127.0.0.1"` | Bind address. A non-loopback bind requires a data-admission token, resolved from `OPENCODEX_API_AUTH_TOKEN`, then `OCX_API_TOKEN_FILE`, then the installed owner-only `service-api-token` — nothing has to be exported by hand. See [Remote access](#remote-access). |
-| `proxy?` | `string` | — | Outbound HTTP(S) or SOCKS5 proxy URL (`socks5://host:port`), `${ENV_VAR}`, or `"auto"`. HTTP URLs apply to `HTTP_PROXY` / `HTTPS_PROXY` when those are unset. SOCKS5 URLs use OpenCodex's real SOCKS5 transport and are also exposed through `ALL_PROXY` (`ocx start --socks5`); inherited `HTTP(S)_PROXY` is cleared in this process. Loopback stays in `NO_PROXY`. `"auto"` reads the Windows system proxy (WinINET `ProxyEnable`/`ProxyServer`) once at process start, preserves distinct `http=` and `https=` entries, and logs the hosts it chose. A bare `ProxyServer` value applies to both schemes. On other platforms, or when the system proxy is off, SOCKS-only, or unreadable, it uses direct egress and says so. PAC/WPAD and live proxy changes are not followed; restart the service after changing the system proxy. |
+| `proxy?` | `string` | — | Outbound HTTP(S) or SOCKS5 proxy URL (`socks5://host:port`), `${ENV_VAR}`, or `"auto"`. HTTP URLs apply to `HTTP_PROXY` / `HTTPS_PROXY` when those are unset. SOCKS5 URLs use OpenCodex's real SOCKS5 transport and are also exposed through `ALL_PROXY` (`ocx start --socks5`); inherited `HTTP(S)_PROXY` is cleared in this process. Loopback stays in `NO_PROXY`. `"auto"` reads Windows WinINET or macOS static HTTP/HTTPS settings once at startup. Inherited HTTP(S) proxy variables skip discovery; on macOS, inherited `ALL_PROXY`/`all_proxy` also skips it. Windows keeps separate `http=` and `https=` entries; a bare `ProxyServer` applies to both. macOS translates IP literals, `*`, and a valid `*.` glob to Bun's `.` bypass. That glob also bypasses the bare apex ``. The exact link-local ranges `169.254/16`, `169.254.0.0/16`, and `fe80::/10` are omitted with a diagnostic: link-local IP literals use the proxy. Other CIDRs, globs, and simple-host exceptions refuse discovery without changing the proxy environment. Disabled, malformed, PAC/WPAD, SOCKS-only, and live changes are not followed. Restart after changing system settings. |
| `noProxy?` | `string \| string[]` | — | Hosts that bypass `proxy`, merged with inherited `NO_PROXY` and loopback entries. A string may use comma-separated `NO_PROXY` syntax or `${ENV_VAR}`. |
| `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. |
| `dropCodexSafetyBuffering?` | `boolean` | `false` | Remove optional client-facing hints from canonical Codex Responses passthrough: the two `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` response headers, `response.metadata` events whose metadata type is `safety_buffering`, and top-level `safety_buffering` fields. Other headers, response data, policy refusals and failures are preserved. This does not disable provider safety enforcement or upstream buffering. Native `codex.response.metadata.headers` WebSocket metadata and `/responses/compact` are outside this filter. |
@@ -41,6 +41,7 @@ runs helper features around provider requests.
| `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. |
| `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. |
| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Redirect recognized Codex helper/shadow calls to a chosen model while preserving the request's configured reasoning effort. The default source prefixes are `gpt-6-luna` and `gpt-5.6-luna`; older clients through 0.144.x used `gpt-5.4-mini`, which `sourceModels` can restore. |
+| `memoryModels?` | `{ extract?: { model: string; reasoningEffort?: string }; consolidation?: { model: string; reasoningEffort?: string } }` | off | Route Codex's two memory phases to a chosen model, with an optional reasoning effort per phase. See [Memory routing](#memory-routing). |
| `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | Web-search sidecar options. |
| `visionSidecar?` | `OcxVisionSidecarConfig` | on when usable | Image-description sidecar options. |
| `images?` | `OcxImagesConfig` | automatic OpenAI selection | Standalone Images relay options for Codex `image_gen`. |
@@ -164,8 +165,14 @@ terminal does not update an already running service.
An unset `proxy` leaves inherited proxy variables unchanged. An explicit HTTP(S)
proxy URL fills `HTTP_PROXY` and `HTTPS_PROXY` only where they are unset.
-`"proxy": "auto"` reads the Windows static WinINET proxy once at startup; existing
-proxy environment variables take precedence. Auto discovery does not resolve
+`"proxy": "auto"` reads Windows static WinINET or macOS static HTTP/HTTPS
+settings once at startup. Existing proxy environment variables take precedence;
+macOS discovery also skips inherited `ALL_PROXY`/`all_proxy`. A macOS `*.`
+exception becomes `.`: `foo.local` bypasses for `*.local`, `xlocal`
+does not, and the bare `local` apex also bypasses. Exact link-local CIDRs
+are dropped with a warning, so link-local IP literals use the proxy. Other
+unrepresentable exceptions refuse discovery without changing proxy variables.
+Auto discovery does not resolve
PAC/WPAD, SOCKS-only settings or live proxy changes. Use a supported static HTTP
proxy setting or an explicit HTTP(S) proxy URL when needed.
@@ -658,6 +665,55 @@ caller's credential does not cross to the other provider. The selected model mus
input size and content. Restart the proxy after editing
`config.json` by hand. Dashboard saves apply immediately.
+## Memory routing
+
+In **Dashboard → Overview → Memory routing**, choose a model and an optional reasoning effort for
+each of Codex's two memory phases, then click **Save**. Select **Off** and save to
+remove the override. Changes apply to the next memory request without restarting the proxy.
+
+Set `memoryModels` in OpenCodex `config.json` to route those requests. With the block omitted,
+both phases keep their existing route. The phases are independent: configuring one leaves the
+other alone.
+
+```json
+{
+ "memoryModels": {
+ "extract": { "model": "provider/model-id", "reasoningEffort": "low" },
+ "consolidation": { "model": "provider/model-id", "reasoningEffort": "medium" }
+ }
+}
+```
+
+`extract` is the pass that summarizes one finished session into a raw memory; `consolidation` is the
+single agent run that merges those raw memories into the files under `$CODEX_HOME/memories`.
+`model` accepts native model IDs, provider-qualified model IDs, and configured combos.
+`reasoningEffort` is optional; omit it to keep the effort Codex asked for. Supported declarations are
+`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`. Codex hard-codes `low` for
+extract and `medium` for consolidation, so a configured effort replaces that value.
+
+OpenCodex recognizes these requests from Codex's own turn metadata: `request_kind: "memory"` in
+the `x-codex-turn-metadata` header marks an extract pass, and `thread_source:
+"memory_consolidation"` marks the consolidation thread. On HTTP, a request whose
+`x-openai-subagent` header names `memory_consolidation` counts as a consolidation pass only when
+turn metadata is absent. Explicit non-memory metadata wins over that fallback. The model id is
+deliberately not a signal: the extract pass runs on the same helper model
+Codex uses for titles and commit messages, so a model-based rule would also capture ordinary
+helper calls. Missing, malformed, or conflicting metadata does not activate the override; when
+several copies of the metadata are supplied they must name the same phase. WebSocket requests use
+each frame's metadata rather than the connection's earlier handshake metadata — the bridge
+re-attaches the handshake's `x-openai-subagent` header to every frame, so that header names the
+connection, not the current pass, and is not a websocket signal.
+
+A configured phase wins when `shadowCallIntercept` would match the same request. A phase left off
+keeps its current routing, including any existing shadow-call rule that matches its model. The
+selected model's provider receives the session text Codex summarizes for memory, including
+sessions that normally run on another provider; the dashboard panel states this next to the model
+pickers. A phase whose target stopped resolving — the provider is
+disabled or deleted, or its combo no longer exists — fails that memory call with `409` and error code
+`memory_model_target_unavailable` instead of falling back to the default provider. The request log
+names the phase (`memory-extract` or `memory-consolidation`) as the routing reason. Restart the
+proxy after editing `config.json` by hand. Dashboard saves apply immediately.
+
## Shadow calls
Codex uses small helper models for tasks such as titles and commit messages. Enable
diff --git a/docs-site/src/content/docs/ru/guides/integrations.md b/docs-site/src/content/docs/ru/guides/integrations.md
index 2bbf27ba135..d6ae86fa896 100644
--- a/docs-site/src/content/docs/ru/guides/integrations.md
+++ b/docs-site/src/content/docs/ru/guides/integrations.md
@@ -1,10 +1,10 @@
---
title: Интеграции
-description: Подключайте opencodex к OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo и Cline CLI из дашборда — отдельный переключатель для каждого клиента и резервная копия перед каждой записью.
+description: Подключайте opencodex к OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI, Kilo и Factory Droid из дашборда — отдельный переключатель для каждого клиента и резервная копия перед каждой записью.
---
Вкладка **Integrations** записывает блок провайдера opencodex в собственный файл
-конфигурации клиента и при необходимости удаляет его. Так работают пятнадцать
+конфигурации клиента и при необходимости удаляет его. Так работают семнадцать
клиентов, у каждого свой переключатель:
| Клиент | Файл конфигурации | Формат | Когда изменение начинает действовать | Учётные данные |
@@ -24,6 +24,8 @@ description: Подключайте opencodex к OpenCode, Pi, OMP, Hermes, Open
| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | сразу после сохранения — Raycast следит за файлом | нет — только loopback |
| omo | `~/.omo/agent/models.json` | JSON | в новых сессиях | заглушка для loopback |
| Cline CLI | `~/.cline/data/settings/providers.json` и соседний `models.json` | пара JSON | после остановки и повторного запуска Cline | заглушка для loopback |
+| Kilo | первый существующий файл среди `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json` и `config.json` в `~/.config/kilo` (`XDG_CONFIG_HOME` переносит каталог; если файлов нет, создаётся `kilo.jsonc`) | JSONC | в новых сессиях | `OPENCODEX_KILO_API_KEY` |
+| Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` в Windows) | JSON | сразу при изменении файла | loopback без ключа |
Создаваемые каталоги включают только модели, включённые в настройках каждого
провайдера. Это относится и к скачиваемым файлам, и к управляемым интеграциям,
@@ -530,6 +532,21 @@ OpenCodex требует явного `--overwrite-conflict`. Отключени
поддерживает удалённую настройку допуска и требует loopback-доступа без
аутентификации.
+## Kilo
+
+Kilo CLI, VS Code и JetBrains используют общую глобальную конфигурацию. Интеграция записывает `provider.opencodex` в первый существующий файл среди `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json` и `config.json` в `~/.config/kilo`. Переменная `XDG_CONFIG_HOME` переносит этот каталог. Если файлов нет, создаётся `kilo.jsonc`. Конфигурация проекта не изменяется.
+
+Kilo объединяет все эти глобальные файлы. Если другой файл-кандидат тоже определяет `provider.opencodex`, статус перечисляет конфликтующие файлы, а применение и замена отклоняются. Перед включением удалите `provider.opencodex` из этих файлов. Отключение уже принадлежащего OpenCodex блока доступно и при таком конфликте. Нечитаемый или небезопасный файл-кандидат также блокирует запись.
+
+Интеграции принадлежит только `provider.opencodex` в формате OpenCode V1 (`npm`, `options`, `models`). Поле OpenCode V2 `providers` не создаётся. `$schema`, `model`, `enabled_providers`, MCP и прочие ключи остаются под управлением пользователя. После применения выберите в Kilo `opencodex/`.
+
+Для loopback значение `options.apiKey` — `{env:OPENCODEX_KILO_API_KEY}`. При привязке не к loopback авторизация переносится в `options.headers["x-opencodex-api-key"]`; настоящий ключ не записывается. Применение переписывает весь глобальный файл как форматированный JSON, поэтому комментарии и завершающие запятые в других ключах не сохраняются. Kilo не участвует в автоматическом обновлении каталога; после изменения выбора маршрутизируемых моделей обновите интеграцию явно.
+
+```bash
+ocx integration client enable --client kilo
+ocx export --client kilo --out ./kilo.jsonc
+```
+
## Приложение GitHub Copilot
Настольное приложение GitHub Copilot может использовать opencodex как совместимого с OpenAI поставщика моделей. Это ручная настройка клиента без переключателя на вкладке Integrations. Она не связана с upstream-провайдером `github-copilot`, который использует подписку Copilot как backend для opencodex.
@@ -554,3 +571,7 @@ OpenCodex требует явного `--overwrite-conflict`. Отключени
Для получения списка моделей приложение использует `GET /v1/models`, а для запросов — `POST /v1/chat/completions`. Запросы проходят через обычную маршрутизацию моделей opencodex, поэтому применяются учётные данные провайдера, OAuth-аккаунты и комбинации моделей, как и для любого другого клиента. Поддерживаемые поля запроса перечислены в [справочнике форматов прокси](/reference/proxy-formats/).
Если приложение сообщает, что моделей нет, проверьте, что Base URL заканчивается на `/v1`, а не на `/v1/chat/completions`, и что `/v1/models` возвращает непустой массив `data`. Если opencodex слушает адрес вне loopback, укажите в поле API key ключ допуска данных (токен из раздела [удалённого доступа](/reference/configuration/server/#remote-access) или созданный в дашборде ключ `ocx_…`). Приложение отправляет его как `Authorization: Bearer`; `/v1/chat/completions` использует его только для допуска к прокси и не пересылает upstream. Подробнее см. [матрицу аутентификации](/reference/proxy-formats/#authentication-matrix).
+
+## Factory Droid
+
+Factory Droid использует `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` в Windows). Явно включите интеграцию командой `ocx integration client enable --client droid`, затем выберите пользовательскую модель через `/model`. Управляемые записи не содержат ключа и работают только через loopback. Отключение удаляет управляемые записи, а Undo восстанавливает сохранённые байты. Если в прежнем `config.json` есть записи OpenCodex или `settings.local.json` переопределяет `customModels`, устраните конфликт до включения. См. [документацию Factory BYOK](https://docs.factory.ai/model-independence/byok).
diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md
index e2ad3a31e22..c1835afff88 100644
--- a/docs-site/src/content/docs/ru/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ru/reference/cli/agents.md
@@ -163,7 +163,7 @@ override, но файлы на диске никогда не меняются.
## Экспорт client config
-### `ocx export --client `
+### `ocx export --client `
Печатает client config, направленный на работающий прокси. Команда сериализует блок
провайдера `opencodex` в нативном формате выбранного клиента: base URL, список моделей и,
@@ -174,7 +174,7 @@ override, но файлы на диске никогда не меняются.
| Флаг | Действие |
| --- | --- |
-| `--client ` | Обязателен. Выбирает формат конфигурации клиента. |
+| `--client ` | Обязателен. Выбирает формат конфигурации клиента. |
| `--json` | Печатать только JSON-конфиг в stdout, чтобы redirect сохранял побайтно точный вывод. Вся диагностика, включая заметку о записи через `--out`, идёт в stderr. |
| `--out ` | Записать конфиг в ``. Перезаписывать существующий файл не позволит. |
| `--force` | Разрешить `--out` заменить существующий файл. |
@@ -207,6 +207,8 @@ ocx export --client opencode --out ~/opencodex-opencode.json
| `aside` | `~/.aside/u//models.json` для аккаунта, который `accounts.json` самого Aside называет текущим; нечитаемый манифест отклоняется, а не подменяется произвольным аккаунтом | `aside-models.json` | нет — loopback placeholder |
| `raycast` | `~/.config/raycast/ai/providers.yaml` одинаково на macOS и Windows (Raycast не учитывает `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | нет — только loopback, запись `api_keys` не создаётся |
| `omo` | `~/.omo/agent/models.json` (`OMO_CODING_AGENT_DIR`, затем `SENPI_CODING_AGENT_DIR`, затем `PI_CODING_AGENT_DIR` имеют приоритет в этом порядке, если заданы; относительное значение отклоняется) | `omo-models.json` | нет — loopback placeholder |
+| `kilo` | первый существующий файл среди `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json` или `config.json` в `~/.config/kilo` (`XDG_CONFIG_HOME` переносит каталог); если ни одного нет, используется `kilo.jsonc` | `kilo.jsonc` | `OPENCODEX_KILO_API_KEY` |
+| `droid` | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | `factory-settings.json` | только loopback; переменная окружения не нужна |
Экспорт для Raycast — это отдельный документ `providers.yaml` с одним элементом `id: opencodex` в
последовательности `providers`: `name: OpenCodex`, базовый URL прокси с `/v1` и каждая маршрутизируемая
diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md
index f2a7bc704ab..2a4dcb20c68 100644
--- a/docs-site/src/content/docs/ru/reference/configuration/server.md
+++ b/docs-site/src/content/docs/ru/reference/configuration/server.md
@@ -232,6 +232,6 @@ opencodex. Перед использованием прогоните soak-test
## Сетевая диагностика квоты Codex
-Поле `quotaRefresh` в строке основного аккаунта Codex описывает получение квоты, а не её остаток или право доступа к модели. Оно может отсутствовать при чтении кэша или если запрос не выполнялся. Используется окружение работающего прокси-сервиса, а не текущего терминала. Если `proxy` не задан, существующее окружение сохраняется; `"auto"` читает только статические настройки прокси Windows при запуске. PAC/WPAD, настройки только SOCKS и изменения во время работы автоматически не учитываются. Успех через TUN сам по себе не подтверждает исправность пути HTTP-прокси. См. [команды и состояния на английском](/reference/configuration/server/#codex-quota-network-diagnostics).
+Поле `quotaRefresh` в строке основного аккаунта Codex описывает получение квоты, а не её остаток или право доступа к модели. Оно может отсутствовать при чтении кэша или если запрос не выполнялся. Используется окружение работающего прокси-сервиса, а не текущего терминала. Если `proxy` не задан, существующее окружение сохраняется; `"auto"` при запуске читает статические настройки HTTP/HTTPS Windows или macOS. На macOS унаследованный прокси отменяет это чтение. На macOS допустимый шаблон `*.` преобразуется в `.`: для `*.local` прямое соединение получают `foo.local` и само имя `local`, но не `xlocal`. Точные диапазоны `169.254/16`, `169.254.0.0/16` и `fe80::/10` пропускаются с диагностикой: link-local IP-адреса используют прокси. IP-адреса и `*` принимаются; прочие CIDR, glob-шаблоны и исключения простых имён отменяют обнаружение без изменения окружения. PAC/WPAD, настройки только SOCKS и изменения во время работы автоматически не учитываются. Успех через TUN сам по себе не подтверждает исправность пути HTTP-прокси. См. [команды и состояния на английском](/reference/configuration/server/#codex-quota-network-diagnostics).
`dropCodexSafetyBuffering`: не меняет проверки безопасности провайдера или отказы. Native WebSocket `codex.response.metadata.headers` и `/responses/compact` не входят в область фильтра.
diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md
index 71d77a09e5b..7d1731ca433 100644
--- a/docs-site/src/content/docs/tr/guides/integrations.md
+++ b/docs-site/src/content/docs/tr/guides/integrations.md
@@ -1,10 +1,10 @@
---
title: Entegrasyonlar
-description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast ve omo'yu opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek.
+description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI, Kilo ve Factory Droid'u opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek.
---
**Entegrasyonlar** sekmesi, opencodex'in sağlayıcı bloğunu istemcinin kendi
-yapılandırma dosyasına yazar ve tekrar kaldırır. On beş istemci bu şekilde
+yapılandırma dosyasına yazar ve tekrar kaldırır. On yedi istemci bu şekilde
çalışır, her biri bir anahtarla:
| İstemci | Yapılandırma dosyası | Format | Değişiklik ne zaman geçerli olur? | Kimlik bilgisi |
@@ -24,6 +24,8 @@ yapılandırma dosyasına yazar ve tekrar kaldırır. On beş istemci bu şekild
| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | kaydedildiği anda — Raycast dosyayı izler | yok — yalnızca geri döngü |
| omo | `~/.omo/agent/models.json` | JSON | yeni oturumlarda | geri döngü yer tutucusu |
| Cline CLI | `~/.cline/data/settings/providers.json` + `models.json` | JSON | kapatıp yeniden başlattıktan sonra | yalnızca loopback |
+| Kilo | `~/.config/kilo` altında ilk bulunan `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json` veya `config.json` (`XDG_CONFIG_HOME` bu dizini taşır; hiçbiri yoksa `kilo.jsonc` oluşturulur) | JSONC | yeni oturumlarda | `OPENCODEX_KILO_API_KEY` |
+| Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` Windows'ta) | JSON | dosya değişince hemen | anahtarsız geri döngü |
Desteklenen akıl yürütme düzeylerine sahip GJC modelleri, GJC'nin düzey seçimi sunabilmesi için `reasoning: true`, `thinking.levels` ve `compat.supportsReasoningEffort` alanlarını dışa aktarır. Yerel Codex modelleri, katalogda belirtilmese bile standart düzeylerini alır. Bilinen düzeyi olmayan modellerde bu alanlar bulunmaz. `none` düzey göndermez ve `ultra` gönderimde `max` düzeyine dönüşür; bu yüzden seçeneklerde yer almazlar. Model seçeneklerini güncellemek için entegrasyonu yenileyin.
@@ -324,3 +326,17 @@ ocx integration client restore --op
```
[CLI / rollback / CLINE_PROVIDER_SETTINGS_PATH](/guides/integrations/#cline-cli).
+
+## Kilo
+
+Kilo yalnızca `~/.config/kilo` altındaki ilk mevcut genel dosyada `provider.opencodex` yazar (`XDG_CONFIG_HOME` bu dizini taşır; hiçbir aday yoksa `kilo.jsonc` oluşturulur). Başka bir aday dosya da `provider.opencodex` tanımlıyorsa durum çakışma bildirir ve Uygula işlemi reddedilir. Diğer anahtarlar değişmez. Uygula dosyanın tamamını yeniden yazar; yorumlar ve sondaki virgüller korunmaz. Kilo’da `opencodex/` seçin.
+
+Başka bir aday çakışsa veya ayrıştırılamasa bile Devre Dışı Bırak, kaydedilen dosyadaki OpenCodex'e ait bloğu kaldırabilir; diğer aday dosya değişmez.
+
+```bash
+ocx integration client enable --client kilo
+```
+
+## Factory Droid
+
+Factory Droid, `~/.factory/settings.json` dosyasını (Windows'ta `%USERPROFILE%\.factory\settings.json`) kullanır. `ocx integration client enable --client droid` komutuyla açıkça etkinleştirin, ardından `/model` içinde özel bir model seçin. Yönetilen satırlar anahtarsızdır ve yalnızca geri döngü bağlantısında çalışır. Devre dışı bırakma yönetilen satırları kaldırır; Undo kaydedilen baytları geri yükler. Eski `config.json` dosyasında OpenCodex satırları varsa veya `settings.local.json`, `customModels` değerini geçersiz kılıyorsa etkinleştirmeden önce çakışmayı giderin. [Factory BYOK belgelerine](https://docs.factory.ai/model-independence/byok) bakın.
diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md
index c1fb9849612..5a610d045d3 100644
--- a/docs-site/src/content/docs/tr/reference/cli/agents.md
+++ b/docs-site/src/content/docs/tr/reference/cli/agents.md
@@ -202,7 +202,7 @@ Grok Build model çitini yönetin ve uygulayın.
## İstemci yapılandırma dışa aktarma
-### `ocx export --client `
+### `ocx export --client `
Çalışan proxy'ye bağlı bir istemci yapılandırmasını yazdırın. Komut, `opencodex`
sağlayıcı bloğunu — temel URL, model listesi ve istemcinin kimlik bilgisi
@@ -214,7 +214,7 @@ yalnızca Codex'in şu anda görebildiği modelleri yayınlar.
| Bayrak | Eylem |
| --- | --- |
-| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. |
+| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. |
| `--json` | Betikler için stdout üzerinde oluşturulan belgeyi JSON olarak yazdırın. Bu, seçilen istemcinin yerel formatı YAML, TOML veya JSON5 olsa bile JSON'dur. |
| `--out ` | İstemcinin yerel yapılandırma formatını `` konumuna yazın. Mevcut bir dosyanın üzerine yazmayı reddeder. |
| `--force` | `--out`'un mevcut bir dosyanın üzerine yazmasına izin verin. |
@@ -247,6 +247,8 @@ için kendi varsayılanlarını uygular) gelir.
| `aside` | Aside'ın kendi `accounts.json` dosyasının güncel olarak gösterdiği hesap için `~/.aside/u//models.json`; okunamayan bir manifest, gelişigüzel bir hesaba düşmek yerine reddedilir | `aside-models.json` | yok — geri döngü yer tutucusu |
| `raycast` | `~/.config/raycast/ai/providers.yaml`, macOS ve Windows'ta aynı (Raycast `XDG_CONFIG_HOME` değerini dikkate almaz) | `raycast-providers.yaml` | yok — yalnızca geri döngü, `api_keys` girdisi yazılmaz |
| `omo` | `~/.omo/agent/models.json` (ayarlandığında sırasıyla `OMO_CODING_AGENT_DIR`, `SENPI_CODING_AGENT_DIR`, `PI_CODING_AGENT_DIR` öncelikli; göreli değer reddedilir) | `omo-models.json` | yok — geri döngü yer tutucusu |
+| `kilo` | `~/.config/kilo` altında ilk bulunan `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json` veya `config.json` (`XDG_CONFIG_HOME` bu dizini taşır); hiçbiri yoksa `kilo.jsonc` kullanılır | `kilo.jsonc` | `OPENCODEX_KILO_API_KEY` |
+| `droid` | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | `factory-settings.json` | yalnızca loopback; ortam değişkeni gerekmez |
Raycast dışa aktarımı, `providers` dizisinde tek bir `id: opencodex` öğesi içeren bağımsız
bir `providers.yaml` belgesidir: `name: OpenCodex`, proxy'nin `/v1` temel URL'si ve
diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md
index 71524df464a..a4d69c1f4a0 100644
--- a/docs-site/src/content/docs/tr/reference/configuration/server.md
+++ b/docs-site/src/content/docs/tr/reference/configuration/server.md
@@ -304,4 +304,4 @@ yeniden kullanır. Hedeflenen hesap ve iş yükünü kapsamlı bir şekilde test
## Codex kota ağı tanılaması
-Ana Codex hesabının satırındaki `quotaRefresh`, kalan kotayı veya model erişim yetkisini değil, kota sorgusunun sonucunu açıklar. Önbellek kullanıldığında ya da sorgu yapılmadığında alan bulunmayabilir. Sorgu, etkileşimli terminalin değil çalışan proxy servisinin ortamını kullanır. `proxy` ayarlanmazsa mevcut ortam korunur; `"auto"` yalnızca başlangıçta Windows’un statik proxy ayarlarını okur. PAC/WPAD, yalnızca SOCKS ayarları ve çalışma sırasındaki değişiklikler otomatik uygulanmaz. TUN ile başarı, HTTP proxy yolunun da çalıştığını tek başına göstermez. [Komutlar ve durumlar için İngilizce bölüme](/reference/configuration/server/#codex-quota-network-diagnostics) bakın.
+Ana Codex hesabının satırındaki `quotaRefresh`, kalan kotayı veya model erişim yetkisini değil, kota sorgusunun sonucunu açıklar. Önbellek kullanıldığında ya da sorgu yapılmadığında alan bulunmayabilir. Sorgu, etkileşimli terminalin değil çalışan proxy servisinin ortamını kullanır. `proxy` ayarlanmazsa mevcut ortam korunur; `"auto"` başlangıçta Windows veya macOS statik HTTP/HTTPS ayarlarını okur. macOS üzerinde devralınmış proxy varsa bu ayarlar okunmaz. macOS üzerinde geçerli `*.` kalıbı `.` olur: `*.local` için `foo.local` ve yalın `local` doğrudan gider, `xlocal` gitmez. Tam `169.254/16`, `169.254.0.0/16` ve `fe80::/10` aralıkları bir tanıyla atlanır; link-local IP adresleri proxy kullanır. IP adresleri ve `*` kabul edilir; diğer CIDR, glob ve yalın ana makine istisnaları ortam değiştirilmeden keşfi reddeder. PAC/WPAD, yalnızca SOCKS ayarları ve çalışma sırasındaki değişiklikler otomatik uygulanmaz. TUN ile başarı, HTTP proxy yolunun da çalıştığını tek başına göstermez. [Komutlar ve durumlar için İngilizce bölüme](/reference/configuration/server/#codex-quota-network-diagnostics) bakın.
diff --git a/docs-site/src/content/docs/zh-cn/guides/integrations.md b/docs-site/src/content/docs/zh-cn/guides/integrations.md
index 96b973d31fe..90018e3100a 100644
--- a/docs-site/src/content/docs/zh-cn/guides/integrations.md
+++ b/docs-site/src/content/docs/zh-cn/guides/integrations.md
@@ -1,9 +1,9 @@
---
title: 集成
-description: 从仪表盘将 opencodex 连接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、gjc、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside、Raycast、omo 和 Cline CLI;每个客户端都有独立开关,且每次写入前都会备份。
+description: 从仪表盘将 opencodex 连接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、gjc、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside、Raycast、omo、Cline CLI、Kilo 和 Factory Droid;每个客户端都有独立开关,且每次写入前都会备份。
---
-**Integrations** 标签页可将 opencodex 的提供商配置块写入客户端自己的配置文件,也可再次移除。以下 15 个客户端都采用这种方式,各有独立开关:
+**Integrations** 标签页可将 opencodex 的提供商配置块写入客户端自己的配置文件,也可再次移除。以下 17 个客户端都采用这种方式,各有独立开关:
| 客户端 | 配置文件 | 格式 | 变更生效时间 | 凭据 |
|---|---|---|---|---|
@@ -22,6 +22,8 @@ description: 从仪表盘将 opencodex 连接到 OpenCode、Pi、OMP、Hermes、
| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | 保存后立即生效——Raycast 监视该文件 | 无——仅回环 |
| omo | `~/.omo/agent/models.json` | JSON | 新会话 | 回环占位符 |
| Cline CLI | `~/.cline/data/settings/providers.json` 及同目录下的 `models.json` | JSON 文件对 | 停止并重启 Cline 后 | 回环占位符 |
+| Kilo | `~/.config/kilo` 下最先存在的 `kilo.jsonc`、`kilo.json`、`opencode.jsonc`、`opencode.json` 或 `config.json`(`XDG_CONFIG_HOME` 可迁移目录;均不存在时创建 `kilo.jsonc`) | JSONC | 新会话 | `OPENCODEX_KILO_API_KEY` |
+| Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` Windows 上) | JSON | 文件变更时立即生效 | 无密钥回环 |
生成的目录只包含各提供商选择中已启用的模型。下载文件和托管集成都遵循这一规则,Pi 和 Aside 也不例外。管理模型列表仍显示完整阵容,以便启用更多模型。
@@ -214,6 +216,21 @@ Undo 会恢复**两个原始字节串**,包括原本不存在的文件。操
下载文件 `cline-config-bundle.json` 包含两个原生文档成员:对应 `providers.json` 的 `settings`,以及对应 `models.json` 的 `catalog`。它本身不是 Cline 设置文件。建议使用集成命令,以获得带日志的合并和回滚。此生成集成不支持远程准入接线,需要免认证的回环访问。
+## Kilo
+
+Kilo CLI、VS Code 和 JetBrains 共用一份全局配置。此集成只在 `~/.config/kilo` 下最先存在的 `kilo.jsonc`、`kilo.json`、`opencode.jsonc`、`opencode.json` 或 `config.json` 中写入 `provider.opencodex`。`XDG_CONFIG_HOME` 可以迁移该目录;如果候选文件均不存在,则创建 `kilo.jsonc`。不会修改项目配置。
+
+Kilo 会合并所有这些全局文件。如果另一个候选文件也定义了 `provider.opencodex`,状态会列出冲突文件,应用和替换都会被拒绝。启用集成前,请从那些文件中移除 `provider.opencodex`。即使发生这种冲突,仍可禁用已归 OpenCodex 所有的配置块。无法读取或不安全的候选文件也会阻止写入。
+
+仅 `provider.opencodex` 属于此集成,采用 OpenCode V1 结构(`npm`、`options`、`models`);不会输出 OpenCode V2 的 `providers` 键。`$schema`、`model`、`enabled_providers`、MCP 等键仍由用户管理。应用后,在 Kilo 中选择 `opencodex/`。
+
+回环连接使用 `{env:OPENCODEX_KILO_API_KEY}` 作为 `options.apiKey`。非回环绑定将认证移到 `options.headers["x-opencodex-api-key"]`,且不会写入真实密钥。应用时会将整个全局文件重写为格式化 JSON,因此其他键中的注释和尾随逗号不会保留。Kilo 不参与自动目录刷新;更改路由模型选择后,请明确刷新此集成。
+
+```bash
+ocx integration client enable --client kilo
+ocx export --client kilo --out ./kilo.jsonc
+```
+
## GitHub Copilot 应用
GitHub Copilot 桌面应用可以将 opencodex 用作兼容 OpenAI 的模型提供方。这需要手动配置客户端,Integrations 标签页没有对应的开关;它也不同于上游 `github-copilot` 提供方,后者使用 Copilot 订阅作为 opencodex 的后端。
@@ -238,3 +255,7 @@ GitHub Copilot 桌面应用可以将 opencodex 用作兼容 OpenAI 的模型提
应用通过 `GET /v1/models` 发现模型,并通过 `POST /v1/chat/completions` 发送请求。这些请求经过 opencodex 的常规模型路由,因此与其他客户端一样会应用提供方凭据、OAuth 账户和组合路由。支持的请求字段见[代理格式参考](/reference/proxy-formats/)。
如果应用提示没有模型,请确认 Base URL 以 `/v1` 结尾,而不是 `/v1/chat/completions`,并确认 `/v1/models` 返回非空的 `data` 数组。如果 opencodex 监听的不是回环地址,请在应用的 API key 字段中填写数据准入密钥([远程访问](/reference/configuration/server/#remote-access)中说明的令牌,或由仪表盘生成的 `ocx_…` 密钥)。应用会将其作为 `Authorization: Bearer` 发送;`/v1/chat/completions` 仅将其用于代理准入,不会转发到上游。详见[认证矩阵](/reference/proxy-formats/#authentication-matrix)。
+
+## Factory Droid
+
+Factory Droid 使用 `~/.factory/settings.json`(Windows 上为 `%USERPROFILE%\.factory\settings.json`)。使用 `ocx integration client enable --client droid` 明确启用,然后在 `/model` 中选择自定义模型。托管条目不含密钥,且仅支持回环连接。禁用会移除托管条目;Undo 会恢复保存的原始字节。如果旧版 `config.json` 含有 OpenCodex 条目,或 `settings.local.json` 覆盖了 `customModels`,请先解决冲突再启用。参见 [Factory BYOK 文档](https://docs.factory.ai/model-independence/byok)。
diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md
index b1a0d4306ce..4c1795216d2 100644
--- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md
+++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md
@@ -141,7 +141,7 @@ ocx claude desktop import [--apply] Validate and import JSON
## Client config export
-### `ocx export --client `
+### `ocx export --client `
输出连接到正在运行代理的客户端配置。此命令会以所选客户端的原生格式序列化 `opencodex` provider 块,其中包含基础 URL、模型列表,以及该客户端适用的凭据引用或 `opencodex-loopback` 占位值。
@@ -149,7 +149,7 @@ ocx claude desktop import [--apply] Validate and import JSON
| 标志 | 动作 |
| --- | --- |
-| `--client ` | 必需。选择客户端配置格式。 |
+| `--client ` | 必需。选择客户端配置格式。 |
| `--json` | 仅在 stdout 打印配置 JSON,这样重定向即可捕获字节级精确输出。包括 `--out` 写入提示在内的所有诊断信息都会输出到 stderr。 |
| `--out ` | 将配置写入 ``。拒绝替换已存在的文件。 |
| `--force` | 允许 `--out` 替换已存在的文件。 |
@@ -179,6 +179,8 @@ ocx export --client opencode --out ~/opencodex-opencode.json
| `aside` | `~/.aside/u//models.json`,对应 Aside 自己的 `accounts.json` 指明的当前账户;清单不可读时会被拒绝,而不是退回到某个账户 | `aside-models.json` | 无 — loopback placeholder |
| `raycast` | `~/.config/raycast/ai/providers.yaml`(macOS 与 Windows 相同;Raycast 不遵循 `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | 无 — 仅限回环,不会写入 `api_keys` 条目 |
| `omo` | `~/.omo/agent/models.json`(设置后依次由 `OMO_CODING_AGENT_DIR`、`SENPI_CODING_AGENT_DIR`、`PI_CODING_AGENT_DIR` 优先;相对路径会被拒绝) | `omo-models.json` | 无 — loopback placeholder |
+| `kilo` | `~/.config/kilo` 下最先存在的 `kilo.jsonc`、`kilo.json`、`opencode.jsonc`、`opencode.json` 或 `config.json`(`XDG_CONFIG_HOME` 可更改该目录);均不存在时使用 `kilo.jsonc` | `kilo.jsonc` | `OPENCODEX_KILO_API_KEY` |
+| `droid` | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | `factory-settings.json` | 仅限回环;无需环境变量 |
Raycast 导出是一份独立的 `providers.yaml` 文档,在 `providers` 序列中只有一个 `id: opencodex` 元素:`name: OpenCodex`、代理的 `/v1` 基础 URL,以及每个已路由模型及其 `abilities`(`tools` 与 `system_message` 始终支持,`vision` 取自目录的输入模态,`reasoning_effort` 在模型有 effort 阶梯时设置,`temperature` 对推理模型关闭)。Custom Providers 是 Raycast Pro 功能,且 Raycast 会监视该文件,因此保存后的更改无需重启即可生效。格式见 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。不会写入任何 `api_keys` 条目,所以该导出仅限回环,非回环绑定会被拒绝。
diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md
index 76efbe5738e..1fc4ee8ccc7 100644
--- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md
+++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md
@@ -198,6 +198,6 @@ Anthropic OAuth 侧车会复用 opencodex 现有的 Claude Code OAuth 指纹。
## Codex 额度网络诊断
-主 Codex 账户行中的 `quotaRefresh` 描述额度查询结果,并不代表剩余额度或模型访问权限。读取缓存或未执行查询时,该字段可能省略。查询使用正在运行的代理服务的环境,而不是当前终端的环境。未设置 `proxy` 时保留现有环境;`"auto"` 只在启动时读取 Windows 静态代理设置,不自动处理 PAC/WPAD、仅 SOCKS 的设置或运行中的更改。TUN 测试成功并不能单独证明 HTTP 代理路径正常。命令和状态说明见[英文网络诊断章节](/reference/configuration/server/#codex-quota-network-diagnostics)。
+主 Codex 账户行中的 `quotaRefresh` 描述额度查询结果,并不代表剩余额度或模型访问权限。读取缓存或未执行查询时,该字段可能省略。查询使用正在运行的代理服务的环境,而不是当前终端的环境。未设置 `proxy` 时保留现有环境;`"auto"` 在启动时读取 Windows 或 macOS 静态 HTTP/HTTPS 设置;macOS 上若有继承代理则跳过读取。macOS 将有效的 `*.` 转为 `.`:`*.local` 使 `foo.local` 和裸域名 `local` 直连,但不匹配 `xlocal`。精确的 `169.254/16`、`169.254.0.0/16`、`fe80::/10` 网段会跳过并给出诊断,因此链路本地 IP 地址使用代理。IP 地址和 `*` 仍可用;其他 CIDR、通配形式和简单主机名例外会在修改环境前拒绝自动发现。不自动处理 PAC/WPAD、仅 SOCKS 的设置或运行中的更改。TUN 测试成功并不能单独证明 HTTP 代理路径正常。命令和状态说明见[英文网络诊断章节](/reference/configuration/server/#codex-quota-network-diagnostics)。
`dropCodexSafetyBuffering`: 不会改变供应商安全策略或拒绝响应。原生 WebSocket `codex.response.metadata.headers` 和 `/responses/compact` 不在过滤范围内。
diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md
index 9aa5f572483..20a31542115 100644
--- a/docs-site/src/content/docs/zh-tw/guides/integrations.md
+++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md
@@ -1,9 +1,9 @@
---
title: 整合
-description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、gjc、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside、Raycast 與 omo——每個客戶端一個開關,每次寫入前都會先備份。
+description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、gjc、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside、Raycast、omo、Cline CLI、Kilo 與 Factory Droid——每個客戶端一個開關,每次寫入前都會先備份。
---
-**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十五個客戶端以這種方式運作,每個都有一個開關:
+**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十七個客戶端以這種方式運作,每個都有一個開關:
| 客戶端 | 設定檔 | 格式 | 變更生效時機 | 憑證 |
|---|---|---|---|---|
@@ -22,6 +22,8 @@ description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、
| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | 儲存後立即生效——Raycast 會監看該檔案 | 無——僅限 loopback |
| omo | `~/.omo/agent/models.json` | JSON | 新工作階段 | loopback 佔位符 |
| Cline CLI | `~/.cline/data/settings/providers.json` + `models.json` | JSON | 結束並重新啟動後 | 僅限 loopback |
+| Kilo | `~/.config/kilo` 下最先存在的 `kilo.jsonc`、`kilo.json`、`opencode.jsonc`、`opencode.json` 或 `config.json`(`XDG_CONFIG_HOME` 會移動該目錄;若都不存在則建立 `kilo.jsonc`) | JSONC | 新工作階段 | `OPENCODEX_KILO_API_KEY` |
+| Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` Windows 上) | JSON | 檔案變更時立即生效 | 無金鑰迴環 |
具有受支援推理強度階梯的 GJC 模型會匯出 `reasoning: true`、`thinking.levels` 與 `compat.supportsReasoningEffort`,讓 GJC 提供強度選擇。原生 Codex 模型即使未在目錄中列出階梯,也會取得標準階梯。沒有已知階梯的模型會省略這些欄位;`none` 不傳送強度,`ultra` 在傳輸時會折疊成 `max`,因此不會列為選項。重新整理整合即可更新模型選項。
@@ -201,3 +203,17 @@ ocx integration client restore --op
```
[CLI / rollback / CLINE_PROVIDER_SETTINGS_PATH](/guides/integrations/#cline-cli).
+
+## Kilo
+
+Kilo 只會把 `provider.opencodex` 寫入 `~/.config/kilo` 下最先存在的全域檔(`XDG_CONFIG_HOME` 會移動該目錄;若沒有任何候選檔則建立 `kilo.jsonc`)。若另一個候選檔也定義 `provider.opencodex`,狀態會回報衝突且套用會拒絕。其他鍵保持不變。套用會重寫整個檔案,因此不會保留註解與尾隨逗號。請在 Kilo 中選擇 `opencodex/<模型>`。
+
+即使其他候選檔發生衝突或無法剖析,停用仍可移除已記錄檔案中由 OpenCodex 管理的區塊;其他候選檔不會變動。
+
+```bash
+ocx integration client enable --client kilo
+```
+
+## Factory Droid
+
+Factory Droid 使用 `~/.factory/settings.json`(Windows 上為 `%USERPROFILE%\.factory\settings.json`)。使用 `ocx integration client enable --client droid` 明確啟用,然後在 `/model` 中選擇自訂模型。受管理的項目不含金鑰,且僅支援迴環連線。停用會移除受管理的項目;Undo 會還原儲存的原始位元組。如果舊版 `config.json` 含有 OpenCodex 項目,或 `settings.local.json` 覆寫了 `customModels`,請先解決衝突再啟用。請參閱 [Factory BYOK 文件](https://docs.factory.ai/model-independence/byok)。
diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md
index 9b06950e1a3..f8d3b04c87a 100644
--- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md
+++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md
@@ -139,7 +139,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON
## 客戶端設定匯出
-### `ocx export --client `
+### `ocx export --client `
印出連接到執行中代理的客戶端設定。此指令會用所選客戶端的原生格式,序列化含有 base URL、模型清單,以及適用的環境變數參考或 loopback 佔位符的 `opencodex` provider 區塊。
@@ -147,7 +147,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON
| 旗標 | 動作 |
| --- | --- |
-| `--client ` | 必填。選擇客戶端設定格式。 |
+| `--client ` | 必填。選擇客戶端設定格式。 |
| `--json` | 僅在 stdout 印出設定 JSON,使重導向能擷取逐位元組輸出。所有診斷訊息(含 `--out` 寫入提示)皆送至 stderr。 |
| `--out ` | 將設定寫入 ``。拒絕覆寫既有檔案。 |
| `--force` | 允許 `--out` 覆寫既有檔案。 |
@@ -177,6 +177,8 @@ ocx export --client opencode --out ~/opencodex-opencode.json
| `aside` | `~/.aside/u//models.json`,對應 Aside 自己的 `accounts.json` 指定的目前帳戶;資訊清單無法讀取時會被拒絕,而不是退回任一帳戶 | `aside-models.json` | 無——loopback 佔位符 |
| `raycast` | `~/.config/raycast/ai/providers.yaml`(macOS 與 Windows 相同;Raycast 不遵循 `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | 無——僅限 loopback,不會寫入 `api_keys` 項目 |
| `omo` | `~/.omo/agent/models.json`(設定後依序由 `OMO_CODING_AGENT_DIR`、`SENPI_CODING_AGENT_DIR`、`PI_CODING_AGENT_DIR` 優先;相對路徑會被拒絕) | `omo-models.json` | 無——loopback 佔位符 |
+| `kilo` | `~/.config/kilo` 下最先存在的 `kilo.jsonc`、`kilo.json`、`opencode.jsonc`、`opencode.json` 或 `config.json`(`XDG_CONFIG_HOME` 可變更該目錄);皆不存在時使用 `kilo.jsonc` | `kilo.jsonc` | `OPENCODEX_KILO_API_KEY` |
+| `droid` | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | `factory-settings.json` | 僅限迴環;不需環境變數 |
Raycast 匯出是一份獨立的 `providers.yaml` 文件,在 `providers` 序列中只有一個 `id: opencodex` 元素:`name: OpenCodex`、proxy 的 `/v1` base URL,以及每個路由模型及其 `abilities`(`tools` 與 `system_message` 一律支援,`vision` 依目錄的輸入模態而定,`reasoning_effort` 在模型有 effort 階梯時設定,`temperature` 對推理模型關閉)。Custom Providers 是 Raycast Pro 功能,且 Raycast 會監看該檔案,因此儲存後的變更不需重新啟動即可生效。格式說明見 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。不會寫入任何 `api_keys` 項目,所以此匯出僅限 loopback,非 loopback 的 bind 會被拒絕。
diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md
index ec8ffe6e41f..ee1b7e72c77 100644
--- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md
+++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md
@@ -217,4 +217,4 @@ Anthropic OAuth sidecar 重用 opencodex 既有的 Claude Code OAuth 指紋。
## Codex 配額網路診斷
-主 Codex 帳戶列中的 `quotaRefresh` 描述配額查詢結果,並不代表剩餘配額或模型存取權限。讀取快取或未執行查詢時,這個欄位可能省略。查詢使用執行中代理服務的環境,而不是目前終端機的環境。未設定 `proxy` 時保留既有環境;`"auto"` 只在啟動時讀取 Windows 靜態代理設定,不會自動處理 PAC/WPAD、僅 SOCKS 的設定或執行中的變更。TUN 測試成功本身不能證明 HTTP 代理路徑正常。命令與狀態說明請見[英文網路診斷章節](/reference/configuration/server/#codex-quota-network-diagnostics)。
+主 Codex 帳戶列中的 `quotaRefresh` 描述配額查詢結果,並不代表剩餘配額或模型存取權限。讀取快取或未執行查詢時,這個欄位可能省略。查詢使用執行中代理服務的環境,而不是目前終端機的環境。未設定 `proxy` 時保留既有環境;`"auto"` 在啟動時讀取 Windows 或 macOS 靜態 HTTP/HTTPS 設定;macOS 上若有繼承代理則略過讀取。macOS 會將有效的 `*.` 轉成 `.`:`*.local` 讓 `foo.local` 與裸網域 `local` 直連,但不比對 `xlocal`。精確的 `169.254/16`、`169.254.0.0/16`、`fe80::/10` 網段會略過並顯示診斷,因此鏈路本機 IP 位址使用代理。IP 位址與 `*` 仍可使用;其他 CIDR、萬用字元形式及簡單主機名稱例外會在修改環境前拒絕自動探索。不會自動處理 PAC/WPAD、僅 SOCKS 的設定或執行中的變更。TUN 測試成功本身不能證明 HTTP 代理路徑正常。命令與狀態說明請見[英文網路診斷章節](/reference/configuration/server/#codex-quota-network-diagnostics)。
diff --git a/gui/public/provider-icons/README.md b/gui/public/provider-icons/README.md
index 3f0878f924e..7efbbbac919 100644
--- a/gui/public/provider-icons/README.md
+++ b/gui/public/provider-icons/README.md
@@ -380,3 +380,5 @@ committing it.
`b60df52303ba7170772b256c20c04940`), gradient id and all. The contributor works
at Crusoe and confirms this is the company mark. Painted as an image: the
gradient is the brand, so it must never be masked.
+
+- `factory-droid.svg` — Factory Docs favicon, fetched 2026-09-28 from `https://docs.factory.ai/favicon.svg`; unmodified first-party asset for Factory Droid.
diff --git a/gui/public/provider-icons/factory-droid.svg b/gui/public/provider-icons/factory-droid.svg
new file mode 100644
index 00000000000..38aad643a75
--- /dev/null
+++ b/gui/public/provider-icons/factory-droid.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts
index 3f7f107b8c1..ee5be633727 100644
--- a/gui/src/app-routing.ts
+++ b/gui/src/app-routing.ts
@@ -109,6 +109,8 @@ export const INTEGRATION_TAB_HASHES = [
"integrations/raycast",
"integrations/omo",
"integrations/cline",
+ "integrations/kilo",
+ "integrations/droid",
] as const;
/**
diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts
index 0695cd5eada..28750c0ef87 100644
--- a/gui/src/combo-workspace-data.ts
+++ b/gui/src/combo-workspace-data.ts
@@ -91,6 +91,8 @@ export interface ComboTarget {
weight?: number;
/** Exact efforts JEV may choose; omitted means every currently advertised effort. */
reasoningEfforts?: ComboEffort[];
+ /** Optional operator note that supplements the built-in JEV profile. */
+ modelProfile?: string;
/** UI-only stable key for React lists; never sent to the API. */
clientKey?: string;
}
@@ -111,6 +113,7 @@ export function newComboTarget(partial: Partial = {}): ComboTarget
...(partial.reasoningEfforts !== undefined
? { reasoningEfforts: [...partial.reasoningEfforts] }
: {}),
+ ...(partial.modelProfile !== undefined ? { modelProfile: partial.modelProfile } : {}),
clientKey: partial.clientKey ?? `ct-${++comboTargetKeySeq}`,
};
}
@@ -257,6 +260,7 @@ export function parseComboList(payload: unknown): ComboItem[] {
model,
...(weight !== undefined ? { weight } : {}),
...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
+ ...(typeof tr.modelProfile === "string" ? { modelProfile: tr.modelProfile } : {}),
}));
}
out.push({
@@ -448,7 +452,8 @@ export function draftEquals(a: ComboItem, b: ComboItem): boolean {
return t.provider === o.provider
&& t.model === o.model
&& (t.weight ?? 1) === (o.weight ?? 1)
- && targetReasoningEffortsEqual(t, o);
+ && targetReasoningEffortsEqual(t, o)
+ && (t.modelProfile ?? "") === (o.modelProfile ?? "");
});
}
@@ -479,6 +484,9 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}
...(target.reasoningEfforts !== undefined
? { reasoningEfforts: [...target.reasoningEfforts] }
: {}),
+ ...(target.modelProfile?.trim()
+ ? { modelProfile: target.modelProfile.trim() }
+ : {}),
})),
strategy: item.strategy,
defaultEffort: item.defaultEffort,
@@ -515,6 +523,7 @@ export type ComboDraftError =
| "invalidStickyLimit"
| "invalidWeight"
| "invalidReasoningEfforts"
+ | "invalidModelProfile"
| "noEnabledTarget";
export function validateComboDraft(
@@ -565,6 +574,13 @@ export function validateComboDraft(
|| new Set(t.reasoningEfforts).size !== t.reasoningEfforts.length)) {
return "invalidReasoningEfforts";
}
+ if (t.modelProfile !== undefined
+ && (t.modelProfile.length > 512 || [...t.modelProfile].some(char => {
+ const code = char.charCodeAt(0);
+ return (code < 32 && code !== 9 && code !== 10 && code !== 13) || code === 127;
+ }))) {
+ return "invalidModelProfile";
+ }
}
const targets = new Set();
diff --git a/gui/src/components/MemoryModelsPanel.tsx b/gui/src/components/MemoryModelsPanel.tsx
new file mode 100644
index 00000000000..595f166843f
--- /dev/null
+++ b/gui/src/components/MemoryModelsPanel.tsx
@@ -0,0 +1,226 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useT, type TKey } from "../i18n/shared";
+import { IconAlert, IconInfo, IconX } from "../icons";
+import { Select } from "../ui";
+import { createBoundedFetch } from "../bounded-fetch";
+import { requireJson, useModalDialog, type ModelInfo } from "../pages/dashboard-shared";
+import { formatNamespacedModelId } from "../provider-icons";
+
+type Phase = "extract" | "consolidation";
+interface PhaseSetting { model?: string; reasoningEffort?: string }
+type Settings = { extract?: PhaseSetting; consolidation?: PhaseSetting };
+
+const EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
+
+/**
+ * Read the persisted phases. A phase without a model is "Off", so it is dropped rather than kept
+ * as an empty row: that is also the shape the PUT sends back for it.
+ */
+function readSettings(payload: { memoryModels?: unknown }): Settings {
+ const value = payload.memoryModels;
+ if (value == null) return {};
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid settings");
+ const out: Settings = {};
+ for (const phase of ["extract", "consolidation"] as const) {
+ const raw = (value as Record)[phase];
+ if (raw === undefined) continue;
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid phase");
+ const model = "model" in raw && typeof raw.model === "string" ? raw.model.trim() : "";
+ if (!model) throw new Error("invalid model");
+ const effort = "reasoningEffort" in raw ? raw.reasoningEffort : undefined;
+ if (effort !== undefined && (typeof effort !== "string" || !EFFORTS.includes(effort))) throw new Error("invalid effort");
+ out[phase] = { model, ...(effort ? { reasoningEffort: effort } : {}) };
+ }
+ return out;
+}
+
+/**
+ * One phase's PUT payload. A phase with no model is "Off", which the route reads as an absent
+ * key, so it must stay out of the object rather than travel as an empty string.
+ */
+function phasePayload(model: string, effort: string): PhaseSetting | undefined {
+ return model ? { model, ...(effort ? { reasoningEffort: effort } : {}) } : undefined;
+}
+
+export default function MemoryModelsPanel(props: { apiBase: string; models: ModelInfo[] }) {
+ return ;
+}
+
+function MemoryModelsControls({ apiBase, models }: { apiBase: string; models: ModelInfo[] }) {
+ const t = useT();
+ const [saved, setSaved] = useState(undefined);
+ const [infoOpen, setInfoOpen] = useState(false);
+ const [extractModel, setExtractModel] = useState("");
+ const [extractEffort, setExtractEffort] = useState("");
+ const [consolidationModel, setConsolidationModel] = useState("");
+ const [consolidationEffort, setConsolidationEffort] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [loadError, setLoadError] = useState(false);
+ const [feedback, setFeedback] = useState<"saved" | "failed" | null>(null);
+ const active = useRef(false);
+ const pending = useRef | null>(null);
+ const infoTriggerRef = useRef(null);
+ const infoDialogRef = useModalDialog(infoOpen, infoTriggerRef);
+
+ const accept = useCallback((value: Settings) => {
+ setSaved(value);
+ setExtractModel(value.extract?.model ?? "");
+ setExtractEffort(value.extract?.reasoningEffort ?? "");
+ setConsolidationModel(value.consolidation?.model ?? "");
+ setConsolidationEffort(value.consolidation?.reasoningEffort ?? "");
+ }, []);
+
+ const load = useCallback(async () => {
+ if (pending.current) return;
+ const request = createBoundedFetch(15_000);
+ pending.current = request;
+ setLoadError(false);
+ try {
+ const response = await fetch(`${apiBase}/api/settings`, { signal: request.signal });
+ const value = readSettings(await requireJson(response));
+ if (active.current && pending.current === request) accept(value);
+ } catch {
+ if (active.current && pending.current === request) setLoadError(true);
+ } finally {
+ request.clear();
+ if (pending.current === request) pending.current = null;
+ }
+ }, [apiBase, accept]);
+
+ useEffect(() => {
+ active.current = true;
+ const timer = window.setTimeout(() => { void load(); }, 0);
+ return () => {
+ window.clearTimeout(timer);
+ active.current = false;
+ pending.current?.controller.abort();
+ pending.current?.clear();
+ pending.current = null;
+ };
+ }, [load]);
+
+ const save = async () => {
+ if (pending.current || saved === undefined) return;
+ const request = createBoundedFetch(15_000);
+ pending.current = request;
+ setBusy(true);
+ setFeedback(null);
+ const extract = phasePayload(extractModel, extractEffort);
+ const consolidation = phasePayload(consolidationModel, consolidationEffort);
+ try {
+ const response = await fetch(`${apiBase}/api/settings`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ // Null clears the whole block; a phase left at "Off" is simply absent.
+ memoryModels: extract || consolidation
+ ? { ...(extract ? { extract } : {}), ...(consolidation ? { consolidation } : {}) }
+ : null,
+ }),
+ signal: request.signal,
+ });
+ const value = readSettings(await requireJson(response));
+ if (active.current && pending.current === request) {
+ accept(value);
+ setFeedback("saved");
+ }
+ } catch {
+ if (active.current && pending.current === request) setFeedback("failed");
+ } finally {
+ request.clear();
+ if (active.current && pending.current === request) setBusy(false);
+ if (pending.current === request) pending.current = null;
+ }
+ };
+
+ const options = [{ value: "", label: t("memoryModels.off") },
+ ...[...new Set([...models.map(item => item.namespaced),
+ ...[extractModel, consolidationModel].filter(Boolean)])]
+ .map(value => ({ value, label: formatNamespacedModelId(value, t) }))];
+ const effortOptions = [{ value: "", label: t("memoryModels.defaultEffort") },
+ ...EFFORTS.map(value => ({ value, label: t(`models.reasoningEffort.${value}` as TKey) }))];
+ const disabled = busy || saved === undefined || loadError;
+ const dirty = extractModel !== (saved?.extract?.model ?? "")
+ || extractEffort !== (saved?.extract?.reasoningEffort ?? "")
+ || consolidationModel !== (saved?.consolidation?.model ?? "")
+ || consolidationEffort !== (saved?.consolidation?.reasoningEffort ?? "");
+ // The account notice is about the phase that stays on Codex's own model, so it is both
+ // true and useful only while exactly one of the two phases is routed.
+ const partiallyRouted = Boolean(extractModel) !== Boolean(consolidationModel);
+ const info = t("memoryModels.info");
+
+ const row = (phase: Phase, model: string, effort: string, setModel: (value: string) => void, setEffort: (value: string) => void) => (
+
}
+
+
+ );
+}
diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts
index e164810fbb7..e4ac4a62e1a 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 = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"] as const;
+export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid"] as const;
export type ExportClientId = (typeof CLIENTS)[number];
export const CLIENT_LABEL_KEYS = {
@@ -27,6 +27,8 @@ export const CLIENT_LABEL_KEYS = {
raycast: "api.clientConfig.clientRaycast",
omo: "api.clientConfig.clientOmo",
cline: "api.clientConfig.clientCline",
+ kilo: "api.clientConfig.clientKilo",
+ droid: "api.clientConfig.clientDroid",
} as const;
/**
@@ -79,6 +81,8 @@ export const CLIENT_MARKS: Partial> = {
// so it would paint the plate and throw the face away — see the README.
omo: "/provider-icons/omo.svg",
cline: "/provider-icons/cline-color.svg",
+ kilo: "/provider-icons/kilo.svg",
+ droid: "/provider-icons/factory-droid.svg",
};
/**
diff --git a/gui/src/components/combo-workspace-controls.tsx b/gui/src/components/combo-workspace-controls.tsx
index 7096fac936f..8f4f3a9d81d 100644
--- a/gui/src/components/combo-workspace-controls.tsx
+++ b/gui/src/components/combo-workspace-controls.tsx
@@ -179,7 +179,7 @@ export function TargetEditor({
const replaceModel = (index: number, patch: Pick) => {
onChange(targets.map((row, i) => {
if (i !== index) return row;
- const { reasoningEfforts: _reasoningEfforts, ...rest } = row;
+ const { reasoningEfforts: _reasoningEfforts, modelProfile: _modelProfile, ...rest } = row;
return { ...rest, ...patch };
}));
};
@@ -381,6 +381,18 @@ export function TargetEditor({
})}
)}
+
)}
diff --git a/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx
index 4e2e95ba60d..c5ea2500bdb 100644
--- a/gui/src/components/combo-workspace-detail-panel.tsx
+++ b/gui/src/components/combo-workspace-detail-panel.tsx
@@ -94,7 +94,7 @@ export function DetailPanel({
const [copied, setCopied] = useState(false);
const dirty = !draftEquals(draft, baseline);
const allTargetsExhausted = comboQuotaState(draft.targets, providerQuotaStates, providerMap) === "exhausted";
- const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.nativeAlias}:${baseline.displayName ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.imageInput ?? "auto"}:${baseline.reasoningEffortMode ?? "strict"}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`;
+ const baselineSyncKey = JSON.stringify([baseline.id, baseline.alias, baseline.nativeAlias, baseline.displayName, baseline.strategy, baseline.stickyLimit, baseline.defaultEffort, baseline.imageInput, baseline.reasoningEffortMode, baseline.targets.map(t => [t.provider, t.model, t.weight, t.reasoningEfforts, t.modelProfile])]);
const effortMap = useMemo(() => {
const map = new Map();
for (const model of models) {
diff --git a/gui/src/components/integration-marks.ts b/gui/src/components/integration-marks.ts
index 58873cb1cb0..669cc2ba114 100644
--- a/gui/src/components/integration-marks.ts
+++ b/gui/src/components/integration-marks.ts
@@ -60,6 +60,8 @@ export const INTEGRATION_MARKS: Record = {
raycast: CLIENT_MARKS.raycast ?? null,
omo: CLIENT_MARKS.omo ?? null,
cline: CLIENT_MARKS.cline ?? null,
+ kilo: CLIENT_MARKS.kilo ?? null,
+ droid: CLIENT_MARKS.droid ?? null,
};
/**
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index cedce555631..767a53bf9c1 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -422,6 +422,23 @@ export const de: Record = {
"compactionRouting.loadFailed": "Komprimierungseinstellungen konnten nicht geladen werden.",
"compactionRouting.saved": "Komprimierungseinstellungen gespeichert.",
"compactionRouting.saveFailed": "Speichern fehlgeschlagen. Deine Änderungen sind noch vorhanden; versuche es erneut.",
+ "memoryModels.title": "Memory-Routing",
+ "memoryModels.description": "Codex schreibt Memories nach einer Sitzung im Hintergrund. Wähle für jede Phase ein Modell oder behalte die bestehende Route bei.",
+ "memoryModels.infoLabel": "Was sind Extract und Consolidation?",
+ "memoryModels.info": "Codex macht Memory in zwei Schritten. Extract liest eine beendete Sitzung und notiert, was passiert ist: ein Notizzettel pro Sitzung, also viele kleine Aufrufe. Consolidation nimmt diese Zettel und schreibt sie in die Memory-Dateien, die Codex am Anfang deiner nächsten Sitzungen liest. Läuft selten, bearbeitet aber Dateien. Jeder Schritt fragt sein Modell selbst an, deshalb stehen sie hier getrennt.",
+ "memoryModels.extract": "Extraktion",
+ "memoryModels.extractHint": "Fasst jede beendete Sitzung zu einem Raw Memory zusammen. Läuft einmal pro Sitzung.",
+ "memoryModels.consolidation": "Konsolidierung",
+ "memoryModels.consolidationHint": "Führt die Raw Memories in die Memory-Dateien zusammen, die Codex später liest. Läuft selten und bearbeitet Dateien.",
+ "memoryModels.model": "Modell",
+ "memoryModels.effort": "Reasoning-Aufwand",
+ "memoryModels.off": "Aus",
+ "memoryModels.defaultEffort": "Codex-Standard",
+ "memoryModels.dataNotice": "Das gewählte Modell erhält die Eingabe seiner Phase: die beendete Sitzung bei Extract, die Raw Memories bei Consolidation.",
+ "memoryModels.accountNotice": "Nur eine Phase ist hier geroutet; die andere behält ihre bestehende Route. Der Shadow Call Intercept kann auch die Memory-Aufrufe dieser Phase an sein eingestelltes Modell schicken.",
+ "memoryModels.loadFailed": "Memory-Einstellungen konnten nicht geladen werden.",
+ "memoryModels.saved": "Memory-Einstellungen gespeichert.",
+ "memoryModels.saveFailed": "Speichern fehlgeschlagen. Deine Änderungen stehen noch da; versuch es erneut.",
"dash.shadowCallIntercept": "Shadow-Call-Abfangen",
"dash.shadowCallInterceptHint": "Fängt die Hintergrund-Hilfsaufrufe der Codex-App ({models}) ab und leitet sie an das gewählte Modell um.",
"dash.shadowCallWarning": "⚠ Bei Aktivierung werden ALLE Anfragen an {models} durch das gewählte Modell ersetzt.",
@@ -1431,6 +1448,8 @@ export const de: Record = {
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
"integrations.tab.cline": "Cline CLI",
+ "integrations.tab.kilo": "Kilo",
+ "integrations.tab.droid": "Factory Droid",
"integrations.aside.profilesTitle": "Aside-Profile",
"integrations.aside.profilesHint": "Wähle, welche Profile die ausgewählten Modelle erhalten. Das aktive Aside-Profil bleibt unverändert.",
"integrations.aside.all": "Alle Profile synchronisieren",
@@ -1556,6 +1575,7 @@ export const de: Record = {
"integrations.status.notInstalled": "Nicht installiert",
"integrations.status.appliedAt": "Angewendet",
"integrations.status.supersededStore": "Dieser Client liest seine Provider jetzt aus {path}, und opencodex schreibt diese Datei nicht. Ein Aktivieren hier ändert nichts an dem, was der Client lädt.",
+ "integrations.status.candidateConflict": "Eine weitere Kilo-Konfigurationsdatei, {path}, definiert ebenfalls provider.opencodex. Entfernen Sie provider.opencodex aus dieser Datei, bevor Sie die Integration anwenden.",
"integrations.status.backup": "Sicherung",
"integrations.status.lastRestore": "Letzte Wiederherstellung",
"integrations.status.unknown": "Unbekannt",
@@ -1639,6 +1659,8 @@ export const de: Record = {
"integrations.semantics.raycast": "Fügt einen OpenCodex-Provider-Eintrag in die providers.yaml von Raycast ein, damit jedes geroutete Modell in der Modellauswahl von Raycast AI erscheint. Raycast Pro erforderlich.",
"integrations.semantics.omo": "Verwaltet ausschließlich providers.opencodex in der models.json von omo — ~/.omo/agent, sofern nicht OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR oder PI_CODING_AGENT_DIR sie umleitet. Ihre übrigen Provider bleiben unverändert. Gilt ab neuen Sitzungen.",
"integrations.semantics.cline": "Verwaltet OpenCodex in providers.json und models.json der Cline CLI. Beenden Sie Cline vor Änderungen oder Synchronisierung und starten Sie es danach neu. Rückgängig stellt beide Originaldateien wieder her. Ihr Standardanbieter bleibt unverändert; wählen Sie OpenCodex in Cline.",
+ "integrations.semantics.kilo": "Verwaltet nur provider.opencodex in Kilos globaler Konfiguration — die erste vorhandene Datei unter kilo.jsonc, kilo.json, opencode.jsonc, opencode.json oder config.json in ~/.config/kilo (XDG_CONFIG_HOME verschiebt dieses Verzeichnis; ist keine vorhanden, wird kilo.jsonc erstellt). Andere Schlüssel bleiben unverändert. Anwenden schreibt die ganze Datei neu, daher bleiben Kommentare und nachgestellte Kommas nicht erhalten. Wählen Sie opencodex/ in Kilo.",
+ "integrations.semantics.droid": "Fügt Factory Droid in settings.json OpenCodex-Modelle hinzu. Deaktivieren entfernt nur verwaltete Einträge; Rückgängig stellt die gespeicherte Datei wieder her.",
"integrations.raycast.proRequired": "Custom Providers ist eine Funktion von Raycast Pro. Die Datei wird geschrieben, aber Raycast ignoriert sie, bis ein Pro-Abonnement aktiv ist.",
"integrations.raycast.planUnknown": "Es konnte nicht festgestellt werden, ob Raycast Pro aktiv ist; Custom Providers erfordert Raycast Pro.",
"integrations.raycast.revealConfig": "Öffnen Sie Raycast → Einstellungen → AI und klicken Sie einmal auf „Reveal Providers Config“, damit der Providers-Ordner existiert.",
@@ -2133,6 +2155,8 @@ export const de: Record = {
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
"api.clientConfig.clientCline": "Cline CLI",
+ "api.clientConfig.clientKilo": "Kilo",
+ "api.clientConfig.clientDroid": "Factory Droid",
"api.clientConfig.clineBundle": "Dies ist ein Paket für zwei Dateien, keine Cline-Einstellungsdatei. Fügen Sie settings in providers.json und catalog in die benachbarte models.json ein. Der Integrationsschalter erstellt vor dem Schreiben ein Backup.",
"api.clientConfig.clineDownloaded": "{filename} heruntergeladen. Noch nichts geändert; settings und catalog gehören in zwei separate Cline-Dateien.",
"api.clientConfig.copy": "Konfiguration kopieren",
@@ -2778,6 +2802,10 @@ export const de: Record = {
"cws.jev.exists": "JEV Auto ist bereits vorhanden.",
"cws.jev.setupHint": "Erstellt eine optionale, vollständig bearbeitbare Combo. JEV wählt für jede Anfrage ein erlaubtes Ziel und einen Reasoning-Aufwand.",
"cws.jev.failOpen": "Fail-open-Ziel",
+ "cws.jev.modelProfile": "Zusätzliche Modell-Notizen für JEV",
+ "cws.jev.modelProfilePlaceholder": "Optional: leer lassen, wenn keine zusätzlichen Notizen nötig sind.",
+ "cws.jev.modelProfileHint": "Optionale Notizen, die das integrierte Standardprofil ergänzen (nie ersetzen): Fähigkeiten, Kontext und relative Abo-Kosten. Wird bei jeder JEV-Entscheidung an TypeSafe gesendet.",
+ "cws.err.invalidModelProfile": "Modell-Notizen dürfen höchstens 512 Zeichen und keine Steuerzeichen enthalten.",
"cws.jev.allowedEfforts": "JEV darf auswählen",
"cws.jev.efforts": "Reasoning-Aufwände: {efforts}",
"cws.jev.effortsUnknown": "Reasoning-Aufwände nicht angegeben",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index d708ceacb4a..99ead720394 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -440,6 +440,23 @@ export const en = {
"compactionRouting.loadFailed": "Could not load compaction settings.",
"compactionRouting.saved": "Compaction settings saved.",
"compactionRouting.saveFailed": "Could not save. Your changes are still here; try again.",
+ "memoryModels.title": "Memory routing",
+ "memoryModels.description": "Codex writes memories after a session ends. Choose a model for either phase, or keep its existing route.",
+ "memoryModels.infoLabel": "What are Extract and Consolidation?",
+ "memoryModels.info": "Codex turns finished sessions into memory in two steps. Extract reads one finished session and jots down what happened: one note per session, so it makes many small calls. Consolidation takes those notes and writes them into the memory files Codex reads at the start of your next sessions. It runs rarely, but it edits files. Each step asks for its own model, which is why they are listed separately here.",
+ "memoryModels.extract": "Extract",
+ "memoryModels.extractHint": "Summarizes each finished session into a raw memory. Runs once per session.",
+ "memoryModels.consolidation": "Consolidation",
+ "memoryModels.consolidationHint": "Merges the raw memories into the memory files Codex reads later. Runs rarely and edits files.",
+ "memoryModels.model": "Model",
+ "memoryModels.effort": "Reasoning effort",
+ "memoryModels.off": "Off",
+ "memoryModels.defaultEffort": "Codex default",
+ "memoryModels.dataNotice": "The chosen model receives that phase's input: the finished session for Extract, the raw memories for Consolidation.",
+ "memoryModels.accountNotice": "Only one phase is routed here; the other keeps its existing route. Shadow Call Intercept can also send that phase's memory calls to its configured model.",
+ "memoryModels.loadFailed": "Could not load memory settings.",
+ "memoryModels.saved": "Memory settings saved.",
+ "memoryModels.saveFailed": "Could not save. Your changes are still here; try again.",
"dash.shadowCallIntercept": "Shadow Call Intercept",
"dash.shadowCallInterceptHint": "Intercepts Codex App's background helper calls ({models}) for title generation and commit messages and redirects them to your chosen model.",
"dash.shadowCallWarning": "⚠ When enabled, ALL requests for {models} will be replaced with the selected model.",
@@ -1980,6 +1997,8 @@ export const en = {
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
"integrations.tab.cline": "Cline CLI",
+ "integrations.tab.kilo": "Kilo",
+ "integrations.tab.droid": "Factory Droid",
"integrations.aside.profilesTitle": "Aside profiles",
"integrations.aside.profilesHint": "Choose which profiles receive the selected models. Aside’s active profile stays unchanged.",
"integrations.aside.all": "Sync all profiles",
@@ -2145,6 +2164,7 @@ export const en = {
"integrations.status.notInstalled": "Not installed",
"integrations.status.appliedAt": "Applied",
"integrations.status.supersededStore": "This client now reads its providers from {path}, which opencodex does not write, so enabling it here would change nothing it loads.",
+ "integrations.status.candidateConflict": "Another Kilo config file, {path}, also defines provider.opencodex. Remove provider.opencodex from that file before applying.",
"integrations.status.backup": "Backup",
"integrations.status.lastRestore": "Last restore",
"integrations.status.unknown": "Unknown",
@@ -2228,6 +2248,8 @@ export const en = {
"integrations.semantics.raycast": "Adds an OpenCodex provider entry to Raycast's providers.yaml so every routed model appears in the Raycast AI model picker. Raycast Pro required.",
"integrations.semantics.omo": "Manages only providers.opencodex in omo's models.json — ~/.omo/agent unless OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR or PI_CODING_AGENT_DIR redirects it. Your other providers stay unchanged. Applies to new sessions.",
"integrations.semantics.cline": "Manages OpenCodex in Cline CLI providers.json and models.json. Stop Cline before changing or syncing these files, then restart. Undo restores both originals. Your default provider stays unchanged; select OpenCodex in Cline.",
+ "integrations.semantics.kilo": "Manages only provider.opencodex in Kilo's global config — the first existing file among kilo.jsonc, kilo.json, opencode.jsonc, opencode.json, or config.json under ~/.config/kilo (XDG_CONFIG_HOME relocates that directory; kilo.jsonc is created when none exist). Other keys stay unchanged. Apply rewrites the whole file, so comments and trailing commas are not preserved. Select opencodex/ in Kilo.",
+ "integrations.semantics.droid": "Adds OpenCodex custom models to Factory Droid settings.json. Disable removes only managed rows; undo restores the saved file.",
"integrations.raycast.proRequired": "Custom Providers is a Raycast Pro feature. The file will be written, but Raycast ignores it until a Pro subscription is active.",
"integrations.raycast.planUnknown": "Could not determine whether Raycast Pro is active; Custom Providers requires Raycast Pro.",
"integrations.raycast.revealConfig": "Open Raycast → Settings → AI and click Reveal Providers Config once so the providers folder exists.",
@@ -2733,6 +2755,8 @@ export const en = {
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
"api.clientConfig.clientCline": "Cline CLI",
+ "api.clientConfig.clientKilo": "Kilo",
+ "api.clientConfig.clientDroid": "Factory Droid",
"api.clientConfig.clineBundle": "This is a two-file bundle, not a Cline settings file. Merge settings into providers.json and catalog into sibling models.json. Use the integration switch for a backed-up write.",
"api.clientConfig.clineDownloaded": "Downloaded {filename}. Nothing changed yet; settings and catalog belong in two separate Cline files.",
"api.clientConfig.copy": "Copy config",
@@ -2879,6 +2903,10 @@ export const en = {
"cws.jev.exists": "JEV Auto already exists.",
"cws.jev.setupHint": "Create an optional, fully editable Combo. JEV chooses one allowed target and reasoning effort for each request.",
"cws.jev.failOpen": "Fail-open target",
+ "cws.jev.modelProfile": "Additional model notes for JEV",
+ "cws.jev.modelProfilePlaceholder": "Optional: leave blank for no additional note.",
+ "cws.jev.modelProfileHint": "Optional notes that supplement (never replace) the built-in standard profile: capability, context, and relative subscription cost. Sent to TypeSafe per JEV decision.",
+ "cws.err.invalidModelProfile": "Model notes must be at most 512 characters; line breaks and tabs are the only control characters allowed.",
"cws.jev.allowedEfforts": "JEV may select",
"cws.jev.efforts": "Reasoning efforts: {efforts}",
"cws.jev.effortsUnknown": "Reasoning efforts not advertised",
diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts
index 78577fe8128..ca2c0b7572f 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -430,6 +430,23 @@ export const fr: Record = {
"compactionRouting.loadFailed": "Impossible de charger les paramètres de compaction.",
"compactionRouting.saved": "Paramètres de compaction enregistrés.",
"compactionRouting.saveFailed": "Échec de l’enregistrement. Vos modifications sont conservées ; réessayez.",
+ "memoryModels.title": "Routage de la mémoire",
+ "memoryModels.description": "Codex écrit les mémoires en arrière-plan après la session. Choisissez un modèle pour chaque étape ou conservez son routage actuel.",
+ "memoryModels.infoLabel": "Que sont Extract et Consolidation ?",
+ "memoryModels.info": "Codex transforme les sessions terminées en mémoire en deux étapes. Extract lit une session terminée et note ce qui s'y est passé : une fiche par session, donc beaucoup de petits appels. Consolidation reprend ces fiches et les écrit dans les fichiers de mémoire que Codex lit au début de vos sessions suivantes. Elle passe rarement, mais elle modifie des fichiers. Chaque étape demande son propre modèle, d'où ces deux lignes.",
+ "memoryModels.extract": "Extraction",
+ "memoryModels.extractHint": "Résume chaque session terminée en une mémoire brute. Une fois par session.",
+ "memoryModels.consolidation": "Consolidation",
+ "memoryModels.consolidationHint": "Fusionne les mémoires brutes dans les fichiers que Codex lit ensuite. Passe rarement et modifie des fichiers.",
+ "memoryModels.model": "Modèle",
+ "memoryModels.effort": "Effort de raisonnement",
+ "memoryModels.off": "Désactivé",
+ "memoryModels.defaultEffort": "Valeur Codex",
+ "memoryModels.dataNotice": "Le modèle choisi reçoit l'entrée de sa phase : la session terminée pour Extract, les mémoires brutes pour Consolidation.",
+ "memoryModels.accountNotice": "Une seule phase est routée ici ; l'autre garde sa route actuelle. Shadow Call Intercept peut aussi envoyer les appels de mémoire de cette phase vers son modèle configuré.",
+ "memoryModels.loadFailed": "Impossible de charger les réglages de mémoire.",
+ "memoryModels.saved": "Réglages de mémoire enregistrés.",
+ "memoryModels.saveFailed": "Échec de l'enregistrement. Vos modifications sont conservées ; réessayez.",
"dash.shadowCallIntercept": "Interception des appels fantômes",
"dash.shadowCallInterceptHint": "Intercepte les appels auxiliaires en arrière-plan de l’application Codex ({models}) pour générer les titres et les messages de commit, puis les redirige vers le modèle choisi.",
"dash.shadowCallWarning": "⚠ Lorsque cette option est activée, TOUTES les requêtes destinées à {models} sont remplacées par le modèle sélectionné.",
@@ -1952,6 +1969,8 @@ export const fr: Record = {
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
"integrations.tab.cline": "Cline CLI",
+ "integrations.tab.kilo": "Kilo",
+ "integrations.tab.droid": "Factory Droid",
"integrations.aside.profilesTitle": "Profils Aside",
"integrations.aside.profilesHint": "Choisissez les profils qui recevront les modèles sélectionnés. Le profil actif dans Aside reste inchangé.",
"integrations.aside.all": "Synchroniser tous les profils",
@@ -2077,6 +2096,7 @@ export const fr: Record = {
"integrations.status.notInstalled": "Non installé",
"integrations.status.appliedAt": "Appliqué",
"integrations.status.supersededStore": "Ce client lit désormais ses fournisseurs depuis {path}, un fichier qu'opencodex n'écrit pas : l'activer ici ne changerait rien à ce qu'il charge.",
+ "integrations.status.candidateConflict": "Un autre fichier de configuration Kilo, {path}, définit aussi provider.opencodex. Supprimez provider.opencodex de ce fichier avant d'appliquer.",
"integrations.status.backup": "Sauvegarde",
"integrations.status.lastRestore": "Dernière restauration",
"integrations.status.unknown": "Inconnu",
@@ -2160,6 +2180,8 @@ export const fr: Record = {
"integrations.semantics.raycast": "Ajoute une entrée de fournisseur OpenCodex dans le providers.yaml de Raycast afin que chaque modèle routé apparaisse dans le sélecteur de modèles de Raycast AI. Raycast Pro requis.",
"integrations.semantics.omo": "Gère uniquement providers.opencodex dans le models.json d'omo — ~/.omo/agent, sauf redirection par OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR ou PI_CODING_AGENT_DIR. Vos autres fournisseurs restent inchangés. S'applique aux nouvelles sessions.",
"integrations.semantics.cline": "Gère OpenCodex dans providers.json et models.json de Cline CLI. Quittez Cline avant toute modification ou synchronisation, puis redémarrez-le. Annuler restaure les deux fichiers originaux. Votre fournisseur par défaut reste inchangé ; sélectionnez OpenCodex dans Cline.",
+ "integrations.semantics.kilo": "Gère uniquement provider.opencodex dans la configuration globale de Kilo — le premier fichier existant parmi kilo.jsonc, kilo.json, opencode.jsonc, opencode.json ou config.json sous ~/.config/kilo (XDG_CONFIG_HOME déplace ce répertoire ; kilo.jsonc est créé si aucun n'existe). Les autres clés restent inchangées. Appliquer réécrit tout le fichier : commentaires et virgules finales ne sont pas conservés. Sélectionnez opencodex/ dans Kilo.",
+ "integrations.semantics.droid": "Ajoute les modèles personnalisés OpenCodex au settings.json de Factory Droid. Désactiver retire seulement les entrées gérées ; Annuler restaure le fichier sauvegardé.",
"integrations.raycast.proRequired": "Custom Providers est une fonctionnalité Raycast Pro. Le fichier sera écrit, mais Raycast l'ignore tant qu'un abonnement Pro n'est pas actif.",
"integrations.raycast.planUnknown": "Impossible de déterminer si Raycast Pro est actif ; Custom Providers nécessite Raycast Pro.",
"integrations.raycast.revealConfig": "Ouvrez Raycast → Réglages → AI et cliquez une fois sur « Reveal Providers Config » pour que le dossier des fournisseurs existe.",
@@ -2654,6 +2676,8 @@ export const fr: Record = {
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
"api.clientConfig.clientCline": "Cline CLI",
+ "api.clientConfig.clientKilo": "Kilo",
+ "api.clientConfig.clientDroid": "Factory Droid",
"api.clientConfig.clineBundle": "Ce paquet contient deux fichiers, pas un fichier de paramètres Cline. Fusionnez settings dans providers.json et catalog dans models.json du même dossier. Utilisez le commutateur d’intégration pour sauvegarder avant l’écriture.",
"api.clientConfig.clineDownloaded": "{filename} téléchargé. Rien n’a changé ; settings et catalog vont dans deux fichiers Cline distincts.",
"api.clientConfig.copy": "Copier la configuration",
@@ -2797,6 +2821,10 @@ export const fr: Record = {
"cws.jev.exists": "JEV Auto existe déjà.",
"cws.jev.setupHint": "Crée une combinaison facultative et entièrement modifiable. JEV choisit une cible autorisée et un effort de raisonnement pour chaque requête.",
"cws.jev.failOpen": "Cible de repli lorsque la décision JEV est indisponible ou invalide",
+ "cws.jev.modelProfile": "Notes de modèle supplémentaires pour JEV",
+ "cws.jev.modelProfilePlaceholder": "Facultatif : laissez vide pour ne pas ajouter de note.",
+ "cws.jev.modelProfileHint": "Notes facultatives qui complètent (sans jamais remplacer) le profil standard intégré : capacités, contexte et coût relatif de l'abonnement. Envoyées à TypeSafe à chaque décision JEV.",
+ "cws.err.invalidModelProfile": "Les notes de modèle ne doivent pas dépasser 512 caractères ni contenir de caractères de contrôle.",
"cws.jev.allowedEfforts": "JEV peut sélectionner",
"cws.jev.efforts": "Efforts de raisonnement : {efforts}",
"cws.jev.effortsUnknown": "Efforts de raisonnement non indiqués",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index a23b7addfae..283ed6f8f0b 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -431,6 +431,23 @@ export const ja: Record = {
"compactionRouting.loadFailed": "圧縮設定を読み込めませんでした。",
"compactionRouting.saved": "圧縮設定を保存しました。",
"compactionRouting.saveFailed": "保存できませんでした。変更内容は保持されています。再試行してください。",
+ "memoryModels.title": "メモリルーティング",
+ "memoryModels.description": "Codex はセッション終了後にバックグラウンドでメモリを書き込みます。各段階のモデルを選ぶか、現在のルートを維持します。",
+ "memoryModels.infoLabel": "Extract と Consolidation とは?",
+ "memoryModels.info": "Codex は終了したセッションを 2 段階でメモリにします。Extract は終了したセッションを 1 つ読み、起きたことを書き留めます。セッションごとにメモ 1 枚、つまり小さな呼び出しがたくさん発生します。Consolidation はそのメモをまとめ、次のセッションの開始時に Codex が読むメモリファイルへ書き込みます。めったに動きませんが、ファイルを編集します。各段階が自分のモデルを要求するため、ここでは別々に表示しています。",
+ "memoryModels.extract": "抽出",
+ "memoryModels.extractHint": "終了したセッションごとに生のメモリへ要約します。セッションごとに 1 回動きます。",
+ "memoryModels.consolidation": "統合",
+ "memoryModels.consolidationHint": "生のメモリを、Codex が後で読むメモリファイルへ統合します。めったに動かず、ファイルを編集します。",
+ "memoryModels.model": "モデル",
+ "memoryModels.effort": "推論の強さ",
+ "memoryModels.off": "オフ",
+ "memoryModels.defaultEffort": "Codex の既定",
+ "memoryModels.dataNotice": "選んだモデルにはその段階の入力が送られます。Extract では終了したセッション、Consolidation では生のメモリです。",
+ "memoryModels.accountNotice": "ここでは片方の段階だけをルーティングしています。もう一方は既存のルートのままです。Shadow Call Intercept がその段階のメモリ呼び出しを設定済みのモデルへ送ることもあります。",
+ "memoryModels.loadFailed": "メモリ設定を読み込めませんでした。",
+ "memoryModels.saved": "メモリ設定を保存しました。",
+ "memoryModels.saveFailed": "保存できませんでした。変更は残っています。もう一度お試しください。",
"dash.shadowCallIntercept": "シャドウコール傍受",
"dash.shadowCallInterceptHint": "Codex App のバックグラウンドヘルパー呼び出し({models}: タイトル生成、コミットメッセージ)を傍受し、選択したモデルにリダイレクトします。",
"dash.shadowCallWarning": "⚠ オンにすると、{models} へのリクエストがすべて選択したモデルに置き換えられます。",
@@ -1855,6 +1872,8 @@ export const ja: Record = {
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
"integrations.tab.cline": "Cline CLI",
+ "integrations.tab.kilo": "Kilo",
+ "integrations.tab.droid": "Factory Droid",
"integrations.aside.profilesTitle": "Asideのプロファイル",
"integrations.aside.profilesHint": "選択したモデルを同期するプロファイルを選んでください。Asideで使用中のプロファイルは変わりません。",
"integrations.aside.all": "すべてのプロファイルを同期",
@@ -1980,6 +1999,7 @@ export const ja: Record = {
"integrations.status.notInstalled": "未インストール",
"integrations.status.appliedAt": "適用",
"integrations.status.supersededStore": "このクライアントは現在 {path} からプロバイダーを読み込みます。opencodex はこのファイルを書き込まないため、ここで有効にしても読み込む内容は変わりません。",
+ "integrations.status.candidateConflict": "別の Kilo 設定ファイル {path} にも provider.opencodex が定義されています。適用する前に、そのファイルから provider.opencodex を削除してください。",
"integrations.status.backup": "バックアップ",
"integrations.status.lastRestore": "最終復元",
"integrations.status.unknown": "不明",
@@ -2063,6 +2083,8 @@ export const ja: Record = {
"integrations.semantics.raycast": "Raycast の providers.yaml に OpenCodex のプロバイダーエントリを追加し、ルーティングされたすべてのモデルを Raycast AI のモデル選択に表示します。Raycast Pro が必要です。",
"integrations.semantics.omo": "omo の models.json にある providers.opencodex のみを管理します。場所は ~/.omo/agent で、OMO_CODING_AGENT_DIR・SENPI_CODING_AGENT_DIR・PI_CODING_AGENT_DIR のいずれかが設定されている場合はそちらが優先されます。他のプロバイダーは変更しません。新しいセッションから適用されます。",
"integrations.semantics.cline": "Cline CLI の providers.json と models.json の OpenCodex 項目を管理します。変更・同期前に Cline を終了し、完了後に再起動してください。元に戻すと両方の元ファイルが復元されます。既定のプロバイダーは変わりません。Cline で OpenCodex を選択してください。",
+ "integrations.semantics.kilo": "Kilo のグローバル設定(~/.config/kilo 配下で最初に存在する kilo.jsonc、kilo.json、opencode.jsonc、opencode.json、config.json。XDG_CONFIG_HOME はこのディレクトリを移動し、いずれも存在しなければ kilo.jsonc が作成されます)の provider.opencodex のみを管理します。他のキーは変更しません。適用時にファイル全体を書き直すため、コメントと末尾カンマは保持されません。Kilo で opencodex/<モデル> を選択してください。",
+ "integrations.semantics.droid": "Factory Droid の settings.json に OpenCodex カスタムモデルを追加します。無効化すると管理対象の項目だけが削除され、元に戻すと保存したファイルが復元されます。",
"integrations.raycast.proRequired": "Custom Providers は Raycast Pro の機能です。ファイルは書き込まれますが、Pro サブスクリプションが有効になるまで Raycast はこれを無視します。",
"integrations.raycast.planUnknown": "Raycast Pro が有効かどうか確認できませんでした。Custom Providers には Raycast Pro が必要です。",
"integrations.raycast.revealConfig": "Raycast → 設定 → AI を開き、「Reveal Providers Config」を一度クリックして providers フォルダを作成してください。",
@@ -2563,6 +2585,8 @@ export const ja: Record = {
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
"api.clientConfig.clientCline": "Cline CLI",
+ "api.clientConfig.clientKilo": "Kilo",
+ "api.clientConfig.clientDroid": "Factory Droid",
"api.clientConfig.clineBundle": "これは Cline 設定ファイルではなく、2 ファイル分のデータです。settings を providers.json に、catalog を隣の models.json にマージしてください。バックアップ付きの書き込みには統合スイッチを使ってください。",
"api.clientConfig.clineDownloaded": "{filename} をダウンロードしました。設定はまだ変わっていません。settings と catalog は別々の Cline ファイルに保存します。",
"api.clientConfig.copy": "設定をコピー",
@@ -2853,6 +2877,10 @@ export const ja: Record = {
"cws.jev.exists": "JEV Auto はすでに存在します。",
"cws.jev.setupHint": "任意で追加でき、完全に編集可能なコンボを作成します。JEV はリクエストごとに、許可されたターゲットを 1 つ選び、互換性のある effort を選択します。",
"cws.jev.failOpen": "フェイルオープン先",
+ "cws.jev.modelProfile": "JEV の追加モデルメモ",
+ "cws.jev.modelProfilePlaceholder": "任意:追加メモが不要なら空欄のままにしてください。",
+ "cws.jev.modelProfileHint": "組み込み標準プロファイルを補完する(置き換えない)任意のメモ:能力、コンテキスト、相対的なサブスクリプション費用。JEV の判断ごとに TypeSafe に送信されます。",
+ "cws.err.invalidModelProfile": "モデルメモは 512 文字以内で、制御文字を含めないでください。",
"cws.jev.allowedEfforts": "JEV が選択可能",
"cws.jev.efforts": "推論 effort: {efforts}",
"cws.jev.effortsUnknown": "推論 effort は公開されていません",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index d7df5375960..9cd167e472b 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -426,6 +426,23 @@ export const ko: Record = {
"compactionRouting.loadFailed": "압축 설정을 불러올 수 없습니다.",
"compactionRouting.saved": "압축 설정을 저장했습니다.",
"compactionRouting.saveFailed": "저장하지 못했습니다. 변경 사항은 유지됩니다. 다시 시도하세요.",
+ "memoryModels.title": "메모리 라우팅",
+ "memoryModels.description": "Codex는 세션이 끝난 뒤 백그라운드에서 메모리를 작성합니다. 각 단계의 모델을 고르거나 기존 경로를 유지하세요.",
+ "memoryModels.infoLabel": "Extract와 Consolidation이 무엇인가요?",
+ "memoryModels.info": "Codex는 끝난 세션을 두 단계로 메모리로 만듭니다. Extract는 끝난 세션 하나를 읽고 무슨 일이 있었는지 적습니다. 세션마다 메모 한 장, 즉 작은 호출이 많습니다. Consolidation은 그 메모를 모아 다음 세션 시작에 Codex가 읽는 메모리 파일에 씁니다. 드물게 실행되지만 파일을 수정합니다. 각 단계가 자기 모델을 요청하기 때문에 여기에 따로 표시됩니다.",
+ "memoryModels.extract": "추출",
+ "memoryModels.extractHint": "끝난 세션마다 원시 메모리로 요약합니다. 세션당 한 번 실행됩니다.",
+ "memoryModels.consolidation": "통합",
+ "memoryModels.consolidationHint": "원시 메모리를 Codex가 나중에 읽는 메모리 파일로 합칩니다. 드물게 실행되며 파일을 수정합니다.",
+ "memoryModels.model": "모델",
+ "memoryModels.effort": "추론 노력",
+ "memoryModels.off": "사용 안 함",
+ "memoryModels.defaultEffort": "Codex 기본값",
+ "memoryModels.dataNotice": "선택한 모델은 해당 단계의 입력을 받습니다. Extract는 끝난 세션, Consolidation은 원시 메모리입니다.",
+ "memoryModels.accountNotice": "여기서는 한 단계만 라우팅했습니다. 나머지 단계는 기존 경로를 그대로 씁니다. Shadow Call Intercept가 그 단계의 메모리 호출을 설정된 모델로 보낼 수도 있습니다.",
+ "memoryModels.loadFailed": "메모리 설정을 불러오지 못했습니다.",
+ "memoryModels.saved": "메모리 설정을 저장했습니다.",
+ "memoryModels.saveFailed": "저장하지 못했습니다. 변경 사항은 그대로 있습니다. 다시 시도하세요.",
"dash.shadowCallIntercept": "쉐도우 호출 가로채기",
"dash.shadowCallInterceptHint": "Codex 앱이 제목·커밋 메시지 생성에 쓰는 백그라운드 호출({models})을 가로채 선택한 모델로 바꿉니다.",
"dash.shadowCallWarning": "⚠ 활성화하면 {models} 요청이 모두 선택한 모델로 대체됩니다.",
@@ -1467,6 +1484,8 @@ export const ko: Record = {
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
"integrations.tab.cline": "Cline CLI",
+ "integrations.tab.kilo": "Kilo",
+ "integrations.tab.droid": "Factory Droid",
"integrations.aside.profilesTitle": "Aside 프로필",
"integrations.aside.profilesHint": "선택한 모델을 동기화할 프로필을 고르세요. Aside에서 사용 중인 프로필은 바뀌지 않습니다.",
"integrations.aside.all": "모든 프로필 동기화",
@@ -1592,6 +1611,7 @@ export const ko: Record = {
"integrations.status.notInstalled": "설치되지 않음",
"integrations.status.appliedAt": "적용",
"integrations.status.supersededStore": "이 클라이언트는 이제 {path}에서 프로바이더를 읽습니다. opencodex는 이 파일을 쓰지 않으므로 여기서 켜도 클라이언트가 불러오는 내용은 달라지지 않습니다.",
+ "integrations.status.candidateConflict": "다른 Kilo 설정 파일 {path}에도 provider.opencodex가 정의되어 있습니다. 적용하려면 그 파일에서 provider.opencodex를 제거하세요.",
"integrations.status.backup": "백업",
"integrations.status.lastRestore": "마지막 복원",
"integrations.status.unknown": "알 수 없음",
@@ -1675,6 +1695,8 @@ export const ko: Record = {
"integrations.semantics.raycast": "Raycast의 providers.yaml에 OpenCodex 프로바이더 항목을 추가해 라우팅된 모든 모델이 Raycast AI 모델 선택기에 표시되도록 합니다. Raycast Pro가 필요합니다.",
"integrations.semantics.omo": "omo의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.omo/agent이며 OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR, PI_CODING_AGENT_DIR 중 설정된 값이 있으면 그쪽이 우선합니다. 다른 프로바이더는 그대로 유지됩니다. 새 세션부터 적용됩니다.",
"integrations.semantics.cline": "Cline CLI의 providers.json과 models.json에서 OpenCodex 항목을 관리합니다. 변경·동기화 전에 Cline을 종료하고 완료 후 다시 실행하세요. 되돌리기는 두 파일의 원본을 복원합니다. 기본 프로바이더는 유지되므로 Cline에서 OpenCodex를 선택하세요.",
+ "integrations.semantics.kilo": "Kilo 전역 설정(~/.config/kilo에서 먼저 존재하는 kilo.jsonc, kilo.json, opencode.jsonc, opencode.json, config.json. XDG_CONFIG_HOME는 이 디렉터리를 이동하며, 아무것도 없으면 kilo.jsonc가 생성됨)의 provider.opencodex만 관리합니다. 다른 키는 그대로 둡니다. 적용 시 파일 전체를 다시 쓰므로 주석과 후행 쉼표는 보존되지 않습니다. Kilo에서 opencodex/<모델>을 선택하세요.",
+ "integrations.semantics.droid": "Factory Droid의 settings.json에 OpenCodex 사용자 지정 모델을 추가합니다. 비활성화하면 관리 항목만 제거하고, 되돌리기는 저장한 파일을 복원합니다.",
"integrations.raycast.proRequired": "Custom Providers는 Raycast Pro 기능입니다. 파일은 기록되지만 Pro 구독이 활성화될 때까지 Raycast는 이를 무시합니다.",
"integrations.raycast.planUnknown": "Raycast Pro 활성 여부를 확인할 수 없습니다. Custom Providers에는 Raycast Pro가 필요합니다.",
"integrations.raycast.revealConfig": "Raycast → 설정 → AI를 열고 「Reveal Providers Config」를 한 번 클릭해 providers 폴더를 만드세요.",
@@ -2172,6 +2194,8 @@ export const ko: Record = {
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
"api.clientConfig.clientCline": "Cline CLI",
+ "api.clientConfig.clientKilo": "Kilo",
+ "api.clientConfig.clientDroid": "Factory Droid",
"api.clientConfig.clineBundle": "이 묶음은 Cline 설정 파일 자체가 아닙니다. settings는 providers.json에, catalog는 옆의 models.json에 병합하세요. 백업을 남기려면 통합 스위치를 사용하세요.",
"api.clientConfig.clineDownloaded": "{filename}을 다운로드했습니다. 아직 설정은 바뀌지 않았습니다. settings와 catalog는 서로 다른 Cline 파일에 넣어야 합니다.",
"api.clientConfig.copy": "설정 복사",
@@ -2817,6 +2841,10 @@ export const ko: Record = {
"cws.jev.exists": "JEV Auto가 이미 있습니다.",
"cws.jev.setupHint": "선택 사항인 완전 편집 가능 콤보를 만듭니다. JEV가 요청마다 허용된 대상과 추론 노력을 선택합니다.",
"cws.jev.failOpen": "장애 시 기본 대상",
+ "cws.jev.modelProfile": "JEV 추가 모델 노트",
+ "cws.jev.modelProfilePlaceholder": "선택 사항: 추가 노트가 없으면 비워 두세요.",
+ "cws.jev.modelProfileHint": "내장 표준 프로필을 보완하는(대체하지 않는) 선택적 노트: 기능, 컨텍스트, 상대적 구독 비용. JEV 결정 시마다 TypeSafe로 전송됩니다.",
+ "cws.err.invalidModelProfile": "모델 노트는 최대 512자이며 제어 문자를 포함할 수 없습니다.",
"cws.jev.allowedEfforts": "JEV 선택 가능",
"cws.jev.efforts": "추론 노력: {efforts}",
"cws.jev.effortsUnknown": "추론 노력이 공개되지 않음",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 6bd6b67ae65..0da14c4754d 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -431,6 +431,23 @@ export const ru: Record = {
"compactionRouting.loadFailed": "Не удалось загрузить настройки сжатия.",
"compactionRouting.saved": "Настройки сжатия сохранены.",
"compactionRouting.saveFailed": "Не удалось сохранить. Изменения остались; попробуйте снова.",
+ "memoryModels.title": "Маршрутизация памяти",
+ "memoryModels.description": "Codex пишет память в фоне после завершения сессии. Выберите модель для каждого этапа или сохраните текущий маршрут.",
+ "memoryModels.infoLabel": "Что такое Extract и Consolidation?",
+ "memoryModels.info": "Codex превращает завершённые сессии в память за два шага. Extract читает одну завершённую сессию и записывает, что в ней произошло: одна заметка на сессию, то есть много небольших вызовов. Consolidation берёт эти заметки и записывает их в файлы памяти, которые Codex читает в начале следующих сессий. Запускается редко, но изменяет файлы. Каждый шаг сам запрашивает модель, поэтому они показаны отдельно.",
+ "memoryModels.extract": "Извлечение",
+ "memoryModels.extractHint": "Сводит каждую завершённую сессию в одну сырую запись. Запускается раз на сессию.",
+ "memoryModels.consolidation": "Консолидация",
+ "memoryModels.consolidationHint": "Сводит сырые записи в файлы памяти, которые Codex читает позже. Запускается редко и изменяет файлы.",
+ "memoryModels.model": "Модель",
+ "memoryModels.effort": "Усилие рассуждений",
+ "memoryModels.off": "Выключено",
+ "memoryModels.defaultEffort": "Как в Codex",
+ "memoryModels.dataNotice": "Выбранная модель получает входные данные своей фазы: завершённую сессию для Extract и сырые записи для Consolidation.",
+ "memoryModels.accountNotice": "Здесь смаршрутизирована только одна фаза; другая сохраняет свой текущий маршрут. Shadow Call Intercept тоже может отправлять вызовы памяти другой фазы в свою настроенную модель.",
+ "memoryModels.loadFailed": "Не удалось загрузить настройки памяти.",
+ "memoryModels.saved": "Настройки памяти сохранены.",
+ "memoryModels.saveFailed": "Не удалось сохранить. Ваши изменения на месте; попробуйте снова.",
"dash.shadowCallIntercept": "Перехват теневых вызовов",
"dash.shadowCallInterceptHint": "Перехватывает фоновые служебные вызовы Codex App ({models}: генерация заголовков, сообщений коммитов) и перенаправляет их на выбранную вами модель.",
"dash.shadowCallWarning": "⚠ Когда функция включена, ВСЕ запросы к {models} будут заменены выбранной моделью.",
@@ -1946,6 +1963,8 @@ export const ru: Record = {
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
"integrations.tab.cline": "Cline CLI",
+ "integrations.tab.kilo": "Kilo",
+ "integrations.tab.droid": "Factory Droid",
"integrations.aside.profilesTitle": "Профили Aside",
"integrations.aside.profilesHint": "Выберите профили, в которые будут добавлены выбранные модели. Активный профиль Aside не изменится.",
"integrations.aside.all": "Синхронизировать все профили",
@@ -2071,6 +2090,7 @@ export const ru: Record = {
"integrations.status.notInstalled": "Не установлен",
"integrations.status.appliedAt": "Применено",
"integrations.status.supersededStore": "Этот клиент теперь читает провайдеров из {path}, а opencodex этот файл не пишет, поэтому включение здесь ничего не изменит в том, что клиент загружает.",
+ "integrations.status.candidateConflict": "Другой файл конфигурации Kilo, {path}, тоже определяет provider.opencodex. Перед применением удалите provider.opencodex из этого файла.",
"integrations.status.backup": "Резервная копия",
"integrations.status.lastRestore": "Последнее восстановление",
"integrations.status.unknown": "Неизвестно",
@@ -2154,6 +2174,8 @@ export const ru: Record = {
"integrations.semantics.raycast": "Добавляет запись провайдера OpenCodex в providers.yaml Raycast, чтобы каждая маршрутизируемая модель появилась в выборе моделей Raycast AI. Требуется Raycast Pro.",
"integrations.semantics.omo": "Управляет только providers.opencodex в models.json omo — ~/.omo/agent, если только OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR или PI_CODING_AGENT_DIR не перенаправляет путь. Остальные провайдеры остаются без изменений. Применяется к новым сессиям.",
"integrations.semantics.cline": "Управляет OpenCodex в файлах providers.json и models.json Cline CLI. Закройте Cline перед изменением или синхронизацией и запустите снова после завершения. Отмена восстанавливает оба исходных файла. Провайдер по умолчанию не меняется; выберите OpenCodex в Cline.",
+ "integrations.semantics.kilo": "Управляет только provider.opencodex в глобальной конфигурации Kilo — первый существующий файл среди kilo.jsonc, kilo.json, opencode.jsonc, opencode.json или config.json в ~/.config/kilo (XDG_CONFIG_HOME переносит этот каталог; если файла нет, создаётся kilo.jsonc). Остальные ключи не меняются. Применение перезаписывает весь файл, поэтому комментарии и завершающие запятые не сохраняются. Выберите opencodex/<модель> в Kilo.",
+ "integrations.semantics.droid": "Добавляет модели OpenCodex в settings.json Factory Droid. Отключение удаляет только управляемые записи; отмена восстанавливает сохранённый файл.",
"integrations.raycast.proRequired": "Custom Providers — функция Raycast Pro. Файл будет записан, но Raycast игнорирует его, пока не активна подписка Pro.",
"integrations.raycast.planUnknown": "Не удалось определить, активен ли Raycast Pro; для Custom Providers требуется Raycast Pro.",
"integrations.raycast.revealConfig": "Откройте Raycast → Настройки → AI и один раз нажмите «Reveal Providers Config», чтобы папка провайдеров появилась.",
@@ -2653,6 +2675,8 @@ export const ru: Record = {
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
"api.clientConfig.clientCline": "Cline CLI",
+ "api.clientConfig.clientKilo": "Kilo",
+ "api.clientConfig.clientDroid": "Factory Droid",
"api.clientConfig.clineBundle": "Это пакет для двух файлов, а не файл настроек Cline. Объедините settings с providers.json, а catalog — с models.json в той же папке. Переключатель интеграции создаёт резервную копию перед записью.",
"api.clientConfig.clineDownloaded": "Файл {filename} загружен. Настройки ещё не изменены; settings и catalog предназначены для двух отдельных файлов Cline.",
"api.clientConfig.copy": "Копировать конфигурацию",
@@ -2944,6 +2968,10 @@ export const ru: Record = {
"cws.jev.exists": "JEV Auto уже существует.",
"cws.jev.setupHint": "Создаёт необязательное, полностью редактируемое комбо. JEV выбирает разрешённую цель и уровень рассуждения для каждого запроса.",
"cws.jev.failOpen": "Цель fail-open",
+ "cws.jev.modelProfile": "Дополнительные заметки о модели для JEV",
+ "cws.jev.modelProfilePlaceholder": "Необязательно: оставьте поле пустым, если дополнительных заметок нет.",
+ "cws.jev.modelProfileHint": "Необязательные заметки, дополняющие (но не заменяющие) встроенный стандартный профиль: возможности, контекст и относительная стоимость подписки. Отправляются в TypeSafe при каждом решении JEV.",
+ "cws.err.invalidModelProfile": "Заметки о модели должны содержать не более 512 символов и не содержать управляющих символов.",
"cws.jev.allowedEfforts": "JEV может выбрать",
"cws.jev.efforts": "Уровни рассуждения: {efforts}",
"cws.jev.effortsUnknown": "Уровни рассуждения не заявлены",
diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts
index 0faaf29a308..a718cb0c04d 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -432,6 +432,23 @@ export const tr: Record = {
"compactionRouting.loadFailed": "Özetleme ayarları yüklenemedi.",
"compactionRouting.saved": "Özetleme ayarları kaydedildi.",
"compactionRouting.saveFailed": "Kaydedilemedi. Değişiklikleriniz korunuyor; tekrar deneyin.",
+ "memoryModels.title": "Bellek yönlendirmesi",
+ "memoryModels.description": "Codex, oturum bittikten sonra belleği arka planda yazar. Her aşama için model seçin veya mevcut yönlendirmeyi koruyun.",
+ "memoryModels.infoLabel": "Extract ve Consolidation nedir?",
+ "memoryModels.info": "Codex, biten oturumları iki adımda belleğe dönüştürür. Extract biten bir oturumu okuyup ne olduğunu not eder: oturum başına bir not, yani çok sayıda küçük çağrı. Consolidation bu notları alıp Codex'in sonraki oturumların başında okuduğu bellek dosyalarına yazar. Seyrek çalışır ama dosyaları düzenler. Her adım kendi modelini ister, bu yüzden burada ayrı görünürler.",
+ "memoryModels.extract": "Çıkarım",
+ "memoryModels.extractHint": "Her biten oturumu bir ham belleğe özetler. Oturum başına bir kez çalışır.",
+ "memoryModels.consolidation": "Birleştirme",
+ "memoryModels.consolidationHint": "Ham bellekleri Codex'in sonra okuduğu bellek dosyalarında birleştirir. Seyrek çalışır ve dosyaları düzenler.",
+ "memoryModels.model": "Model",
+ "memoryModels.effort": "Akıl yürütme çabası",
+ "memoryModels.off": "Kapalı",
+ "memoryModels.defaultEffort": "Codex varsayılanı",
+ "memoryModels.dataNotice": "Seçilen model kendi aşamasının girdisini alır: Extract için biten oturum, Consolidation için ham bellekler.",
+ "memoryModels.accountNotice": "Burada yalnızca bir aşama yönlendirildi; diğeri mevcut yolunu korur. Shadow Call Intercept o aşamanın bellek çağrılarını da kendi ayarlı modeline gönderebilir.",
+ "memoryModels.loadFailed": "Bellek ayarları yüklenemedi.",
+ "memoryModels.saved": "Bellek ayarları kaydedildi.",
+ "memoryModels.saveFailed": "Kaydedilemedi. Değişiklikleriniz duruyor; tekrar deneyin.",
"dash.shadowCallIntercept": "Gölge Çağrı Yakalama",
"dash.shadowCallInterceptHint": "Codex App'in arka plan yardımcı çağrılarını ({models}) başlık oluşturma ve commit mesajları için yakalar ve seçtiğiniz modele yönlendirir.",
"dash.shadowCallWarning": "⚠ Etkinleştirildiğinde, {models} için olan TÜM istekler seçilen modelle değiştirilecektir.",
@@ -1965,6 +1982,8 @@ export const tr: Record = {
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
"integrations.tab.cline": "Cline CLI",
+ "integrations.tab.kilo": "Kilo",
+ "integrations.tab.droid": "Factory Droid",
"integrations.aside.profilesTitle": "Aside profilleri",
"integrations.aside.profilesHint": "Seçili modellerin hangi profillere aktarılacağını seçin. Aside’ın etkin profili değişmez.",
"integrations.aside.all": "Tüm profilleri eşitle",
@@ -2090,6 +2109,7 @@ export const tr: Record = {
"integrations.status.notInstalled": "Yüklü değil",
"integrations.status.appliedAt": "Uygulandı",
"integrations.status.supersededStore": "Bu istemci sağlayıcılarını artık {path} dosyasından okuyor; opencodex bu dosyayı yazmadığı için buradan etkinleştirmek istemcinin yüklediklerini değiştirmez.",
+ "integrations.status.candidateConflict": "Başka bir Kilo yapılandırma dosyası olan {path} da provider.opencodex tanımlıyor. Uygulamadan önce bu dosyadan provider.opencodex değerini kaldırın.",
"integrations.status.backup": "Yedek",
"integrations.status.lastRestore": "Son geri yükleme",
"integrations.status.unknown": "Bilinmiyor",
@@ -2172,6 +2192,8 @@ export const tr: Record = {
"integrations.semantics.raycast": "Raycast'in providers.yaml dosyasına bir OpenCodex sağlayıcı girdisi ekler; böylece yönlendirilen her model Raycast AI model seçicisinde görünür. Raycast Pro gerekir.",
"integrations.semantics.omo": "Yalnızca omo'nun models.json dosyasındaki providers.opencodex girdisini yönetir — OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR veya PI_CODING_AGENT_DIR yönlendirmediği sürece ~/.omo/agent. Diğer sağlayıcılarınız değişmeden kalır. Yeni oturumlardan itibaren geçerlidir.",
"integrations.semantics.cline": "Cline CLI providers.json ve models.json dosyalarındaki OpenCodex girdilerini yönetir. Değişiklik veya eşitleme öncesinde Cline’ı kapatın, ardından yeniden başlatın. Geri al iki özgün dosyayı da geri yükler. Varsayılan sağlayıcınız değişmez; Cline içinde OpenCodex’i seçin.",
+ "integrations.semantics.kilo": "Yalnızca Kilo’nun genel yapılandırmasındaki provider.opencodex öğesini yönetir — ~/.config/kilo altındaki ilk bulunan kilo.jsonc, kilo.json, opencode.jsonc, opencode.json veya config.json (XDG_CONFIG_HOME bu dizini taşır; hiçbiri yoksa kilo.jsonc oluşturulur). Diğer anahtarlar değişmez. Uygula dosyanın tamamını yeniden yazar; yorumlar ve sondaki virgüller korunmaz. Kilo’da opencodex/ seçin.",
+ "integrations.semantics.droid": "Factory Droid settings.json dosyasına OpenCodex özel modellerini ekler. Devre dışı bırakma yalnızca yönetilen girdileri kaldırır; Geri al kaydedilen dosyayı geri yükler.",
"integrations.raycast.proRequired": "Custom Providers bir Raycast Pro özelliğidir. Dosya yazılır, ancak bir Pro aboneliği etkin olana kadar Raycast bunu yok sayar.",
"integrations.raycast.planUnknown": "Raycast Pro’nun etkin olup olmadığı belirlenemedi; Custom Providers için Raycast Pro gerekir.",
"integrations.raycast.revealConfig": "Raycast → Ayarlar → AI bölümünü açıp sağlayıcı klasörünün oluşması için „Reveal Providers Config“ seçeneğine bir kez tıklayın.",
@@ -2672,6 +2694,8 @@ export const tr: Record = {
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
"api.clientConfig.clientCline": "Cline CLI",
+ "api.clientConfig.clientKilo": "Kilo",
+ "api.clientConfig.clientDroid": "Factory Droid",
"api.clientConfig.clineBundle": "Bu, bir Cline ayar dosyası değil, iki dosyalık bir pakettir. settings içeriğini providers.json, catalog içeriğini aynı klasördeki models.json ile birleştirin. Yazmadan önce yedek almak için entegrasyon anahtarını kullanın.",
"api.clientConfig.clineDownloaded": "{filename} indirildi. Henüz değişiklik yapılmadı; settings ve catalog ayrı Cline dosyalarına aittir.",
"api.clientConfig.copy": "JSON Kopyala",
@@ -2820,6 +2844,10 @@ export const tr: Record = {
"cws.jev.exists": "JEV Auto zaten mevcut.",
"cws.jev.setupHint": "İsteğe bağlı ve tamamen düzenlenebilir bir kombo oluşturur. JEV her istek için izin verilen bir hedef ve akıl yürütme eforu seçer.",
"cws.jev.failOpen": "Fail-open hedefi",
+ "cws.jev.modelProfile": "JEV için ek model notları",
+ "cws.jev.modelProfilePlaceholder": "İsteğe bağlı: ek not yoksa boş bırakın.",
+ "cws.jev.modelProfileHint": "Yerleşik standart profili tamamlayan (asla değiştirmeyen) isteğe bağlı notlar: yetenek, bağlam ve göreli abonelik maliyeti. Her JEV kararında TypeSafe'e gönderilir.",
+ "cws.err.invalidModelProfile": "Model notları en fazla 512 karakter olmalı ve denetim karakteri içermemelidir.",
"cws.jev.allowedEfforts": "JEV şunları seçebilir",
"cws.jev.efforts": "Akıl yürütme eforları: {efforts}",
"cws.jev.effortsUnknown": "Akıl yürütme eforları belirtilmedi",
diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts
index 9082a7e4ba8..09734a65782 100644
--- a/gui/src/i18n/vi.ts
+++ b/gui/src/i18n/vi.ts
@@ -430,6 +430,23 @@ export const vi: Record = {
"compactionRouting.loadFailed": "Không thể tải cài đặt nén.",
"compactionRouting.saved": "Đã lưu cài đặt nén.",
"compactionRouting.saveFailed": "Không thể lưu. Thay đổi của bạn vẫn còn; hãy thử lại.",
+ "memoryModels.title": "Định tuyến bộ nhớ",
+ "memoryModels.description": "Codex ghi bộ nhớ ở chế độ nền sau khi phiên kết thúc. Chọn mô hình cho từng giai đoạn hoặc giữ tuyến hiện tại.",
+ "memoryModels.infoLabel": "Extract và Consolidation là gì?",
+ "memoryModels.info": "Codex biến các phiên đã kết thúc thành bộ nhớ qua hai bước. Extract đọc một phiên đã kết thúc và ghi lại những gì đã diễn ra: một ghi chú cho mỗi phiên, nên có nhiều lời gọi nhỏ. Consolidation lấy các ghi chú đó và ghi vào các tệp bộ nhớ mà Codex đọc khi bắt đầu các phiên sau. Hiếm khi chạy nhưng có sửa tệp. Mỗi bước tự yêu cầu mô hình riêng, nên ở đây chúng được tách riêng.",
+ "memoryModels.extract": "Trích xuất",
+ "memoryModels.extractHint": "Tóm tắt mỗi phiên đã kết thúc thành một bộ nhớ thô. Chạy một lần mỗi phiên.",
+ "memoryModels.consolidation": "Hợp nhất",
+ "memoryModels.consolidationHint": "Hợp nhất các bộ nhớ thô vào tệp bộ nhớ mà Codex đọc sau này. Hiếm khi chạy và có sửa tệp.",
+ "memoryModels.model": "Mô hình",
+ "memoryModels.effort": "Mức suy luận",
+ "memoryModels.off": "Tắt",
+ "memoryModels.defaultEffort": "Mặc định của Codex",
+ "memoryModels.dataNotice": "Mô hình được chọn nhận đầu vào của giai đoạn đó: phiên đã kết thúc với Extract, bộ nhớ thô với Consolidation.",
+ "memoryModels.accountNotice": "Ở đây chỉ có một giai đoạn được định tuyến; giai đoạn còn lại giữ nguyên tuyến hiện có. Shadow Call Intercept cũng có thể gửi các lệnh gọi bộ nhớ của giai đoạn đó tới mô hình đã đặt.",
+ "memoryModels.loadFailed": "Không tải được cài đặt bộ nhớ.",
+ "memoryModels.saved": "Đã lưu cài đặt bộ nhớ.",
+ "memoryModels.saveFailed": "Không lưu được. Thay đổi của bạn vẫn còn; hãy thử lại.",
"dash.shadowCallIntercept": "Shadow Call Intercept",
"dash.shadowCallInterceptHint": "Chặn các lệnh gọi helper nền ({models}) của ứng dụng Codex để tạo tiêu đề và commit messages, sau đó chuyển hướng chúng đến model bạn đã chọn.",
"dash.shadowCallWarning": "⚠ Khi được bật, TẤT CẢ yêu cầu đối với {models} sẽ được thay thế bằng model được chọn.",
@@ -1935,6 +1952,8 @@ export const vi: Record = {
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
"integrations.tab.cline": "Cline CLI",
+ "integrations.tab.kilo": "Kilo",
+ "integrations.tab.droid": "Factory Droid",
"integrations.aside.profilesTitle": "Cấu hình Aside",
"integrations.aside.profilesHint": "Chọn profile nào sẽ nhận các model được chọn. Profile đang hoạt động của Aside sẽ được giữ nguyên.",
"integrations.aside.all": "Đồng bộ tất cả profile",
@@ -2100,6 +2119,7 @@ export const vi: Record = {
"integrations.status.notInstalled": "Chưa cài đặt",
"integrations.status.appliedAt": "Đã áp dụng",
"integrations.status.supersededStore": "Máy khách này hiện đọc danh sách nhà cung cấp từ {path}, tệp mà opencodex không ghi, nên bật ở đây sẽ không thay đổi những gì nó tải.",
+ "integrations.status.candidateConflict": "Một tệp cấu hình Kilo khác, {path}, cũng định nghĩa provider.opencodex. Hãy xóa provider.opencodex khỏi tệp đó trước khi áp dụng.",
"integrations.status.backup": "Sao lưu",
"integrations.status.lastRestore": "Lần khôi phục cuối",
"integrations.status.unknown": "Không xác định",
@@ -2183,6 +2203,8 @@ export const vi: Record = {
"integrations.semantics.raycast": "Thêm một provider OpenCodex vào providers.yaml của Raycast để mọi model được định tuyến xuất hiện trong bộ chọn model AI của Raycast. Yêu cầu Raycast Pro.",
"integrations.semantics.omo": "Chỉ quản lý providers.opencodex trong models.json của omo — ~/.omo/agent, trừ khi OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR hoặc PI_CODING_AGENT_DIR chuyển hướng. Các nhà cung cấp khác của bạn không thay đổi. Áp dụng cho phiên mới.",
"integrations.semantics.cline": "Quản lý OpenCodex trong providers.json và models.json của Cline CLI. Hãy dừng Cline trước khi thay đổi hoặc đồng bộ các tệp này, rồi khởi động lại. Hoàn tác sẽ khôi phục cả hai bản gốc. Nhà cung cấp mặc định của bạn không thay đổi; hãy chọn OpenCodex trong Cline.",
+ "integrations.semantics.kilo": "Chỉ quản lý provider.opencodex trong cấu hình toàn cục của Kilo — tệp tồn tại đầu tiên trong kilo.jsonc, kilo.json, opencode.jsonc, opencode.json hoặc config.json dưới ~/.config/kilo (XDG_CONFIG_HOME chuyển thư mục này; nếu không tệp nào tồn tại, kilo.jsonc sẽ được tạo). Các khóa khác giữ nguyên. Áp dụng ghi lại toàn bộ tệp nên chú thích và dấu phẩy cuối không được giữ. Chọn opencodex/ trong Kilo.",
+ "integrations.semantics.droid": "Thêm các mô hình OpenCodex vào settings.json của Factory Droid. Tắt chỉ xóa các mục được quản lý; Hoàn tác khôi phục tệp đã lưu.",
"integrations.raycast.proRequired": "Custom Providers là một tính năng của Raycast Pro. File sẽ được ghi, nhưng Raycast sẽ bỏ qua nó cho đến khi đăng ký Pro được kích hoạt.",
"integrations.raycast.planUnknown": "Không thể xác định xem Raycast Pro có đang hoạt động hay không; Custom Providers yêu cầu Raycast Pro.",
"integrations.raycast.revealConfig": "Mở Raycast → Settings → AI và nhấp vào Reveal Providers Config một lần để thư mục providers được tạo.",
@@ -2667,6 +2689,8 @@ export const vi: Record = {
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
"api.clientConfig.clientCline": "Cline CLI",
+ "api.clientConfig.clientKilo": "Kilo",
+ "api.clientConfig.clientDroid": "Factory Droid",
"api.clientConfig.clineBundle": "Đây là gói gồm hai tệp, không phải tệp cài đặt Cline. Hãy hợp nhất cài đặt vào providers.json và catalog vào models.json cùng cấp. Dùng công tắc tích hợp để ghi kèm bản sao lưu.",
"api.clientConfig.clineDownloaded": "Đã tải xuống {filename}. Chưa có gì thay đổi; cài đặt và catalog thuộc hai tệp Cline riêng biệt.",
"api.clientConfig.copy": "Sao chép cấu hình",
@@ -2810,6 +2834,10 @@ export const vi: Record = {
"cws.jev.exists": "JEV Auto đã tồn tại.",
"cws.jev.setupHint": "Tạo một combo tùy chọn, có thể chỉnh sửa hoàn toàn. JEV chọn một mục tiêu được phép và mức suy luận cho mỗi yêu cầu.",
"cws.jev.failOpen": "Mục tiêu fail-open",
+ "cws.jev.modelProfile": "Ghi chú mô hình bổ sung cho JEV",
+ "cws.jev.modelProfilePlaceholder": "Tùy chọn: để trống nếu không có ghi chú bổ sung.",
+ "cws.jev.modelProfileHint": "Ghi chú tùy chọn bổ sung (không thay thế) hồ sơ chuẩn tích hợp sẵn: năng lực, ngữ cảnh và chi phí thuê bao tương đối. Được gửi tới TypeSafe theo mỗi quyết định của JEV.",
+ "cws.err.invalidModelProfile": "Ghi chú mô hình tối đa 512 ký tự và không được chứa ký tự điều khiển.",
"cws.jev.allowedEfforts": "JEV có thể chọn",
"cws.jev.efforts": "Mức suy luận: {efforts}",
"cws.jev.effortsUnknown": "Mức suy luận chưa được công bố",
diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts
index e3f4654a540..d8039a77b94 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -308,6 +308,23 @@ export const zhTW: Record = {
"compactionRouting.loadFailed": "無法載入壓縮設定。",
"compactionRouting.saved": "壓縮設定已儲存。",
"compactionRouting.saveFailed": "儲存失敗。變更仍然保留,請重試。",
+ "memoryModels.title": "記憶路由",
+ "memoryModels.description": "工作階段結束後,Codex 會在背景寫入記憶。為每個階段選擇模型,或保留現有路由。",
+ "memoryModels.infoLabel": "Extract 和 Consolidation 是什麼?",
+ "memoryModels.info": "Codex 用兩個步驟把結束的工作階段變成記憶。Extract 讀取一個已結束的工作階段並記下其中發生的事:每個工作階段一張筆記,因此會有很多小型請求。Consolidation 把這些筆記寫進 Codex 在後續工作階段開始時讀取的記憶檔案。很少執行,但會修改檔案。兩個步驟各自要求自己的模型,所以這裡分開顯示。",
+ "memoryModels.extract": "擷取",
+ "memoryModels.extractHint": "把每個結束的工作階段彙整成一筆原始記憶。每個工作階段執行一次。",
+ "memoryModels.consolidation": "合併",
+ "memoryModels.consolidationHint": "把原始記憶合併進 Codex 之後讀取的記憶檔案。很少執行,而且會修改檔案。",
+ "memoryModels.model": "模型",
+ "memoryModels.effort": "推理強度",
+ "memoryModels.off": "關閉",
+ "memoryModels.defaultEffort": "Codex 預設",
+ "memoryModels.dataNotice": "所選模型會收到該階段的輸入:Extract 是已結束的工作階段,Consolidation 是原始記憶。",
+ "memoryModels.accountNotice": "這裡只路由了一個階段;另一個階段保留其現有路由。Shadow Call Intercept 也可能把該階段的記憶呼叫送往其設定的模型。",
+ "memoryModels.loadFailed": "無法載入記憶設定。",
+ "memoryModels.saved": "記憶設定已儲存。",
+ "memoryModels.saveFailed": "儲存失敗。你的變更還在,請再試一次。",
"dash.shadowCallIntercept": "影子呼叫攔截",
"dash.shadowCallInterceptHint": "攔截 Codex 應用的背景 helper 呼叫({models})以生成標題與提交訊息,並將它們重定向到您選擇的模型。",
"dash.shadowCallWarning": "⚠ 啟用後,{models} 的所有請求將被替換為所選模型。",
@@ -2094,6 +2111,10 @@ export const zhTW: Record = {
"cws.jev.exists": "JEV Auto 已存在。",
"cws.jev.setupHint": "建立一個選用且可完整編輯的組合。JEV 會為每個請求選擇一個允許的目標與推理強度。",
"cws.jev.failOpen": "故障開放目標",
+ "cws.jev.modelProfile": "JEV 的附加模型備註",
+ "cws.jev.modelProfilePlaceholder": "可選:沒有附加備註時請留空。",
+ "cws.jev.modelProfileHint": "可選備註,用於補充(而非取代)內建標準設定檔:能力、脈絡與相對訂閱成本。每次 JEV 決策時傳送給 TypeSafe。",
+ "cws.err.invalidModelProfile": "模型備註最多 512 個字元,且不可包含控制字元。",
"cws.jev.allowedEfforts": "JEV 可選擇",
"cws.jev.efforts": "推理強度:{efforts}",
"cws.jev.effortsUnknown": "未公布推理強度",
@@ -2753,6 +2774,8 @@ export const zhTW: Record = {
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
"integrations.tab.cline": "Cline CLI",
+ "integrations.tab.kilo": "Kilo",
+ "integrations.tab.droid": "Factory Droid",
"integrations.aside.profilesTitle": "Aside 設定檔",
"integrations.aside.profilesHint": "選擇要接收所選模型的設定檔。Aside 目前使用的設定檔不會改變。",
"integrations.aside.all": "同步所有設定檔",
@@ -2878,6 +2901,7 @@ export const zhTW: Record = {
"integrations.status.notInstalled": "未安裝",
"integrations.status.appliedAt": "已套用",
"integrations.status.supersededStore": "此用戶端現在從 {path} 讀取供應商,而 opencodex 不會寫入該檔案,因此在這裡啟用不會改變它載入的內容。",
+ "integrations.status.candidateConflict": "另一個 Kilo 設定檔 {path} 也定義了 provider.opencodex。套用之前,請從該檔案移除 provider.opencodex。",
"integrations.status.backup": "備份",
"integrations.status.lastRestore": "上次還原",
"integrations.status.unknown": "未知",
@@ -2961,6 +2985,8 @@ export const zhTW: Record = {
"integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中新增一個 OpenCodex 供應商項目,讓所有已路由的模型出現在 Raycast AI 模型選擇器中。需要 Raycast Pro。",
"integrations.semantics.omo": "僅管理 omo 的 models.json 中的 providers.opencodex,路徑為 ~/.omo/agent,若設定了 OMO_CODING_AGENT_DIR、SENPI_CODING_AGENT_DIR 或 PI_CODING_AGENT_DIR 則以其為準。你的其他供應商維持不變。對新工作階段生效。",
"integrations.semantics.cline": "管理 Cline CLI 的 providers.json 和 models.json 中的 OpenCodex 項目。修改或同步前請結束 Cline,完成後重新啟動。復原會還原兩個檔案的原始內容。預設供應商保持不變,請在 Cline 中選擇 OpenCodex。",
+ "integrations.semantics.kilo": "僅管理 Kilo 全域設定——~/.config/kilo 下最先存在的 kilo.jsonc、kilo.json、opencode.jsonc、opencode.json 或 config.json(XDG_CONFIG_HOME 會移動該目錄;若都不存在則建立 kilo.jsonc)——中的 provider.opencodex。其他鍵保持不變。套用會重寫整個檔案,因此不會保留註解與尾隨逗號。請在 Kilo 中選擇 opencodex/<模型>。",
+ "integrations.semantics.droid": "將 OpenCodex 自訂模型加入 Factory Droid 的 settings.json。停用只移除受管理項目;復原會還原已儲存的檔案。",
"integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。檔案會被寫入,但在 Pro 訂閱生效之前 Raycast 會忽略它。",
"integrations.raycast.planUnknown": "無法確認 Raycast Pro 是否已啟用;Custom Providers 需要 Raycast Pro。",
"integrations.raycast.revealConfig": "開啟 Raycast → 設定 → AI,點一次「Reveal Providers Config」,以便建立 providers 資料夾。",
@@ -3014,6 +3040,8 @@ export const zhTW: Record = {
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
"api.clientConfig.clientCline": "Cline CLI",
+ "api.clientConfig.clientKilo": "Kilo",
+ "api.clientConfig.clientDroid": "Factory Droid",
"api.clientConfig.clineBundle": "這是兩個檔案的資料包,不是 Cline 設定檔。將 settings 合併至 providers.json,將 catalog 合併至同目錄的 models.json。使用整合開關可在寫入前備份。",
"api.clientConfig.clineDownloaded": "已下載 {filename},設定尚未變更。settings 與 catalog 應分別儲存至兩個 Cline 檔案。",
"cws.tabsLabel": "Combo 詳細區段",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index 9d4516af97d..b6f1b869da1 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -426,6 +426,23 @@ export const zh: Record = {
"compactionRouting.loadFailed": "无法加载压缩设置。",
"compactionRouting.saved": "压缩设置已保存。",
"compactionRouting.saveFailed": "保存失败。更改仍然保留,请重试。",
+ "memoryModels.title": "记忆路由",
+ "memoryModels.description": "会话结束后,Codex 会在后台写入记忆。为每个阶段选择模型,或保留现有路由。",
+ "memoryModels.infoLabel": "Extract 和 Consolidation 是什么?",
+ "memoryModels.info": "Codex 分两步把结束的会话变成记忆。Extract 读取一个已结束的会话并记下其中的内容:每个会话一张笔记,因此有很多小请求。Consolidation 把这些笔记写进 Codex 在后续会话开始时读取的记忆文件。很少运行,但会修改文件。两步各自请求自己的模型,所以这里分开展示。",
+ "memoryModels.extract": "提取",
+ "memoryModels.extractHint": "把每个结束的会话汇总成一条原始记忆。每个会话运行一次。",
+ "memoryModels.consolidation": "整合",
+ "memoryModels.consolidationHint": "把原始记忆合并进 Codex 之后读取的记忆文件。很少运行,并会修改文件。",
+ "memoryModels.model": "模型",
+ "memoryModels.effort": "推理强度",
+ "memoryModels.off": "关闭",
+ "memoryModels.defaultEffort": "Codex 默认",
+ "memoryModels.dataNotice": "所选模型会收到该阶段的输入:Extract 是已结束的会话,Consolidation 是原始记忆。",
+ "memoryModels.accountNotice": "这里只路由了一个阶段;另一个阶段保留其现有路由。Shadow Call Intercept 也可能把该阶段的记忆调用发往其配置的模型。",
+ "memoryModels.loadFailed": "无法加载记忆设置。",
+ "memoryModels.saved": "记忆设置已保存。",
+ "memoryModels.saveFailed": "保存失败。你的改动仍在,请重试。",
"dash.shadowCallIntercept": "影子调用拦截",
"dash.shadowCallInterceptHint": "拦截 Codex 应用的后台辅助调用({models}:标题生成、提交消息)并重定向到所选模型。",
"dash.shadowCallWarning": "⚠ 启用后,所有对 {models} 的请求都将被替换为所选模型。",
@@ -1448,6 +1465,8 @@ export const zh: Record = {
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
"integrations.tab.cline": "Cline CLI",
+ "integrations.tab.kilo": "Kilo",
+ "integrations.tab.droid": "Factory Droid",
"integrations.aside.profilesTitle": "Aside 配置文件",
"integrations.aside.profilesHint": "选择要接收所选模型的配置文件。Aside 当前使用的配置文件不会改变。",
"integrations.aside.all": "同步所有配置文件",
@@ -1573,6 +1592,7 @@ export const zh: Record = {
"integrations.status.notInstalled": "未安装",
"integrations.status.appliedAt": "已应用",
"integrations.status.supersededStore": "此客户端现在从 {path} 读取提供方,而 opencodex 不写入该文件,因此在这里启用不会改变它加载的内容。",
+ "integrations.status.candidateConflict": "另一个 Kilo 配置文件 {path} 也定义了 provider.opencodex。应用之前,请从该文件中移除 provider.opencodex。",
"integrations.status.backup": "备份",
"integrations.status.lastRestore": "上次恢复",
"integrations.status.unknown": "未知",
@@ -1656,6 +1676,8 @@ export const zh: Record = {
"integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中添加一个 OpenCodex 提供商条目,让所有已路由的模型出现在 Raycast AI 模型选择器中。需要 Raycast Pro。",
"integrations.semantics.omo": "仅管理 omo 的 models.json 中的 providers.opencodex,路径为 ~/.omo/agent,若设置了 OMO_CODING_AGENT_DIR、SENPI_CODING_AGENT_DIR 或 PI_CODING_AGENT_DIR 则以其为准。你的其他提供商保持不变。对新会话生效。",
"integrations.semantics.cline": "管理 Cline CLI 的 providers.json 和 models.json 中的 OpenCodex 项目。修改或同步前请退出 Cline,完成后重新启动。撤销会恢复两个文件的原始内容。默认提供商保持不变,请在 Cline 中选择 OpenCodex。",
+ "integrations.semantics.kilo": "仅管理 Kilo 全局配置——~/.config/kilo 下最先存在的 kilo.jsonc、kilo.json、opencode.jsonc、opencode.json 或 config.json(XDG_CONFIG_HOME 会移动该目录;若都不存在则创建 kilo.jsonc)——中的 provider.opencodex。其他键保持不变。应用会重写整个文件,因此不会保留注释和尾随逗号。请在 Kilo 中选择 opencodex/<模型>。",
+ "integrations.semantics.droid": "将 OpenCodex 自定义模型添加到 Factory Droid 的 settings.json。停用仅移除托管条目;撤销会恢复已保存的文件。",
"integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。文件会被写入,但在 Pro 订阅生效之前 Raycast 会忽略它。",
"integrations.raycast.planUnknown": "无法确定 Raycast Pro 是否已激活;Custom Providers 需要 Raycast Pro。",
"integrations.raycast.revealConfig": "打开 Raycast → 设置 → AI,点击一次“Reveal Providers Config”,以便创建 providers 文件夹。",
@@ -2153,6 +2175,8 @@ export const zh: Record = {
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
"api.clientConfig.clientCline": "Cline CLI",
+ "api.clientConfig.clientKilo": "Kilo",
+ "api.clientConfig.clientDroid": "Factory Droid",
"api.clientConfig.clineBundle": "这是两个文件的数据包,不是 Cline 设置文件。将 settings 合并到 providers.json,将 catalog 合并到同目录的 models.json。使用集成开关可在写入前备份。",
"api.clientConfig.clineDownloaded": "已下载 {filename},设置尚未更改。settings 和 catalog 应分别保存到两个 Cline 文件。",
"api.clientConfig.copy": "复制配置",
@@ -2798,6 +2822,10 @@ export const zh: Record = {
"cws.jev.exists": "JEV Auto 已存在。",
"cws.jev.setupHint": "创建一个可选且完全可编辑的组合。JEV 会为每个请求选择一个允许的目标和推理强度。",
"cws.jev.failOpen": "故障开放目标",
+ "cws.jev.modelProfile": "JEV 的附加模型备注",
+ "cws.jev.modelProfilePlaceholder": "可选:没有附加备注时请留空。",
+ "cws.jev.modelProfileHint": "可选备注,用于补充(而非替代)内置标准档案:能力、上下文和相对订阅成本。每次 JEV 决策时发送给 TypeSafe。",
+ "cws.err.invalidModelProfile": "模型备注最多 512 个字符,且不能包含控制字符。",
"cws.jev.allowedEfforts": "JEV 可选择",
"cws.jev.efforts": "推理强度:{efforts}",
"cws.jev.effortsUnknown": "未公布推理强度",
diff --git a/gui/src/pages/dashboard-overview-panels.tsx b/gui/src/pages/dashboard-overview-panels.tsx
index 9d6a9e43684..afe2874d380 100644
--- a/gui/src/pages/dashboard-overview-panels.tsx
+++ b/gui/src/pages/dashboard-overview-panels.tsx
@@ -1,5 +1,6 @@
import CompactionRoutingPanel from "../components/CompactionRoutingPanel";
import MemoryObservabilityCard from "../components/MemoryObservabilityCard";
+import MemoryModelsPanel from "../components/MemoryModelsPanel";
import type { useDashboardData } from "./use-dashboard-data";
import {
DashboardEffortCapPanel,
@@ -20,6 +21,7 @@ export function DashboardOverviewPanels(props: Dash) {
+
>
);
diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx
index b8806d563df..bcfa783178e 100644
--- a/gui/src/pages/integrations/FileIntegrationPage.tsx
+++ b/gui/src/pages/integrations/FileIntegrationPage.tsx
@@ -15,6 +15,7 @@ import {
loadIntegrationJournal,
loadIntegrationState,
previewIntegrationMutation,
+ canDisableKiloWithCandidateIssue,
toggleIntegration,
bindingFor,
IntegrationApiError,
@@ -83,6 +84,8 @@ const SEMANTICS_KEY: Record = {
raycast: "integrations.semantics.raycast",
omo: "integrations.semantics.omo",
cline: "integrations.semantics.cline",
+ kilo: "integrations.semantics.kilo",
+ droid: "integrations.semantics.droid",
};
const TAB_LABEL_KEY: Record = {
@@ -101,6 +104,8 @@ const TAB_LABEL_KEY: Record = {
raycast: "integrations.tab.raycast",
omo: "integrations.tab.omo",
cline: "integrations.tab.cline",
+ kilo: "integrations.tab.kilo",
+ droid: "integrations.tab.droid",
};
export default function FileIntegrationPage({
@@ -261,12 +266,13 @@ export default function FileIntegrationPage({
);
}
- const applied = status.state === "current" || status.state === "stale";
+ const removableKiloIssue = canDisableKiloWithCandidateIssue(status);
+ const applied = status.state === "current" || status.state === "stale" || removableKiloIssue;
const enabled = profileId !== undefined ? status.enabled === true : applied;
const profileUnavailable = profileId !== undefined && (stateResource.state.showError || stateResource.state.refreshing);
// A profile may stop future sync even when its file cannot be changed; the
// writer still refuses unsafe deletion and reports the actual state separately.
- const locked = (!status.installed || status.state === "conflict" || status.state === "unsafe")
+ const locked = (!status.installed || ((status.state === "conflict" || status.state === "unsafe") && !removableKiloIssue))
&& !(profileId !== undefined && enabled);
return (
@@ -312,10 +318,10 @@ export default function FileIntegrationPage({
Conflict used to be a dead end: the switch locks, the page explains why,
and the only way forward was to open the file and edit it by hand -- which
is the thing a user came to a dashboard to avoid. The switch stays locked
- and this is the one way past it, behind a dialog that names the file and
- says what is lost.
+ and this is the way past an overwriteable conflict, behind a dialog
+ that names the file and says what is lost.
*/}
- {status.installed && status.state === "conflict" && (
+ {status.installed && status.state === "conflict" && status.reason !== "candidate-conflict" && (
{t(SEMANTICS_KEY[client])}
@@ -201,8 +206,7 @@ function OverviewCard({
// advisory refusal must all be resolved before mutation.
disabled={row.state === "unknown"
|| !row.installed
- || row.state === "conflict"
- || row.state === "unsafe"
+ || ((row.state === "conflict" || row.state === "unsafe") && !removableKiloIssue)
|| toggleBlocked
|| pending}
label={toggleOn
@@ -217,8 +221,8 @@ function OverviewCard({
{/*
Only in conflict, and only for a file client. The switch beside it stays
- disabled -- this is not a second way to toggle, it is the way past a state
- the toggle deliberately refuses to guess about.
+ disabled -- this is not a second way to toggle, it is the way past a
+ conflict the server permits replacing.
*/}
{onOverwrite && (
@@ -778,7 +782,7 @@ export default function IntegrationsOverview({
result={cardResults[row.id] ?? null}
onOpen={() => navigateHash(row.hash)}
onToggle={row.toggle ? () => requestToggle(row, !(row.toggleOn ?? row.applied)) : null}
- onOverwrite={row.status !== null && row.status.state === "conflict" && row.installed
+ onOverwrite={row.status !== null && row.status.state === "conflict" && row.status.reason !== "candidate-conflict" && row.installed
? () => void requestFilePlan(row, "overwrite")
: null}
/>
@@ -795,7 +799,7 @@ export default function IntegrationsOverview({
{t("integrations.rollback.title")}
{/*
The newest operation stays visible and the rest collapse. This page
- already carries a summary, an API row and fifteen cards, so fifty
+ already carries a summary, an API row and the file-client cards, so fifty
bordered rows below them buried the one control a user wants after a
mistake. The older rows are kept rather than dropped: this is the only
place showing one chronology ACROSS clients, since each client tab reads
diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts
index 02733e095ce..873e28683fa 100644
--- a/gui/src/pages/integrations/integration-api.ts
+++ b/gui/src/pages/integrations/integration-api.ts
@@ -17,6 +17,8 @@ export const FILE_INTEGRATION_CLIENTS = [
"raycast",
"omo",
"cline",
+ "kilo",
+ "droid",
] as const;
export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number];
@@ -29,6 +31,7 @@ export type IntegrationReason =
| "unowned-key"
| "blocked-container"
| "ambiguous-selector"
+ | "candidate-conflict"
| "unresolvable-path";
export type IntegrationRefusalReason =
@@ -62,6 +65,8 @@ export interface IntegrationStatus {
appliedAt?: string;
lastOpId?: string;
reason?: IntegrationReason;
+ conflictPaths?: string[];
+ candidateFailurePath?: string;
/**
* The store this client reads instead of `configPath`, when one exists.
*
@@ -78,6 +83,14 @@ export interface IntegrationStatus {
raycast?: RaycastInstall;
}
+/** A candidate issue can block adding Kilo while removal still targets its recorded file. */
+export function canDisableKiloWithCandidateIssue(status: IntegrationStatus): boolean {
+ return status.clientId === "kilo" && !!status.lastOpId
+ && (status.reason === "candidate-conflict"
+ || (status.state === "unsafe" && !!status.candidateFailurePath
+ && status.candidateFailurePath !== status.configPath));
+}
+
export interface IntegrationStateListEnvelope {
clients: IntegrationStatus[];
}
@@ -238,6 +251,7 @@ const PLAN_SCHEMA_PATHS = new Set([
"providers.[id=opencodex]",
"settings.providers.opencodex",
"catalog.providers.opencodex",
+ "customModels.*",
// 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/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts
index a1810763ddb..4978aaeebcc 100644
--- a/gui/src/pages/integrations/integration-tabs.ts
+++ b/gui/src/pages/integrations/integration-tabs.ts
@@ -49,6 +49,8 @@ export const TABS: readonly TabDefinition[] = [
{ id: "raycast", hash: "integrations/raycast", labelKey: "integrations.tab.raycast" },
{ id: "omo", hash: "integrations/omo", labelKey: "integrations.tab.omo" },
{ 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" },
] as const;
export const FILE_CLIENTS = new Set([
@@ -67,4 +69,6 @@ export const FILE_CLIENTS = new Set([
"raycast",
"omo",
"cline",
+ "kilo",
+ "droid",
]);
diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts
index d54d99d0057..df02ccf29b5 100644
--- a/gui/src/pages/integrations/overview-clients.ts
+++ b/gui/src/pages/integrations/overview-clients.ts
@@ -16,6 +16,7 @@ import type { TKey } from "../../i18n/shared";
import type { VisualIntegrationState } from "./IntegrationStateBadge";
import {
FILE_INTEGRATION_CLIENTS,
+ canDisableKiloWithCandidateIssue,
type FileIntegrationClientId,
type IntegrationJournalRow,
type IntegrationStatus,
@@ -155,6 +156,8 @@ const FILE_LABEL_KEY: Record = {
raycast: "integrations.tab.raycast",
omo: "integrations.tab.omo",
cline: "integrations.tab.cline",
+ kilo: "integrations.tab.kilo",
+ droid: "integrations.tab.droid",
};
/** A file client's block is in the file for both `current` and `stale`. */
@@ -519,7 +522,7 @@ function fileRow(status: IntegrationStatus): OverviewRow {
// of its file state; do the same here so the grid and the count agree.
state: status.installed ? status.state : "not-installed",
installed: status.installed,
- applied: status.installed && isAppliedState(status.state),
+ applied: status.installed && (isAppliedState(status.state) || canDisableKiloWithCandidateIssue(status)),
detail: status.configPath,
detailKey: null,
detailVars: null,
diff --git a/gui/src/styles-dashboard-workspace.css b/gui/src/styles-dashboard-workspace.css
index 22f60ef9159..71b08d4e470 100644
--- a/gui/src/styles-dashboard-workspace.css
+++ b/gui/src/styles-dashboard-workspace.css
@@ -655,6 +655,52 @@
.dash-shadow-controls > .custom-select { flex: 1; min-width: 0; }
.dash-shadow-controls > .switch { order: 1; }
+/* Memory routing: two fixed column widths, shared by both rows. The pickers must not
+ size themselves from their own label — content-sized pills turned "Low" and "Medium"
+ into two different widths, so the Consolidation row never lined up with the Extract
+ row above it, and the effort picker alone wasted 220px on a five-letter word.
+
+ Each column gets its own width and both rows use the same two, so the columns compare
+ across the rows instead of inside one row: the model column is exactly as wide as the
+ longest common id needs (measured 220px at the trigger's 13px font, trigger padding,
+ chevron gap and chevron included), the effort column fits the longest option label
+ "Codex default" (measured 136px, rounded to 140px). A longer model id ellipsizes
+ inside its column instead of widening one row's pill, so the two rows keep matching
+ columns whatever is selected. */
+.memory-models-row { align-items: center; }
+
+.memory-models-controls {
+ display: flex;
+ align-items: center;
+ flex-wrap: nowrap;
+ gap: 8px;
+ flex: 0 0 min(100%, 23rem);
+ min-width: 0;
+}
+
+.memory-models-controls > .custom-select:first-child {
+ flex: 0 1 220px;
+ min-width: 0;
+}
+
+.memory-models-controls > .custom-select:last-child {
+ flex: 0 1 140px;
+ min-width: 0;
+}
+
+.memory-models-controls .select-trigger {
+ justify-content: space-between;
+ width: 100%;
+ min-width: 0;
+}
+
+.memory-models-controls .select-trigger > span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.dash-overview-stack .setting-hint,
.dash-overview-stack .dash-sync-hint { max-width: 60ch; }
diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx
index dffaf4d0994..57d7a577ebb 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(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"]);
+ expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid"]);
expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh");
expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode");
expect(CLIENT_LABEL_KEYS.zcode).toBe("api.clientConfig.clientZcode");
diff --git a/gui/tests/combos-detail-tabs-dom.test.tsx b/gui/tests/combos-detail-tabs-dom.test.tsx
index 60ef6b58d51..fcdbd845ea4 100644
--- a/gui/tests/combos-detail-tabs-dom.test.tsx
+++ b/gui/tests/combos-detail-tabs-dom.test.tsx
@@ -92,6 +92,33 @@ test("both tabs control an element that exists", () => {
});
});
+test("a baseline with delimiter-shaped JEV notes resets the target draft", async () => {
+ const { createRoot } = await import("react-dom/client");
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+ const first = { ...emptyDraft("collision"), strategy: "jev" as const, targets: [
+ { provider: "provider-a", model: "model-a", weight: 1, modelProfile: "x,provider-b/model-b:1:y" },
+ ] };
+ const second = { ...first, targets: [
+ { provider: "provider-a", model: "model-a", weight: 1, modelProfile: "x" },
+ { provider: "provider-b", model: "model-b", weight: 1, modelProfile: "y" },
+ ] };
+ const render = (baseline: typeof first) => (
+ {}} onSave={async () => ({ ok: true })} onDirtyChange={() => {}} />
+ );
+ try {
+ await act(async () => { root.render(render(first)); });
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
+ expect(container.querySelectorAll(".cwi-target-entry textarea")).toHaveLength(1);
+ await act(async () => { root.render(render(second)); });
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
+ expect(container.querySelectorAll(".cwi-target-entry textarea")).toHaveLength(2);
+ } finally { await act(async () => root.unmount()); }
+});
+
test("exactly one panel is exposed at a time", async () => {
const { container, root } = await mountDetail();
try {
diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts
index 9baa172e2ec..62038c5c9c3 100644
--- a/gui/tests/fr-localization.test.ts
+++ b/gui/tests/fr-localization.test.ts
@@ -146,6 +146,11 @@ const INTENTIONAL_ENGLISH = new Set([
// Cline product name and CLI acronym are intentionally preserved.
"integrations.tab.cline",
"api.clientConfig.clientCline",
+ "integrations.tab.kilo",
+ "api.clientConfig.clientKilo",
+ // Factory Droid is a product name, identical in every locale.
+ "integrations.tab.droid",
+ "api.clientConfig.clientDroid",
"models.reasoningEffort.minimal",
"models.reasoningEffort.max",
"models.reasoningEffort.ultra",
@@ -218,6 +223,10 @@ const INTENTIONAL_ENGLISH = new Set([
"logs.protocol.wire.chat",
"logs.protocol.wire.messages",
"logs.protocol.hop.ir",
+ // The consolidation phase's name is the ordinary French noun, spelled exactly as in English.
+ // Inventing a synonym would also break the pair with the extract row, whose French label is
+ // "Extraction".
+ "memoryModels.consolidation",
]);
function placeholders(value: string): string[] {
diff --git a/gui/tests/integration-marks.test.ts b/gui/tests/integration-marks.test.ts
index b13387d1b68..8b60b94de7d 100644
--- a/gui/tests/integration-marks.test.ts
+++ b/gui/tests/integration-marks.test.ts
@@ -12,8 +12,8 @@ function bodyOf(src: string): string {
}
function inksOf(body: string): Set {
- const matches = body.match(/(?:fill|stop-color)\s*[:=]\s*"?#[0-9a-fA-F]{3,8}/g) ?? [];
- return new Set(matches.map(raw => raw.split(/[:=]/).pop()!.replace(/"/g, "").trim().toLowerCase()));
+ const matches = body.matchAll(/(?:fill|stop-color)\s*[:=]\s*"?(#[0-9a-fA-F]{3,8}|oklch\([^)]+\))/g);
+ return new Set([...matches].map(match => match[1]!.toLowerCase()));
}
/*
diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts
index 17599aa4998..546bb3dc473 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([
- "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline",
+ "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid",
]);
});
diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts
index 4e44e8d674b..f37bcecd54c 100644
--- a/gui/tests/integrations-overview-rows.test.ts
+++ b/gui/tests/integrations-overview-rows.test.ts
@@ -4,7 +4,7 @@ import {
countOverviewRows,
type OverviewSources,
} from "../src/pages/integrations/overview-clients";
-import type { IntegrationStatus } from "../src/pages/integrations/integration-api";
+import { FILE_INTEGRATION_CLIENTS, type IntegrationStatus } from "../src/pages/integrations/integration-api";
import type { NativeStatus } from "../src/pages/integrations/native-api";
/**
@@ -234,6 +234,26 @@ test("file clients keep their existing badge and applied semantics", () => {
expect(counts.stale).toBe(1);
});
+test("an owned Kilo block remains switch-on while a later candidate conflicts", () => {
+ const rows = buildOverviewRows(sources({ clients: [
+ fileStatus({ clientId: "kilo", state: "conflict", reason: "candidate-conflict", lastOpId: "owned-operation" }),
+ fileStatus({ clientId: "hermes", state: "conflict", reason: "foreign-edit", lastOpId: "other-operation" }),
+ ] }));
+ expect(rowById(rows, "kilo")).toMatchObject({ state: "conflict", applied: true });
+ expect(rowById(rows, "hermes").applied).toBe(false);
+
+ const laterUnsafe = buildOverviewRows(sources({ clients: [fileStatus({
+ clientId: "kilo", state: "unsafe", reason: "unparseable", configPath: "/tmp/kilo.jsonc",
+ candidateFailurePath: "/tmp/kilo.json", lastOpId: "owned-operation",
+ })] }));
+ expect(rowById(laterUnsafe, "kilo").applied).toBe(true);
+ const selectedUnsafe = buildOverviewRows(sources({ clients: [fileStatus({
+ clientId: "kilo", state: "unsafe", reason: "unparseable", configPath: "/tmp/config.json",
+ candidateFailurePath: "/tmp/config.json", lastOpId: "owned-operation",
+ })] }));
+ expect(rowById(selectedUnsafe, "kilo").applied).toBe(false);
+});
+
test("every client counts toward the summary, not just the file clients", () => {
const rows = buildOverviewRows(sources({
clients: [fileStatus({ clientId: "opencode", state: "current" })],
@@ -290,8 +310,10 @@ test("every client counts toward the summary, not just the file clients", () =>
test("an unsettled file list renders unknown rows instead of dropping them", () => {
const built = buildOverviewRows(sources({ clients: [], clientsSettled: false }));
- expect(built.rows).toHaveLength(20);
+ expect(built.rows).toHaveLength(FILE_INTEGRATION_CLIENTS.length + 5);
expect(rowById(built, "cline")).toMatchObject({ hash: "integrations/cline", labelKey: "integrations.tab.cline", state: "unknown" });
+ expect(rowById(built, "kilo")).toMatchObject({ hash: "integrations/kilo", labelKey: "integrations.tab.kilo", state: "unknown" });
+ expect(rowById(built, "droid")).toMatchObject({ hash: "integrations/droid", labelKey: "integrations.tab.droid", state: "unknown" });
expect(rowById(built, "omp").state).toBe("unknown");
expect(rowById(built, "mcode").state).toBe("unknown");
expect(rowById(built, "zcode").state).toBe("unknown");
diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx
index 5a4429d3b10..fdade2a8396 100644
--- a/gui/tests/integrations-surfaces.test.tsx
+++ b/gui/tests/integrations-surfaces.test.tsx
@@ -237,7 +237,7 @@ afterEach(async () => {
async function mountClient(
active = true,
- client: "hermes" | "dsh" = "hermes",
+ client: "hermes" | "dsh" | "kilo" = "hermes",
): Promise {
const [{ createRoot }, { LanguageProvider }, { default: FileIntegrationPage }] = await Promise.all([
import("react-dom/client"),
@@ -262,7 +262,7 @@ async function mountClient(
* replays the first response and the new `stateResponse` has no effect. Rotating
* the base is what makes a state sweep in a single test possible at all.
*/
-async function remountClient(client: "hermes" | "dsh" = "hermes"): Promise {
+async function remountClient(client: "hermes" | "dsh" | "kilo" = "hermes"): Promise {
if (root) {
const current = root;
await act(async () => { current.unmount(); });
@@ -439,6 +439,43 @@ test("a conflict offers an overwrite, and no other state does", async () => {
expect(buttonByText("Replace")).toBeDefined();
});
+test("Kilo candidate conflict names the other file and offers no overwrite", async () => {
+ const path = "/tmp/home/.config/kilo/opencode.jsonc";
+ stateResponse = () => json(status({ clientId: "kilo", state: "conflict", reason: "candidate-conflict", conflictPaths: [path] }));
+ await mountClient(true, "kilo");
+ expect(buttonByText("Replace")).toBeUndefined();
+ expect(container.textContent).toContain(path);
+ expect(container.textContent).toContain("Remove provider.opencodex from that file");
+});
+
+test("an owned Kilo candidate conflict still offers Disable", async () => {
+ const path = "/tmp/home/.config/kilo/opencode.jsonc";
+ stateResponse = () => json(status({ clientId: "kilo", state: "conflict", reason: "candidate-conflict", conflictPaths: [path], lastOpId: "owned-operation" }));
+ await mountClient(true, "kilo");
+ const sw = toggleSwitch();
+ expect(sw.disabled).toBe(false);
+ expect(sw.getAttribute("aria-pressed")).toBe("true");
+ await act(async () => { sw.click(); });
+ await confirmDialog("Disable");
+ expect(requests.find(request => request.method === "PUT")?.body).toEqual({
+ enabled: false, operation: "disable", planFingerprint: previewPlan("disable").fingerprint,
+ });
+});
+
+test("Kilo can disable past an unsafe later candidate, but not an unsafe selected file", async () => {
+ const configPath = "/tmp/home/.config/kilo/kilo.jsonc";
+ stateResponse = () => json(status({ clientId: "kilo", configPath, state: "unsafe", reason: "unparseable",
+ candidateFailurePath: "/tmp/home/.config/kilo/opencode.jsonc", lastOpId: "owned-operation" }));
+ await mountClient(true, "kilo");
+ expect(toggleSwitch().disabled).toBe(false);
+ expect(toggleSwitch().getAttribute("aria-pressed")).toBe("true");
+
+ stateResponse = () => json(status({ clientId: "kilo", configPath, state: "unsafe", reason: "unparseable",
+ candidateFailurePath: configPath, lastOpId: "owned-operation" }));
+ await remountClient("kilo");
+ expect(toggleSwitch().disabled).toBe(true);
+});
+
test("a client with no config on disk is never offered an overwrite", async () => {
// installed:false means there is nothing to replace; the server refuses it as
// not_installed, so offering the button would only produce an error dialog.
@@ -679,6 +716,41 @@ async function mountOverview(): Promise {
await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 30)); });
}
+test("overview names a competing Kilo file without offering Replace", async () => {
+ const path = "/tmp/home/.config/kilo/config.json";
+ stateResponse = () => json({ clients: [status({ clientId: "kilo", state: "conflict", reason: "candidate-conflict", conflictPaths: [path] })] });
+ await mountOverview();
+ const card = container.querySelector('.integration-card[data-client="kilo"]')!;
+ expect(card.textContent).toContain(path);
+ expect(Array.from(card.querySelectorAll("button")).some(button => button.textContent?.trim() === "Replace")).toBe(false);
+});
+
+test("overview allows disabling an owned Kilo block during candidate conflict", async () => {
+ const path = "/tmp/home/.config/kilo/config.json";
+ stateResponse = () => json({ clients: [status({ clientId: "kilo", state: "conflict", reason: "candidate-conflict", conflictPaths: [path], lastOpId: "owned-operation" })] });
+ await mountOverview();
+ const card = container.querySelector('.integration-card[data-client="kilo"]')!;
+ const sw = card.querySelector("button.switch") as HTMLButtonElement;
+ expect(sw.disabled).toBe(false);
+ expect(sw.getAttribute("aria-pressed")).toBe("true");
+ await act(async () => { sw.click(); });
+ expect(requests.some(request => request.url.endsWith("/api/client-integrations/preview")
+ && (request.body as { operation?: string }).operation === "disable")).toBe(true);
+});
+
+test("Disable all includes an owned Kilo block with an off-target candidate conflict", async () => {
+ const path = "/tmp/home/.config/kilo/config.json";
+ stateResponse = () => json({ clients: [status({ clientId: "kilo", state: "conflict", reason: "candidate-conflict", conflictPaths: [path], lastOpId: "owned-operation" })] });
+ await mountOverview();
+ const disableAll = buttonByText("Disable all…");
+ expect(disableAll?.disabled).toBe(false);
+ await act(async () => { disableAll!.click(); });
+ await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 40)); });
+ expect(requests.some(request => request.url.endsWith("/api/client-integrations/preview")
+ && (request.body as { clientId?: string; operation?: string }).clientId === "kilo"
+ && (request.body as { operation?: string }).operation === "disable")).toBe(true);
+});
+
test("the overview reconciles a journal row another tab already deleted", async () => {
stateResponse = () => json({ clients: [status()] });
journalRows = [{
diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts
index 44508dd9dd0..bfc2d25dba6 100644
--- a/gui/tests/locale-parity.test.ts
+++ b/gui/tests/locale-parity.test.ts
@@ -119,7 +119,9 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([
"api.clientConfig.clientOpencode",
// Cline CLI is a product name, not untranslated interface copy.
"integrations.tab.cline",
+ "integrations.tab.droid",
"api.clientConfig.clientCline",
+ "api.clientConfig.clientDroid",
"api.clientConfig.clientPi",
"api.clientConfig.clientOmp",
"api.clientConfig.clientHermes",
@@ -160,7 +162,11 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([
"api.clientConfig.clientOmo",
// Cline product name and CLI acronym are intentionally preserved.
"integrations.tab.cline",
+ "integrations.tab.droid",
"api.clientConfig.clientCline",
+ "integrations.tab.kilo",
+ "api.clientConfig.clientKilo",
+ "api.clientConfig.clientDroid",
"integrations.codex.title",
// Provider proper nouns kept in English
"provider.name.commandCodeAuth",
diff --git a/gui/tests/memory-models-panel.test.tsx b/gui/tests/memory-models-panel.test.tsx
new file mode 100644
index 00000000000..878061aa933
--- /dev/null
+++ b/gui/tests/memory-models-panel.test.tsx
@@ -0,0 +1,126 @@
+/** @jsxImportSource react */
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import { act, StrictMode } from "react";
+import type { Root } from "react-dom/client";
+import { LanguageProvider } from "../src/i18n/provider";
+import MemoryModelsPanel from "../src/components/MemoryModelsPanel";
+
+const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const;
+let previous: Record;
+let win: Window;
+let root: Root | undefined;
+let container: HTMLDivElement;
+let setting: { extract?: { model: string; reasoningEffort?: string }; consolidation?: { model: string; reasoningEffort?: string } } | null;
+let failLoad: boolean;
+let failSave: boolean;
+let writes: unknown[];
+const models = [{ id: "cheap", provider: "gateway", namespaced: "gateway/cheap" }, { id: "brisk", provider: "combo", namespaced: "combo/brisk" }];
+
+beforeEach(() => {
+ previous = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
+ win = new Window({ url: "http://localhost/" });
+ for (const key of ["document", "window", "navigator", "localStorage", "sessionStorage", "HTMLElement"] as const) {
+ Object.defineProperty(globalThis, key, { configurable: true, value: key === "window" ? win : win[key] });
+ }
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
+ win.localStorage.setItem("ocx-lang", "en");
+ setting = null; failLoad = false; failSave = false; writes = [];
+ Object.defineProperty(globalThis, "fetch", { configurable: true, writable: true, value: async (_input: unknown, init?: RequestInit) => {
+ if (init?.method === "PUT") {
+ const body = JSON.parse(String(init.body));
+ writes.push(body);
+ if (failSave) return Response.json({ error: "fixture failure" }, { status: 500 });
+ setting = body.memoryModels;
+ } else if (failLoad) return Response.json({ error: "unavailable" }, { status: 503 });
+ return Response.json({ memoryModels: setting });
+ } });
+});
+
+afterEach(async () => {
+ if (root) await act(async () => { root!.unmount(); });
+ root = undefined; win.close();
+ for (const key of globals) {
+ if (previous[key]) Object.defineProperty(globalThis, key, previous[key]!);
+ else delete (globalThis as Record)[key];
+ }
+});
+
+async function flush() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); }
+async function render(base = "") {
+ if (!root) {
+ container = win.document.createElement("div") as unknown as HTMLDivElement;
+ win.document.body.appendChild(container);
+ root = (await import("react-dom/client")).createRoot(container);
+ }
+ await act(async () => { root!.render(); });
+ await flush();
+}
+async function choose(id: string, label: string) {
+ await act(async () => { container.querySelector("#memory-models-" + id)!.click(); });
+ const option = [...win.document.querySelectorAll('[role="option"]')].find(node => node.textContent === label);
+ expect(option).toBeDefined();
+ await act(async () => { (option as unknown as HTMLButtonElement).click(); });
+}
+function saveButton() { return [...container.querySelectorAll("button")].find(button => button.textContent === "Save")!; }
+async function save() { await act(async () => { saveButton().click(); }); }
+function notice() { return container.querySelector('[role="note"]'); }
+const OFF = "Off";
+
+test("the account notice tracks the half-routed state", async () => {
+ await render();
+ expect(notice()).toBeNull();
+ await choose("extract", "gateway/cheap");
+ expect(notice()).not.toBeNull();
+ await choose("consolidation", "gateway/cheap");
+ expect(notice()).toBeNull();
+ await choose("extract", OFF);
+ expect(notice()).not.toBeNull();
+ await choose("consolidation", OFF);
+ expect(notice()).toBeNull();
+});
+
+test("saves each phase independently and clears effort with its model", async () => {
+ await render();
+ expect(saveButton().disabled).toBe(true);
+ await choose("extract", "gateway/cheap");
+ await choose("extract-effort", "Low");
+ await choose("consolidation", "gateway/cheap");
+ await save();
+ expect(writes.at(-1)).toEqual({
+ memoryModels: { extract: { model: "gateway/cheap", reasoningEffort: "low" }, consolidation: { model: "gateway/cheap" } },
+ });
+ expect(container.querySelector('[role="status"]')?.textContent).toBe("Memory settings saved.");
+ // The effort picker is armed only while its phase names a model, and clearing the model
+ // clears the effort with it, so a phase is either fully routed or absent.
+ await choose("consolidation", OFF);
+ expect((container.querySelector("#memory-models-consolidation-effort") as HTMLButtonElement)!.disabled).toBe(true);
+ await choose("consolidation", "gateway/cheap");
+ await choose("consolidation-effort", "Medium");
+ await save();
+ expect(writes.at(-1)).toEqual({
+ memoryModels: { extract: { model: "gateway/cheap", reasoningEffort: "low" }, consolidation: { model: "gateway/cheap", reasoningEffort: "medium" } },
+ });
+ await choose("consolidation", OFF);
+ await choose("consolidation", "gateway/cheap");
+ await save();
+ expect(writes.at(-1)).toEqual({
+ memoryModels: { extract: { model: "gateway/cheap", reasoningEffort: "low" }, consolidation: { model: "gateway/cheap" } },
+ });
+ await choose("extract", OFF);
+ await save();
+ expect(writes.at(-1)).toEqual({ memoryModels: { consolidation: { model: "gateway/cheap" } } });
+ await choose("consolidation", OFF);
+ await save();
+ expect(writes.at(-1)).toEqual({ memoryModels: null });
+});
+
+test("a saved target missing from the current model list remains visible and removable", async () => {
+ setting = { extract: { model: "disabled/gone", reasoningEffort: "high" } };
+ await render();
+ expect(container.querySelector("#memory-models-extract")?.textContent).toContain("disabled/gone");
+ expect(saveButton().disabled).toBe(true);
+ await choose("extract", OFF);
+ await save();
+ expect(writes.at(-1)).toEqual({ memoryModels: null });
+});
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index 10f45ae4f90..da0966e7b43 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -1,172 +1,6 @@
{
"version": 1,
"root": "tests",
- "keepAtRoot": [
- "preload.ts",
- "fake-codex-server.ts",
- "tsconfig.doctor-service-memory-contract.json",
- "test-layout.test.ts",
- "test-layout-tooling.test.ts"
- ],
- "domains": {
- "providers": {
- "match": [
- "^(?:aside(?!-profile)|auto|azure|baseten|chutes|cline(?!-(?:client|writer))|command|commandcode|context(?!-compat|-history)|crusoe|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-"
- ],
- "children": {
- "cursor": [
- "^(?:cursor)-"
- ],
- "kiro": [
- "^(?:kiro)-"
- ],
- "xai": [
- "^(?:grok)-"
- ],
- "ollama": [
- "^(?:ollama)-"
- ],
- "github-copilot": [
- "^(?:github)-"
- ]
- }
- },
- "codex-integration": {
- "match": [
- "^context-compat\\.test\\.ts$",
- "^(?:active|app|bearer|catalog|combos\\.test\\.ts|doctor\\.test\\.ts|effort|gather|history|injection|issue|multi|native|parallel|project|selected|slug|ultrafast|warmup\\.test\\.ts)-"
- ]
- },
- "server": {
- "match": [
- "^context-history\\.test\\.ts$",
- "^aside-profiles-routes",
- "^update-async-routes\\.test\\.ts$",
- "^(?:account|alias|bounded|cancel|config\\.test\\.ts|consume|data|debug|error|errors|fetch|health|input|loopback|management|memory|outbound|owned|passive|port|ports\\.test\\.ts|proxy|relay|response|retry|server|session|sidebar|stream|v2)-"
- ]
- },
- "adapters": {
- "match": [
- "^(?:bridge\\.test\\.ts|buffered|identity|run|tool|translator)-"
- ],
- "children": {
- "google": [
- "^(?:antigravity|gcp|google|vertex)-"
- ],
- "anthropic": [
- "^(?:anthropic)-"
- ],
- "openai": [
- "^(?:openai)-"
- ]
- }
- },
- "responses": {
- "match": [
- "^(?:apply|chat|citation|continuation|eventstream|legacy|namespace|passthrough|responses|sse|thought|ws)-"
- ]
- },
- "lab": {
- "match": [
- "^(?:core|lab)-"
- ]
- },
- "cli": {
- "match": [
- "^(?:cli|ensure|interactive|ocx|restore|star|uninstall\\.test\\.ts)-"
- ]
- },
- "routing": {
- "match": [
- "^(?:cl01|destination|fastwire|policy|router|router\\.test\\.ts|subagent)-"
- ]
- },
- "gui": {
- "match": [
- "^(?:dashboard|gui|models|qwen|tencent)-"
- ]
- },
- "oauth": {
- "match": [
- "^(?:chatgpt|generic|state)-"
- ]
- },
- "claude-integration": {
- "match": []
- },
- "ci-workflows": {
- "match": [
- "^(?:assert|build|bump|ci|cleanup|closed|docs|dsh|fixture|install|keyring|package|release|repo|skill|test|zz)-"
- ]
- },
- "usage": {
- "match": [
- "^(?:cost|request|usage|user)-"
- ]
- },
- "lib": {
- "match": [
- "^(?:acl|clearable|credential|debug\\.test\\.ts|optional|redact\\.test\\.ts|remove|self|stall|strict|transient)-"
- ]
- },
- "clients": {
- "match": [
- "^aside-profile(?!s-routes)",
- "^cline-(?:client|writer)(?:-|[.])",
- "^(?:desktop|omp|pi|prime|remote|sync)-"
- ]
- },
- "service": {
- "match": [
- "^(?:autostart|crash|doctor|init|service|service\\.test\\.ts|shutdown|stale|stop|systemd|winsw\\.test\\.ts)-"
- ]
- },
- "windows": {
- "match": [
- "^(?:tray|win|windows|winsw)-"
- ]
- },
- "storage": {
- "match": [
- "^(?:storage)-"
- ]
- },
- "vision": {
- "match": [
- "^(?:sidecar)-"
- ]
- },
- "config": {
- "match": [
- "^(?:config|expand|settings|types|url|yaml)-"
- ]
- },
- "web-search": {
- "match": [
- "^(?:format|web)-"
- ]
- },
- "update": {
- "match": [
- "^(?:update)-"
- ]
- },
- "images": {
- "match": [
- "^(?:artifacts|download|loop|loop\\.test\\.ts|plan\\.test\\.ts|synthetic|z)-"
- ]
- },
- "videos": {
- "match": [
- "^(?:fulfill|plan)-"
- ]
- },
- "e2e-style": {
- "match": [
- "^(?:phase100)-"
- ]
- }
- },
"explicit": {
"pnpm-command-isolation.test.ts": "update", "provider-antigravity-quota-retry.test.ts": "providers", "project-config-warning-snapshot.test.ts": "codex-integration", "codex-quota-auto-refresh-generation.test.ts": "codex-integration", "codex-account-clear-paused.test.ts": "codex-integration",
"responses-compaction-recovery.test.ts": "responses", "compaction-recovery-settings.test.ts": "config", "responses-compaction-recovery-policy.test.ts": "responses", "plugin-loader.test.ts": "lib", "plugin-upstream-hooks.test.ts": "lib",
@@ -944,6 +778,7 @@
"doctor.test.ts": "codex-integration",
"download-cap-default.test.ts": "images",
"download-connect-deadline-default.test.ts": "images",
+ "droid-client.test.ts": "clients",
"dsh-path-contract.test.ts": "ci-workflows",
"dsh-rc6-compat-script.test.ts": "ci-workflows",
"dsh-writer-lock.test.ts": "ci-workflows",
@@ -1078,6 +913,7 @@
"jev-decision.test.ts": "routing",
"jev-provider.test.ts": "providers",
"keyring-smoke.test.ts": "ci-workflows",
+ "kilo-client.test.ts": "clients",
"kimi-oauth-identity.test.ts": "providers",
"kimi-responses-adjacency.test.ts": "providers",
"kiro-account-load.test.ts": "providers/kiro",
@@ -1451,6 +1287,7 @@
"provider-workspace-data.test.ts": "gui",
"provider-workspace-rail.test.ts": "gui",
"provider-workspace-state.test.ts": "gui",
+ "proxy-env-macos.test.ts": "server",
"proxy-env.test.ts": "server",
"proxy-liveness-package-tree-fence.test.ts": "server",
"proxy-liveness.test.ts": "server",
@@ -1604,6 +1441,7 @@
"responses-self-named-namespace-scrub.test.ts": "responses",
"responses-send-budget-counts.test.ts": "responses",
"responses-send-budget-errors.test.ts": "responses",
+ "responses-memory-models.test.ts": "responses",
"responses-shadow-intercept.test.ts": "responses",
"responses-show-thinking-summary.test.ts": "responses",
"responses-snapshot-repair-server.test.ts": "responses",
@@ -1709,6 +1547,7 @@
"settings-desktop-switch-apply.test.ts": "config",
"settings-fast-rows.test.ts": "config",
"settings-main-account-hard-lock.test.ts": "config",
+ "settings-memory-models.test.ts": "config",
"settings-oauth-open-browser.test.ts": "config",
"settings-startup-health-seam.test.ts": "config",
"settings-stream-mode.test.ts": "config",
@@ -1971,29 +1810,5 @@
"injection-routing-drift.test.ts": "codex-integration", "injection-routing-healer.test.ts": "codex-integration",
"injection-routing-heal-apply.test.ts": "codex-integration", "cli-start-routing-heal-wiring.test.ts": "cli",
"cli-status-codex-routing-drift.test.ts": "cli"
- },
- "migrated": [
- "adapters",
- "ci-workflows",
- "claude-integration",
- "cli",
- "clients",
- "codex-integration",
- "config",
- "gui",
- "lab",
- "lib",
- "oauth",
- "providers",
- "responses",
- "routing",
- "server",
- "service",
- "storage",
- "update",
- "usage",
- "vision",
- "web-search",
- "windows"
- ]
+ }
}
diff --git a/scripts/test-layout/move.ts b/scripts/test-layout/move.ts
index 5a930cff27f..5c2e6a79e1e 100644
--- a/scripts/test-layout/move.ts
+++ b/scripts/test-layout/move.ts
@@ -1,6 +1,6 @@
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { basename, dirname, join, relative } from "node:path";
-import { LAYOUT_PATH, loadLayout, rewriteMetaDirEscapes, rewriteSource, scanEscapes, type Layout } from "./schema";
+import { LAYOUT_PATH, loadLayout, rewriteMetaDirEscapes, rewriteSource, scanEscapes, seedsPathFor, type Layout } from "./schema";
import { parseDomainArgs, planMoves, repoRootFromHere, type Move } from "./plan";
import { filesNamingAny, runVerify } from "./verify";
@@ -11,7 +11,7 @@ import { filesNamingAny, runVerify } from "./verify";
*
* Order is preflight-all, move-all, rewrite-all, append migrated, escape scan, verify. The
* preflight computes the full write set (every source file, every file that names a moved
- * path, scripts/test.ts when a serial-lane file is in the slice, layout.json) and refuses to
+ * path, scripts/test.ts when a serial-lane file is in the slice, seeds.json) and refuses to
* start if any of them is dirty; `git mv` itself would happily carry an unrelated edit inside a
* rename. Exit 2 means the slice is fully moved and the lines printed as MANUAL need a human;
* exit 1 means the automatic verify failed after a clean move.
@@ -68,6 +68,7 @@ export function runMove(options: MoveOptions): MoveReport {
const log = options.log ?? ((line: string) => console.log(line));
const git = options.git ?? defaultGit(root);
const layoutPath = options.layoutPath ?? LAYOUT_PATH;
+ const seedsPath = seedsPathFor(layoutPath);
const layout: Layout = loadLayout(layoutPath);
if (domains.length === 0) throw new Error("move: at least one --domain is required");
for (const domain of domains) {
@@ -76,7 +77,7 @@ export function runMove(options: MoveOptions): MoveReport {
const { moves, unresolved } = planMoves(layout, root, domains);
if (unresolved.length > 0) {
- throw new Error(`move: ${unresolved.length} unresolved file(s); fix layout.json first:\n ${unresolved.join("\n ")}`);
+ throw new Error(`move: ${unresolved.length} unresolved file(s); fix layout.json or seeds.json first:\n ${unresolved.join("\n ")}`);
}
if (moves.length === 0) {
log("move: nothing to do");
@@ -97,8 +98,8 @@ export function runMove(options: MoveOptions): MoveReport {
const serial = new Set(serialLaneFiles(root));
const touchesSerial = moves.some(move => serial.has(basename(move.from)));
if (touchesSerial && !literalTargets.has(SERIAL_LANE_SOURCE)) literalTargets.set(SERIAL_LANE_SOURCE, []);
- const layoutRel = relative(root, layoutPath).split("\\").join("/");
- const writeSet = new Set([...moves.map(move => move.from), ...literalTargets.keys(), layoutRel]);
+ const seedsRel = relative(root, seedsPath).split("\\").join("/");
+ const writeSet = new Set([...moves.map(move => move.from), ...literalTargets.keys(), seedsRel]);
const status = git(["status", "--porcelain", "--", ...writeSet]);
if (status.status !== 0) throw new Error(`git status failed: ${status.stderr}`);
const dirty = status.stdout.split("\n").filter(Boolean);
@@ -163,8 +164,15 @@ export function runMove(options: MoveOptions): MoveReport {
const migrated = new Set(layout.migrated);
for (const domain of domains) migrated.add(domain);
- layout.migrated = [...migrated].sort();
- writeFileSync(layoutPath, JSON.stringify(layout, null, 2) + "\n");
+ const seeds = JSON.parse(readFileSync(seedsPath, "utf8")) as Pick;
+ seeds.migrated = [...migrated].sort();
+ const tempSeedsPath = `${seedsPath}.tmp-${process.pid}`;
+ try {
+ writeFileSync(tempSeedsPath, JSON.stringify(seeds, null, 2) + "\n");
+ renameSync(tempSeedsPath, seedsPath);
+ } finally {
+ rmSync(tempSeedsPath, { force: true });
+ }
scanMoved(root, moves, move => readFileSync(join(root, move.to), "utf8"), manual, suppressed);
for (const hit of suppressed) log(` layout: local honoured at ${hit.file}:${hit.line}`);
diff --git a/scripts/test-layout/schema.ts b/scripts/test-layout/schema.ts
index de75004d000..72e79f26bf1 100644
--- a/scripts/test-layout/schema.ts
+++ b/scripts/test-layout/schema.ts
@@ -3,9 +3,9 @@ import { dirname, join } from "node:path";
import { maskNonCode, specifierSites, tokenize } from "./tokens";
/**
- * The tests/ layout map. `explicit` is the authoritative basename -> directory table; the
- * regex seeds under `domains` exist so a brand-new test file can still resolve before someone
- * adds it to `explicit`. `migrated` lists the domains whose files have already left the root.
+ * The tests/ layout map merges layout.json's authoritative basename -> directory table with
+ * seeds.json beside it. Its `domains` regexes place a brand-new test before it joins `explicit`;
+ * `migrated` lists domains whose files have already left the root.
*/
export interface DomainSpec {
match: string[];
@@ -22,19 +22,27 @@ export interface Layout {
}
export const LAYOUT_PATH = join(import.meta.dir, "layout.json");
+export function seedsPathFor(path: string): string {
+ return join(dirname(path), "seeds.json");
+}
export function loadLayout(path: string = LAYOUT_PATH): Layout {
- const parsed = JSON.parse(readFileSync(path, "utf8")) as Layout;
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Pick;
+ const seedsPath = seedsPathFor(path);
+ const seeds = JSON.parse(readFileSync(seedsPath, "utf8")) as Pick;
if (parsed.version !== 1 || parsed.root !== "tests") {
throw new Error(`${path}: unsupported layout version/root`);
}
for (const key of ["keepAtRoot", "migrated"] as const) {
- if (!Array.isArray(parsed[key])) throw new Error(`${path}: ${key} must be an array`);
+ if (!Array.isArray(seeds[key])) throw new Error(`${seedsPath}: ${key} must be an array`);
+ }
+ if (typeof seeds.domains !== "object" || seeds.domains === null || Array.isArray(seeds.domains)) {
+ throw new Error(`${seedsPath}: domains must be an object`);
}
- if (typeof parsed.explicit !== "object" || parsed.explicit === null) {
+ if (typeof parsed.explicit !== "object" || parsed.explicit === null || Array.isArray(parsed.explicit)) {
throw new Error(`${path}: explicit must be an object`);
}
- return parsed;
+ return { ...parsed, ...seeds };
}
/**
diff --git a/scripts/test-layout/seeds.json b/scripts/test-layout/seeds.json
new file mode 100644
index 00000000000..394627e716b
--- /dev/null
+++ b/scripts/test-layout/seeds.json
@@ -0,0 +1,192 @@
+{
+ "keepAtRoot": [
+ "preload.ts",
+ "fake-codex-server.ts",
+ "tsconfig.doctor-service-memory-contract.json",
+ "test-layout.test.ts",
+ "test-layout-tooling.test.ts"
+ ],
+ "domains": {
+ "providers": {
+ "match": [
+ "^(?:aside(?!-profile)|auto|azure|baseten|chutes|cline(?!-(?:client|writer))|command|commandcode|context(?!-compat|-history)|crusoe|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-"
+ ],
+ "children": {
+ "cursor": [
+ "^(?:cursor)-"
+ ],
+ "kiro": [
+ "^(?:kiro)-"
+ ],
+ "xai": [
+ "^(?:grok)-"
+ ],
+ "ollama": [
+ "^(?:ollama)-"
+ ],
+ "github-copilot": [
+ "^(?:github)-"
+ ]
+ }
+ },
+ "codex-integration": {
+ "match": [
+ "^context-compat\\.test\\.ts$",
+ "^(?:active|app|bearer|catalog|combos\\.test\\.ts|doctor\\.test\\.ts|effort|gather|history|injection|issue|multi|native|parallel|project|selected|slug|ultrafast|warmup\\.test\\.ts)-"
+ ]
+ },
+ "server": {
+ "match": [
+ "^context-history\\.test\\.ts$",
+ "^aside-profiles-routes",
+ "^update-async-routes\\.test\\.ts$",
+ "^(?:account|alias|bounded|cancel|config\\.test\\.ts|consume|data|debug|error|errors|fetch|health|input|loopback|management|memory|outbound|owned|passive|port|ports\\.test\\.ts|proxy|relay|response|retry|server|session|sidebar|stream|v2)-"
+ ]
+ },
+ "adapters": {
+ "match": [
+ "^(?:bridge\\.test\\.ts|buffered|identity|run|tool|translator)-"
+ ],
+ "children": {
+ "google": [
+ "^(?:antigravity|gcp|google|vertex)-"
+ ],
+ "anthropic": [
+ "^(?:anthropic)-"
+ ],
+ "openai": [
+ "^(?:openai)-"
+ ]
+ }
+ },
+ "responses": {
+ "match": [
+ "^(?:apply|chat|citation|continuation|eventstream|legacy|namespace|passthrough|responses|sse|thought|ws)-"
+ ]
+ },
+ "lab": {
+ "match": [
+ "^(?:core|lab)-"
+ ]
+ },
+ "cli": {
+ "match": [
+ "^(?:cli|ensure|interactive|ocx|restore|star|uninstall\\.test\\.ts)-"
+ ]
+ },
+ "routing": {
+ "match": [
+ "^(?:cl01|destination|fastwire|policy|router|router\\.test\\.ts|subagent)-"
+ ]
+ },
+ "gui": {
+ "match": [
+ "^(?:dashboard|gui|models|qwen|tencent)-"
+ ]
+ },
+ "oauth": {
+ "match": [
+ "^(?:chatgpt|generic|state)-"
+ ]
+ },
+ "claude-integration": {
+ "match": []
+ },
+ "ci-workflows": {
+ "match": [
+ "^(?:assert|build|bump|ci|cleanup|closed|docs|dsh|fixture|install|keyring|package|release|repo|skill|test|zz)-"
+ ]
+ },
+ "usage": {
+ "match": [
+ "^(?:cost|request|usage|user)-"
+ ]
+ },
+ "lib": {
+ "match": [
+ "^(?:acl|clearable|credential|debug\\.test\\.ts|optional|redact\\.test\\.ts|remove|self|stall|strict|transient)-"
+ ]
+ },
+ "clients": {
+ "match": [
+ "^aside-profile(?!s-routes)",
+ "^cline-(?:client|writer)(?:-|[.])",
+ "^(?:desktop|omp|pi|prime|remote|sync)-"
+ ]
+ },
+ "service": {
+ "match": [
+ "^(?:autostart|crash|doctor|init|service|service\\.test\\.ts|shutdown|stale|stop|systemd|winsw\\.test\\.ts)-"
+ ]
+ },
+ "windows": {
+ "match": [
+ "^(?:tray|win|windows|winsw)-"
+ ]
+ },
+ "storage": {
+ "match": [
+ "^(?:storage)-"
+ ]
+ },
+ "vision": {
+ "match": [
+ "^(?:sidecar)-"
+ ]
+ },
+ "config": {
+ "match": [
+ "^(?:config|expand|settings|types|url|yaml)-"
+ ]
+ },
+ "web-search": {
+ "match": [
+ "^(?:format|web)-"
+ ]
+ },
+ "update": {
+ "match": [
+ "^(?:update)-"
+ ]
+ },
+ "images": {
+ "match": [
+ "^(?:artifacts|download|loop|loop\\.test\\.ts|plan\\.test\\.ts|synthetic|z)-"
+ ]
+ },
+ "videos": {
+ "match": [
+ "^(?:fulfill|plan)-"
+ ]
+ },
+ "e2e-style": {
+ "match": [
+ "^(?:phase100)-"
+ ]
+ }
+ },
+ "migrated": [
+ "adapters",
+ "ci-workflows",
+ "claude-integration",
+ "cli",
+ "clients",
+ "codex-integration",
+ "config",
+ "gui",
+ "lab",
+ "lib",
+ "oauth",
+ "providers",
+ "responses",
+ "routing",
+ "server",
+ "service",
+ "storage",
+ "update",
+ "usage",
+ "vision",
+ "web-search",
+ "windows"
+ ]
+}
diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts
index 557e6149c4e..1fb42126474 100644
--- a/src/cli/dispatch.ts
+++ b/src/cli/dispatch.ts
@@ -512,7 +512,7 @@ const commandRunners: Record = {
},
config,
port: live.port,
- }, ["mcode", "pi", "raycast", "omo", "cline"]));
+ }, ["mcode", "pi", "raycast", "omo", "cline", "droid"]));
} 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 21d62f59c77..ca9eeba54ea 100644
--- a/src/cli/export-command.ts
+++ b/src/cli/export-command.ts
@@ -1,8 +1,8 @@
/**
* `ocx export --client ` — print a client config for the live proxy.
*
- * Fourteen clients, five formats. The accepted list is `EXPORT_CLIENT_IDS`, not
- * this comment: OpenCode, Pi, Prime, Aside, ZCode and omo are JSON; OMP,
+ * The accepted clients span five formats. The accepted list is `EXPORT_CLIENT_IDS`, not
+ * this comment: OpenCode, Pi, Prime, Aside, ZCode, omo and Kilo are JSON; OMP,
* Hermes, gjc, DSH, MiniMax Code and Raycast are YAML; OpenClaw is JSON5; Kimi
* is TOML.
*
@@ -159,16 +159,16 @@ 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") {
+ if (client === "raycast" || client === "droid") {
// 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.
const exported = await runtimeRequest<{
client: string; format: string; config: unknown; text: string;
- }>("/api/client-config?client=raycast", {}, { ...deps, baseUrl: root });
- if (!exported || exported.client !== "raycast" || exported.format !== "yaml"
+ }>(`/api/client-config?client=${client}`, {}, { ...deps, baseUrl: root });
+ if (!exported || exported.client !== client || exported.format !== spec.format
|| typeof exported.text !== "string" || exported.config === undefined) {
- throw new RuntimeApiError("Management API returned an unexpected Raycast export payload.", 502, null);
+ throw new RuntimeApiError(`Management API returned an unexpected ${client} export payload.`, 502, null);
}
built = { document: exported.config, text: exported.text };
} else {
diff --git a/src/cli/help.ts b/src/cli/help.ts
index 967e431add0..488df7bc4ff 100644
--- a/src/cli/help.ts
+++ b/src/cli/help.ts
@@ -83,7 +83,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 (15 clients)
+ ocx export --client Print a client config wired to the running proxy (17 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/opencode.ts b/src/cli/opencode.ts
index d70bf56302c..7b6fe1d14b5 100644
--- a/src/cli/opencode.ts
+++ b/src/cli/opencode.ts
@@ -51,6 +51,8 @@ import type { OcxConfig } from "../types";
import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
import { selfLaunchArgv } from "../lib/self-launch-argv";
import { withoutSiblingMarker } from "../codex/sibling-start";
+import { parseJsonc } from "../lib/jsonc";
+export { parseJsonc };
/**
* The provider-block serializer, its constants, and the config-path helpers now live in
@@ -124,89 +126,6 @@ function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
-/**
- * Strip `//` and block comments outside string literals. Escape-aware so a quote inside
- * an escaped sequence cannot flip string state and expose config text to the stripper.
- */
-function stripJsonComments(text: string): string {
- let out = "";
- let inString = false;
- let inLine = false;
- let inBlock = false;
- for (let i = 0; i < text.length; i++) {
- const ch = text[i]!;
- const next = text[i + 1];
- if (inLine) {
- if (ch === "\n") {
- inLine = false;
- out += ch;
- }
- continue;
- }
- if (inBlock) {
- // Newlines are preserved so JSON.parse error positions stay meaningful.
- if (ch === "\n") out += ch;
- else if (ch === "*" && next === "/") { inBlock = false; i++; }
- continue;
- }
- if (inString) {
- out += ch;
- if (ch === "\\") {
- const escaped = text[i + 1];
- if (escaped !== undefined) { out += escaped; i++; }
- continue;
- }
- if (ch === "\"") inString = false;
- continue;
- }
- if (ch === "\"") { inString = true; out += ch; continue; }
- if (ch === "/" && next === "/") { inLine = true; i++; continue; }
- if (ch === "/" && next === "*") { inBlock = true; i++; continue; }
- out += ch;
- }
- return out;
-}
-
-/** Drop commas that sit directly before `}` or `]`, ignoring string contents. */
-function stripTrailingCommas(text: string): string {
- let out = "";
- let inString = false;
- for (let i = 0; i < text.length; i++) {
- const ch = text[i]!;
- if (inString) {
- out += ch;
- if (ch === "\\") {
- const escaped = text[i + 1];
- if (escaped !== undefined) { out += escaped; i++; }
- continue;
- }
- if (ch === "\"") inString = false;
- continue;
- }
- if (ch === "\"") { inString = true; out += ch; continue; }
- if (ch === ",") {
- let j = i + 1;
- while (j < text.length && /\s/.test(text[j]!)) j++;
- if (text[j] === "}" || text[j] === "]") continue;
- }
- out += ch;
- }
- return out;
-}
-
-/**
- * opencode documents opencode.json as JSONC, so a valid user config may carry comments
- * or trailing commas. Strict JSON.parse runs first and untouched — the tolerant path is
- * only attempted when that throws, keeping well-formed configs away from the stripper.
- */
-export function parseJsonc(text: string): unknown {
- try {
- return JSON.parse(text);
- } catch {
- return JSON.parse(stripTrailingCommas(stripJsonComments(text)));
- }
-}
-
/** Model key as the proxy routes it: `provider/id` for routed models, bare slug for native OpenAI entries. */
export function opencodeModelKey(provider: string, id: string): string {
return provider === "native" ? id : `${provider}/${id}`;
diff --git a/src/cli/registry.ts b/src/cli/registry.ts
index 214baa31a25..3dcc639c7e2 100644
--- a/src/cli/registry.ts
+++ b/src/cli/registry.ts
@@ -416,12 +416,13 @@ 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, Prime Agent, Aside, Raycast, omo, Cline) wired to the running proxy.",
+ 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, Prime Agent, Aside, Raycast, omo, Cline, Kilo, Factory Droid) 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.",
"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/config-export.ts b/src/clients/config-export.ts
index 36de2d58225..63efee0c0f3 100644
--- a/src/clients/config-export.ts
+++ b/src/clients/config-export.ts
@@ -31,7 +31,7 @@ import type { OcxConfig } from "../types";
export type { ConfigFormat } from "../integrations/serialize";
export type { ManagedFragment, ManagedContribution, BuildContribution, OpencodeLaunchEnv, OpencodeCatalogModel, ExportModel, ExportContext, ExportClientId, ExportClientSpec, PiModelEntry } from "./config-export/contracts";
-export { OPENCODE_PROVIDER_ID, OPENCODE_CONFIG_SCHEMA, OPENCODE_API_KEY_ENV, OPENCODE_API_KEY_ENV_REF, HERMES_API_KEY_ENV, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV, OPENCLAW_API_KEY_ENV_REF, LOOPBACK_API_KEY_PLACEHOLDER, GAJAE_API_KEY_ENV, SCHEMA_REQUIRED_OUTPUT_BUDGET, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG } from "./config-export/constants";
+export { OPENCODE_PROVIDER_ID, OPENCODE_CONFIG_SCHEMA, OPENCODE_API_KEY_ENV, OPENCODE_API_KEY_ENV_REF, KILO_API_KEY_ENV, KILO_API_KEY_ENV_REF, KILO_CONFIG_SCHEMA, HERMES_API_KEY_ENV, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV, OPENCLAW_API_KEY_ENV_REF, LOOPBACK_API_KEY_PLACEHOLDER, GAJAE_API_KEY_ENV, SCHEMA_REQUIRED_OUTPUT_BUDGET, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG } from "./config-export/constants";
export { normalizeExportModels } from "./config-export/model-metadata";
export type { OmpModelEntry, OmpProviderBlock, OmpGeneratedConfig } from "./config-export/omp";
export type { ZcodeModelEntry, ZcodeProviderBlock, ZcodeGeneratedConfig } from "./config-export/zcode";
@@ -51,9 +51,11 @@ export type { DshReasoningEffort, DshWireReasoningEffort, DshModelEntry, DshProv
export type { McodeProviderBlock, McodeModelEntry, McodeGeneratedConfig } from "./config-export/mcode";
export type { RaycastAbility, RaycastAbilityName, RaycastModelEntry, RaycastProviderEntry, RaycastGeneratedConfig } from "./config-export/raycast";
export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast";
+export { droidHomeDir, droidConfigPath, buildDroidClientConfig, summarizeDroid, buildDroidContribution } from "./config-export/droid";
+export type { DroidModelEntry, DroidGeneratedConfig } from "./config-export/droid";
import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts";
-import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants";
+import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV, KILO_API_KEY_ENV } from "./config-export/constants";
import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, opencodeModelCapabilities, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata";
import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./config-export/omp";
import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh";
@@ -61,6 +63,10 @@ import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "
import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode";
import { buildClineClientConfig, summarizeCline, buildClineContribution } from "./config-export/cline";
import { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast";
+import { buildKiloClientConfig, summarizeKilo, buildKiloContribution, kiloConfigPath } from "./config-export/kilo";
+export { kiloConfigPath, kiloHomeDir, kiloCandidatePath, KILO_CONFIG_CANDIDATES } from "./config-export/kilo";
+export type { KiloGeneratedConfig, KiloProviderBlock, KiloModelEntry } from "./config-export/kilo";
+import { droidConfigPath, buildDroidClientConfig, summarizeDroid, buildDroidContribution } from "./config-export/droid";
@@ -1588,6 +1594,31 @@ export const EXPORT_CLIENTS: Record = {
buildContribution: buildClineContribution,
loopbackOnly: true,
},
+ kilo: {
+ id: "kilo",
+ filename: "kilo.jsonc",
+ destination: env => kiloConfigPath(env),
+ apiKeyEnv: KILO_API_KEY_ENV,
+ exportHint: `export ${KILO_API_KEY_ENV}=`,
+ build: buildKiloClientConfig,
+ format: "json",
+ summarize: summarizeKilo,
+ buildContribution: buildKiloContribution,
+ loopbackOnly: false,
+ jsonc: true,
+ },
+ droid: {
+ id: "droid",
+ filename: "factory-settings.json",
+ destination: env => droidConfigPath(env),
+ apiKeyEnv: "",
+ exportHint: "Factory Droid reads keyless loopback custom models from settings.json. Select one with /model.",
+ build: buildDroidClientConfig,
+ format: "json",
+ summarize: summarizeDroid,
+ buildContribution: buildDroidContribution,
+ loopbackOnly: true,
+ },
};
export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[];
diff --git a/src/clients/config-export/constants.ts b/src/clients/config-export/constants.ts
index 5e272cec4e3..a80cbbf4c5e 100644
--- a/src/clients/config-export/constants.ts
+++ b/src/clients/config-export/constants.ts
@@ -14,6 +14,14 @@ export const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json";
*/
export const OPENCODE_API_KEY_ENV = "OPENCODEX_OPENCODE_API_KEY";
+/**
+ * Env var carrying the proxy admission key to Kilo. Independent of OpenCode's
+ * so the two clients can keep distinct credentials.
+ */
+export const KILO_API_KEY_ENV = "OPENCODEX_KILO_API_KEY";
+export const KILO_API_KEY_ENV_REF = `{env:${KILO_API_KEY_ENV}}`;
+export const KILO_CONFIG_SCHEMA = "https://app.kilo.ai/config.json";
+
/** Env reference shared by apiKey and the dedicated proxy admission header. */
export const OPENCODE_API_KEY_ENV_REF = `{env:${OPENCODE_API_KEY_ENV}}`;
diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts
index 6744150d7a7..f32d4abff96 100644
--- a/src/clients/config-export/contracts.ts
+++ b/src/clients/config-export/contracts.ts
@@ -105,7 +105,9 @@ export type ExportClientId =
| "aside"
| "raycast"
| "omo"
- | "cline";
+ | "cline"
+ | "kilo"
+ | "droid";
export interface ExportClientSpec {
id: ExportClientId;
@@ -146,6 +148,16 @@ export interface ExportClientSpec {
* reasoning as the Grok managed block's non-loopback refusal.
*/
loopbackOnly: boolean;
+ /**
+ * True when the destination file may carry comments and trailing commas
+ * even though `format` is "json" and serialization stays pretty JSON.
+ *
+ * Parse tolerates them by canonicalizing the text before the rewrite-safety
+ * scan (Kilo's kilo.jsonc). A spec flag rather than a client-name branch:
+ * the next OpenCode-family client opts in here instead of growing another
+ * `clientId ===` check at every parse site.
+ */
+ jsonc?: boolean;
}
export interface PiModelEntry {
diff --git a/src/clients/config-export/droid.ts b/src/clients/config-export/droid.ts
new file mode 100644
index 00000000000..1dfc14556b0
--- /dev/null
+++ b/src/clients/config-export/droid.ts
@@ -0,0 +1,71 @@
+import { win32, join } from "node:path";
+import { homedir } from "node:os";
+import { exportPresentationLabel } from "../model-presentation";
+import type { ExportContext, ManagedContribution, OpencodeLaunchEnv } from "./contracts";
+import { normalizeExportModels } from "./model-metadata";
+import { formatSelectorConjunction } from "../../integrations/merge";
+
+/** Factory personal settings: https://docs.factory.ai/model-independence/byok */
+export interface DroidModelEntry {
+ model: string;
+ displayName: string;
+ baseUrl: string;
+ provider: "generic-chat-completion-api";
+ noImageSupport: boolean;
+}
+
+export interface DroidGeneratedConfig { customModels: DroidModelEntry[] }
+
+const isWindowsHome = (home: string) => /^[A-Za-z]:[\\/]|^\\\\/.test(home);
+
+export function droidHomeDir(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
+ return isWindowsHome(home) ? win32.join(home, ".factory") : join(home, ".factory");
+}
+
+export function droidConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
+ const root = droidHomeDir(env, home);
+ return isWindowsHome(root) ? win32.join(root, "settings.json") : join(root, "settings.json");
+}
+
+function buildDroidRows(ctx: ExportContext): Array<{ row: DroidModelEntry; selector: string }> {
+ const rows: Array<{ row: DroidModelEntry; selector: string }> = [];
+ for (const model of normalizeExportModels(ctx.models)) {
+ const displayName = `OpenCodex: ${exportPresentationLabel(model)}`;
+ const selector = formatSelectorConjunction([
+ { field: "model", value: model.namespaced },
+ { field: "displayName", value: displayName },
+ ]);
+ // A row we cannot address safely cannot be managed or exported.
+ if (!selector) continue;
+ rows.push({ selector, row: {
+ model: model.namespaced,
+ displayName,
+ baseUrl: ctx.baseUrl,
+ provider: "generic-chat-completion-api",
+ noImageSupport: !model.inputModalities?.includes("image"),
+ } });
+ }
+ return rows;
+}
+
+export function buildDroidClientConfig(ctx: ExportContext): DroidGeneratedConfig {
+ const rows = buildDroidRows(ctx);
+ if (ctx.models.length > 0 && rows.length === 0) {
+ throw new Error("Factory Droid has no addressable models in the selected catalog");
+ }
+ return { customModels: rows.map(({ row }) => row) };
+}
+
+export function summarizeDroid(document: unknown) {
+ const rows = (document as DroidGeneratedConfig | undefined)?.customModels;
+ const count = Array.isArray(rows) ? rows.length : 0;
+ // Droid's personal schema has no context-window field.
+ return { modelCount: count, modelsWithoutLimits: count };
+}
+
+export function buildDroidContribution(ctx: ExportContext): ManagedContribution {
+ return {
+ clientId: "droid",
+ fragments: buildDroidRows(ctx).map(({ row, selector }) => ({ path: ["customModels", selector], value: row })),
+ };
+}
diff --git a/src/clients/config-export/kilo.ts b/src/clients/config-export/kilo.ts
new file mode 100644
index 00000000000..c28124f79dc
--- /dev/null
+++ b/src/clients/config-export/kilo.ts
@@ -0,0 +1,116 @@
+import { existsSync } from "node:fs";
+import { homedir } from "node:os";
+import { join, win32 } from "node:path";
+import type { ExportContext, ManagedContribution, OpencodeLaunchEnv } from "./contracts";
+import {
+ KILO_API_KEY_ENV_REF,
+ KILO_CONFIG_SCHEMA,
+ OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG,
+ OPENCODE_PROVIDER_ID,
+} from "./constants";
+import {
+ authoritativeContextWindow,
+ exportModelLabel,
+ normalizeExportModels,
+ opencodeModelCapabilities,
+ outputBudgetFor,
+ proxyAdmissionHeaders,
+ singleFragment,
+} from "./model-metadata";
+
+export const KILO_CONFIG_CANDIDATES = [
+ "kilo.jsonc",
+ "kilo.json",
+ "opencode.jsonc",
+ "opencode.json",
+ "config.json",
+] as const;
+
+export interface KiloModelEntry {
+ name: string;
+ limit?: { context: number; output: number };
+ attachment?: boolean;
+ modalities?: { input: string[]; output: string[] };
+}
+
+export interface KiloProviderBlock {
+ npm: string;
+ name: string;
+ options: {
+ baseURL: string;
+ apiKey?: string;
+ headers?: Record;
+ };
+ models: Record;
+}
+
+export interface KiloGeneratedConfig {
+ $schema: string;
+ provider: Record;
+}
+
+function windowsPath(path: string): boolean {
+ return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\");
+}
+
+export function kiloHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
+ const override = env.XDG_CONFIG_HOME || undefined;
+ const pathJoin = windowsPath(override ?? home) ? win32.join : join;
+ const xdg = override ?? pathJoin(home, ".config");
+ return pathJoin(xdg, "kilo");
+}
+
+export function kiloCandidatePath(dir: string, name: string): string {
+ return (windowsPath(dir) ? win32.join : join)(dir, name);
+}
+
+export function kiloConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
+ const dir = kiloHomeDir(env, home);
+ for (const name of KILO_CONFIG_CANDIDATES) {
+ const path = kiloCandidatePath(dir, name);
+ if (existsSync(path)) return path;
+ }
+ return kiloCandidatePath(dir, "kilo.jsonc");
+}
+
+function kiloProviderBlock(ctx: ExportContext): KiloProviderBlock {
+ const config = ctx.config ?? OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG;
+ const models: Record = {};
+ for (const model of normalizeExportModels(ctx.models)) {
+ const entry: KiloModelEntry = { name: exportModelLabel(model) };
+ const context = authoritativeContextWindow(model.contextWindow);
+ if (context !== undefined) {
+ entry.limit = { context, output: outputBudgetFor(context, model) };
+ }
+ const capabilities = opencodeModelCapabilities(model.inputModalities);
+ if (capabilities) {
+ entry.attachment = capabilities.attachment;
+ entry.modalities = capabilities.modalities;
+ }
+ models[model.namespaced] = entry;
+ }
+ const headers = proxyAdmissionHeaders(config, KILO_API_KEY_ENV_REF);
+ return {
+ npm: "@ai-sdk/openai-compatible",
+ name: "OpenCodex",
+ options: headers ? { baseURL: ctx.baseUrl, headers } : { baseURL: ctx.baseUrl, apiKey: KILO_API_KEY_ENV_REF },
+ models,
+ };
+}
+
+export function buildKiloClientConfig(ctx: ExportContext): KiloGeneratedConfig {
+ return {
+ $schema: KILO_CONFIG_SCHEMA,
+ provider: { [OPENCODE_PROVIDER_ID]: kiloProviderBlock(ctx) },
+ };
+}
+
+export function summarizeKilo(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
+ const models = Object.values((document as KiloGeneratedConfig | undefined)?.provider?.[OPENCODE_PROVIDER_ID]?.models ?? {});
+ return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length };
+}
+
+export function buildKiloContribution(ctx: ExportContext): ManagedContribution {
+ const doc = buildKiloClientConfig(ctx);
+ return singleFragment("kilo", ["provider", OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]);
+}
diff --git a/src/combos/jev.ts b/src/combos/jev.ts
index a64dafdd9a8..79cbfda4891 100644
--- a/src/combos/jev.ts
+++ b/src/combos/jev.ts
@@ -6,6 +6,7 @@ import {
import { resolveProviderApiKey } from "../providers/api-key-resolve";
import { providerMatchesRegistryTransport } from "../providers/registry";
import type { OcxComboDefaultEffort, OcxConfig, OcxProviderConfig } from "../types";
+import { JEV_MAX_CANDIDATE_FIELD_CHARS } from "./types";
export const JEV_PROVIDER_ID = "jev";
export const JEV_API_URL = "https://api.typesafe.ai/v1/systemone";
@@ -13,7 +14,6 @@ export const JEV_MODEL = "jev-latest";
const JEV_TIMEOUT_MS = 4_000;
const JEV_MAX_CANDIDATES = 64;
-const JEV_MAX_CANDIDATE_FIELD_CHARS = 512;
const JEV_MAX_REQUEST_BYTES = 65_536;
const JEV_MAX_RESPONSE_BYTES = 65_536;
const JEV_OUTBOUND_DEPENDENCIES = {
@@ -70,6 +70,8 @@ export interface JevCandidate {
provider: string;
model: string;
reasoningEfforts: readonly OcxComboDefaultEffort[];
+ /** Optional operator note sent as decision evidence for this target only. */
+ modelProfile?: string;
}
export interface JevDecision {
@@ -337,7 +339,7 @@ function hasImageContent(item: Record): boolean {
return item.content.some(part => isRecord(part) && (part.type === "input_image" || part.type === "image_url"));
}
-export function buildJevState(body: unknown): Record {
+export function buildJevState(body: unknown, candidates: readonly JevCandidate[] = []): Record {
const input = isRecord(body) ? body.input : undefined;
let task = "";
let previousAssistant = "";
@@ -383,11 +385,18 @@ export function buildJevState(body: unknown): Record {
}
}
+ const operatorNotes: Record = {};
+ for (const candidate of candidates) {
+ const note = candidate.modelProfile?.trim();
+ if (note) operatorNotes[candidate.key] = note;
+ }
+
return {
task,
signals: { has_image: hasImage, tool_history: toolHistory },
step,
...(previousAssistant ? { previous_assistant: previousAssistant.slice(-ASSISTANT_TAIL_CHARS) } : {}),
+ ...(Object.keys(operatorNotes).length ? { operator_notes: operatorNotes } : {}),
};
}
@@ -425,7 +434,9 @@ function candidateOptions(candidates: readonly JevCandidate[]): Map JEV_MAX_CANDIDATES) return false;
return candidates.every(candidate => [candidate.key, candidate.provider, candidate.model]
- .every(value => value.length > 0 && value.length <= JEV_MAX_CANDIDATE_FIELD_CHARS));
+ .every(value => value.length > 0 && value.length <= JEV_MAX_CANDIDATE_FIELD_CHARS)
+ && (candidate.modelProfile === undefined
+ || (typeof candidate.modelProfile === "string" && candidate.modelProfile.length <= JEV_MAX_CANDIDATE_FIELD_CHARS)));
}
function modelProfile(candidate: JevCandidate): string {
@@ -566,7 +577,7 @@ export async function resolveJevDecision(options: ResolveJevDecisionOptions): Pr
let requestBody: string;
try {
- const state = buildJevState(options.body);
+ const state = buildJevState(options.body, options.candidates);
if (!hasJevDecisionState(state)) return failed("no_state");
requestBody = JSON.stringify({
model: JEV_MODEL,
diff --git a/src/combos/types.ts b/src/combos/types.ts
index fb7c8d318c8..e91beb5cf0c 100644
--- a/src/combos/types.ts
+++ b/src/combos/types.ts
@@ -4,6 +4,7 @@ import type { OcxComboConfig, OcxComboCooldownWaitPolicy, OcxComboDefaultEffort,
import { COMBO_NAMESPACE, isValidComboId, targetKey } from "./identifiers";
export const COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS = 0;
+export const JEV_MAX_CANDIDATE_FIELD_CHARS = 512;
export { COMBO_NAMESPACE, preservesPhysicalComboProvider, isNativeAliasCombo, targetKey, parseComboModelId, comboModelId, comboPublicModelId, comboDisabledModelId, comboDisabledModelSelectors, resolveComboId, isValidComboId } from "./identifiers";
/**
@@ -27,6 +28,8 @@ export interface NormalizedComboTarget {
/** Emergency-only target, deferred under `cooldownWaitPolicy` (#5691). */
lastResort: boolean;
reasoningEfforts?: OcxComboDefaultEffort[];
+ /** Optional JEV decision description. */
+ modelProfile?: string;
}
export interface NormalizedComboConfig {
@@ -333,6 +336,16 @@ export function comboConfigIssues(
message: `targets[${i}].lastResort must be a boolean`,
});
}
+ if (target.modelProfile !== undefined
+ && (typeof target.modelProfile !== "string"
+ || target.modelProfile.trim().length === 0
+ || target.modelProfile.length > JEV_MAX_CANDIDATE_FIELD_CHARS
+ || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(target.modelProfile))) {
+ issues.push({
+ path: ["targets", i, "modelProfile"],
+ message: `targets[${i}].modelProfile must be a non-empty string of at most ${JEV_MAX_CANDIDATE_FIELD_CHARS} characters; only tab, line feed and carriage return are allowed among control characters`,
+ });
+ }
if (provider && model) {
const key = targetKey({ provider, model });
@@ -389,6 +402,9 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig
...(target.reasoningEfforts !== undefined
? { reasoningEfforts: [...target.reasoningEfforts] }
: {}),
+ ...(typeof target.modelProfile === "string" && target.modelProfile.trim()
+ ? { modelProfile: target.modelProfile.trim() }
+ : {}),
lastResort: target.lastResort === true,
})),
};
diff --git a/src/config.ts b/src/config.ts
index 3f845f70323..37191f58c72 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -267,6 +267,7 @@ export function loadConfig(): OcxConfig {
warnConfigRepaired(configPath, result.error);
const config = normalizeApiKeyIds(retryResult.data as OcxConfig);
warnInheritedFastWireConflicts(configPath, config);
+ warnDegradedTopLevelOptIns(parsed, config);
warnDegradedHostname(parsed, config);
warnDegradedListeners(parsed, config);
warnDegradedApiKeys(parsed, config);
@@ -296,6 +297,7 @@ export function loadConfig(): OcxConfig {
warnDroppedConfigSections(configPath, salvaged.dropped, salvaged.issues);
const config = normalizeApiKeyIds(salvaged.parsed);
warnInheritedFastWireConflicts(configPath, config);
+ warnDegradedTopLevelOptIns(parsed, config);
warnDegradedHostname(parsed, config);
warnDegradedListeners(parsed, config);
warnDegradedApiKeys(parsed, config);
diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts
index d1ab81747c4..4c630cf169f 100644
--- a/src/config/diagnostics.ts
+++ b/src/config/diagnostics.ts
@@ -64,6 +64,7 @@ import {
spendSchema,
compactionRoutingSchema,
skillsConfigSchema,
+ memoryModelsSchema,
} from "./schema/leaf-validators";
export type ConfigDiagnostics = {
@@ -611,6 +612,10 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
if (compactionRouting !== undefined && !compactionRoutingSchema.safeParse(compactionRouting).success) {
return { ok: false, error: "schema_invalid: compactionRouting: requires a nonblank model, an optional valid reasoningEffort, and optional non-repeating triggers drawn from \"manual\" and \"auto\"" };
}
+ const memoryModels = rawConfigRecord(value)?.memoryModels;
+ if (memoryModels !== undefined && !memoryModelsSchema.safeParse(memoryModels).success) {
+ return { ok: false, error: "schema_invalid: memoryModels: requires a nonblank model and an optional declared reasoningEffort per configured phase, and no other fields" };
+ }
const boundaryError = compactionRecoveryConfigError(value) ?? configReasoningPinsConfigError(value)
?? blankHostnameError(value)
?? claudeSubagentEffortError(value)
diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts
index aa8cc5e94c4..e2e196b8a75 100644
--- a/src/config/load-degrade.ts
+++ b/src/config/load-degrade.ts
@@ -122,6 +122,35 @@ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxCon
if (compactionRecoveryConfigError(rawParsed)) console.warn("⚠️ invalid compactionRecovery disabled; the original compaction failure is preserved");
warnDegradedStreamMode(rawParsed, validated);
warnDegradedCompactionRouting(rawParsed, validated);
+ warnDegradedMemoryModels(rawParsed, validated);
+}
+
+/**
+ * A malformed `memoryModels` phase disables that phase rather than failing the whole schema, so
+ * say so once: silently keeping whatever route the phase already had — which may be the shadow
+ * intercept rather than Codex's own model — is the outcome a typo must not produce quietly.
+ */
+export function warnDegradedMemoryModels(rawParsed: unknown, validated: OcxConfig): void {
+ if (!rawParsed || typeof rawParsed !== "object") return;
+ const raw = (rawParsed as Record).memoryModels;
+ if (raw === undefined) return;
+ if (validated.memoryModels === undefined || raw === null || typeof raw !== "object" || Array.isArray(raw)) {
+ console.warn("\u26a0\ufe0f config.json memoryModels is invalid (expected { extract?: { model, reasoningEffort? }, consolidation?: { model, reasoningEffort? } } with a nonblank model and a declared effort per phase) \u2014 the memory pipeline keeps its existing route, which may include shadow-call interception");
+ return;
+ }
+ // A misspelled phase key is stripped by the permissive load schema, so without this warning it
+ // disappears silently and the next settings save persists the sanitized map without it.
+ for (const key of Object.keys(raw as Record)) {
+ if (key === "extract" || key === "consolidation") continue;
+ // Redact and JSON-escape the key name: a malformed hand-edit can place a secret in a property
+ // name, and a control character in one must not be able to forge a log line.
+ console.warn("\u26a0\ufe0f config.json memoryModels." + JSON.stringify(redactSecretString(key)) + " is not a recognized phase \u2014 ignoring it");
+ }
+ for (const phase of ["extract", "consolidation"] as const) {
+ if ((raw as Record)[phase] !== undefined && validated.memoryModels[phase] === undefined) {
+ console.warn("\u26a0\ufe0f config.json memoryModels." + phase + " is invalid (expected { model, reasoningEffort? } with a nonblank model) \u2014 that phase keeps its existing route, which may include shadow-call interception");
+ }
+ }
}
/**
diff --git a/src/config/macos-system-proxy.ts b/src/config/macos-system-proxy.ts
new file mode 100644
index 00000000000..c54c3938f7f
--- /dev/null
+++ b/src/config/macos-system-proxy.ts
@@ -0,0 +1,114 @@
+import { execFileSync } from "node:child_process";
+import { isIP } from "node:net";
+
+export type MacOSProxyReader = () => string | null;
+export type MacOSSystemProxyResult =
+ | { kind: "proxy"; httpUrl?: string; httpsUrl?: string; exceptions: string[]; droppedLinkLocal: boolean }
+ | { kind: "disabled" | "unreadable" | "unsafe-exceptions" };
+
+function readScutilProxy(): string {
+ return execFileSync("/usr/sbin/scutil", ["--proxy"], {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ timeout: 2_000,
+ maxBuffer: 64 * 1024,
+ });
+}
+
+function proxyUrl(host: string | undefined, port: string | undefined): string | undefined {
+ if (!host || !port || !/^\d+$/.test(port) || +port < 1 || +port > 65535) return undefined;
+ const bareHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
+ if (!isIP(bareHost) && !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.?$/i.test(host)) return undefined;
+ try {
+ return new URL(`http://${isIP(bareHost) === 6 ? `[${bareHost}]` : host}:${port}`).origin;
+ } catch {
+ return undefined;
+ }
+}
+
+// Bun matches a leading-dot entry at DNS-label boundaries and also bypasses the
+// bare apex. Translating "*.local" to ".local" therefore widens only to "local";
+// other glob shapes are refused. Bun cannot represent the default link-local
+// CIDRs, so they are dropped with a diagnostic instead of blocking discovery.
+// null means one of those exact ranges was dropped; undefined refuses discovery.
+function translateException(value: string): string | null | undefined {
+ if (value === "*") return value;
+ if (value === "169.254/16" || value === "169.254.0.0/16") return null;
+ const ipv6Range = /^(?:\[([0-9a-f:]+)\]|([0-9a-f:]+))\/10$/i.exec(value);
+ const ipv6Base = ipv6Range?.[1] ?? ipv6Range?.[2];
+ if (ipv6Base && isIP(ipv6Base) === 6
+ && new URL(`http://[${ipv6Base}]`).hostname === "[fe80::]") return null;
+ if (value.startsWith("*.")) {
+ const domain = value.slice(2);
+ if (domain.length > 253 || !domain.split(".").every(label => label.length <= 63
+ && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label))) return undefined;
+ return `.${domain.toLowerCase()}`;
+ }
+ if (isIP(value) === 4) {
+ const canonical = new URL(`http://${value}`).hostname;
+ return value === canonical ? value : undefined;
+ }
+ const bare = value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
+ return isIP(bare) === 6 ? new URL(`http://[${bare}]`).hostname : undefined;
+}
+
+/** Read only the global dictionary; scoped service dictionaries do not apply globally. */
+export function readMacOSSystemProxy(reader: MacOSProxyReader = readScutilProxy): MacOSSystemProxyResult {
+ try {
+ const output = reader();
+ if (!output || output.length > 64 * 1024 || !/^\s*\s*\{/.test(output)) return { kind: "unreadable" };
+ const values = new Map();
+ const exceptions: string[] = [];
+ let depth = 0;
+ let inExceptions = false;
+ for (const row of output.split(/\r?\n/)) {
+ const line = row.trim();
+ if (line.endsWith("{")) {
+ if (inExceptions) return { kind: "unreadable" };
+ if (depth === 1) {
+ inExceptions = /^ExceptionsList\s*:\s*\s*\{$/.test(line);
+ if (line.startsWith("ExceptionsList") && !inExceptions) return { kind: "unreadable" };
+ }
+ depth++;
+ } else if (line === "}") {
+ if (--depth < 0) return { kind: "unreadable" };
+ if (depth === 1) inExceptions = false;
+ } else {
+ const entry = /^([^:]+)\s*:\s*(.*?)\s*$/.exec(line);
+ if (!entry) {
+ if (inExceptions) return { kind: "unreadable" };
+ continue;
+ }
+ if (depth === 1) {
+ if (entry[1]!.trim() === "ExceptionsList") return { kind: "unreadable" };
+ values.set(entry[1]!.trim(), entry[2]!);
+ }
+ if (depth === 2 && inExceptions) {
+ if (!/^\d+$/.test(entry[1]!.trim())) return { kind: "unreadable" };
+ exceptions.push(entry[2]!);
+ }
+ }
+ }
+ if (depth !== 0) return { kind: "unreadable" };
+ if (values.get("ExcludeSimpleHostnames") === "1" || values.get("ProxyAutoConfigEnable") === "1"
+ || values.get("ProxyAutoDiscoveryEnable") === "1") return { kind: "unsafe-exceptions" };
+ for (const key of ["HTTPEnable", "HTTPSEnable", "ExcludeSimpleHostnames", "ProxyAutoConfigEnable", "ProxyAutoDiscoveryEnable"]) {
+ const value = values.get(key);
+ if (value !== undefined && value !== "0" && value !== "1") return { kind: "unreadable" };
+ }
+ const translated = exceptions.map(translateException);
+ if (translated.some(value => value === undefined)) return { kind: "unsafe-exceptions" };
+ const httpEnabled = values.get("HTTPEnable") === "1";
+ const httpsEnabled = values.get("HTTPSEnable") === "1";
+ const httpUrl = httpEnabled ? proxyUrl(values.get("HTTPProxy"), values.get("HTTPPort")) : undefined;
+ const httpsUrl = httpsEnabled ? proxyUrl(values.get("HTTPSProxy"), values.get("HTTPSPort")) : undefined;
+ if ((httpEnabled && !httpUrl) || (httpsEnabled && !httpsUrl)) return { kind: "unreadable" };
+ return httpUrl || httpsUrl
+ ? { kind: "proxy", httpUrl, httpsUrl,
+ exceptions: translated.filter((value): value is string => typeof value === "string"),
+ droppedLinkLocal: translated.includes(null) }
+ : { kind: "disabled" };
+ } catch {
+ return { kind: "unreadable" };
+ }
+}
diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts
index 09123fd81ad..e83f740b3d2 100644
--- a/src/config/proxy-env.ts
+++ b/src/config/proxy-env.ts
@@ -5,6 +5,7 @@ import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "./subagent-mod
import { MULTI_AGENT_SURFACE_ADVISORY_VERSION } from "./multi-agent-surface";
import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES } from "../lib/app-owned-memory";
import { describeProxyForLog, readWindowsSystemProxy, type WindowsProxyRegistryReader } from "../lib/windows-system-proxy";
+import { readMacOSSystemProxy, type MacOSProxyReader } from "./macos-system-proxy";
import { OPENAI_PROVIDER_TIER_VERSION, type OcxConfig } from "../types";
import type { OcxRuntimeRole } from "../types/config";
@@ -152,6 +153,23 @@ function mergeNoProxyEntries(configured: readonly string[] = [], loopback: reado
}
}
+function configuredNoProxyEntries(config: OcxConfig): string[] {
+ const raw = config.noProxy;
+ let entries: string[];
+ if (Array.isArray(raw)) {
+ if (raw.some(entry => typeof entry !== "string")) warnProxyConfigDiscardOnce("noProxyElements");
+ entries = raw.filter((entry): entry is string => typeof entry === "string");
+ } else if (typeof raw === "string") {
+ const resolved = resolveEnvValue(raw);
+ if (raw && resolved === undefined) warnProxyConfigDiscardOnce("noProxy");
+ entries = (resolved ?? "").split(",");
+ } else {
+ if (raw !== undefined) warnProxyConfigDiscardOnce("noProxy");
+ entries = [];
+ }
+ return entries.map(entry => entry.trim()).filter(Boolean);
+}
+
/**
* Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports
* such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY
@@ -172,7 +190,7 @@ export function applyProxyEnv(config: OcxConfig, announce = false): void {
/** Test seam for `proxy: "auto"`: the registry reader and platform are injectable. */
export function applyProxyEnvWith(
config: OcxConfig,
- auto: { reader?: WindowsProxyRegistryReader; platform?: NodeJS.Platform } = {},
+ auto: { reader?: WindowsProxyRegistryReader; macOSReader?: MacOSProxyReader; platform?: NodeJS.Platform } = {},
): void {
// `proxy` and `noProxy` are not declared in the top-level schema, which ends in
// `.passthrough()`, so whatever is on disk arrives here verbatim. A non-string value
@@ -194,6 +212,52 @@ export function applyProxyEnvWith(
return;
}
if (proxy.trim().toLowerCase() === "auto") {
+ if ((auto.platform ?? process.platform) === "darwin") {
+ // An inherited scheme or ALL_PROXY route owns both its proxy and bypass
+ // variables. Combining it with system exceptions would change that route.
+ if (["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"]
+ .some(key => process.env[key]?.trim())) {
+ console.log('[opencodex] proxy "auto": existing proxy environment wins; macOS system proxy not consulted');
+ configureSocks5Fetch();
+ return;
+ }
+ const found = readMacOSSystemProxy(auto.macOSReader);
+ if (found.kind !== "proxy") {
+ const reason = found.kind === "unsafe-exceptions"
+ ? "macOS exceptions cannot be safely translated; discovery refused"
+ : found.kind === "disabled"
+ ? "macOS system proxy is disabled"
+ : "macOS proxy settings could not be read";
+ console.log(`[opencodex] proxy "auto": ${reason}; proxy environment unchanged`);
+ return;
+ }
+ const configured = configuredNoProxyEntries(config);
+ const inheritedLowercase = process.env.no_proxy?.trim();
+ if (inheritedLowercase && configured.some(host => /^localhost\.?$/i.test(host))) {
+ console.log('[opencodex] proxy "auto": configured noProxy "localhost" cannot be represented exactly for Bun while an inherited no_proxy is set; discovery refused');
+ return;
+ }
+ const origins = [
+ found.httpUrl && `HTTP ${describeProxyForLog(found.httpUrl)}`,
+ found.httpsUrl && `HTTPS ${describeProxyForLog(found.httpsUrl)}`,
+ ].filter(Boolean).join(", ");
+ console.log(`[opencodex] proxy "auto": using macOS system proxy ${origins}`);
+ if (found.droppedLinkLocal) {
+ console.log('[opencodex] proxy "auto": link-local IP literals use the proxy; macOS link-local range exceptions are not expressible');
+ }
+ if (found.httpUrl) process.env.HTTP_PROXY = found.httpUrl;
+ if (found.httpsUrl) process.env.HTTPS_PROXY = found.httpsUrl;
+ // Bun gives non-empty lowercase no_proxy priority over NO_PROXY. Before
+ // discovery there was no proxy, so an ordinary configured name and its
+ // subdomains can stay direct in both paths. Bare localhost is refused
+ // above when lowercase is inherited: Bun cannot match it exactly there.
+ mergeNoProxyEntries([...configured, ...found.exceptions], LOOPBACK_ADDRESS_NO_PROXY);
+ if (process.env.no_proxy?.trim()) {
+ process.env.no_proxy = withNoProxyEntries(process.env.no_proxy, [...configured, ...found.exceptions], LOOPBACK_ADDRESS_NO_PROXY);
+ }
+ configureSocks5Fetch();
+ return;
+ }
// #1525 slice 1: one startup read of the Windows static proxy. Never copy the literal
// "auto" into HTTP_PROXY; every non-proxy outcome leaves outbound routing as it was.
if (process.env.HTTP_PROXY?.trim() || process.env.http_proxy?.trim()
@@ -213,7 +277,7 @@ export function applyProxyEnvWith(
proxy = undefined;
} else {
const reason = found.kind === "unsupported"
- ? "only Windows system proxy discovery is supported; using direct egress on this OS"
+ ? "only Windows and macOS system proxy discovery is supported; using direct egress on this OS"
: found.kind === "disabled"
? "Windows system proxy is disabled; using direct egress"
: found.kind === "socks-only"
@@ -240,23 +304,6 @@ export function applyProxyEnvWith(
}
// Configured entries first, then loopback: loopback is unconditional, so appending it last
// keeps it present even when the operator lists a loopback host themselves.
- const raw = config.noProxy;
- let configuredEntries: string[];
- if (Array.isArray(raw)) {
- // One unusable element must not discard the operator's other entries.
- if (raw.some(entry => typeof entry !== "string")) warnProxyConfigDiscardOnce("noProxyElements");
- configuredEntries = raw.filter((entry): entry is string => typeof entry === "string");
- } else if (typeof raw === "string") {
- const resolved = resolveEnvValue(raw);
- if (raw && resolved === undefined) warnProxyConfigDiscardOnce("noProxy");
- configuredEntries = (resolved ?? "").split(",");
- } else {
- if (raw !== undefined) warnProxyConfigDiscardOnce("noProxy");
- configuredEntries = [];
- }
- const configured = configuredEntries
- .map(entry => entry.trim())
- .filter(Boolean);
- mergeNoProxyEntries(configured);
+ mergeNoProxyEntries(configuredNoProxyEntries(config));
configureSocks5Fetch();
}
diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts
index a855202a29a..238c5647ad2 100644
--- a/src/config/schema/config-schema.ts
+++ b/src/config/schema/config-schema.ts
@@ -25,6 +25,8 @@ import {
codexAccountNamespacesSchema,
modelPinnedEffortsSchema,
compactionRoutingSchema,
+ memoryModelSettingSchema,
+ memoryModelsSchema,
modelPreferHostedToolsConfigError,
providerModelCostsConfigError,
providerRelativeSendPathConfigError,
@@ -160,6 +162,17 @@ export const configSchema = z.object({
modelPinnedEfforts: modelPinnedEffortsSchema.optional(),
compactionRouting: compactionRoutingSchema.optional().catch(undefined),
compactionRecovery: compactionRecoverySchema.optional().catch(undefined),
+ // A hand-edited malformed phase disables only that phase instead of rejecting
+ // providers/apiKeys, matching the load-time degradation notice; the management write
+ // boundary (validateConfigCandidate) still refuses the bad value through the shared,
+ // catch-free memoryModelsSchema.
+ memoryModels: z
+ .object({
+ extract: memoryModelSettingSchema.optional().catch(undefined),
+ consolidation: memoryModelSettingSchema.optional().catch(undefined),
+ })
+ .optional()
+ .catch(undefined),
defaultProvider: z.string().min(1).default("openai"),
defaultModelAliases: z.boolean().optional(),
// Malformed hand edits disable this opt-in projection without rejecting providers.
diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts
index 9560a863d7b..15cfba2a59c 100644
--- a/src/config/schema/leaf-validators.ts
+++ b/src/config/schema/leaf-validators.ts
@@ -55,6 +55,21 @@ export const compactionRoutingSchema = z.object({
.optional(),
}).strict();
+/**
+ * One phase of Codex's memory pipeline. A present phase must name a model: the GUI's "Off"
+ * removes the phase instead of blanking it, so an empty entry would only ever come from a
+ * hand-edited file, where failing the write is the honest answer.
+ */
+export const memoryModelSettingSchema = z.object({
+ model: z.string().trim().min(1),
+ reasoningEffort: z.string().refine(value => pinnedReasoningEffortConfigError(value) === null).optional(),
+}).strict();
+
+export const memoryModelsSchema = z.object({
+ extract: memoryModelSettingSchema.optional(),
+ consolidation: memoryModelSettingSchema.optional(),
+}).strict();
+
/**
* Bounds for the opt-in same-target 429 wait-and-retry policy. Single source of truth
* shared by the config schema, the load-time sanitizer, and the management write
diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts
index 9f17ed6be2b..ca2ff0350b0 100644
--- a/src/integrations/catalog-refresh.ts
+++ b/src/integrations/catalog-refresh.ts
@@ -11,7 +11,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"],
+ clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "droid"],
): Promise {
// Client files are shared with the live proxy a sibling instance runs beside; their entries
// point at the owner's port, and refreshing them here would re-point them at this one.
diff --git a/src/integrations/config-io.ts b/src/integrations/config-io.ts
index df5337a121e..45f5458cf25 100644
--- a/src/integrations/config-io.ts
+++ b/src/integrations/config-io.ts
@@ -9,6 +9,7 @@
*/
import { lstatSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
import type { ConfigFormat } from "../clients/config-export";
+import { canonicalizeJsonc } from "../lib/jsonc";
import { MAX_JSON_NESTING } from "./serialize";
import { atomicWriteFileNoFollow, isMissingPathError } from "../config/atomic-write";
import type { JournalEntry } from "./journal";
@@ -132,7 +133,11 @@ function jsonTextSafeToRewrite(text: string): boolean {
}
/** Parse a client config, tolerating absence. PARSE_FAILED on garbage. */
-export function parseConfig(text: string | null, format: ConfigFormat): unknown | typeof PARSE_FAILED {
+export function parseConfig(
+ text: string | null,
+ format: ConfigFormat,
+ options?: { jsonc?: boolean },
+): unknown | typeof PARSE_FAILED {
if (text === null || text.trim().length === 0) return {};
try {
switch (format) {
@@ -143,9 +148,13 @@ export function parseConfig(text: string | null, format: ConfigFormat): unknown
* would otherwise cap the rewrite only after JSON.parse had already
* built the 50k-deep object graph. The outcome is unchanged: invalid
* JSON still returns PARSE_FAILED, from the catch below.
+ *
+ * Kilo's global file is JSONC. Comments and trailing commas are
+ * stripped first so the rewrite-safety scan sees JSON, not comment text.
*/
- if (!jsonTextSafeToRewrite(text)) return PARSE_FAILED;
- return JSON.parse(text);
+ const source = options?.jsonc ? canonicalizeJsonc(text) : text;
+ if (!jsonTextSafeToRewrite(source)) return PARSE_FAILED;
+ return JSON.parse(source);
}
case "json5": return Bun.JSON5.parse(text);
case "yaml": return Bun.YAML.parse(text);
diff --git a/src/integrations/droid-settings.ts b/src/integrations/droid-settings.ts
new file mode 100644
index 00000000000..0c0989f8130
--- /dev/null
+++ b/src/integrations/droid-settings.ts
@@ -0,0 +1,65 @@
+/** Read-only guard for competing Factory settings before a managed write. */
+import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
+import { join, win32 } from "node:path";
+
+const isWindowsRoot = (path: string) => /^[A-Za-z]:[\\/]|^\\\\/.test(path);
+
+function endpointKey(value: string): string | null {
+ try {
+ const url = new URL(value);
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
+ const host = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname.toLowerCase())
+ ? "127.0.0.1" : url.hostname.toLowerCase();
+ return `${url.protocol}//${host}:${url.port}${url.pathname.replace(/\/+$/, "") || "/"}`;
+ } catch { return null; }
+}
+
+/** Other Factory settings can take priority over the managed personal rows. */
+export function assertDroidSettingsUnambiguous(root: string, baseUrl?: string, modelIds: readonly string[] = []): void {
+ const managedEndpoint = baseUrl === undefined ? null : endpointKey(baseUrl);
+ const managedModels = new Set(modelIds);
+ try {
+ const directory = lstatSync(root);
+ if (!directory.isDirectory()) throw new Error("Unsafe Factory settings directory");
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+ }
+ for (const name of ["config.json", "settings.local.json"]) {
+ const path = isWindowsRoot(root) ? win32.join(root, name) : join(root, name);
+ let stat: ReturnType;
+ try { stat = lstatSync(path); }
+ catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
+ throw new Error(`Cannot inspect Factory ${name}`);
+ }
+ if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error(`Unsafe Factory ${name}`);
+ let value: unknown;
+ let fd: number | undefined;
+ try {
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
+ const opened = fstatSync(fd);
+ if (!opened.isFile() || opened.size > 1024 * 1024 || opened.ino !== stat.ino || opened.dev !== stat.dev) {
+ throw new Error("file changed during inspection");
+ }
+ value = JSON.parse(readFileSync(fd, "utf8"));
+ } catch { throw new Error(`Cannot safely parse Factory ${name}`); }
+ finally { if (fd !== undefined) closeSync(fd); }
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Unsafe Factory ${name}`);
+ const rows = name === "config.json"
+ ? (value as Record).custom_models
+ : (value as Record).customModels;
+ if (name === "settings.local.json" && rows !== undefined) {
+ throw new Error("Factory settings.local.json overrides customModels; resolve its precedence before enabling Droid");
+ }
+ if (name === "config.json" && Array.isArray(rows) && rows.some(row => {
+ if (!row || typeof row !== "object" || Array.isArray(row)) return false;
+ const legacy = row as Record;
+ return (typeof legacy.display_name === "string" && legacy.display_name.startsWith("OpenCodex:"))
+ || (typeof legacy.model === "string" && managedModels.has(legacy.model))
+ || (managedEndpoint !== null && typeof legacy.base_url === "string"
+ && endpointKey(legacy.base_url) === managedEndpoint);
+ })) {
+ throw new Error("Factory config.json already defines OpenCodex models; resolve its precedence before enabling Droid");
+ }
+ }
+}
diff --git a/src/integrations/kilo-candidates.ts b/src/integrations/kilo-candidates.ts
new file mode 100644
index 00000000000..55c33cc1eb2
--- /dev/null
+++ b/src/integrations/kilo-candidates.ts
@@ -0,0 +1,30 @@
+import { homedir } from "node:os";
+import { KILO_CONFIG_CANDIDATES, kiloCandidatePath, kiloHomeDir } from "../clients/config-export";
+import { loadTarget, parseConfig, PARSE_FAILED, type IntegrationIO } from "./config-io";
+
+/** Kilo deep-merges every global candidate, so an off-target provider can override ours. */
+export function inspectKiloCandidates(input: {
+ io: IntegrationIO;
+ selectedPath: string;
+ env?: NodeJS.ProcessEnv;
+ home?: string;
+}): { kind: "ok" } | { kind: "unsafe"; path: string; why: "unparseable" | "not-regular-file" }
+ | { kind: "conflict"; paths: string[] } {
+ const dir = kiloHomeDir(input.env ?? process.env, input.home ?? homedir());
+ const conflictPaths: string[] = [];
+ for (const name of KILO_CONFIG_CANDIDATES) {
+ const path = kiloCandidatePath(dir, name);
+ const loaded = loadTarget(input.io, path);
+ if (!loaded.ok) return { kind: "unsafe", path, why: loaded.why === "read-failed" ? "unparseable" : "not-regular-file" };
+ if (loaded.before === null) continue;
+ const parsed = parseConfig(loaded.before, "json", { jsonc: true });
+ if (parsed === PARSE_FAILED) return { kind: "unsafe", path, why: "unparseable" };
+ if (path !== input.selectedPath && typeof parsed === "object" && parsed !== null
+ && !Array.isArray(parsed) && Object.hasOwn(parsed, "provider")) {
+ const provider = (parsed as Record).provider;
+ if (typeof provider === "object" && provider !== null && !Array.isArray(provider)
+ && Object.hasOwn(provider, "opencodex")) conflictPaths.push(path);
+ }
+ }
+ return conflictPaths.length > 0 ? { kind: "conflict", paths: conflictPaths } : { kind: "ok" };
+}
diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts
index 9ab2fac0d82..c35ee4e27d5 100644
--- a/src/integrations/mutation-plan.ts
+++ b/src/integrations/mutation-plan.ts
@@ -26,13 +26,18 @@ import { parseClineDocument } from "./cline-document";
import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
import {
INTEGRATION_CLIENTS,
+ boundIntegrationConfigPath,
+ assertDroidPathsUnambiguous,
+ assertDroidRecordedSettingsUnambiguous,
isLoopbackOnly,
resolveIntegrationPaths,
+ restoreOwnershipCollision,
type IntegrationClientId,
} from "./registry";
import { declaredIntegrationTarget, resolveIntegrationTarget, type IntegrationTarget } from "./target";
import { shouldInjectApiAuthHeader } from "../codex/inject";
import { classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
+import { inspectKiloCandidates } from "./kilo-candidates";
import { InvalidSelectorError } from "./merge";
import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
import type { OcxConfig } from "../types";
@@ -144,6 +149,8 @@ const CLIENT_MANAGED_PATHS = {
["settings", "providers", OPENCODE_PROVIDER_ID],
["catalog", "providers", OPENCODE_PROVIDER_ID],
],
+ kilo: [["provider", OPENCODE_PROVIDER_ID]],
+ droid: [["customModels", DYNAMIC_SEGMENT]],
} satisfies Record;
/** Not a configuration surface. Exported so a parity case can compare it against the shipped clients. */
@@ -339,6 +346,7 @@ function foreignEditOf(input: PlanInput): IntegrationPlanForeignEdit {
function applyOutcome(input: PlanInput): PlanOutcome {
if (input.installKind !== "dir") return deny("not_installed");
if (input.admissionBlocked) return deny("non_loopback");
+ if (input.clientId === "droid" && input.contribution?.fragments.length === 0) return deny("unsafe");
/*
* Before any file state. The document may be perfectly writable and our block
* may already be current in it; neither says anything about whether the
@@ -551,7 +559,9 @@ export function observeRestore(
const clientId = input.clientId;
let resolved: { configPath: string; detectDir: string };
try {
- resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
+ const context = exportContextOf(input);
+ resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home, context);
+ if (clientId === "droid" && input.resolvedPaths) assertDroidPathsUnambiguous(resolved.detectDir, context);
} catch (error) {
if (!(error instanceof ClientPathError)) throw error;
return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
@@ -582,6 +592,22 @@ export function observeRestore(
failed: observationFailure("conflict", "conflict", "that operation was recorded for a different location"),
} as const;
}
+ /*
+ * A legal historical path is not enough. Another candidate can already own
+ * the single record, and committing this row's prior record would orphan the
+ * block that candidate still holds. Direct restore asks the same question.
+ */
+ const currentOwner = store.readRecords()[clientId] ?? null;
+ const collision = restoreOwnershipCollision({
+ clientId,
+ journaledPath: configPath,
+ currentPath: currentOwner && currentOwner.clientId === clientId ? currentOwner.configPath : null,
+ env: input.env,
+ home: input.home,
+ });
+ if (collision !== null) {
+ return { failed: observationFailure("conflict", "conflict", collision) } as const;
+ }
if (clientId === "cline") {
try { io = createClineIO(io, configPath, store, effects.recover); }
catch (error) {
@@ -640,7 +666,7 @@ export function previewIntegration(input: IntegrationWriteInput, request: Previe
// Restore never reaches the general observation, because the writer's undo path never parses
// or classifies and a preview that did would answer a different question.
if (request.operation === "restore") return previewRestore(input, request);
- const observed = observeIntegration(input, { maintenance: false, recover: false });
+ const observed = observeIntegration(input, { maintenance: false, recover: false }, request.operation);
if (observed.failed) return unboundPlan(input.clientId, request.operation, observed.failed, request.profileId);
const shared = {
@@ -743,7 +769,7 @@ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest):
? {}
: observed.clientId === "cline"
? parseClineDocument(observed.before)
- : parseConfig(observed.before, observed.format),
+ : parseConfig(observed.before, observed.format, EXPORT_CLIENTS[observed.clientId].jsonc ? { jsonc: true } : undefined),
restore: {
opId: observed.entry.opId,
entry: observed.entry,
@@ -812,7 +838,11 @@ export interface ObservationEffects {
* classification rather than two independent reads that can disagree. The ordering of refusals is
* load-bearing and is preserved exactly as the writer had it.
*/
-export function observeIntegration(input: IntegrationWriteInput, effects: ObservationEffects) {
+export function observeIntegration(
+ input: IntegrationWriteInput,
+ effects: ObservationEffects,
+ operation: Exclude,
+) {
const store = input.store ?? createIntegrationStateStore();
let io = input.io ?? defaultIntegrationIO(store);
const clientId = input.clientId;
@@ -839,7 +869,9 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
* an Aside account switch land between the two, so a direct apply could
* verify account 1 was installed and then write account 0's catalog.
*/
- const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
+ const context = exportContextOf(input);
+ const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home, context);
+ if (clientId === "droid" && input.resolvedPaths) assertDroidPathsUnambiguous(resolved.detectDir, context);
detectDir = resolved.detectDir;
if (clientId === "cline") io = createClineIO(io, resolved.configPath, store, effects.recover);
/*
@@ -850,6 +882,10 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
* target is known, because that is the path it has to match.
*/
stored = store.readRecords()[clientId] ?? null;
+ const recordedPath = boundIntegrationConfigPath({
+ clientId, record: stored, resolvedPath: resolved.configPath,
+ statKind: io.statKind, env: input.env, home: input.home,
+ });
/*
* Inside the same guard as resolution, because this resolver can refuse the
* same way: the store is named by a client env var, and a relative one is a
@@ -857,7 +893,7 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
* collection route.
*/
effective = resolveIntegrationTarget({
- clientId, configPath: resolved.configPath, io, record: stored, env: input.env, home: input.home,
+ clientId, configPath: recordedPath, io, record: stored, env: input.env, home: input.home,
});
configPath = effective.configPath;
} catch (error) {
@@ -868,6 +904,13 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
}
// Pruning writes, so only a mutation may perform it. Preview reports the state it finds.
+ if (clientId === "kilo" && operation !== "disable") {
+ const candidates = inspectKiloCandidates({ io, selectedPath: configPath, env: input.env, home: input.home });
+ if (candidates.kind !== "ok") return { failed: candidates.kind === "conflict"
+ ? observationFailure("conflict", "conflict", `${configPath} cannot be managed while ${candidates.paths.join(", ")} also defines provider.opencodex`)
+ : observationFailure("unsafe", "unsafe", `${candidates.path} cannot be inspected safely (${candidates.why})`),
+ } as const;
+ }
if (effects.maintenance) store.retryPendingPrunes();
const loaded = loadTarget(io, configPath);
@@ -880,7 +923,9 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
} as const;
}
const before = loaded.before;
- const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, effective.format);
+ const parsed = clientId === "cline"
+ ? parseClineDocument(before)
+ : parseConfig(before, effective.format, exportSpec.jsonc ? { jsonc: true } : undefined);
if (parsed === PARSE_FAILED) {
return { failed: observationFailure("unsafe", "unsafe",
`${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const;
@@ -898,6 +943,9 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
const record = stored && stored.clientId === clientId && stored.configPath === configPath
? stored
: null;
+ if (clientId === "droid" && input.models.length > 0 && contribution.fragments.length === 0 && !record) {
+ return { failed: observationFailure("unsafe", "unsafe", "Factory Droid has no addressable models in the selected catalog") } as const;
+ }
// `configPath`/`clientId` are load-bearing, not decoration: a record proves
// ownership of ONE file, and the writer mutates whatever path resolves NOW.
// Without them a record written for another home directory would grant
@@ -906,6 +954,13 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
format: effective.format,
});
+ if (clientId === "droid" && record && (classified.state === "current" || classified.state === "stale")) {
+ try { assertDroidRecordedSettingsUnambiguous(detectDir, parsed, record); }
+ catch (error) {
+ if (!(error instanceof ClientPathError)) throw error;
+ return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
+ }
+ }
return {
failed: undefined, store, io, clientId, spec, exportSpec, target: effective, configPath, detectDir,
/*
diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts
index 55d87a60ac4..798d0900071 100644
--- a/src/integrations/registry.ts
+++ b/src/integrations/registry.ts
@@ -9,11 +9,17 @@
* Design of record: devlog/_fin/260802_client_toggle_api/021 §1.
*/
import { homedir } from "node:os";
+import { assertDroidSettingsUnambiguous } from "./droid-settings";
+import { readPath } from "./merge";
+import type { OwnershipRecord } from "./ownership";
import { join } from "node:path";
import {
ClientPathError,
+ buildDroidContribution,
clineConfigPath,
clineSettingsDir,
+ droidConfigPath,
+ droidHomeDir,
EXPORT_CLIENTS,
asideAccountDir,
asideConfigPath,
@@ -48,7 +54,13 @@ import {
zcodeStoreSchemaEstablished,
type BuildContribution,
type ConfigFormat,
+ kiloConfigPath,
+ kiloHomeDir,
+ kiloCandidatePath,
+ KILO_CONFIG_CANDIDATES,
type ExportClientId,
+ type DroidModelEntry,
+ type ExportContext,
} from "../clients/config-export";
/**
@@ -93,13 +105,12 @@ export interface IntegrationClientSpec {
* Derive the config path AND the detect directory from one resolution, for a
* client whose paths depend on mutable state rather than only env and home.
*
- * Only Aside needs this. Its two paths both come from the account id in
- * `accounts.json`, so calling `configPath` and `detectDir` in sequence can
- * straddle an account switch and check one account's install while writing
- * another's catalog. Reading the id once and deriving both paths from it
- * removes the window instead of narrowing it.
+ * Aside needs this because both paths come from the account id in
+ * `accounts.json`: one read prevents an account switch between resolutions.
+ * Droid uses the same seam to check competing settings against the export
+ * context before status, preview, or mutation proceeds.
*/
- resolvePaths?: (env?: NodeJS.ProcessEnv, home?: string) => { configPath: string; detectDir: string };
+ resolvePaths?: (env?: NodeJS.ProcessEnv, home?: string, exportContext?: ExportContext) => { configPath: string; detectDir: string };
/**
* Where the client's config WOULD live, for a client whose real path cannot
* be resolved yet.
@@ -115,6 +126,19 @@ export interface IntegrationClientSpec {
* catalog. `resolveIntegrationPaths` still throws for callers that mutate.
*/
unresolvedPathHint?: (env?: NodeJS.ProcessEnv, home?: string) => string;
+ /**
+ * Recognize a resolution drift that is still THIS client's own file, for a
+ * client whose config path depends on mutable world state rather than only
+ * env and home.
+ *
+ * Kilo resolves to the first EXISTING candidate, so a candidate created
+ * after apply moves resolution while the owned file still holds our block.
+ * While this predicate accepts the recorded path, reads and mutations stay
+ * bound to it instead of silently re-homing onto the newcomer. A client
+ * without this hook never binds: a record from another home stays a refusal
+ * ("a record for one home cannot authorize a write to another").
+ */
+ bindsDriftedRecord?: (recordPath: string, env?: NodeJS.ProcessEnv, home?: string) => boolean;
}
/**
@@ -129,12 +153,38 @@ export function resolveIntegrationPaths(
clientId: IntegrationClientId,
env: NodeJS.ProcessEnv = process.env,
home: string = homedir(),
+ exportContext?: ExportContext,
): { configPath: string; detectDir: string } {
const spec = INTEGRATION_CLIENTS[clientId];
- if (spec.resolvePaths) return spec.resolvePaths(env, home);
+ if (spec.resolvePaths) return spec.resolvePaths(env, home, exportContext);
return { configPath: spec.configPath(env, home), detectDir: spec.detectDir(env, home) };
}
+export function assertDroidPathsUnambiguous(root: string, exportContext?: ExportContext): void {
+ try {
+ const generated = exportContext ? buildDroidContribution(exportContext).fragments : [];
+ assertDroidSettingsUnambiguous(root, exportContext?.baseUrl, generated.map(fragment => (fragment.value as DroidModelEntry).model));
+ }
+ catch (error) { throw new ClientPathError((error as Error).message); }
+}
+
+/** Check the identities still owned on disk even after they leave the catalog. */
+export function assertDroidRecordedSettingsUnambiguous(root: string, parsed: unknown, record: OwnershipRecord): void {
+ try {
+ const byEndpoint = new Map>();
+ for (const path of record.fragmentPaths) {
+ const row = readPath(parsed, path) as Partial | undefined;
+ if (typeof row?.baseUrl !== "string" || typeof row.model !== "string") {
+ throw new Error("Cannot verify recorded Factory Droid rows");
+ }
+ const models = byEndpoint.get(row.baseUrl) ?? new Set();
+ models.add(row.model);
+ byEndpoint.set(row.baseUrl, models);
+ }
+ for (const [baseUrl, models] of byEndpoint) assertDroidSettingsUnambiguous(root, baseUrl, [...models]);
+ } catch (error) { throw new ClientPathError((error as Error).message); }
+}
+
/**
* The location to name when resolution refused, or `""` when there is none.
*
@@ -345,11 +395,90 @@ export const INTEGRATION_CLIENTS: Record clineSettingsDir(env, home),
writerLock: { suffix: ".lock" },
},
+ kilo: {
+ id: "kilo",
+ configPath: (env = process.env, home = homedir()) => kiloConfigPath(env, home),
+ detectDir: (env = process.env, home = homedir()) => kiloHomeDir(env, home),
+ bindsDriftedRecord: (recordPath, env = process.env, home = homedir()) =>
+ KILO_CONFIG_CANDIDATES.some(name => recordPath === kiloCandidatePath(kiloHomeDir(env, home), name)),
+ },
+ droid: {
+ id: "droid",
+ configPath: (env = process.env, home = homedir()) => droidConfigPath(env, home),
+ detectDir: (env = process.env, home = homedir()) => droidHomeDir(env, home),
+ resolvePaths: (env = process.env, home = homedir(), exportContext) => {
+ const detectDir = droidHomeDir(env, home);
+ assertDroidPathsUnambiguous(detectDir, exportContext);
+ return { configPath: droidConfigPath(env, home), detectDir };
+ },
+ },
};
export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
Object.keys(INTEGRATION_CLIENTS) as IntegrationClientId[];
+/**
+ * The effective config path for a read or mutation, given the ownership record.
+ *
+ * One implementation for status AND the mutation planner: when only one side
+ * carried the binding, the two could disagree again and status would report a
+ * file the writer never touches. Binds only while the client's own
+ * `bindsDriftedRecord` accepts the recorded path (still one of that client's
+ * candidates under the CURRENT env and home) and the file still exists; a
+ * record from another home never binds and keeps its refusal contract.
+ */
+export function boundIntegrationConfigPath(input: {
+ clientId: IntegrationClientId;
+ record: { clientId: IntegrationClientId; configPath: string } | null;
+ resolvedPath: string;
+ statKind: (path: string) => string;
+ env?: NodeJS.ProcessEnv;
+ home?: string;
+}): string {
+ const record = input.record;
+ if (
+ record && record.clientId === input.clientId &&
+ record.configPath !== input.resolvedPath &&
+ input.statKind(record.configPath) === "file" &&
+ INTEGRATION_CLIENTS[input.clientId].bindsDriftedRecord?.(record.configPath, input.env, input.home) === true
+ ) {
+ return record.configPath;
+ }
+ return input.resolvedPath;
+}
+
+/**
+ * Why a historical restore must not run, or null when it may.
+ *
+ * Kilo keeps one ownership record and may legally have written more than one
+ * candidate. Treating every same-home journaled path as a restore target lets
+ * an undo of an older file commit that file's prior record over the candidate
+ * that owns the integration now. The managed block in the current file stays
+ * on disk, the record points at the old file, and a later disable drops the
+ * record and orphans the newcomer.
+ *
+ * A missing current record is not a collision: undoing the disable that
+ * dropped it still restores the journaled file. A client without
+ * bindsDriftedRecord is unchanged, because that seam is what made the second
+ * candidate a legal target. Direct restore and its preview both ask here, so
+ * they cannot admit different answers.
+ */
+export function restoreOwnershipCollision(input: {
+ clientId: IntegrationClientId;
+ journaledPath: string;
+ currentPath: string | null;
+ env?: NodeJS.ProcessEnv;
+ home?: string;
+}): string | null {
+ const currentPath = input.currentPath;
+ if (currentPath === null || currentPath === input.journaledPath) return null;
+ const binds = INTEGRATION_CLIENTS[input.clientId].bindsDriftedRecord;
+ if (!binds) return null;
+ if (binds(input.journaledPath, input.env, input.home) !== true) return null;
+ if (binds(currentPath, input.env, input.home) !== true) return null;
+ return `that operation was recorded for ${input.journaledPath}, but ${currentPath} currently owns this integration`;
+}
+
export function isIntegrationClientId(value: string): value is IntegrationClientId {
return Object.prototype.hasOwnProperty.call(INTEGRATION_CLIENTS, value);
}
diff --git a/src/integrations/state.ts b/src/integrations/state.ts
index d41444615bd..1e396eb8659 100644
--- a/src/integrations/state.ts
+++ b/src/integrations/state.ts
@@ -33,11 +33,15 @@ import {
} from "./ownership-policy";
import {
INTEGRATION_CLIENTS,
+ boundIntegrationConfigPath,
+ assertDroidPathsUnambiguous,
+ assertDroidRecordedSettingsUnambiguous,
resolveIntegrationPaths,
unresolvedPathHintFor,
type IntegrationClientId,
} from "./registry";
import { resolveIntegrationTarget, type IntegrationTarget } from "./target";
+import { inspectKiloCandidates } from "./kilo-candidates";
import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
export type IntegrationState = "absent" | "current" | "stale" | "conflict" | "unsafe";
@@ -49,6 +53,7 @@ export type StateReason =
/** A container we would have to write through holds a non-object value. */
| "blocked-container"
| "ambiguous-selector"
+ | "candidate-conflict"
/** A path selector we cannot resolve, e.g. a relative OPENCLAW_CONFIG_PATH. */
| "unresolvable-path";
@@ -60,6 +65,10 @@ export interface IntegrationStatus {
appliedAt?: string;
lastOpId?: string;
reason?: StateReason;
+ /** Other Kilo global candidates defining provider.opencodex. */
+ conflictPaths?: string[];
+ /** Kilo candidate that could not be inspected; may differ from the owned target. */
+ candidateFailurePath?: string;
/**
* The store this client reads instead of `configPath`.
*
@@ -328,7 +337,13 @@ export function classifyIntegration(input: {
}
throw error;
}
- if (!hasOurFragments(input.parsed, input.contribution)) return { state: "absent" };
+ // A catalog can shrink to zero while the record still owns earlier rows.
+ // Presence for disable must include those recorded paths, independent of the
+ // current export roster; the ownership fingerprint is checked below.
+ if (!hasOurFragments(input.parsed, input.contribution)
+ && !(input.record?.fragmentPaths.some(path => readPath(input.parsed, path) !== undefined))) {
+ return { state: "absent" };
+ }
/*
* Fragments the desired contribution carries beyond the paths this record names. Both
@@ -520,7 +535,9 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
try {
// One resolution for both, so a client whose paths come from mutable state
// cannot report one account's install beside another account's config path.
- const paths = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home);
+ const context = exportContextOf(input);
+ const paths = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home, context);
+ if (input.clientId === "droid" && input.resolvedPaths) assertDroidPathsUnambiguous(paths.detectDir, context);
installed = io.statKind(paths.detectDir) === "dir";
if (input.clientId === "cline") io = createClineIO(io, paths.configPath, store);
/*
@@ -530,8 +547,12 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
* would let the badge and the switch disagree.
*/
record = store.readRecords()[input.clientId] ?? null;
+ const recordedPath = boundIntegrationConfigPath({
+ clientId: input.clientId, record, resolvedPath: paths.configPath,
+ statKind: io.statKind, env: input.env, home: input.home,
+ });
effective = resolveIntegrationTarget({
- clientId: input.clientId, configPath: paths.configPath, io, record, env: input.env, home: input.home,
+ clientId: input.clientId, configPath: recordedPath, io, record, env: input.env, home: input.home,
});
} catch (error) {
if (!(error instanceof ClientPathError)) throw error;
@@ -558,6 +579,20 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
}
const configPath = effective.configPath;
+ if (input.clientId === "kilo") {
+ const candidates = inspectKiloCandidates({ io, selectedPath: configPath, env: input.env, home: input.home });
+ if (candidates.kind !== "ok") return {
+ clientId: input.clientId,
+ state: candidates.kind === "conflict" ? "conflict" : "unsafe",
+ installed,
+ configPath,
+ reason: candidates.kind === "conflict" ? "candidate-conflict" : candidates.why,
+ ...(candidates.kind === "conflict" ? { conflictPaths: candidates.paths } : {}),
+ ...(candidates.kind === "unsafe" ? { candidateFailurePath: candidates.path } : {}),
+ ...(record && record.configPath === configPath ? { appliedAt: record.appliedAt, lastOpId: record.opId } : {}),
+ ...retention,
+ };
+ }
const loaded = loadTarget(io, configPath);
if (!loaded.ok) {
return {
@@ -572,8 +607,13 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
const parsed = input.clientId === "cline"
? parseClineDocument(loaded.before)
- : parseConfig(loaded.before, effective.format);
+ : parseConfig(loaded.before, effective.format, EXPORT_CLIENTS[input.clientId].jsonc ? { jsonc: true } : undefined);
const contribution = effective.buildContribution(exportContextOf(input));
+ if (input.clientId === "droid" && input.models.length > 0 && contribution.fragments.length === 0
+ && (!record || record.configPath !== configPath)) {
+ return { clientId: input.clientId, state: "unsafe", installed, configPath,
+ reason: "unresolvable-path", ...retention };
+ }
const { state, reason } = classifyIntegration({
fileText: loaded.before,
fileIsRegular: true,
@@ -584,6 +624,14 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
clientId: input.clientId,
format: effective.format,
});
+ if (input.clientId === "droid" && record && (state === "current" || state === "stale")) {
+ try { assertDroidRecordedSettingsUnambiguous(spec.detectDir(input.env, input.home), parsed, record); }
+ catch (error) {
+ if (!(error instanceof ClientPathError)) throw error;
+ return { clientId: input.clientId, state: "unsafe", installed, configPath,
+ reason: "unresolvable-path", ...retention };
+ }
+ }
return {
clientId: input.clientId,
diff --git a/src/integrations/target.ts b/src/integrations/target.ts
index 3a62d9feee5..40840dafbae 100644
--- a/src/integrations/target.ts
+++ b/src/integrations/target.ts
@@ -194,6 +194,9 @@ export function declaredIntegrationTarget(args: {
}): IntegrationTarget | null {
const { clientId, configPath, resolvedConfigPath } = args;
if (configPath === resolvedConfigPath) return configFileTarget(clientId, configPath, null);
+ if (INTEGRATION_CLIENTS[clientId].bindsDriftedRecord?.(configPath, args.env, args.home) === true) {
+ return configFileTarget(clientId, configPath, null);
+ }
const declared = INTEGRATION_CLIENTS[clientId].currentStore;
if (!declared) return null;
try {
diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts
index 10419149cc0..b364694c5fc 100644
--- a/src/integrations/writer.ts
+++ b/src/integrations/writer.ts
@@ -19,6 +19,7 @@ import { detachedConfigSnapshot } from "../config/admitted-identity";
import { copyPlainData } from "../lib/plain-data";
import type { OcxConfig } from "../types";
import { defaultIntegrationIO, loadTarget, type IntegrationIO } from "./config-io";
+import { inspectKiloCandidates } from "./kilo-candidates";
import {
fingerprint,
canonicalContribution,
@@ -33,7 +34,14 @@ import {
semanticProtectedContributionFingerprint,
} from "./ownership-policy";
import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge";
-import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry";
+import {
+ INTEGRATION_CLIENTS,
+ assertDroidPathsUnambiguous,
+ isLoopbackOnly,
+ resolveIntegrationPaths,
+ restoreOwnershipCollision,
+ type IntegrationClientId,
+} from "./registry";
import { declaredIntegrationTarget } from "./target";
import { exportContextOf } from "./state";
import type { IntegrationState } from "./state";
@@ -225,8 +233,8 @@ function sourcePreservingFragmentValue(
* and Cline transaction recovery both write. Translating the planner's refusal into WriteRefused
* here keeps the planner free of any dependency on this module's result type.
*/
-function preflight(input: IntegrationWriteInput) {
- const observed = observeIntegration(input, { maintenance: true, recover: true });
+function preflight(input: IntegrationWriteInput, operation: "apply" | "disable") {
+ const observed = observeIntegration(input, { maintenance: true, recover: true }, operation);
if (!observed.failed) return observed;
const { reason, state, message, snapshotPath, residual } = observed.failed;
const refused = refuse(input.clientId, reason, state, message, snapshotPath);
@@ -251,7 +259,7 @@ function applyOrRefreshIntegration(
allowAbsent: boolean,
conflictPolicy: ConflictPolicy = "refuse",
): WriteOutcome {
- const pre = preflight(input);
+ const pre = preflight(input, "apply");
if (pre.failed) return pre.failed;
const { store, io, clientId, spec, target, configPath, detectDir, before, parsed, contribution, record, classified } = pre;
@@ -264,6 +272,9 @@ function applyOrRefreshIntegration(
return refuse(clientId, "non_loopback", classified.state,
`The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`);
}
+ if (clientId === "droid" && contribution.fragments.length === 0) {
+ return refuse(clientId, "unsafe", classified.state, "Factory Droid has no addressable models in the selected catalog");
+ }
/*
* The write would land, and nothing would read it.
*
@@ -429,6 +440,21 @@ function applyOrRefreshIntegration(
if (rechecked === undefined || rechecked !== before) {
return refuse(clientId, "conflict", "conflict", `${configPath} changed while applying`);
}
+ if (clientId === "kilo") {
+ const candidates = inspectKiloCandidates({ io, selectedPath: configPath, env: input.env, home: input.home });
+ if (candidates.kind === "conflict") return refuse(clientId, "conflict", "conflict",
+ `${configPath} cannot be managed while ${candidates.paths.join(", ")} also defines provider.opencodex`);
+ if (candidates.kind === "unsafe") return refuse(clientId, "unsafe", "unsafe",
+ `${candidates.path} cannot be inspected safely (${candidates.why})`);
+ }
+ if (clientId === "droid") {
+ try {
+ assertDroidPathsUnambiguous(detectDir, exportContextOf(input));
+ } catch (error) {
+ if (!(error instanceof ClientPathError)) throw error;
+ return refuse(clientId, "unsafe", "unsafe", messageOf(error));
+ }
+ }
const opId = newOpId();
const snapshot = store.captureSnapshot(clientId, opId, before);
@@ -497,7 +523,7 @@ export function refreshIntegration(input: IntegrationWriteInput): WriteOutcome {
}
export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
- const pre = preflight(input);
+ const pre = preflight(input, "disable");
if (pre.failed) return pre.failed;
const { store, io, clientId, spec, target, configPath, before, parsed, record, classified } = pre;
@@ -625,6 +651,21 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
return refuse(clientId, "conflict", "conflict",
`that operation was recorded for ${configPath}, which this client no longer writes; it now resolves to ${resolvedPath}`);
}
+ /*
+ * Preview refuses this in observeRestore. Refusing here too is what keeps an
+ * undo of an older candidate from replacing the record a newer candidate owns.
+ */
+ const currentOwner = store.readRecords()[clientId] ?? null;
+ const collision = restoreOwnershipCollision({
+ clientId,
+ journaledPath: configPath,
+ currentPath: currentOwner && currentOwner.clientId === clientId ? currentOwner.configPath : null,
+ env: input.env,
+ home: input.home,
+ });
+ if (collision !== null) {
+ return refuse(clientId, "conflict", "conflict", collision);
+ }
if (clientId === "cline") {
try { io = createClineIO(io, configPath, store, true); }
catch (error) {
@@ -763,6 +804,14 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
const store = input.store ?? createIntegrationStateStore();
const io = input.io ?? defaultIntegrationIO(store);
const spec = INTEGRATION_CLIENTS[input.clientId];
+ const config = detachedConfigSnapshot(input.config);
+ if (config === null) {
+ throw new UncopyableIntegrationInputError("the proxy configuration could not be captured for this write");
+ }
+ const models = copyPlainData(input.models);
+ if (!models.ok) {
+ throw new UncopyableIntegrationInputError("the model roster could not be captured for this write");
+ }
/*
* One resolution for both paths. Aside derives them from the account id in
* its manifest, so two independent calls could verify one account's install
@@ -770,7 +819,7 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
*/
const resolvedPaths = input.resolvedPaths
? { ...input.resolvedPaths }
- : resolveIntegrationPaths(input.clientId, env, home);
+ : resolveIntegrationPaths(input.clientId, env, home, exportContextOf({ ...input, config, models: models.value }));
/*
* The configuration and the roster are seams like the others, and they were the two still held
* by reference. A coordinated write plans from this input, awaits the writer lock and a
@@ -779,14 +828,6 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
* checked in one state and written from another, which is the substitution the fingerprint
* exists to prevent. Copying both here gives the plan and the document one input.
*/
- const config = detachedConfigSnapshot(input.config);
- if (config === null) {
- throw new UncopyableIntegrationInputError("the proxy configuration could not be captured for this write");
- }
- const models = copyPlainData(input.models);
- if (!models.ok) {
- throw new UncopyableIntegrationInputError("the model roster could not be captured for this write");
- }
return { ...input, config, models: models.value, env, home, store, io, resolvedPaths };
}
diff --git a/src/lib/jsonc.ts b/src/lib/jsonc.ts
new file mode 100644
index 00000000000..dda3c392eb7
--- /dev/null
+++ b/src/lib/jsonc.ts
@@ -0,0 +1,110 @@
+/**
+ * JSONC parse used by OpenCode's launcher and Kilo's managed writer.
+ *
+ * Comments and trailing commas are stripped by escape-aware passes that are
+ * identity on valid strict JSON, so no strict-parse probe is ever needed.
+ */
+
+/**
+ * Strip `//` and block comments outside string literals. Escape-aware so a quote inside
+ * an escaped sequence cannot flip string state and expose config text to the stripper.
+ */
+function stripJsonComments(text: string): string {
+ let out = "";
+ let inString = false;
+ let inLine = false;
+ let inBlock = false;
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i]!;
+ const next = text[i + 1];
+ if (inLine) {
+ if (ch === "\n") {
+ inLine = false;
+ out += ch;
+ }
+ continue;
+ }
+ if (inBlock) {
+ // Newlines are preserved so JSON.parse error positions stay meaningful.
+ if (ch === "\n") out += ch;
+ else if (ch === "*" && next === "/") {
+ // Emit a separator, not nothing: a block comment between two digits is
+ // two tokens and must not collapse into one, which would silently
+ // change a malformed value into a different valid one. Whitespace is
+ // legal wherever a comment was, so this is identity for valid JSONC.
+ out += " ";
+ inBlock = false;
+ i++;
+ }
+ continue;
+ }
+ if (inString) {
+ out += ch;
+ if (ch === "\\") {
+ const escaped = text[i + 1];
+ if (escaped !== undefined) { out += escaped; i++; }
+ continue;
+ }
+ if (ch === "\"") inString = false;
+ continue;
+ }
+ if (ch === "\"") { inString = true; out += ch; continue; }
+ if (ch === "/" && next === "/") { inLine = true; i++; continue; }
+ if (ch === "/" && next === "*") { inBlock = true; i++; continue; }
+ out += ch;
+ }
+ /*
+ * An unterminated block comment means the remainder of the document was
+ * comment text. Returning it stripped would let a trailing `/*` delete an
+ * arbitrary malformed tail, so the caller sees a parse failure instead.
+ */
+ if (inBlock) throw new SyntaxError("Unterminated block comment");
+ return out;
+}
+
+/** Drop commas that sit directly before `}` or `]`, ignoring string contents. */
+function stripTrailingCommas(text: string): string {
+ let out = "";
+ let inString = false;
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i]!;
+ if (inString) {
+ out += ch;
+ if (ch === "\\") {
+ const escaped = text[i + 1];
+ if (escaped !== undefined) { out += escaped; i++; }
+ continue;
+ }
+ if (ch === "\"") inString = false;
+ continue;
+ }
+ if (ch === "\"") { inString = true; out += ch; continue; }
+ if (ch === ",") {
+ let j = i + 1;
+ while (j < text.length && /\s/.test(text[j]!)) j++;
+ if (text[j] === "}" || text[j] === "]") continue;
+ }
+ out += ch;
+ }
+ return out;
+}
+
+/**
+ * Return JSON that `JSON.parse` will accept. Comments and trailing commas are
+ * stripped unconditionally: never probed with a strict `JSON.parse` first,
+ * because materializing a deeply nested document before the rewrite guard's
+ * depth ceiling would bypass that guard's resource contract. The passes are
+ * identity on valid strict JSON — `//`, `/*`, and a comma before `}` or `]`
+ * can only appear inside strings there, which the strippers never touch.
+ */
+export function canonicalizeJsonc(text: string): string {
+ return stripTrailingCommas(stripJsonComments(text));
+}
+
+/**
+ * JSON with optional comments and trailing commas. Same stripping rules as
+ * `canonicalizeJsonc`; throws on text that is still not JSON afterwards.
+ */
+export function parseJsonc(text: string): unknown {
+ return JSON.parse(canonicalizeJsonc(text));
+}
diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts
index c0cb63a2d2b..3c802aba67e 100644
--- a/src/server/management/combo-routes.ts
+++ b/src/server/management/combo-routes.ts
@@ -244,9 +244,11 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise
- lastResort ? { ...target, lastResort: true } : target,
- ),
+ targets: normalizedBase.targets.map(({ lastResort, modelProfile, ...target }) => ({
+ ...target,
+ ...(lastResort ? { lastResort: true } : {}),
+ ...(modelProfile ? { modelProfile } : {}),
+ })),
...(normalizedAlias ? { alias: normalizedAlias } : {}),
...(normalizedNativeAlias ? { nativeAlias: true } : {}),
...(normalizedDisplayName ? { displayName: normalizedDisplayName } : {}),
diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts
index 45cfee41f76..2e5b7f2d2e4 100644
--- a/src/server/management/config-routes.ts
+++ b/src/server/management/config-routes.ts
@@ -1,4 +1,4 @@
-import { compactionRoutingSchema } from "../../config/schema/leaf-validators";
+import { compactionRoutingSchema, memoryModelsSchema } from "../../config/schema/leaf-validators";
import { compactionRecoverySchema } from "../../config/schema/compaction-recovery";
import { captureConfigTopLevelRollback } from "../../config/rebase-provenance";
import type { IntegrationClientId } from "../../integrations/registry";
@@ -272,7 +272,7 @@ export async function syncEnabledClientIntegrations(
},
config,
port,
- }, ["mcode", "pi", "aside", "raycast", "omo", "cline"]));
+ }, ["mcode", "pi", "aside", "raycast", "omo", "cline", "droid"]));
return out;
}
@@ -374,6 +374,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise${applied.to}` : applied.to;
+ if (isInjectionDebugEnabled()) {
+ injectionDebugLog(`[opencodex] ${route.modelId}: memory ${phase} effort applied (${applied.from ?? "none"} -> ${applied.to})`);
+ }
+ }
+ }
+ }
+
{
const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("../effort-policy");
const surface = collabSurface(parsed);
diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts
index a09bf122941..aea7f850800 100644
--- a/src/server/responses/core-options.ts
+++ b/src/server/responses/core-options.ts
@@ -145,6 +145,8 @@ export interface HandleResponsesOptions {
comboAttempt?: boolean;
/** Internal handoff: this combo was selected by shadow-call interception. */
shadowCallIntercepted?: boolean;
+ /** Internal handoff: the memory phase this turn belongs to, so combo children keep its routing. */
+ memoryModelPhase?: "extract" | "consolidation";
compactionRoutingOverride?: CompactionRoutingOverride | null;
/** Internal combo handoff for one parent-validated continuation snapshot. */
comboReplaySnapshot?: {
diff --git a/src/server/responses/memory-models.ts b/src/server/responses/memory-models.ts
new file mode 100644
index 00000000000..53fc0ebce48
--- /dev/null
+++ b/src/server/responses/memory-models.ts
@@ -0,0 +1,169 @@
+/**
+ * Model routing for Codex's own memory pipeline.
+ *
+ * Codex writes memories in two background phases, and both ask the provider for a bare native
+ * model: Phase 1 ("extract") summarizes one finished thread per call and asks for
+ * `gpt-5.6-luna` at effort `low`; Phase 2 ("consolidation") is one agent run that merges those
+ * summaries into the files under `$CODEX_HOME/memories` and asks for `gpt-5.6-terra` at effort
+ * `medium`. Without a configured target each keeps its existing route; Phase 1 additionally looks like the app's
+ * title/commit helper traffic, because the app uses the same model id for those.
+ *
+ * A phase is therefore recognized from Codex's own turn metadata, never inferred from the model
+ * id, the timing, or the token counts. Phase 1 sends `request_kind: "memory"`; both phases carry
+ * `thread_source: "memory_consolidation"`, and Phase 2 additionally arrives with
+ * `x-openai-subagent: memory_consolidation` (codex-rs `core/src/responses_metadata.rs`).
+ */
+import type { OcxConfig, OcxParsedRequest } from "../../types";
+import { isDeclaredReasoningEffort } from "../../reasoning-effort";
+
+/** The two phases Codex runs, in the order it runs them. */
+export type MemoryModelPhase = "extract" | "consolidation";
+
+/** codex-rs serializes both keys below into the JSON `x-codex-turn-metadata` header. */
+const TURN_METADATA_HEADER = "x-codex-turn-metadata";
+const REQUEST_KIND_KEY = "request_kind";
+const THREAD_SOURCE_KEY = "thread_source";
+/** `CodexResponsesRequestKind::Memory` (codex-rs `core/src/responses_metadata.rs`). */
+const MEMORY_REQUEST_KIND = "memory";
+/** `ThreadSource::MemoryConsolidation` / `InternalSessionSource::MemoryConsolidation`. */
+const MEMORY_THREAD_SOURCE = "memory_consolidation";
+const SUBAGENT_HEADER = "x-openai-subagent";
+
+function record(value: unknown): Record | undefined {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : undefined;
+}
+
+/** One metadata copy's verdict. `"none"` is a well-formed copy that is not a memory turn. */
+type CopyVerdict = MemoryModelPhase | "none";
+
+function verdictOf(parsed: Record): CopyVerdict {
+ // Phase 1's detached request names the memory kind explicitly. Phase 2 is an ordinary turn
+ // inside the `memory_consolidation` thread, so its thread source is the only signal there.
+ if (parsed[REQUEST_KIND_KEY] === MEMORY_REQUEST_KIND) return "extract";
+ if (parsed[THREAD_SOURCE_KEY] === MEMORY_THREAD_SOURCE) return "consolidation";
+ return "none";
+}
+
+/**
+ * Recognize a memory-pipeline turn, or null.
+ *
+ * Every copy of the turn metadata the request carries must agree — the same rule
+ * `applyCompactionRoutingOverride` applies to compaction turns: a request that contradicts itself
+ * is not a memory turn, so neither copy can widen what the setting covers. On HTTP the sub-agent
+ * header is accepted on its own because Codex may deliver only that copy; on websocket it is not,
+ * because the bridge re-attaches the handshake's header to every frame, so there it marks the
+ * connection rather than the turn and the per-frame metadata decides alone.
+ */
+export function detectMemoryModelPhase(
+ body: unknown,
+ headers: Headers,
+ options: { transport?: "websocket" } = {},
+): MemoryModelPhase | null {
+ const metadata: unknown[] = [];
+ const header = headers.get(TURN_METADATA_HEADER);
+ if (options.transport !== "websocket" && header !== null) metadata.push(header);
+ const bodyRecord = record(body);
+ const rawClient = bodyRecord?.["client_metadata"];
+ // Present but malformed client metadata is a turn that failed validation, not an absent
+ // copy; it must not fall through to the connection-level sub-agent header below.
+ if (bodyRecord && Object.hasOwn(bodyRecord, "client_metadata") && !record(rawClient)) return null;
+ const client = record(rawClient);
+ if (client && Object.hasOwn(client, TURN_METADATA_HEADER)) metadata.push(client[TURN_METADATA_HEADER]);
+
+ let verdict: CopyVerdict | null = null;
+ for (const value of metadata) {
+ if (typeof value !== "string") return null;
+ let parsed: Record | undefined;
+ try {
+ parsed = record(JSON.parse(value));
+ } catch {
+ return null;
+ }
+ if (!parsed) return null;
+ const copy = verdictOf(parsed);
+ if (verdict !== null && verdict !== copy) return null;
+ verdict = copy;
+ }
+ if (verdict === "extract" || verdict === "consolidation") return verdict;
+ // A validated non-memory turn is an explicit decision. The connection-level sub-agent
+ // header is only a compatibility signal when turn metadata is wholly absent.
+ if (verdict === "none") return null;
+ // The websocket bridge rebuilds internal requests from a header allowlist and re-attaches the
+ // handshake's sub-agent header to every frame. Trusting it here would sweep the connection's
+ // later ordinary turns into the consolidation phase, so websocket frames rely on the per-frame
+ // turn metadata above and nothing else.
+ if (options.transport === "websocket") return null;
+ return headers.get(SUBAGENT_HEADER) === MEMORY_THREAD_SOURCE ? "consolidation" : null;
+}
+
+/** The configured destination for one phase, or undefined while the phase keeps Codex's choice. */
+export function configuredMemoryModel(
+ config: Pick | undefined,
+ phase: MemoryModelPhase,
+): { model: string; reasoningEffort?: string } | undefined {
+ const setting = config?.memoryModels?.[phase];
+ if (!setting) return undefined;
+ const model = typeof setting.model === "string" ? setting.model.trim() : "";
+ if (!model) return undefined;
+ const effort = typeof setting.reasoningEffort === "string" ? setting.reasoningEffort : undefined;
+ return { model, ...(effort ? { reasoningEffort: effort } : {}) };
+}
+
+/**
+ * Force the configured effort onto a memory turn.
+ *
+ * Codex hard-codes the phase effort (`low` for Phase 1, `medium` for Phase 2) and has no config
+ * key for it, so this is the only place the operator's choice can land. Both wire shapes are
+ * written: `parsed.options.reasoning` feeds the routed adapters, `_rawBody.reasoning.effort` feeds
+ * the ChatGPT passthrough serializer — the same dual-shape contract `applyPinnedEffort` uses.
+ */
+export function applyMemoryModelEffort(
+ parsed: OcxParsedRequest,
+ config: Pick | undefined,
+ phase: MemoryModelPhase,
+): { from: string | undefined; to: string } | null {
+ const effort = configuredMemoryModel(config, phase)?.reasoningEffort;
+ if (!effort || !isDeclaredReasoningEffort(effort)) return null;
+ const requested = parsed.options.reasoning;
+ if (requested === effort) return null;
+ parsed.options.reasoning = effort;
+ const raw = parsed._rawBody as { reasoning?: { effort?: string } } | undefined;
+ if (raw && typeof raw === "object") {
+ raw.reasoning = { ...(record(raw.reasoning) ?? {}), effort } as { effort?: string };
+ }
+ return { from: requested, to: effort };
+}
+
+/** Route reason recorded for a routed memory turn, so the request log names the phase. */
+export function memoryModelRouteReason(phase: MemoryModelPhase): string {
+ return phase === "extract" ? "memory-extract" : "memory-consolidation";
+}
+
+/** Non-retryable: the target stays unavailable until the operator changes the setting. */
+export const MEMORY_MODEL_TARGET_UNAVAILABLE_CODE = "memory_model_target_unavailable";
+export const MEMORY_MODEL_TARGET_UNAVAILABLE_STATUS = 409;
+
+const warnedPhases = new Set();
+
+/**
+ * A configured phase destination that stopped resolving fails its call once, clearly, instead of
+ * silently falling back to the native model the operator routed away from — the same contract the
+ * shadow intercept uses for its single target.
+ */
+export function memoryModelTargetUnavailableResponse(
+ phase: MemoryModelPhase,
+): Response {
+ // A hand-edited model id or resolver detail may contain a credential-bearing URL. Neither
+ // belongs in logs or the client error; the settings panel already displays the saved target.
+ const message = `Memory ${phase} model is unavailable. Choose another model in Memory routing or re-enable its provider.`;
+ if (!warnedPhases.has(phase)) {
+ warnedPhases.add(phase);
+ console.warn(`memory-models: ${message}`);
+ }
+ return new Response(
+ JSON.stringify({ error: { message, type: "invalid_request_error", code: MEMORY_MODEL_TARGET_UNAVAILABLE_CODE } }),
+ { status: MEMORY_MODEL_TARGET_UNAVAILABLE_STATUS, headers: { "Content-Type": "application/json" } },
+ );
+}
diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts
index 2ac1679a72f..60e0ed6fa38 100644
--- a/src/server/responses/request-prepare.ts
+++ b/src/server/responses/request-prepare.ts
@@ -13,7 +13,14 @@ import {
} from "./core-errors";
import { parseSyntheticRowId } from "../fast-row";
import { resolveComboId, comboIdFromRawBody, NoAvailableComboTargetsError } from "../../combos";
-import { INTERCEPT_TARGET_UNAVAILABLE_CODE, interceptTargetUnavailableResponse, resolveShadowCallTarget } from "./shadow-target-availability";
+import { INTERCEPT_TARGET_UNAVAILABLE_CODE, interceptTargetUnavailableResponse, resolveChosenTarget, resolveShadowCallTarget } from "./shadow-target-availability";
+import {
+ MEMORY_MODEL_TARGET_UNAVAILABLE_CODE,
+ configuredMemoryModel,
+ detectMemoryModelPhase,
+ memoryModelRouteReason,
+ memoryModelTargetUnavailableResponse,
+} from "./memory-models";
import { recallComboForLane } from "./combo-session-recall";
import {
sessionLaneIdFromRequest,
@@ -175,6 +182,20 @@ export async function prepareResponsesRequest(
transport: options.inboundTransport,
});
}
+ // Codex's memory pipeline names a destination per phase. The phase is read from Codex's own turn
+ // metadata, never from the model id: Phase 1 shares `gpt-5.6-luna` with the app's title/commit
+ // helper calls. Read here, ahead of the shadow intercept below, because the phase decision is the
+ // more specific of the two settings and must be the one that survives when both match one request.
+ const memoryModelPhase = options.memoryModelPhase
+ ?? ((config.memoryModels?.extract || config.memoryModels?.consolidation)
+ && !options.comboAttempt && !options.compactionRoutingOverride && inboundWire === "responses"
+ ? detectMemoryModelPhase(body, req.headers, { transport: options.inboundTransport }) ?? undefined
+ : undefined);
+ const memoryModelTarget = memoryModelPhase ? configuredMemoryModel(config, memoryModelPhase) : undefined;
+ // A combo child is a synthetic replay of the parent's decision: its model is already the target's
+ // concrete provider/model, so neither site below may rewrite or re-resolve it. It keeps the phase
+ // through `options.memoryModelPhase` instead, which is what applies the phase effort.
+ const memoryModelApplies = memoryModelTarget !== undefined && options.comboAttempt !== true;
options.onRequestBodyParsed?.(body);
// An effort row naming a table-less combo (`combo/x--high`) must reach the combo dispatcher
// as its base id, so the selector is normalized here, before comboIdFromRawBody reads model.
@@ -229,11 +250,22 @@ export async function prepareResponsesRequest(
// hops — which only exist inside that loop — are unreachable (#4129). Rewrite the selector
// here instead, before comboIdFromRawBody reads `model`, and identify the combo by CONFIG
// LOOKUP so the check can never observe a one-candidate collapse.
+ // A memory target that names a combo has to reach the combo dispatcher as `model`, or its own
+ // failover loop is unreachable (#4129) — the same reason the shadow intercept rewrites its combo
+ // target here. Every other target is resolved at the late site, where the admission scope exists.
+ let memoryModelComboRouted = false;
+ if (memoryModelApplies && memoryModelTarget && body && typeof body === "object" && !Array.isArray(body)) {
+ const memoryComboId = resolveComboId(config, memoryModelTarget.model);
+ if (memoryComboId && Object.hasOwn(config.combos ?? {}, memoryComboId)) {
+ memoryModelComboRouted = true;
+ (body as Record).model = memoryModelTarget.model;
+ }
+ }
let shadowCallIntercepted = false;
// A spawned sub-agent turn names its model on purpose; gpt-6-luna is both the helper
// slug and a default sub-agent model, so neither intercept site may rewrite that turn.
const threadSpawn = isThreadSpawnRequest(req.headers);
- if (!options.comboAttempt && !options.compactionRoutingOverride && !threadSpawn && body && typeof body === "object" && !Array.isArray(body)) {
+ if (!options.comboAttempt && !options.compactionRoutingOverride && !threadSpawn && !memoryModelApplies && body && typeof body === "object" && !Array.isArray(body)) {
const shadowIntercept = config.shadowCallIntercept;
const rawShadowModel = (body as { model?: unknown }).model;
if (shadowIntercept?.enabled && shadowIntercept.model && typeof rawShadowModel === "string"
@@ -259,6 +291,9 @@ export async function prepareResponsesRequest(
// Concrete combo child selectors no longer match the shadow source model. Carry the
// interception decision explicitly so provider-specific helper isolation still applies.
shadowCallIntercepted,
+ // Same handoff for a memory phase whose target is a combo: the child keeps the phase's effort
+ // override and stays out of the parent conversation.
+ memoryModelPhase: memoryModelComboRouted ? memoryModelPhase : undefined,
// The original request body was accepted above. Combo children are synthetic
// replays and must not repeat the caller-owned timeout transition.
onRequestBodyRead: undefined,
@@ -391,6 +426,10 @@ export async function prepareResponsesRequest(
}
if (cursorClientThreadId) parsed._cursorClientThreadId = cursorClientThreadId;
if (options.shadowCallIntercepted === true) parsed._cursorIsolateConversation = true;
+ if (options.memoryModelPhase !== undefined) {
+ parsed._memoryModelPhase = options.memoryModelPhase;
+ parsed._cursorIsolateConversation = true;
+ }
} catch (err) {
if (isTranslatorBudgetExceededError(err)) {
return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", {
@@ -494,9 +533,28 @@ export async function prepareResponsesRequest(
: parsed._compactionRequest === true
? routeCompactionModel(config, modelId, evidenceFromBody(parsed._rawBody))
: routeModel(config, modelId, evidenceFromBody(parsed._rawBody)));
+ // The phase's destination. Resolved through the admission-scoped resolver every other route
+ // uses, and it fails closed exactly like the shadow target: falling back to the native model
+ // would spend the quota the operator routed away from, without their choosing it.
+ let memoryRoute: RouteResult | undefined;
+ if (memoryModelApplies && memoryModelPhase && memoryModelTarget) {
+ const memoryTarget = resolveChosenTarget(memoryModelTarget.model, resolveRoute);
+ if ("unavailable" in memoryTarget) {
+ logCtx.errorCode = MEMORY_MODEL_TARGET_UNAVAILABLE_CODE;
+ return memoryModelTargetUnavailableResponse(memoryModelPhase);
+ }
+ credentialDomainWasRewritten = true;
+ parsed.modelId = memoryModelTarget.model;
+ if (parsed._rawBody && typeof parsed._rawBody === "object") {
+ (parsed._rawBody as { model?: string }).model = memoryModelTarget.model;
+ }
+ parsed._memoryModelPhase = memoryModelPhase;
+ parsed._cursorIsolateConversation = true;
+ memoryRoute = memoryTarget.route;
+ }
const _sci = config.shadowCallIntercept;
let shadowRoute: RouteResult | undefined;
- if (!options.compactionRoutingOverride && !threadSpawn && _sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
+ if (!memoryRoute && !options.memoryModelPhase && !options.compactionRoutingOverride && !threadSpawn && _sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
const sourcePrefix = shadowSourceModelPrefix(parsed.modelId, _sci.sourceModels)!;
let sourceIdentity = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourcePrefix };
try {
@@ -533,7 +591,20 @@ export async function prepareResponsesRequest(
}
}
if (parsed._compactionRequest === true || options.compactionRoutingOverride) parsed._cursorIsolateConversation = true;
- route = shadowRoute ?? resolveRoute(parsed.modelId);
+ route = memoryRoute ?? shadowRoute ?? resolveRoute(parsed.modelId);
+ // Name the phase in the persisted route decision, so the request log says why this turn went to
+ // the memory destination instead of leaving it looking like a plain user selection. Set here, on
+ // the resolved route, so a combo child's own route carries it too.
+ if (parsed._memoryModelPhase) {
+ const reason = memoryModelRouteReason(parsed._memoryModelPhase);
+ route.routeReason = reason;
+ if (route.routeDecision) {
+ route.routeDecision = {
+ ...route.routeDecision,
+ selected: { ...route.routeDecision.selected, reason },
+ };
+ }
+ }
if (options.compactionRoutingOverride && !compactionRoutingKeepsProviderIdentity(config, options.compactionRoutingOverride, route)) {
credentialDomainWasRewritten = true;
// The destination does not share the conversation's credential domain, so it can neither
diff --git a/src/server/responses/shadow-target-availability.ts b/src/server/responses/shadow-target-availability.ts
index 814f99c9316..4c329fb2ff9 100644
--- a/src/server/responses/shadow-target-availability.ts
+++ b/src/server/responses/shadow-target-availability.ts
@@ -16,16 +16,21 @@ export const INTERCEPT_TARGET_UNAVAILABLE_CODE = "intercept_target_unavailable";
/** Non-retryable: the target stays unavailable until the operator changes the configuration. */
export const INTERCEPT_TARGET_UNAVAILABLE_STATUS = 409;
-export type ShadowTargetResolution = { route: RouteResult } | { unavailable: string };
+export type ChosenTargetResolution = { route: RouteResult } | { unavailable: string };
+export type ShadowTargetResolution = ChosenTargetResolution;
/**
- * Resolve the configured target. Admission-scope refusals, exhausted combos and policy
+ * Resolve one operator-chosen target. Admission-scope refusals, exhausted combos and policy
* evaluations keep their existing responses, so they are rethrown to the caller's handler.
+ *
+ * Shared by the shadow-call intercept and the memory-model routing: both name a single destination
+ * whose unavailability must not be papered over by the router's terminal default-provider
+ * fallback. `shadowCallTargetsIntersect` and the memory setting are the two callers.
*/
-export function resolveShadowCallTarget(
+export function resolveChosenTarget(
model: string,
resolve: (model: string) => RouteResult,
-): ShadowTargetResolution {
+): ChosenTargetResolution {
let route: RouteResult;
try {
route = resolve(model);
@@ -44,6 +49,14 @@ export function resolveShadowCallTarget(
return { route };
}
+/** The shadow-call name for the shared resolver, kept because that is the surface's own vocabulary. */
+export function resolveShadowCallTarget(
+ model: string,
+ resolve: (model: string) => RouteResult,
+): ShadowTargetResolution {
+ return resolveChosenTarget(model, resolve);
+}
+
const warnedTargets = new Set();
export function interceptTargetUnavailableResponse(model: string, detail: string): Response {
diff --git a/src/types/config.ts b/src/types/config.ts
index 940aa2b06fd..d8bc49ee311 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -738,6 +738,27 @@ export interface OcxConfig {
};
/** Opt-in failure-only recovery; never replaces the initial compaction model. */
compactionRecovery?: { enabled: boolean; model: string; allowDevinInvalidArgument?: boolean };
+ /**
+ * Destination model for Codex's own memory pipeline, per phase
+ * (src/server/responses/memory-models.ts).
+ *
+ * Codex runs Phase 1 ("extract") once per finished thread to summarize that thread's rollout,
+ * and Phase 2 ("consolidation") once as an agent run that merges the summaries into the files
+ * under `$CODEX_HOME/memories`. Without an entry here each phase keeps its existing route,
+ * including any configured shadow-call interception.
+ *
+ * A phase is recognized from Codex's turn metadata, never inferred from the model id, the timing
+ * or the token counts: Phase 1 shares `gpt-5.6-luna` with the app's title/commit helper calls,
+ * and `shadowCallIntercept` is the setting for those. A configured phase wins over that
+ * intercept, because the memory decision is the more specific one.
+ *
+ * `model` is required for a configured phase; omitting the phase leaves its route in place.
+ * `reasoningEffort` overrides the effort Codex hard-codes for that phase.
+ */
+ memoryModels?: {
+ extract?: { model: string; reasoningEffort?: string };
+ consolidation?: { model: string; reasoningEffort?: string };
+ };
/**
* Models hidden from Codex discovery without blocking direct proxy calls. Routed provider ids
* are excluded from the catalog + /v1/models entirely. Account-qualified native ids hide only
@@ -883,10 +904,12 @@ export interface OcxConfig {
* HTTP URLs are mirrored into HTTP_PROXY/HTTPS_PROXY when unset. SOCKS5 URLs are mirrored
* into ALL_PROXY, clear inherited HTTP(S)_PROXY, and use OpenCodex's SOCKS5 transport.
* Loopback stays in NO_PROXY.
- * The literal `"auto"` reads the Windows WinINET static proxy (`ProxyEnable`/`ProxyServer`)
- * once at process start, preserving separate HTTP and HTTPS entries; on other platforms, or
- * when the system proxy is off, SOCKS-only, or unreadable, it degrades to direct egress with
- * one log line (#1525). PAC/WPAD and live changes are not followed.
+ * The literal `"auto"` reads Windows WinINET or macOS static HTTP/HTTPS proxy settings
+ * once at startup. Inherited scheme proxies win; on macOS, inherited ALL_PROXY also skips
+ * discovery. A macOS `*.` exception maps to `.` (including the apex),
+ * exact link-local CIDRs are omitted with a warning, and other unsafe exceptions
+ * refuse discovery without environment writes.
+ * PAC/WPAD, SOCKS-only settings, and live changes are not followed.
*/
proxy?: string;
/**
@@ -1240,6 +1263,11 @@ export interface OcxComboTarget {
* target currently advertises; an explicit list must be non-empty.
*/
reasoningEfforts?: OcxComboDefaultEffort[];
+ /**
+ * Operator-authored capability description sent only to the JEV decision
+ * service for this target. The built-in model profile always applies.
+ */
+ modelProfile?: string;
/**
* Marks an emergency-only target. Inert unless the combo sets
* `cooldownWaitPolicy`, and never makes a target permanently ineligible —
diff --git a/src/types/request.ts b/src/types/request.ts
index 03faeb5afec..288de9383f4 100644
--- a/src/types/request.ts
+++ b/src/types/request.ts
@@ -143,6 +143,12 @@ export interface OcxParsedRequest {
_compactionRequest?: boolean;
/** Manual compaction moved to another provider: summarize portably even on a canonical ChatGPT target. */
_portableCompaction?: boolean;
+ /**
+ * Codex memory pipeline phase this turn belongs to, when `memoryModels` routes it
+ * (src/server/responses/memory-models.ts). Read at the effort choke point, which runs after the
+ * route is known.
+ */
+ _memoryModelPhase?: "extract" | "consolidation";
/**
* True when the current request newly introduced a stored compaction summary/marker. Historical
* markers restored by previous_response_id expansion were already acknowledged and do not reset
diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md
index 9bc33663d3c..ca3f614ec1e 100644
--- a/structure/clients/integrations.md
+++ b/structure/clients/integrations.md
@@ -32,6 +32,7 @@ parsing and ownership rules below.
| `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. |
| `src/integrations/config-io.ts` | Bounded file loading and parsing. Values that cannot round-trip through the target serializer are rejected before mutation. |
+| `src/integrations/kilo-candidates.ts` | Inspects all Kilo global config candidates for unsafe files and a competing `provider.opencodex` block before status or any operation that adds or replaces a block. |
| `src/integrations/state.ts` | The single `absent` / `current` / `stale` / `conflict` / `unsafe` classifier used by status and every writer operation. |
| `src/integrations/ownership.ts` | Durable ownership records: file, generated contribution, protected contribution, exact fragment paths, and operation identity. |
| `src/integrations/ownership-policy.ts` | Client-scoped declarations for fields a client is documented to derive after apply. It must never contain a broad format-wide exemption. |
@@ -39,6 +40,29 @@ parsing and ownership rules below.
| `src/integrations/mutation-plan.ts` | The shared observation both a preview and a mutation read, and the value-free plan an operator confirms. It owns no IO of its own, takes no lock, and must never import `writer.ts`. |
| `src/integrations/store.ts` / `journal.ts` | One-root persistence for ownership records, operation history, snapshots, and retention maintenance. |
+Factory Droid's explicit integration writes only documented `customModels` rows in
+`~/.factory/settings.json` (`%USERPROFILE%\\.factory\\settings.json` on Windows). Each
+row is addressed by its `model` and `OpenCodex:` prefixed `displayName`; `baseUrl`
+remains in the protected row value. Duplicate matches refuse. Rows whose model ID
+or display name cannot be represented by that selector are omitted from both the
+export document and managed fragments. A nonempty catalog that yields no rows refuses.
+Direct and management exports use the live listener policy and refuse when Droid
+would need an admission header. When a previously managed catalog becomes empty
+or wholly unaddressable, classification still checks recorded fragment paths and
+their fingerprints so disable can remove owned rows without deleting foreign edits.
+The legacy settings guard also checks the recorded model IDs and endpoints when
+those rows leave the current catalog.
+Apply and refresh still refuse an empty managed contribution.
+The builder omits `apiKey` and unsupported metadata. The shared writer snapshots
+prior bytes and refuses changed managed rows or unsafe paths. `src/integrations/droid-settings.ts` refuses
+legacy `config.json` rows that share the exported endpoint, a generated model ID, or an
+`OpenCodex:` display name, and any `customModels` override in
+`settings.local.json`, because Factory merges those files with personal settings.
+Apply and refresh repeat that competing-settings check after the target-file
+compare and before taking a snapshot. Droid has no writer lock, so a competing
+settings file can still appear after this check and before the write.
+No Droid file is written by detection or on the proxy request path.
+
## Cursor installed capability reads
`src/integrations/cursor-effort-table.ts` reads the installed agent bundle through one regular-file
@@ -173,7 +197,7 @@ All registered integrations consume the shared catalog, including [Anthropic see
| Client | Per-model output |
| --- | --- |
-| OpenCode | `attachment`, `modalities.input` |
+| OpenCode, Kilo | `attachment`, `modalities.input` |
| Pi, OMP, Prime, Aside, omo, Gajae, DSH | `input` (text/image only) |
| ZCode | `modalities.input` (text/image only) |
| Cline | `modalities.input`, `supportsVision` |
@@ -375,6 +399,44 @@ sibling policy. Profile journal views retain source-store provenance for older l
The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md).
+## Kilo global JSONC
+
+Kilo owns only `provider.opencodex` in the first existing global file among `kilo.jsonc`,
+`kilo.json`, `opencode.jsonc`, `opencode.json`, and `config.json` under `~/.config/kilo`
+(`XDG_CONFIG_HOME` relocates that directory); when none exists, the destination is
+`kilo.jsonc`. Parse accepts JSONC comments and trailing
+commas; serialize rewrites the whole file as pretty JSON, so comments in other keys are
+not preserved. Kilo is not on the implicit owned-catalog fan-out. Remote admission uses
+the same `{env:OPENCODEX_KILO_API_KEY}` / `x-opencodex-api-key` rule as OpenCode.
+All candidate files are inspected through the no-follow, bounded parser before status or
+any operation that adds or replaces a block. If another candidate defines
+`provider.opencodex`, status reports a conflict with every competing path in
+`conflictPaths`; preview/apply/overwrite refuse and name the selected and competing
+paths. An unsafe or unparseable candidate also blocks those writes. Disable instead
+classifies the recorded target and removes only a still-owned, unchanged block; a competing
+or unparseable off-target candidate remains untouched. Status retains the candidate issue,
+the recorded owner, and the unsafe candidate's path so the dashboard can offer Disable
+only when that issue is off-target. Restore uses its separate journal and drift checks.
+Apply scans the candidates again after its selected-file compare and before snapshot capture,
+so a competing file introduced during planning is refused before commit.
+
+Because that resolution depends on which candidates EXIST, a candidate created after
+apply can win discovery while the owned file still holds the block. The registry's
+opt-in `bindsDriftedRecord` seam covers exactly that case: while the recorded path is
+still one of Kilo's own candidates under the current env and home, reads and mutations
+stay bound to the recorded file (status reports it, disable removes the block from it,
+and both restore paths act on the journaled file instead of refusing) and priority
+discovery resumes only once the record is dropped. A record from a
+different home never binds, preserving the audit contract that a record for one home
+cannot authorize a write to another.
+
+Restore of a journaled candidate stays legal while that file is the current owner, and
+while no record owns the client (undoing the disable that dropped the record). It is
+refused, by both direct restore and preview, when a different Kilo candidate currently
+holds the single ownership record. Committing the older row's prior record would point
+ownership back at the old file and leave the active block on disk with nothing to
+disable it.
+
## Cline paired files
Cline CLI uses `providers.json` for connection settings and sibling `models.json` for its
diff --git a/structure/config-proxy.md b/structure/config-proxy.md
index a4c6e5af550..bf344981509 100644
--- a/structure/config-proxy.md
+++ b/structure/config-proxy.md
@@ -3,7 +3,9 @@
`src/config/proxy-env.ts` remains the single application owner for global proxy
configuration. An explicit SOCKS5 or SOCKS5h URL selects ALL_PROXY and removes
stale scheme-proxy variables; HTTP(S) settings retain their existing environment
-precedence. Activation keeps the existing Windows auto-discovery path and loopback
+precedence. Activation keeps the existing Windows auto-discovery path and adds opt-in
+macOS discovery for `proxy: "auto"`. It never consults macOS settings when any scheme
+proxy or `ALL_PROXY`/`all_proxy` is inherited. The shared path keeps loopback
NO_PROXY entries; the no-configured-proxy return merges all of them only when an inherited
SOCKS proxy is the only inherited proxy; whenever Bun applies an inherited HTTP(S) scheme proxy
or HTTP(S) `ALL_PROXY`/`all_proxy`, it matches by domain suffix, so activation adds only the
@@ -13,7 +15,7 @@ matcher treats a bare `localhost` or IP-literal entry as one host, never a suffi
`ALL_PROXY` and `all_proxy` provide SOCKS and HTTP(S) together, the SOCKS wrapper forces an exact
`localhost` request direct while keeping the address-only environment bypass. An inherited non-empty
lowercase `no_proxy`, which Bun fetch reads first with suffix matching, receives only the loopback
-addresses, never a name it would match as a suffix. When the
+addresses from the shared path; macOS auto-discovery adds its translated exceptions separately. When the
environment no longer selects SOCKS, activation
restores the native fetch; removing a saved field alone does not erase inherited
process environment variables.
@@ -29,3 +31,32 @@ userinfo is stripped while host and port stay visible, `direct` and credential-l
print unchanged, and a non-URL value that is not `direct` is masked whole. `config export`
keeps the raw file so exports can restore credentials. Get and mutation output select
redaction by the normalized final path segment, matching lookup and mutation semantics.
+
+On macOS, `src/config/macos-system-proxy.ts` reads the top-level static HTTP/HTTPS
+settings from `/usr/sbin/scutil --proxy` once, with a timeout and output bound.
+Only enabled schemes are installed. IP literals and the all-host `*` exception
+are translated. A single leading `*.` followed by a valid DNS name maps to
+`.`; Bun matches at label boundaries, so `foo.local` bypasses for
+`*.local` while `xlocal` does not. Bun also bypasses the bare apex `local`,
+the one widening of that translation. The exact link-local ranges
+`169.254/16`, `169.254.0.0/16`, and `fe80::/10` are omitted because Bun
+cannot represent them; one generic diagnostic says link-local IP literals
+use the proxy. Other CIDRs or glob forms, simple-host bypasses, PAC/WPAD,
+and malformed settings refuse discovery before any proxy-environment write.
+Accepted exceptions and configured `noProxy` entries enter both `NO_PROXY`
+and an inherited non-empty `no_proxy`, since Bun gives lowercase precedence.
+Before macOS discovery there is no inherited proxy, so Bun's suffix matching
+of an ordinary configured name can only keep that name and its subdomains on
+their pre-discovery direct route; it cannot move a host onto the proxy. When
+an inherited non-empty lowercase `no_proxy` exists and configured `noProxy`
+contains bare `localhost` (any case, with or without a trailing dot), discovery
+refuses before any environment write. Bun cannot represent that exact-host
+bypass in lowercase: adding it would also bypass `app.localhost`, while omitting
+it would send exact `localhost` through the new proxy. Without inherited
+lowercase `no_proxy`, the existing uppercase-only merge remains. This applies
+only to macOS discovery, not inherited or explicit proxy activation. For
+loopback, only addresses are appended, never an automatic bare `localhost` suffix.
+Inherited SOCKS routes keep their existing uppercase bypass semantics and do
+not receive macOS exceptions. The diagnostic reports a category, never raw
+settings or credential-bearing URLs. Regression cases live in
+`tests/server/proxy-env-macos.test.ts`.
diff --git a/structure/config.md b/structure/config.md
index e476b51a00a..cf0f065355d 100644
--- a/structure/config.md
+++ b/structure/config.md
@@ -98,7 +98,7 @@ A schema-invalid top-level JSON value is repairable only when it is a non-array
| Listener | `port`, `hostname` | The listener owns the port; `runtime-port.json` reports where it actually landed. |
| Routing | `defaultProvider`, `providers`, per-provider `selectedModels`, `combos` | Explicit `provider/model` wins over `defaultProvider`; combo dispatch uses the selected target's existing capability ladder and does not create a second catalog authority. For Kiro OAuth, the management API validates `providers.kiro.oauthAccountFailover.strategy` (`least-loaded`) and `maxConcurrentPerAccount` (1–100) only for Kiro; the cap persists, while active lease counts remain process-local. |
| Request pacing | `providers..requestPacing`, `requestPacing.models.` | Optional client-side request-start pacing supports interval limits and positive-integer `maxConcurrentRequests` caps. A provider or model rule may be concurrency-only; model entries target exact upstream IDs and can only add delay or narrow concurrency. |
-| Compaction routing | `compactionRouting.model`, optional `compactionRouting.reasoningEffort`, optional `compactionRouting.triggers` | Explicit Codex compaction metadata whose `compaction.trigger` is one the block names activates a request-local override; `triggers` defaults to `["manual"]`. See [Responses compaction](transports/responses-failover.md#compaction-routing-overrides). Invalid hand edits disable the block with a load warning without discarding providers; candidate writes reject invalid blocks. |
+| Compaction and memory routing | `compactionRouting.model`, optional `compactionRouting.reasoningEffort`, optional `compactionRouting.triggers`; `memoryModels.extract`, `memoryModels.consolidation` | Explicit Codex compaction metadata whose `compaction.trigger` is one the block names activates a request-local override; `triggers` defaults to `["manual"]`. See [Responses compaction](transports/responses-failover.md#compaction-routing-overrides). Invalid hand edits disable the block with a load warning without discarding providers; candidate writes reject invalid blocks. Each optional memory phase names a nonblank model and optional declared `reasoningEffort`; absence preserves the current route, candidate writes reject malformed values, and load degrades only the invalid phase with a warning. See [memory phase routing](transports/responses-failover.md#memory-phase-routing). |
| Catalog | `disabledModels`, `customModels`, `modelCacheTtlMs`, `providerContextCaps`, `contextCapValue`, per-provider `modelDisplayNames`, `codexAccountNamespaces`, `codexAccountPickerEnabled` | Catalog state is derived; config only records intent. Exact provider model display names are durable display only overlays. The picker flag is an explicit visibility override, while selector mappings remain the durable exact-routing contract. |
| Retained state | `appOwnedMemoryBudgetMb` | Process-wide eviction target for app-owned logs, caches, blobs, and continuation payloads. Default 256 MiB, valid 64..4096; pinned state may temporarily exceed the target, but every pin-capable store has a finite local cap and their documented aggregate stays below `APP_OWNED_WORST_CASE_PINNED_BYTES` (512 MiB). Neither value caps RSS or native runtime memory. |
| Spend | `spend.root`, `spend.identity`, `spend.pool`, `spend.retentionDays` | Durable token ceilings for the spend-reservation ledger. Absent is the default and means observe-only accounting: spend is still journaled and nothing is refused, so observe-only and enforced servers take the same state-directory writer lease. One live process may write one directory; explicit sibling instances need separate `OPENCODEX_HOME` directories. There is no default figure for any scope — the ledger is on by default, so a shipped ceiling would refuse real traffic on upgrade against a number nobody chose. Strictly validated and positive-integer only, because 0 would read as a budget and refuse everything; a malformed section degrades to no ceiling, which is why the write path rejects it and load diagnostics report it. Resolution and application live in `src/lib/spend-reservation-ledger.ts`; see [`transports/responses.md`](transports/responses.md). |
diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md
index c9b5838459c..f714a5b03a9 100644
--- a/structure/gui-and-management-api.md
+++ b/structure/gui-and-management-api.md
@@ -15,6 +15,12 @@ Native steering follows [the shared WebSocket contract](transports/streaming-hea
The shared server request path follows the Responses
[core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [priority failback](providers/openai-accounts.md#ongoing-priority-failback) preference adds no new dashboard control or account-eligibility override.
+The Overview Memory routing panel reads and saves optional per-phase `memoryModels` through
+`src/server/management/config-routes.ts`. Settings GET and PUT echo the persisted block; PUT
+rejects malformed targets without dropping unrelated config. An unknown saved model remains
+visible for correction, and disabling a phase removes its effort setting. The route contract is
+defined by [memory phase routing](transports/responses-failover.md#memory-phase-routing).
+
The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages)
is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses-wire-shapes.md#passthrough-sse-stream-shapes-314). Management provider-validation calls use the [initialization-independent relative send-path validation](config.md#provider-relative-send-paths) before persistence. Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](runtime.md#lifecycle).
diff --git a/structure/overview.md b/structure/overview.md
index c31d9d628e9..390e9d215b9 100644
--- a/structure/overview.md
+++ b/structure/overview.md
@@ -136,8 +136,9 @@ still cover the rule, which is a judgement only review makes.
unchanged. The service-stop and uninstall paths of the same promise are covered
separately in `tests/cli/restore-completes-shared-teardown.test.ts` and are not bound to this id.
Enforced by `tests/codex-integration/codex-catalog-restore.test.ts`.
-- **INV-TESTS-01** — `tests/` is organised by domain (`tests//`, mirroring `src/`); the map
- is `scripts/test-layout/layout.json` and `tests/test-layout.test.ts` rejects a test outside its
+- **INV-TESTS-01** — `tests/` is organised by domain (`tests//`, mirroring `src/`); the explicit map
+ is `scripts/test-layout/layout.json`, with regex seeds and migration state in
+ `scripts/test-layout/seeds.json`, and `tests/test-layout.test.ts` rejects a test outside its
domain. Only the two layout guards sit at the root. Source-oracle tests reach the repository
through `tests/helpers/repo-root.ts`, never `import.meta.dir + "/.."`. Provider additions register
their focused test in both the explicit layout map and its expected-map fixture.
diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md
index fc9089732dd..7bcda7886b7 100644
--- a/structure/providers-and-adapters.md
+++ b/structure/providers-and-adapters.md
@@ -228,6 +228,16 @@ and caller-cancellation propagation. Missing credentials or safe state, transpor
answers fail open to the first eligible target; no response can escape the configured choice map.
Telemetry never retains extracted state or credentials.
+The optional `targets[].modelProfile` note is validated at the Combo management input
+boundary to a non-empty string of at most 512 characters; tab, line feed and carriage
+return are allowed for multi-line notes, every other C0 control character and DEL is
+refused, and the value is stored sparsely.
+`src/combos/jev.ts` sends a configured target note as `state.operator_notes` on a
+JEV decision, keyed by target; built-in `instructions.model_profiles` and the
+target/effort allowlist stay authoritative. The note reaches TypeSafe with each
+applicable decision, so operators must keep secrets and private paths out of it.
+An absent note leaves the prior decision payload shape intact.
+
`src/server/responses/core-combo.ts` computes current eligibility, asks JEV once for the initial pick,
applies the validated effort, and removes caller `service_tier` for that child. A retryable child
failure re-enters the ordinary Combo fallback loop from the untouched request without another JEV
@@ -481,5 +491,10 @@ same way. `src/types/wire.ts` owns accepted wire enumerations such as the per-pr
HTTP-version pin, shared by the config load schema, the management write boundary, and the fetch
runtime, so no boundary accepts a value another rejects.
+`src/types/config.ts` declares the optional per-phase `memoryModels` setting;
+`src/types/request.ts` carries the selected phase through combo handoffs without changing the
+public request model. [Memory phase routing](transports/responses-failover.md#memory-phase-routing)
+owns the selection rule.
+
Preflight heartbeat retention keeps `replayUnsafe` sticky in the replayed tail, so a second
preflight cannot forget earlier side effects after the original marker is evicted.
diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md
index 0d5d4a310fd..9b99e83567a 100644
--- a/structure/transports/responses-failover.md
+++ b/structure/transports/responses-failover.md
@@ -277,6 +277,20 @@ The [explicit model-capability contract](../config.md#explicit-per-model-capabil
Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. Unicode pattern normalization uses [copy-on-write traversal](byte-accounting.md#unicode-pattern-normalization) while preserving the existing schema and wire semantics.
+## Memory phase routing
+
+`src/server/responses/memory-models.ts` classifies Codex memory turns from validated
+`x-codex-turn-metadata` in HTTP headers or per-frame WebSocket `client_metadata`.
+`request_kind: "memory"` selects extract; `thread_source: "memory_consolidation"`
+selects consolidation. Supplied copies must agree. Explicit non-memory metadata blocks the
+HTTP `x-openai-subagent: memory_consolidation` fallback, which applies only when turn metadata
+is absent. WebSocket frames never use that handshake fallback. A configured phase in
+`memoryModels` wins over shadow-call interception; an unset phase keeps its existing route.
+Unavailable targets return 409 without contacting a different provider, while scoped API-key
+admission keeps its own refusal. Combo children retain the phase and its optional effort.
+The selected route decision records `memory-extract` or `memory-consolidation` as its reason,
+including when the destination is a combo, so request history names the phase that chose it.
+
## Compaction routing overrides
`src/server/responses/compaction-routing.ts` applies `compactionRouting` before model routing in
diff --git a/structure/transports/responses.md b/structure/transports/responses.md
index 41d2d6b8cc7..ca4e94d4d7a 100644
--- a/structure/transports/responses.md
+++ b/structure/transports/responses.md
@@ -4,14 +4,14 @@ Native result continuations and function-result injection follow [the mode-speci
Native steering follows [the shared WebSocket contract](streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged.
-The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages)
-is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Management provider-validation calls use the [initialization-independent relative send-path validation](../config.md#provider-relative-send-paths) before persistence. Cursor's localized native-shell names follow the [routing-commentary guard contract](../providers/cursor.md#cursor-native-exec).
+The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Management provider-validation calls use the [initialization-independent relative send-path validation](../config.md#provider-relative-send-paths) before persistence. Cursor's localized native-shell names follow the [routing-commentary guard contract](../providers/cursor.md#cursor-native-exec).
Plaintext collaboration restoration treats a null namespace as absent, rejects non-string namespace types, and restores the native namespace/name pair before HTTP/WS delivery and continuation publication.
When a successful streamed native response has a missing or unrecognized non-JSON content type, the plaintext V2 path confirms a bounded Responses SSE prefix, under the server's `stallTimeoutSec` probe budget, before applying that restoration; an `application/json` body takes the bounded JSON path instead, and an unknown, stalled, or unreadable body retains the fail-closed response.
## Responses HTTP/SSE
-Responses request preparation stabilizes incoming `` under `skills.catalog_refresh`: `per_session` (default) reuses the first received catalog for a conversation; `per_turn` leaves the supplied catalog unchanged. Other instruction sections and user/tool content remain untouched. Requests without a reliable conversation identity bypass snapshots; shared prompt-cache cohorts are not conversation identities. Only a body with exactly one catalog block across its instructions and developer/system content takes part; two or more pass through unchanged. A known snapshot is substituted before parsing, but a new catalog is stored only when preparation reaches its success return, so a request rejected by parsing or admission pins nothing. Without a named principal, snapshots are shared by conversation id only on a server that requires no data-plane auth. Snapshots are process-local, expire after four idle hours, and use bounded LRU retention; oversized blocks bypass caching. The dashboard's `src/codex/prompt-layers.ts` and `src/codex/prompt-text-probe.ts` continue observing current files for previews and do not own session snapshots.
+
+Optional Codex memory selection enters `src/server/responses/request-prepare.ts` for HTTP and WebSocket frames, as specified in [memory phase routing](responses-failover.md#memory-phase-routing); an unset phase keeps its existing route and starts no background work. Responses request preparation stabilizes incoming `` under `skills.catalog_refresh`: `per_session` (default) reuses the first received catalog for a conversation; `per_turn` leaves the supplied catalog unchanged. Other instruction sections and user/tool content remain untouched. Requests without a reliable conversation identity bypass snapshots; shared prompt-cache cohorts are not conversation identities. Only a body with exactly one catalog block across its instructions and developer/system content takes part; two or more pass through unchanged. A known snapshot is substituted before parsing, but a new catalog is stored only when preparation reaches its success return, so a request rejected by parsing or admission pins nothing. Without a named principal, snapshots are shared by conversation id only on a server that requires no data-plane auth. Snapshots are process-local, expire after four idle hours, and use bounded LRU retention; oversized blocks bypass caching. The dashboard's `src/codex/prompt-layers.ts` and `src/codex/prompt-text-probe.ts` continue observing current files for previews and do not own session snapshots.
`/v1/responses` is the main Codex-facing endpoint. The server parses Responses input, routes to a
provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to
Responses-compatible streaming output. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding).
diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts
index 6d8a5135586..b5a8571b768 100644
--- a/tests/cli/cli-export-command.test.ts
+++ b/tests/cli/cli-export-command.test.ts
@@ -230,6 +230,16 @@ describe("ocx export --json (accept criterion 1)", () => {
expect(ids).not.toContain("banned/hidden");
expect(ids).toEqual(["anthropic/claude-opus-5", "custom/no-context", "gpt-5.6-luna"]);
});
+
+ test("Pi uses the selected catalog without changing its provenance", async () => {
+ const proxy = fakeProxy();
+ const pi = await run(["--client", "pi", "--json"], { baseUrl: proxy.baseUrl });
+ expect(pi.code).toBe(0);
+ const piRows = JSON.parse(pi.stdout) as { providers: { opencodex: { models: Array<{ id: string }> } } };
+ expect(piRows.providers.opencodex.models.map(row => row.id)).toEqual([
+ "anthropic/claude-opus-5", "custom/no-context", "gpt-5.6-luna",
+ ]);
+ });
});
describe("ocx export human output (accept criterion 2)", () => {
@@ -562,9 +572,9 @@ describe("export allowlist parity", () => {
});
});
-describe("Raycast export uses the live management admission policy", () => {
- for (const secondary of [false, true]) {
- test(`live wildcard bind with secondary=${secondary} wins over saved loopback config`, async () => {
+describe("keyless exports use the live management admission policy", () => {
+ for (const client of ["raycast", "droid"] as const) for (const secondary of [false, true]) {
+ test(`${client} live wildcard bind with secondary=${secondary} wins over saved loopback config`, async () => {
const oldHome = process.env.OPENCODEX_HOME;
const oldCodexHome = process.env.CODEX_HOME;
const root = tempDir();
@@ -583,15 +593,16 @@ describe("Raycast export uses the live management admission policy", () => {
const proxy = managementProxy(liveConfig);
const out = join(root, "providers.yaml");
writeFileSync(out, "keep existing export\n");
- const result = await run(["--client", "raycast", "--json", "--out", out, "--force"], {
+ const result = await run(["--client", client, "--json", "--out", out, "--force"], {
baseUrl: proxy.baseUrl,
// Deliberately contradict both live bind and secondary port.
config: config({ unauthenticatedLoopbackListener: { enabled: true, port: 10999 } }),
});
if (secondary) {
expect(result.code).toBe(0);
- const document = JSON.parse(result.stdout) as { providers: Array<{ base_url: string }> };
- expect(document.providers[0]!.base_url).toBe("http://127.0.0.1:10237/v1");
+ const document = JSON.parse(result.stdout) as { providers?: Array<{ base_url: string }>; customModels?: Array<{ baseUrl: string }> };
+ expect(client === "raycast" ? document.providers?.[0]?.base_url : document.customModels?.[0]?.baseUrl)
+ .toBe("http://127.0.0.1:10237/v1");
expect(readFileSync(out, "utf8")).toContain("10237/v1");
expect(readFileSync(out, "utf8")).not.toContain("10999");
} else {
diff --git a/tests/clients/droid-client.test.ts b/tests/clients/droid-client.test.ts
new file mode 100644
index 00000000000..f830ac70c02
--- /dev/null
+++ b/tests/clients/droid-client.test.ts
@@ -0,0 +1,327 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ buildClientConfigText, buildClientContribution, droidConfigPath, droidHomeDir,
+ type DroidGeneratedConfig, type ExportModel,
+} from "../../src/clients/config-export";
+import { INTEGRATION_CLIENTS, resolveIntegrationPaths } from "../../src/integrations/registry";
+import { readIntegrationState } from "../../src/integrations/state";
+import { previewIntegration } from "../../src/integrations/mutation-plan";
+import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store";
+import { applyIntegration, disableIntegration, restoreIntegration } from "../../src/integrations/writer";
+import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh";
+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 MODELS: ExportModel[] = [
+ { namespaced: "anthropic/claude-fable-5-1", provider: "anthropic", id: "claude-fable-5-1", displayName: "Claude Fable 5.1", inputModalities: ["text", "image"] },
+ { namespaced: "mock/text", provider: "mock", id: "text", inputModalities: ["text"] },
+];
+const BASE = "http://127.0.0.1:10100/v1";
+let home: string;
+let store: IntegrationStateStore;
+
+beforeEach(() => {
+ home = mkdtempSync(join(tmpdir(), "ocx-droid-home-"));
+ store = createIntegrationStateStore(mkdtempSync(join(tmpdir(), "ocx-droid-store-")));
+});
+afterEach(() => { removeTreeWithRetry(home); removeTreeWithRetry(store.root); });
+
+function install(seed?: string) {
+ const dir = INTEGRATION_CLIENTS.droid.detectDir({}, home);
+ mkdirSync(dir, { recursive: true });
+ const path = INTEGRATION_CLIENTS.droid.configPath({}, home);
+ if (seed !== undefined) writeFileSync(path, seed);
+ return path;
+}
+function request(models: ExportModel[] = MODELS) {
+ return { clientId: "droid" as const, models, config: CONFIG, port: 10100, env: {}, home, store };
+}
+function read(path: string): { customModels: Array>; theme?: string } {
+ return JSON.parse(readFileSync(path, "utf8"));
+}
+
+describe("Factory Droid documented personal settings", () => {
+ test("exports only supported fields with a keyless loopback gateway", () => {
+ const built = buildClientConfigText("droid", { baseUrl: BASE, models: MODELS, config: CONFIG });
+ expect(built.format).toBe("json");
+ expect(built.document).toEqual({ customModels: [
+ { model: "anthropic/claude-fable-5-1", displayName: "OpenCodex: Claude Fable 5.1", baseUrl: BASE, provider: "generic-chat-completion-api", noImageSupport: false },
+ { model: "mock/text", displayName: "OpenCodex: Text", baseUrl: BASE, provider: "generic-chat-completion-api", noImageSupport: true },
+ ] } satisfies DroidGeneratedConfig);
+ expect(JSON.parse(built.text)).toEqual(built.document);
+ expect(built.text).not.toContain("apiKey");
+ expect(buildClientContribution("droid", { baseUrl: BASE, models: MODELS }).fragments.map(f => f.path)).toEqual([
+ ["customModels", "[v2:model=anthropic/claude-fable-5-1,displayName=OpenCodex: Claude Fable 5.1]"],
+ ["customModels", "[v2:model=mock/text,displayName=OpenCodex: Text]"],
+ ]);
+ });
+
+ test("skips models that cannot be addressed by managed selectors", () => {
+ const models: ExportModel[] = [
+ { namespaced: "mock/a,b", provider: "mock", id: "a,b", inputModalities: ["text"] },
+ { namespaced: "mock/c]d", provider: "mock", id: "c]d", inputModalities: ["text"] },
+ { namespaced: "mock/comma-label", provider: "mock", id: "comma-label", displayName: "Comma, label" },
+ MODELS[1]!,
+ ];
+ const path = install('{"customModels":[]}\n');
+ const input = request(models);
+ expect(readIntegrationState(input).state).toBe("absent");
+ expect(previewIntegration(input, { operation: "apply" })).toMatchObject({ canApply: true, willChange: true });
+ const context = { baseUrl: BASE, models, config: CONFIG };
+ const exported = buildClientConfigText("droid", context);
+ const document = exported.document as DroidGeneratedConfig;
+ const fragments = buildClientContribution("droid", context).fragments;
+ expect(document.customModels.map(row => row.model)).toEqual(["mock/text"]);
+ expect(JSON.parse(exported.text)).toEqual(document);
+ expect(fragments.map(fragment => fragment.value)).toEqual(document.customModels);
+ expect(fragments.map(fragment => fragment.path)).toEqual([
+ ["customModels", "[v2:model=mock/text,displayName=OpenCodex: Text]"],
+ ]);
+ expect(applyIntegration(input).ok).toBe(true);
+ expect(read(path).customModels.map(row => row.model)).toEqual(["mock/text"]);
+ });
+
+ test("resolves macOS and Windows-shaped homes without writing to the real home", () => {
+ expect(droidHomeDir({}, home)).toBe(join(home, ".factory"));
+ expect(droidConfigPath({}, home)).toBe(join(home, ".factory", "settings.json"));
+ expect(droidConfigPath({}, "C:\\Users\\Ada")).toBe("C:\\Users\\Ada\\.factory\\settings.json");
+ });
+
+ test("absent client refuses apply and creates no settings", () => {
+ expect(applyIntegration(request())).toMatchObject({ ok: false, reason: "not_installed" });
+ expect(existsSync(droidConfigPath({}, home))).toBe(false);
+ });
+
+ test("apply preserves foreign settings and models; disable removes only owned rows", () => {
+ const foreign = { model: "local", displayName: "Local", baseUrl: "http://127.0.0.1:11434/v1", provider: "generic-chat-completion-api" };
+ const seed = JSON.stringify({ theme: "dark", customModels: [foreign] }, null, 2) + "\n";
+ const path = install(seed);
+ expect(applyIntegration(request()).ok).toBe(true);
+ expect(read(path).customModels).toEqual([foreign, ...((buildClientConfigText("droid", { baseUrl: BASE, models: MODELS }).document as DroidGeneratedConfig).customModels)]);
+ expect(disableIntegration(request()).ok).toBe(true);
+ expect(read(path)).toEqual({ theme: "dark", customModels: [foreign] });
+ });
+
+ test("catalog refresh touches only an already owned Droid file", async () => {
+ const path = install('{"customModels":[]}\n');
+ expect(await refreshOwnedCatalogIntegrations({ models: MODELS, config: CONFIG, port: 10100, env: {}, home, store })).toEqual([]);
+ expect(readFileSync(path, "utf8")).toBe('{"customModels":[]}\n');
+ expect(applyIntegration(request()).ok).toBe(true);
+ expect(await refreshOwnedCatalogIntegrations({ models: MODELS.slice(0, 1), config: CONFIG, port: 10100, env: {}, home, store })).toEqual([
+ { client: "droid", ok: true, changed: true },
+ ]);
+ expect(read(path).customModels.map(row => row.model)).toEqual([MODELS[0]!.namespaced]);
+ });
+
+ test("restore returns exact prior bytes", () => {
+ const seed = '{\n "customModels":[], "theme":"dark"\n}\n';
+ const path = install(seed);
+ expect(applyIntegration(request()).ok).toBe(true);
+ const opId = store.listOperations("droid")[0]!.opId;
+ expect(restoreIntegration({ ...request(), opId }).ok).toBe(true);
+ expect(readFileSync(path, "utf8")).toBe(seed);
+ });
+
+ test("IPv6 loopback exports every row and disable/restore round-trip exact bytes", () => {
+ const seed = '{\n "theme": "dark",\n "customModels": []\n}\n';
+ const path = install(seed);
+ const input = { ...request(), config: { ...CONFIG, hostname: "::1" } };
+ const ipv6Base = "http://[::1]:10100/v1";
+ const exported = buildClientConfigText("droid", { baseUrl: ipv6Base, models: MODELS, config: input.config });
+ expect((exported.document as DroidGeneratedConfig).customModels.map(row => row.baseUrl)).toEqual([ipv6Base, ipv6Base]);
+ expect(readIntegrationState(input).state).toBe("absent");
+ expect(previewIntegration(input, { operation: "apply" }).canApply).toBe(true);
+ expect(applyIntegration(input).ok).toBe(true);
+ const applied = readFileSync(path, "utf8");
+ expect(read(path).customModels).toHaveLength(MODELS.length);
+ const applyOpId = store.listOperations("droid")[0]!.opId;
+ expect(disableIntegration(input).ok).toBe(true);
+ expect(read(path).customModels).toEqual([]);
+ const disableOpId = store.listOperations("droid")[0]!.opId;
+ expect(restoreIntegration({ ...input, opId: disableOpId }).ok).toBe(true);
+ expect(readFileSync(path, "utf8")).toBe(applied);
+ expect(restoreIntegration({ ...input, opId: applyOpId }).ok).toBe(true);
+ expect(readFileSync(path, "utf8")).toBe(seed);
+ });
+
+ test("a foreign row with the same model and different displayName remains untouched", () => {
+ const foreign = { model: MODELS[0]!.namespaced, displayName: "Personal", baseUrl: BASE, provider: "generic-chat-completion-api" };
+ const path = install(JSON.stringify({ customModels: [foreign] }) + "\n");
+ expect(applyIntegration(request()).ok).toBe(true);
+ expect(read(path).customModels[0]).toEqual(foreign);
+ expect(disableIntegration(request()).ok).toBe(true);
+ expect(read(path).customModels).toEqual([foreign]);
+ });
+
+ test("a nonempty catalog with no addressable rows refuses before mutation", () => {
+ const models: ExportModel[] = [
+ { namespaced: "mock/a,b", provider: "mock", id: "a,b" },
+ { namespaced: "mock/c]d", provider: "mock", id: "c]d" },
+ ];
+ const seed = '{"customModels":[]}\n';
+ const path = install(seed);
+ const input = request(models);
+ expect(() => buildClientConfigText("droid", { baseUrl: BASE, models, config: CONFIG })).toThrow("no addressable models");
+ expect(readIntegrationState(input).state).toBe("unsafe");
+ expect(previewIntegration(input, { operation: "apply" })).toMatchObject({ canApply: false, refusalReason: "unsafe" });
+ expect(applyIntegration(input).ok).toBe(false);
+ expect(readFileSync(path, "utf8")).toBe(seed);
+ expect(store.listOperations("droid")).toHaveLength(0);
+ expect(existsSync(join(store.root, "snapshots", "droid"))).toBe(false);
+ });
+
+ for (const [name, models] of [
+ ["empty", []],
+ ["unaddressable", [{ namespaced: "mock/a,b", provider: "mock", id: "a,b" }]],
+ ] as const) {
+ test(`disable removes recorded rows after the catalog becomes ${name}`, () => {
+ const foreign = { model: "personal", displayName: "Personal", baseUrl: "http://localhost:11434/v1" };
+ const path = install(JSON.stringify({ customModels: [foreign] }) + "\n");
+ expect(applyIntegration(request()).ok).toBe(true);
+ const changed = request([...models]);
+ expect(readIntegrationState(changed).state).toBe("stale");
+ expect(previewIntegration(changed, { operation: "disable" })).toMatchObject({ canApply: true, willChange: true });
+ expect(previewIntegration(changed, { operation: "apply" }).canApply).toBe(false);
+ expect(disableIntegration(changed)).toMatchObject({ ok: true, changed: true, state: "absent" });
+ expect(read(path).customModels).toEqual([foreign]);
+ });
+ }
+
+ test("catalog loss does not authorize removal of a foreign edit", () => {
+ const path = install('{"customModels":[]}\n');
+ expect(applyIntegration(request()).ok).toBe(true);
+ const edited = read(path);
+ edited.customModels[0]!.baseUrl = "http://localhost:11434/v1";
+ writeFileSync(path, JSON.stringify(edited));
+ expect(disableIntegration(request([]))).toMatchObject({ ok: false, reason: "conflict" });
+ expect(read(path).customModels[0]!.baseUrl).toBe("http://localhost:11434/v1");
+ });
+
+ test("catalog loss still refuses a legacy row with a recorded model ID", () => {
+ const path = install('{"customModels":[]}\n');
+ expect(applyIntegration(request()).ok).toBe(true);
+ const before = readFileSync(path, "utf8");
+ writeFileSync(join(droidHomeDir({}, home), "config.json"), JSON.stringify({ custom_models: [
+ { model: MODELS[0]!.namespaced, display_name: "Personal", base_url: "http://localhost:11434/v1" },
+ ] }));
+ expect(readIntegrationState(request([]))).toMatchObject({ state: "unsafe", reason: "unresolvable-path" });
+ expect(previewIntegration(request([]), { operation: "disable" }).canApply).toBe(false);
+ expect(disableIntegration(request([])).ok).toBe(false);
+ expect(readFileSync(path, "utf8")).toBe(before);
+ });
+
+ test("refuses ambiguous rows, symlink targets, and edited managed rows", () => {
+ const row = { model: MODELS[0]!.namespaced, displayName: "OpenCodex: Claude Fable 5.1", baseUrl: BASE, provider: "generic-chat-completion-api" };
+ const ambiguous = JSON.stringify({ customModels: [row, row] });
+ const path = install(ambiguous);
+ expect(applyIntegration(request()).ok).toBe(false);
+ expect(readFileSync(path, "utf8")).toBe(ambiguous);
+ writeFileSync(path, '{"customModels":[]}');
+ expect(applyIntegration(request()).ok).toBe(true);
+ const edited = read(path);
+ edited.customModels[0]!.displayName = "User edit";
+ writeFileSync(path, JSON.stringify(edited));
+ expect(disableIntegration(request()).ok).toBe(false);
+ expect(read(path).customModels[0]!.displayName).toBe("User edit");
+ // A fresh installation with a final symlink must never follow that link.
+ const other = mkdtempSync(join(tmpdir(), "ocx-droid-link-"));
+ try {
+ const target = join(other, "settings.json");
+ writeFileSync(target, "{}\n");
+ const linkedHome = mkdtempSync(join(tmpdir(), "ocx-droid-linked-home-"));
+ try {
+ mkdirSync(join(linkedHome, ".factory"));
+ symlinkSync(target, join(linkedHome, ".factory", "settings.json"));
+ expect(applyIntegration({ ...request(), home: linkedHome }).ok).toBe(false);
+ expect(readFileSync(target, "utf8")).toBe("{}\n");
+ } finally { removeTreeWithRetry(linkedHome); }
+ const parentHome = mkdtempSync(join(tmpdir(), "ocx-droid-parent-home-"));
+ try {
+ symlinkSync(other, join(parentHome, ".factory"), "dir");
+ expect(applyIntegration({ ...request(), home: parentHome }).ok).toBe(false);
+ expect(readFileSync(target, "utf8")).toBe("{}\n");
+ } finally { removeTreeWithRetry(parentHome); }
+ } finally { removeTreeWithRetry(other); }
+ });
+
+ test("refuses competing local or legacy OpenCodex models before mutation", () => {
+ const path = install('{"customModels":[]}\n');
+ const dir = droidHomeDir({}, home);
+ writeFileSync(join(dir, "config.json"), JSON.stringify({ custom_models: [{ display_name: "OpenCodex: Existing" }] }));
+ expect(() => resolveIntegrationPaths("droid", {}, home)).toThrow("config.json");
+ expect(applyIntegration(request()).ok).toBe(false);
+ expect(readFileSync(path, "utf8")).toBe('{"customModels":[]}\n');
+ writeFileSync(join(dir, "config.json"), '{"custom_models":[]}');
+ writeFileSync(join(dir, "settings.local.json"), '{"customModels":[]}');
+ expect(() => resolveIntegrationPaths("droid", {}, home)).toThrow("settings.local.json");
+ });
+
+ test("a competing local override created after preflight refuses before snapshot or write", () => {
+ const seed = '{"customModels":[]}\n';
+ const path = install(seed);
+ const local = join(droidHomeDir({}, home), "settings.local.json");
+ const baseIO = store.io();
+ let selectedReads = 0;
+ const result = applyIntegration({ ...request(), io: {
+ ...baseIO,
+ readText(candidate) {
+ const read = baseIO.readText(candidate);
+ if (candidate === path && ++selectedReads === 2) {
+ writeFileSync(local, '{"customModels":[]}\n');
+ }
+ return read;
+ },
+ } });
+ expect(selectedReads).toBe(2);
+ expect(result).toMatchObject({ ok: false, reason: "unsafe" });
+ if (!result.ok) expect(result.message).toContain("settings.local.json");
+ expect(readFileSync(path, "utf8")).toBe(seed);
+ expect(store.listOperations("droid")).toHaveLength(0);
+ expect(existsSync(join(store.root, "snapshots", "droid"))).toBe(false);
+ });
+
+ for (const [caseName, row] of [
+ ["legacy OpenCodex display name", { model: "other", display_name: "OpenCodex: Existing", base_url: "http://example.test/v1" }],
+ ["same generated model id", { model: MODELS[0]!.namespaced, display_name: "Personal", base_url: "http://example.test/v1" }],
+ ["localhost endpoint with trailing slash", { model: "other", display_name: "Personal", base_url: "http://localhost:10100/v1/" }],
+ ["IPv6 loopback endpoint", { model: "other", display_name: "Personal", base_url: "http://[::1]:10100/v1" }],
+ ] as const) {
+ test(`refuses ${caseName} before snapshot or write`, () => {
+ const seed = '{"customModels":[]}\n';
+ const path = install(seed);
+ writeFileSync(join(droidHomeDir({}, home), "config.json"), JSON.stringify({ custom_models: [row] }));
+ const input = caseName === "same generated model id"
+ ? { ...request(), resolvedPaths: { configPath: path, detectDir: droidHomeDir({}, home) } }
+ : request();
+ expect(readIntegrationState(input)).toMatchObject({ state: "unsafe", reason: "unresolvable-path" });
+ expect(previewIntegration(input, { operation: "apply" })).toMatchObject({ canApply: false, refusalReason: "unsafe" });
+ const result = applyIntegration(input);
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.message).toContain("config.json");
+ expect(result.message).not.toContain("http");
+ }
+ expect(readFileSync(path, "utf8")).toBe(seed);
+ expect(store.listOperations("droid")).toHaveLength(0);
+ expect(existsSync(join(store.root, "snapshots", "droid"))).toBe(false);
+ });
+ }
+
+ test("allows a non-colliding legacy model", () => {
+ const path = install('{"customModels":[]}\n');
+ writeFileSync(join(droidHomeDir({}, home), "config.json"), JSON.stringify({ custom_models: [
+ { model: "personal", display_name: "Personal", base_url: "http://localhost:11434/v1/" },
+ ] }));
+ expect(readIntegrationState(request()).state).toBe("absent");
+ expect(previewIntegration(request(), { operation: "apply" }).canApply).toBe(true);
+ expect(applyIntegration(request()).ok).toBe(true);
+ expect(read(path).customModels).toHaveLength(MODELS.length);
+ });
+});
diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts
index f96cf134c51..66516cde100 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("omp, pi, kimi, gajae, dsh, mcode, zcode, prime, aside, raycast and omo are loopback-only and nobody else is", () => {
const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id));
- expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"]);
+ expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "droid"]);
});
test("the registry restates nothing — it reads the export spec", () => {
diff --git a/tests/clients/kilo-client.test.ts b/tests/clients/kilo-client.test.ts
new file mode 100644
index 00000000000..58da8d28a7a
--- /dev/null
+++ b/tests/clients/kilo-client.test.ts
@@ -0,0 +1,597 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import {
+ EXPORT_CLIENTS,
+ KILO_API_KEY_ENV_REF,
+ KILO_CONFIG_SCHEMA,
+ OPENCODE_PROVIDER_ID,
+ buildClientConfig,
+ buildClientConfigText,
+ buildClientContribution,
+ kiloConfigPath,
+ kiloHomeDir,
+ type ExportContext,
+} from "../../src/clients/config-export";
+import type { KiloGeneratedConfig } from "../../src/clients/config-export/kilo";
+import { PARSE_FAILED, fileIO, parseConfig } from "../../src/integrations/config-io";
+import { inspectKiloCandidates } from "../../src/integrations/kilo-candidates";
+import { INTEGRATION_CLIENTS } from "../../src/integrations/registry";
+import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store";
+import { readIntegrationState } from "../../src/integrations/state";
+import { previewIntegration } from "../../src/integrations/mutation-plan";
+import { applyIntegration, disableIntegration, overwriteIntegration, restoreIntegration } from "../../src/integrations/writer";
+import type { OcxConfig } from "../../src/types";
+import { removeTreeWithRetry } from "../helpers/remove-tree";
+
+const LOOPBACK: OcxConfig = {
+ port: 10100,
+ hostname: "127.0.0.1",
+ defaultProvider: "mock",
+ providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } },
+} as OcxConfig;
+
+const REMOTE: OcxConfig = { ...LOOPBACK, hostname: "0.0.0.0" } as OcxConfig;
+
+function context(config: OcxConfig = LOOPBACK): ExportContext {
+ return {
+ baseUrl: "http://127.0.0.1:10100/v1",
+ config,
+ models: [
+ { namespaced: "anthropic/claude-opus-5", provider: "anthropic", id: "claude-opus-5", contextWindow: 200_000, inputModalities: ["text", "image"] },
+ { namespaced: "mystery/model", provider: "mystery", id: "model" },
+ { namespaced: "audio/only", provider: "audio", id: "only", inputModalities: ["audio"] },
+ { namespaced: "unknown/mod", provider: "unknown", id: "mod", inputModalities: ["smell"] },
+ ],
+ };
+}
+
+describe("kilo client config", () => {
+ test("uses the model output limit in Kilo's context metadata", () => {
+ const document = buildClientConfig("kilo", {
+ ...context(),
+ models: [{ namespaced: "custom/limited", provider: "custom", id: "limited", contextWindow: 100_000, maxTokens: 8_192 }],
+ }) as KiloGeneratedConfig;
+ expect(document.provider[OPENCODE_PROVIDER_ID]?.models["custom/limited"]?.limit)
+ .toEqual({ context: 100_000, output: 8_192 });
+ });
+
+ test("emits a V1-only document with Kilo's schema and npm package", () => {
+ const document = buildClientConfig("kilo", context()) as KiloGeneratedConfig;
+ expect(document.$schema).toBe(KILO_CONFIG_SCHEMA);
+ expect(document).not.toHaveProperty("providers");
+ expect(Object.keys(document.provider)).toEqual([OPENCODE_PROVIDER_ID]);
+ const provider = document.provider[OPENCODE_PROVIDER_ID]!;
+ expect(provider.npm).toBe("@ai-sdk/openai-compatible");
+ expect(provider.name).toBe("OpenCodex");
+ expect(JSON.stringify(document)).not.toContain('"package"');
+ expect(JSON.stringify(provider.models)).not.toContain("variants");
+ });
+
+ test("emits the exact documented provider shape for one routed model", () => {
+ expect(buildClientConfig("kilo", { ...context(), models: [
+ { namespaced: "sample/model", provider: "sample", id: "model", displayName: "Sample", contextWindow: 8192, maxTokens: 1024 },
+ ] })).toEqual({
+ $schema: "https://app.kilo.ai/config.json",
+ provider: { opencodex: {
+ npm: "@ai-sdk/openai-compatible",
+ name: "OpenCodex",
+ options: { baseURL: "http://127.0.0.1:10100/v1", apiKey: "{env:OPENCODEX_KILO_API_KEY}" },
+ models: { "sample/model": { name: "Sample (sample)", limit: { context: 8192, output: 1024 } } },
+ } },
+ });
+ });
+
+ test("the contribution owns only provider.opencodex", () => {
+ const contribution = buildClientContribution("kilo", context());
+ expect(contribution.clientId).toBe("kilo");
+ expect(contribution.fragments.map(fragment => fragment.path)).toEqual([["provider", OPENCODE_PROVIDER_ID]]);
+ });
+
+ test("loopback uses the Kilo env ref; remote uses the admission header; never a real key", () => {
+ const sentinel = ["sk", "live", "kilo", "sentinel"].join("-");
+ const withKey = { ...LOOPBACK, apiKeys: [{ key: sentinel }] } as OcxConfig;
+ const loopback = buildClientConfig("kilo", context(withKey)) as KiloGeneratedConfig;
+ expect(loopback.provider[OPENCODE_PROVIDER_ID]!.options.apiKey).toBe(KILO_API_KEY_ENV_REF);
+ expect(loopback.provider[OPENCODE_PROVIDER_ID]!.options.headers).toBeUndefined();
+
+ const remote = buildClientConfig("kilo", context({ ...REMOTE, apiKeys: [{ key: sentinel }] } as OcxConfig)) as KiloGeneratedConfig;
+ expect(remote.provider[OPENCODE_PROVIDER_ID]!.options.apiKey).toBeUndefined();
+ expect(remote.provider[OPENCODE_PROVIDER_ID]!.options.headers).toEqual({ "x-opencodex-api-key": KILO_API_KEY_ENV_REF });
+
+ const bytes = buildClientConfigText("kilo", context(withKey)).text;
+ expect(bytes).not.toContain(sentinel);
+ expect(bytes).not.toContain("OPENCODEX_OPENCODE_API_KEY");
+ expect(EXPORT_CLIENTS.kilo.loopbackOnly).toBe(false);
+ expect(EXPORT_CLIENTS.kilo.filename).toBe("kilo.jsonc");
+ expect(EXPORT_CLIENTS.kilo.format).toBe("json");
+ });
+
+ test("audio-only and unknown modalities follow OpenCode's capability helper", () => {
+ const document = buildClientConfig("kilo", context()) as KiloGeneratedConfig;
+ const models = document.provider[OPENCODE_PROVIDER_ID]!.models;
+ expect(models["audio/only"]).toEqual({
+ name: "only (audio)",
+ attachment: true,
+ modalities: { input: ["audio"], output: ["text"] },
+ });
+ expect(models["unknown/mod"]).toEqual({ name: "mod (unknown)" });
+ expect(models["mystery/model"]!.limit).toBeUndefined();
+ expect(EXPORT_CLIENTS.kilo.summarize(document)).toEqual({ modelCount: 4, modelsWithoutLimits: 3 });
+ });
+
+ test("path order: first existing candidate wins; empty dir is kilo.jsonc; XDG relocates", () => {
+ const root = mkdtempSync(join(tmpdir(), "ocx-kilo-path-"));
+ try {
+ const home = join(root, "home");
+ const dir = kiloHomeDir({}, home);
+ expect(dir).toBe(join(home, ".config", "kilo"));
+ mkdirSync(dir, { recursive: true });
+ expect(kiloConfigPath({}, home)).toBe(join(dir, "kilo.jsonc"));
+ writeFileSync(join(dir, "config.json"), "{}\n");
+ expect(kiloConfigPath({}, home)).toBe(join(dir, "config.json"));
+ writeFileSync(join(dir, "kilo.json"), "{}\n");
+ expect(kiloConfigPath({}, home)).toBe(join(dir, "kilo.json"));
+ writeFileSync(join(dir, "kilo.jsonc"), "{}\n");
+ expect(kiloConfigPath({}, home)).toBe(join(dir, "kilo.jsonc"));
+
+ const xdg = join(root, "xdg");
+ mkdirSync(join(xdg, "kilo"), { recursive: true });
+ expect(kiloHomeDir({ XDG_CONFIG_HOME: xdg }, home)).toBe(join(xdg, "kilo"));
+ expect(kiloConfigPath({ XDG_CONFIG_HOME: xdg }, home)).toBe(join(xdg, "kilo", "kilo.jsonc"));
+ expect(INTEGRATION_CLIENTS.kilo.detectDir({ XDG_CONFIG_HOME: xdg }, home)).toBe(join(xdg, "kilo"));
+ } finally {
+ removeTreeWithRetry(root);
+ }
+ });
+
+ test("Windows-shaped home and XDG paths retain native separators", () => {
+ expect(kiloHomeDir({}, "C:\\Users\\Ada")).toBe("C:\\Users\\Ada\\.config\\kilo");
+ expect(kiloHomeDir({ XDG_CONFIG_HOME: "" }, "C:\\Users\\Ada"))
+ .toBe("C:\\Users\\Ada\\.config\\kilo");
+ expect(INTEGRATION_CLIENTS.kilo.detectDir({ XDG_CONFIG_HOME: "" }, "C:\\Users\\Ada"))
+ .toBe("C:\\Users\\Ada\\.config\\kilo");
+ expect(kiloConfigPath({}, "C:\\Users\\Ada")).toBe("C:\\Users\\Ada\\.config\\kilo\\kilo.jsonc");
+ expect(kiloConfigPath({ XDG_CONFIG_HOME: "D:\\settings" }, "C:\\Users\\Ada"))
+ .toBe("D:\\settings\\kilo\\kilo.jsonc");
+ });
+
+ test("candidate read failures report unparseable, while non-files report their shape", () => {
+ const home = "C:\\Users\\Ada";
+ const selectedPath = kiloConfigPath({}, home);
+ const base = fileIO();
+ const inspect = (statKind: ReturnType) => inspectKiloCandidates({
+ io: { ...base, statKind: () => statKind }, selectedPath, home, env: {},
+ });
+ expect(inspect("failed")).toEqual({ kind: "unsafe", path: selectedPath, why: "unparseable" });
+ expect(inspect("directory")).toEqual({ kind: "unsafe", path: selectedPath, why: "not-regular-file" });
+ });
+});
+
+describe("kilo JSONC apply/disable/restore", () => {
+ let home: string;
+ let store: IntegrationStateStore;
+
+ beforeEach(() => {
+ const base = mkdtempSync(join(tmpdir(), "ocx-kilo-writer-"));
+ home = join(base, "home");
+ mkdirSync(home, { recursive: true });
+ store = createIntegrationStateStore(join(base, "store", "integrations"));
+ });
+
+ afterEach(() => {
+ removeTreeWithRetry(dirname(home));
+ });
+
+ const kitchenSink = `{
+ // user comment
+ "$schema": "https://app.kilo.ai/config.json",
+ "model": "anthropic/claude-opus-4",
+ "enabled_providers": ["anthropic"],
+ "mcp": { "keep": true },
+ "provider": {
+ "anthropic": { "npm": "@ai-sdk/anthropic" },
+ },
+}
+`;
+
+ function writeInput() {
+ return { clientId: "kilo" as const, models: context().models, config: LOOPBACK,
+ port: 10100, env: {} as NodeJS.ProcessEnv, home, store };
+ }
+
+ test("absent Kilo install refuses apply without creating its config", () => {
+ const result = applyIntegration(writeInput());
+ expect(result.ok).toBe(false);
+ expect(readIntegrationState(writeInput()).installed).toBe(false);
+ expect(INTEGRATION_CLIENTS.kilo.configPath({}, home)).toBe(join(home, ".config", "kilo", "kilo.jsonc"));
+ });
+
+ test("status, preview and apply refuse two candidate files with distinct provider blocks", () => {
+ const dir = INTEGRATION_CLIENTS.kilo.detectDir({}, home);
+ mkdirSync(dir, { recursive: true });
+ const first = join(dir, "kilo.jsonc");
+ const later = join(dir, "opencode.jsonc");
+ const firstText = '{"provider":{"opencodex":{"name":"first"}}}\n';
+ const laterText = '{"provider":{"opencodex":{"name":"later"}}}\n';
+ writeFileSync(first, firstText);
+ writeFileSync(later, laterText);
+ const input = writeInput();
+ expect(readIntegrationState(input)).toMatchObject({ state: "conflict", reason: "candidate-conflict", configPath: first, conflictPaths: [later] });
+ const preview = previewIntegration(input, { operation: "apply" });
+ expect(preview.canApply).toBe(false);
+ expect(preview.refusalReason).toBe("conflict");
+ expect(previewIntegration(input, { operation: "overwrite" }).canApply).toBe(false);
+ const result = applyIntegration(input);
+ expect(result.ok).toBe(false);
+ expect(overwriteIntegration(input)).toMatchObject({ ok: false, reason: "conflict" });
+ if (!result.ok) {
+ expect(result.reason).toBe("conflict");
+ expect(result.message).toContain(first);
+ expect(result.message).toContain(later);
+ }
+ expect(readFileSync(first, "utf8")).toBe(firstText);
+ expect(readFileSync(later, "utf8")).toBe(laterText);
+ expect(store.listOperations("kilo")).toHaveLength(0);
+ });
+
+ test("status names every competing Kilo candidate", () => {
+ const dir = INTEGRATION_CLIENTS.kilo.detectDir({}, home);
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, "kilo.jsonc"), "{}\n");
+ const paths = [join(dir, "kilo.json"), join(dir, "config.json")];
+ for (const path of paths) writeFileSync(path, '{"provider":{"opencodex":{}}}\n');
+ expect(readIntegrationState(writeInput()).conflictPaths).toEqual(paths);
+ });
+
+ test("an owned block can be disabled despite a later competing candidate", () => {
+ const dir = INTEGRATION_CLIENTS.kilo.detectDir({}, home);
+ mkdirSync(dir, { recursive: true });
+ const ownedPath = join(dir, "kilo.jsonc");
+ const competingPath = join(dir, "opencode.jsonc");
+ writeFileSync(ownedPath, '{"model":"keep"}\n');
+ const input = writeInput();
+ expect(applyIntegration(input).ok).toBe(true);
+ const ownedText = readFileSync(ownedPath, "utf8");
+ const competingText = '{"provider":{"opencodex":{"name":"other"}}}\n';
+ writeFileSync(competingPath, competingText);
+
+ expect(readIntegrationState(input)).toMatchObject({
+ state: "conflict", reason: "candidate-conflict", configPath: ownedPath,
+ conflictPaths: [competingPath], lastOpId: expect.any(String),
+ });
+ for (const operation of ["apply", "overwrite"] as const) {
+ expect(previewIntegration(input, { operation })).toMatchObject({ canApply: false, refusalReason: "conflict" });
+ }
+ expect(applyIntegration(input)).toMatchObject({ ok: false, reason: "conflict" });
+ expect(overwriteIntegration(input)).toMatchObject({ ok: false, reason: "conflict" });
+ expect(readFileSync(ownedPath, "utf8")).toBe(ownedText);
+
+ expect(previewIntegration(input, { operation: "disable" }).canApply).toBe(true);
+ expect(disableIntegration(input)).toMatchObject({ ok: true, changed: true });
+ expect(JSON.parse(readFileSync(ownedPath, "utf8"))).toEqual({ model: "keep" });
+ expect(readFileSync(competingPath, "utf8")).toBe(competingText);
+ expect(readIntegrationState(input)).toMatchObject({ state: "conflict", reason: "candidate-conflict" });
+ });
+
+ test("an unparseable later candidate does not strand an owned block", () => {
+ const dir = INTEGRATION_CLIENTS.kilo.detectDir({}, home);
+ mkdirSync(dir, { recursive: true });
+ const ownedPath = join(dir, "kilo.jsonc");
+ const laterPath = join(dir, "opencode.jsonc");
+ writeFileSync(ownedPath, "{}\n");
+ const input = writeInput();
+ expect(applyIntegration(input).ok).toBe(true);
+ writeFileSync(laterPath, "{broken");
+ expect(readIntegrationState(input)).toMatchObject({
+ state: "unsafe", reason: "unparseable", candidateFailurePath: laterPath,
+ lastOpId: expect.any(String),
+ });
+ expect(previewIntegration(input, { operation: "apply" }).canApply).toBe(false);
+ expect(previewIntegration(input, { operation: "disable" }).canApply).toBe(true);
+ expect(disableIntegration(input)).toMatchObject({ ok: true, changed: true });
+ expect(JSON.parse(readFileSync(ownedPath, "utf8"))).toEqual({});
+ expect(readFileSync(laterPath, "utf8")).toBe("{broken");
+ });
+
+ test("a later provider block refuses even when the first candidate has none", () => {
+ const dir = INTEGRATION_CLIENTS.kilo.detectDir({}, home);
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, "kilo.jsonc"), '{"model":"keep"}\n');
+ writeFileSync(join(dir, "config.json"), '{"provider":{"opencodex":{"name":"later"}}}\n');
+ expect(readIntegrationState(writeInput()).reason).toBe("candidate-conflict");
+ expect(applyIntegration(writeInput()).ok).toBe(false);
+ });
+
+ test("a candidate created after observation refuses before snapshot", () => {
+ const dir = INTEGRATION_CLIENTS.kilo.detectDir({}, home);
+ mkdirSync(dir, { recursive: true });
+ const first = join(dir, "kilo.jsonc");
+ const later = join(dir, "opencode.jsonc");
+ const firstText = '{"model":"keep"}\n';
+ const laterText = '{"provider":{"opencodex":{"name":"late"}}}\n';
+ writeFileSync(first, firstText);
+ const baseIO = store.io();
+ let selectedReads = 0;
+ const result = applyIntegration({ ...writeInput(), io: {
+ ...baseIO,
+ readText(path) {
+ const read = baseIO.readText(path);
+ if (path === first && ++selectedReads === 2) writeFileSync(later, laterText);
+ return read;
+ },
+ } });
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.reason).toBe("conflict");
+ expect(readFileSync(first, "utf8")).toBe(firstText);
+ expect(readFileSync(later, "utf8")).toBe(laterText);
+ expect(store.listOperations("kilo")).toHaveLength(0);
+ });
+
+ test("an unsafe second candidate refuses before touching the selected file", () => {
+ const dir = INTEGRATION_CLIENTS.kilo.detectDir({}, home);
+ mkdirSync(dir, { recursive: true });
+ const first = join(dir, "kilo.jsonc");
+ writeFileSync(first, '{"model":"keep"}\n');
+ symlinkSync(first, join(dir, "opencode.jsonc"));
+ expect(readIntegrationState(writeInput())).toMatchObject({ state: "unsafe", reason: "not-regular-file" });
+ expect(applyIntegration(writeInput()).ok).toBe(false);
+ expect(readFileSync(first, "utf8")).toBe('{"model":"keep"}\n');
+ });
+
+ test("restore returns comment-bearing config bytes exactly", () => {
+ const dir = INTEGRATION_CLIENTS.kilo.detectDir({}, home);
+ mkdirSync(dir, { recursive: true });
+ const path = join(dir, "kilo.jsonc");
+ writeFileSync(path, kitchenSink);
+ const input = writeInput();
+ expect(applyIntegration(input).ok).toBe(true);
+ const op = store.listOperations("kilo").find(row => row.kind === "apply");
+ expect(op).toBeDefined();
+ expect(restoreIntegration({ ...input, opId: op!.opId }).ok).toBe(true);
+ expect(readFileSync(path, "utf8")).toBe(kitchenSink);
+ });
+
+ test("a foreign edit inside the owned block refuses refresh and disable", () => {
+ const dir = INTEGRATION_CLIENTS.kilo.detectDir({}, home);
+ mkdirSync(dir, { recursive: true });
+ const path = join(dir, "kilo.jsonc");
+ writeFileSync(path, kitchenSink);
+ const input = writeInput();
+ expect(applyIntegration(input).ok).toBe(true);
+ const edited = readFileSync(path, "utf8").replace('"name": "OpenCodex"', '"name": "Personal"');
+ writeFileSync(path, edited);
+ expect(readIntegrationState(input)).toMatchObject({ state: "conflict", reason: "foreign-edit" });
+ expect(applyIntegration(input).ok).toBe(false);
+ expect(disableIntegration(input).ok).toBe(false);
+ expect(readFileSync(path, "utf8")).toBe(edited);
+ });
+
+ test("JSONC comments and trailing commas parse; apply/disable leave non-owned keys", () => {
+ const spec = INTEGRATION_CLIENTS.kilo;
+ mkdirSync(spec.detectDir({}, home), { recursive: true });
+ const configPath = spec.configPath({}, home);
+ writeFileSync(configPath, kitchenSink);
+
+ const parsed = parseConfig(kitchenSink, "json", { jsonc: true });
+ expect(parsed).not.toBe(PARSE_FAILED);
+ expect(parsed).toMatchObject({
+ model: "anthropic/claude-opus-4",
+ enabled_providers: ["anthropic"],
+ mcp: { keep: true },
+ provider: { anthropic: { npm: "@ai-sdk/anthropic" } },
+ });
+ expect(parseConfig(kitchenSink, "json")).toBe(PARSE_FAILED);
+
+ const write = {
+ clientId: "kilo" as const,
+ models: context().models,
+ config: LOOPBACK,
+ port: 10100,
+ env: {} as NodeJS.ProcessEnv,
+ home,
+ store,
+ };
+ const applied = applyIntegration(write);
+ expect(applied.ok).toBe(true);
+ const afterApply = JSON.parse(readFileSync(configPath, "utf8")) as KiloGeneratedConfig & {
+ model: string;
+ enabled_providers: string[];
+ mcp: { keep: boolean };
+ provider: Record;
+ };
+ expect(afterApply.model).toBe("anthropic/claude-opus-4");
+ expect(afterApply.enabled_providers).toEqual(["anthropic"]);
+ expect(afterApply.mcp).toEqual({ keep: true });
+ expect(afterApply.provider.anthropic).toEqual({ npm: "@ai-sdk/anthropic" });
+ expect(afterApply.provider[OPENCODE_PROVIDER_ID]).toBeDefined();
+ expect(afterApply).not.toHaveProperty("providers");
+
+ const disabled = disableIntegration(write);
+ expect(disabled.ok).toBe(true);
+ const afterDisable = JSON.parse(readFileSync(configPath, "utf8")) as typeof afterApply;
+ expect(afterDisable.provider[OPENCODE_PROVIDER_ID]).toBeUndefined();
+ expect(afterDisable.provider.anthropic).toEqual({ npm: "@ai-sdk/anthropic" });
+ expect(afterDisable.model).toBe("anthropic/claude-opus-4");
+ expect(afterDisable.mcp).toEqual({ keep: true });
+
+ const restored = restoreIntegration({ ...write, opId: store.listOperations("kilo")[0]!.opId });
+ expect(restored.ok).toBe(true);
+ });
+
+ test("a block comment is a token separator, not deletion: malformed values refuse", () => {
+ // `1/*x*/2` is two tokens; stripping the comment to nothing would yield
+ // `12` — a different valid value. The stripper keeps a separator, so the
+ // rewrite gate sees a parse failure instead of a changed user value.
+ expect(parseConfig('{"value":1/*c*/2}', "json", { jsonc: true })).toBe(PARSE_FAILED);
+ // Where a comment was, whitespace is legal: valid JSONC is unaffected.
+ expect(parseConfig('{"value": 1 /* keep */ , "b": [1,/*c*/2]}', "json", { jsonc: true })).toEqual({ value: 1, b: [1, 2] });
+ });
+
+ test("an unterminated block comment is PARSE_FAILED, and apply refuses without touching the file", () => {
+ const spec = INTEGRATION_CLIENTS.kilo;
+ mkdirSync(spec.detectDir({}, home), { recursive: true });
+ const configPath = spec.configPath({}, home);
+ const poisoned = '{\n "model": "keep",\n /* never closed\n';
+ writeFileSync(configPath, poisoned);
+
+ // Stripping an unterminated block comment would delete the malformed tail;
+ // the stripper throws instead so the rewrite gate sees a parse failure.
+ expect(parseConfig(poisoned, "json", { jsonc: true })).toBe(PARSE_FAILED);
+
+ const applied = applyIntegration({
+ clientId: "kilo", models: context().models, config: LOOPBACK,
+ port: 10100, env: {} as NodeJS.ProcessEnv, home, store,
+ });
+ expect(applied.ok).toBe(false);
+ expect(readFileSync(configPath, "utf8")).toBe(poisoned);
+ });
+
+ test("lifecycle stays bound to the owned file when a higher-priority candidate appears", () => {
+ /*
+ * Resolution picks the first EXISTING candidate, so apply can own
+ * config.json while a later-created kilo.jsonc wins discovery. The
+ * ownership record then binds reads and mutations to config.json while
+ * it still exists: status reports it, disable removes OUR block from it,
+ * and the newcomer is never touched. Only after the record is dropped
+ * does priority discovery pick kilo.jsonc up again.
+ */
+ const spec = INTEGRATION_CLIENTS.kilo;
+ const dir = spec.detectDir({}, home);
+ mkdirSync(dir, { recursive: true });
+ const ownedPath = join(dir, "config.json");
+ writeFileSync(ownedPath, "{}\n");
+
+ const write = {
+ clientId: "kilo" as const,
+ models: context().models,
+ config: LOOPBACK,
+ port: 10100,
+ env: {} as NodeJS.ProcessEnv,
+ home,
+ store,
+ };
+ expect(applyIntegration(write).ok).toBe(true);
+ const owned = readFileSync(ownedPath, "utf8");
+ expect(owned).toContain(OPENCODE_PROVIDER_ID);
+
+ const newcomer = join(dir, "kilo.jsonc");
+ const newcomerText = '{ "model": "keep" }\n';
+ writeFileSync(newcomer, newcomerText);
+
+ const bound = readIntegrationState(write);
+ expect(bound.state).toBe("current");
+ expect(bound.configPath).toBe(ownedPath);
+
+ const disabled = disableIntegration(write);
+ expect(disabled.ok).toBe(true);
+ const afterDisable = JSON.parse(readFileSync(ownedPath, "utf8")) as { provider?: Record };
+ expect(afterDisable.provider?.[OPENCODE_PROVIDER_ID]).toBeUndefined();
+ expect(readFileSync(newcomer, "utf8")).toBe(newcomerText);
+
+ // Record dropped: discovery is priority again, pointing at the newcomer.
+ const released = readIntegrationState(write);
+ expect(released.state).toBe("absent");
+ expect(released.configPath).toBe(newcomer);
+ });
+
+ test("restore and its preview stay bound to the journaled file when a higher-priority candidate appears", () => {
+ const spec = INTEGRATION_CLIENTS.kilo;
+ const dir = spec.detectDir({}, home);
+ mkdirSync(dir, { recursive: true });
+ const ownedPath = join(dir, "config.json");
+ writeFileSync(ownedPath, "{}\n");
+
+ const write = {
+ clientId: "kilo" as const,
+ models: context().models,
+ config: LOOPBACK,
+ port: 10100,
+ env: {} as NodeJS.ProcessEnv,
+ home,
+ store,
+ };
+ expect(applyIntegration(write).ok).toBe(true);
+
+ const newcomer = join(dir, "kilo.jsonc");
+ const newcomerText = '{ "model": "keep" }\n';
+ writeFileSync(newcomer, newcomerText);
+
+ expect(disableIntegration(write).ok).toBe(true);
+ const disableOp = store.listOperations("kilo")[0]!;
+
+ // Fresh priority discovery now picks the newcomer; the journaled disable
+ // op names config.json, still one of Kilo's own candidates here, so both
+ // restore paths act on the journaled file instead of refusing.
+ const preview = previewIntegration(write, { operation: "restore", opId: disableOp.opId });
+ expect(preview.refusalReason).toBeUndefined();
+ expect(preview.canApply).toBe(true);
+
+ const restored = restoreIntegration({ ...write, opId: disableOp.opId });
+ expect(restored.ok).toBe(true);
+ expect(readFileSync(ownedPath, "utf8")).toContain(OPENCODE_PROVIDER_ID);
+ expect(readFileSync(newcomer, "utf8")).toBe(newcomerText);
+ });
+
+ test("refuses a historical restore once another candidate owns the integration", () => {
+ /*
+ * apply config.json, then a higher-priority kilo.jsonc appears, disable
+ * drops the old record, and a fresh apply owns kilo.jsonc. Restoring the
+ * historical disable would put config.json's prior record back into the
+ * single slot while kilo.jsonc still holds the live block. Later disable
+ * would then drop that record and orphan the newcomer. Both restore paths
+ * refuse, and the live file stays the one disable removes.
+ */
+ const spec = INTEGRATION_CLIENTS.kilo;
+ const dir = spec.detectDir({}, home);
+ mkdirSync(dir, { recursive: true });
+ const ownedPath = join(dir, "config.json");
+ writeFileSync(ownedPath, "{}\n");
+
+ const write = {
+ clientId: "kilo" as const,
+ models: context().models,
+ config: LOOPBACK,
+ port: 10100,
+ env: {} as NodeJS.ProcessEnv,
+ home,
+ store,
+ };
+ expect(applyIntegration(write).ok).toBe(true);
+
+ const newcomer = join(dir, "kilo.jsonc");
+ const newcomerText = '{ "model": "keep" }\n';
+ writeFileSync(newcomer, newcomerText);
+ expect(disableIntegration(write).ok).toBe(true);
+ const disableOp = store.listOperations("kilo").find(op => op.kind === "disable" && op.configPath === ownedPath);
+ expect(disableOp).toBeDefined();
+
+ expect(applyIntegration(write).ok).toBe(true);
+ expect(store.readRecords().kilo?.configPath).toBe(newcomer);
+ const live = readFileSync(newcomer, "utf8");
+ expect(live).toContain(OPENCODE_PROVIDER_ID);
+ const retired = readFileSync(ownedPath, "utf8");
+ expect(retired).not.toContain(OPENCODE_PROVIDER_ID);
+
+ const preview = previewIntegration(write, { operation: "restore", opId: disableOp!.opId });
+ expect(preview.canApply).toBe(false);
+ expect(preview.refusalReason).toBe("conflict");
+
+ const restored = restoreIntegration({ ...write, opId: disableOp!.opId });
+ expect(restored.ok).toBe(false);
+ if (restored.ok) return;
+ expect(restored.reason).toBe("conflict");
+ expect(restored.message).toContain(newcomer);
+ expect(readFileSync(newcomer, "utf8")).toBe(live);
+ expect(readFileSync(ownedPath, "utf8")).toBe(retired);
+ expect(store.readRecords().kilo?.configPath).toBe(newcomer);
+
+ const disabled = disableIntegration(write);
+ expect(disabled.ok).toBe(true);
+ const after = JSON.parse(readFileSync(newcomer, "utf8")) as { provider?: Record };
+ expect(after.provider?.[OPENCODE_PROVIDER_ID]).toBeUndefined();
+ expect(readFileSync(ownedPath, "utf8")).toBe(retired);
+ expect(store.readRecords().kilo).toBeUndefined();
+ });
+});
diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts
index 3d5b4eadb59..7b65f9dee10 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"]');
+ expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo", "cline", "droid"]');
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);
@@ -908,7 +908,7 @@ test("the direct ocx sync command refreshes MCode, Pi, Raycast, omo and server-o
const start = src.indexOf("sync: async deps =>");
const command = src.slice(start, src.indexOf("v2: async deps =>", start));
expect(command).toContain("refreshOwnedCatalogIntegrations");
- expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline"]');
+ expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline", "droid"]');
expect(command).toContain("refreshAsideProfilesThroughServer");
expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations"));
expect(command).toContain('synced.status !== "refused"');
diff --git a/tests/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts
index daa73e1a361..13979e9b4af 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 reuses Pi's builder, which emits no headers at all.
const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly);
- expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"]);
+ expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "droid"]);
});
test("every client that is not loopback-only carries the header on a remote bind", () => {
diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts
index d1dd5ebca47..97bd31148a9 100644
--- a/tests/config/client-config-export.test.ts
+++ b/tests/config/client-config-export.test.ts
@@ -847,8 +847,8 @@ describe("hub-resolved Fast exports", () => {
});
describe("EXPORT_CLIENTS registry", () => {
- test("covers exactly the fourteen file-toggle clients", () => {
- expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"]);
+ test("covers exactly the seventeen file-toggle clients", () => {
+ expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid"]);
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);
@@ -1003,9 +1003,10 @@ describe("EXPORT_CLIENTS registry", () => {
expect(typeof spec.summarize).toBe("function");
expect(typeof spec.buildContribution).toBe("function");
// The filename's extension must match the declared format, so a reader
- // never has to guess which one is authoritative.
+ // never has to guess which one is authoritative. Kilo's destination is
+ // `.jsonc` while serialize stays pretty JSON (`format: "json"`).
const extension = spec.filename.slice(spec.filename.lastIndexOf(".") + 1);
- expect(extension).toBe(spec.format);
+ expect(extension).toBe(id === "kilo" ? "jsonc" : spec.format);
}
});
@@ -1019,6 +1020,11 @@ describe("EXPORT_CLIENTS registry", () => {
const extensionFor = { json: "json", yaml: "yaml", toml: "toml", json5: "json5" } as const;
for (const id of EXPORT_CLIENT_IDS) {
const spec = EXPORT_CLIENTS[id];
+ if (id === "kilo") {
+ expect(spec.filename.endsWith(".jsonc")).toBe(true);
+ expect(spec.format).toBe("json");
+ continue;
+ }
expect(spec.filename.endsWith(`.${extensionFor[spec.format]}`)).toBe(true);
}
});
diff --git a/tests/config/settings-memory-models.test.ts b/tests/config/settings-memory-models.test.ts
new file mode 100644
index 00000000000..767b1195a58
--- /dev/null
+++ b/tests/config/settings-memory-models.test.ts
@@ -0,0 +1,113 @@
+/**
+ * `/api/settings` round-trip for per-phase memory routing.
+ *
+ * GET reports the block, PUT persists it to config.json and echoes it in its own response,
+ * and a fresh `loadConfig()` reads it back. The echo is load-bearing: the dashboard panel
+ * re-reads the response of its own save, so a response without the block would render both
+ * phases as "Off" while the server still held them.
+ */
+import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
+import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { getConfigPath, loadConfig, saveConfig } from "../../src/config";
+import { handleManagementAPI, type ManagementApiDeps } from "../../src/server/management-api";
+import { invalidateStartupHealthCache } from "../../src/server/startup-health-cache";
+import type { OcxConfig } from "../../src/types";
+import { startupHealthFixture } from "../helpers/startup-health";
+import { removeTreeWithRetry } from "../helpers/remove-tree";
+
+let home = "";
+let previousHome: string | undefined;
+
+const baseConfig = (): OcxConfig => ({
+ port: 10100,
+ defaultProvider: "gateway",
+ providers: { gateway: { adapter: "openai-chat", baseUrl: "https://gateway.test/v1", apiKey: "fixture" } },
+});
+
+function settings(cfg: OcxConfig, body?: unknown) {
+ const req = new Request("http://127.0.0.1:10100/api/settings", {
+ method: body === undefined ? "GET" : "PUT",
+ headers: { host: "127.0.0.1:10100", "content-type": "application/json" },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+ const deps: Partial = { getCachedStartupHealth: async () => startupHealthFixture() };
+ return handleManagementAPI(req, new URL(req.url), cfg, deps);
+}
+
+beforeEach(() => {
+ previousHome = process.env.OPENCODEX_HOME;
+ home = mkdtempSync(join(tmpdir(), "ocx-memory-models-settings-"));
+ process.env.OPENCODEX_HOME = home;
+ invalidateStartupHealthCache();
+});
+
+afterEach(() => {
+ invalidateStartupHealthCache();
+ if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
+ else process.env.OPENCODEX_HOME = previousHome;
+ removeTreeWithRetry(home);
+});
+
+describe("/api/settings memoryModels", () => {
+ test("an unconfigured install reports no memory routing", async () => {
+ const response = await settings(baseConfig());
+ expect(await response!.json()).toMatchObject({ memoryModels: null });
+ });
+
+ test("a save is echoed in the PUT response, persisted, and reported by GET", async () => {
+ const config = baseConfig();
+ saveConfig(config);
+ const setting = {
+ extract: { model: "gateway/cheap" },
+ consolidation: { model: "gateway/strong", reasoningEffort: "high" },
+ };
+ const put = await settings(config, { memoryModels: setting });
+ expect(put!.status).toBe(200);
+ expect(await put!.json()).toMatchObject({ ok: true, memoryModels: setting });
+ expect(config.memoryModels).toEqual(setting);
+ expect(loadConfig().memoryModels).toEqual(setting);
+ expect(await (await settings(config))!.json()).toMatchObject({ memoryModels: setting });
+ });
+
+ test("null clears the block from the file and from the response", async () => {
+ const config = baseConfig();
+ config.memoryModels = { extract: { model: "gateway/cheap" } };
+ saveConfig(config);
+ const put = await settings(config, { memoryModels: null });
+ expect(put!.status).toBe(200);
+ expect(await put!.json()).toMatchObject({ memoryModels: null });
+ expect(config.memoryModels).toBeUndefined();
+ expect(loadConfig().memoryModels).toBeUndefined();
+ const raw = JSON.parse(readFileSync(getConfigPath(), "utf8")) as Record;
+ expect(Object.hasOwn(raw, "memoryModels")).toBe(false);
+ });
+
+ test("a malformed phase is rejected before any mutation", async () => {
+ const config = baseConfig();
+ config.memoryModels = { extract: { model: "gateway/cheap" } };
+ saveConfig(config);
+ const before = structuredClone(config);
+ for (const value of [{ extract: { model: " " } }, { extract: { model: "m", reasoningEffort: "bogus" } },
+ { extract: { model: "m", extra: true } }, { extract: "gateway/cheap" }]) {
+ const response = await settings(config, { memoryModels: value });
+ expect(response!.status).toBe(400);
+ expect(config).toEqual(before);
+ }
+ });
+
+ test.each(["merged defaults", "salvaged profile"])("warns about a degraded phase after %s", route => {
+ const raw: Record = { ...baseConfig(), memoryModels: { extract: { model: " " } } };
+ if (route === "merged defaults") delete raw.defaultProvider;
+ else raw.routingProfiles = { bad: { candidates: [] } };
+ writeFileSync(getConfigPath(), JSON.stringify(raw));
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ const loaded = loadConfig();
+ expect(loaded.providers.gateway).toBeDefined();
+ expect(loaded.memoryModels?.extract).toBeUndefined();
+ expect(warn.mock.calls.flat().join("\n")).toContain("memoryModels.extract is invalid");
+ } finally { warn.mockRestore(); }
+ });
+});
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index b3cc772e296..a02bf39bd4a 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -783,6 +783,7 @@
"doctor.test.ts": "codex-integration",
"download-cap-default.test.ts": "images",
"download-connect-deadline-default.test.ts": "images",
+ "droid-client.test.ts": "clients",
"dsh-path-contract.test.ts": "ci-workflows",
"dsh-rc6-compat-script.test.ts": "ci-workflows",
"dsh-writer-lock.test.ts": "ci-workflows",
@@ -917,6 +918,7 @@
"jev-decision.test.ts": "routing",
"jev-provider.test.ts": "providers",
"keyring-smoke.test.ts": "ci-workflows",
+ "kilo-client.test.ts": "clients",
"kimi-oauth-identity.test.ts": "providers",
"kimi-responses-adjacency.test.ts": "providers",
"kiro-account-load.test.ts": "providers/kiro",
@@ -1297,6 +1299,7 @@
"provider-workspace-data.test.ts": "gui",
"provider-workspace-rail.test.ts": "gui",
"provider-workspace-state.test.ts": "gui",
+ "proxy-env-macos.test.ts": "server",
"proxy-env.test.ts": "server",
"proxy-liveness-package-tree-fence.test.ts": "server",
"proxy-liveness.test.ts": "server",
@@ -1452,6 +1455,7 @@
"responses-self-named-namespace-scrub.test.ts": "responses",
"responses-send-budget-counts.test.ts": "responses",
"responses-send-budget-errors.test.ts": "responses",
+ "responses-memory-models.test.ts": "responses",
"responses-shadow-intercept.test.ts": "responses",
"responses-show-thinking-summary.test.ts": "responses",
"responses-snapshot-repair-server.test.ts": "responses",
@@ -1558,6 +1562,7 @@
"settings-desktop-switch-apply.test.ts": "config",
"settings-fast-rows.test.ts": "config",
"settings-main-account-hard-lock.test.ts": "config",
+ "settings-memory-models.test.ts": "config",
"settings-oauth-open-browser.test.ts": "config",
"settings-startup-health-seam.test.ts": "config",
"settings-stream-mode.test.ts": "config",
diff --git a/tests/gui/combo-workspace-data.test.ts b/tests/gui/combo-workspace-data.test.ts
index 04c5e318be3..384ea45cbdb 100644
--- a/tests/gui/combo-workspace-data.test.ts
+++ b/tests/gui/combo-workspace-data.test.ts
@@ -130,6 +130,36 @@ describe("combo-workspace-data", () => {
});
});
+ test("parse, edit and PUT round-trip a target-specific model note", () => {
+ const parsed = parseComboList({ combos: [{
+ id: "jev-auto", strategy: "jev",
+ targets: [{ provider: "a", model: "m1", modelProfile: "Low marginal subscription cost; 1M context." }],
+ }] })[0]!;
+ expect(parsed.targets[0]?.modelProfile).toBe("Low marginal subscription cost; 1M context.");
+ expect(draftEquals(parsed, { ...parsed, targets: [{ ...parsed.targets[0]!, modelProfile: "Different" }] })).toBe(false);
+ expect(toPutBody(parsed).combo.targets[0]).toEqual({
+ provider: "a", model: "m1", modelProfile: "Low marginal subscription cost; 1M context.",
+ });
+ const switched = { ...parsed, strategy: "failover" as const };
+ expect(toPutBody(switched).combo.strategy).toBe("failover");
+ expect(toPutBody(switched).combo.targets[0]).toEqual({
+ provider: "a", model: "m1", modelProfile: "Low marginal subscription cost; 1M context.",
+ });
+ const blankNote = {
+ ...switched,
+ targets: [{ ...switched.targets[0]!, modelProfile: " \t " }],
+ };
+ expect(toPutBody(blankNote).combo.targets[0]).toEqual({ provider: "a", model: "m1" });
+ expect(validate({ ...parsed, targets: [{ ...parsed.targets[0]!, modelProfile: "x".repeat(513) }] }))
+ .toBe("invalidModelProfile");
+ expect(validate({ ...parsed, targets: [{ ...parsed.targets[0]!, modelProfile: "Bell\u0007note" }] }))
+ .toBe("invalidModelProfile");
+ expect(validate({ ...parsed, targets: [{ ...parsed.targets[0]!, modelProfile: "Del\u007fnote" }] }))
+ .toBe("invalidModelProfile");
+ expect(validate({ ...parsed, targets: [{ ...parsed.targets[0]!, modelProfile: "Line one\n\tLine two\r\nLine three" }] }))
+ .not.toBe("invalidModelProfile");
+ });
+
test("parse, dirty tracking, validation, and PUT preserve exact JEV target efforts", () => {
const payload = {
combos: [{
diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts
index 0378ac8e1b6..f172ae9059f 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(15);
+ expect(expected).toHaveLength(17);
expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected);
expect([...gui.CLIENTS].sort()).toEqual(expected);
@@ -252,6 +252,7 @@ describe("every client survives a full lifecycle", () => {
/** A pre-existing user document in each client's own format. */
const SEED: Record = {
cline: '{"version":1,"modes":{},"providers":{"mine":{"settings":{"provider":"mine"},"updatedAt":"2026-01-01T00:00:00.000Z","tokenSource":"manual"}}}\n',
+ droid: '{"theme":"dark","customModels":[{"model":"local","displayName":"Local","baseUrl":"http://127.0.0.1:11434/v1","provider":"generic-chat-completion-api"}]}\n',
opencode: '{\n "provider": {\n "mine": { "npm": "keep-me" }\n }\n}\n',
pi: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n',
omp: "providers:\n mine:\n api: http://keep-me\n",
@@ -273,10 +274,12 @@ describe("every client survives a full lifecycle", () => {
// contract -- verified against senpi's own compiled validator, not assumed
// 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',
};
/** Where the seed's user-owned entry lives when the seed is a sequence. */
const USER_ELEMENT: Partial> = {
raycast: ["providers", "[id=lmstudio]"],
+ droid: ["customModels", "[model=local]"],
};
for (const clientId of INTEGRATION_CLIENT_IDS) {
diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts
index f803be42c3e..6d40d36e466 100644
--- a/tests/helpers/responses-core-source.ts
+++ b/tests/helpers/responses-core-source.ts
@@ -35,6 +35,7 @@ export const RESPONSES_CORE_MODULES = [
"compaction-routing.ts",
"compaction-recovery.ts",
"compaction-recovery-policy.ts",
+ "memory-models.ts",
"request-transport.ts",
"request-sidecar-auth.ts",
"response-effects.ts",
diff --git a/tests/responses/responses-memory-models.test.ts b/tests/responses/responses-memory-models.test.ts
new file mode 100644
index 00000000000..90ed7968e22
--- /dev/null
+++ b/tests/responses/responses-memory-models.test.ts
@@ -0,0 +1,443 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import {
+ MEMORY_MODEL_TARGET_UNAVAILABLE_CODE,
+ applyMemoryModelEffort,
+ configuredMemoryModel,
+ detectMemoryModelPhase,
+} from "../../src/server/responses/memory-models";
+import { handleResponses } from "../../src/server/responses";
+import type { RequestLogContext } from "../../src/server/request-log";
+import { MODEL_NOT_ALLOWED_FOR_KEY } from "../../src/server/admission-model-scope";
+import { getDefaultConfig, validateConfigCandidate } from "../../src/config";
+import { configSchema } from "../../src/config/schema/config-schema";
+import { warnDegradedMemoryModels } from "../../src/config/load-degrade";
+import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos";
+import { acquireOwnedSpendHome } from "../helpers/owned-spend-home";
+import type { OcxConfig, OcxParsedRequest } from "../../src/types";
+import type { ServerWebSocket } from "bun";
+import { createWebsocketHandler } from "../../src/server/index/websocket-handler";
+import type { ServeOptionsContext } from "../../src/server/index/serve-options";
+import type { WsData } from "../../src/server/ws-bridge";
+
+const originalFetch = globalThis.fetch;
+/** The spend-journal writer lease is taken by startServer, so a bare handler call needs one. */
+let releaseSpendHome: (() => void) | undefined;
+
+/** Phase 1's shape: codex-rs marks the kind AND the thread source. */
+const extractMetadata = (extra: Record = {}) =>
+ JSON.stringify({ request_kind: "memory", thread_source: "memory_consolidation", ...extra });
+/** Phase 2's shape: an ordinary turn inside the consolidation thread. */
+const consolidationMetadata = () =>
+ JSON.stringify({ request_kind: "turn", thread_source: "memory_consolidation" });
+
+function config(): OcxConfig {
+ return {
+ ...getDefaultConfig(),
+ defaultProvider: "gateway",
+ providers: {
+ gateway: {
+ adapter: "openai-responses", authMode: "key",
+ baseUrl: "https://gateway.example/v1", apiKey: "fixture-key",
+ },
+ },
+ memoryModels: {
+ extract: { model: "gateway/cheap", reasoningEffort: "high" },
+ consolidation: { model: "gateway/strong", reasoningEffort: "xhigh" },
+ },
+ };
+}
+
+function body(model = "gpt-5.6-luna"): Record {
+ return {
+ model, stream: false,
+ reasoning: { effort: "low", summary: "auto" },
+ input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Summarize this rollout." }] }],
+ };
+}
+
+function request(value: unknown, metadata?: string, extraHeaders: Record = {}): Request {
+ return new Request("http://localhost/v1/responses", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json", session_id: "memory-models-fixture",
+ ...(metadata ? { "x-codex-turn-metadata": metadata } : {}),
+ ...extraHeaders,
+ },
+ body: JSON.stringify(value),
+ });
+}
+
+function completion(): Record {
+ return {
+ id: "resp_memory_fixture", status: "completed",
+ output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }],
+ usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
+ };
+}
+
+beforeEach(() => {
+ releaseSpendHome = acquireOwnedSpendHome();
+});
+
+afterEach(() => {
+ releaseSpendHome?.();
+ releaseSpendHome = undefined;
+ globalThis.fetch = originalFetch;
+ clearComboSelectionState();
+ clearComboTargetCooldowns();
+});
+
+describe("memory phase detection", () => {
+ test("recognizes each phase from Codex's own turn metadata", () => {
+ expect(detectMemoryModelPhase(body(), new Headers({ "x-codex-turn-metadata": extractMetadata() }))).toBe("extract");
+ expect(detectMemoryModelPhase(body("gpt-5.6-terra"), new Headers({ "x-codex-turn-metadata": consolidationMetadata() }))).toBe("consolidation");
+ });
+
+ test("explicit non-memory metadata outranks the sub-agent header", () => {
+ const headers = new Headers({
+ "x-codex-turn-metadata": JSON.stringify({ request_kind: "turn" }),
+ "x-openai-subagent": "memory_consolidation",
+ });
+ expect(detectMemoryModelPhase(body("gpt-5.6-terra"), headers)).toBeNull();
+ expect(detectMemoryModelPhase(body("gpt-5.6-terra"), new Headers({ "x-openai-subagent": "memory_consolidation" }))).toBe("consolidation");
+ // Any other internal turn category is not a memory turn.
+ expect(detectMemoryModelPhase(body(), new Headers({ "x-openai-subagent": "collab_spawn" }))).toBeNull();
+ expect(detectMemoryModelPhase(body(), new Headers({ "x-openai-subagent": "review" }))).toBeNull();
+ // Malformed client metadata is not absent metadata: the header fallback stays closed.
+ for (const malformed of ["bad", 42, ["x"], true, null]) {
+ expect(detectMemoryModelPhase(
+ { ...body("gpt-5.6-terra"), client_metadata: malformed },
+ new Headers({ "x-openai-subagent": "memory_consolidation" }),
+ )).toBeNull();
+ }
+ });
+
+ test("an ordinary turn, absent metadata, or malformed metadata is never a memory turn", () => {
+ expect(detectMemoryModelPhase(body(), new Headers())).toBeNull();
+ expect(detectMemoryModelPhase(body(), new Headers({ "x-codex-turn-metadata": JSON.stringify({ request_kind: "turn", thread_source: "cli" }) }))).toBeNull();
+ for (const value of ["{", "null", "[]", '"memory"', JSON.stringify({ request_kind: "memory_consolidation" })]) {
+ expect(detectMemoryModelPhase(body(), new Headers({ "x-codex-turn-metadata": value }))).toBeNull();
+ }
+ // A non-string copy is malformed rather than absent.
+ expect(detectMemoryModelPhase({ ...body(), client_metadata: { "x-codex-turn-metadata": 42 } }, new Headers())).toBeNull();
+ });
+
+ test("conflicting copies are not treated as a memory turn", () => {
+ for (const [header, embedded] of [[extractMetadata(), consolidationMetadata()], [consolidationMetadata(), extractMetadata()], [extractMetadata(), "{"], ["{", extractMetadata()]]) {
+ const input = { ...body(), client_metadata: { "x-codex-turn-metadata": embedded } };
+ expect(detectMemoryModelPhase(input, new Headers({ "x-codex-turn-metadata": header! }))).toBeNull();
+ }
+ });
+
+ test("both copies must agree on the same phase", () => {
+ const input = { ...body(), client_metadata: { "x-codex-turn-metadata": extractMetadata() } };
+ expect(detectMemoryModelPhase(input, new Headers({ "x-codex-turn-metadata": extractMetadata() }))).toBe("extract");
+ });
+
+ test("WebSocket frames read the body copy instead of the handshake header", () => {
+ const input = { ...body("gpt-5.6-terra"), client_metadata: { "x-codex-turn-metadata": consolidationMetadata() } };
+ const headers = new Headers({ "x-codex-turn-metadata": extractMetadata() });
+ expect(detectMemoryModelPhase(input, headers)).toBeNull();
+ expect(detectMemoryModelPhase(input, headers, { transport: "websocket" })).toBe("consolidation");
+ });
+
+ test("the connection's sub-agent header consolidates HTTP turns but not websocket frames", () => {
+ const headers = new Headers({ "x-openai-subagent": "memory_consolidation" });
+ expect(detectMemoryModelPhase(body("gpt-5.6-terra"), headers)).toBe("consolidation");
+ expect(detectMemoryModelPhase(body("gpt-5.6-terra"), headers, { transport: "websocket" })).toBeNull();
+ });
+});
+
+describe("memory model settings", () => {
+ test("a phase without a model is off, and a blank model is not a destination", () => {
+ const settings = config();
+ expect(configuredMemoryModel(settings, "extract")).toEqual({ model: "gateway/cheap", reasoningEffort: "high" });
+ delete settings.memoryModels!.consolidation;
+ expect(configuredMemoryModel(settings, "consolidation")).toBeUndefined();
+ settings.memoryModels = { extract: { model: " " } };
+ expect(configuredMemoryModel(settings, "extract")).toBeUndefined();
+ expect(configuredMemoryModel(undefined, "extract")).toBeUndefined();
+ });
+
+ test("the configured effort is written to both wire shapes", () => {
+ const parsed = { modelId: "gpt-5.6-luna", options: { reasoning: "low" }, _rawBody: { reasoning: { effort: "low", summary: "auto" } } } as unknown as OcxParsedRequest;
+ expect(applyMemoryModelEffort(parsed, config(), "extract")).toEqual({ from: "low", to: "high" });
+ expect(parsed.options.reasoning).toBe("high");
+ expect(parsed._rawBody!.reasoning).toEqual({ effort: "high", summary: "auto" });
+ // Idempotent, and a phase without an effort leaves Codex's own value alone.
+ expect(applyMemoryModelEffort(parsed, config(), "extract")).toBeNull();
+ expect(applyMemoryModelEffort(parsed, config(), "consolidation")).toEqual({ from: "high", to: "xhigh" });
+ const bare = config();
+ bare.memoryModels = { extract: { model: "gateway/cheap" } };
+ const untouched = { modelId: "gpt-5.6-luna", options: { reasoning: "low" }, _rawBody: {} } as unknown as OcxParsedRequest;
+ expect(applyMemoryModelEffort(untouched, bare, "extract")).toBeNull();
+ expect(untouched.options.reasoning).toBe("low");
+ });
+});
+
+describe("memory model config", () => {
+ test("validates both phases without resetting providers on malformed hand edits", () => {
+ expect(validateConfigCandidate(config()).ok).toBe(true);
+ for (const value of [null, [], "cheap", { extract: { model: " " } }, { extract: { model: 42 } },
+ { consolidation: { model: "gateway/strong", reasoningEffort: "fast" } },
+ { extract: { model: "gateway/cheap", typo: true } }, { extract: {}, unknown: true }]) {
+ const raw = { ...config(), memoryModels: value };
+ expect(validateConfigCandidate(raw).ok).toBe(false);
+ const loaded = configSchema.parse(raw);
+ expect(loaded.providers).toEqual(config().providers);
+ }
+ // A wholly broken block drops entirely; a broken phase drops only that phase, so a typo in
+ // one phase can no longer delete the operator's routing for the other.
+ for (const value of [null, [], "cheap"]) {
+ const loaded = configSchema.parse({ ...config(), memoryModels: value });
+ expect(loaded.memoryModels).toBeUndefined();
+ }
+ const oneBroken = configSchema.parse({ ...config(), memoryModels: { extract: { model: " " }, consolidation: { model: "gateway/strong" } } });
+ expect(oneBroken.memoryModels).toEqual({ consolidation: { model: "gateway/strong" } });
+ });
+
+ test("an empty block is valid and means both phases stay with Codex", () => {
+ const raw = { ...config(), memoryModels: {} };
+ expect(validateConfigCandidate(raw).ok).toBe(true);
+ expect(configuredMemoryModel(configSchema.parse(raw) as OcxConfig, "extract")).toBeUndefined();
+ });
+
+ test("a broken phase warns per phase at load; valid or absent blocks stay silent", () => {
+ const warnings: string[] = [];
+ const original = console.warn;
+ console.warn = (message: unknown) => { warnings.push(String(message)); };
+ try {
+ const invalid = { ...config(), memoryModels: { extract: { model: "" } } };
+ warnDegradedMemoryModels(invalid, configSchema.parse(invalid) as OcxConfig);
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]).toContain("memoryModels.extract is invalid");
+ // The route a broken phase keeps is not necessarily Codex's own model: the shadow-call
+ // intercept can still match the request.
+ expect(warnings[0]).toContain("keeps its existing route");
+ // The surviving phase is not repeated, and a wholly broken block keeps the block wording.
+ const survivor = { ...config(), memoryModels: { extract: { model: "" }, consolidation: { model: "gateway/strong" } } };
+ warnDegradedMemoryModels(survivor, configSchema.parse(survivor) as OcxConfig);
+ expect(warnings).toHaveLength(2);
+ expect(warnings[1]).toContain("memoryModels.extract is invalid");
+ const whole = { ...config(), memoryModels: "cheap" };
+ warnDegradedMemoryModels(whole, configSchema.parse(whole) as OcxConfig);
+ expect(warnings).toHaveLength(3);
+ expect(warnings[2]).toContain("memoryModels is invalid");
+ warnDegradedMemoryModels(config(), configSchema.parse(config()) as OcxConfig);
+ const absent = config();
+ delete absent.memoryModels;
+ warnDegradedMemoryModels(absent, configSchema.parse(absent) as OcxConfig);
+ expect(warnings).toHaveLength(3);
+ } finally {
+ console.warn = original;
+ }
+ });
+
+ test("an unrecognized phase key warns instead of vanishing on the next save", () => {
+ const warnings: string[] = [];
+ const original = console.warn;
+ console.warn = (message: unknown) => { warnings.push(String(message)); };
+ try {
+ const typo = { ...config(), memoryModels: { extrcat: { model: "gateway/cheap" }, consolidation: { model: "gateway/strong" } } };
+ const parsed = configSchema.parse(typo) as OcxConfig;
+ // The load schema stays permissive, so the misspelled key is stripped while the valid phase
+ // survives - which is exactly why the warning has to read the raw object.
+ expect(parsed.memoryModels).toEqual({ consolidation: { model: "gateway/strong" } });
+ warnDegradedMemoryModels(typo, parsed);
+ expect(warnings).toHaveLength(1);
+ // The key name is JSON-quoted because it is redacted and escaped before it reaches the log.
+ expect(warnings[0]).toContain('memoryModels."extrcat" is not a recognized phase');
+ } finally {
+ console.warn = original;
+ }
+ });
+});
+
+describe("memory model routing", () => {
+ test("WebSocket admission uses each frame's metadata, not its handshake subagent header", async () => {
+ const settings = config();
+ const called: string[] = [];
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
+ called.push(JSON.parse(String(init?.body)).model);
+ const event = { type: "response.completed", sequence_number: 0, response: completion() };
+ return new Response(`event: response.completed\ndata: ${JSON.stringify(event)}\n\n`, {
+ headers: { "content-type": "text/event-stream" },
+ });
+ }) as typeof fetch;
+ const handler = createWebsocketHandler({ config: settings, deps: {} } as ServeOptionsContext);
+ const sent: Array> = [];
+ const ws = {
+ readyState: 1,
+ data: { headers: new Headers({ "x-openai-subagent": "memory_consolidation", session_id: "memory-ws-fixture" }) } as WsData,
+ send: (text: string) => { sent.push(JSON.parse(text)); return 1; },
+ close() {},
+ } as unknown as ServerWebSocket;
+ const send = (value: Record) => handler.message(ws, JSON.stringify({ type: "response.create", ...value }));
+ const settled = async (count: number) => {
+ for (let i = 0; i < 1000; i++) {
+ if (sent.filter(frame => frame.type === "response.completed").length >= count) return;
+ await Bun.sleep(1);
+ }
+ throw new Error("WebSocket response did not complete");
+ };
+ try {
+ send({ ...body("gateway/ordinary"), client_metadata: { "x-codex-turn-metadata": JSON.stringify({ request_kind: "turn", thread_source: "cli" }) } });
+ await settled(1);
+ send({ ...body("gateway/ordinary"), client_metadata: { "x-codex-turn-metadata": consolidationMetadata() } });
+ await settled(2);
+ expect(called).toEqual(["ordinary", "strong"]);
+ } finally {
+ handler.close(ws);
+ }
+ });
+
+ test("HTTP admission leaves explicit non-memory metadata on the requested route", async () => {
+ const settings = config();
+ const called: string[] = [];
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
+ called.push(JSON.parse(String(init?.body)).model);
+ return Response.json(completion());
+ }) as typeof fetch;
+ const response = await handleResponses(request(body("gateway/ordinary"), JSON.stringify({ request_kind: "turn", thread_source: "cli" }), {
+ "x-openai-subagent": "memory_consolidation",
+ }), settings, { model: "", provider: "" });
+ expect(response.status).toBe(200);
+ await response.text();
+ expect(called).toEqual(["ordinary"]);
+ });
+
+ test("routes each phase to its own model and effort", async () => {
+ const settings = config();
+ const calls: Array> = [];
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
+ calls.push(JSON.parse(String(init?.body)));
+ return Response.json(completion());
+ }) as typeof fetch;
+
+ const extractCtx = { model: "", provider: "" } as RequestLogContext;
+ const extract = await handleResponses(request(body(), extractMetadata()), settings, extractCtx);
+ expect(extract.status).toBe(200);
+ await extract.text();
+ expect(calls[0]!.model).toBe("cheap");
+ expect(calls[0]!.reasoning.effort).toBe("high");
+ // The caller's own selector stays in the log; only the served model changed.
+ expect(extractCtx.requestedModel).toBe("gpt-5.6-luna");
+ expect(extractCtx.model).toBe("cheap");
+ expect(extractCtx.routeDecision?.selected.reason).toBe("memory-extract");
+
+ const consolidationCtx = { model: "", provider: "" } as RequestLogContext;
+ const consolidation = await handleResponses(request(body("gpt-5.6-terra"), consolidationMetadata()), settings, consolidationCtx);
+ expect(consolidation.status).toBe(200);
+ await consolidation.text();
+ expect(calls[1]!.model).toBe("strong");
+ expect(calls[1]!.reasoning.effort).toBe("xhigh");
+ expect(consolidationCtx.requestedModel).toBe("gpt-5.6-terra");
+ expect(consolidationCtx.routeDecision?.selected.reason).toBe("memory-consolidation");
+ });
+
+ test("an unconfigured phase and a turn without the marker keep their own model", async () => {
+ const settings = config();
+ delete settings.memoryModels!.consolidation;
+ const calls: Array> = [];
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
+ calls.push(JSON.parse(String(init?.body)));
+ return Response.json(completion());
+ }) as typeof fetch;
+
+ // Phase 2 with only Phase 1 configured, then a Phase 1 turn with nothing configured.
+ const consolidation = await handleResponses(request(body("gateway/normal"), consolidationMetadata()), settings, { model: "", provider: "" });
+ expect(consolidation.status).toBe(200);
+ await consolidation.text();
+ const configured = config();
+ delete configured.memoryModels;
+ const unconfigured = await handleResponses(request(body("gateway/normal"), extractMetadata()), configured, { model: "", provider: "" });
+ expect(unconfigured.status).toBe(200);
+ await unconfigured.text();
+ // Same model id, no marker: nothing about the phase may reach it.
+ const ordinary = await handleResponses(request(body("gateway/normal")), settings, { model: "", provider: "" });
+ expect(ordinary.status).toBe(200);
+ await ordinary.text();
+ expect(calls.map(call => [call.model, call.reasoning.effort])).toEqual([
+ ["normal", "low"], ["normal", "low"], ["normal", "low"],
+ ]);
+ });
+
+ test("a memory turn keeps the phase decision when the shadow intercept would match too", async () => {
+ const settings = config();
+ settings.shadowCallIntercept = { enabled: true, model: "gateway/helper" };
+ const calls: Array> = [];
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
+ calls.push(JSON.parse(String(init?.body)));
+ return Response.json(completion());
+ }) as typeof fetch;
+ const logCtx = { model: "", provider: "" } as { model: string; provider: string; shadowCallRewrittenFrom?: string };
+ const response = await handleResponses(request(body(), extractMetadata()), settings, logCtx);
+ expect(response.status).toBe(200);
+ await response.text();
+ expect(calls[0]!.model).toBe("cheap");
+ expect(calls[0]!.reasoning.effort).toBe("high");
+ // Phase 1 shares its model id with the app's helper calls, so the marker is what tells them apart.
+ expect(logCtx.shadowCallRewrittenFrom).toBeUndefined();
+ });
+
+ test("a target that no longer resolves fails the memory call instead of falling back", async () => {
+ const settings = config();
+ settings.memoryModels = { extract: { model: "ghost/secret-token" } };
+ const calls: string[] = [];
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
+ calls.push(JSON.parse(String(init?.body)).model);
+ return Response.json(completion());
+ }) as typeof fetch;
+ const response = await handleResponses(request(body(), extractMetadata()), settings, { model: "", provider: "" });
+ expect(response.status).toBe(409);
+ const error = await response.json() as { error: { code: string; message: string } };
+ expect(error.error.code).toBe(MEMORY_MODEL_TARGET_UNAVAILABLE_CODE);
+ expect(error.error.message).not.toContain("secret-token");
+ expect(calls).toEqual([]);
+ });
+
+ test("an admission denial on the memory target keeps the key's own refusal", async () => {
+ const settings = config();
+ settings.apiKeys = [{
+ id: "scoped", name: "mail", key: "ocx_data_" + "c".repeat(40),
+ createdAt: "2026-01-01T00:00:00.000Z", allowedProviders: ["elsewhere"],
+ }];
+ const calls: string[] = [];
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
+ calls.push(JSON.parse(String(init?.body)).model);
+ return Response.json(completion());
+ }) as typeof fetch;
+ const response = await handleResponses(
+ request(body(), extractMetadata()),
+ settings,
+ { model: "", provider: "" },
+ { admission: { kind: "configured", keyId: "scoped", source: "bearer" } },
+ );
+ // The shared resolver rethrows an admission refusal, so the memory path reports the key's
+ // scope instead of turning it into an unavailable target.
+ expect(response.status).toBe(403);
+ expect((await response.json() as { error: { type: string } }).error.type).toBe(MODEL_NOT_ALLOWED_FOR_KEY);
+ expect(calls).toEqual([]);
+ });
+
+ test("the phase decision survives the combo handoff", async () => {
+ const settings = config();
+ settings.combos = { memory: { targets: [{ provider: "gateway", model: "cheap" }] } };
+ settings.memoryModels = { extract: { model: "combo/memory", reasoningEffort: "high" } };
+ const calls: Array> = [];
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
+ calls.push(JSON.parse(String(init?.body)));
+ return Response.json(completion());
+ }) as typeof fetch;
+ const logCtx = { model: "", provider: "" } as RequestLogContext;
+ const response = await handleResponses(request(body(), extractMetadata()), settings, logCtx);
+ expect(response.status).toBe(200);
+ await response.text();
+ expect(calls[0]!.model).toBe("cheap");
+ expect(calls[0]!.reasoning.effort).toBe("high");
+ // A combo target has to reach the dispatcher as `model`, so the rewritten selector is what the
+ // log records as requested; the phase itself is named in the route decision.
+ expect(logCtx.requestedModel).toBe("combo/memory");
+ expect(logCtx.routeDecision?.selected.reason).toBe("memory-extract");
+ });
+});
diff --git a/tests/routing/combo-management-api.test.ts b/tests/routing/combo-management-api.test.ts
index 3267d8d968b..22515594541 100644
--- a/tests/routing/combo-management-api.test.ts
+++ b/tests/routing/combo-management-api.test.ts
@@ -275,6 +275,62 @@ describe("combo management API", () => {
});
});
+ test("model notes round-trip across strategies and reject invalid lengths", async () => {
+ await withTempHome(async () => {
+ const config = baseConfig({ combos: undefined });
+ saveConfig(config);
+ const created = await comboApi(config, "PUT", "/api/combos", {
+ id: "jev-profile",
+ combo: { strategy: "jev", targets: [{ provider: "a", model: "m1", modelProfile: " Low marginal subscription cost; 1M context. " }] },
+ });
+ expect(created?.status).toBe(200);
+ expect(config.combos?.["jev-profile"]?.targets[0]?.modelProfile).toBe("Low marginal subscription cost; 1M context.");
+ const listed = await responseJson(await comboApi(config, "GET", "/api/combos"));
+ expect(listed.combos[0].targets[0].modelProfile).toBe("Low marginal subscription cost; 1M context.");
+ const switched = await comboApi(config, "PUT", "/api/combos", {
+ id: "jev-profile",
+ combo: {
+ strategy: "failover",
+ targets: [{ provider: "a", model: "m1", modelProfile: " Low marginal subscription cost; 1M context. " }],
+ },
+ });
+ expect(switched?.status).toBe(200);
+ expect(config.combos?.["jev-profile"]).toMatchObject({
+ strategy: "failover",
+ targets: [{ modelProfile: "Low marginal subscription cost; 1M context." }],
+ });
+ const switchedListed = await responseJson(await comboApi(config, "GET", "/api/combos"));
+ expect(switchedListed.combos[0]).toMatchObject({
+ strategy: "failover",
+ targets: [{ modelProfile: "Low marginal subscription cost; 1M context." }],
+ });
+ const reloaded = readConfigDiagnostics();
+ expect(reloaded.source).toBe("file");
+ expect(reloaded.error).toBeNull();
+ expect(reloaded.config.combos?.["jev-profile"]?.targets[0]?.modelProfile)
+ .toBe("Low marginal subscription cost; 1M context.");
+ for (const invalidNote of [" ".repeat(513), " ", "Unsafe\u0000note", "Bell\u0007note", "Del\u007fnote", 123]) {
+ const rejected = await comboApi(config, "PUT", "/api/combos", {
+ id: "jev-profile",
+ combo: { strategy: "jev", targets: [{ provider: "a", model: "m1", modelProfile: invalidNote }] },
+ });
+ expect(rejected?.status).toBe(400);
+ }
+ const invalid = await comboApi(config, "PUT", "/api/combos", {
+ id: "jev-profile",
+ combo: { strategy: "jev", targets: [{ provider: "a", model: "m1", modelProfile: "x".repeat(513) }] },
+ });
+ expect(invalid?.status).toBe(400);
+ expect(config.combos?.["jev-profile"]?.targets[0]?.modelProfile).toBe("Low marginal subscription cost; 1M context.");
+ const multiline = await comboApi(config, "PUT", "/api/combos", {
+ id: "jev-profile",
+ combo: { strategy: "jev", targets: [{ provider: "a", model: "m1", modelProfile: "Line one\n\tLine two\r\nLine three" }] },
+ });
+ expect(multiline?.status).toBe(200);
+ expect(config.combos?.["jev-profile"]?.targets[0]?.modelProfile).toBe("Line one\n\tLine two\r\nLine three");
+ });
+ });
+
test("PUT preserves explicit cooldown knobs and omits sparse defaults", async () => {
await withTempHome(async () => {
const config = baseConfig({ combos: undefined });
diff --git a/tests/routing/jev-decision.test.ts b/tests/routing/jev-decision.test.ts
index d0a4a61f94b..2049d612a8f 100644
--- a/tests/routing/jev-decision.test.ts
+++ b/tests/routing/jev-decision.test.ts
@@ -341,6 +341,46 @@ describe("JEV decision client", () => {
});
});
+ test("transmits only the selected candidate note without changing built-in profiles or choices", async () => {
+ const bodies: Record[] = [];
+ const post = (async (_name, _provider, _url, init) => {
+ bodies.push(JSON.parse(String(init.body)) as Record);
+ return Response.json(validPayload);
+ }) as JevPost;
+ const noted = [
+ { ...candidates[0]!, modelProfile: " Subscription allowance for Astra. " },
+ candidates[1]!,
+ ];
+ await resolveJevDecision({ body: decisionBody, candidates: noted, fallback, config: jevConfig("secret"), post });
+ await resolveJevDecision({ body: decisionBody, candidates, fallback, config: jevConfig("secret"), post });
+ await resolveJevDecision({ body: decisionBody, candidates: [candidates[1]!], fallback, config: jevConfig("secret"), post });
+
+ expect(bodies).toHaveLength(3);
+ expect(bodies[0]?.state).toEqual({
+ ...buildJevState(decisionBody),
+ operator_notes: { "openai/gpt-6-astra": "Subscription allowance for Astra." },
+ });
+ expect(bodies[1]?.state).toEqual(buildJevState(decisionBody));
+ expect(bodies[2]?.state).toEqual(buildJevState(decisionBody));
+ expect((bodies[0]?.questions as Record)).toEqual(buildJevRouteQuestion(candidates));
+ expect((bodies[2]?.questions as Record)).toEqual(buildJevRouteQuestion([candidates[1]!]));
+ expect(JSON.stringify(bodies[2])).not.toContain("Astra");
+ });
+
+ test("rejects an oversized note before an outbound decision", async () => {
+ let calls = 0;
+ const post = (async () => { calls++; return Response.json(validPayload); }) as JevPost;
+ const decision = await resolveJevDecision({
+ body: decisionBody,
+ candidates: [{ ...candidates[0]!, modelProfile: "x".repeat(513) }],
+ fallback,
+ config: jevConfig("secret"),
+ post,
+ });
+ expect(decision.gate).toBe("invalid");
+ expect(calls).toBe(0);
+ });
+
test("resolves environment references and supports TypeSafe and provider-derived key fallbacks", async () => {
const previousTypesafe = process.env.TYPESAFE_API_KEY;
const previousJev = process.env.JEV_API_KEY;
diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts
index ef3cb4c590c..c1973856df1 100644
--- a/tests/server/management-client-config-route.test.ts
+++ b/tests/server/management-client-config-route.test.ts
@@ -289,9 +289,9 @@ describe("native Anthropic effort ladder reaches the Aside document", () => {
});
});
describe("GET /api/client-config", () => {
- for (const hostname of ["0.0.0.0", "::", "192.0.2.40"]) {
- test(`Raycast export refuses authenticated bind ${hostname} before generating a document`, async () => {
- const response = await clientConfigApi(baseConfig({ hostname }), "?client=raycast");
+ for (const client of ["raycast", "droid"]) for (const hostname of ["0.0.0.0", "::", "192.0.2.40"]) {
+ test(`${client} export refuses authenticated bind ${hostname} before generating a document`, async () => {
+ const response = await clientConfigApi(baseConfig({ hostname }), `?client=${client}`);
expect(response.status).toBe(400);
const body = await response.json() as Record;
expect(body.reason).toBe("non_loopback");
@@ -300,6 +300,18 @@ describe("GET /api/client-config", () => {
});
}
+ test("Droid export uses the declared unauthenticated listener", async () => {
+ const response = await clientConfigApi(baseConfig({
+ hostname: "0.0.0.0", unauthenticatedLoopbackListener: { enabled: true, port: 10237 },
+ }), "?client=droid");
+ expect(response.status).toBe(200);
+ const body = await response.json() as ClientConfigEnvelope;
+ const rows = (body.config as { customModels: Array<{ baseUrl: string }> }).customModels;
+ expect(rows.length).toBeGreaterThan(0);
+ expect(rows.every(row => row.baseUrl === "http://127.0.0.1:10237/v1")).toBe(true);
+ expect(body.text).not.toContain(REAL_LOOKING_KEY);
+ });
+
test("Raycast export uses the declared unauthenticated listener instead of the management port", async () => {
const response = await clientConfigApi(baseConfig({
hostname: "0.0.0.0",
diff --git a/tests/server/proxy-env-macos.test.ts b/tests/server/proxy-env-macos.test.ts
new file mode 100644
index 00000000000..500ea93ca18
--- /dev/null
+++ b/tests/server/proxy-env-macos.test.ts
@@ -0,0 +1,276 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { applyProxyEnvWith } from "../../src/config";
+import { readMacOSSystemProxy } from "../../src/config/macos-system-proxy";
+import { noProxyMatches, resolveProxyRoute, configureSocks5Fetch } from "../../src/lib/proxy-env";
+import type { OcxConfig } from "../../src/types";
+
+const KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"] as const;
+let saved: Record;
+const config = (proxy?: string, noProxy?: string | string[]): OcxConfig => ({ proxy, noProxy, providers: {} }) as OcxConfig;
+const scutil = (body: string): string => ` {\n${body}\n}`;
+const both = "HTTPEnable : 1\nHTTPProxy : proxy.example\nHTTPPort : 8080\nHTTPSEnable : 1\nHTTPSProxy : ::1\nHTTPSPort : 8443";
+const snapshot = (): Record => Object.fromEntries(KEYS.map(key => [key, process.env[key]]));
+
+beforeEach(() => {
+ saved = snapshot();
+ for (const key of KEYS) delete process.env[key];
+});
+afterEach(() => {
+ for (const key of KEYS) {
+ if (saved[key] === undefined) delete process.env[key];
+ else process.env[key] = saved[key];
+ }
+ configureSocks5Fetch();
+});
+
+describe('macOS proxy: "auto" (#5853)', () => {
+ test("enabled schemes and safe IP exceptions reach Bun's lowercase bypass", () => {
+ process.env.NO_PROXY = "upper.example";
+ process.env.no_proxy = "lower.example";
+ applyProxyEnvWith(config("auto", "private.example"), {
+ platform: "darwin",
+ macOSReader: () => scutil(`${both}\nExceptionsList : {\n0 : 203.0.113.7\n1 : ::1\n}`),
+ });
+ expect(process.env.HTTP_PROXY).toBe("http://proxy.example:8080");
+ expect(process.env.HTTPS_PROXY).toBe("http://[::1]:8443");
+ expect(process.env.NO_PROXY).toBe("upper.example,private.example,203.0.113.7,[::1],127.0.0.1,::1");
+ expect(process.env.no_proxy).toBe("lower.example,127.0.0.1,::1,[::1],private.example,203.0.113.7");
+ for (const hostname of ["private.example", "child.private.example"]) {
+ const url = new URL(`https://${hostname}/`);
+ expect(noProxyMatches(url, { no_proxy: process.env.no_proxy })).toBe(true);
+ expect(resolveProxyRoute(new URL(`wss://${hostname}/`))).toEqual({ kind: "direct" });
+ }
+ const unrelated = new URL("https://unrelated.example/");
+ expect(noProxyMatches(unrelated, { no_proxy: process.env.no_proxy })).toBe(false);
+ expect(resolveProxyRoute(new URL("wss://unrelated.example/")))
+ .toEqual({ kind: "proxy", proxy: "http://[::1]:8443" });
+ expect(noProxyMatches(new URL("http://203.0.113.7"), { no_proxy: process.env.no_proxy })).toBe(true);
+ expect(noProxyMatches(new URL("http://203.0.113.70"), { no_proxy: process.env.no_proxy })).toBe(false);
+ expect(resolveProxyRoute(new URL("https://example.org"))).toEqual({ kind: "proxy", proxy: "http://[::1]:8443" });
+ });
+
+ test.each(["localhost", "LOCALHOST", "LoCaLhOsT."])(
+ "configured %s refuses discovery with inherited lowercase bypass", localhost => {
+ process.env.no_proxy = "lower.example";
+ process.env.NO_PROXY = "upper.example";
+ const before = snapshot();
+ const lines: string[] = [];
+ const original = console.log;
+ console.log = (...args) => { lines.push(args.join(" ")); };
+ try {
+ applyProxyEnvWith(config("auto", [localhost, "private.example"]), {
+ platform: "darwin", macOSReader: () => scutil(both),
+ });
+ } finally { console.log = original; }
+ expect(snapshot()).toEqual(before);
+ expect(lines.join(" ")).toContain("discovery refused");
+ expect(lines.join(" ")).not.toContain("private.example");
+ },
+ );
+
+ test("without inherited lowercase bypass, configured localhost keeps the uppercase-only route", () => {
+ applyProxyEnvWith(config("auto", ["localhost", "private.example"]), {
+ platform: "darwin", macOSReader: () => scutil(both),
+ });
+ expect(process.env.NO_PROXY?.split(",")).toContain("localhost");
+ expect(process.env.NO_PROXY?.split(",")).toContain("private.example");
+ expect(process.env.no_proxy).toBeUndefined();
+ expect(resolveProxyRoute(new URL("ws://localhost/"))).toEqual({ kind: "direct" });
+ expect(resolveProxyRoute(new URL("ws://app.localhost/")))
+ .toEqual({ kind: "proxy", proxy: "http://proxy.example:8080" });
+ expect(resolveProxyRoute(new URL("ws://private.example/"))).toEqual({ kind: "direct" });
+ });
+
+ test("the all-host wildcard has the same bypass scope on both transports", () => {
+ process.env.no_proxy = "lower.example";
+ applyProxyEnvWith(config("auto"), {
+ platform: "darwin",
+ macOSReader: () => scutil(`${both}\nExceptionsList : {\n0 : *\n}`),
+ });
+ expect(process.env.NO_PROXY?.split(",")).toContain("*");
+ expect(process.env.no_proxy?.split(",")).toContain("*");
+ });
+
+ test("default macOS exceptions activate .local without routing link-local literals direct", () => {
+ process.env.NO_PROXY = "upper.example";
+ process.env.no_proxy = "lower.example";
+ const lines: string[] = [];
+ const original = console.log;
+ console.log = (...args) => { lines.push(args.join(" ")); };
+ try {
+ applyProxyEnvWith(config("auto"), {
+ platform: "darwin",
+ macOSReader: () => scutil(`${both}\nExceptionsList : {\n0 : *.local\n1 : 169.254/16\n}`),
+ });
+ } finally { console.log = original; }
+ expect(process.env.HTTP_PROXY).toBe("http://proxy.example:8080");
+ expect(process.env.NO_PROXY?.split(",")).toContain(".local");
+ expect(process.env.no_proxy?.split(",")).toContain(".local");
+ expect(process.env.NO_PROXY).not.toContain("169.254/16");
+ expect(process.env.no_proxy).not.toContain("169.254/16");
+ expect(lines.filter(line => line.includes("link-local"))).toHaveLength(1);
+ expect(lines.join(" ")).not.toContain("169.254/16");
+ for (const hostname of ["foo.local", "a.b.local", "local"]) {
+ const url = new URL(`http://${hostname}/`);
+ expect(noProxyMatches(url, { no_proxy: process.env.no_proxy })).toBe(true);
+ expect(resolveProxyRoute(new URL(`ws://${hostname}/`))).toEqual({ kind: "direct" });
+ }
+ for (const hostname of ["xlocal", "169.254.1.2"]) {
+ const url = new URL(`http://${hostname}/`);
+ expect(noProxyMatches(url, { no_proxy: process.env.no_proxy })).toBe(false);
+ expect(resolveProxyRoute(new URL(`ws://${hostname}/`))).toEqual({ kind: "proxy", proxy: process.env.HTTP_PROXY });
+ }
+ });
+
+ test.each(["169.254/16", "169.254.0.0/16", "fe80::/10", "FE80:0:0:0:0:0:0:0/10", "[fe80::]/10"])(
+ "drops only the exact link-local range %s", exception => {
+ expect(readMacOSSystemProxy(() => scutil(`${both}\nExceptionsList : {\n0 : ${exception}\n}`)))
+ .toEqual({ kind: "proxy", httpUrl: "http://proxy.example:8080", httpsUrl: "http://[::1]:8443", exceptions: [], droppedLinkLocal: true });
+ },
+ );
+
+ test.each(["HTTP", "HTTPS"])("preserves %s-only settings", scheme => {
+ applyProxyEnvWith(config(" AUTO "), {
+ platform: "darwin",
+ macOSReader: () => scutil(`${scheme}Enable : 1\n${scheme}Proxy : 127.0.0.1\n${scheme}Port : 7890`),
+ });
+ expect(process.env[`${scheme}_PROXY`]).toBe("http://127.0.0.1:7890");
+ expect(process.env[scheme === "HTTP" ? "HTTPS_PROXY" : "HTTP_PROXY"]).toBeUndefined();
+ });
+
+ test.each([
+ "localhost", "example.com", "bad entry", "10.0.0.0/8", "169.254.0.0/15",
+ "fe80::/9", "fe80::1/10", "*.*.local", "foo*.local", "*.bad_name", "*.",
+ ])("refuses an unrepresentable exception %s without any environment write", exception => {
+ process.env.NO_PROXY = "upper.example";
+ process.env.no_proxy = "lower.example";
+ const before = snapshot();
+ const lines: string[] = [];
+ const original = console.log;
+ console.log = (...args) => { lines.push(args.join(" ")); };
+ try {
+ applyProxyEnvWith(config("auto", "configured.example"), {
+ platform: "darwin",
+ macOSReader: () => scutil(`${both}\nExceptionsList : {\n0 : ${exception}\n}`),
+ });
+ } finally { console.log = original; }
+ expect(snapshot()).toEqual(before);
+ expect(lines.join(" ")).toContain("discovery refused");
+ expect(lines.join(" ")).not.toContain(exception);
+ });
+
+ test.each([
+ ["disabled", "HTTPEnable : 0"],
+ ["bad port", "HTTPEnable : 1\nHTTPProxy : proxy.example\nHTTPPort : 0"],
+ ["bad enable", `${both}\nHTTPEnable : maybe`],
+ ["bad syntax", "HTTPEnable : 1\nHTTPProxy : proxy.example\nHTTPPort : 8080\nExceptionsList : {\n0 : 127.0.0.1"],
+ ["SOCKS-only", "SOCKSEnable : 1\nSOCKSProxy : socks.example\nSOCKSPort : 1080"],
+ ["scoped-only", `__SCOPED__ : {\nen0 : {\n${both}\n}\n}`],
+ ["simple host bypass", `${both}\nExcludeSimpleHostnames : 1`],
+ ["PAC", `${both}\nProxyAutoConfigEnable : 1`],
+ ])("%s settings leave egress unchanged", (_case, body) => {
+ process.env.NO_PROXY = "upper.example";
+ process.env.no_proxy = "lower.example";
+ const before = snapshot();
+ applyProxyEnvWith(config("auto"), { platform: "darwin", macOSReader: () => scutil(body) });
+ expect(snapshot()).toEqual(before);
+ });
+
+ test("a failed scutil read leaves egress unchanged", () => {
+ const before = snapshot();
+ applyProxyEnvWith(config("auto"), { platform: "darwin", macOSReader: () => { throw new Error("secret"); } });
+ expect(snapshot()).toEqual(before);
+ expect(readMacOSSystemProxy(() => "garbage")).toEqual({ kind: "unreadable" });
+ });
+
+ test("a credential-shaped system proxy host is rejected without logging it", () => {
+ const before = snapshot();
+ const lines: string[] = [];
+ const original = console.log;
+ console.log = (...args) => { lines.push(args.join(" ")); };
+ try {
+ applyProxyEnvWith(config("auto"), {
+ platform: "darwin",
+ macOSReader: () => scutil("HTTPEnable : 1\nHTTPProxy : user:secret@proxy\nHTTPPort : 8080"),
+ });
+ } finally { console.log = original; }
+ expect(snapshot()).toEqual(before);
+ expect(lines.join(" ")).not.toContain("secret");
+ });
+
+ test("proxy unset never consults the system and leaves a proxy-free environment alone", () => {
+ let called = false;
+ const before = snapshot();
+ applyProxyEnvWith(config(), { platform: "darwin", macOSReader: () => { called = true; return scutil(both); } });
+ expect(called).toBe(false);
+ expect(snapshot()).toEqual(before);
+ });
+
+ test.each(["HTTP_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"])("inherited %s wins without system discovery", key => {
+ process.env[key] = key.toLowerCase().includes("all") ? "socks5h://socks.example:1080" : "http://inherited.example:8080";
+ process.env.NO_PROXY = "upper.example";
+ process.env.no_proxy = "lower.example";
+ const before = snapshot();
+ let called = false;
+ applyProxyEnvWith(config("auto"), { platform: "darwin", macOSReader: () => { called = true; return scutil(both); } });
+ expect(called).toBe(false);
+ expect(snapshot()).toEqual(before);
+ if (key.toLowerCase().includes("all")) {
+ // SOCKS wrapper reads uppercase; Bun's native HTTP transport reads lowercase.
+ expect(resolveProxyRoute(new URL("http://upper.example"))).toEqual({ kind: "direct" });
+ expect(resolveProxyRoute(new URL("http://lower.example")).kind).toBe("fallback");
+ }
+ });
+
+ test("mixed inherited SOCKS and HTTP routes keep their distinct bypass decisions", () => {
+ process.env.ALL_PROXY = "socks5h://socks.example:1080";
+ process.env.HTTP_PROXY = "http://http.example:8080";
+ process.env.NO_PROXY = "upper.example";
+ process.env.no_proxy = "lower.example";
+ const before = snapshot();
+ applyProxyEnvWith(config("auto"), { platform: "darwin", macOSReader: () => { throw new Error("must not read"); } });
+ expect(snapshot()).toEqual(before);
+ for (const [hostname, socksBypass, bunBypass] of [
+ ["upper.example", true, false],
+ ["lower.example", false, true],
+ ] as const) {
+ const url = new URL(`http://${hostname}/`);
+ expect(noProxyMatches(url, { NO_PROXY: process.env.NO_PROXY })).toBe(socksBypass);
+ expect(noProxyMatches(url, { no_proxy: process.env.no_proxy })).toBe(bunBypass);
+ }
+ });
+
+ test.skipIf(process.platform === "win32")("Bun's lowercase bypass and the WebSocket route's uppercase bypass remain distinct", () => {
+ process.env.HTTP_PROXY = "http://http.example:8080";
+ process.env.NO_PROXY = "upper.example.com";
+ process.env.no_proxy = "lower.example.com";
+ const before = snapshot();
+ applyProxyEnvWith(config("auto"), { platform: "darwin", macOSReader: () => { throw new Error("must not read"); } });
+ expect(snapshot()).toEqual(before);
+ const upper = new URL("http://upper.example.com/");
+ const lower = new URL("http://lower.example.com/");
+ // Bun uses the non-empty lowercase value; resolveProxyRoute uses uppercase
+ // even when that key is explicitly defined as an empty string.
+ expect(noProxyMatches(upper, { no_proxy: process.env.no_proxy })).toBe(false);
+ expect(noProxyMatches(lower, { no_proxy: process.env.no_proxy })).toBe(true);
+ expect(resolveProxyRoute(new URL("ws://upper.example.com/"))).toEqual({ kind: "direct" });
+ expect(resolveProxyRoute(new URL("ws://lower.example.com/"))).toEqual({ kind: "proxy", proxy: process.env.HTTP_PROXY });
+ process.env.NO_PROXY = "";
+ expect(resolveProxyRoute(new URL("ws://lower.example.com/"))).toEqual({ kind: "proxy", proxy: process.env.HTTP_PROXY });
+ });
+
+ test("the outbound proxy matcher does not widen localhost to app.localhost", () => {
+ process.env.no_proxy = "lower.example";
+ applyProxyEnvWith(config("auto"), {
+ platform: "darwin",
+ macOSReader: () => scutil("HTTPEnable : 1\nHTTPProxy : 127.0.0.1\nHTTPPort : 8080"),
+ });
+ expect(process.env.no_proxy).not.toContain("localhost");
+ for (const hostname of ["localhost", "app.localhost"]) {
+ const url = new URL(`http://${hostname}:12345/`);
+ expect(noProxyMatches(url, { no_proxy: process.env.no_proxy })).toBe(false);
+ expect(resolveProxyRoute(url, { HTTP_PROXY: process.env.HTTP_PROXY, no_proxy: process.env.no_proxy }))
+ .toEqual({ kind: "proxy", proxy: "http://127.0.0.1:8080" });
+ }
+ });
+});
diff --git a/tests/server/proxy-env.test.ts b/tests/server/proxy-env.test.ts
index 881b39af8e4..a65258b1d07 100644
--- a/tests/server/proxy-env.test.ts
+++ b/tests/server/proxy-env.test.ts
@@ -511,7 +511,7 @@ describe("applyProxyEnv with proxy: \"auto\" (#1525)", () => {
test("auto never leaks the literal into HTTP_PROXY when discovery yields nothing", () => {
for (const [platform, reader] of [
- ["darwin", () => ({ proxyEnable: "0x1", proxyServer: "127.0.0.1:1" })],
+ ["linux", () => ({ proxyEnable: "0x1", proxyServer: "127.0.0.1:1" })],
["win32", () => ({ proxyEnable: "0x0", proxyServer: "127.0.0.1:1" })],
["win32", () => ({ proxyEnable: "0x1", proxyServer: "socks=127.0.0.1:1080" })],
["win32", () => null],
diff --git a/tests/test-layout-tooling.test.ts b/tests/test-layout-tooling.test.ts
index ac8d023d6fa..c675c9ce639 100644
--- a/tests/test-layout-tooling.test.ts
+++ b/tests/test-layout-tooling.test.ts
@@ -359,7 +359,8 @@ describe("move end to end", () => {
explicit: {},
migrated: [],
};
- writeFileSync(join(root, "scripts", "test-layout", "layout.json"), JSON.stringify(layout, null, 2));
+ writeFileSync(join(root, "scripts", "test-layout", "layout.json"), JSON.stringify({ version: layout.version, root: layout.root, explicit: layout.explicit }, null, 2));
+ writeFileSync(join(root, "scripts", "test-layout", "seeds.json"), JSON.stringify({ keepAtRoot: layout.keepAtRoot, domains: layout.domains, migrated: layout.migrated }, null, 2));
git("add", "-A");
git("commit", "-q", "-m", "seed");
return { root, cleanup: () => removeTreeWithRetry(root) };
@@ -369,6 +370,7 @@ describe("move end to end", () => {
const { root, cleanup } = scratchRepo();
try {
const layoutPath = join(root, "scripts", "test-layout", "layout.json");
+ const layoutBefore = readFileSync(layoutPath, "utf8");
const logs: string[] = [];
const plan = planMoves(loadLayout(layoutPath), root, ["server", "providers"]);
expect(plan.moves.map(m => m.to).sort()).toEqual([
@@ -416,6 +418,8 @@ describe("move end to end", () => {
const serial = readFileSync(join(root, "scripts", "test.ts"), "utf8");
expect(serial).toContain('"providers/cursor/cursor-b.test.ts"');
expect(loadLayout(layoutPath).migrated).toEqual(["providers", "server"]);
+ expect(readFileSync(layoutPath, "utf8")).toBe(layoutBefore);
+ expect(JSON.parse(readFileSync(join(root, "scripts", "test-layout", "seeds.json"), "utf8")).migrated).toEqual(["providers", "server"]);
expect(readFileSync(join(root, "src", "thing.ts"), "utf8")).toBe("export const thing = 2;\n");
const status = Bun.spawnSync(["git", "status", "--porcelain"], { cwd: root }).stdout.toString();
// Renamed in the index, then rewritten in the worktree: git reports "RM".