# AGENTS.md — working on dsh-pi-ai-sync Guidance for humans and coding agents continuing development in this repo. Read this before changing the plugin. The README is the user-facing document; this file is the engineering one. ## What this project is A dsh Cordis plugin that keeps provider routes in step with the pi.dev gateway by maintaining **two layers**: 1. **pi-ai catalog layer** — writes missing model records into the installed `@earendil-works/pi-ai` catalog JSON. This is what `dsh-opencode-go-models` does, generalised to any route. 2. **settings layer** — reconciles `llm-pi-ai.providers..models`. This is what `dsh-model-sync` does. The rationale is in the README "Why" section. In short: on dsh 0.1.x a base-less model on a mixed-protocol route cannot be addressed through settings alone, so the fix has to happen in the catalog. ## Host facts this implementation relies on These were verified on the development host (`dsh 0.1.5-rc.3`, `pi-ai 0.85.1`). Re-verify them when bumping the supported dsh line. - **Catalog files**: `/node_modules/@earendil-works/pi-ai/dist/providers/data/.json`, a JSON object keyed by **api protocol** → model id → record. The records are loaded by `dist/providers/.models.js` via `import values from "./data/.json" with { type: "json" }` and flattened by `flattenModelCatalog` — **at boot only**. Hence the restart requirement. See `src/catalog.ts`. - **`zen` and `inference` are the same opencode backend.** pi.dev now states `https://opencode.ai/inference/…` where the catalog (and `dsh-opencode-go-models` before it) had `https://opencode.ai/zen/…`. Measured 2026-10-15: both families are live and serve an identical listing (`/zen/v1/models` and `/inference/v1/models` each 200, same 86 ids, 7212 bytes). A route-level `baseURL` in `llm-pi-ai` overrides a record's `baseUrl`, so a profile can pin an endpoint while everything else tracks pi.dev. - **Protocol resolution** (`dsh-llm-pi-ai/lib/index.js`, `resolveRouteModels`): `const api = request.api ?? base?.api ?? routeApi`, and `routeApi = sharedCatalogApi(defaults)` is `undefined` when the installed catalog mixes protocols. A missing api makes `resolveEntry` throw, and settings writes validate strictly. - **Settings model schema** (`dsh-llm-pi-ai/lib/index.js`, `modelFields`): `id`, `name`, `contextWindow`, `maxTokens`, `input`, `reasoningEfforts`, `compat`. There is **no per-model `api`** — that is the whole reason this plugin exists. - **`models` replaces the catalog; `modelOverrides` layers onto it.** `resolveRouteModels` computes `const entries = configured.length > 0 ? configured : [...defaults].map(m => ({ id: m.id, ...overrides[m.id] }))`. A non-empty `models` therefore makes the catalog's own list unused for that route, and a non-empty `modelOverrides` beside it is rejected outright ("models already replaces the served catalog"). `modelOverrides` may only name ids the catalog describes, and its fields are `modelFields` minus `id` (the id is the dict key). This is why `unset` strips `models` and leaves `modelOverrides` standing. - **Catalog-only ids are normal, and merges must stay add-only.** The disk catalog is the factory snapshot plus every id past rounds merged in; pi.dev's listing gains and drops models (promo `-free` / `-exp` ids rotate out — observed `opencode` 85 ids, then 83, twenty minutes later). An id the catalog has and pi.dev does not list is *catalog-only*, not a pi.dev error: nothing was deleted. Never "fix" the difference by removing either way — a partial pi.dev outage must not delete models from the picker. - **Settings seam**: `ctx.settings.register(ns, schema)` → scope with `.get()` and `.watch()`; writes via `settings.describe()` (find `ns === 'llm-pi-ai'`, read `revision` and raw `user`) then `settings.mutate('llm-pi-ai', ops, revision)` with `{ op: 'set'|'unset', path: [] }`. Retry once on `error.code === 'SETTINGS_CONFLICT'`. See `src/settings.ts`. - **pi.dev listing**: `GET https://pi.dev/api/models/providers/` returns an id-keyed object of catalog-shaped records (including `api`, `baseUrl`, `contextWindow`, `maxTokens`, `thinkingLevelMap`, `compat`, `cost`). 404/501 means the route is absent. See `src/pidev.ts`. - **Only a real restart reloads the catalog.** A round that added models must surface `restartNeeded` (see `RoundReport`). - **`modelOverrides` + `models` are mutually exclusive** in dsh's validation. `unset` mode removes `models` and leaves `modelOverrides` — valid, and the overrides then apply on top of the catalog. ## File map ``` src/ types.ts structural host interfaces (no dsh type deps) + config type errors.ts failure rendering (the `cause` chain) + the bounded retry policy pidev.ts pi.dev fetch + normalization catalog.ts pi-ai catalog discovery, merge, atomic write, backup/restore translate.ts pi.dev record → settings model profile (mirror mode only) settings.ts settings seam: unset / mirror writes, conflict retry sync.ts runRound() orchestration, injected deps, report restore.ts uninstall restore + pluginStillReferenced() index.ts cordis apply(): config schema, lifecycle, command, service test/ node:test suites (import from lib/, run after pretest build) scripts/ smoke-boot.mjs ``` `runRound(deps, config)` in `src/sync.ts` is the seam for tests: everything side-effecting (settings, fetch, catalog directory) is injectable. ## Build, check, test ```bash npm install # legacy-peer-deps is set in .npmrc npm run build # tsc -p tsconfig.json → lib/ npm run check # tsc --noEmit npm test # pretest builds, then node --test test/*.test.mjs node scripts/smoke-boot.mjs ``` Notes: - Tests import the **compiled** `lib/`, never `src/`, so always run through `npm test` (which builds first) or build manually. - `tsconfig` uses `allowImportingTsExtensions` + `rewriteRelativeImportExtensions`: import with `.ts` in `src/`, and tsc emits `.js` specifiers. - `verbatimModuleSyntax` + `erasableSyntaxOnly`: use `import type` for types, no enums / parameter properties. ## Local development against a real dsh ```bash # 1. install this checkout into a profile dsh plugin --profile web add /absolute/path/to/dsh-pi-ai-sync # 2. restart dsh (bundle change), then run a round # either wait for the startup round, or use /pi-ai-sync in the UI # 3. inspect results # - settings: ~/.dsh/settings.yaml, section `pi-ai-sync` and llm-pi-ai.providers..models # - catalog: /node_modules/@earendil-works/pi-ai/dist/providers/data/.json # - backups: /.json.pi-ai-sync-backup.json ``` The plugin must **not** be run beside `dsh-model-sync` on the same routes (they fight over `models`). Disable the other plugin/layer first. ## Conventions - **Atomic writes + backup.** Never write a catalog file in place; use `writeCatalogAtomic` and `ensureBackup` (`src/catalog.ts`). A reader must only ever see the old or the new complete file. - **Add-only catalog merges.** `mergeNewEntries` never modifies or removes existing records. Removal/pruning is deliberately out of scope for v0.1. - **`updateExistingEntries` is the only thing that touches an existing record, and it is opt-in** (`updateExistingModels`, default `false`). It must apply **pi.dev's own stated fields only** — never the defaults `toCatalogEntry` fills in, or a `cost` pi.dev merely omitted gets zeroed and a paid model is silently mispriced. Keep its comparison key-order-insensitive: a refresh that believes it changed something rewrites the catalog every round (measured: 0 further changes on the second pass). - **Fail-safe restore.** `restoreCatalogFile` refuses to roll back a file that was externally replaced (a pi-ai upgrade). Keep that guard. - **No dsh type dependencies at runtime.** Host services are consumed through the structural interfaces in `src/types.ts`; only `@deepseek-ai/schemastery` (`z`) is imported at runtime (for the config schema) and `@deepseek-ai/cordis` as a type. - **Versioning / changelog.** SemVer; update `CHANGELOG.md` (Keep a Changelog format) with every user-visible change. Keep `README.md` and `README.zh-CN.md` in sync. - **Tests.** Prefer pure-function tests plus temp-directory tests; inject `dataDir` / `fetchEntries` / a fake settings service rather than hitting the network or `~/.dsh`. - **Releasing.** A tag *is* the release; `main` is the development channel. Day-to-day a profile installs `github:jiaruihuang/dsh-pi-ai-sync`, so every push is live for the author with no version bookkeeping. Publish a version by bumping `version`, moving the changelog's `[Unreleased]` entries into a dated section, updating the two link definitions at the bottom of `CHANGELOG.md`, then `git tag -a vX.Y.Z -m … && git push origin vX.Y.Z`. Consumers pin with `github:jiaruihuang/dsh-pi-ai-sync#vX.Y.Z`. `lib/` is deliberately **not** committed and there is no `prepare`-less fallback: a `github:` install runs `prepare` (see `package.json`), which pnpm only allows after the profile lists the stable key `dsh-pi-ai-sync@git+https://github.com/jiaruihuang/dsh-pi-ai-sync.git` under `allowBuilds`. Do not swap that for the commit-pinned codeload key pnpm's own error text suggests — it goes stale on the next push. If installs must work with zero profile config, that is the point to move to a Release tarball or npm (see the roadmap), not to start committing `lib/`. ## Roadmap / known gaps ### Found on a real host (2026-10-14) Both found while debugging a real dsh profile, on a proxied network, which is how the development host reaches pi.dev. - **`src/pidev.ts` ignores the proxy environment.** Node's global `fetch` (undici) does **not** honour `http_proxy` / `https_proxy` by default, so the round talks to pi.dev directly even when the host has no direct route to it. Symptom observed: route `opencode` failed with a bare `TypeError: fetch failed` while `opencode-go` succeeded in the same round, and three immediate retries from the same shell all returned HTTP 200 / 50 KB — i.e. intermittent, network-shaped, not a pi.dev outage. Two candidate fixes: set `NODE_USE_ENV_PROXY=1` in the host environment (Node ≥ 24 honours it), or give `fetchPiDevEntries` an undici `EnvHttpProxyAgent` / `ProxyAgent` that respects `NO_PROXY`. **Shipped: the README documents the environment variable** — the code fix means taking on an `undici` dependency in a package that currently has none, and the host flag is one line in a launcher. Revisit if a dependency ever becomes acceptable; until then a proxied host must set the flag itself. - **A round loses its failure cause and does not retry. FIXED.** `errorText` now renders the whole `cause` chain (`fetch failed ← connect ETIMEDOUT 172.67.221.13:443`, AggregateError members capped at three), and the fetch is retried `fetchAttempts` times (default 3) with exponential backoff (500 ms base, 8 s ceiling) before the route is skipped. `isRetryable` retries 5xx/429 and every non-HTTP failure — a 4xx other than 429 is pi.dev answering, not a glitch. **The skip itself is unchanged and must stay: never unset `models` for a route whose listing could not be verified.** Two invariants from this work: `retry` must rethrow the last error **unwrapped** so the caller keeps the cause chain, and a missing/nonsensical attempt count must still run the operation exactly once (a `NaN` count that ran zero times reported someone else's error). ### Longer term - **Provider-native `GET /models` union** (dsh-model-sync's `providerNativeFetch`): fetch a provider's own listing for routes pi.dev lags and union the ids in (additions-only, capacities only when stated). Needs a per-route endpoint/auth table; `src/pidev.ts` is the place. - **`mirror` + `modelOverrides` folding**: currently skipped. Implementing it requires an overrides store (dsh-model-sync keeps one under `~/.dsh/models-store.json`) so folded values survive future rounds. - **ETag / 304 revalidation** for pi.dev fetches (dsh-model-sync has it). - **Per-route configuration** (e.g. different `settingsMode` per route). - **Catalog pruning** of ids the provider retired (opt-in, off by default). Refreshing records that still exist shipped as `updateExistingModels`; only removal is left. - **Real-host boot smoke**: `scripts/smoke-boot.mjs` currently validates the compiled plugin in-process. A CI smoke that boots a scratch dsh profile with the plugin mounted (mocking the pi-ai data dir) would catch cordis inject-gate regressions the way dsh-model-sync's smoke does. - **Cross-version support**: the settings namespace (`pi-ai-sync`) and the `settings.register` lifecycle are dsh 0.1.x. A 0.2.x port would move to the `Config`-schema / profile-entry-id model.