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: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,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 @@ -137,7 +137,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 @@ -429,6 +429,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
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的
- **更小的公开 API。** 包入口只导出 `apply`、`Config`、`inject` 和 `name`(宿主读取的名称),以及配置类型(`PluginConfig`、`ResolvedPluginConfig`、`ModelOverrideConfig`、`ModelRouteConfig`、`EndpointFamily`、`KeySourcePolicy`)。此前额外重新导出的其余内容现在都是内部实现。
- **`./src/*` 不再是导出路径。** `src/` 从未包含在发布文件中,所以这个子路径只在仓库内可解析,安装后并不存在。
- **`internalRoutes` 已移除。** 按协议划分的内部路由总是会挂载。仍带有 `internalRoutes` 的 profile 配置行可以通过校验,该键会被忽略。
- **停用或重载插件会还原它包装过的宿主方法。** 模型列表、模型发现与模型信息的装饰,以及平面适配器的补全,在停用时都会撤下;实时重载会替换它们,而不是再叠加一层,因此重载时移除的设置随即不再生效。
- **`@viztor/dsh-opencode` 不再发布。** 其最后一个版本上的 "Renamed to @viztor/dsh-opencode-patch" 弃用提示即为最终信息;请改装 `dsh-opencode-patch` 或 `@viztor/dsh-opencode-patch`。

## 📜 兼容性与验证
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