Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .freebuff/project-id

This file was deleted.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ coverage/

# Agent workspace: session notes and scratch data, not source.
.workbuddy-ai/
.freebuff/
3 changes: 1 addition & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Host bundle (`lib/index.mjs`), a thin `apply` barrel over small modules:
src/utils/ a function of its arguments. No context, no globals, no I/O.
src/ effects: read a context, ask a utility, cause something.

**The membership test for `utils/` is a property, not a vibe:** no `node:*`, no `@deepseek-ai/cordis`, no `process.env`, no imports from outside the folder except `guards.ts`. `test/architecture.test.ts` walks the tree and fails the build otherwise — verified by planting a violation of each kind and watching it fail.
**The membership test for `utils/` is a property, not a vibe:** no `node:*`, no `@deepseek-ai/cordis`, no `process.env`, no `fetch`, no reference to `console` or a global timer (`setTimeout`/`setInterval`/`setImmediate` and their `clear*` counterparts, called or passed as a value, bare or through `globalThis.`/`window.`/`global.`/`self.`), no relative import that resolves outside the folder (`guards.ts` lives inside it). A utility that needs a clock or a logger takes it as a parameter, the way `utils/retry.ts` takes its `sleep` and `warn` and leaves the timer to the caller. `test/architecture.test.ts` walks the folder recursively, resolves each import from the importing file's own directory, and fails the build otherwise — verified by planting a violation of each kind and watching it fail.

It is the same trick `scripts/check.ts` already uses for the client bundle, at finer grain.

Expand Down Expand Up @@ -87,7 +87,6 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f
- `src/go-limits-data.ts` — the Go plan allowances, per model and per tier, **generated** from the vendor's own page by `scripts/generate-go-limits.ts`; never hand-edit it, and regenerate with `pnpm run limits:shim -- --write`. The scheduled freshness job is what notices when the page moves, which it did: the docs went under `/v2/docs/console/` and serve HTML now, so the generator reads a table off that page rather than the markdown it used to.
- `scripts/freshness-scope.ts` — which of CI's three freshness checks (`catalog:shim`, `limits:shim`, `cli:version`) a pull request is GATED on: the ones whose inputs (the check's script and the local modules it loads, walked from the source as Node loads it — types erased by Node's own stripper, specifiers read by `es-module-lexer` — plus `src/identity.ts` for the CLI version; never the generated files) its own diff touches. The rest only warn, because vendor drift is not the pull request's regression: for the two generated tables the nightly run regenerates them and proposes the refresh, and for the CLI version it warns and the bump is a human pull request, which then gates. The gates fail closed: each runs unless the scope step answered `report`. The CLI version's offline half — every copy in the tree agrees with the pin, `cli:version -- --copies` — gates every pull request, since it skips the generated tables and only the repository can break it. `test/freshness-scope.test.ts` walks the real generators, runs the scope step's own shell and the CLI it calls, and evaluates the workflow's step conditions.
- `src/responses-routes.ts` · `src/responses-provider.ts` — the protocol table and the in-process mount of the host's `llm-pi-ai`.
- `src/adapter-replacement.ts` — taking a provider route over from the adapter that already serves it: `llm.registerAdapter` refuses a route the host owns (`DUPLICATE_ADAPTER`), so the current entry is released first, our adapter is registered through the ordinary API, and the original is put back on disposal. The step towards serving a mixed-protocol route as the same provider the user configured, instead of mounting hidden ones. **Not wired yet**: nothing in `src/` imports it, only its own test, so read it as the next step rather than as live behaviour.
- `src/purge-service.ts` · `src/purge-contract.ts` — the card's "purge deprecated models" action, and the shape it answers with. It **rewrites the profile**, deleting the declared model entries the live catalog has retired, through `configEditor` (the same service the settings UI writes with). That is deliberate: a read-time filter would leave the declaration in place, and then the only way to explain a missing row is to find the filter. One source of truth, and it is the configuration.
- `src/session-cost.ts` · `src/cordis-context.ts` · `src/debug.ts` — per-turn accounting, the typed host interfaces, and stream debug logging.
- `src/pi-provider.ts` — the per-protocol base URLs, the pi model object, and the gateway headers. Every value here is measured; see `docs/redirect-planes.md`.
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ The web profile wires this checkout with `link:`, so builds are picked up like t
- **Restore globals.** Tests that touch `globalThis.fetch` or `process.env` must restore them in `afterEach` (`vi.unstubAllGlobals()`, `delete process.env.…`).
- **Offline by construction.** `test/support/offline.ts` refuses every non-loopback request in the unit and mock suites. The catalog's background revalidation used to reach `models.opencode.ai` dozens of times per run, so a networked runner's results could depend on the vendor's live catalog. A case that means to fetch stubs `fetch` itself.
- **No order dependence.** `test/support/reset-process-state.ts` clears the plugin's module-level stores (captured keys, session spend, the live catalog) after every test. A new module-level store gets a reset next to its siblings. `pnpm run test:shuffle` is how to check, and CI runs it.
- **Tests reach `src` through `test/support/src.ts`.** It re-exports, from each defining module, everything a test needs, so a module move or a barrel change edits one file instead of thirty. A new test imports from `./support/src.ts` (`../support/src.ts` under `test/mock` and `test/e2e`); add a missing name there rather than importing a `src` path. The exceptions are `vi.mock("../src/x.ts")` targets (and their `importOriginal<typeof import(…)>` type), which must name the module; the architecture tests, whose subject is the `src` layout itself; and the jsdom (client) tests, which import the defining module directly, because `src.ts` re-exports the host graph (`node:sqlite`, pi, `lifecycle.ts`) and the next rule keeps that out of client tests. Importing the door loads the whole `src` graph, host and client, so a module that fails to load fails every converted file; a client-safe door, limited to what the bundle gate admits, is P2's to add if that cost shows. The remaining pre-existing direct imports are converted in P2.
- **Host-only fixtures stay out of shared helpers.** `test/test-helpers.ts` is imported by the jsdom (client) tests, so anything that needs `node:sqlite` or the filesystem belongs in `test/support/`, as `opencode-store-fixture.ts` does.
- **A test for a fix fails without it.** Before committing a regression test, revert the fix locally and watch it fail.
- **Meaningful coverage.** Every config field in the `SPECS` register (`src/settings-fields.ts`) needs both the on and off path where it has one; every passthrough claim needs a non-OpenCode URL test proving headers are untouched; helper functions exported from `usage-ui.ts` are asserted directly, not re-derived in the test.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ These exist in the schema but render no control — each is a literal, a marker,
| `userAgent` | empty (= canonical CLI UA) | Literal override; the default is what the gateway expects |
| `originClient` | `cli` | Literal `x-opencode-client` value |
| `sessionIdEnv` | `OPENCODE_SESSION_ID` | Names an env var only a caller-supplied session id uses |
| `freeModelMarker` | `free` | Model-id substring; `*` forces the fallback, `''` disables it |
| `freeModelMarker` | `free` | Model-id substring; `*` forces the fallback; a blank value inherits the default; catalog-free models always qualify |
| `usageBaseURL` | `https://opencode.ai/zen/go/v1` | Endpoint override; auto-discovered from the composition |
| `credentialOrg` | — | The org id an account credential belongs to, when the deployment supplies one out of band |
| `debug` / `debugFile` | `false` / — | Diagnostic JSONL logging, not a behaviour anyone tunes in the UI; the file is written only while `debug` is on |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的
| `userAgent` | 空(= 规范 CLI UA) | 字面覆盖;默认值就是网关所期望的值 |
| `originClient` | `cli` | `x-opencode-client` 的字面值 |
| `sessionIdEnv` | `OPENCODE_SESSION_ID` | 指定一个仅当调用方提供会话 id 时才使用的环境变量名 |
| `freeModelMarker` | `free` | 模型 id 子串;`*` 强制启用回退,`''` 关闭回退 |
| `freeModelMarker` | `free` | 模型 id 子串;`*` 强制启用回退;留空则沿用默认值;目录标记为免费的模型始终适用 |
| `usageBaseURL` | `https://opencode.ai/zen/go/v1` | 端点覆盖;从组合中自动发现 |
| `debug` / `debugFile` | `false` / — | 诊断用 JSONL 日志,不是谁会在 UI 里调节的行为;仅在 `debug` 开启时才写入该文件 |

Expand Down
17 changes: 14 additions & 3 deletions cordis.patch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@
# model discovery; off leaves both raw (default true).
# injectCoreTools: boolean auto-inject read & bash tool schemas on free models (default true).
# usageEnabled: boolean enable host-side OpenCode Go usage querying (default true).
# showUsagePrice: boolean show session spend + active model rate in the meter (default true).
# keySource: string which credential source wins: auto | request | configured (default 'auto').
# readOpencodeStore: boolean read the credential an OpenCode installation already holds, as the LAST
# resort after every declared one (default false).
# endpoint: string endpoint family: auto | modern | legacy (default 'auto', an alias of
# modern). modern = the per-protocol inference family; legacy = one base
# per plane.
#
# Config-only overrides — the card deliberately hides these, because each is a
# literal, a marker, or a reference whose default fits every documented setup.
Expand All @@ -33,10 +37,18 @@
# userAgent: string optional custom User-Agent override (default empty = use OpenCode CLI UA).
# originClient: string x-opencode-client value (default 'cli').
# sessionIdEnv: string env var carrying a caller-supplied session id (default 'OPENCODE_SESSION_ID').
# freeModelMarker: string model-id substring getting the core-tool fallback ('*' = all, '' = none; default 'free').
# freeModelMarker: string model-id substring getting the core-tool fallback ('*' = all; a blank
# value inherits the default 'free'; models the catalog marks free always
# qualify).
# gatewayUrls: [string] URL substrings marking OpenCode gateway traffic (default both
# families: ['opencode.ai/zen', 'opencode.ai/inference']).
# usageBaseURL: string OpenCode Go gateway base URL (default 'https://opencode.ai/zen/go/v1').
# credentialOrg: string org id (wrk_...) sent with an account credential that came from elsewhere.
# maxContextWindow: number optional ceiling clamping model context windows (e.g. 131072).
# modelOverrides: object per-model overrides for contextWindow, maxTokens and metadata (default {}).
# modelRoutes: object per-model wire routing: { <id>: { api, baseURL } } (default {}).
# internalRoutes: boolean mount the internal per-protocol routes; off for a deployment that
# declares its own providers for the other protocols (default true).
# debug: true|false log every streamed call that receives the header.
# debugFile: path optional append target for stream debug JSONL; written only while debug is on.
- insert:
Expand All @@ -58,5 +70,4 @@
injectCoreTools: true
enrichModels: true
usageEnabled: true
showUsagePrice: true
debug: false
12 changes: 12 additions & 0 deletions docs/engineering-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,18 @@ What does NOT work, stated plainly, is the other half. `prepareCall` reads the r

The fix is therefore not a mount: it is a route whose adapter the plugin itself owns and whose `models` list carries the catalog's `reasoningEfforts` — the same `models` list the internal routes already use, which works precisely because `configured.length > 0` takes the `entries` from it directly and tolerates ids the installed catalog has never heard of. That means giving the model an SDK so `internalRouteFor` can redirect it, or listing it in the deployment's profile, and neither is something to invent without a measurement of which endpoint the model is actually served on.

## Replacing another plugin's adapter registration is not an API, so the plugin does not try

`src/adapter-replacement.ts` took a provider route over from the adapter that already served it: it released the llm registry's map entry for a route pi-ai held, registered our adapter through `registerAdapter`, and put the original entry back on disposal. Nothing in `src/` ever imported it, and P1b deleted it with its test.

It could not have been wired, and the reason is read from the host's llm runtime (`packages/llm/llm/src/index.ts`) rather than assumed:

1. **`registerAdapter` is all-or-nothing against a private map.** `prepareRoutes` throws `DUPLICATE_ADAPTER` for any route another registration holds (`:440-444`), so the only way in was to delete that registration's entry first.
2. **Only the owner moves its routes.** A registration's routes change through its own `handle.replace`, the method `registerAdapter` returns on its handle (`:421-428`). Nothing else is an API for it.
3. **The owner's disposer deletes what it still believes it holds.** On dispose, the registration deletes every route in its `owned` set and emits `llm/adapters-updated` (`registerAdapter`'s disposer, `:411-416`). An entry deleted from outside leaves pi-ai's registration believing it still owns the route, so its next dispose or restart deletes OUR entry — the plane then answers `NO_ADAPTER` — and its next `replace` registers over us. This failure mode is read from the host's disposer and has not been run. S0's handback failed for a different reason: the prototype released its registration while its own config listener was still live (see the plan's spike record), which is why P-R1 calls its handle and then makes its listener inert in one synchronous section, with no await between them, and restarts pi-ai only after both (the plan's Teardown).

What the plugin does instead (D13, landing in P-R1 as `src/host/route-claim.ts`): keep pi-ai from claiming `opencode` / `opencode-go` in the first place, through the config waterfall — a prepended `internal/config` listener strips the two plane keys from a copy of the config pi-ai reads — and register its own adapter under the same ids through the ordinary `registerAdapter`. On teardown it releases its own handle and restarts pi-ai, which hands the routes back. It never deletes another plugin's registration; `test/architecture.test.ts` fails the build on a direct `adapters.delete` / `set` / `clear` call (optional chaining included) anywhere in `lifecycle.ts`'s static import graph.

## A placeholder in a string has no spelling this linter accepts

`check.ts` asserts the refresh push carries the PAT by looking for the token URL as a literal, and the literal carries the workflow's own placeholders. Written plainly (`"…${GH_TOKEN}…"`), `no-template-curly-in-string` flags it; written with escapes (`"…\${GH_TOKEN}…"`), `no-useless-escape` flags every escape instead — a plain string has no spelling this oxlint accepts. A regex literal is not an escape hatch either: `prefer-includes` rejects a pattern that is a static string. The one spelling both rules accept is a template literal with `\${`: there the backslash is the documented way to write a literal placeholder, and neither rule applies.
Expand Down
2 changes: 1 addition & 1 deletion docs/improvement-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Phases land as **stacked pull requests**. Each one is based on the previous phas
| :---- | :-------------------------------------------- | :-------- |
| P0 | suite hardening, 12 defect fixes, this plan | in review |
| P1a | `fix:` shipped breakage | in review |
| P1b | `chore:` hygiene | planned |
| P1b | `chore:` hygiene | in review |
| P2 | `feat:` a smaller public API | planned |
| P-T1a | `build:` pin the host | planned |
| P-T1b | `test:` a harness that is the real host | planned |
Expand Down
Loading
Loading