Skip to content
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f
- `src/responses-routes.ts` · `src/responses-provider.ts` — the protocol table and the in-process mount of the host's `llm-pi-ai`.
- `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/host/patch-method.ts` — `patchMethod`, the one way to wrap a method on a host object. A dispose restores only while its wrapper is still the current value (otherwise it becomes a pass-through under whatever wrapped on top), and deletes the own property when the original was inherited rather than assigning it back; a second install of the same site wraps the unpatched method instead of the first install. Identity is read from own descriptors, never from a property read, because cordis's traceable proxy answers a fresh proxy for every method read (`docs/engineering-notes.md`). `rewrapOnAdaptersUpdated` reinstalls a site on `llm/adapters-updated`, for a site that must follow the registry; none uses it yet. Every instance patch in `src/` goes through this module — today the four sites in `models-discovery.ts` — and `test/reload.test.ts` runs apply A, apply B, dispose A, dispose B on each. Two tripwires hold the rule. `test/architecture.test.ts` is textual: anywhere `apply()` reaches but the helper, it fails on a `Reflect.set`/`defineProperty`/`deleteProperty`/`setPrototypeOf`, an `Object.assign`/`defineProperty`/`defineProperties`/`setPrototypeOf` onto `llm`, an adapter, `settings` or `globalThis` (bare or through a property path such as `registration?.adapter`; a prototype swap shadows a method without leaving an own property), a dotted or bracketed assignment (through `?.` or `!` too) onto the first three, and `globalThis.x =` (the global `fetch` patch is the one named exemption, until P6); a write through a local alias or a cast receiver escapes it. The `apply()`-twice reload case is behavioural: it requires the helper's record on every own function `apply()` leaves on the stand-in's `llm` and adapter, and P-R1 adds `settings` to the stand-in together with its site.
- `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`.
- `src/attachment-services.ts` — optional attachment and filesystem services for Google image requests, captured through Cordis child scopes.
- `src/google-bridge.ts` — the Google models, served through the plugin's own adapter on the real `llm` service. It is the ONLY module that imports pi at runtime, and its route is `opencode-google` so the `google` name stays free for a real deployment.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ The web profile wires this checkout with `link:`, so builds are picked up like t
- **Raise them when you genuinely add coverage** — a PR that lifts statements by a few points should lift the threshold with it, or the next contributor inherits a gate nobody re-measured.
- **Never set one above the current measurement.** That does not make the suite better; it makes every subsequent PR fail until someone deletes tests.

`src/index.ts` is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. `responses-provider.ts` and `responses-routes.ts` carry their own per-file floors: the mount has four host contracts to survive, and a well-covered average must not be able to hide it.
`src/index.ts` is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. `responses-provider.ts` and `responses-routes.ts` carry their own per-file floors: the mount has four host contracts to survive, and a well-covered average must not be able to hide it. `host/patch-method.ts` carries a 100% floor, because it is the one way to patch a host method and each of its branches is a reload case.

## The freshness checks in CI

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ These exist in the schema but render no control — each is a literal, a marker,
- **A smaller public API.** The package's entry exports `apply`, `Config`, `inject` and `name` — the names the host reads — and the config types (`PluginConfig`, `ResolvedPluginConfig`, `ModelOverrideConfig`, `ModelRouteConfig`, `EndpointFamily`, `KeySourcePolicy`). Everything else it used to re-export is internal now.
- **`./src/*` is no longer an export.** `src/` was never in the published files, so the subpath resolved in the repository and nowhere else.
- **`internalRoutes` was removed.** The per-protocol routes always mount. A profile row that still carries `internalRoutes` validates and the key is ignored.
- **Disabling or reloading the plugin restores the host methods it wrapped.** The model listing, model discovery and model-info decorations, and the plane adapters' fill, come off on a disable; a live reload replaces them instead of stacking another layer, so a setting removed in the reload stops applying.
- **`@viztor/dsh-opencode` is no longer published.** Its last version's "Renamed to @viztor/dsh-opencode-patch" deprecation stands as the final message; install `dsh-opencode-patch` or `@viztor/dsh-opencode-patch` instead.

## 📜 Compatibility & Verification
Expand Down
18 changes: 18 additions & 0 deletions docs/engineering-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,3 +572,21 @@ Two properties decided the blast radius, and both pointed the same way:
The fix is `Reflect.apply(original, adapter, [provider, model, signal])`, the idiom `decorateModelInfo` already used for the service method, plus a `route in PLANES` gate on the loop. The shared pi-ai adapter still receives the wrap through its plane routes, so the fill keeps applying to its models on every route; an adapter behind no plane route keeps its own methods. The regressions are two cases that fail on the previous code: a class-based adapter whose method reads `this`, and a foreign route that must stay unwrapped.

Measured on the running deployment after the rebuild: `session/modelCatalog` answers with every group populated and `failures: []`.

## A service method read through cordis is a new function every time

Every disposer for the three `llm`-level patches (the listing, discovery and model-info wrappers) checked that it was still installed by reading the property back and comparing: `llm.discoverModels === decorated`. On the real host that comparison is never true. `ctx.llm` is cordis's traceable proxy: `createTraceable` answers each read of a function-valued property with `createShadowMethod`, a NEW `Proxy` around the function (the harness's `vendor/cordis/src/utils.ts`), and only a tracker marked `noShadow` is exempt, which a `Service`'s is not (`vendor/cordis/src/service.ts`, where the tracker is built). So the read is a fresh proxy every time, the check fails, and the disposer silently restores nothing.

Measured with a real `LlmRuntime` and pi-ai mounted (tier A): after every dispose each wrapper was still an own property of the service, and a second activation's wrapper wrapped a shadow of the first's, so a live reload stacked one more layer each time. The plain-object fixtures the unit suite used could not see it, because a plain object's read IS the function. The plane adapters had a different form of the same defect: their disposer assigned the original back without checking, so disposing the first of two activations wrote the prototype's method over the second's, and every dispose left an own property shadowing the class method.

What can be trusted on that proxy is what it does not trap. Its handler has `get`, `set` and `apply` and nothing else, so `Object.hasOwn`, `Reflect.getOwnPropertyDescriptor`, `Reflect.getPrototypeOf`, `Reflect.defineProperty` and `Reflect.deleteProperty` reach the instance itself.

The rule that followed is `src/host/patch-method.ts`, the one way the plugin wraps a host method:

- **Compare descriptors, not reads.** A dispose restores only when the own descriptor still holds the exact function it defined; otherwise someone wrapped on top, and ours turns into a pass-through.
- **Delete when the original was inherited.** Assigning the prototype's method back is not a restore: it leaves an own property that shadows the prototype for good.
- **A same-site install replaces instead of wrapping.** A second activation wraps the true unpatched method (the prototype's, or the recorded own original), and remembers the install it replaced, so either disposal order ends at the exact original.

One limit is kept on purpose. A foreign plugin that reads one of these methods through `ctx.llm`, wraps it, and later puts back what it read puts back a shadow proxy, not our function. The helper finds its records by exact identity, so it treats the proxy as foreign: the proxy stays an own property and passes the call through correctly, and a later install wraps it. A symbol tag would see through the shadow proxy, but it would equally see through a foreign `new Proxy(ours, { apply })` that decorates ours, and a dispose would then remove that live wrapper. One extra pass-through is the cheaper failure. `test/patch-method.test.ts` pins it on tier A.

`test/reload.test.ts` drives apply A, apply B, dispose A, dispose B (and the reverse) on every site, on own and inherited shapes and on the real host; it drives `apply()` itself twice, first disposed first, on a stand-in context. On the code before the helper, all four sites failed it.
Loading
Loading