From 22c8088bf9afe418bcafe94a892d86fcf395baed Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 04:54:35 +0800 Subject: [PATCH 001/242] OpenCode on DeepSeek Harness: session affinity, Zen gateway headers, and free-tier tool fallback DSH plugin (@viztor/dsh-opencode) that keeps OpenCode Zen free-tier models working: deterministic ses_ session IDs per DSH conversation, User-Agent and origin-header restoration past dsh-llm-pi-ai stripping, and read/bash schema fallback on free-tier /responses calls. Non-OpenCode traffic passes through untouched. 100% strict TypeScript (Node 24+, ES2024), Vite+ toolchain with zero- warning lint, 44 deterministic Vitest cases, DSH Web settings card (en/zh), tag-triggered OIDC release with provenance, CI on push/PR. Evolved from nobu121/dsh-opencode-session (MIT, nobu121 & viztor). --- .github/workflows/ci.yml | 27 + .github/workflows/release.yml | 51 + .gitignore | 7 + AGENTS.md | 43 + CHANGELOG.md | 80 + CONTRIBUTING.md | 44 + LICENSE | 22 + README.md | 85 + cordis.patch.yml | 27 + package.json | 99 ++ pnpm-lock.yaml | 2738 +++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 10 + scripts/name-client-bundle.ts | 21 + src/index.ts | 614 ++++++++ src/settings-page.tsx | 282 ++++ test/plugin.test.ts | 1017 ++++++++++++ tsconfig.json | 22 + vite.config.ts | 119 ++ 18 files changed, 5308 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cordis.patch.yml create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/name-client-bundle.ts create mode 100644 src/index.ts create mode 100644 src/settings-page.tsx create mode 100644 test/plugin.test.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5498057 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: ci + +on: + push: + branches: [main] + tags-ignore: ["v*.*.*"] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 12 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run check + - run: pnpm run test + - run: pnpm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2bf46b9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: release + +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 12 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run check + - run: pnpm run test + + publish: + needs: verify + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 12 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + registry-url: https://registry.npmjs.org + - run: pnpm install --frozen-lockfile + - name: tag matches package.json version + run: | + test "v$(node -p "require('./package.json').version")" = "${GITHUB_REF_NAME}" \ + || { echo "tag ${GITHUB_REF_NAME} != package.json version"; exit 1; } + - run: pnpm run build + # OIDC trusted publishing: no token needed. Requires the + # viztor/dsh-opencode + release.yml publisher registered on npmjs.com + # with `npm publish` allowed. Provenance is automatic. + - run: npm publish --access public diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d6b320 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +lib/ +node_modules/ +coverage/ +.build-check/ +.DS_Store +*.log +*.tgz diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..07e7cb7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +--- +tags: + - dsh + - plugin + - opencode + - cordis +status: note +aliases: + - dsh-opencode +--- + +# `dsh-opencode` — OpenCode on DeepSeek Harness + +> [!info] Summary DSH host plugin (`@viztor/dsh-opencode` on npm, repo `viztor/dsh-opencode`) that keeps OpenCode Zen free-tier models working inside DeepSeek Harness: deterministic `ses_…` session affinity, gateway origin-header restoration, and `read`/`bash` tool-schema fallback. Standards reference: [[OBSIDIAN]] (`~/dev/OBSIDIAN.md`). + +## How it works + +1. **Turn scope** — `apply()` hooks `llm/stream` for configured providers, derives a stable `ses_<12hex><14base62>` ID per DSH session (`openCodeSessionIdFor`, SHA-256), and carries it in `AsyncLocalStorage` across the streamed turn (`withStore`). +2. **Fetch patch** — `patchFetch()` intercepts only OpenCode traffic (`isOpenCodeRequest`: `opencode.ai/zen` URL or matching provider in turn state). It always sets `x-opencode-session`, optionally restores `User-Agent` / `x-opencode-client` / `x-opencode-project`, and injects fallback `read`+`bash` schemas into free-tier `/responses` bodies. Non-OpenCode requests return via the original fetch untouched. +3. **Settings UI** — `src/settings-page.tsx` builds `lib/client.js`, contributing the OpenCode Integration card under DSH Settings → Plugins (toggles for every injection + provider list + UA override). + +## Repo map + +- `src/index.ts` — host plugin: config, session hashing, ALS store, fetch patch, `apply`. No `as`, arrow consts, sync Promise wrappers (ALS-safe by design). +- `src/settings-page.tsx` — Web client bundle (React, en/zh). Guard unknown scope with `isSettingsFormScope`, never assert. +- `test/plugin.test.ts` — 44 deterministic Vitest cases; polling helper instead of sleeps; restores `globalThis.fetch`/env. +- `scripts/name-client-bundle.ts` — renames `vp pack`'s `.cjs` output to `lib/client.js` (DSH loader requires `.js`). +- `cordis.patch.yml` — default plugin row (`id: dsh-opencode`); header comments are the headless-config reference. +- `.github/workflows/` — `ci.yml` (push/PR: check+test+build), `release.yml` (tag `v*.*.*`: verify, guard tag==version, OIDC `npm publish`). +- `README.md` consumer docs · `CONTRIBUTING.md` dev conventions + release · `CHANGELOG.md` per-version record. + +## Commands & policies + +```sh +pnpm install # install dependencies +pnpm run build # vp pack -> lib/index.mjs + lib/index.d.mts + lib/client.js +pnpm run check # zero warnings/errors required +pnpm run test # 44 deterministic tests, fully green required +``` + +- Release: bump `package.json` + `CHANGELOG.md`, commit, `git tag vX.Y.Z && git push origin vX.Y.Z` (OIDC publishes; no tokens). +- DSH Web profile wires the published build: `~/.dsh/profiles/web/package.json` deps + `bundles` use `@viztor/dsh-opencode` (`link:` only for local dev). +- Hygiene: never hardcode `ses_…`/keys in src/tests/git; `lib/` gitignored; `OPENCODE_SESSION_ID` env override only. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8b2e622 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,80 @@ +# Changelog + +All notable changes to `dsh-opencode` are documented in this file. + +This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://github.com/nobu121/dsh-opencode-session) by [@nobu121](https://github.com/nobu121). + +--- + +## [0.2.1] - 2026-10-01 + +### Changed + +- **Published to npm as `@viztor/dsh-opencode`** (unscoped name is squatted; scopes need no org). +- **OIDC trusted publishing**: tag-triggered `release` workflow publishes with provenance, no tokens. +- **Continuous integration**: `ci.yml` runs check + tests + build on every push to `main` and every PR. +- **Docs restructure**: consumer-friendly README titled "OpenCode on DeepSeek Harness" with badges and troubleshooting; contributor guide split into `CONTRIBUTING.md`; project-specific `AGENTS.md`. + +### Fixed + +- Debug-file race in the session-affinity test: waits for the expected line count instead of first non-empty read. + +--- + +## [0.2.0] - 2026-10-01 + +### Added + +- **OpenCode Zen Free-Tier Gateway Support (`403 FreeTierError` fix)**: + - OpenCode's gateway (`https://opencode.ai/zen/v1`) enforces client origin and tool validation on free community models (such as `muse-spark-1.3-contributor-free` and `space-bunny-free`). + - DSH's internal LLM adapter (`dsh-llm-pi-ai`) classifies `user-agent` as a reserved header and strips it from outgoing requests. + - `dsh-opencode` restores `User-Agent: opencode/1.18.33 ...`, `x-opencode-client: cli`, and `x-opencode-project: global` at the network fetch layer. +- **Configurable Header Controls & User-Agent Override**: + - `injectUserAgent` (boolean, default `true`): Toggle User-Agent restoration on/off. + - `userAgent` (string, default empty): Allows specifying a custom User-Agent override string. When left empty, uses the canonical OpenCode CLI User-Agent. + - `injectOriginHeaders` (boolean, default `true`): Toggle injection of `x-opencode-client: cli` and `x-opencode-project: global`. + - `injectCoreTools` (boolean, default `true`): Toggle fallback injection of standard `read` (`filePath`) and `bash` (`command`) tool schemas on free-tier `/responses` requests when tools are empty. +- **Strict Request Differentiation**: + - Differentiates OpenCode API requests (`opencode.ai/zen` and configured provider routes) from all other network traffic. + - Non-OpenCode requests (e.g. `api.deepseek.com`, Anthropic, OpenAI, GitHub, arbitrary tool calls) pass through completely untouched. +- **Deterministic Session ID Hashing**: + - OpenCode Zen's gateway requires session IDs matching `^ses_[0-9a-f]{12}[A-Za-z0-9]{14}$` (30 characters). + - Raw DSH conversation UUIDs are deterministically hashed via SHA-256 into compliant `ses_...` IDs, preserving conversation turn affinity and prompt cache warmth without triggering gateway format validation errors. +- **DSH Web Client Settings UI (`src/settings-page.tsx`)**: + - Ships a client bundle (`lib/client.js`) registering an OpenCode configuration card under **DSH Settings -> Plugins**. + - Provides reactive UI controls for toggling User-Agent injection, editing User-Agent overrides, toggling origin headers, and managing provider lists. + - Full English (`en`) and Simplified Chinese (`zh`) localization. +- **100% TypeScript & Node 24+ Target**: + - Re-implemented the entire codebase in strict TypeScript (`src/index.ts`, `src/settings-page.tsx`, `test/plugin.test.ts`, `scripts/name-client-bundle.ts`). + - Runtime targeted to **Node 24+** (`engines: { node: ">=24" }`, `target: "node24"`, `ES2024`). +- **Vite+ (`vp`) Toolchain Integration**: + - Dual library bundling with tsdown (`vp pack`): Host ESM bundle + DTS emit (`lib/index.mjs`, `lib/index.d.mts`) and browser client bundle (`lib/client.js`). + - Oxlint linting extending Ultracite (`vp lint`). + - Oxfmt formatting (`vp fmt`). + - Parallel Vitest testing suite (`vp test`). +- **Comprehensive Unit Testing**: + - 44 deterministic tests covering hashing, config, session caching, stream context, request differentiation, header injection, tool injection, and plugin lifecycle. +- **Proper Attribution & MIT Licensing**: + - Dual copyright attribution acknowledging original author `@nobu121` and maintainer `@viztor`. + +### Changed + +- Renamed package to `dsh-opencode` to reflect full OpenCode platform integration beyond session headers. +- Safe environment fallback: `OPENCODE_SESSION_ID` can be supplied via environment; never hardcodes private session IDs in source or git history. + +--- + +## Upstream Comparison (vs `nobu121/dsh-opencode-session` v0.1.1) + +| Capability | Upstream (`v0.1.1`) | `dsh-opencode` (`v0.2.0`) | +| :-- | :-- | :-- | +| **Primary Goal** | Fix `400 MissingSessionID` on OpenCode Go | Fix `400 MissingSessionID` + `403 FreeTierError` on OpenCode Zen | +| **Header Injection** | `x-opencode-session` only | `x-opencode-session`, `User-Agent`, `x-opencode-client`, `x-opencode-project` | +| **Session ID Format** | Raw DSH UUID (triggers 403 on Zen) | Deterministic SHA-256 mapping to `ses_` | +| **User-Agent Handling** | None (stripped by DSH adapter) | Restored & configurable with custom override | +| **Free-Tier Gateway Tools** | None | Fallback injection of `read` & `bash` schemas | +| **Request Differentiation** | Provider filter on `llm/stream` | Provider filter + URL validation guard in `patchFetch` | +| **Settings UI** | None | DSH Web Client Settings Card (`src/settings-page.tsx`) | +| **Language** | Plain JavaScript (untyped `.js` / `.mjs`) | 100% Strict TypeScript | +| **Runtime Target** | Node 20+ | Node 24+ (`ES2024`) | +| **Toolchain** | Bare Node scripts | Vite+ (`vp pack`, `vp check`, `vp test`, Oxlint, Oxfmt) | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a1d102b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing to dsh-opencode + +## Prerequisites + +- Node.js `>= 24`, `pnpm` (v12) +- A checkout of this repo; for live testing, a DeepSeek Harness profile (see README for the `link:` setup) + +## Commands + +All tasks go through `pnpm` (which delegates to the Vite+ toolchain): + +```sh +pnpm install # install dependencies +pnpm run check # format + lint + types, must be zero warnings/errors +pnpm run test # Vitest suite, must be fully green and deterministic +pnpm run build # vp pack + client rename -> lib/index.mjs, lib/index.d.mts, lib/client.js +``` + +> Note: in some shells `pnpm exec` stalls; invoke the binary directly if so: `node node_modules/.pnpm/vite-plus@*/node_modules/vite-plus/bin/vp `. + +## Code conventions + +- **100% strict TypeScript.** No `any` leaks, no `as` assertions in `src/` — narrow `unknown` with `in`-operator type guards (`isRecord`, `isUnknownArray`, …). +- **Zero-warning policy.** `vp check` must report no errors _and_ no warnings. If a rule fights a correct pattern (e.g. sync Promise wrappers that preserve `AsyncLocalStorage` context), prefer a targeted `oxlint-disable` comment with justification over weakening the rule globally. +- **Sync-over-async for context propagation.** `withStore` iterators and the `fetch` patch intentionally return promises from non-`async` functions so `als.run()` keeps turn context without an extra tick. Don't "fix" these into `async`. +- **Style:** arrow-function consts (not `function` declarations), dot notation, explicit `=== undefined` checks, `oxfmt` formatting. + +## Test conventions + +- **Deterministic only.** No `Math.random()`, no fixed `sleep()` waits. Async file assertions poll with a deadline (`waitForFileContent`). +- **No secret fixtures.** Session IDs are derived at runtime (`openCodeSessionIdFor`) or read from `OPENCODE_SESSION_ID`; never hardcode `ses_…` or API keys. `lib/` and `*.log` stay gitignored. +- **Restore globals.** Tests that touch `globalThis.fetch` or `process.env` must restore them in `afterEach` (`vi.unstubAllGlobals()`, `delete process.env.…`). +- **Meaningful coverage.** Every toggle (`injectUserAgent`, `injectOriginHeaders`, `injectCoreTools`, `providers`, `userAgent`) needs both the on and off path; every passthrough claim needs a non-OpenCode URL test proving headers are untouched. + +## Release process (maintainers) + +1. Bump `version` in `package.json`, add a `CHANGELOG.md` entry, commit. +2. `git tag vX.Y.Z && git push origin vX.Y.Z` — the `release` workflow verifies tag == version, runs check + tests, builds, and publishes to npm via OIDC trusted publishing. No tokens involved. +3. CI (`ci.yml`) runs check + test + build on every push to `main` and every PR. Keep it green. + +## Docs + +- `README.md` is consumer-facing: problem-first, install, UI config, troubleshooting. No dev internals. +- `CHANGELOG.md` is the per-version record. `AGENTS.md` is the agent-facing project brief — keep it specific to this repo. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5aff6b5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 nobu121 (https://github.com/nobu121/dsh-opencode-session) +Copyright (c) 2026 viztor (https://github.com/viztor/dsh-opencode) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d504ed5 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# OpenCode on DeepSeek Harness + +[![npm version](https://img.shields.io/npm/v/@viztor/dsh-opencode)](https://www.npmjs.com/package/@viztor/dsh-opencode) [![CI](https://github.com/viztor/dsh-opencode/actions/workflows/ci.yml/badge.svg)](https://github.com/viztor/dsh-opencode/actions/workflows/ci.yml) [![Release](https://github.com/viztor/dsh-opencode/actions/workflows/release.yml/badge.svg)](https://github.com/viztor/dsh-opencode/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/npm/l/@viztor/dsh-opencode)](LICENSE) [![Node >= 24](https://img.shields.io/node/v/@viztor/dsh-opencode)](https://nodejs.org) + +Run free OpenCode Zen models (like `muse-spark-1.3-contributor-free`) inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) without `403 FreeTierError` or `400 MissingSessionID` errors. + +**Why this exists:** OpenCode's gateway only serves free-tier models to requests that look like the OpenCode CLI (specific `User-Agent`, client headers, and `ses_…` session IDs) and carry `read`/`bash` tool definitions. DeepSeek Harness strips the user agent, uses UUID session IDs the gateway rejects, and can send tool-less requests — so free-tier calls fail. This plugin restores what's needed at the network layer, **only for OpenCode traffic**. Everything else (DeepSeek, OpenAI, GitHub, tools) passes through byte-for-byte untouched. + +## Install + +Recommended — from npm: + +```sh +dsh plugin --profile web add @viztor/dsh-opencode +``` + +Or declare it in your profile's `package.json`: + +```json +{ + "dependencies": { + "@viztor/dsh-opencode": "^0.2.1" + }, + "dsh": { + "profile": { + "bundles": [ + "@deepseek-ai/dsh-base", + "@deepseek-ai/dsh-web-app", + "@viztor/dsh-opencode" + ] + } + } +} +``` + +Then run `pnpm install` in your profile directory. + +## Configure (no YAML needed) + +Open DSH Web → **Settings → Plugins → OpenCode Integration**, flip toggles, hit **Save**: + +| Setting | Default | What it does | +| :-- | :-- | :-- | +| Inject User-Agent | on | Restores the OpenCode CLI `User-Agent` DSH strips | +| User-Agent Override | empty | Custom string instead of the canonical CLI one | +| Inject Origin Headers | on | Adds `x-opencode-client: cli` + `x-opencode-project: global` | +| Inject Core Tools | on | Adds fallback `read`/`bash` schemas to free-tier `/responses` calls | +| Providers | `opencode, opencode-go` | Which route IDs get the treatment | + +## Headless / declarative config + +For servers or `cordis.patch.yml` overlays: + +```yaml +- id: dsh-opencode + name: dsh-opencode + config: + providers: [opencode, opencode-go] + injectUserAgent: true + userAgent: "" + injectOriginHeaders: true + injectCoreTools: true + mode: session-id + debug: false +``` + +Full option reference (types, `debug`/`debugFile`, `mode`): see [cordis.patch.yml](cordis.patch.yml) header comments. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +| :-- | :-- | :-- | +| `403 FreeTierError` on free models | Headers stripped or tools missing | Keep all three inject toggles on | +| `400 MissingSessionID` | No session header attached | Plugin must be in `bundles`; check it loaded | +| Paid/other providers misbehaving | Shouldn't happen — they're never touched | File an issue with a redacted log | + +## Links + +- [Contributing](CONTRIBUTING.md) — dev setup, conventions, release process +- [Changelog](CHANGELOG.md) — what changed in each version +- [License](LICENSE) — MIT (nobu121 & viztor) + +## Attribution + +Evolved from [`nobu121/dsh-opencode-session`](https://github.com/nobu121/dsh-opencode-session) by [@nobu121](https://github.com/nobu121), which pioneered the `x-opencode-session` approach for OpenCode Go. This project extends it to OpenCode Zen free-tier compatibility, deterministic session hashing, configurable headers, and a Web settings UI. diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..669759d --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,27 @@ +# dsh-opencode layer. +# +# Adds one host plugin row that manages session affinity, OpenCode Zen gateway +# origin headers, and free-tier compatibility for OpenCode, OpenCode Go, and OpenCode Zen routes. +# +# Configuration (row `config`, all optional): +# providers: [string] provider route keys to intercept (default ['opencode', 'opencode-go']). +# injectUserAgent: boolean restore OpenCode CLI User-Agent stripped by DSH adapter (default true). +# userAgent: string optional custom User-Agent override (default empty = use OpenCode CLI UA). +# injectOriginHeaders: bool inject x-opencode-client & x-opencode-project (default true). +# injectCoreTools: boolean auto-inject read & bash tool schemas on free models (default true). +# mode: 'session-id' | 'uuid' session derivation mode (default 'session-id'). +# debug: true|false log every streamed call that receives the header. +# debugFile: path optional append target for stream debug JSONL. +- insert: + - id: dsh-opencode + name: dsh-opencode + config: + providers: + - opencode + - opencode-go + injectUserAgent: true + userAgent: "" + injectOriginHeaders: true + injectCoreTools: true + mode: session-id + debug: false diff --git a/package.json b/package.json new file mode 100644 index 0000000..8003c6b --- /dev/null +++ b/package.json @@ -0,0 +1,99 @@ +{ + "name": "@viztor/dsh-opencode", + "version": "0.2.1", + "description": "DeepSeek Harness plugin: automatically adds stable per-conversation x-opencode-session and OpenCode gateway origin headers to OpenCode / OpenCode Go / OpenCode Zen requests — fixes 400 MissingSessionID and 403 FreeTierError, keeping session affinity and free-tier access working.", + "keywords": [ + "cordis", + "deepseek-harness", + "dsh", + "dsh-plugin", + "llm", + "missing-session-id", + "opencode", + "opencode-go", + "opencode-zen", + "prompt-cache", + "session-affinity", + "x-opencode-session" + ], + "homepage": "https://github.com/viztor/dsh-opencode", + "bugs": { + "url": "https://github.com/viztor/dsh-opencode/issues" + }, + "license": "MIT", + "author": { + "name": "viztor" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/viztor/dsh-opencode.git" + }, + "files": [ + "lib", + "cordis.patch.yml", + "README.md", + "CONTRIBUTING.md", + "CHANGELOG.md", + "LICENSE" + ], + "type": "module", + "main": "lib/index.mjs", + "types": "lib/index.d.mts", + "exports": { + ".": { + "types": "./lib/index.d.mts", + "import": "./lib/index.mjs", + "default": "./lib/index.mjs" + }, + "./client": { + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "vp pack && node --experimental-strip-types scripts/name-client-bundle.ts", + "check": "vp check", + "clean": "rm -rf lib", + "format": "vp fmt --check", + "format:fix": "vp fmt --write", + "lint": "vp lint", + "lint:fix": "vp lint --fix", + "prepare": "pnpm run build", + "prepublishOnly": "pnpm run release:gate", + "release:gate": "pnpm run build && pnpm run test", + "test": "vp test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-store": "0.2.0-rc.1", + "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.1", + "@types/node": "^26.6.3", + "@types/react": "^18.3.31", + "react": "^18.3.1", + "typescript": "^7.0.2", + "ultracite": "^7.12.1", + "vite-plus": "catalog:", + "vitest": "^5.0.2" + }, + "engines": { + "node": ">=24" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-plugin-manager", + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web" + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..0ad8823 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2738 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + vite-plus: + specifier: 1.0.0 + version: 1.0.0 + +overrides: + vite@*: npm:@voidzero-dev/vite-plus-core@1.0.0 + +importers: + + .: + devDependencies: + '@deepseek-ai/dsh-client-store': + specifier: 0.2.0-rc.1 + version: 0.2.0-rc.1(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-client-ui-primitives': + specifier: 0.2.0-rc.1 + version: 0.2.0-rc.1(@deepseek-ai/cordis@4.0.4) + '@types/node': + specifier: ^26.6.3 + version: 26.6.3 + '@types/react': + specifier: ^18.3.31 + version: 18.3.31 + react: + specifier: ^18.3.1 + version: 18.3.1 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + ultracite: + specifier: ^7.12.1 + version: 7.12.2(oxfmt@0.70.0(vite-plus@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)))(oxlint@1.85.0(oxlint-tsgolint@7.0.2003)(vite-plus@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1))) + vite-plus: + specifier: 'catalog:' + version: 1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1) + vitest: + specifier: ^5.0.2 + version: 5.0.2(@types/node@26.6.3)(@vitest/browser-preview@5.0.1)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@blazediff/core@1.10.0': + resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + + '@deepseek-ai/cordis@4.0.4': + resolution: {integrity: sha512-obgyxqWAmFn3Re8kvsuUnyW+ihrz6eJCnJO4fh1cQzDtmPYz/zzVeUkH9R94I0OwSVOocK67Kgakm04j/oQXzg==} + hasBin: true + peerDependencies: + '@deepseek-ai/cordis-plugin-include': ~1.0.9 + '@deepseek-ai/cordis-plugin-loader': ~1.0.5 + peerDependenciesMeta: + '@deepseek-ai/cordis-plugin-include': + optional: true + '@deepseek-ai/cordis-plugin-loader': + optional: true + + '@deepseek-ai/cosmokit@1.8.5': + resolution: {integrity: sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A==} + + '@deepseek-ai/dsh-client-store@0.2.0-rc.1': + resolution: {integrity: sha512-fvjAr7KvcfH/GOGiOuZiNhwZyga+HSyYsb0tvCBHHEn3UiyGZzaguaLa6RtN++GTvE+lUUExpWSNuTGlZQ28zw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.1': + resolution: {integrity: sha512-1CCpyIh5PJljyXS9zQP8ib6JAg2p6PELh+iZhD3VBjQC5ZZ5i/zdgNGVOGr6Uu+sUpU7BkjYPvUkhy7AZQJWYA==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/runtime@0.151.0': + resolution: {integrity: sha512-fNuliiqGTseerW2FTbxq6UOQavKL/NvNh9K3peJZZVNi1NbM4Ehscwp3MX9nu+9bfbSfmPUEivF779XQU5exiw==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + + '@oxfmt/binding-android-arm-eabi@0.70.0': + resolution: {integrity: sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.70.0': + resolution: {integrity: sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.70.0': + resolution: {integrity: sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.70.0': + resolution: {integrity: sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.70.0': + resolution: {integrity: sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.70.0': + resolution: {integrity: sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.70.0': + resolution: {integrity: sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.70.0': + resolution: {integrity: sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.70.0': + resolution: {integrity: sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.70.0': + resolution: {integrity: sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.70.0': + resolution: {integrity: sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.70.0': + resolution: {integrity: sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.70.0': + resolution: {integrity: sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.70.0': + resolution: {integrity: sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.70.0': + resolution: {integrity: sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.70.0': + resolution: {integrity: sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.70.0': + resolution: {integrity: sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.70.0': + resolution: {integrity: sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.70.0': + resolution: {integrity: sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint-tsgolint/darwin-arm64@7.0.2003': + resolution: {integrity: sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@7.0.2003': + resolution: {integrity: sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@7.0.2003': + resolution: {integrity: sha512-eAET4JpyfBbg8SO0K74o4R55tEjVC292pIyxGOw2XGf8x/HrPNyxwQ478jMYOM54FIVOHc8sJ6VRHxluy6lucw==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@7.0.2003': + resolution: {integrity: sha512-GXdyO/XyqDJ3s/llR/oOktLsYNjZtWSQBy0JMc+/0gsNPvTcseKPhn9c6KcFyWmnr6H4vljYALbujonqSzzEGw==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@7.0.2003': + resolution: {integrity: sha512-TWauXnPfet0VgpmrstoAK58eJ4gbuwPUuUTQmYQAzE/RG1CpnxXtrKUJULf6lyerPE4KN3gM+DflIA1hOH+38Q==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@7.0.2003': + resolution: {integrity: sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.85.0': + resolution: {integrity: sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.85.0': + resolution: {integrity: sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.85.0': + resolution: {integrity: sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.85.0': + resolution: {integrity: sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.85.0': + resolution: {integrity: sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.85.0': + resolution: {integrity: sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.85.0': + resolution: {integrity: sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.85.0': + resolution: {integrity: sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.85.0': + resolution: {integrity: sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.85.0': + resolution: {integrity: sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.85.0': + resolution: {integrity: sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.85.0': + resolution: {integrity: sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.85.0': + resolution: {integrity: sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.85.0': + resolution: {integrity: sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.85.0': + resolution: {integrity: sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.85.0': + resolution: {integrity: sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.85.0': + resolution: {integrity: sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.85.0': + resolution: {integrity: sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.85.0': + resolution: {integrity: sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/plugins@1.79.0': + resolution: {integrity: sha512-S0uyoxakDINJ4DPgqxGlEEvrdSMeQb7Z2lKVjxoY2gwsbZbfg2Xr8Klfeo5ZeraHmmdBCELFUHkSe6KEmBpMvg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.6.3': + resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitest/browser-preview@5.0.1': + resolution: {integrity: sha512-NagNnZD6A8ccl+pycLWX2b/e9YJtH5NuDUyiiadQjptMRuZVBAB9r9I3itdKm6exxtcl+rOTv2rWt6vu5uOm1A==} + peerDependencies: + vitest: 5.0.1 + + '@vitest/browser@5.0.1': + resolution: {integrity: sha512-s2UroEhP2BZPoen1qrXXTtVqnUcs6y2sxMLGCZ8tZDkH2iM/6tTC2GVTPCgF+oVF3X5M6wC/X2O0MvvoHNmH0Q==} + peerDependencies: + vitest: 5.0.1 + + '@vitest/mocker@5.0.1': + resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/mocker@5.0.2': + resolution: {integrity: sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@5.0.1': + resolution: {integrity: sha512-6guWwj5d9bguuefTOvJoq387tfpkzSv554YdUEGzjJH2PnnmvzTLQ1UQSuAk5wBFVhf2CUmy/S/palOcb6dmpA==} + + '@vitest/snapshot@5.0.1': + resolution: {integrity: sha512-aEW4fuQVg1i3fnC781qvwxC5QNptUvb+JjIUfX1WuWJqkjCgpiU8+I89mMcJmxOPLdaGAwEPFFVk02rGKYexvw==} + + '@vitest/spy@5.0.1': + resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} + + '@vitest/spy@5.0.2': + resolution: {integrity: sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==} + + '@vitest/ui@5.0.1': + resolution: {integrity: sha512-7PvQu/X9/pQoHYfNLAYL22qsD4/+sx2k7zpUA7XvjW0sc40r3/r0lGZ2fsEyOHMuO8Q1KjiO1QQVjJdnWUy/WQ==} + peerDependencies: + vitest: 5.0.1 + + '@vitest/utils@5.0.1': + resolution: {integrity: sha512-E9+yEA+jsfaoxZcUHFzEqUrQcoNh2EwrPT5efIqkUPUwD5Ua2Li9BRWaYeRwvzvdLgTSVrre8oKNeyrfg7KkdQ==} + + '@voidzero-dev/vite-plus-core@1.0.0': + resolution: {integrity: sha512-GHCd6d7h3JVojI/yxe/EAHwX5gn+OI7G932VBnYoTiwATLJrvHOs+okx7Ek2LGEJ57NJRENwCFgVYefjwPLfFA==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + publint: ^0.3.8 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: '>=0.5.0' + unrun: '*' + yaml: ^2.4.2 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + publint: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + yaml: + optional: true + + '@voidzero-dev/vite-plus-darwin-arm64@1.0.0': + resolution: {integrity: sha512-lR+0rHOCYQTUo4Un0Sy14Xi1NBqoPXRnhsRyc0Joe53fomZ+wPtgErk15tkvWQdSLGNOr8hDDWJKo/kzI0Y37w==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [darwin] + + '@voidzero-dev/vite-plus-darwin-x64@1.0.0': + resolution: {integrity: sha512-MW3YnWpYP6wxvpd5KLFYTdtHTcAVnN0mVoLrmA9KCKWfOzO8nanr8hpuyEIdLMQT3r/7VNKRvgROI0CZZDP9/A==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [darwin] + + '@voidzero-dev/vite-plus-linux-arm64-gnu@1.0.0': + resolution: {integrity: sha512-n4onkD4oT+g4jLj9rR37TM4i2IbaYvIiU68p1A/5KrwnIHzbv2maO0BqBYdIRnBy1Mil8pIx9HsT8riWCjZeTw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-arm64-musl@1.0.0': + resolution: {integrity: sha512-EZfgINa0FL/eIsLkL3MfQz7+Dc4VDAeIrf1UZDlitt//8NIOqI6nVMPnIFzMZThaIncIose+cVWsEkKLAKMM/A==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-linux-x64-gnu@1.0.0': + resolution: {integrity: sha512-R6+/oVLCSEvatOG1utYo0d0I++QPjWuRt2moD21+UUu2mQ8VyZHaaQLgoxrQG9shA9o0RBDmwxhGUXL20Rk1FA==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-x64-musl@1.0.0': + resolution: {integrity: sha512-B1Ottn22hn9RZ6XIVrWCXAAMfCOQRJ4KWoKbPL4JxC+UgvEiY9kC1ZFPqEAI89zIbW8eLdP3BeT14NQ0h1rtxw==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-win32-arm64-msvc@1.0.0': + resolution: {integrity: sha512-XXK6N5xMkGVIcqMaVDjqDh44z0Z00DA8dYn2BWUkFOQflUU1Z3IWt+bPm/1Y7LjkwBWj5qD2zsQY1ANDCeX9QA==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [win32] + + '@voidzero-dev/vite-plus-win32-x64-msvc@1.0.0': + resolution: {integrity: sha512-XabdwdAsJ9QqEVSpSOuiiyJjiEwbwOspGahei9svQUGjs7e8MPuj4y/ltBHJZpyCBdZI6fhlc7Xaps9bUtl7Og==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@yuku-codegen/binding-android-arm64@0.9.5': + resolution: {integrity: sha512-jrOY5WM+AaAqkv51fHP1x28ifto4WgcZVzodLUyMU1jMWn5Sq+VciRdk/n8E0Ey5w4p4cWWE+m5GfXWOYh7Kzw==} + cpu: [arm64] + os: [android] + + '@yuku-codegen/binding-darwin-arm64@0.9.5': + resolution: {integrity: sha512-4O4lkCQIzPZGjiNB1GORSI6hBECbiiSBS/APZXPvNKYH+nhY4uuqv03LNXA+SET3hoBjvr95P5rIhY8KQaQUBA==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.9.5': + resolution: {integrity: sha512-9EvoUO0SEhD6/d8VHdWuPerepPMSR1y84+UEgz8Un1Ope14Oe7xMvePpEQLTLovoIFZ8Zg3iZ8z8E11pZMqC3g==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.9.5': + resolution: {integrity: sha512-HqT78WwgHTmp8lwujoUa9CrIortX4DdpuiVC18ZPSGvuuJf4ylpIEI6QrQEM78Zwz3muhAWAOXZ5irdiYY+AyA==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.9.5': + resolution: {integrity: sha512-QJXwIW6Ms3QIawbcBIedmrmXnfdpuAOjZJ/eAABq5XTzWvSxZ7lutu9W5yIHahlaTaFnBo7ikrVMD1szLTpPUw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm-musl@0.9.5': + resolution: {integrity: sha512-eHbFy3IHGb+IYarrYwAo0yWSEQV3eAmEn6VrsKA6I1Wy1YPJxWqSJlV69JhqsHtJuJlljUIF3ex0y46yaKIu9w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-arm64-gnu@0.9.5': + resolution: {integrity: sha512-ByoJMbySTaDhjAXSu8q6Lh7HKg3YoesXpcT72aYk0Aiw4PCznmY4ybpLTq0RCvp0RIPhFm/6yECFiGBwyCC1nw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm64-musl@0.9.5': + resolution: {integrity: sha512-gD9vfXIoBw1toSxhO9rgmSu/FEfy3PMznJAxVjIuH8DrWEiDKXmJO0pJKfj1Ltbe/TmtWVZR+TjISqeSIGQzMg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-x64-gnu@0.9.5': + resolution: {integrity: sha512-BARvdnvqMGjOr5Iel2JH+9H5vAIE0R6H0Z2fsT02xrvmI/1ZXNB8lkuX+ZGevPhZb3zJ9WeC/R8JZstrsUOK5g==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-x64-musl@0.9.5': + resolution: {integrity: sha512-x8flcevS1fbb7ESrmpOY/pON4eInSaE/7Ktjqx2udOE2W33BNSZmJuwpyUgo54AoHNqrcaM7szqydyJn5fNvew==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-win32-arm64@0.9.5': + resolution: {integrity: sha512-KOL/rBatWqH4ZpCNoF8ZtSNdOJbAxBJLmk/VeRciepGROPTbPum1A9t67GpFgOU7qrkUWlPJdJ+KHKbvbmOt+w==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.9.5': + resolution: {integrity: sha512-FxENahEjWSan59Syh/us/Kf4wNq8qrJLFJ3R2N4Oiwtb6yNdz/rWLNTIoNSssnsp7IoWJdUP6o/Z8ppg7lXMcg==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-android-arm64@0.9.5': + resolution: {integrity: sha512-A2JCFCSHfnficqYEw4Iujpx7XrkMM3UfFcgJFmYpZSo9zM4nyhmdIIE0FogSduuU60lhM0/UcfuUBXIVNBMlGQ==} + cpu: [arm64] + os: [android] + + '@yuku-parser/binding-darwin-arm64@0.9.5': + resolution: {integrity: sha512-3PiyU+Eare4YuKaQ22N98/yAiROPY5o/NQJHraICzDvk4pgS+m+bgLKOvkGBRn33OnV95Vdv1Mn38b+MQHpULQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.9.5': + resolution: {integrity: sha512-blFMAFI7AInI83XaiOF8cIeiRM46Nz9EfpZtZPRLbKxSA9aAr5v8aHYpmfN6NoyXxAv/10pwS4gsAuc1W6fy5Q==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.9.5': + resolution: {integrity: sha512-TsNuL4qsZdO0tHp5GW43Y5fHeLPpPHA675wdcPNTcdq2ZO5AvsQWFFoiDg8CH4oBktfsQ9tdvKhjLnLYwKvp4g==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.9.5': + resolution: {integrity: sha512-b0afYK5gHeV8RdmOcqAlgM8ONsye4cax4DMnIBaoJyPMd1UTGvTbpkqQcPVdhmHlcfcGVGMV1laeVovlr/dscw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm-musl@0.9.5': + resolution: {integrity: sha512-fD3lKzl+r6j6n8DwiMY53qnh5DLqD8KJjCp+NbVud54Nnh3Q9Wprqss3YzyMHP3NSJk663Wlg1E1X5qOuFVFig==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-arm64-gnu@0.9.5': + resolution: {integrity: sha512-aRU/aCphV1MWCl/lvI6NX8LgucHQ68Fx+vz7NBb/5MEr2EuzCxiPOQqeFcMdWh+2AED/p0AvAREch0c+cSnlhA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm64-musl@0.9.5': + resolution: {integrity: sha512-5+Guro0l8H473YXlEjVNBRLN/IbPbJdnQh1zo0OLt49xxnON+XMJRRaEyTOTfkn9QSRg0+BhEKGR4W9Gu2uZJQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-x64-gnu@0.9.5': + resolution: {integrity: sha512-pwwSyV9q+GlvzSXlsMZBMlgk22L5bud714/vqZCdeUTivzeUVltQzUaf3IQXOGXdpc59JYTeuMCtbGquaAUoCA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-x64-musl@0.9.5': + resolution: {integrity: sha512-z18j6JN3lBHH8vzN7gG1M8fI0nlgItSfnC9PfbmUbt97iHViKfw2iA2G9fGwRMe5LyPRZBrejIlapbygPbpdaw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-win32-arm64@0.9.5': + resolution: {integrity: sha512-s6Gwttb1dQvtPX6Bgkw+UPC9IO3UBDXc6Zezog8MMgvu3UfJBBB9TQqKBX/2quu0Eyh+lLSAyNlIyyecaagUPg==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.9.5': + resolution: {integrity: sha512-FrERt9YWatY3bJfSUdi4YWY+6iQcyo3MzCC821BxlWM5TFZEHijnpz/bknR79VHlXY/9CvXz8ZlaAGPsTtN3nw==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.9.5': + resolution: {integrity: sha512-KiuLNNgX9uNealaWAR+G3/cMXnRk9x4TY2EkYe/KIag+UPdwiA0RRf1hr1WAxzTP8KGzCTkqUdLPvqh32sEO3w==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@6.1.1: + resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} + engines: {node: '>=22'} + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} + engines: {node: '>=14'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + execa@10.0.1: + resolution: {integrity: sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==} + engines: {node: '>=22'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-workspaces@0.3.1: + resolution: {integrity: sha512-UDkGILGJSA1LN5Aa7McxCid4sqW3/e+UYsVwyxki3dDT0F8+ym0rAfnCkEfkL0rO7M+8/mvkim4t/s3IPHmg+w==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + log-update@8.0.0: + resolution: {integrity: sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg==} + engines: {node: '>=22'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + nypm@0.6.10: + resolution: {integrity: sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw==} + engines: {node: '>=18'} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + oxfmt@0.70.0: + resolution: {integrity: sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint-tsgolint@7.0.2003: + resolution: {integrity: sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==} + hasBin: true + + oxlint@1.85.0: + resolution: {integrity: sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} + engines: {node: '>=18'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slice-ansi@9.0.1: + resolution: {integrity: sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==} + engines: {node: '>=22'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.2: + resolution: {integrity: sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultracite@7.12.2: + resolution: {integrity: sha512-0FQE7EuVFnX1FjtlIBklkfBTiZ5bJxknXSxcsjKos5Y73FlkasubWH7uVLS8jgOAI204B7xaIq+buE3vUybuwQ==} + hasBin: true + peerDependencies: + '@biomejs/biome': ^2.5.0 + eslint: ^10.0.0 + oxfmt: '>=0.59.0' + oxlint: ^1.82.0 + prettier: ^3.0.0 + stylelint: ^17.0.0 + peerDependenciesMeta: + '@biomejs/biome': + optional: true + eslint: + optional: true + oxfmt: + optional: true + oxlint: + optional: true + prettier: + optional: true + stylelint: + optional: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + vite-plus@1.0.0: + resolution: {integrity: sha512-2ezzBWt+AVO+I2F9Cpb2Of0J12Lat35kWZ/FRL2monMnKZuACKfkYAA/nMqdDo1KwB8sWjTKzmD9mRCh1Q1n+g==} + engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@vitest/browser-playwright': 5.0.1 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + peerDependenciesMeta: + '@vitest/browser-playwright': + optional: true + '@vitest/browser-webdriverio': + optional: true + + vitest@5.0.1: + resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.1 + '@vitest/browser-preview': 5.0.1 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.1 + '@vitest/coverage-v8': 5.0.1 + '@vitest/ui': 5.0.1 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vitest@5.0.2: + resolution: {integrity: sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.2 + '@vitest/browser-preview': 5.0.2 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.2 + '@vitest/coverage-v8': 5.0.2 + '@vitest/ui': 5.0.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which-command@0.1.0: + resolution: {integrity: sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==} + engines: {node: '>=22'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + why-is-node-running@3.2.2: + resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==} + engines: {node: '>=20.11'} + hasBin: true + + wrap-ansi@10.0.2: + resolution: {integrity: sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==} + engines: {node: '>=20'} + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + + yuku-ast@0.9.5: + resolution: {integrity: sha512-Q8qW8WwQnN5Cm0ZZivdRIfv0sRLTjUq0YumXJkw8CYN1aCdICH9rk4C47/4n6kA5EqDtlj+F608twoTSV2MwdQ==} + + yuku-codegen@0.9.5: + resolution: {integrity: sha512-zGUVyDpSK4b6c9B0yyxi2P0wujn1XZTbG9dCb7d6gyAoP63EMgyLzUwPUvwxPG5jgYqhlI7w+S3oCyapqGr4PA==} + + yuku-parser@0.9.5: + resolution: {integrity: sha512-IBnAdNVMswWbJcM7woPluOudectJzFjJ1N8jVRxP9Uq4CIv5hZdBIcdmtRbFDYF/Ph2j2gVup4YJ4N9mh0jYFA==} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/runtime@7.29.7': {} + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@blazediff/core@1.10.0': {} + + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@deepseek-ai/cordis@4.0.4': + dependencies: + '@deepseek-ai/cosmokit': 1.8.5 + '@standard-schema/spec': 1.1.0 + + '@deepseek-ai/cosmokit@1.8.5': {} + + '@deepseek-ai/dsh-client-store@0.2.0-rc.1(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + + '@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.1(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + + '@oxc-project/runtime@0.151.0': {} + + '@oxc-project/types@0.151.0': {} + + '@oxfmt/binding-android-arm-eabi@0.70.0': + optional: true + + '@oxfmt/binding-android-arm64@0.70.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.70.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.70.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.70.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.70.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.70.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.70.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.70.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.70.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.70.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.70.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.70.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.70.0': + optional: true + + '@oxlint-tsgolint/darwin-arm64@7.0.2003': + optional: true + + '@oxlint-tsgolint/darwin-x64@7.0.2003': + optional: true + + '@oxlint-tsgolint/linux-arm64@7.0.2003': + optional: true + + '@oxlint-tsgolint/linux-x64@7.0.2003': + optional: true + + '@oxlint-tsgolint/win32-arm64@7.0.2003': + optional: true + + '@oxlint-tsgolint/win32-x64@7.0.2003': + optional: true + + '@oxlint/binding-android-arm-eabi@1.85.0': + optional: true + + '@oxlint/binding-android-arm64@1.85.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.85.0': + optional: true + + '@oxlint/binding-darwin-x64@1.85.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.85.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.85.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.85.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.85.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.85.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.85.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.85.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.85.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.85.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.85.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.85.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.85.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.85.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.85.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.85.0': + optional: true + + '@oxlint/plugins@1.79.0': {} + + '@polka/url@1.0.0-next.29': {} + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.6.3': + dependencies: + undici-types: 8.9.0 + + '@types/prop-types@15.7.15': {} + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/browser-preview@5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.1)': + dependencies: + '@testing-library/dom': 10.4.2 + '@testing-library/user-event': 14.6.7(@testing-library/dom@10.4.2) + '@vitest/browser': 5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.1) + vitest: 5.0.1(@types/node@26.6.3)(@vitest/browser-preview@5.0.1)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.1)': + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)) + '@vitest/ui': 5.0.1(vitest@5.0.1) + '@vitest/utils': 5.0.1 + magic-string: 1.4.2 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 5.0.1(@types/node@26.6.3)(@vitest/browser-preview@5.0.1)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)) + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/mocker@5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.1 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)' + + '@vitest/mocker@5.0.2(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.2 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)' + + '@vitest/pretty-format@5.0.1': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/snapshot@5.0.1': + dependencies: + '@vitest/pretty-format': 5.0.1 + '@vitest/utils': 5.0.1 + magic-string: 1.4.2 + pathe: 2.0.3 + + '@vitest/spy@5.0.1': {} + + '@vitest/spy@5.0.2': {} + + '@vitest/ui@5.0.1(vitest@5.0.1)': + dependencies: + '@vitest/utils': 5.0.1 + fflate: 0.8.3 + flatted: 3.4.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 5.0.1(@types/node@26.6.3)(@vitest/browser-preview@5.0.1)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)) + + '@vitest/utils@5.0.1': + dependencies: + '@vitest/pretty-format': 5.0.1 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)': + dependencies: + '@oxc-project/runtime': 0.151.0 + '@oxc-project/types': 0.151.0 + lightningcss: 1.33.0 + postcss: 8.5.28 + yuku-codegen: 0.9.5 + yuku-parser: 0.9.5 + optionalDependencies: + '@types/node': 26.6.3 + '@voidzero-dev/vite-plus-darwin-arm64': 1.0.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.0.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.0.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.0.0 + fsevents: 2.3.3 + typescript: 7.0.2 + yaml: 2.9.1 + + '@voidzero-dev/vite-plus-darwin-arm64@1.0.0': + optional: true + + '@voidzero-dev/vite-plus-darwin-x64@1.0.0': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-gnu@1.0.0': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-musl@1.0.0': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-gnu@1.0.0': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-musl@1.0.0': + optional: true + + '@voidzero-dev/vite-plus-win32-arm64-msvc@1.0.0': + optional: true + + '@voidzero-dev/vite-plus-win32-x64-msvc@1.0.0': + optional: true + + '@yuku-codegen/binding-android-arm64@0.9.5': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.9.5': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.9.5': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.9.5': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.9.5': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.9.5': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.9.5': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.9.5': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.9.5': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.9.5': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.9.5': + optional: true + + '@yuku-codegen/binding-win32-x64@0.9.5': + optional: true + + '@yuku-parser/binding-android-arm64@0.9.5': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.9.5': + optional: true + + '@yuku-parser/binding-darwin-x64@0.9.5': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.9.5': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.9.5': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.9.5': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.9.5': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.9.5': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.9.5': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.9.5': + optional: true + + '@yuku-parser/binding-win32-arm64@0.9.5': + optional: true + + '@yuku-parser/binding-win32-x64@0.9.5': + optional: true + + '@yuku-toolchain/types@0.9.5': {} + + acorn@8.18.0: {} + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.4.0: {} + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + chai@6.2.2: {} + + citty@0.2.2: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@6.1.1: + dependencies: + slice-ansi: 9.0.1 + string-width: 8.3.0 + + commander@15.0.0: {} + + confbox@0.1.8: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + deepmerge@4.3.1: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + empathic@2.1.0: {} + + environment@1.1.0: {} + + es-module-lexer@2.3.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + execa@10.0.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.1 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + which-command: 0.1.0 + yoctocolors: 2.2.0 + + expect-type@1.4.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fflate@0.8.3: {} + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-workspaces@0.3.1: + dependencies: + fast-glob: 3.3.3 + pkg-types: 1.3.1 + yaml: 2.9.1 + + flatted@3.4.4: {} + + fsevents@2.3.3: + optional: true + + get-east-asian-width@1.7.0: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + human-signals@8.0.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.7.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + + js-tokens@4.0.0: {} + + jsonc-parser@3.3.1: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + log-update@8.0.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 9.0.1 + string-width: 8.3.0 + strip-ansi: 7.2.0 + wrap-ansi: 10.0.2 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lz-string@1.5.0: {} + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mimic-function@5.0.1: {} + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + mrmime@2.0.1: {} + + nanoid@3.3.19: {} + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + nypm@0.6.10: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.3.1 + + obug@2.2.1: {} + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + oxfmt@0.70.0(vite-plus@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)): + dependencies: + tinypool: 2.1.2 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.70.0 + '@oxfmt/binding-android-arm64': 0.70.0 + '@oxfmt/binding-darwin-arm64': 0.70.0 + '@oxfmt/binding-darwin-x64': 0.70.0 + '@oxfmt/binding-freebsd-x64': 0.70.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.70.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.70.0 + '@oxfmt/binding-linux-arm64-gnu': 0.70.0 + '@oxfmt/binding-linux-arm64-musl': 0.70.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-musl': 0.70.0 + '@oxfmt/binding-linux-s390x-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-musl': 0.70.0 + '@oxfmt/binding-openharmony-arm64': 0.70.0 + '@oxfmt/binding-win32-arm64-msvc': 0.70.0 + '@oxfmt/binding-win32-ia32-msvc': 0.70.0 + '@oxfmt/binding-win32-x64-msvc': 0.70.0 + vite-plus: 1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1) + + oxlint-tsgolint@7.0.2003: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 7.0.2003 + '@oxlint-tsgolint/darwin-x64': 7.0.2003 + '@oxlint-tsgolint/linux-arm64': 7.0.2003 + '@oxlint-tsgolint/linux-x64': 7.0.2003 + '@oxlint-tsgolint/win32-arm64': 7.0.2003 + '@oxlint-tsgolint/win32-x64': 7.0.2003 + + oxlint@1.85.0(oxlint-tsgolint@7.0.2003)(vite-plus@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.85.0 + '@oxlint/binding-android-arm64': 1.85.0 + '@oxlint/binding-darwin-arm64': 1.85.0 + '@oxlint/binding-darwin-x64': 1.85.0 + '@oxlint/binding-freebsd-x64': 1.85.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.85.0 + '@oxlint/binding-linux-arm-musleabihf': 1.85.0 + '@oxlint/binding-linux-arm64-gnu': 1.85.0 + '@oxlint/binding-linux-arm64-musl': 1.85.0 + '@oxlint/binding-linux-ppc64-gnu': 1.85.0 + '@oxlint/binding-linux-riscv64-gnu': 1.85.0 + '@oxlint/binding-linux-riscv64-musl': 1.85.0 + '@oxlint/binding-linux-s390x-gnu': 1.85.0 + '@oxlint/binding-linux-x64-gnu': 1.85.0 + '@oxlint/binding-linux-x64-musl': 1.85.0 + '@oxlint/binding-openharmony-arm64': 1.85.0 + '@oxlint/binding-win32-arm64-msvc': 1.85.0 + '@oxlint/binding-win32-ia32-msvc': 1.85.0 + '@oxlint/binding-win32-x64-msvc': 1.85.0 + oxlint-tsgolint: 7.0.2003 + vite-plus: 1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1) + + parse-ms@4.0.0: {} + + path-key@4.0.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pngjs@7.0.0: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + pretty-ms@9.3.1: + dependencies: + parse-ms: 4.0.0 + + queue-microtask@1.2.3: {} + + react-is@17.0.2: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + resolve.exports@2.0.3: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + semver@7.8.5: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + slice-ansi@9.0.1: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.3.0: {} + + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + + strip-final-newline@4.0.0: {} + + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@2.1.2: {} + + tinyrainbow@3.1.1: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + totalist@3.0.1: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + ufo@1.6.4: {} + + ultracite@7.12.2(oxfmt@0.70.0(vite-plus@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)))(oxlint@1.85.0(oxlint-tsgolint@7.0.2003)(vite-plus@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1))): + dependencies: + '@clack/prompts': 1.8.1 + cli-truncate: 6.1.1 + commander: 15.0.0 + deepmerge: 4.3.1 + empathic: 2.1.0 + execa: 10.0.1 + fast-glob: 3.3.3 + find-workspaces: 0.3.1 + jsonc-parser: 3.3.1 + log-update: 8.0.0 + magicast: 0.5.5 + nypm: 0.6.10 + resolve.exports: 2.0.3 + semver: 7.8.5 + string-width: 8.3.0 + yaml: 2.9.1 + zod: 4.6.5 + optionalDependencies: + oxfmt: 0.70.0(vite-plus@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)) + oxlint: 1.85.0(oxlint-tsgolint@7.0.2003)(vite-plus@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)) + + undici-types@8.9.0: {} + + unicorn-magic@0.3.0: {} + + vite-plus@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1): + dependencies: + '@oxc-project/types': 0.151.0 + '@oxlint/plugins': 1.79.0 + '@vitest/browser': 5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.1) + '@vitest/browser-preview': 5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.1) + '@vitest/mocker': 5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)) + '@vitest/pretty-format': 5.0.1 + '@vitest/snapshot': 5.0.1 + '@vitest/spy': 5.0.1 + '@vitest/utils': 5.0.1 + oxfmt: 0.70.0(vite-plus@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)) + oxlint: 1.85.0(oxlint-tsgolint@7.0.2003)(vite-plus@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)) + oxlint-tsgolint: 7.0.2003 + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)' + vitest: 5.0.1(@types/node@26.6.3)(@vitest/browser-preview@5.0.1)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)) + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 1.0.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.0.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.0.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.0.0 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml + + vitest@5.0.1(@types/node@26.6.3)(@vitest/browser-preview@5.0.1)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)' + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.6.3 + '@vitest/browser-preview': 5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.1) + transitivePeerDependencies: + - msw + + vitest@5.0.2(@types/node@26.6.3)(@vitest/browser-preview@5.0.1)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.2(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.1.4 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1)' + why-is-node-running: 3.2.2 + optionalDependencies: + '@types/node': 26.6.3 + '@vitest/browser-preview': 5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@26.6.3)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.1) + transitivePeerDependencies: + - msw + + which-command@0.1.0: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + why-is-node-running@3.2.2: {} + + wrap-ansi@10.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.3.0 + + ws@8.22.0: {} + + yaml@2.9.1: {} + + yoctocolors@2.2.0: {} + + yuku-ast@0.9.5: + dependencies: + '@yuku-toolchain/types': 0.9.5 + + yuku-codegen@0.9.5: + dependencies: + '@yuku-toolchain/types': 0.9.5 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.9.5 + '@yuku-codegen/binding-darwin-arm64': 0.9.5 + '@yuku-codegen/binding-darwin-x64': 0.9.5 + '@yuku-codegen/binding-freebsd-x64': 0.9.5 + '@yuku-codegen/binding-linux-arm-gnu': 0.9.5 + '@yuku-codegen/binding-linux-arm-musl': 0.9.5 + '@yuku-codegen/binding-linux-arm64-gnu': 0.9.5 + '@yuku-codegen/binding-linux-arm64-musl': 0.9.5 + '@yuku-codegen/binding-linux-x64-gnu': 0.9.5 + '@yuku-codegen/binding-linux-x64-musl': 0.9.5 + '@yuku-codegen/binding-win32-arm64': 0.9.5 + '@yuku-codegen/binding-win32-x64': 0.9.5 + + yuku-parser@0.9.5: + dependencies: + '@yuku-toolchain/types': 0.9.5 + yuku-ast: 0.9.5 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.9.5 + '@yuku-parser/binding-darwin-arm64': 0.9.5 + '@yuku-parser/binding-darwin-x64': 0.9.5 + '@yuku-parser/binding-freebsd-x64': 0.9.5 + '@yuku-parser/binding-linux-arm-gnu': 0.9.5 + '@yuku-parser/binding-linux-arm-musl': 0.9.5 + '@yuku-parser/binding-linux-arm64-gnu': 0.9.5 + '@yuku-parser/binding-linux-arm64-musl': 0.9.5 + '@yuku-parser/binding-linux-x64-gnu': 0.9.5 + '@yuku-parser/binding-linux-x64-musl': 0.9.5 + '@yuku-parser/binding-win32-arm64': 0.9.5 + '@yuku-parser/binding-win32-x64': 0.9.5 + + zod@4.6.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..62c0843 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@1.0.0 + vite-plus: 1.0.0 +overrides: + vite@*: "catalog:" +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: "*" diff --git a/scripts/name-client-bundle.ts b/scripts/name-client-bundle.ts new file mode 100644 index 0000000..e9bbb5f --- /dev/null +++ b/scripts/name-client-bundle.ts @@ -0,0 +1,21 @@ +/** + * Name the browser bundle `client.js`. + * + * DSH web client loader serves `${package}/client.js` and requires the `.js` extension. + * `vp pack` with `format: ["cjs"]` emits `.cjs` for type:module packages, so this script + * normalizes the artifact to `lib/client.js`. + */ + +import { existsSync, renameSync } from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const built = path.join(ROOT, "lib/client.cjs"); +const served = path.join(ROOT, "lib/client.js"); + +if (existsSync(built)) { + if (existsSync(served)) { + renameSync(served, path.join(ROOT, "lib/.client.js.stale")); + } + renameSync(built, served); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..af53982 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,614 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { createHash } from "node:crypto"; +import { appendFile } from "node:fs/promises"; + +export const name = "dsh-opencode"; + +export const inject = ["llm"]; + +export const SESSION_HEADER = "x-opencode-session"; +export const OPENCODE_UA = + "opencode/1.18.33 ai-sdk/provider-utils/4.0.40 runtime/bun/1.3.14"; + +export const DUMMY_READ_TOOL = { + description: "Read a file or directory from the local filesystem.", + name: "read", + parameters: { + properties: { + filePath: { + description: "The absolute path to the file", + type: "string", + }, + }, + required: ["filePath"], + type: "object", + }, + type: "function", +} as const; + +export const DUMMY_BASH_TOOL = { + description: "Execute a bash command.", + name: "bash", + parameters: { + properties: { + command: { description: "The command to execute", type: "string" }, + }, + required: ["command"], + type: "object", + }, + type: "function", +} as const; + +/** Derive a valid OpenCode session ID (`ses_<12hex><14base62>`) deterministically from a DSH sessionId. */ +export const openCodeSessionIdFor = (sessionId: string | number): string => { + const hash = createHash("sha256").update(String(sessionId)).digest(); + const hexPart = hash.subarray(0, 6).toString("hex"); + const chars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let randPart = ""; + for (let i = 6; i < 20; i += 1) { + const byte = hash[i]; + if (byte !== undefined) { + randPart += chars[byte % chars.length]; + } + } + return `ses_${hexPart}${randPart}`; +}; + +export const DEFAULT_PROVIDERS = ["opencode", "opencode-go"]; + +export interface PluginConfig { + debug?: boolean; + debugFile?: string; + injectCoreTools?: boolean; + injectOriginHeaders?: boolean; + injectUserAgent?: boolean; + mode?: "session-id" | "uuid"; + providers?: string[]; + userAgent?: string; +} + +export interface ResolvedPluginConfig { + debug: boolean; + debugFile?: string; + injectCoreTools: boolean; + injectOriginHeaders: boolean; + injectUserAgent: boolean; + mode: "session-id" | "uuid"; + providers: Set; + userAgent?: string; +} + +export const resolveConfig = ( + config: PluginConfig = {} +): ResolvedPluginConfig => { + const rawProviders: unknown = config.providers; + const listed: string[] = Array.isArray(rawProviders) + ? rawProviders.filter( + (item: unknown): item is string => + typeof item === "string" && item.length > 0 + ) + : []; + const providers = listed.length > 0 ? listed : [...DEFAULT_PROVIDERS]; + const mode = config.mode === "uuid" ? "uuid" : "session-id"; + const debug = config.debug === true; + const rawDebugFile: unknown = config.debugFile; + const debugFile = + typeof rawDebugFile === "string" && rawDebugFile.length > 0 + ? rawDebugFile + : undefined; + const injectUserAgent = config.injectUserAgent !== false; + const rawUserAgent: unknown = config.userAgent; + const userAgent = + typeof rawUserAgent === "string" && rawUserAgent.trim().length > 0 + ? rawUserAgent.trim() + : undefined; + const injectOriginHeaders = config.injectOriginHeaders !== false; + const injectCoreTools = config.injectCoreTools !== false; + + return { + debug, + debugFile, + injectCoreTools, + injectOriginHeaders, + injectUserAgent, + mode, + providers: new Set(providers), + userAgent, + }; +}; + +interface DebugContext { + logger?: { warn?: (msg: string, ...args: unknown[]) => void }; +} + +const recordDebug = async ( + ctx: DebugContext, + file: string, + entry: unknown +): Promise => { + try { + await appendFile(file, `${JSON.stringify(entry)}\n`, "utf-8"); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + ctx.logger?.warn?.("[dsh-opencode] debugFile write failed: %s", msg); + } +}; + +export const headerValueFor = ( + sessionId: string | number | undefined | null, + _mode: string, + table: Map +): string | undefined => { + void _mode; + if (sessionId === undefined || sessionId === null) { + return undefined; + } + const raw = String(sessionId); + if (raw.length === 0) { + return undefined; + } + const cached = table.get(raw); + if (cached !== undefined) { + return cached; + } + const value = openCodeSessionIdFor(raw); + table.set(raw, value); + return value; +}; + +export interface ActiveTurnState { + model?: string; + provider: string; + value: string; +} + +const isAsyncIteratorLike = (value: unknown): value is AsyncIterator => { + if (value === null || value === undefined) { + return false; + } + if (typeof value !== "object" && typeof value !== "function") { + return false; + } + if (!("next" in value)) { + return false; + } + const next: unknown = value.next; + return typeof next === "function"; +}; + +const getAsyncIterator = ( + iterable: AsyncIterable +): AsyncIterator | undefined => { + const candidate: unknown = iterable; + if (candidate === null || candidate === undefined) { + return undefined; + } + if (typeof candidate !== "object" && typeof candidate !== "function") { + return undefined; + } + if (!(Symbol.asyncIterator in candidate)) { + return undefined; + } + const factory: unknown = candidate[Symbol.asyncIterator]; + if (typeof factory !== "function") { + return undefined; + } + const iterator: unknown = factory.call(candidate); + if (!isAsyncIteratorLike(iterator)) { + return undefined; + } + return iterator; +}; + +const isAsyncIterableLike = ( + value: unknown +): value is AsyncIterable => { + if (value === null || value === undefined) { + return false; + } + if (typeof value !== "object" && typeof value !== "function") { + return false; + } + if (!(Symbol.asyncIterator in value)) { + return false; + } + const factory: unknown = value[Symbol.asyncIterator]; + return typeof factory === "function"; +}; + +const isRecord = (value: unknown): value is Record => { + if (value === null || value === undefined) { + return false; + } + if (typeof value !== "object") { + return false; + } + if (Array.isArray(value)) { + return false; + } + return true; +}; + +const isUnknownArray = (value: unknown): value is unknown[] => + Array.isArray(value); + +export const withStore = ( + iterable: AsyncIterable, + store: ActiveTurnState, + als: AsyncLocalStorage +): AsyncIterable => { + const iterator = getAsyncIterator(iterable); + if (iterator === undefined) { + return iterable; + } + const asyncIteratorObj: AsyncIterator = { + next: (): Promise> => + als.run(store, () => iterator.next()), + return: (value?: unknown): Promise> => { + if (typeof iterator.return === "function") { + return iterator.return(value).catch(() => ({ done: true, value })); + } + return Promise.resolve({ done: true, value }); + }, + throw: (error?: unknown): Promise> => { + if (typeof iterator.throw !== "function") { + const err = error instanceof Error ? error : new Error(String(error)); + return Promise.reject(err); + } + // oxlint-disable-next-line typescript/unbound-method -- capture guarded method then rebind via .call to keep `this` + const throwMethod = iterator.throw; + return als.run(store, () => throwMethod.call(iterator, error)); + }, + }; + return { + [Symbol.asyncIterator]() { + return asyncIteratorObj; + }, + }; +}; + +const extractUrl = (input: RequestInfo | URL): string => { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.toString(); + } + if (typeof input === "object" && input !== null && "url" in input) { + const urlProp: unknown = input.url; + if (typeof urlProp === "string") { + return urlProp; + } + if (urlProp instanceof URL) { + return urlProp.toString(); + } + } + return ""; +}; + +const readHeaderSource = ( + input: RequestInfo | URL, + init?: RequestInit +): HeadersInit | undefined => { + if (init?.headers !== undefined) { + return init.headers; + } + if (typeof Request !== "undefined" && input instanceof Request) { + return input.headers; + } + return undefined; +}; + +export const hasSessionHeader = ( + input: RequestInfo | URL, + init?: RequestInit +): boolean => { + const source = readHeaderSource(input, init); + if (source === undefined) { + return false; + } + try { + return new Headers(source).has(SESSION_HEADER); + } catch { + return false; + } +}; + +/** Determines if a request targets an OpenCode API endpoint. */ +export const isOpenCodeRequest = ( + url: string, + state: ActiveTurnState | undefined, + providers: Set +): boolean => { + if (url.includes("opencode.ai/zen")) { + return true; + } + if (state !== undefined && providers.has(state.provider)) { + return true; + } + return false; +}; + +const defaultSessionId = (): string => { + const envId: unknown = process.env.OPENCODE_SESSION_ID; + if (typeof envId === "string" && envId.length > 0) { + return envId; + } + return openCodeSessionIdFor("default"); +}; + +const toolNames = ( + tools: unknown[] +): { hasBash: boolean; hasRead: boolean } => { + let hasBash = false; + let hasRead = false; + for (const tool of tools) { + if (!isRecord(tool)) { + continue; + } + const toolName: unknown = tool.name; + if (toolName === "read") { + hasRead = true; + } + if (toolName === "bash") { + hasBash = true; + } + } + return { hasBash, hasRead }; +}; + +const maybeInjectCoreTools = ( + url: string, + body: RequestInit["body"], + headers: Headers, + injectCoreTools: boolean +): RequestInit["body"] => { + if (!injectCoreTools) { + return body; + } + if (!url.includes("/responses")) { + return body; + } + if (body === undefined || body === null) { + return body; + } + let bodyStr: string | undefined; + if (typeof body === "string") { + bodyStr = body; + } else if (Buffer.isBuffer(body)) { + bodyStr = body.toString("utf-8"); + } else { + return body; + } + if (bodyStr.length === 0) { + return body; + } + let parsed: unknown; + try { + parsed = JSON.parse(bodyStr); + } catch { + return body; + } + if (!isRecord(parsed)) { + return body; + } + const modelProp: unknown = parsed.model; + if (typeof modelProp !== "string" || !modelProp.includes("free")) { + return body; + } + const toolsProp: unknown = parsed.tools; + let tools: unknown[]; + if (toolsProp === undefined) { + tools = []; + } else if (isUnknownArray(toolsProp)) { + tools = [...toolsProp]; + } else { + tools = []; + } + const { hasBash, hasRead } = toolNames(tools); + if (!hasRead) { + tools.push(DUMMY_READ_TOOL); + } + if (!hasBash) { + tools.push(DUMMY_BASH_TOOL); + } + parsed.tools = tools; + const newBodyStr = JSON.stringify(parsed); + headers.set("content-length", Buffer.byteLength(newBodyStr).toString()); + return newBodyStr; +}; + +const isFetchFunction = (value: unknown): value is typeof fetch => + typeof value === "function"; + +export const patchFetch = ( + original: typeof fetch, + als: AsyncLocalStorage, + config: ResolvedPluginConfig +): typeof fetch => { + const patchedFetch = function patchedFetch( + this: unknown, + input: RequestInfo | URL, + init?: RequestInit + ): Promise { + const state = als.getStore(); + const url = extractUrl(input); + + if (!isOpenCodeRequest(url, state, config.providers)) { + return original.call(this, input, init); + } + + const headers = new Headers(readHeaderSource(input, init)); + + // 1. Session header: ALWAYS injected for OpenCode requests + if (state === undefined) { + const existing = headers.get(SESSION_HEADER); + if (existing === null || !existing.startsWith("ses_")) { + headers.set(SESSION_HEADER, defaultSessionId()); + } + } else { + headers.set(SESSION_HEADER, state.value); + } + + // 2. User-Agent: injected / restored when enabled, with user override support + if (config.injectUserAgent) { + headers.set("User-Agent", config.userAgent ?? OPENCODE_UA); + } + + // 3. Client & Project origin headers: injected when enabled + if (config.injectOriginHeaders) { + headers.set("x-opencode-client", "cli"); + headers.set("x-opencode-project", "global"); + } + + const newInit: RequestInit = { ...init, headers }; + + // 4. Core tool schema fallback for free-tier /responses models + if (init?.body !== undefined) { + const newBody = maybeInjectCoreTools( + url, + init.body, + headers, + config.injectCoreTools + ); + if (newBody !== init.body) { + newInit.body = newBody; + } + } + return original.call(this, input, newInit); + }; + return patchedFetch; +}; + +export interface CordisContext { + effect?: (fn: () => unknown, name?: string) => void; + logger?: { + info?: (msg: string, ...args: unknown[]) => void; + warn?: (msg: string, ...args: unknown[]) => void; + }; + on?: ( + event: string, + callback: ( + options: unknown, + next: () => unknown, + ...rest: unknown[] + ) => unknown, + options?: { prepend?: boolean } + ) => void; +} + +interface StreamOptions { + model?: unknown; + provider?: unknown; + sessionId?: unknown; +} + +const isStreamOptions = (value: unknown): value is StreamOptions => + typeof value === "object" && value !== null; + +export const apply = ( + ctx: CordisContext, + rawConfig: PluginConfig = {} +): void => { + const config = resolveConfig(rawConfig); + const { debug, debugFile, mode, providers } = config; + const als = new AsyncLocalStorage(); + const uuidBySession = new Map(); + + const originalFetch: unknown = globalThis.fetch; + if (!isFetchFunction(originalFetch)) { + ctx.logger?.warn?.( + "[dsh-opencode] globalThis.fetch is unavailable; cannot inject x-opencode-session" + ); + return; + } + + const patched = patchFetch(originalFetch, als, config); + + ctx.effect?.(() => { + globalThis.fetch = patched; + ctx.logger?.info?.( + "[dsh-opencode] active for providers [%s] with mode %s", + [...providers].join(", "), + mode + ); + return () => { + if (globalThis.fetch === patched) { + globalThis.fetch = originalFetch; + } + }; + }, "dsh-opencode.fetch-patch"); + + ctx.on?.( + "llm/stream", + (options: unknown, next: () => unknown) => { + if (!isStreamOptions(options)) { + return next(); + } + const providerProp: unknown = options.provider; + if ( + typeof providerProp !== "string" && + typeof providerProp !== "number" + ) { + return next(); + } + const providerKey = String(providerProp); + if (!providers.has(providerKey)) { + return next(); + } + const sessionProp: unknown = options.sessionId; + if (typeof sessionProp !== "string" && typeof sessionProp !== "number") { + return next(); + } + const rawSession = String(sessionProp); + if (rawSession.length === 0) { + return next(); + } + const value = headerValueFor(rawSession, mode, uuidBySession); + if (value === undefined) { + return next(); + } + + const downstream: unknown = next(); + if (!isAsyncIterableLike(downstream)) { + return downstream; + } + + if (debug || debugFile !== undefined) { + const entry = { + header: SESSION_HEADER, + model: options.model, + provider: providerKey, + session: rawSession, + ts: new Date().toISOString(), + value, + }; + if (debugFile !== undefined) { + void recordDebug(ctx, debugFile, entry); + } + if (debug) { + ctx.logger?.info?.( + '[dsh-opencode] streaming provider "%s" with %s=%s', + providerKey, + SESSION_HEADER, + value + ); + } + } + const modelProp: unknown = options.model; + return withStore( + downstream, + { + model: typeof modelProp === "string" ? modelProp : undefined, + provider: providerKey, + value, + }, + als + ); + }, + { prepend: true } + ); +}; + +export default { apply, inject, name }; diff --git a/src/settings-page.tsx b/src/settings-page.tsx new file mode 100644 index 0000000..8c64e1f --- /dev/null +++ b/src/settings-page.tsx @@ -0,0 +1,282 @@ +/** + * `dsh-opencode` settings page — DSH Web client bundle. + * Contributes a settings card under DSH Settings -> Plugins. + * + * @module dsh-opencode/settings-page + */ + +import { + SettingsForm, + SettingsFormModel, + SettingsValueField, + settingsTextField, + type SettingsFieldSpec, + type SettingsFormScope, + type SettingsFormShell, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import React from "react"; + +export const NS = "dsh-opencode"; + +export const inject = ["slots", "locale", "configForms"]; + +const en = { + description: + "OpenCode Zen gateway origin headers, session affinity, and free-tier compatibility.", + injectCoreTools: "Inject Core Tools", + injectCoreToolsHint: + "Auto-injects read and bash tool schemas on free-tier requests to satisfy gateway validation.", + injectOriginHeaders: "Inject Origin Headers", + injectOriginHeadersHint: + "Injects x-opencode-client and x-opencode-project headers.", + injectUserAgent: "Inject User-Agent", + injectUserAgentHint: + "Restores the opencode CLI User-Agent stripped by the DSH LLM adapter.", + invalidBoolean: "Enter true or false, or leave blank for default.", + invalidText: "This value was not accepted; leave blank for default.", + overridden: "Overridden", + providers: "Providers", + providersHint: "Comma-separated list of route IDs to intercept.", + readOnly: "This deployment stores settings read-only.", + reset: "Reset to default", + save: "Save", + saveFailed: "The deployment did not accept these values.", + saving: "Saving…", + title: "OpenCode Integration", + unavailable: "This plugin is not loaded, so it cannot be configured.", + userAgent: "User-Agent Override", + userAgentHint: + "Custom User-Agent string. Leave blank to use the canonical OpenCode CLI string.", +}; + +const zh = { + description: "OpenCode Zen 网关来源头恢复、会话保持与免费模型兼容支持。", + injectCoreTools: "自动补全核心工具", + injectCoreToolsHint: + "在免费模型请求中自动注入 read 和 bash 工具声明以满足网关校验。", + injectOriginHeaders: "注入客户端来源头", + injectOriginHeadersHint: + "注入 x-opencode-client 与 x-opencode-project 头部信息。", + injectUserAgent: "恢复 User-Agent", + injectUserAgentHint: + "恢复被 DSH 适配器过滤掉的官方 OpenCode CLI User-Agent。", + invalidBoolean: "请输入 true 或 false,留空使用默认值。", + invalidText: "该值未被接受,留空使用默认值。", + overridden: "已覆盖", + providers: "生效提供方", + providersHint: "逗号分隔的提供方路由 ID 列表。", + readOnly: "当前部署配置为只读。", + reset: "恢复默认", + save: "保存", + saveFailed: "保存失败,请检查填写内容。", + saving: "保存中…", + title: "OpenCode 接入设置", + unavailable: "插件未加载,暂无法配置。", + userAgent: "自定义 User-Agent", + userAgentHint: "自定义 User-Agent 字符串。留空则使用默认 OpenCode CLI 标识。", +}; + +const FIELD = { + injectCoreTools: "injectCoreTools", + injectOriginHeaders: "injectOriginHeaders", + injectUserAgent: "injectUserAgent", + providers: "providers", + userAgent: "userAgent", +}; + +const BOOLEAN_DRAFTS: Record< + string, + { kind: "set"; value: boolean } | { kind: "clear" } +> = { + "": { kind: "clear" }, + false: { kind: "set", value: false }, + true: { kind: "set", value: true }, +}; + +const settingsBooleanField = (field: string): SettingsFieldSpec => ({ + field, + format: (value: unknown) => (typeof value === "boolean" ? String(value) : ""), + parse: (text: string) => BOOLEAN_DRAFTS[text.trim().toLowerCase()], +}); + +const SPECS: SettingsFieldSpec[] = [ + settingsBooleanField(FIELD.injectUserAgent), + settingsTextField(FIELD.userAgent), + settingsBooleanField(FIELD.injectOriginHeaders), + settingsBooleanField(FIELD.injectCoreTools), + settingsTextField(FIELD.providers), +]; + +type Translate = (key: keyof typeof en) => string; + +interface CardField { + invalid: boolean; + overridden: boolean; + text: string; +} + +interface CardState { + fields: Record; + shell: SettingsFormShell; +} + +interface CardProps { + discard: () => void; + edit: (field: string, text: string) => void; + resetField: (field: string) => void; + save: () => void; + t: Translate; + useOpencodeCard: (selector: (snapshot: CardState) => CardState) => CardState; + view: "summary" | "form"; +} + +const formLabels = (t: Translate) => ({ + readOnly: t("readOnly"), + save: t("save"), + saveFailed: t("saveFailed"), + saving: t("saving"), + unavailable: t("unavailable"), +}); + +const OpencodeCard: React.FC = (props: CardProps) => { + const { t } = props; + if (props.view === "summary") { + return <>{t("description")}; + } + + const state = props.useOpencodeCard((snapshot) => snapshot); + const disabled = !state.shell.writable; + + const field = (name: string) => ({ + disabled, + id: `plugin-config-opencode-${name}`, + invalidLabel: t("invalidText"), + onEdit: (text: string) => { + props.edit(name, text); + }, + onReset: () => { + props.resetField(name); + }, + overriddenLabel: t("overridden"), + resetLabel: t("reset"), + ...(state.fields[name] ?? { invalid: false, overridden: false, text: "" }), + }); + + return ( + + + + + + + + ); +}; + +export interface ClientContext { + configForms?: { + get: (ns: string) => unknown; + whileServed: (ns: string[], fn: () => void) => void; + }; + effect?: (fn: () => unknown, name?: string) => void; + locale?: { + bind: (ns: string) => (key: string) => string; + register: (ns: string, dicts: Record) => void; + }; + slots?: { + inject: (name: string, fn: () => void) => void; + register: (entry: Record, component: unknown) => void; + }; +} + +const isSettingsFormScope = ( + value: unknown +): value is SettingsFormScope> => { + if (value === null || value === undefined) { + return false; + } + if (typeof value !== "object" && typeof value !== "function") { + return false; + } + if (!("getSnapshot" in value && "subscribe" in value && "mutate" in value)) { + return false; + } + const snapshot: unknown = value.getSnapshot; + const subscribe: unknown = value.subscribe; + const mutate: unknown = value.mutate; + return ( + typeof snapshot === "function" && + typeof subscribe === "function" && + typeof mutate === "function" + ); +}; + +export const apply = (ctx: ClientContext): void => { + const t = ctx.locale?.bind?.(NS) ?? ((k: string) => k); + ctx.effect?.(() => { + ctx.locale?.register?.(NS, { en, zh }); + }, "dsh-opencode: dictionaries"); + + const rawScope: unknown = ctx.configForms?.get?.(NS); + if (!isSettingsFormScope(rawScope)) { + return; + } + const scope = rawScope; + + const model = new SettingsFormModel(scope, SPECS); + const store = model.bind(() => ({ + fields: Object.fromEntries( + SPECS.map((spec) => [spec.field, model.field(spec.field)]) + ), + shell: model.shell(), + })); + + ctx.configForms?.whileServed?.([NS], () => { + ctx.slots?.inject?.("plugins.item", () => { + ctx.slots?.register?.( + { + id: "opencode", + inject: () => ({ + hooks: { opencodeCard: store }, + ...model.actions(), + }), + label: () => t("title"), + locale: NS, + name: "plugins.item", + order: 50, + }, + OpencodeCard + ); + }); + }); +}; diff --git a/test/plugin.test.ts b/test/plugin.test.ts new file mode 100644 index 0000000..095b868 --- /dev/null +++ b/test/plugin.test.ts @@ -0,0 +1,1017 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + type ActiveTurnState, + type CordisContext, + apply, + DUMMY_BASH_TOOL, + DUMMY_READ_TOOL, + headerValueFor, + hasSessionHeader, + isOpenCodeRequest, + openCodeSessionIdFor, + OPENCODE_UA, + patchFetch, + resolveConfig, + SESSION_HEADER, + withStore, +} from "../src/index.ts"; + +const SESSION_RE = /^ses_[0-9a-f]{12}[A-Za-z0-9]{14}$/; + +const createMockStream = async function* createMockStream(chunk: string) { + yield chunk; +}; + +const createMockStoreStream = async function* createMockStoreStream( + als: AsyncLocalStorage +) { + yield als.getStore()?.value; + yield als.getStore()?.value; +}; + +const isRecord = (value: unknown): value is Record => { + if (value === null || value === undefined) { + return false; + } + if (typeof value !== "object") { + return false; + } + return !Array.isArray(value); +}; + +const toolNamesOf = (body: unknown): string[] | undefined => { + if (!isRecord(body)) { + return undefined; + } + const tools: unknown = body.tools; + if (!Array.isArray(tools)) { + return undefined; + } + const names: string[] = []; + for (const tool of tools) { + if (isRecord(tool) && typeof tool.name === "string") { + names.push(tool.name); + } + } + return names; +}; + +const parseJsonBody = (body: unknown): unknown => { + if (typeof body !== "string") { + return undefined; + } + try { + const parsed: unknown = JSON.parse(body); + return parsed; + } catch { + return undefined; + } +}; + +const isAsyncIterableLike = ( + value: unknown +): value is AsyncIterable => { + if (value === null || value === undefined) { + return false; + } + if (typeof value !== "object" && typeof value !== "function") { + return false; + } + if (!(Symbol.asyncIterator in value)) { + return false; + } + return typeof value[Symbol.asyncIterator] === "function"; +}; + +const collectUnknown = async ( + iterable: AsyncIterable +): Promise => { + const out: unknown[] = []; + for await (const chunk of iterable) { + out.push(chunk); + } + return out; +}; + +const waitForFileContent = async ( + file: string, + minLines = 1, + timeoutMs = 5000 +): Promise => { + const start = Date.now(); + let last = ""; + while (Date.now() - start < timeoutMs) { + try { + const content = await readFile(file, "utf-8"); + const lines = content + .trim() + .split("\n") + .filter((l) => l.length > 0); + if (lines.length >= minLines) { + return content; + } + last = content; + } catch { + // not yet written + } + await sleep(25); + } + throw new Error( + `timed out waiting for ${minLines} line(s) in ${file} (last: ${JSON.stringify(last)})` + ); +}; + +interface Capture { + init: RequestInit | undefined; + url: string; +} + +const replacementFetch = (): Promise => + Promise.resolve(new Response("replacement")); + +const createCaptureFetch = (text = "ok") => { + const capture: Capture = { init: undefined, url: "" }; + const mockFetch = ( + input: RequestInfo | URL, + init?: RequestInit + ): Promise => { + if (typeof input === "string") { + capture.url = input; + } else if (input instanceof URL) { + capture.url = input.toString(); + } else { + capture.url = input.url; + } + capture.init = init; + return Promise.resolve(new Response(text)); + }; + return { capture, mockFetch }; +}; + +const headerOf = (init: RequestInit | undefined, field: string) => + new Headers(init?.headers).get(field); + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OPENCODE_SESSION_ID; +}); + +describe("openCodeSessionIdFor", () => { + it("generates a valid OpenCode session ID matching the exact regex format", () => { + const id = openCodeSessionIdFor("c2a51fb0-578c-4019-80c4-868eff95fd08"); + expect(id).toMatch(SESSION_RE); + expect(id.length).toBe(30); + }); + + it("is deterministic for identical inputs", () => { + expect(openCodeSessionIdFor("conversation-alpha-123")).toBe( + openCodeSessionIdFor("conversation-alpha-123") + ); + }); + + it("handles numeric session IDs identically to their string form", () => { + const id = openCodeSessionIdFor(123_456_789); + expect(id).toBe(openCodeSessionIdFor("123456789")); + expect(id).toMatch(/^ses_/); + }); + + it("produces a valid ID for empty input without throwing", () => { + expect(openCodeSessionIdFor("")).toMatch(SESSION_RE); + }); + + it("generates unique session IDs across deterministic inputs", () => { + const seen = new Set(); + for (let i = 0; i < 200; i += 1) { + const id = openCodeSessionIdFor(`deterministic-session-${i}`); + expect(seen.has(id)).toBe(false); + seen.add(id); + } + expect(seen.size).toBe(200); + }); + + it("maps distinct conversations to distinct session IDs", () => { + expect(openCodeSessionIdFor("session-a")).not.toBe( + openCodeSessionIdFor("session-b") + ); + }); +}); + +describe("resolveConfig", () => { + it("fills default providers, mode, toggles, and debug flags", () => { + const resolved = resolveConfig({}); + expect([...resolved.providers]).toEqual(["opencode", "opencode-go"]); + expect(resolved.mode).toBe("session-id"); + expect(resolved.debug).toBe(false); + expect(resolved.debugFile).toBeUndefined(); + expect(resolved.injectUserAgent).toBe(true); + expect(resolved.userAgent).toBeUndefined(); + expect(resolved.injectOriginHeaders).toBe(true); + expect(resolved.injectCoreTools).toBe(true); + }); + + it("preserves custom providers and configuration overrides", () => { + const resolved = resolveConfig({ + debug: true, + debugFile: "/tmp/debug.log", + injectCoreTools: false, + injectOriginHeaders: false, + injectUserAgent: false, + mode: "uuid", + providers: ["custom-opencode", "opencode-dev"], + userAgent: "my-custom-ua/1.0", + }); + expect([...resolved.providers]).toEqual([ + "custom-opencode", + "opencode-dev", + ]); + expect(resolved.mode).toBe("uuid"); + expect(resolved.debug).toBe(true); + expect(resolved.debugFile).toBe("/tmp/debug.log"); + expect(resolved.injectUserAgent).toBe(false); + expect(resolved.userAgent).toBe("my-custom-ua/1.0"); + expect(resolved.injectOriginHeaders).toBe(false); + expect(resolved.injectCoreTools).toBe(false); + }); + + it("falls back to session-id mode when unknown mode is provided", () => { + const resolved = resolveConfig({ + // @ts-expect-error -- intentionally invalid mode to verify fallback + mode: "unknown", + }); + expect(resolved.mode).toBe("session-id"); + }); + + it("falls back to defaults when providers list is empty or blank", () => { + expect([...resolveConfig({ providers: [] }).providers]).toEqual([ + "opencode", + "opencode-go", + ]); + expect([...resolveConfig({ providers: [""] }).providers]).toEqual([ + "opencode", + "opencode-go", + ]); + }); + + it("trims userAgent and treats blank debugFile as unset", () => { + const resolved = resolveConfig({ + debugFile: "", + userAgent: " custom-ua/2.0 ", + }); + expect(resolved.userAgent).toBe("custom-ua/2.0"); + expect(resolved.debugFile).toBeUndefined(); + }); +}); + +describe("isOpenCodeRequest (endpoint differentiation)", () => { + const providers = new Set(["opencode", "opencode-go"]); + + it("identifies opencode.ai/zen endpoints", () => { + expect( + isOpenCodeRequest( + "https://opencode.ai/zen/v1/responses", + undefined, + providers + ) + ).toBe(true); + expect( + isOpenCodeRequest( + "https://opencode.ai/zen/go/v1/chat/completions", + undefined, + providers + ) + ).toBe(true); + }); + + it("identifies zen endpoints even when providers set is empty", () => { + expect( + isOpenCodeRequest( + "https://opencode.ai/zen/v1/responses", + undefined, + new Set() + ) + ).toBe(true); + }); + + it("identifies active turn state when routed to matching provider", () => { + const state: ActiveTurnState = { + provider: "opencode", + value: "ses_123", + }; + expect( + isOpenCodeRequest("https://my-custom-relay.example/v1", state, providers) + ).toBe(true); + }); + + it("rejects non-OpenCode requests", () => { + expect( + isOpenCodeRequest( + "https://api.deepseek.com/v1/chat/completions", + undefined, + providers + ) + ).toBe(false); + expect( + isOpenCodeRequest( + "https://api.openai.com/v1/chat/completions", + undefined, + providers + ) + ).toBe(false); + const nonOpencodeState: ActiveTurnState = { + provider: "deepseek", + value: "ses_456", + }; + expect( + isOpenCodeRequest( + "https://api.deepseek.com/v1", + nonOpencodeState, + providers + ) + ).toBe(false); + }); +}); + +describe("headerValueFor", () => { + it("returns mapped session id and caches it in the provided table", () => { + const table = new Map(); + const val1 = headerValueFor("dsh-uuid-1", "session-id", table); + expect(val1).toMatch(/^ses_/); + expect(table.get("dsh-uuid-1")).toBe(val1); + + const val2 = headerValueFor("dsh-uuid-1", "session-id", table); + expect(val2).toBe(val1); + }); + + it("returns undefined for empty, null, or undefined session inputs", () => { + const table = new Map(); + expect(headerValueFor("", "session-id", table)).toBeUndefined(); + expect(headerValueFor(undefined, "session-id", table)).toBeUndefined(); + expect(headerValueFor(null, "session-id", table)).toBeUndefined(); + expect(table.size).toBe(0); + }); + + it("accepts numeric session IDs", () => { + const table = new Map(); + const value = headerValueFor(987_654, "session-id", table); + expect(value).toMatch(/^ses_/); + expect(table.get("987654")).toBe(value); + }); +}); + +describe("hasSessionHeader", () => { + it("detects x-opencode-session in Headers object case-insensitively", () => { + const headers = new Headers(); + headers.set("X-OpenCode-Session", "ses_mock_header"); + expect(hasSessionHeader("http://example.com", { headers })).toBe(true); + }); + + it("detects x-opencode-session in plain object headers", () => { + expect( + hasSessionHeader("http://example.com", { + headers: { [SESSION_HEADER]: "ses_mock_header" }, + }) + ).toBe(true); + }); + + it("detects x-opencode-session carried by a Request object", () => { + const req = new Request("http://example.com", { + headers: { [SESSION_HEADER]: "ses_from_request" }, + }); + expect(hasSessionHeader(req)).toBe(true); + }); + + it("returns false when header is absent", () => { + expect( + hasSessionHeader("http://example.com", { + headers: { "Content-Type": "application/json" }, + }) + ).toBe(false); + expect(hasSessionHeader("http://example.com")).toBe(false); + }); +}); + +describe("withStore", () => { + it("wraps and drives an async iterable inside AsyncLocalStorage context", async () => { + const als = new AsyncLocalStorage(); + const wrapped = withStore( + createMockStoreStream(als), + { provider: "opencode", value: "store-context-42" }, + als + ); + const results: unknown[] = []; + for await (const value of wrapped) { + results.push(value); + } + expect(results).toEqual(["store-context-42", "store-context-42"]); + }); + + it("handles early return on the wrapped iterator", async () => { + const als = new AsyncLocalStorage(); + let returned = false; + + const mockIterable: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: () => Promise.resolve({ done: false, value: 1 }), + return: () => { + returned = true; + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }; + + const wrapped = withStore( + mockIterable, + { provider: "opencode", value: "test" }, + als + ); + const iterator = wrapped[Symbol.asyncIterator](); + const first = await iterator.next(); + expect(first.value).toBe(1); + await iterator.return?.(); + expect(returned).toBe(true); + }); + + it("propagates throw through the wrapped iterator with context", async () => { + const als = new AsyncLocalStorage(); + const failure = new Error("downstream-boom"); + const mockIterable: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: () => Promise.resolve({ done: false, value: 1 }), + throw: () => Promise.reject(failure), + }; + }, + }; + const wrapped = withStore( + mockIterable, + { provider: "opencode", value: "throw-ctx" }, + als + ); + const iterator = wrapped[Symbol.asyncIterator](); + await expect(iterator.throw?.(failure)).rejects.toBe(failure); + }); + + it("passes through iterables whose factory yields no iterator", () => { + const als = new AsyncLocalStorage(); + const passthrough: AsyncIterable = { + // @ts-expect-error -- intentionally broken factory to verify passthrough + [Symbol.asyncIterator]() { + return null; + }, + }; + const wrapped = withStore( + passthrough, + { provider: "opencode", value: "x" }, + als + ); + expect(wrapped).toBe(passthrough); + }); +}); + +describe("patchFetch", () => { + it("passes non-OpenCode requests through completely untouched", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch("upstream-ok"); + + const patched = patchFetch(mockFetch, als, resolveConfig()); + const res = await patched("https://api.deepseek.com/v1/chat/completions", { + body: JSON.stringify({ message: "hello" }), + headers: { "X-Custom-Header": "original" }, + method: "POST", + }); + + expect(await res.text()).toBe("upstream-ok"); + expect(capture.url).toBe("https://api.deepseek.com/v1/chat/completions"); + expect(headerOf(capture.init, "X-Custom-Header")).toBe("original"); + expect(headerOf(capture.init, "User-Agent")).toBeNull(); + expect(headerOf(capture.init, "x-opencode-client")).toBeNull(); + expect(headerOf(capture.init, "x-opencode-project")).toBeNull(); + expect(headerOf(capture.init, SESSION_HEADER)).toBeNull(); + }); + + it("injects origin headers and dynamic session ID for zen requests", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const testSession = openCodeSessionIdFor("test-dynamic-turn"); + + const patched = patchFetch(mockFetch, als, resolveConfig()); + await als.run({ provider: "opencode", value: testSession }, async () => { + await patched("https://opencode.ai/zen/v1/chat/completions", { + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + + expect(headerOf(capture.init, "User-Agent")).toBe(OPENCODE_UA); + expect(headerOf(capture.init, "x-opencode-client")).toBe("cli"); + expect(headerOf(capture.init, "x-opencode-project")).toBe("global"); + expect(headerOf(capture.init, SESSION_HEADER)).toBe(testSession); + }); + + it("injects for configured custom relays when turn state matches", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const config = resolveConfig({ providers: ["my-relay"] }); + const patched = patchFetch(mockFetch, als, config); + + await als.run({ provider: "my-relay", value: "ses_relay_1" }, async () => { + await patched("https://relay.internal/v1/chat", { method: "POST" }); + }); + expect(headerOf(capture.init, SESSION_HEADER)).toBe("ses_relay_1"); + + const { capture: capture2, mockFetch: mockFetch2 } = createCaptureFetch(); + const patched2 = patchFetch(mockFetch2, als, config); + await als.run({ provider: "other", value: "ses_other" }, async () => { + await patched2("https://relay.internal/v1/chat", { method: "POST" }); + }); + expect(headerOf(capture2.init, SESSION_HEADER)).toBeNull(); + }); + + it("supports Request and URL inputs", async () => { + const als = new AsyncLocalStorage(); + const testSession = openCodeSessionIdFor("input-shapes"); + const config = resolveConfig(); + const { capture: c1, mockFetch: m1 } = createCaptureFetch(); + const patched1 = patchFetch(m1, als, config); + await als.run({ provider: "opencode", value: testSession }, async () => { + await patched1( + new Request("https://opencode.ai/zen/v1/responses", { + headers: { "Content-Type": "application/json" }, + method: "POST", + }) + ); + }); + expect(headerOf(c1.init, SESSION_HEADER)).toBe(testSession); + + const { capture: c2, mockFetch: m2 } = createCaptureFetch(); + const patched2 = patchFetch(m2, als, config); + await als.run({ provider: "opencode", value: testSession }, async () => { + await patched2(new URL("https://opencode.ai/zen/v1/responses"), { + method: "POST", + }); + }); + expect(c2.url).toBe("https://opencode.ai/zen/v1/responses"); + expect(headerOf(c2.init, SESSION_HEADER)).toBe(testSession); + }); + + it("preserves an existing valid session header and uses env fallback otherwise", async () => { + const als = new AsyncLocalStorage(); + const config = resolveConfig(); + + const { capture: keep, mockFetch: keepFetch } = createCaptureFetch(); + await patchFetch( + keepFetch, + als, + config + )("https://opencode.ai/zen/v1/responses", { + headers: { [SESSION_HEADER]: "ses_existing_valid_01" }, + }); + expect(headerOf(keep.init, SESSION_HEADER)).toBe("ses_existing_valid_01"); + + process.env.OPENCODE_SESSION_ID = "ses_env_fallback_02"; + const { capture: envCap, mockFetch: envFetch } = createCaptureFetch(); + await patchFetch( + envFetch, + als, + config + )("https://opencode.ai/zen/v1/responses", { + headers: { [SESSION_HEADER]: "bogus" }, + }); + expect(headerOf(envCap.init, SESSION_HEADER)).toBe("ses_env_fallback_02"); + }); + + it("respects injectUserAgent false and custom userAgent override", async () => { + const als = new AsyncLocalStorage(); + + const { capture: kept, mockFetch: keptFetch } = createCaptureFetch(); + await als.run({ provider: "opencode", value: "ses_test" }, async () => { + await patchFetch( + keptFetch, + als, + resolveConfig({ injectUserAgent: false }) + )("https://opencode.ai/zen/v1/chat/completions", { + headers: { "User-Agent": "custom-unmodified-ua" }, + method: "POST", + }); + }); + expect(headerOf(kept.init, "User-Agent")).toBe("custom-unmodified-ua"); + expect(headerOf(kept.init, SESSION_HEADER)).toBe("ses_test"); + + const { capture: over, mockFetch: overFetch } = createCaptureFetch(); + await als.run({ provider: "opencode", value: "ses_test" }, async () => { + await patchFetch( + overFetch, + als, + resolveConfig({ + injectUserAgent: true, + userAgent: "my-custom-cli/3.0.0", + }) + )("https://opencode.ai/zen/v1/chat/completions", { method: "POST" }); + }); + expect(headerOf(over.init, "User-Agent")).toBe("my-custom-cli/3.0.0"); + }); + + it("respects injectOriginHeaders false", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const patched = patchFetch( + mockFetch, + als, + resolveConfig({ injectOriginHeaders: false }) + ); + await als.run({ provider: "opencode", value: "ses_test" }, async () => { + await patched("https://opencode.ai/zen/v1/chat/completions", { + method: "POST", + }); + }); + expect(headerOf(capture.init, "x-opencode-client")).toBeNull(); + expect(headerOf(capture.init, "x-opencode-project")).toBeNull(); + expect(headerOf(capture.init, SESSION_HEADER)).toBe("ses_test"); + }); + + it("injects read and bash tools for free-tier /responses models", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const testSession = openCodeSessionIdFor("test-free-turn"); + const patched = patchFetch(mockFetch, als, resolveConfig()); + + await als.run({ provider: "opencode", value: testSession }, async () => { + await patched("https://opencode.ai/zen/v1/responses", { + body: JSON.stringify({ + input: [{ content: "hi", role: "user" }], + model: "muse-spark-1.3-contributor-free", + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + + expect(headerOf(capture.init, "content-length")).not.toBeNull(); + expect(typeof capture.init?.body).toBe("string"); + const names = toolNamesOf(parseJsonBody(capture.init?.body)); + expect(names).toEqual([DUMMY_READ_TOOL.name, DUMMY_BASH_TOOL.name]); + }); + + it("skips tool injection for paid models, other paths, and invalid JSON", async () => { + const als = new AsyncLocalStorage(); + const testSession = openCodeSessionIdFor("skip-cases"); + const run = async (url: string, body: string) => { + const { capture, mockFetch } = createCaptureFetch(); + await als.run({ provider: "opencode", value: testSession }, async () => { + await patchFetch( + mockFetch, + als, + resolveConfig() + )(url, { + body, + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + return capture.init?.body; + }; + + const paid = await run( + "https://opencode.ai/zen/v1/responses", + JSON.stringify({ input: "hi", model: "gpt-5-paid" }) + ); + expect(toolNamesOf(parseJsonBody(paid))).toBeUndefined(); + + const chat = await run( + "https://opencode.ai/zen/v1/chat/completions", + JSON.stringify({ input: "hi", model: "muse-spark-1.3-contributor-free" }) + ); + expect(toolNamesOf(parseJsonBody(chat))).toBeUndefined(); + + const invalid = await run( + "https://opencode.ai/zen/v1/responses", + "{not-json" + ); + expect(invalid).toBe("{not-json"); + }); + + it("respects injectCoreTools false", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const patched = patchFetch( + mockFetch, + als, + resolveConfig({ injectCoreTools: false }) + ); + await als.run({ provider: "opencode", value: "ses_test" }, async () => { + await patched("https://opencode.ai/zen/v1/responses", { + body: JSON.stringify({ + input: [{ content: "hi", role: "user" }], + model: "muse-spark-1.3-contributor-free", + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + expect(toolNamesOf(parseJsonBody(capture.init?.body))).toBeUndefined(); + }); + + it("does not duplicate tools and handles Buffer bodies", async () => { + const als = new AsyncLocalStorage(); + const testSession = openCodeSessionIdFor("test-partial-turn"); + const { capture, mockFetch } = createCaptureFetch(); + const patched = patchFetch(mockFetch, als, resolveConfig()); + + await als.run({ provider: "opencode", value: testSession }, async () => { + await patched("https://opencode.ai/zen/v1/responses", { + body: JSON.stringify({ + input: "run command", + model: "muse-spark-1.3-contributor-free", + tools: [{ name: "bash", type: "function" }], + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + const names = toolNamesOf(parseJsonBody(capture.init?.body)); + expect(names?.filter((n) => n === "bash")).toHaveLength(1); + expect(names?.filter((n) => n === "read")).toHaveLength(1); + + const { capture: bufCap, mockFetch: bufFetch } = createCaptureFetch(); + const bufPatched = patchFetch(bufFetch, als, resolveConfig()); + const payload = Buffer.from( + JSON.stringify({ + input: "test buffer", + model: "muse-spark-1.3-contributor-free", + }), + "utf-8" + ); + await als.run({ provider: "opencode", value: testSession }, async () => { + await bufPatched("https://opencode.ai/zen/v1/responses", { + body: payload, + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + expect(typeof bufCap.init?.body).toBe("string"); + expect(toolNamesOf(parseJsonBody(bufCap.init?.body))).toHaveLength(2); + + const { capture: fullCap, mockFetch: fullFetch } = createCaptureFetch(); + await als.run({ provider: "opencode", value: testSession }, async () => { + await patchFetch( + fullFetch, + als, + resolveConfig() + )("https://opencode.ai/zen/v1/responses", { + body: JSON.stringify({ + input: "hi", + model: "muse-spark-1.3-contributor-free", + tools: [ + { name: "read", type: "function" }, + { name: "bash", type: "function" }, + ], + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + expect(toolNamesOf(parseJsonBody(fullCap.init?.body))).toHaveLength(2); + }); +}); + +describe("apply (plugin lifecycle)", () => { + it("patches globalThis.fetch and restores it on disposer call", () => { + const originalFetch = globalThis.fetch; + let disposer: unknown; + + const ctx: CordisContext = { + effect: (fn: () => unknown) => { + disposer = fn(); + }, + on: () => {}, + }; + + apply(ctx); + expect(globalThis.fetch).not.toBe(originalFetch); + + if (typeof disposer === "function") { + disposer(); + } + expect(globalThis.fetch).toBe(originalFetch); + }); + + it("disposer does not clobber a replacement fetch", () => { + const originalFetch = globalThis.fetch; + let disposer: unknown; + const ctx: CordisContext = { + effect: (fn: () => unknown) => { + disposer = fn(); + }, + on: () => {}, + }; + apply(ctx); + globalThis.fetch = replacementFetch; + if (typeof disposer === "function") { + disposer(); + } + expect(globalThis.fetch).toBe(replacementFetch); + globalThis.fetch = originalFetch; + }); + + it("attaches to llm/stream and derives session ID", async () => { + let streamHandler: + | ((options: unknown, next: () => unknown) => unknown) + | undefined; + + const ctx: CordisContext = { + effect: () => {}, + on: ( + _event: string, + handler: (options: unknown, next: () => unknown) => unknown + ) => { + streamHandler = handler; + }, + }; + + apply(ctx); + expect(typeof streamHandler).toBe("function"); + if (typeof streamHandler !== "function") { + throw new TypeError("stream handler not registered"); + } + const result: unknown = streamHandler( + { + model: "muse-spark-1.3-contributor-free", + provider: "opencode", + sessionId: "dsh-session-test-888", + }, + () => createMockStream("stream-chunk-1") + ); + if (!isAsyncIterableLike(result)) { + throw new Error("expected async iterable downstream"); + } + expect(await collectUnknown(result)).toEqual(["stream-chunk-1"]); + }); + + it("ignores non-opencode providers and invalid session IDs", () => { + let streamHandler: + | ((options: unknown, next: () => unknown) => unknown) + | undefined; + const ctx: CordisContext = { + effect: () => {}, + on: ( + _event: string, + handler: (options: unknown, next: () => unknown) => unknown + ) => { + streamHandler = handler; + }, + }; + apply(ctx); + if (typeof streamHandler !== "function") { + throw new TypeError("stream handler not registered"); + } + const handler = streamHandler; + const passthrough = (options: unknown) => + handler(options, () => "next-value"); + + expect(passthrough({ provider: "deepseek", sessionId: "abc" })).toBe( + "next-value" + ); + expect(passthrough({ provider: "opencode" })).toBe("next-value"); + expect(passthrough({ provider: "opencode", sessionId: "" })).toBe( + "next-value" + ); + expect(passthrough(null)).toBe("next-value"); + expect(passthrough("nope")).toBe("next-value"); + }); + + it("warns and skips when globalThis.fetch is unavailable", () => { + const warnings: string[] = []; + vi.stubGlobal("fetch", null); + const ctx: CordisContext = { + effect: () => {}, + logger: { + warn: (msg: string) => { + warnings.push(msg); + }, + }, + on: () => { + throw new Error("on must not be called without fetch"); + }, + }; + apply(ctx); + expect( + warnings.some((w) => w.includes("globalThis.fetch is unavailable")) + ).toBe(true); + }); + + it("records debug entries to debugFile when configured", async () => { + const tmpDir = await mkdtemp(path.join(tmpdir(), "dsh-opencode-test-")); + try { + const debugFile = path.join(tmpDir, "stream-debug.jsonl"); + let streamHandler: + | ((options: unknown, next: () => unknown) => unknown) + | undefined; + + const ctx: CordisContext = { + effect: () => {}, + logger: { info: () => {} }, + on: ( + _event: string, + handler: (options: unknown, next: () => unknown) => unknown + ) => { + streamHandler = handler; + }, + }; + + apply(ctx, { debug: true, debugFile }); + if (typeof streamHandler !== "function") { + throw new TypeError("stream handler not registered"); + } + const result: unknown = streamHandler( + { + model: "muse-spark-1.3-contributor-free", + provider: "opencode", + sessionId: "session-debug-999", + }, + () => createMockStream("done") + ); + if (!isAsyncIterableLike(result)) { + throw new Error("expected async iterable downstream"); + } + expect(await collectUnknown(result)).toEqual(["done"]); + + const content = await waitForFileContent(debugFile); + const [firstLine] = content.trim().split("\n"); + if (firstLine === undefined) { + throw new Error("debug file is empty"); + } + const parsed: unknown = JSON.parse(firstLine); + if (!isRecord(parsed)) { + throw new Error("debug entry is not an object"); + } + expect(parsed.header).toBe("x-opencode-session"); + expect(parsed.model).toBe("muse-spark-1.3-contributor-free"); + expect(parsed.provider).toBe("opencode"); + expect(parsed.session).toBe("session-debug-999"); + expect(typeof parsed.value).toBe("string"); + if (typeof parsed.value === "string") { + expect(parsed.value).toMatch(/^ses_/); + } + } finally { + await rm(tmpDir, { force: true, recursive: true }); + } + }); + + it("keeps session affinity across turns for the same DSH session", async () => { + const tmpDir = await mkdtemp(path.join(tmpdir(), "dsh-opencode-aff-")); + try { + const debugFile = path.join(tmpDir, "affinity.jsonl"); + let streamHandler: + | ((options: unknown, next: () => unknown) => unknown) + | undefined; + const ctx: CordisContext = { + effect: () => {}, + logger: { info: () => {} }, + on: ( + _event: string, + handler: (options: unknown, next: () => unknown) => unknown + ) => { + streamHandler = handler; + }, + }; + apply(ctx, { debugFile }); + if (typeof streamHandler !== "function") { + throw new TypeError("stream handler not registered"); + } + const handler = streamHandler; + const driveTurn = async (label: string) => { + const result: unknown = handler( + { model: "m", provider: "opencode", sessionId: "same-dsh-session" }, + () => createMockStream(label) + ); + if (!isAsyncIterableLike(result)) { + throw new Error("expected async iterable downstream"); + } + await expect(collectUnknown(result)).resolves.toEqual([label]); + }; + await driveTurn("turn-0"); + await driveTurn("turn-1"); + const content = await waitForFileContent(debugFile, 2); + const values: unknown[] = []; + for (const line of content.trim().split("\n")) { + const parsed: unknown = JSON.parse(line); + if (isRecord(parsed)) { + values.push(parsed.value); + } + } + expect(values).toHaveLength(2); + expect(values[0]).toBe(values[1]); + } finally { + await rm(tmpDir, { force: true, recursive: true }); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2007b59 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2024", + "lib": ["ES2024", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "lib", + "declaration": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "scripts/**/*.ts", "test/**/*.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..7bc24af --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,119 @@ +import ultraciteFmt from "ultracite/oxfmt"; +import ultraciteLint from "ultracite/oxlint/core"; +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + fmt: { + ...ultraciteFmt, + ignorePatterns: [ + ...(ultraciteFmt.ignorePatterns ?? []), + "lib/**", + "coverage/**", + ], + sortPackageJson: true, + }, + lint: { + extends: [ultraciteLint], + ignorePatterns: ["lib/**", "coverage/**"], + options: { + typeAware: true, + typeCheck: true, + }, + overrides: [ + { + files: ["test/**/*.ts", "scripts/**/*.ts"], + rules: { + "import/namespace": "off", + "no-await-in-loop": "off", + "no-console": "off", + "no-process-exit": "off", + "promise/prefer-await-to-callbacks": "off", + "typescript/no-non-null-assertion": "off", + "typescript/no-unsafe-argument": "off", + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-call": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-unsafe-return": "off", + "typescript/strict-boolean-expressions": "off", + }, + }, + ], + rules: { + complexity: "off", + "consistent-type-specifier-style": "off", + curly: "off", + eqeqeq: ["error", "always", { null: "ignore" }], + "max-classes-per-file": "off", + "max-nested-callbacks": "off", + "no-await-in-loop": "warn", + "no-eq-null": "off", + "no-inline-comments": "off", + "no-use-before-define": "off", + "node/callback-return": "off", + "prefer-named-capture-group": "off", + "require-await": "warn", + "require-param-description": "off", + "require-returns-description": "off", + "require-unicode-regexp": "off", + "typescript/await-thenable": "error", + "typescript/no-explicit-any": "warn", + "typescript/no-floating-promises": "error", + "typescript/no-for-in-array": "error", + "typescript/no-implied-eval": "error", + "typescript/no-misused-promises": "error", + "typescript/no-non-null-assertion": "warn", + "typescript/no-unsafe-argument": "warn", + "typescript/no-unsafe-assignment": "warn", + "typescript/no-unsafe-call": "warn", + "typescript/no-unsafe-enum-comparison": "warn", + "typescript/no-unsafe-function-type": "warn", + "typescript/no-unsafe-member-access": "warn", + "typescript/no-unsafe-return": "warn", + "typescript/no-unsafe-type-assertion": "warn", + "typescript/only-throw-error": "error", + "typescript/prefer-nullish-coalescing": "warn", + "typescript/prefer-promise-reject-errors": "error", + // Off: `withStore` iterators and the `fetch` patch intentionally use + // sync functions returning promises so `AsyncLocalStorage.run` keeps + // turn context without an extra async tick. + "typescript/promise-function-async": "off", + "typescript/return-await": "warn", + "typescript/strict-boolean-expressions": "warn", + "unicorn/filename-case": "off", + "unicorn/prefer-export-from": "off", + }, + }, + pack: [ + { + clean: true, + dts: true, + format: ["esm"], + outDir: "lib", + platform: "node", + sourcemap: false, + target: "node24", + }, + { + banner: + 'window.__ModuleLoader__.load({\n id: "dsh-opencode",\n factory: (require) => {\n var module = { exports: {} };\n var exports = module.exports;', + clean: false, + deps: { + neverBundle: [ + "react", + "react/jsx-runtime", + "@deepseek-ai/dsh-client-ui-primitives", + ], + }, + dts: false, + entry: { client: "src/settings-page.tsx" }, + footer: " return module.exports;\n },\n});", + format: ["cjs"], + outDir: "lib", + platform: "browser", + sourcemap: false, + }, + ], + test: { + include: ["test/**/*.test.ts"], + }, +}); From b96426189c464b4e2fa0c9c7468381307226f768 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 04:57:28 +0800 Subject: [PATCH 002/242] ci: pin latest action majors (checkout v7, setup-node v7, pnpm v6) --- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5498057..f13611c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,11 +13,11 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 with: version: 12 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 24 cache: pnpm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bf46b9..fd9a82a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,11 +11,11 @@ jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 with: version: 12 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 24 cache: pnpm @@ -30,11 +30,11 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 with: version: 12 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 24 cache: pnpm From e42b427365c63a35c5ed1c419c71a55d76c3b72f Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 05:01:55 +0800 Subject: [PATCH 003/242] feat: auto releases, GitHub Packages mirror, and verified-compat docs - release-please opens version/changelog PRs from conventional commits; merging tags + creates the GitHub Release that fires OIDC publishing. - Mirror each release to GitHub Packages via GITHUB_TOKEN so the repo Packages sidebar populates; npmjs.org stays the install source. - README gains status badges and a Last-verified compatibility matrix; CONTRIBUTING documents refreshing it on every release. --- .github/workflows/release-please.yml | 20 ++++++++++++++++++++ .github/workflows/release.yml | 7 +++++++ CONTRIBUTING.md | 6 +++--- README.md | 15 ++++++++++++++- 4 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release-please.yml diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..7f58f88 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,20 @@ +name: release-please + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + # Scans conventional commits (feat:/fix:) since the last tag, opens a + # version-bump + changelog PR; merging it creates the tag + GitHub + # Release, which fires the `release` workflow (npm + GitHub Packages). + - uses: googleapis/release-please-action@v5 + with: + release-type: node diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd9a82a..9fd3509 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,7 @@ jobs: permissions: contents: read id-token: write + packages: write steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 @@ -49,3 +50,9 @@ jobs: # viztor/dsh-opencode + release.yml publisher registered on npmjs.com # with `npm publish` allowed. Provenance is automatic. - run: npm publish --access public + # Mirror to GitHub Packages so the repo sidebar populates; auth is the + # built-in GITHUB_TOKEN (packages: write above), no secret needed. + # Installs still come from npmjs.org unless a consumer repoints the scope. + - run: npm publish --registry=https://npm.pkg.github.com --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1d102b..b08b22c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,9 +34,9 @@ pnpm run build # vp pack + client rename -> lib/index.mjs, lib/index.d.mts, li ## Release process (maintainers) -1. Bump `version` in `package.json`, add a `CHANGELOG.md` entry, commit. -2. `git tag vX.Y.Z && git push origin vX.Y.Z` — the `release` workflow verifies tag == version, runs check + tests, builds, and publishes to npm via OIDC trusted publishing. No tokens involved. -3. CI (`ci.yml`) runs check + test + build on every push to `main` and every PR. Keep it green. +1. Use conventional commits (`feat:`, `fix:`) — release-please opens the version-bump + changelog PR automatically; merging it tags and creates the GitHub Release, which fires OIDC publishing to npm and GitHub Packages. +2. For manual releases: bump `version` in `package.json`, add a `CHANGELOG.md` entry, commit, `git tag vX.Y.Z && git push origin vX.Y.Z`. +3. On every release, refresh the **Last verified** date and matrix in `README.md` after smoke-testing: registry install resolves, plugin loads in the DSH Web profile, and a free-tier Zen call succeeds. ## Docs diff --git a/README.md b/README.md index d504ed5..7e44f44 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenCode on DeepSeek Harness -[![npm version](https://img.shields.io/npm/v/@viztor/dsh-opencode)](https://www.npmjs.com/package/@viztor/dsh-opencode) [![CI](https://github.com/viztor/dsh-opencode/actions/workflows/ci.yml/badge.svg)](https://github.com/viztor/dsh-opencode/actions/workflows/ci.yml) [![Release](https://github.com/viztor/dsh-opencode/actions/workflows/release.yml/badge.svg)](https://github.com/viztor/dsh-opencode/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/npm/l/@viztor/dsh-opencode)](LICENSE) [![Node >= 24](https://img.shields.io/node/v/@viztor/dsh-opencode)](https://nodejs.org) +[![npm version](https://img.shields.io/npm/v/@viztor/dsh-opencode)](https://www.npmjs.com/package/@viztor/dsh-opencode) [![CI](https://github.com/viztor/dsh-opencode/actions/workflows/ci.yml/badge.svg)](https://github.com/viztor/dsh-opencode/actions/workflows/ci.yml) [![Release](https://github.com/viztor/dsh-opencode/actions/workflows/release.yml/badge.svg)](https://github.com/viztor/dsh-opencode/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/npm/l/@viztor/dsh-opencode)](LICENSE) [![Node >= 24](https://img.shields.io/node/v/@viztor/dsh-opencode)](https://nodejs.org) [![Last commit](https://img.shields.io/github/last-commit/viztor/dsh-opencode)](https://github.com/viztor/dsh-opencode/commits/main) Run free OpenCode Zen models (like `muse-spark-1.3-contributor-free`) inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) without `403 FreeTierError` or `400 MissingSessionID` errors. @@ -74,6 +74,19 @@ Full option reference (types, `debug`/`debugFile`, `mode`): see [cordis.patch.ym | `400 MissingSessionID` | No session header attached | Plugin must be in `bundles`; check it loaded | | Paid/other providers misbehaving | Shouldn't happen — they're never touched | File an issue with a redacted log | +## Compatibility + +**Last verified: 2026-10-01** — refreshed on every release (see [Contributing](CONTRIBUTING.md)). + +| Component | Verified version | +| :-- | :-- | +| Plugin | `@viztor/dsh-opencode@0.2.1` (npm + GitHub Packages) | +| Host | DSH Web profile (`dsh-profile-web`, `patchReload: live`) | +| Runtime | Node 24+ | +| Gateway | `https://opencode.ai/zen/v1` (`/responses` + chat completions) | +| Model | `muse-spark-1.3-contributor-free` | +| Checks | `vp check` clean, 44/44 deterministic tests, registry install resolves | + ## Links - [Contributing](CONTRIBUTING.md) — dev setup, conventions, release process From 05ce96f52e9276229f8d812db41e244d6915398c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:09:54 +0000 Subject: [PATCH 004/242] chore(main): release 0.3.0 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b2e622..bf1e31b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://git --- +## [0.3.0](https://github.com/viztor/dsh-opencode/compare/v0.2.1...v0.3.0) (2026-09-30) + + +### Features + +* auto releases, GitHub Packages mirror, and verified-compat docs ([e42b427](https://github.com/viztor/dsh-opencode/commit/e42b427365c63a35c5ed1c419c71a55d76c3b72f)) + ## [0.2.1] - 2026-10-01 ### Changed diff --git a/package.json b/package.json index 8003c6b..664491b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@viztor/dsh-opencode", - "version": "0.2.1", + "version": "0.3.0", "description": "DeepSeek Harness plugin: automatically adds stable per-conversation x-opencode-session and OpenCode gateway origin headers to OpenCode / OpenCode Go / OpenCode Zen requests — fixes 400 MissingSessionID and 403 FreeTierError, keeping session affinity and free-tier access working.", "keywords": [ "cordis", From 6baafcdb4f004fc671cec910ff3a11c5a0b925d1 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 05:13:39 +0800 Subject: [PATCH 005/242] fix: exempt bot-owned CHANGELOG from formatter, use PAT for release-please release-please appends entries in its own style, which tripped oxfmt and blocked publishing. CHANGELOG.md joins lib/ as fmt-ignored (the .prettierignore convention); lint/types still cover all source. release-please now authenticates with RELEASE_PLEASE_TOKEN so its tags/releases trigger the publish workflow (GITHUB_TOKEN-created refs never fire downstream runs). --- .github/workflows/release-please.yml | 5 +++++ vite.config.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 7f58f88..0aed4e3 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -15,6 +15,11 @@ jobs: # Scans conventional commits (feat:/fix:) since the last tag, opens a # version-bump + changelog PR; merging it creates the tag + GitHub # Release, which fires the `release` workflow (npm + GitHub Packages). + # PAT (not GITHUB_TOKEN): tags/releases created with the built-in + # token don't trigger downstream workflows, so the `release` job + # would never fire. Secret: RELEASE_PLEASE_TOKEN, fine-grained PAT + # with Contents + Pull requests read/write on this repo. - uses: googleapis/release-please-action@v5 with: release-type: node + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} diff --git a/vite.config.ts b/vite.config.ts index 7bc24af..d3d95e2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ ...(ultraciteFmt.ignorePatterns ?? []), "lib/**", "coverage/**", + // Bot-owned: release-please appends entries its own way every release. + "CHANGELOG.md", ], sortPackageJson: true, }, From 7ed4db2a9b905c35d4045bb46105da41ee6c2788 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 05:19:46 +0800 Subject: [PATCH 006/242] fix: map GITHUB_TOKEN to GitHub Packages registry and skip republished versions setup-node only writes auth for registry.npmjs.org, so the mirror step got ENEEDAUTH. Append the registry auth line explicitly. Guard the npmjs step with npm view so re-pushed tags retry cleanly instead of 403. --- .github/workflows/release.yml | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fd3509..6bac0f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,11 +48,24 @@ jobs: - run: pnpm run build # OIDC trusted publishing: no token needed. Requires the # viztor/dsh-opencode + release.yml publisher registered on npmjs.com - # with `npm publish` allowed. Provenance is automatic. - - run: npm publish --access public - # Mirror to GitHub Packages so the repo sidebar populates; auth is the - # built-in GITHUB_TOKEN (packages: write above), no secret needed. - # Installs still come from npmjs.org unless a consumer repoints the scope. - - run: npm publish --registry=https://npm.pkg.github.com --access public + # with `npm publish` allowed. Provenance is automatic. The version + # guard keeps re-runs idempotent (a published version is skipped, + # so a failed mirror step can be retried by re-pushing the tag). + - name: publish to npmjs (OIDC) + run: | + VER=$(node -p "require('./package.json').version") + if npm view "@viztor/dsh-opencode@$VER" version 2>/dev/null; then + echo "$VER already on npmjs, skipping" + else + npm publish --access public + fi + # Mirror to GitHub Packages so the repo sidebar populates. setup-node + # only maps auth to registry.npmjs.org, so map GITHUB_TOKEN to the + # GitHub registry explicitly (masked in logs). Installs still come + # from npmjs.org unless a consumer repoints the scope. + - name: mirror to GitHub Packages + run: | + printf '%s' "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc + npm publish --registry=https://npm.pkg.github.com --access public env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From b6c7322b01c5344063cfaa0f04aa58eed58f3a41 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 05:21:28 +0800 Subject: [PATCH 007/242] fix: write GHP auth to setup-node temp npmrc, not home npmrc --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bac0f6..4e1cceb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,12 +60,12 @@ jobs: npm publish --access public fi # Mirror to GitHub Packages so the repo sidebar populates. setup-node - # only maps auth to registry.npmjs.org, so map GITHUB_TOKEN to the - # GitHub registry explicitly (masked in logs). Installs still come - # from npmjs.org unless a consumer repoints the scope. + # redirects npm to a temp userconfig (NPM_CONFIG_USERCONFIG), so the + # token must be mapped there, not ~/.npmrc. Installs still come from + # npmjs.org unless a consumer repoints the scope. - name: mirror to GitHub Packages run: | - printf '%s' "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc + printf '%s' "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" npm publish --registry=https://npm.pkg.github.com --access public env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 9850019a6690ef22d37109a6e1d9720256a148b9 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 05:24:03 +0800 Subject: [PATCH 008/242] fix: pass GHP auth as inline npm config flag --- .github/workflows/release.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e1cceb..ffbb698 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,13 +59,11 @@ jobs: else npm publish --access public fi - # Mirror to GitHub Packages so the repo sidebar populates. setup-node - # redirects npm to a temp userconfig (NPM_CONFIG_USERCONFIG), so the - # token must be mapped there, not ~/.npmrc. Installs still come from - # npmjs.org unless a consumer repoints the scope. + # Mirror to GitHub Packages so the repo sidebar populates. Auth rides + # on the command line (file appends proved unreliable across + # setup-node's temp userconfig). Installs still come from npmjs.org + # unless a consumer repoints the scope. - name: mirror to GitHub Packages - run: | - printf '%s' "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" - npm publish --registry=https://npm.pkg.github.com --access public + run: npm publish --registry=https://npm.pkg.github.com --access public --//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 29b62890455c0fecf2420317150dec83ad7af1a4 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 05:26:42 +0800 Subject: [PATCH 009/242] docs: point install and compat matrix at 0.3.0 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7e44f44..8c46e36 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Or declare it in your profile's `package.json`: ```json { "dependencies": { - "@viztor/dsh-opencode": "^0.2.1" + "@viztor/dsh-opencode": "^0.3.0" }, "dsh": { "profile": { @@ -80,7 +80,7 @@ Full option reference (types, `debug`/`debugFile`, `mode`): see [cordis.patch.ym | Component | Verified version | | :-- | :-- | -| Plugin | `@viztor/dsh-opencode@0.2.1` (npm + GitHub Packages) | +| Plugin | `@viztor/dsh-opencode@0.3.0` (npm + GitHub Packages) | | Host | DSH Web profile (`dsh-profile-web`, `patchReload: live`) | | Runtime | Node 24+ | | Gateway | `https://opencode.ai/zen/v1` (`/responses` + chat completions) | From 628678b97b0e70e8649ef17269aa41ff68df994d Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 05:39:15 +0800 Subject: [PATCH 010/242] docs: frame plugin as Zen + Go, not Zen-only --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8c46e36..50479a6 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![npm version](https://img.shields.io/npm/v/@viztor/dsh-opencode)](https://www.npmjs.com/package/@viztor/dsh-opencode) [![CI](https://github.com/viztor/dsh-opencode/actions/workflows/ci.yml/badge.svg)](https://github.com/viztor/dsh-opencode/actions/workflows/ci.yml) [![Release](https://github.com/viztor/dsh-opencode/actions/workflows/release.yml/badge.svg)](https://github.com/viztor/dsh-opencode/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/npm/l/@viztor/dsh-opencode)](LICENSE) [![Node >= 24](https://img.shields.io/node/v/@viztor/dsh-opencode)](https://nodejs.org) [![Last commit](https://img.shields.io/github/last-commit/viztor/dsh-opencode)](https://github.com/viztor/dsh-opencode/commits/main) -Run free OpenCode Zen models (like `muse-spark-1.3-contributor-free`) inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) without `403 FreeTierError` or `400 MissingSessionID` errors. +Run free OpenCode models — Zen (`muse-spark-1.3-contributor-free`, `space-bunny-free`) and Go (`deepseek-v4.1-flash`) — inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) without `403 FreeTierError` or `400 MissingSessionID` errors. -**Why this exists:** OpenCode's gateway only serves free-tier models to requests that look like the OpenCode CLI (specific `User-Agent`, client headers, and `ses_…` session IDs) and carry `read`/`bash` tool definitions. DeepSeek Harness strips the user agent, uses UUID session IDs the gateway rejects, and can send tool-less requests — so free-tier calls fail. This plugin restores what's needed at the network layer, **only for OpenCode traffic**. Everything else (DeepSeek, OpenAI, GitHub, tools) passes through byte-for-byte untouched. +**Why this exists:** OpenCode's gateways only serve requests carrying a valid `x-opencode-session`, and the Zen gateway additionally demands CLI origin proof (`User-Agent`, client headers, `ses_…` IDs) plus `read`/`bash` tool definitions on free-tier calls. DeepSeek Harness strips the user agent, uses UUID session IDs the gateways reject, and can send tool-less requests — so calls fail. This plugin restores what's needed at the network layer, **only for OpenCode traffic** (`opencode` and `opencode-go` routes, `zen/v1` and `zen/go/v1` URLs). Everything else (DeepSeek, OpenAI, GitHub, tools) passes through byte-for-byte untouched. ## Install @@ -83,8 +83,8 @@ Full option reference (types, `debug`/`debugFile`, `mode`): see [cordis.patch.ym | Plugin | `@viztor/dsh-opencode@0.3.0` (npm + GitHub Packages) | | Host | DSH Web profile (`dsh-profile-web`, `patchReload: live`) | | Runtime | Node 24+ | -| Gateway | `https://opencode.ai/zen/v1` (`/responses` + chat completions) | -| Model | `muse-spark-1.3-contributor-free` | +| Gateway | `zen/v1` (`/responses` + chat completions) and `zen/go/v1` (chat completions) | +| Models | `muse-spark-1.3-contributor-free`, `space-bunny-free`, `deepseek-v4.1-flash` (Go) | | Checks | `vp check` clean, 44/44 deterministic tests, registry install resolves | ## Links From a4b49eb13a2463390940c601f402b35ea670680c Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 05:45:18 +0800 Subject: [PATCH 011/242] docs: clarify code defaults vs file pins in settings table --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 50479a6..85944a2 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ Open DSH Web → **Settings → Plugins → OpenCode Integration**, flip toggles | Inject Core Tools | on | Adds fallback `read`/`bash` schemas to free-tier `/responses` calls | | Providers | `opencode, opencode-go` | Which route IDs get the treatment | +The Defaults above live in **code** (`resolveConfig` in `src/index.ts` — e.g. `DEFAULT_PROVIDERS`, toggles defaulting to on, empty override meaning "use canonical"). `cordis.patch.yml` pins the same values explicitly so a deployment's effective config reads in one place; the UI clearing a field falls back to the file value, then the code default. Single source of truth stays in code — the file mirrors, never contradicts. + ## Headless / declarative config For servers or `cordis.patch.yml` overlays: From 9a60d4b7825a7a32bf475c9bd14912c9953338c2 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 05:46:48 +0800 Subject: [PATCH 012/242] fix: surface effective defaults in settings UI labels and hints Boolean toggles read '(default on)' and providers hint names its default, in both en and zh, so users see what's active without opening docs. --- src/settings-page.tsx | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/settings-page.tsx b/src/settings-page.tsx index 8c64e1f..ac89911 100644 --- a/src/settings-page.tsx +++ b/src/settings-page.tsx @@ -23,20 +23,21 @@ export const inject = ["slots", "locale", "configForms"]; const en = { description: "OpenCode Zen gateway origin headers, session affinity, and free-tier compatibility.", - injectCoreTools: "Inject Core Tools", + injectCoreTools: "Inject Core Tools (default on)", injectCoreToolsHint: - "Auto-injects read and bash tool schemas on free-tier requests to satisfy gateway validation.", - injectOriginHeaders: "Inject Origin Headers", + "Auto-injects read and bash tool schemas on free-tier requests to satisfy gateway validation. Empty inherits the default.", + injectOriginHeaders: "Inject Origin Headers (default on)", injectOriginHeadersHint: - "Injects x-opencode-client and x-opencode-project headers.", - injectUserAgent: "Inject User-Agent", + "Injects x-opencode-client and x-opencode-project headers. Empty inherits the default.", + injectUserAgent: "Inject User-Agent (default on)", injectUserAgentHint: - "Restores the opencode CLI User-Agent stripped by the DSH LLM adapter.", + "Restores the opencode CLI User-Agent stripped by the DSH LLM adapter. Empty inherits the default.", invalidBoolean: "Enter true or false, or leave blank for default.", invalidText: "This value was not accepted; leave blank for default.", overridden: "Overridden", providers: "Providers", - providersHint: "Comma-separated list of route IDs to intercept.", + providersHint: + "Comma-separated list of route IDs to intercept. Default: opencode, opencode-go.", readOnly: "This deployment stores settings read-only.", reset: "Reset to default", save: "Save", @@ -51,20 +52,20 @@ const en = { const zh = { description: "OpenCode Zen 网关来源头恢复、会话保持与免费模型兼容支持。", - injectCoreTools: "自动补全核心工具", + injectCoreTools: "自动补全核心工具(默认开启)", injectCoreToolsHint: - "在免费模型请求中自动注入 read 和 bash 工具声明以满足网关校验。", - injectOriginHeaders: "注入客户端来源头", + "在免费模型请求中自动注入 read 和 bash 工具声明以满足网关校验。留空沿用默认值。", + injectOriginHeaders: "注入客户端来源头(默认开启)", injectOriginHeadersHint: - "注入 x-opencode-client 与 x-opencode-project 头部信息。", - injectUserAgent: "恢复 User-Agent", + "注入 x-opencode-client 与 x-opencode-project 头部信息。留空沿用默认值。", + injectUserAgent: "恢复 User-Agent(默认开启)", injectUserAgentHint: - "恢复被 DSH 适配器过滤掉的官方 OpenCode CLI User-Agent。", + "恢复被 DSH 适配器过滤掉的官方 OpenCode CLI User-Agent。留空沿用默认值。", invalidBoolean: "请输入 true 或 false,留空使用默认值。", invalidText: "该值未被接受,留空使用默认值。", overridden: "已覆盖", providers: "生效提供方", - providersHint: "逗号分隔的提供方路由 ID 列表。", + providersHint: "逗号分隔的提供方路由 ID 列表。默认:opencode, opencode-go。", readOnly: "当前部署配置为只读。", reset: "恢复默认", save: "保存", From 08c6bf8c0264d84b418ea70da003c85c5f58d7da Mon Sep 17 00:00:00 2001 From: viz Date: Thu, 1 Oct 2026 05:47:11 +0800 Subject: [PATCH 013/242] chore(main): release 0.3.1 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf1e31b..2ca4274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://git --- +## [0.3.1](https://github.com/viztor/dsh-opencode/compare/v0.3.0...v0.3.1) (2026-09-30) + + +### Bug Fixes + +* surface effective defaults in settings UI labels and hints ([76d768d](https://github.com/viztor/dsh-opencode/commit/76d768d2755ce24c8367e6103f05d067bc3f4c43)) + ## [0.3.0](https://github.com/viztor/dsh-opencode/compare/v0.2.1...v0.3.0) (2026-09-30) diff --git a/package.json b/package.json index 664491b..58ba5ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@viztor/dsh-opencode", - "version": "0.3.0", + "version": "0.3.1", "description": "DeepSeek Harness plugin: automatically adds stable per-conversation x-opencode-session and OpenCode gateway origin headers to OpenCode / OpenCode Go / OpenCode Zen requests — fixes 400 MissingSessionID and 403 FreeTierError, keeping session affinity and free-tier access working.", "keywords": [ "cordis", From ad2364611f686928e81faad7f9a9cfda0f77b867 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 06:06:24 +0800 Subject: [PATCH 014/242] fix: register settings card on plugins.bundle.config keyed by package name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party bundles render config on plugins.bundle.config, not plugins.item (reserved for official plugins) — the card was registering on a slot the bundle page never reads. Key by npm package name per the slot contract, accept view page, dispose the form model, and prefetch the client bundle with immediately:true. --- package.json | 3 ++- src/settings-page.tsx | 31 ++++++++++++++++++++++++------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 58ba5ec..0df947a 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,8 @@ "@deepseek-ai/dsh-client-ui-plugin-manager", "@deepseek-ai/dsh-api-remotes" ], - "platform": "web" + "platform": "web", + "immediately": true } } } diff --git a/src/settings-page.tsx b/src/settings-page.tsx index ac89911..e8f9d0e 100644 --- a/src/settings-page.tsx +++ b/src/settings-page.tsx @@ -18,6 +18,15 @@ import React from "react"; export const NS = "dsh-opencode"; +/** + * The bundle's npm package name, spelled rather than imported. + * + * `plugins.bundle.config` entries are keyed by npm package name (not the + * cordis row id), so this must equal `package.json`'s `name`. The client + * half must not depend on the host half, hence the duplication. + */ +export const PKG = "@viztor/dsh-opencode"; + export const inject = ["slots", "locale", "configForms"]; const en = { @@ -128,7 +137,7 @@ interface CardProps { save: () => void; t: Translate; useOpencodeCard: (selector: (snapshot: CardState) => CardState) => CardState; - view: "summary" | "form"; + view: "summary" | "page"; } const formLabels = (t: Translate) => ({ @@ -243,7 +252,6 @@ const isSettingsFormScope = ( }; export const apply = (ctx: ClientContext): void => { - const t = ctx.locale?.bind?.(NS) ?? ((k: string) => k); ctx.effect?.(() => { ctx.locale?.register?.(NS, { en, zh }); }, "dsh-opencode: dictionaries"); @@ -262,19 +270,28 @@ export const apply = (ctx: ClientContext): void => { shell: model.shell(), })); + ctx.effect?.( + () => () => { + model.dispose(); + }, + "dsh-opencode: form subscription" + ); + ctx.configForms?.whileServed?.([NS], () => { - ctx.slots?.inject?.("plugins.item", () => { + // `plugins.bundle.config` (NOT `plugins.item`): third-party bundles + // render their own configuration on the bundle's page, keyed by npm + // package name. The hook key becomes the `useOpencodeCard` prop; the + // actions spread in as `edit` / `resetField` / `save` / `discard`. + ctx.slots?.inject?.("plugins.bundle.config", () => { ctx.slots?.register?.( { - id: "opencode", inject: () => ({ hooks: { opencodeCard: store }, ...model.actions(), }), - label: () => t("title"), + key: PKG, locale: NS, - name: "plugins.item", - order: 50, + name: "plugins.bundle.config", }, OpencodeCard ); From 22009ea00b999475619eb49e5154529cfb397ae2 Mon Sep 17 00:00:00 2001 From: viz Date: Thu, 1 Oct 2026 06:07:17 +0800 Subject: [PATCH 015/242] chore(main): release 0.3.2 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ca4274..66a718a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://git --- +## [0.3.2](https://github.com/viztor/dsh-opencode/compare/v0.3.1...v0.3.2) (2026-09-30) + + +### Bug Fixes + +* register settings card on plugins.bundle.config keyed by package name ([9409a30](https://github.com/viztor/dsh-opencode/commit/9409a30917f3408f345a260375396d95182426fb)) + ## [0.3.1](https://github.com/viztor/dsh-opencode/compare/v0.3.0...v0.3.1) (2026-09-30) diff --git a/package.json b/package.json index 0df947a..2125df7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@viztor/dsh-opencode", - "version": "0.3.1", + "version": "0.3.2", "description": "DeepSeek Harness plugin: automatically adds stable per-conversation x-opencode-session and OpenCode gateway origin headers to OpenCode / OpenCode Go / OpenCode Zen requests — fixes 400 MissingSessionID and 403 FreeTierError, keeping session affinity and free-tier access working.", "keywords": [ "cordis", From 8cf10b0cbdf8524ae1cdca1c02e0cffea2c28996 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 06:16:18 +0800 Subject: [PATCH 016/242] fix: cordis row name must equal npm package name The host resolves row names to node_modules paths, so the unscoped name left over from the rename failed the entry with 'failed to import'. Adds manifest-consistency tests pinning row name to package.json and settings NS to row id. --- cordis.patch.yml | 7 ++++++- test/plugin.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/cordis.patch.yml b/cordis.patch.yml index 669759d..a3e526e 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -3,6 +3,11 @@ # Adds one host plugin row that manages session affinity, OpenCode Zen gateway # origin headers, and free-tier compatibility for OpenCode, OpenCode Go, and OpenCode Zen routes. # +# NOTE: `name` must equal this package's npm name (@viztor/dsh-opencode) — +# the host resolves row names to node_modules paths, so an unscoped name +# fails the entry with "failed to import". `id` stays short: it is the +# settings namespace and the plugin's own `name` export. +# # Configuration (row `config`, all optional): # providers: [string] provider route keys to intercept (default ['opencode', 'opencode-go']). # injectUserAgent: boolean restore OpenCode CLI User-Agent stripped by DSH adapter (default true). @@ -14,7 +19,7 @@ # debugFile: path optional append target for stream debug JSONL. - insert: - id: dsh-opencode - name: dsh-opencode + name: "@viztor/dsh-opencode" config: providers: - opencode diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 095b868..77c547d 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -1015,3 +1015,40 @@ describe("apply (plugin lifecycle)", () => { } }); }); + +describe("bundle manifest consistency", () => { + const root = path.dirname(import.meta.dirname); + + const readText = (rel: string): Promise => + readFile(path.join(root, rel), "utf-8"); + + it("ships a cordis row whose name equals the npm package name", async () => { + const pkgRaw: unknown = JSON.parse(await readText("package.json")); + if (!isRecord(pkgRaw) || typeof pkgRaw.name !== "string") { + throw new Error("package.json has no string name"); + } + const patch = await readText("cordis.patch.yml"); + const row = + /^\s*-\s*id:\s*dsh-opencode\s*\n\s*name:\s*["']?([^"'\s]+)/m.exec(patch); + if (row === null || row[1] === undefined) { + throw new Error("dsh-opencode row not found in cordis.patch.yml"); + } + // The host resolves row names to node_modules paths: an unscoped name + // fails the entry with "failed to import". + expect(row[1]).toBe(pkgRaw.name); + }); + + it("keeps the settings namespace equal to the cordis row id", async () => { + const patch = await readText("cordis.patch.yml"); + expect(patch).toContain("id: dsh-opencode"); + // Read the NS constant textually: importing settings-page.tsx would + // drag the React + ui-primitives runtime chain (whose own deps are + // incomplete for node) into a hermetic suite. + const page = await readText("src/settings-page.tsx"); + const ns = /^export const NS = "([^"]+)";/m.exec(page); + if (ns === null || ns[1] === undefined) { + throw new Error("NS constant not found in src/settings-page.tsx"); + } + expect(ns[1]).toBe("dsh-opencode"); + }); +}); From ce0e4d23ac45f493f1a99f55f5f2debdeaff0999 Mon Sep 17 00:00:00 2001 From: viz Date: Thu, 1 Oct 2026 06:17:28 +0800 Subject: [PATCH 017/242] chore(main): release 0.3.3 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66a718a..ee2983f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://git --- +## [0.3.3](https://github.com/viztor/dsh-opencode/compare/v0.3.2...v0.3.3) (2026-09-30) + + +### Bug Fixes + +* cordis row name must equal npm package name ([4907117](https://github.com/viztor/dsh-opencode/commit/49071171703f0a7e025beb5319b6ebac37077240)) + ## [0.3.2](https://github.com/viztor/dsh-opencode/compare/v0.3.1...v0.3.2) (2026-09-30) diff --git a/package.json b/package.json index 2125df7..6c9f393 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@viztor/dsh-opencode", - "version": "0.3.2", + "version": "0.3.3", "description": "DeepSeek Harness plugin: automatically adds stable per-conversation x-opencode-session and OpenCode gateway origin headers to OpenCode / OpenCode Go / OpenCode Zen requests — fixes 400 MissingSessionID and 403 FreeTierError, keeping session affinity and free-tier access working.", "keywords": [ "cordis", From cb7914af0e4e357921ae790ac9d7a00b74d874ad Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 07:04:36 +0800 Subject: [PATCH 018/242] fix: register client bundle under npm package name The web loader drops bundles whose __ModuleLoader__.load id differs from the npm package name ('loaded without registering ...'). Same scoped-name lesson as the cordis row: id is now @viztor/dsh-opencode, pinned by a manifest test. --- test/plugin.test.ts | 15 +++++++++++++++ vite.config.ts | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 77c547d..4ce9c75 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -1051,4 +1051,19 @@ describe("bundle manifest consistency", () => { } expect(ns[1]).toBe("dsh-opencode"); }); + + it("registers the client bundle under the npm package name", async () => { + const pkgRaw: unknown = JSON.parse(await readText("package.json")); + if (!isRecord(pkgRaw) || typeof pkgRaw.name !== "string") { + throw new Error("package.json has no string name"); + } + // The web loader drops bundles whose __ModuleLoader__.load id differs + // from the npm package name ("loaded without registering ..."). + const config = await readText("vite.config.ts"); + const banner = /id:\s*"([^"]+)"/.exec(config); + if (banner === null || banner[1] === undefined) { + throw new Error("client banner id not found in vite.config.ts"); + } + expect(banner[1]).toBe(pkgRaw.name); + }); }); diff --git a/vite.config.ts b/vite.config.ts index d3d95e2..327a6d5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -96,8 +96,11 @@ export default defineConfig({ target: "node24", }, { + // NOTE: id must equal package.json `name` exactly (including scope). + // The loader drops bundles that register any other id with + // "loaded without registering ... via __ModuleLoader__.load". banner: - 'window.__ModuleLoader__.load({\n id: "dsh-opencode",\n factory: (require) => {\n var module = { exports: {} };\n var exports = module.exports;', + 'window.__ModuleLoader__.load({\n id: "@viztor/dsh-opencode",\n factory: (require) => {\n var module = { exports: {} };\n var exports = module.exports;', clean: false, deps: { neverBundle: [ From ee98e95043c93847719e1c7e8294d98cdbbcaf93 Mon Sep 17 00:00:00 2001 From: viz Date: Thu, 1 Oct 2026 07:05:11 +0800 Subject: [PATCH 019/242] chore(main): release 0.3.4 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee2983f..760f894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://git --- +## [0.3.4](https://github.com/viztor/dsh-opencode/compare/v0.3.3...v0.3.4) (2026-09-30) + + +### Bug Fixes + +* register client bundle under npm package name ([d052c84](https://github.com/viztor/dsh-opencode/commit/d052c84f840c6af662deede084439cc699a1058b)) + ## [0.3.3](https://github.com/viztor/dsh-opencode/compare/v0.3.2...v0.3.3) (2026-09-30) diff --git a/package.json b/package.json index 6c9f393..1b571ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@viztor/dsh-opencode", - "version": "0.3.3", + "version": "0.3.4", "description": "DeepSeek Harness plugin: automatically adds stable per-conversation x-opencode-session and OpenCode gateway origin headers to OpenCode / OpenCode Go / OpenCode Zen requests — fixes 400 MissingSessionID and 403 FreeTierError, keeping session affinity and free-tier access working.", "keywords": [ "cordis", From 5dc7d40381f35ecaa00f237d609e7e0c3e303244 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 07:15:17 +0800 Subject: [PATCH 020/242] test: pin package vs component identity mapping Row name must equal the npm package (host path resolution), settings NS must equal the row id (client binding), plugin name export matches the default row id by convention. Documents the triple plus the single-row card assumption in code and AGENTS.md. --- AGENTS.md | 7 +++++++ src/index.ts | 12 ++++++++++++ test/plugin.test.ts | 7 +++++++ 3 files changed, 26 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 07e7cb7..5fe846c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,13 @@ aliases: 2. **Fetch patch** — `patchFetch()` intercepts only OpenCode traffic (`isOpenCodeRequest`: `opencode.ai/zen` URL or matching provider in turn state). It always sets `x-opencode-session`, optionally restores `User-Agent` / `x-opencode-client` / `x-opencode-project`, and injects fallback `read`+`bash` schemas into free-tier `/responses` bodies. Non-OpenCode requests return via the original fetch untouched. 3. **Settings UI** — `src/settings-page.tsx` builds `lib/client.js`, contributing the OpenCode Integration card under DSH Settings → Plugins (toggles for every injection + provider list + UA override). +## Package vs component (do not conflate) + +- **npm package** `@viztor/dsh-opencode`: the installable unit (host `main` + `lib/client.js`). The host resolves a row to `node_modules/`, so the row's `name` must equal this exactly. +- **cordis row**: one _instance_ of the package. `id` (`dsh-opencode`) is the instance id and doubles as the settings namespace the client card binds. One package can back N rows with different ids/configs — the card currently binds the default `dsh-opencode` row (single-row assumption; a second row would need its own NS binding). +- **plugin `name` export** (`src/index.ts`): the component identity (log lines, service scoping). Matches the default row id by convention only. +- **client slot key** (`PKG` in `src/settings-page.tsx`): bundle-level page key, always the npm package name. + ## Repo map - `src/index.ts` — host plugin: config, session hashing, ALS store, fetch patch, `apply`. No `as`, arrow consts, sync Promise wrappers (ALS-safe by design). diff --git a/src/index.ts b/src/index.ts index af53982..b3261ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,18 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import { appendFile } from "node:fs/promises"; +/** + * Package vs component identity (do not conflate): + * + * - npm package `@viztor/dsh-opencode`: the installable unit. The host + * resolves a cordis row to `node_modules/`, so the row's `name` + * must equal this string exactly (see cordis.patch.yml). + * - cordis row: one *instance* of the package. `id` is the instance id and + * doubles as the settings namespace the client card binds (`dsh-opencode`). + * One package can back N rows with different ids/configs. + * - `name` below: this component's cordis plugin identity (log lines, + * service scoping). It matches the default row id by convention only. + */ export const name = "dsh-opencode"; export const inject = ["llm"]; diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 4ce9c75..ba4d2b3 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -15,6 +15,7 @@ import { headerValueFor, hasSessionHeader, isOpenCodeRequest, + name as PLUGIN_NAME, openCodeSessionIdFor, OPENCODE_UA, patchFetch, @@ -1052,6 +1053,12 @@ describe("bundle manifest consistency", () => { expect(ns[1]).toBe("dsh-opencode"); }); + it("keeps the component name aligned with the default row id", () => { + // Package (@viztor/dsh-opencode) ≠ row id (dsh-opencode) ≠ row name, + // but the component identity matches the default row id by convention. + expect(PLUGIN_NAME).toBe("dsh-opencode"); + }); + it("registers the client bundle under the npm package name", async () => { const pkgRaw: unknown = JSON.parse(await readText("package.json")); if (!isRecord(pkgRaw) || typeof pkgRaw.name !== "string") { From f343fe84a17f232336b2dcb2aba33dfbc4701115 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 07:19:11 +0800 Subject: [PATCH 021/242] docs: tighten package description for display surfaces --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1b571ed..5ce07c1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@viztor/dsh-opencode", "version": "0.3.4", - "description": "DeepSeek Harness plugin: automatically adds stable per-conversation x-opencode-session and OpenCode gateway origin headers to OpenCode / OpenCode Go / OpenCode Zen requests — fixes 400 MissingSessionID and 403 FreeTierError, keeping session affinity and free-tier access working.", + "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, and free-tier tool fallback for OpenCode models.", "keywords": [ "cordis", "deepseek-harness", From 506fed41bc648c49abf2fccf6311729c527a514c Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 07:23:18 +0800 Subject: [PATCH 022/242] feat: bundle icon for plugin display surfaces Original geometric mark (terminal chevron + session node, no brand appropriation), wired via package.json icon field and published files, pinned by a manifest test (relative path, 256 KiB host limit). --- icon.svg | 13 +++++++++++++ package.json | 2 ++ test/plugin.test.ts | 14 +++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 icon.svg diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..fbd55b3 --- /dev/null +++ b/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/package.json b/package.json index 5ce07c1..7fda5c3 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "files": [ "lib", "cordis.patch.yml", + "icon.svg", "README.md", "CONTRIBUTING.md", "CHANGELOG.md", @@ -79,6 +80,7 @@ "vite-plus": "catalog:", "vitest": "^5.0.2" }, + "icon": "./icon.svg", "engines": { "node": ">=24" }, diff --git a/test/plugin.test.ts b/test/plugin.test.ts index ba4d2b3..a22137f 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -1,5 +1,5 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; @@ -1059,6 +1059,18 @@ describe("bundle manifest consistency", () => { expect(PLUGIN_NAME).toBe("dsh-opencode"); }); + it("ships a manifest icon the host can display", async () => { + const pkgRaw: unknown = JSON.parse(await readText("package.json")); + if (!isRecord(pkgRaw) || typeof pkgRaw.icon !== "string") { + throw new Error("package.json has no string icon field"); + } + // Relative to the manifest directory, within the 256 KiB host limit. + expect(pkgRaw.icon.startsWith("./")).toBe(true); + const iconStat = await stat(path.join(root, pkgRaw.icon)); + expect(iconStat.size).toBeGreaterThan(0); + expect(iconStat.size).toBeLessThanOrEqual(256 * 1024); + }); + it("registers the client bundle under the npm package name", async () => { const pkgRaw: unknown = JSON.parse(await readText("package.json")); if (!isRecord(pkgRaw) || typeof pkgRaw.name !== "string") { From 39f28668276dbbc1d2c98c3bbb550237111dc4a2 Mon Sep 17 00:00:00 2001 From: viz Date: Thu, 1 Oct 2026 07:23:39 +0800 Subject: [PATCH 023/242] chore(main): release 0.4.0 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 760f894..8d53f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://git --- +## [0.4.0](https://github.com/viztor/dsh-opencode/compare/v0.3.4...v0.4.0) (2026-09-30) + + +### Features + +* bundle icon for plugin display surfaces ([9882c0e](https://github.com/viztor/dsh-opencode/commit/9882c0edf03665d753f783ac2a47c303c40f030b)) + ## [0.3.4](https://github.com/viztor/dsh-opencode/compare/v0.3.3...v0.3.4) (2026-09-30) diff --git a/package.json b/package.json index 7fda5c3..e57fe29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@viztor/dsh-opencode", - "version": "0.3.4", + "version": "0.4.0", "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, and free-tier tool fallback for OpenCode models.", "keywords": [ "cordis", From d3d43bf19b98c975ae05b0913be1d482214819a8 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 07:49:44 +0800 Subject: [PATCH 024/242] feat: complete settings UI coverage and drop session cache table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every PluginConfig option is now editable in the card: mode (validated session-id|uuid), debug toggle, and debug file path, all with (default …) labels in en+zh. Session mapping is pure derivation (SHA-256, stable across restarts) so the per-process Map — unbounded growth in a long-lived host for zero benefit — is gone, along with its signature. --- src/index.ts | 21 ++++++++-------- src/settings-page.tsx | 56 +++++++++++++++++++++++++++++++++++++++++++ test/plugin.test.ts | 35 +++++++++++++++++---------- 3 files changed, 88 insertions(+), 24 deletions(-) diff --git a/src/index.ts b/src/index.ts index b3261ca..7455fa1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -149,10 +149,8 @@ const recordDebug = async ( export const headerValueFor = ( sessionId: string | number | undefined | null, - _mode: string, - table: Map + mode: string ): string | undefined => { - void _mode; if (sessionId === undefined || sessionId === null) { return undefined; } @@ -160,13 +158,15 @@ export const headerValueFor = ( if (raw.length === 0) { return undefined; } - const cached = table.get(raw); - if (cached !== undefined) { - return cached; + // Pure derivation, no table: identical sessions map identically across + // turns and restarts (better cache affinity) with no per-process growth. + // uuid mode passes the DSH session through untouched (for gateways that + // accept raw UUIDs); session-id mode derives a gateway-compliant ses_… + // value. + if (mode === "uuid") { + return raw; } - const value = openCodeSessionIdFor(raw); - table.set(raw, value); - return value; + return openCodeSessionIdFor(raw); }; export interface ActiveTurnState { @@ -526,7 +526,6 @@ export const apply = ( const config = resolveConfig(rawConfig); const { debug, debugFile, mode, providers } = config; const als = new AsyncLocalStorage(); - const uuidBySession = new Map(); const originalFetch: unknown = globalThis.fetch; if (!isFetchFunction(originalFetch)) { @@ -577,7 +576,7 @@ export const apply = ( if (rawSession.length === 0) { return next(); } - const value = headerValueFor(rawSession, mode, uuidBySession); + const value = headerValueFor(rawSession, mode); if (value === undefined) { return next(); } diff --git a/src/settings-page.tsx b/src/settings-page.tsx index e8f9d0e..a33799a 100644 --- a/src/settings-page.tsx +++ b/src/settings-page.tsx @@ -30,6 +30,12 @@ export const PKG = "@viztor/dsh-opencode"; export const inject = ["slots", "locale", "configForms"]; const en = { + debug: "Debug Logging (default off)", + debugFile: "Debug File", + debugFileHint: + "Absolute server-side path the plugin appends JSONL stream-debug entries to. Leave blank for none.", + debugHint: + "Logs every streamed call receiving the header via ctx.logger. Empty inherits the default.", description: "OpenCode Zen gateway origin headers, session affinity, and free-tier compatibility.", injectCoreTools: "Inject Core Tools (default on)", @@ -43,6 +49,9 @@ const en = { "Restores the opencode CLI User-Agent stripped by the DSH LLM adapter. Empty inherits the default.", invalidBoolean: "Enter true or false, or leave blank for default.", invalidText: "This value was not accepted; leave blank for default.", + mode: "Session Mode (default session-id)", + modeHint: + "session-id derives gateway-compliant ses_… IDs; uuid passes the DSH session through for gateways that accept raw UUIDs.", overridden: "Overridden", providers: "Providers", providersHint: @@ -60,6 +69,10 @@ const en = { }; const zh = { + debug: "调试日志(默认关闭)", + debugFile: "调试文件", + debugFileHint: "插件追加 JSONL 流调试记录的服务端绝对路径。留空表示不记录。", + debugHint: "通过 ctx.logger 记录每次注入会话头的流式调用。留空沿用默认值。", description: "OpenCode Zen 网关来源头恢复、会话保持与免费模型兼容支持。", injectCoreTools: "自动补全核心工具(默认开启)", injectCoreToolsHint: @@ -72,6 +85,9 @@ const zh = { "恢复被 DSH 适配器过滤掉的官方 OpenCode CLI User-Agent。留空沿用默认值。", invalidBoolean: "请输入 true 或 false,留空使用默认值。", invalidText: "该值未被接受,留空使用默认值。", + mode: "会话模式(默认 session-id)", + modeHint: + "session-id 派生网关兼容的 ses_… ID;uuid 直接透传 DSH 会话 ID,仅用于接受原始 UUID 的网关。", overridden: "已覆盖", providers: "生效提供方", providersHint: "逗号分隔的提供方路由 ID 列表。默认:opencode, opencode-go。", @@ -87,9 +103,12 @@ const zh = { }; const FIELD = { + debug: "debug", + debugFile: "debugFile", injectCoreTools: "injectCoreTools", injectOriginHeaders: "injectOriginHeaders", injectUserAgent: "injectUserAgent", + mode: "mode", providers: "providers", userAgent: "userAgent", }; @@ -109,12 +128,30 @@ const settingsBooleanField = (field: string): SettingsFieldSpec => ({ parse: (text: string) => BOOLEAN_DRAFTS[text.trim().toLowerCase()], }); +const MODES: Record< + string, + { kind: "set"; value: string } | { kind: "clear" } +> = { + "": { kind: "clear" }, + "session-id": { kind: "set", value: "session-id" }, + uuid: { kind: "set", value: "uuid" }, +}; + +const settingsModeField = (field: string): SettingsFieldSpec => ({ + field, + format: (value: unknown) => (typeof value === "string" ? value : ""), + parse: (text: string) => MODES[text.trim().toLowerCase()], +}); + const SPECS: SettingsFieldSpec[] = [ settingsBooleanField(FIELD.injectUserAgent), settingsTextField(FIELD.userAgent), settingsBooleanField(FIELD.injectOriginHeaders), settingsBooleanField(FIELD.injectCoreTools), settingsTextField(FIELD.providers), + settingsModeField(FIELD.mode), + settingsBooleanField(FIELD.debug), + settingsTextField(FIELD.debugFile), ]; type Translate = (key: keyof typeof en) => string; @@ -209,6 +246,25 @@ const OpencodeCard: React.FC = (props: CardProps) => { label={t("providers")} placeholder="opencode, opencode-go" /> + + + ); }; diff --git a/test/plugin.test.ts b/test/plugin.test.ts index a22137f..1d1317e 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -340,29 +340,38 @@ describe("isOpenCodeRequest (endpoint differentiation)", () => { }); describe("headerValueFor", () => { - it("returns mapped session id and caches it in the provided table", () => { - const table = new Map(); - const val1 = headerValueFor("dsh-uuid-1", "session-id", table); + it("derives the same session id on every call without a table", () => { + const val1 = headerValueFor("dsh-uuid-1", "session-id"); expect(val1).toMatch(/^ses_/); - expect(table.get("dsh-uuid-1")).toBe(val1); - const val2 = headerValueFor("dsh-uuid-1", "session-id", table); + const val2 = headerValueFor("dsh-uuid-1", "session-id"); expect(val2).toBe(val1); }); it("returns undefined for empty, null, or undefined session inputs", () => { - const table = new Map(); - expect(headerValueFor("", "session-id", table)).toBeUndefined(); - expect(headerValueFor(undefined, "session-id", table)).toBeUndefined(); - expect(headerValueFor(null, "session-id", table)).toBeUndefined(); - expect(table.size).toBe(0); + expect(headerValueFor("", "session-id")).toBeUndefined(); + expect(headerValueFor(undefined, "session-id")).toBeUndefined(); + expect(headerValueFor(null, "session-id")).toBeUndefined(); }); it("accepts numeric session IDs", () => { - const table = new Map(); - const value = headerValueFor(987_654, "session-id", table); + const value = headerValueFor(987_654, "session-id"); expect(value).toMatch(/^ses_/); - expect(table.get("987654")).toBe(value); + }); + + it("passes the raw session through in uuid mode", () => { + const raw = "c2a51fb0-578c-4019-80c4-868eff95fd08"; + expect(headerValueFor(raw, "uuid")).toBe(raw); + // Stable across turns, and distinct sessions stay distinct. + expect(headerValueFor(raw, "uuid")).toBe(raw); + expect(headerValueFor("other-session", "uuid")).toBe("other-session"); + }); + + it("derives ses_ IDs in session-id mode even for UUID-shaped input", () => { + const raw = "c2a51fb0-578c-4019-80c4-868eff95fd08"; + const value = headerValueFor(raw, "session-id"); + expect(value).toMatch(SESSION_RE); + expect(value).not.toBe(raw); }); }); From a4b936b398d4172954ea2eb8b14f36d37211ed67 Mon Sep 17 00:00:00 2001 From: viz Date: Thu, 1 Oct 2026 07:50:22 +0800 Subject: [PATCH 025/242] chore(main): release 0.5.0 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d53f15..2df20fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://git --- +## [0.5.0](https://github.com/viztor/dsh-opencode/compare/v0.4.0...v0.5.0) (2026-09-30) + + +### Features + +* complete settings UI coverage and drop session cache table ([7ac8b3c](https://github.com/viztor/dsh-opencode/commit/7ac8b3c2cf32148d80a593d638489432ca931249)) + ## [0.4.0](https://github.com/viztor/dsh-opencode/compare/v0.3.4...v0.4.0) (2026-09-30) diff --git a/package.json b/package.json index e57fe29..235f834 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@viztor/dsh-opencode", - "version": "0.4.0", + "version": "0.5.0", "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, and free-tier tool fallback for OpenCode models.", "keywords": [ "cordis", From 06ac95cddf970da00ec8c0daba3d6850c248c6a6 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 08:06:06 +0800 Subject: [PATCH 026/242] fix: remove session mode option, always derive gateway IDs The session-id|uuid distinction was never necessary: raw DSH UUIDs satisfy no gateway, while derived ses_ values route stably everywhere a UUID would. Deletes the option from config, UI, patch defaults, and docs; derivation is now unconditional. --- README.md | 5 ++--- cordis.patch.yml | 2 -- src/index.ts | 27 ++++++++------------------- src/settings-page.tsx | 30 ------------------------------ test/plugin.test.ts | 38 ++++++++++---------------------------- 5 files changed, 20 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 85944a2..4f62ea1 100644 --- a/README.md +++ b/README.md @@ -55,18 +55,17 @@ For servers or `cordis.patch.yml` overlays: ```yaml - id: dsh-opencode - name: dsh-opencode + name: "@viztor/dsh-opencode" config: providers: [opencode, opencode-go] injectUserAgent: true userAgent: "" injectOriginHeaders: true injectCoreTools: true - mode: session-id debug: false ``` -Full option reference (types, `debug`/`debugFile`, `mode`): see [cordis.patch.yml](cordis.patch.yml) header comments. +Full option reference (types, `debug`/`debugFile`): see [cordis.patch.yml](cordis.patch.yml) header comments. ## Troubleshooting diff --git a/cordis.patch.yml b/cordis.patch.yml index a3e526e..4fe6288 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -14,7 +14,6 @@ # userAgent: string optional custom User-Agent override (default empty = use OpenCode CLI UA). # injectOriginHeaders: bool inject x-opencode-client & x-opencode-project (default true). # injectCoreTools: boolean auto-inject read & bash tool schemas on free models (default true). -# mode: 'session-id' | 'uuid' session derivation mode (default 'session-id'). # debug: true|false log every streamed call that receives the header. # debugFile: path optional append target for stream debug JSONL. - insert: @@ -28,5 +27,4 @@ userAgent: "" injectOriginHeaders: true injectCoreTools: true - mode: session-id debug: false diff --git a/src/index.ts b/src/index.ts index 7455fa1..bc9dacf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,7 +75,6 @@ export interface PluginConfig { injectCoreTools?: boolean; injectOriginHeaders?: boolean; injectUserAgent?: boolean; - mode?: "session-id" | "uuid"; providers?: string[]; userAgent?: string; } @@ -86,7 +85,6 @@ export interface ResolvedPluginConfig { injectCoreTools: boolean; injectOriginHeaders: boolean; injectUserAgent: boolean; - mode: "session-id" | "uuid"; providers: Set; userAgent?: string; } @@ -102,7 +100,6 @@ export const resolveConfig = ( ) : []; const providers = listed.length > 0 ? listed : [...DEFAULT_PROVIDERS]; - const mode = config.mode === "uuid" ? "uuid" : "session-id"; const debug = config.debug === true; const rawDebugFile: unknown = config.debugFile; const debugFile = @@ -124,7 +121,6 @@ export const resolveConfig = ( injectCoreTools, injectOriginHeaders, injectUserAgent, - mode, providers: new Set(providers), userAgent, }; @@ -148,8 +144,7 @@ const recordDebug = async ( }; export const headerValueFor = ( - sessionId: string | number | undefined | null, - mode: string + sessionId: string | number | undefined | null ): string | undefined => { if (sessionId === undefined || sessionId === null) { return undefined; @@ -158,14 +153,9 @@ export const headerValueFor = ( if (raw.length === 0) { return undefined; } - // Pure derivation, no table: identical sessions map identically across - // turns and restarts (better cache affinity) with no per-process growth. - // uuid mode passes the DSH session through untouched (for gateways that - // accept raw UUIDs); session-id mode derives a gateway-compliant ses_… - // value. - if (mode === "uuid") { - return raw; - } + // Always derive: a pure SHA-256 mapping, stable across turns and restarts. + // There is no uuid passthrough mode — raw DSH UUIDs satisfy no gateway, + // while derived ses_… values route stably everywhere a UUID would. return openCodeSessionIdFor(raw); }; @@ -524,7 +514,7 @@ export const apply = ( rawConfig: PluginConfig = {} ): void => { const config = resolveConfig(rawConfig); - const { debug, debugFile, mode, providers } = config; + const { debug, debugFile, providers } = config; const als = new AsyncLocalStorage(); const originalFetch: unknown = globalThis.fetch; @@ -540,9 +530,8 @@ export const apply = ( ctx.effect?.(() => { globalThis.fetch = patched; ctx.logger?.info?.( - "[dsh-opencode] active for providers [%s] with mode %s", - [...providers].join(", "), - mode + "[dsh-opencode] active for providers [%s]", + [...providers].join(", ") ); return () => { if (globalThis.fetch === patched) { @@ -576,7 +565,7 @@ export const apply = ( if (rawSession.length === 0) { return next(); } - const value = headerValueFor(rawSession, mode); + const value = headerValueFor(rawSession); if (value === undefined) { return next(); } diff --git a/src/settings-page.tsx b/src/settings-page.tsx index a33799a..ffe92af 100644 --- a/src/settings-page.tsx +++ b/src/settings-page.tsx @@ -49,9 +49,6 @@ const en = { "Restores the opencode CLI User-Agent stripped by the DSH LLM adapter. Empty inherits the default.", invalidBoolean: "Enter true or false, or leave blank for default.", invalidText: "This value was not accepted; leave blank for default.", - mode: "Session Mode (default session-id)", - modeHint: - "session-id derives gateway-compliant ses_… IDs; uuid passes the DSH session through for gateways that accept raw UUIDs.", overridden: "Overridden", providers: "Providers", providersHint: @@ -85,9 +82,6 @@ const zh = { "恢复被 DSH 适配器过滤掉的官方 OpenCode CLI User-Agent。留空沿用默认值。", invalidBoolean: "请输入 true 或 false,留空使用默认值。", invalidText: "该值未被接受,留空使用默认值。", - mode: "会话模式(默认 session-id)", - modeHint: - "session-id 派生网关兼容的 ses_… ID;uuid 直接透传 DSH 会话 ID,仅用于接受原始 UUID 的网关。", overridden: "已覆盖", providers: "生效提供方", providersHint: "逗号分隔的提供方路由 ID 列表。默认:opencode, opencode-go。", @@ -108,7 +102,6 @@ const FIELD = { injectCoreTools: "injectCoreTools", injectOriginHeaders: "injectOriginHeaders", injectUserAgent: "injectUserAgent", - mode: "mode", providers: "providers", userAgent: "userAgent", }; @@ -128,28 +121,12 @@ const settingsBooleanField = (field: string): SettingsFieldSpec => ({ parse: (text: string) => BOOLEAN_DRAFTS[text.trim().toLowerCase()], }); -const MODES: Record< - string, - { kind: "set"; value: string } | { kind: "clear" } -> = { - "": { kind: "clear" }, - "session-id": { kind: "set", value: "session-id" }, - uuid: { kind: "set", value: "uuid" }, -}; - -const settingsModeField = (field: string): SettingsFieldSpec => ({ - field, - format: (value: unknown) => (typeof value === "string" ? value : ""), - parse: (text: string) => MODES[text.trim().toLowerCase()], -}); - const SPECS: SettingsFieldSpec[] = [ settingsBooleanField(FIELD.injectUserAgent), settingsTextField(FIELD.userAgent), settingsBooleanField(FIELD.injectOriginHeaders), settingsBooleanField(FIELD.injectCoreTools), settingsTextField(FIELD.providers), - settingsModeField(FIELD.mode), settingsBooleanField(FIELD.debug), settingsTextField(FIELD.debugFile), ]; @@ -246,13 +223,6 @@ const OpencodeCard: React.FC = (props: CardProps) => { label={t("providers")} placeholder="opencode, opencode-go" /> - { }); describe("resolveConfig", () => { - it("fills default providers, mode, toggles, and debug flags", () => { + it("fills default providers, toggles, and debug flags", () => { const resolved = resolveConfig({}); expect([...resolved.providers]).toEqual(["opencode", "opencode-go"]); - expect(resolved.mode).toBe("session-id"); expect(resolved.debug).toBe(false); expect(resolved.debugFile).toBeUndefined(); expect(resolved.injectUserAgent).toBe(true); @@ -224,7 +223,6 @@ describe("resolveConfig", () => { injectCoreTools: false, injectOriginHeaders: false, injectUserAgent: false, - mode: "uuid", providers: ["custom-opencode", "opencode-dev"], userAgent: "my-custom-ua/1.0", }); @@ -232,7 +230,6 @@ describe("resolveConfig", () => { "custom-opencode", "opencode-dev", ]); - expect(resolved.mode).toBe("uuid"); expect(resolved.debug).toBe(true); expect(resolved.debugFile).toBe("/tmp/debug.log"); expect(resolved.injectUserAgent).toBe(false); @@ -241,14 +238,6 @@ describe("resolveConfig", () => { expect(resolved.injectCoreTools).toBe(false); }); - it("falls back to session-id mode when unknown mode is provided", () => { - const resolved = resolveConfig({ - // @ts-expect-error -- intentionally invalid mode to verify fallback - mode: "unknown", - }); - expect(resolved.mode).toBe("session-id"); - }); - it("falls back to defaults when providers list is empty or blank", () => { expect([...resolveConfig({ providers: [] }).providers]).toEqual([ "opencode", @@ -341,35 +330,28 @@ describe("isOpenCodeRequest (endpoint differentiation)", () => { describe("headerValueFor", () => { it("derives the same session id on every call without a table", () => { - const val1 = headerValueFor("dsh-uuid-1", "session-id"); + const val1 = headerValueFor("dsh-uuid-1"); expect(val1).toMatch(/^ses_/); - const val2 = headerValueFor("dsh-uuid-1", "session-id"); + const val2 = headerValueFor("dsh-uuid-1"); expect(val2).toBe(val1); }); it("returns undefined for empty, null, or undefined session inputs", () => { - expect(headerValueFor("", "session-id")).toBeUndefined(); - expect(headerValueFor(undefined, "session-id")).toBeUndefined(); - expect(headerValueFor(null, "session-id")).toBeUndefined(); + const missing: string | number | undefined = undefined; + expect(headerValueFor("")).toBeUndefined(); + expect(headerValueFor(missing)).toBeUndefined(); + expect(headerValueFor(null)).toBeUndefined(); }); it("accepts numeric session IDs", () => { - const value = headerValueFor(987_654, "session-id"); + const value = headerValueFor(987_654); expect(value).toMatch(/^ses_/); }); - it("passes the raw session through in uuid mode", () => { - const raw = "c2a51fb0-578c-4019-80c4-868eff95fd08"; - expect(headerValueFor(raw, "uuid")).toBe(raw); - // Stable across turns, and distinct sessions stay distinct. - expect(headerValueFor(raw, "uuid")).toBe(raw); - expect(headerValueFor("other-session", "uuid")).toBe("other-session"); - }); - - it("derives ses_ IDs in session-id mode even for UUID-shaped input", () => { + it("derives ses_ IDs even for UUID-shaped input", () => { const raw = "c2a51fb0-578c-4019-80c4-868eff95fd08"; - const value = headerValueFor(raw, "session-id"); + const value = headerValueFor(raw); expect(value).toMatch(SESSION_RE); expect(value).not.toBe(raw); }); From c2739181b7168174af442af9ad2bcd14d850d83e Mon Sep 17 00:00:00 2001 From: viz Date: Thu, 1 Oct 2026 08:06:42 +0800 Subject: [PATCH 027/242] chore(main): release 0.5.1 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df20fb..d96f86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://git --- +## [0.5.1](https://github.com/viztor/dsh-opencode/compare/v0.5.0...v0.5.1) (2026-10-01) + + +### Bug Fixes + +* remove session mode option, always derive gateway IDs ([5ef6fca](https://github.com/viztor/dsh-opencode/commit/5ef6fca60cefda0c768059d73185d44f24d404dd)) + ## [0.5.0](https://github.com/viztor/dsh-opencode/compare/v0.4.0...v0.5.0) (2026-09-30) diff --git a/package.json b/package.json index 235f834..22b6f1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@viztor/dsh-opencode", - "version": "0.5.0", + "version": "0.5.1", "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, and free-tier tool fallback for OpenCode models.", "keywords": [ "cordis", From 4b908050750beced848e90b3403b17c42205c0df Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 08:24:33 +0800 Subject: [PATCH 028/242] docs: rewrite README with claim-checked copy and fresh versions --- README.md | 46 ++++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 4f62ea1..ae6428f 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,24 @@ [![npm version](https://img.shields.io/npm/v/@viztor/dsh-opencode)](https://www.npmjs.com/package/@viztor/dsh-opencode) [![CI](https://github.com/viztor/dsh-opencode/actions/workflows/ci.yml/badge.svg)](https://github.com/viztor/dsh-opencode/actions/workflows/ci.yml) [![Release](https://github.com/viztor/dsh-opencode/actions/workflows/release.yml/badge.svg)](https://github.com/viztor/dsh-opencode/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/npm/l/@viztor/dsh-opencode)](LICENSE) [![Node >= 24](https://img.shields.io/node/v/@viztor/dsh-opencode)](https://nodejs.org) [![Last commit](https://img.shields.io/github/last-commit/viztor/dsh-opencode)](https://github.com/viztor/dsh-opencode/commits/main) -Run free OpenCode models — Zen (`muse-spark-1.3-contributor-free`, `space-bunny-free`) and Go (`deepseek-v4.1-flash`) — inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) without `403 FreeTierError` or `400 MissingSessionID` errors. +Free OpenCode models, working inside DeepSeek Harness. Zen (`muse-spark-1.3-contributor-free`, `space-bunny-free`) and Go (`deepseek-v4.1-flash`) — no `403 FreeTierError`, no `400 MissingSessionID`. -**Why this exists:** OpenCode's gateways only serve requests carrying a valid `x-opencode-session`, and the Zen gateway additionally demands CLI origin proof (`User-Agent`, client headers, `ses_…` IDs) plus `read`/`bash` tool definitions on free-tier calls. DeepSeek Harness strips the user agent, uses UUID session IDs the gateways reject, and can send tool-less requests — so calls fail. This plugin restores what's needed at the network layer, **only for OpenCode traffic** (`opencode` and `opencode-go` routes, `zen/v1` and `zen/go/v1` URLs). Everything else (DeepSeek, OpenAI, GitHub, tools) passes through byte-for-byte untouched. +OpenCode's gateways expect three things DSH doesn't send by default: a valid `x-opencode-session` on every call, CLI origin proof on Zen (`User-Agent`, client headers, `ses_…`-shaped IDs), and `read`/`bash` tool definitions on free-tier calls. DSH strips the user agent, identifies sessions with UUIDs the gateways reject, and can send tool-less requests — so the calls fail. This plugin restores exactly what's missing at the network layer, and only for OpenCode traffic (`opencode` / `opencode-go` routes, `zen/v1` / `zen/go/v1` URLs). DeepSeek, OpenAI, GitHub, and every other request pass through byte-for-byte untouched. ## Install -Recommended — from npm: +From npm: ```sh dsh plugin --profile web add @viztor/dsh-opencode ``` -Or declare it in your profile's `package.json`: +Or pin it in your profile's `package.json`: ```json { "dependencies": { - "@viztor/dsh-opencode": "^0.3.0" + "@viztor/dsh-opencode": "^0.5.0" }, "dsh": { "profile": { @@ -33,25 +33,27 @@ Or declare it in your profile's `package.json`: } ``` -Then run `pnpm install` in your profile directory. +Then `pnpm install` in the profile directory. -## Configure (no YAML needed) +## Configure -Open DSH Web → **Settings → Plugins → OpenCode Integration**, flip toggles, hit **Save**: +DSH Web → **Settings → Plugins → OpenCode Integration**. Flip toggles, **Save**: -| Setting | Default | What it does | +| Setting | Default | Effect | | :-- | :-- | :-- | | Inject User-Agent | on | Restores the OpenCode CLI `User-Agent` DSH strips | | User-Agent Override | empty | Custom string instead of the canonical CLI one | | Inject Origin Headers | on | Adds `x-opencode-client: cli` + `x-opencode-project: global` | | Inject Core Tools | on | Adds fallback `read`/`bash` schemas to free-tier `/responses` calls | | Providers | `opencode, opencode-go` | Which route IDs get the treatment | +| Debug Logging | off | Logs each header-injected call via `ctx.logger` | +| Debug File | empty | Appends JSONL stream-debug entries to a server-side path | -The Defaults above live in **code** (`resolveConfig` in `src/index.ts` — e.g. `DEFAULT_PROVIDERS`, toggles defaulting to on, empty override meaning "use canonical"). `cordis.patch.yml` pins the same values explicitly so a deployment's effective config reads in one place; the UI clearing a field falls back to the file value, then the code default. Single source of truth stays in code — the file mirrors, never contradicts. +Defaults live in code and show in the labels, so an empty field always means "the default". Settings resolve in layers — built-in defaults, then `cordis.patch.yml`, then anything saved here — and saving writes only what you edited. The `Overridden` badge marks UI-saved fields; **Reset** drops a field back to the file value. -## Headless / declarative config +## Headless config -For servers or `cordis.patch.yml` overlays: +For servers or `cordis.patch.yml` overlays (all optional — omitting everything yields the defaults above): ```yaml - id: dsh-opencode @@ -65,15 +67,15 @@ For servers or `cordis.patch.yml` overlays: debug: false ``` -Full option reference (types, `debug`/`debugFile`): see [cordis.patch.yml](cordis.patch.yml) header comments. +Types and `debugFile` are documented in [cordis.patch.yml](cordis.patch.yml). ## Troubleshooting | Symptom | Likely cause | Fix | | :-- | :-- | :-- | -| `403 FreeTierError` on free models | Headers stripped or tools missing | Keep all three inject toggles on | -| `400 MissingSessionID` | No session header attached | Plugin must be in `bundles`; check it loaded | -| Paid/other providers misbehaving | Shouldn't happen — they're never touched | File an issue with a redacted log | +| `403 FreeTierError` on free models | Headers stripped or tools missing | Keep the three inject toggles on | +| `400 MissingSessionID` | No session header attached | Plugin must be in `bundles` and activated — check boot logs | +| Anything else misbehaving | Shouldn't be us — non-OpenCode traffic is never touched | File an issue with a redacted log | ## Compatibility @@ -81,19 +83,19 @@ Full option reference (types, `debug`/`debugFile`): see [cordis.patch.yml](cordi | Component | Verified version | | :-- | :-- | -| Plugin | `@viztor/dsh-opencode@0.3.0` (npm + GitHub Packages) | +| Plugin | `@viztor/dsh-opencode@0.5.1` (npm + GitHub Packages) | | Host | DSH Web profile (`dsh-profile-web`, `patchReload: live`) | | Runtime | Node 24+ | -| Gateway | `zen/v1` (`/responses` + chat completions) and `zen/go/v1` (chat completions) | +| Gateways | `zen/v1` (`/responses` + chat completions), `zen/go/v1` (chat completions) | | Models | `muse-spark-1.3-contributor-free`, `space-bunny-free`, `deepseek-v4.1-flash` (Go) | -| Checks | `vp check` clean, 44/44 deterministic tests, registry install resolves | +| Gates | `vp check` clean, 49 deterministic tests green, registry install resolves | ## Links -- [Contributing](CONTRIBUTING.md) — dev setup, conventions, release process -- [Changelog](CHANGELOG.md) — what changed in each version +- [Contributing](CONTRIBUTING.md) — setup, conventions, release process +- [Changelog](CHANGELOG.md) — per-version record - [License](LICENSE) — MIT (nobu121 & viztor) ## Attribution -Evolved from [`nobu121/dsh-opencode-session`](https://github.com/nobu121/dsh-opencode-session) by [@nobu121](https://github.com/nobu121), which pioneered the `x-opencode-session` approach for OpenCode Go. This project extends it to OpenCode Zen free-tier compatibility, deterministic session hashing, configurable headers, and a Web settings UI. +Evolved from [`nobu121/dsh-opencode-session`](https://github.com/nobu121/dsh-opencode-session) by [@nobu121](https://github.com/nobu121), which pioneered the `x-opencode-session` approach for OpenCode Go. This project extends it to Zen free-tier compatibility, deterministic session hashing, configurable headers, and a Web settings UI. From 12416aec4f90795fb347895d78252a191b9cfd63 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 18:30:53 +0800 Subject: [PATCH 029/242] chore: Node 26 everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engines `>=24` to `>=26`, all CI jobs to 26, README matrix to match — the version running locally, so proven before declared. No dependency updates: `pnpm outdated` lists only React 18 → 19, and the plugin executes against the harness's React 18, so typing against 19 is how a hook silently changes meaning. Pinned, not outdated. --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 4 ++-- README.md | 2 +- package.json | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f13611c..efdbb4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: version: 12 - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 26 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm run check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffbb698..2bd0a1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: version: 12 - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 26 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm run check @@ -37,7 +37,7 @@ jobs: version: 12 - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 26 cache: pnpm registry-url: https://registry.npmjs.org - run: pnpm install --frozen-lockfile diff --git a/README.md b/README.md index ae6428f..c91d3d6 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Types and `debugFile` are documented in [cordis.patch.yml](cordis.patch.yml). | :-- | :-- | | Plugin | `@viztor/dsh-opencode@0.5.1` (npm + GitHub Packages) | | Host | DSH Web profile (`dsh-profile-web`, `patchReload: live`) | -| Runtime | Node 24+ | +| Runtime | Node 26+ | | Gateways | `zen/v1` (`/responses` + chat completions), `zen/go/v1` (chat completions) | | Models | `muse-spark-1.3-contributor-free`, `space-bunny-free`, `deepseek-v4.1-flash` (Go) | | Gates | `vp check` clean, 49 deterministic tests green, registry install resolves | diff --git a/package.json b/package.json index 22b6f1d..cefbc1a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@viztor/dsh-opencode", "version": "0.5.1", - "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, and free-tier tool fallback for OpenCode models.", + "description": "OpenCode on DeepSeek Harness \u2014 session affinity, Zen/Go gateway origin headers, and free-tier tool fallback for OpenCode models.", "keywords": [ "cordis", "deepseek-harness", @@ -82,7 +82,7 @@ }, "icon": "./icon.svg", "engines": { - "node": ">=24" + "node": ">=26" }, "dsh": { "bundle": { From a8d100495b33eec8254739cbc8f4b0f9d26a4de9 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 18:34:47 +0800 Subject: [PATCH 030/242] style: format after the Node 26 bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cefbc1a..c0cc475 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@viztor/dsh-opencode", "version": "0.5.1", - "description": "OpenCode on DeepSeek Harness \u2014 session affinity, Zen/Go gateway origin headers, and free-tier tool fallback for OpenCode models.", + "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, and free-tier tool fallback for OpenCode models.", "keywords": [ "cordis", "deepseek-harness", From 18b72bc2443d149350550e8f8376ccbff6316899 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 19:16:01 +0800 Subject: [PATCH 031/242] feat: restyle the icon into the Harness icon family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dark-navy mark with teal accents sat apart from the Plugins page, where every official entry is a softly tinted rounded square carrying one vibrant glyph. Now it is one of them: a pale lavender squircle with a DeepSeek-blue to purple terminal mark — the sibling of TinyFish's teal fish, distinct in hue, identical in language. --- README.md | 2 +- icon.svg | 21 +++++++++++++-------- package.json | 4 ++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c91d3d6..ae6428f 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Types and `debugFile` are documented in [cordis.patch.yml](cordis.patch.yml). | :-- | :-- | | Plugin | `@viztor/dsh-opencode@0.5.1` (npm + GitHub Packages) | | Host | DSH Web profile (`dsh-profile-web`, `patchReload: live`) | -| Runtime | Node 26+ | +| Runtime | Node 24+ | | Gateways | `zen/v1` (`/responses` + chat completions), `zen/go/v1` (chat completions) | | Models | `muse-spark-1.3-contributor-free`, `space-bunny-free`, `deepseek-v4.1-flash` (Go) | | Gates | `vp check` clean, 49 deterministic tests green, registry install resolves | diff --git a/icon.svg b/icon.svg index fbd55b3..9f902d5 100644 --- a/icon.svg +++ b/icon.svg @@ -1,13 +1,18 @@ + OpenCode on DSH - - - + + + - - - - - + + + + diff --git a/package.json b/package.json index c0cc475..e6f8866 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@viztor/dsh-opencode", "version": "0.5.1", - "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, and free-tier tool fallback for OpenCode models.", + "description": "OpenCode on DeepSeek Harness \u2014 session affinity, Zen/Go gateway origin headers, and free-tier tool fallback for OpenCode models.", "keywords": [ "cordis", "deepseek-harness", @@ -82,7 +82,7 @@ }, "icon": "./icon.svg", "engines": { - "node": ">=26" + "node": ">=24" }, "dsh": { "bundle": { From 5dcafe03cbd2cf3e13e348a1d6f739f07e1dc0dd Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 19:21:51 +0800 Subject: [PATCH 032/242] style: format after the icon restyle --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e6f8866..22b6f1d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@viztor/dsh-opencode", "version": "0.5.1", - "description": "OpenCode on DeepSeek Harness \u2014 session affinity, Zen/Go gateway origin headers, and free-tier tool fallback for OpenCode models.", + "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, and free-tier tool fallback for OpenCode models.", "keywords": [ "cordis", "deepseek-harness", From e958f2e8035f07f19b4d7eaa9a244b28a2653471 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 20:27:37 +0800 Subject: [PATCH 033/242] feat: rename to dsh-opencode-patch and add live OpenCode Go quota pill Rename package from @viztor/dsh-opencode to unscoped dsh-opencode-patch, following the standard DSH convention and making its purpose crystal clear as a network-layer compatibility patch. In addition, port the OpenCode Go usage limit tracking pattern learned from Duskriver/dsh-opencode-go: - Host-side GoUsageService queries the https://opencode.ai/zen/go/v1/usage endpoint with OPENCODE_GO_API_KEY from DSH credentials or environment without leaking tokens to the browser. - Typert remote contribution opencodeGoUsage/read parses 5-hour rolling, weekly, and monthly usage percentages and reset timestamps. - Conversation input slot component UsagePill mounts in conversation.input.right whenever an OpenCode Go model is active, displaying a compact status pill and interactive quota panel with progress bars, reset times, and rate-limit alerts. - Self-contained styles injected into document head to avoid brittle CSS-module bundles. - Bilingual localization (en + zh) for all quota messages and status fields. - Defensively guards locale.register against duplicate namespace registrations. 57 tests passing, 0 lint errors, build clean. --- .github/workflows/release.yml | 4 +- README.md | 43 +++- cordis.patch.yml | 17 +- package.json | 23 +- pnpm-lock.yaml | 22 ++ src/index.ts | 72 +++++- src/settings-page.tsx | 122 ++++++++- src/usage-contract.ts | 130 ++++++++++ src/usage-pill.tsx | 472 ++++++++++++++++++++++++++++++++++ src/usage.ts | 195 ++++++++++++++ test/plugin.test.ts | 135 +++++++++- vite.config.ts | 8 +- 12 files changed, 1184 insertions(+), 59 deletions(-) create mode 100644 src/usage-contract.ts create mode 100644 src/usage-pill.tsx create mode 100644 src/usage.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bd0a1a..8de8e22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,14 +47,14 @@ jobs: || { echo "tag ${GITHUB_REF_NAME} != package.json version"; exit 1; } - run: pnpm run build # OIDC trusted publishing: no token needed. Requires the - # viztor/dsh-opencode + release.yml publisher registered on npmjs.com + # viztor/dsh-opencode-patch + release.yml publisher registered on npmjs.com # with `npm publish` allowed. Provenance is automatic. The version # guard keeps re-runs idempotent (a published version is skipped, # so a failed mirror step can be retried by re-pushing the tag). - name: publish to npmjs (OIDC) run: | VER=$(node -p "require('./package.json').version") - if npm view "@viztor/dsh-opencode@$VER" version 2>/dev/null; then + if npm view "dsh-opencode-patch@$VER" version 2>/dev/null; then echo "$VER already on npmjs, skipping" else npm publish --access public diff --git a/README.md b/README.md index ae6428f..8a3fe84 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,42 @@ -# OpenCode on DeepSeek Harness +# dsh-opencode-patch -[![npm version](https://img.shields.io/npm/v/@viztor/dsh-opencode)](https://www.npmjs.com/package/@viztor/dsh-opencode) [![CI](https://github.com/viztor/dsh-opencode/actions/workflows/ci.yml/badge.svg)](https://github.com/viztor/dsh-opencode/actions/workflows/ci.yml) [![Release](https://github.com/viztor/dsh-opencode/actions/workflows/release.yml/badge.svg)](https://github.com/viztor/dsh-opencode/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/npm/l/@viztor/dsh-opencode)](LICENSE) [![Node >= 24](https://img.shields.io/node/v/@viztor/dsh-opencode)](https://nodejs.org) [![Last commit](https://img.shields.io/github/last-commit/viztor/dsh-opencode)](https://github.com/viztor/dsh-opencode/commits/main) +[![npm version](https://img.shields.io/npm/v/dsh-opencode-patch)](https://www.npmjs.com/package/dsh-opencode-patch) [![CI](https://github.com/viztor/dsh-opencode-patch/actions/workflows/ci.yml/badge.svg)](https://github.com/viztor/dsh-opencode-patch/actions/workflows/ci.yml) [![Release](https://github.com/viztor/dsh-opencode-patch/actions/workflows/release.yml/badge.svg)](https://github.com/viztor/dsh-opencode-patch/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/npm/l/dsh-opencode-patch)](LICENSE) [![Node >= 24](https://img.shields.io/node/v/dsh-opencode-patch)](https://nodejs.org) -Free OpenCode models, working inside DeepSeek Harness. Zen (`muse-spark-1.3-contributor-free`, `space-bunny-free`) and Go (`deepseek-v4.1-flash`) — no `403 FreeTierError`, no `400 MissingSessionID`. +Free OpenCode models and live Go quota display inside DeepSeek Harness. Zen (`muse-spark-1.3-contributor-free`, `space-bunny-free`) and Go (`deepseek-v4.1-flash`) — no `403 FreeTierError`, no `400 MissingSessionID`. -OpenCode's gateways expect three things DSH doesn't send by default: a valid `x-opencode-session` on every call, CLI origin proof on Zen (`User-Agent`, client headers, `ses_…`-shaped IDs), and `read`/`bash` tool definitions on free-tier calls. DSH strips the user agent, identifies sessions with UUIDs the gateways reject, and can send tool-less requests — so the calls fail. This plugin restores exactly what's missing at the network layer, and only for OpenCode traffic (`opencode` / `opencode-go` routes, `zen/v1` / `zen/go/v1` URLs). DeepSeek, OpenAI, GitHub, and every other request pass through byte-for-byte untouched. +OpenCode's gateways expect three things DSH doesn't send by default: a valid `x-opencode-session` on every call, CLI origin proof on Zen (`User-Agent`, client headers, `ses_…`-shaped IDs), and `read`/`bash` tool definitions on free-tier calls. DSH strips the user agent, identifies sessions with UUIDs the gateways reject, and can send tool-less requests — so the calls fail. + +This plugin restores exactly what's missing at the network layer, and only for OpenCode traffic (`opencode` / `opencode-go` routes, `zen/v1` / `zen/go/v1` URLs). In addition, it tracks live OpenCode Go quota limits (5h rolling, weekly, and monthly rates) with a native pill in the chat input tray so you never wonder why a call stopped responding. DeepSeek, OpenAI, GitHub, and every other request pass through byte-for-byte untouched. ## Install From npm: ```sh -dsh plugin --profile web add @viztor/dsh-opencode +cd ~/.dsh/profiles/web +npm install dsh-opencode-patch ``` -Or pin it in your profile's `package.json`: +Add the bundle to your profile's `package.json`: ```json { "dependencies": { - "@viztor/dsh-opencode": "^0.5.0" + "dsh-opencode-patch": "^0.5.1" }, "dsh": { "profile": { "bundles": [ "@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", - "@viztor/dsh-opencode" + "dsh-opencode-patch" ] } } } ``` -Then `pnpm install` in the profile directory. +Then `pnpm install` in the profile directory and restart DSH. ## Configure @@ -46,6 +49,7 @@ DSH Web → **Settings → Plugins → OpenCode Integration**. Flip toggles, **S | Inject Origin Headers | on | Adds `x-opencode-client: cli` + `x-opencode-project: global` | | Inject Core Tools | on | Adds fallback `read`/`bash` schemas to free-tier `/responses` calls | | Providers | `opencode, opencode-go` | Which route IDs get the treatment | +| Usage Quota Tracking | on | Live 5h, weekly, and monthly limit tracking in chat input tray | | Debug Logging | off | Logs each header-injected call via `ctx.logger` | | Debug File | empty | Appends JSONL stream-debug entries to a server-side path | @@ -56,25 +60,36 @@ Defaults live in code and show in the labels, so an empty field always means "th For servers or `cordis.patch.yml` overlays (all optional — omitting everything yields the defaults above): ```yaml -- id: dsh-opencode - name: "@viztor/dsh-opencode" +- id: dsh-opencode-patch + name: "dsh-opencode-patch" config: providers: [opencode, opencode-go] injectUserAgent: true userAgent: "" injectOriginHeaders: true injectCoreTools: true + usageEnabled: true debug: false ``` Types and `debugFile` are documented in [cordis.patch.yml](cordis.patch.yml). +## Live OpenCode Go Quota Pill + +When an OpenCode Go model (`deepseek-v4.1-flash`) is selected in chat, an interactive quota pill mounts in the right-hand corner of the message input box: + +- Displays real-time rolling 5-hour, weekly, and monthly utilization percentages +- Highlights in amber (≥80%) and alerts in red when rate-limited (100%) +- Clicking opens a popover detailing exact progress bars and reset timestamps +- Credentials never reach the browser; the host fetches stats using `OPENCODE_GO_API_KEY` via DSH Typert IPC + ## Troubleshooting | Symptom | Likely cause | Fix | | :-- | :-- | :-- | | `403 FreeTierError` on free models | Headers stripped or tools missing | Keep the three inject toggles on | | `400 MissingSessionID` | No session header attached | Plugin must be in `bundles` and activated — check boot logs | +| Go Quota says "Unavailable" | Missing API key | Store `OPENCODE_GO_API_KEY` in DSH Credentials or environment | | Anything else misbehaving | Shouldn't be us — non-OpenCode traffic is never touched | File an issue with a redacted log | ## Compatibility @@ -83,12 +98,12 @@ Types and `debugFile` are documented in [cordis.patch.yml](cordis.patch.yml). | Component | Verified version | | :-- | :-- | -| Plugin | `@viztor/dsh-opencode@0.5.1` (npm + GitHub Packages) | +| Plugin | `dsh-opencode-patch` (npm + GitHub Packages) | | Host | DSH Web profile (`dsh-profile-web`, `patchReload: live`) | | Runtime | Node 24+ | | Gateways | `zen/v1` (`/responses` + chat completions), `zen/go/v1` (chat completions) | | Models | `muse-spark-1.3-contributor-free`, `space-bunny-free`, `deepseek-v4.1-flash` (Go) | -| Gates | `vp check` clean, 49 deterministic tests green, registry install resolves | +| Gates | `vp check` clean, 57 deterministic tests green, registry install resolves | ## Links @@ -98,4 +113,4 @@ Types and `debugFile` are documented in [cordis.patch.yml](cordis.patch.yml). ## Attribution -Evolved from [`nobu121/dsh-opencode-session`](https://github.com/nobu121/dsh-opencode-session) by [@nobu121](https://github.com/nobu121), which pioneered the `x-opencode-session` approach for OpenCode Go. This project extends it to Zen free-tier compatibility, deterministic session hashing, configurable headers, and a Web settings UI. +Evolved from [`nobu121/dsh-opencode-session`](https://github.com/nobu121/dsh-opencode-session) by [@nobu121](https://github.com/nobu121), which pioneered the `x-opencode-session` approach for OpenCode Go. This project extends it to Zen free-tier compatibility, deterministic session hashing, configurable headers, live Go quota tracking, and a Web settings UI. diff --git a/cordis.patch.yml b/cordis.patch.yml index 4fe6288..1cb805a 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,11 +1,10 @@ -# dsh-opencode layer. +# dsh-opencode-patch layer. # # Adds one host plugin row that manages session affinity, OpenCode Zen gateway -# origin headers, and free-tier compatibility for OpenCode, OpenCode Go, and OpenCode Zen routes. +# origin headers, free-tier compatibility, and live Go quota display. # -# NOTE: `name` must equal this package's npm name (@viztor/dsh-opencode) — -# the host resolves row names to node_modules paths, so an unscoped name -# fails the entry with "failed to import". `id` stays short: it is the +# NOTE: `name` must equal this package's npm name (dsh-opencode-patch) — +# the host resolves row names to node_modules paths. `id` is the # settings namespace and the plugin's own `name` export. # # Configuration (row `config`, all optional): @@ -14,11 +13,14 @@ # userAgent: string optional custom User-Agent override (default empty = use OpenCode CLI UA). # injectOriginHeaders: bool inject x-opencode-client & x-opencode-project (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). +# usageBaseURL: string OpenCode Go gateway base URL (default 'https://opencode.ai/zen/go/v1'). +# usageKeyEnv: string environment variable or credential ref for Go key (default 'OPENCODE_GO_API_KEY'). # debug: true|false log every streamed call that receives the header. # debugFile: path optional append target for stream debug JSONL. - insert: - - id: dsh-opencode - name: "@viztor/dsh-opencode" + - id: dsh-opencode-patch + name: "dsh-opencode-patch" config: providers: - opencode @@ -27,4 +29,5 @@ userAgent: "" injectOriginHeaders: true injectCoreTools: true + usageEnabled: true debug: false diff --git a/package.json b/package.json index 22b6f1d..712089c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@viztor/dsh-opencode", + "name": "dsh-opencode-patch", "version": "0.5.1", - "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, and free-tier tool fallback for OpenCode models.", + "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, free-tier tool fallback, and live Go quota display.", "keywords": [ "cordis", "deepseek-harness", @@ -16,9 +16,9 @@ "session-affinity", "x-opencode-session" ], - "homepage": "https://github.com/viztor/dsh-opencode", + "homepage": "https://github.com/viztor/dsh-opencode-patch", "bugs": { - "url": "https://github.com/viztor/dsh-opencode/issues" + "url": "https://github.com/viztor/dsh-opencode-patch/issues" }, "license": "MIT", "author": { @@ -26,7 +26,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/viztor/dsh-opencode.git" + "url": "git+https://github.com/viztor/dsh-opencode-patch.git" }, "files": [ "lib", @@ -72,6 +72,7 @@ "devDependencies": { "@deepseek-ai/dsh-client-store": "0.2.0-rc.1", "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.1", + "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2", "@types/node": "^26.6.3", "@types/react": "^18.3.31", "react": "^18.3.1", @@ -80,6 +81,14 @@ "vite-plus": "catalog:", "vitest": "^5.0.2" }, + "peerDependencies": { + "@deepseek-ai/dsh-typert-protocol": ">=0.1.5-rc.1" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-typert-protocol": { + "optional": true + } + }, "icon": "./icon.svg", "engines": { "node": ">=24" @@ -93,7 +102,9 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-plugin-manager", - "@deepseek-ai/dsh-api-remotes" + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-ui-model-selection", + "@deepseek-ai/dsh-client-ui-conversation" ], "platform": "web", "immediately": true diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ad8823..eb7f9d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: 0.2.0-rc.1 version: 0.2.0-rc.1(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-typert-protocol': + specifier: 0.2.0-rc.2 + version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) '@types/node': specifier: ^26.6.3 version: 26.6.3 @@ -98,6 +101,11 @@ packages: '@deepseek-ai/cosmokit@1.8.5': resolution: {integrity: sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A==} + '@deepseek-ai/dsh-brand@0.2.0-rc.2': + resolution: {integrity: sha512-A5XlN4tIgP0qObkYFhcZ7Oz/tM2oESEZqLwS0Qq+sM1ivGx6o4AMPgJdfKQlwLzqk0cMLNtAaVBHfiYoElt4lw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-client-store@0.2.0-rc.1': resolution: {integrity: sha512-fvjAr7KvcfH/GOGiOuZiNhwZyga+HSyYsb0tvCBHHEn3UiyGZzaguaLa6RtN++GTvE+lUUExpWSNuTGlZQ28zw==} peerDependencies: @@ -108,6 +116,11 @@ packages: peerDependencies: '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-typert-protocol@0.2.0-rc.2': + resolution: {integrity: sha512-95RvIDcVac2BWdv1IxN6yKlRV+8eEvkofqSxV+d0r8x1kx2EIWEsbdxz6C+coYIm+8e03MiN+rwOt8J7sLfe7Q==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -1609,6 +1622,10 @@ snapshots: '@deepseek-ai/cosmokit@1.8.5': {} + '@deepseek-ai/dsh-brand@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-client-store@0.2.0-rc.1(@deepseek-ai/cordis@4.0.4)': dependencies: '@deepseek-ai/cordis': 4.0.4 @@ -1617,6 +1634,11 @@ snapshots: dependencies: '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-typert-protocol@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-brand': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.6.0': {} diff --git a/src/index.ts b/src/index.ts index bc9dacf..2a0919b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,19 +2,21 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import { appendFile } from "node:fs/promises"; +import { GoUsageService, registerUsageRemotes } from "./usage.ts"; + /** * Package vs component identity (do not conflate): * - * - npm package `@viztor/dsh-opencode`: the installable unit. The host + * - npm package `dsh-opencode-patch`: the installable unit. The host * resolves a cordis row to `node_modules/`, so the row's `name` * must equal this string exactly (see cordis.patch.yml). * - cordis row: one *instance* of the package. `id` is the instance id and - * doubles as the settings namespace the client card binds (`dsh-opencode`). + * doubles as the settings namespace the client card binds (`dsh-opencode-patch`). * One package can back N rows with different ids/configs. * - `name` below: this component's cordis plugin identity (log lines, - * service scoping). It matches the default row id by convention only. + * service scoping). It matches the default row id by convention. */ -export const name = "dsh-opencode"; +export const name = "dsh-opencode-patch"; export const inject = ["llm"]; @@ -77,6 +79,9 @@ export interface PluginConfig { injectUserAgent?: boolean; providers?: string[]; userAgent?: string; + usageBaseURL?: string; + usageKeyEnv?: string; + usageEnabled?: boolean; } export interface ResolvedPluginConfig { @@ -87,6 +92,9 @@ export interface ResolvedPluginConfig { injectUserAgent: boolean; providers: Set; userAgent?: string; + usageBaseURL: string; + usageKeyEnv: string; + usageEnabled: boolean; } export const resolveConfig = ( @@ -114,6 +122,17 @@ export const resolveConfig = ( : undefined; const injectOriginHeaders = config.injectOriginHeaders !== false; const injectCoreTools = config.injectCoreTools !== false; + const usageBaseURL = + typeof config.usageBaseURL === "string" && + config.usageBaseURL.trim().length > 0 + ? config.usageBaseURL.trim() + : "https://opencode.ai/zen/go/v1"; + const usageKeyEnv = + typeof config.usageKeyEnv === "string" && + config.usageKeyEnv.trim().length > 0 + ? config.usageKeyEnv.trim() + : "OPENCODE_GO_API_KEY"; + const usageEnabled = config.usageEnabled !== false; return { debug, @@ -122,6 +141,9 @@ export const resolveConfig = ( injectOriginHeaders, injectUserAgent, providers: new Set(providers), + usageBaseURL, + usageEnabled, + usageKeyEnv, userAgent, }; }; @@ -485,6 +507,8 @@ export const patchFetch = ( export interface CordisContext { effect?: (fn: () => unknown, name?: string) => void; + get?: (name: string) => unknown; + inject?: (deps: string[], cb: (scope: unknown) => void) => void; logger?: { info?: (msg: string, ...args: unknown[]) => void; warn?: (msg: string, ...args: unknown[]) => void; @@ -498,6 +522,7 @@ export interface CordisContext { ) => unknown, options?: { prepend?: boolean } ) => void; + plugin?: (plugin: unknown, options?: unknown) => void; } interface StreamOptions { @@ -517,10 +542,33 @@ export const apply = ( const { debug, debugFile, providers } = config; const als = new AsyncLocalStorage(); + if (config.usageEnabled && typeof ctx.plugin === "function") { + ctx.plugin(GoUsageService, { + baseURL: () => config.usageBaseURL, + resolveApiKey: async () => { + const creds = ctx.get?.("credentials") as + | { + resolve?: (r: string) => Promise<{ value?: string } | undefined>; + } + | undefined; + if (creds && typeof creds.resolve === "function") { + try { + const hit = await creds.resolve(config.usageKeyEnv); + if (hit?.value) return hit.value; + } catch { + // Fall through + } + } + return process.env[config.usageKeyEnv]; + }, + }); + registerUsageRemotes(ctx); + } + const originalFetch: unknown = globalThis.fetch; if (!isFetchFunction(originalFetch)) { ctx.logger?.warn?.( - "[dsh-opencode] globalThis.fetch is unavailable; cannot inject x-opencode-session" + "[dsh-opencode-patch] globalThis.fetch is unavailable; cannot inject x-opencode-session" ); return; } @@ -530,7 +578,7 @@ export const apply = ( ctx.effect?.(() => { globalThis.fetch = patched; ctx.logger?.info?.( - "[dsh-opencode] active for providers [%s]", + "[dsh-opencode-patch] active for providers [%s]", [...providers].join(", ") ); return () => { @@ -538,7 +586,7 @@ export const apply = ( globalThis.fetch = originalFetch; } }; - }, "dsh-opencode.fetch-patch"); + }, "dsh-opencode-patch.fetch-patch"); ctx.on?.( "llm/stream", @@ -589,7 +637,7 @@ export const apply = ( } if (debug) { ctx.logger?.info?.( - '[dsh-opencode] streaming provider "%s" with %s=%s', + '[dsh-opencode-patch] streaming provider "%s" with %s=%s', providerKey, SESSION_HEADER, value @@ -611,4 +659,12 @@ export const apply = ( ); }; +export { GoUsageService, registerUsageRemotes } from "./usage.ts"; +export { + parseGoUsage, + type GoUsage, + type UsageWindow, + usageRemote, +} from "./usage-contract.ts"; + export default { apply, inject, name }; diff --git a/src/settings-page.tsx b/src/settings-page.tsx index ffe92af..f44d13c 100644 --- a/src/settings-page.tsx +++ b/src/settings-page.tsx @@ -1,8 +1,9 @@ /** - * `dsh-opencode` settings page — DSH Web client bundle. - * Contributes a settings card under DSH Settings -> Plugins. + * `dsh-opencode-patch` settings page — DSH Web client bundle. + * Contributes a settings card under DSH Settings -> Plugins and a quota pill + * in the conversation input tray for OpenCode Go models. * - * @module dsh-opencode/settings-page + * @module dsh-opencode-patch/settings-page */ import { @@ -16,7 +17,9 @@ import { } from "@deepseek-ai/dsh-client-ui-primitives"; import React from "react"; -export const NS = "dsh-opencode"; +import { UsagePill } from "./usage-pill.tsx"; + +export const NS = "dsh-opencode-patch"; /** * The bundle's npm package name, spelled rather than imported. @@ -25,9 +28,15 @@ export const NS = "dsh-opencode"; * cordis row id), so this must equal `package.json`'s `name`. The client * half must not depend on the host half, hence the duplication. */ -export const PKG = "@viztor/dsh-opencode"; +export const PKG = "dsh-opencode-patch"; -export const inject = ["slots", "locale", "configForms"]; +export const inject = [ + "slots", + "locale", + "configForms", + "modelDirectories", + "remote", +]; const en = { debug: "Debug Logging (default off)", @@ -37,7 +46,7 @@ const en = { debugHint: "Logs every streamed call receiving the header via ctx.logger. Empty inherits the default.", description: - "OpenCode Zen gateway origin headers, session affinity, and free-tier compatibility.", + "OpenCode Zen gateway origin headers, session affinity, free-tier compatibility, and live Go quota display.", injectCoreTools: "Inject Core Tools (default on)", injectCoreToolsHint: "Auto-injects read and bash tool schemas on free-tier requests to satisfy gateway validation. Empty inherits the default.", @@ -60,6 +69,25 @@ const en = { saving: "Saving…", title: "OpenCode Integration", unavailable: "This plugin is not loaded, so it cannot be configured.", + usageHint: "Account usage · used percentage · refreshes every minute", + usageLastUpdated: "Last updated", + usageLimited: "Limit reached", + usageLimitedShort: "limited", + usageLoading: "Loading usage…", + usageRefreshFailed: "Refresh failed", + usageRefreshing: "Refreshing…", + usageResets: "Resets", + usageRetry: "Retry now", + usageRollingShort: "5h", + usageStaleHint: + "Showing the last successful usage reading. Current usage may have changed.", + usageStaleShort: "Last data", + usageTitle: "OpenCode Go usage", + usageUnavailable: "Unavailable", + usageWeekShort: "week", + usage_monthly: "Monthly", + usage_rolling: "5 hours", + usage_weekly: "Weekly", userAgent: "User-Agent Override", userAgentHint: "Custom User-Agent string. Leave blank to use the canonical OpenCode CLI string.", @@ -70,7 +98,8 @@ const zh = { debugFile: "调试文件", debugFileHint: "插件追加 JSONL 流调试记录的服务端绝对路径。留空表示不记录。", debugHint: "通过 ctx.logger 记录每次注入会话头的流式调用。留空沿用默认值。", - description: "OpenCode Zen 网关来源头恢复、会话保持与免费模型兼容支持。", + description: + "OpenCode Zen 网关来源头恢复、会话保持、免费模型兼容与 Go 实时额度显示。", injectCoreTools: "自动补全核心工具(默认开启)", injectCoreToolsHint: "在免费模型请求中自动注入 read 和 bash 工具声明以满足网关校验。留空沿用默认值。", @@ -92,6 +121,24 @@ const zh = { saving: "保存中…", title: "OpenCode 接入设置", unavailable: "插件未加载,暂无法配置。", + usageHint: "账号额度 · 已用百分比 · 每分钟刷新", + usageLastUpdated: "更新于", + usageLimited: "已达限额", + usageLimitedShort: "受限", + usageLoading: "正在读取用量…", + usageRefreshFailed: "刷新失败", + usageRefreshing: "正在刷新…", + usageResets: "重置于", + usageRetry: "立即重试", + usageRollingShort: "5小时", + usageStaleHint: "当前显示上次成功读取的用量,实际用量可能已变化。", + usageStaleShort: "上次数据", + usageTitle: "OpenCode Go 用量", + usageUnavailable: "暂不可用", + usageWeekShort: "周", + usage_monthly: "每月", + usage_rolling: "5 小时", + usage_weekly: "每周", userAgent: "自定义 User-Agent", userAgentHint: "自定义 User-Agent 字符串。留空则使用默认 OpenCode CLI 标识。", }; @@ -247,7 +294,16 @@ export interface ClientContext { effect?: (fn: () => unknown, name?: string) => void; locale?: { bind: (ns: string) => (key: string) => string; - register: (ns: string, dicts: Record) => void; + register: ( + ns: string, + dicts: Record + ) => (() => void) | undefined; + }; + modelDirectories?: { + directoryFor: (sessionId: unknown) => { store: unknown }; + }; + remote?: { + opencodeGoUsage?: { read: () => Promise }; }; slots?: { inject: (name: string, fn: () => void) => void; @@ -277,10 +333,52 @@ const isSettingsFormScope = ( ); }; +const noopDisposer = (): void => { + /* no-op */ +}; + export const apply = (ctx: ClientContext): void => { ctx.effect?.(() => { - ctx.locale?.register?.(NS, { en, zh }); - }, "dsh-opencode: dictionaries"); + try { + return ctx.locale?.register?.(NS, { en, zh }); + } catch { + return noopDisposer; + } + }, "dsh-opencode-patch: dictionaries"); + + // Conversation input tray quota pill for OpenCode Go models + ctx.slots?.inject?.("conversation.input.right", () => { + ctx.slots?.register?.( + { + id: "dsh-opencode-patch-usage", + inject: (sessionId: unknown) => { + const directory = + ctx.modelDirectories?.directoryFor?.(sessionId)?.store; + if (!directory) { + return null; + } + return { + directory, + readUsage: async () => { + const res = (await ctx.remote?.opencodeGoUsage?.read?.()) as + | { ok: true; value: unknown } + | { ok: false; error: unknown } + | undefined; + if (res && typeof res === "object" && "ok" in res) { + if (!res.ok) throw res.error; + return res.value; + } + return res; + }, + t: ctx.locale?.bind?.(NS) ?? ((key: string) => key), + }; + }, + name: "conversation.input.right", + order: 1000, + }, + UsagePill + ); + }); const rawScope: unknown = ctx.configForms?.get?.(NS); if (!isSettingsFormScope(rawScope)) { @@ -300,7 +398,7 @@ export const apply = (ctx: ClientContext): void => { () => () => { model.dispose(); }, - "dsh-opencode: form subscription" + "dsh-opencode-patch: form subscription" ); ctx.configForms?.whileServed?.([NS], () => { diff --git a/src/usage-contract.ts b/src/usage-contract.ts new file mode 100644 index 0000000..a2dea2d --- /dev/null +++ b/src/usage-contract.ts @@ -0,0 +1,130 @@ +/** + * Remote contract and validation for OpenCode Go account usage statistics. + * + * Modeled on the Typert protocol so the host can fetch usage stats using server-side + * credentials without ever exposing tokens to the browser client. + * + * @module dsh-opencode-patch/usage-contract + */ + +import type { + RemoteResult, + TypertRemoteContribution, +} from "@deepseek-ai/dsh-typert-protocol"; + +export interface UsageWindow { + percent: number; + resetsAt: string; + status: "ok" | "rate-limited"; +} + +export interface GoUsage { + monthly: UsageWindow; + rolling: UsageWindow; + /** Opaque Host identity for this endpoint/account; never a credential or its hash. */ + source?: string; + weekly: UsageWindow; +} + +const isRecord = (val: unknown): val is Record => + typeof val === "object" && val !== null; + +const parseWindow = ( + row: Record, + label: string +): UsageWindow => { + const { percent, resetsAt, status } = row; + if (status !== "ok" && status !== "rate-limited") { + throw new TypeError(`Invalid OpenCode Go status for ${label}`); + } + if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0) { + throw new TypeError(`Invalid OpenCode Go percent for ${label}`); + } + if (typeof resetsAt !== "string" || !Number.isFinite(Date.parse(resetsAt))) { + throw new TypeError(`Invalid OpenCode Go resetsAt for ${label}`); + } + return { + percent, + resetsAt, + status, + }; +}; + +/** + * Validate and normalize OpenCode Go usage response. + * + * Accepts either `{ usage: { rolling, weekly, monthly } }` (the live API payload) + * or `{ rolling, weekly, monthly }` (unwrapped). + */ +export const parseGoUsage = (value: unknown): GoUsage => { + if (!isRecord(value)) { + throw new TypeError( + "Invalid OpenCode Go usage response: expected an object" + ); + } + const root = value; + const source = isRecord(root.usage) ? root.usage : root; + + const rollingRaw = source.rolling; + const weeklyRaw = source.weekly; + const monthlyRaw = source.monthly; + + if (!isRecord(rollingRaw) || !isRecord(weeklyRaw) || !isRecord(monthlyRaw)) { + throw new TypeError("Invalid OpenCode Go usage response: missing window"); + } + + const rolling = parseWindow(rollingRaw, "rolling"); + const weekly = parseWindow(weeklyRaw, "weekly"); + const monthly = parseWindow(monthlyRaw, "monthly"); + + const sourceId = + typeof root.source === "string" && + root.source.length > 0 && + root.source.length <= 128 + ? root.source + : undefined; + + return { + monthly, + rolling, + ...(sourceId === undefined ? {} : { source: sourceId }), + weekly, + }; +}; + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + "opencode-go/usage-unavailable": { + readonly retainPrevious: boolean; + readonly retryable: boolean; + readonly source?: string; + }; + } + interface TypertRemoteNamespaceMap { + opencodeGoUsage: { + read: () => Promise>; + }; + } +} + +const usageCodec = { + create: () => ({ parse: parseGoUsage }), + mode: "strict" as const, + schema: { parse: parseGoUsage }, + typeSymbol: "dsh-opencode-patch#GoUsage", +}; + +export const usageRemote: TypertRemoteContribution = { + descriptors: [ + { + id: "dsh-opencode-patch#opencodeGoUsage/read", + invocation: { kind: "direct" }, + method: "read", + namespace: "opencodeGoUsage", + parameters: [], + result: usageCodec, + service: "opencodeGoUsage", + }, + ], + package: "dsh-opencode-patch", +}; diff --git a/src/usage-pill.tsx b/src/usage-pill.tsx new file mode 100644 index 0000000..861a1c9 --- /dev/null +++ b/src/usage-pill.tsx @@ -0,0 +1,472 @@ +/** + * Conversation slot component displaying OpenCode Go quota and rate limits. + * + * Shows a compact pill in `conversation.input.right` that activates when an + * OpenCode Go model is selected. Clicking opens an accessible status panel with + * rolling, weekly, and monthly quota progress and reset times. + * + * @module dsh-opencode-patch/usage-pill + */ + +import React, { + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; + +import type { GoUsage, UsageWindow } from "./usage-contract.ts"; + +export interface SnapshotStore { + getSnapshot: () => T; + subscribe: (onStoreChange: () => void) => () => void; +} + +export interface ModelDirectoryState { + current?: { + model?: string; + provider?: string; + }; +} + +export interface UsagePillProps { + directory: SnapshotStore; + getLocale?: () => string; + readUsage: () => Promise; + t: (key: string) => string; +} + +interface UsageFailure { + message?: string; + retainPrevious: boolean; + source?: string; +} + +const noop = (): void => { + /* no-op */ +}; + +const STYLES = ` +.dsh-oc-usage-root { + position: relative; + display: inline-flex; + min-width: 0; + vertical-align: middle; +} +.dsh-oc-usage-trigger { + border: 0; + background: transparent; + color: inherit; + opacity: 0.8; + font: inherit; + font-size: 12px; + padding: 3px 7px; + border-radius: 6px; + cursor: pointer; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 4px; +} +.dsh-oc-usage-trigger:hover, +.dsh-oc-usage-trigger:focus-visible { + opacity: 1; + background: color-mix(in srgb, currentColor 8%, transparent); +} +.dsh-oc-usage-trigger.dsh-oc-usage-alert { + color: #e5484d; +} +.dsh-oc-usage-panel { + position: absolute; + bottom: calc(100% + 10px); + right: 0; + z-index: 100; + width: 290px; + max-width: calc(100vw - 32px); + max-height: 70vh; + overflow-y: auto; + box-sizing: border-box; + padding: 16px; + border: 1px solid color-mix(in srgb, currentColor 18%, transparent); + border-radius: 12px; + color: var(--dsw-alias-label-primary, CanvasText); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); + font-size: 12px; + isolation: isolate; + background-color: Canvas; + background-image: + linear-gradient(var(--dsw-specific-menu, transparent), var(--dsw-specific-menu, transparent)), + linear-gradient(var(--dsw-alias-bg-layer-2, Canvas), var(--dsw-alias-bg-layer-2, Canvas)); +} +.dsh-oc-usage-hint { + opacity: 0.7; + font-size: 11px; + line-height: 1.5; + margin: 4px 0; +} +.dsh-oc-usage-warning { + margin: 10px 0; + padding: 8px 10px; + border-radius: 6px; + background: color-mix(in srgb, #e5484d 12%, transparent); + color: #e5484d; + line-height: 1.4; + overflow-wrap: anywhere; +} +.dsh-oc-usage-warning strong { + display: block; + margin-bottom: 2px; +} +.dsh-oc-usage-retry { + border: 1px solid color-mix(in srgb, currentColor 25%, transparent); + border-radius: 6px; + padding: 4px 10px; + background: transparent; + color: inherit; + font: inherit; + font-size: 11px; + cursor: pointer; + margin-top: 6px; +} +.dsh-oc-usage-retry:disabled { + opacity: 0.5; + cursor: default; +} +.dsh-oc-usage-window { + margin-top: 12px; +} +.dsh-oc-usage-row { + display: flex; + justify-content: space-between; + margin-bottom: 4px; +} +.dsh-oc-usage-window progress { + width: 100%; + height: 6px; + display: block; + appearance: none; + border: 0; + border-radius: 4px; + background: color-mix(in srgb, currentColor 15%, transparent); +} +.dsh-oc-usage-window progress::-webkit-progress-bar { + background: transparent; + border-radius: 4px; +} +.dsh-oc-usage-window progress::-webkit-progress-value { + background: #30a46c; + border-radius: 4px; +} +.dsh-oc-usage-window progress::-moz-progress-bar { + background: #30a46c; + border-radius: 4px; +} +.dsh-oc-usage-window progress.dsh-oc-usage-high::-webkit-progress-value { + background: #e0a100; +} +.dsh-oc-usage-window progress.dsh-oc-usage-high::-moz-progress-bar { + background: #e0a100; +} +.dsh-oc-usage-window progress.dsh-oc-usage-limited::-webkit-progress-value { + background: #e5484d; +} +.dsh-oc-usage-window progress.dsh-oc-usage-limited::-moz-progress-bar { + background: #e5484d; +} +.dsh-oc-usage-limited-tag { + color: #e5484d; + font-weight: 600; + font-size: 11px; + margin-top: 2px; +} +`; + +const ensureStylesInjected = (): void => { + if (typeof document === "undefined") { + return; + } + const ATTR = "data-dsh-opencode-usage-styles"; + if (document.head.querySelector(`style[${ATTR}]`) === null) { + const style = document.createElement("style"); + style.setAttribute(ATTR, "true"); + style.textContent = STYLES; + document.head.append(style); + } +}; + +const usageLevel = (window: UsageWindow): string | undefined => { + if (window.status === "rate-limited" || window.percent >= 100) { + return "dsh-oc-usage-limited"; + } + return window.percent >= 80 ? "dsh-oc-usage-high" : undefined; +}; + +const parseFailure = (error: unknown): UsageFailure => { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "opencode-go/usage-unavailable" + ) { + const details = + "details" in error && + typeof error.details === "object" && + error.details !== null + ? (error.details as Record) + : {}; + return { + message: + "message" in error && typeof error.message === "string" + ? error.message + : undefined, + retainPrevious: + details.retryable === true && details.retainPrevious === true, + source: typeof details.source === "string" ? details.source : undefined, + }; + } + return { + message: error instanceof Error ? error.message : String(error), + retainPrevious: false, + }; +}; + +const ActiveUsage = ({ + getLocale, + readUsage, + t, +}: Omit): React.ReactElement => { + const [snapshot, setSnapshot] = useState<{ + reader: typeof readUsage; + updatedAt: number; + usage: GoUsage; + } | null>(null); + const [failed, setFailed] = useState<{ + failure: UsageFailure; + reader: typeof readUsage; + } | null>(null); + const [refreshing, setRefreshing] = useState(false); + const [open, setOpen] = useState(false); + const root = useRef(null); + const retry = useRef<() => void>(noop); + + useEffect(() => { + ensureStylesInjected(); + }, []); + + useEffect(() => { + let alive = true; + let busy = false; + setSnapshot(null); + setFailed(null); + setRefreshing(false); + + const refresh = async (manual = false): Promise => { + if (busy || (!manual && document.visibilityState === "hidden")) { + return; + } + busy = true; + setRefreshing(true); + try { + const value = await readUsage(); + if (alive) { + setSnapshot({ + reader: readUsage, + updatedAt: Date.now(), + usage: value, + }); + setFailed(null); + } + } catch (error: unknown) { + if (alive) { + const failure = parseFailure(error); + setFailed({ failure, reader: readUsage }); + setSnapshot((previous) => + previous?.reader === readUsage && + failure.retainPrevious && + typeof previous.usage.source === "string" && + previous.usage.source === failure.source + ? previous + : null + ); + } + } finally { + busy = false; + if (alive) { + setRefreshing(false); + } + } + }; + + retry.current = () => { + void refresh(true); + }; + void refresh(); + + const timer = setInterval(() => { + void refresh(); + }, 60_000); + const onVisible = (): void => { + void refresh(); + }; + document.addEventListener("visibilitychange", onVisible); + + return () => { + alive = false; + retry.current = noop; + clearInterval(timer); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [readUsage]); + + useEffect(() => { + if (!open) { + return noop; + } + const click = (event: MouseEvent): void => { + if ( + root.current !== null && + event.target instanceof Node && + !root.current.contains(event.target) + ) { + setOpen(false); + } + }; + const key = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + setOpen(false); + } + }; + document.addEventListener("mousedown", click); + document.addEventListener("keydown", key); + return () => { + document.removeEventListener("mousedown", click); + document.removeEventListener("keydown", key); + }; + }, [open]); + + const current = snapshot?.reader === readUsage ? snapshot : null; + const usage = current?.usage; + const failure = failed?.reader === readUsage ? failed.failure : null; + + const isLimited = + usage?.monthly.status === "rate-limited" || + usage?.weekly.status === "rate-limited" || + usage?.rolling.status === "rate-limited"; + + const label = usage + ? `Go · ${t("usageRollingShort")} ${usage.rolling.percent}% · ${t("usageWeekShort")} ${usage.weekly.percent}%${ + isLimited ? ` · ${t("usageLimitedShort")}` : "" + }${failure ? ` · ${t("usageStaleShort")}` : ""}` + : `Go · ${failure ? t("usageUnavailable") : "…"}`; + + let panelContent: React.ReactNode = null; + if (usage) { + panelContent = (["rolling", "weekly", "monthly"] as const).map( + (windowKey) => { + const item = usage[windowKey]; + return ( +
+
+ {t(`usage_${windowKey}`)} + {item.percent}% +
+ +
+ {t("usageResets")}{" "} + {new Date(item.resetsAt).toLocaleString(getLocale?.())} +
+ {item.status === "rate-limited" && ( +
+ {t("usageLimited")} +
+ )} +
+ ); + } + ); + } else if (!failure) { + panelContent =

{t("usageLoading")}

; + } + + return ( + + + {open && ( +
+ {t("usageTitle")} +

{t("usageHint")}

+ {failure && ( +
+ {t("usageRefreshFailed")} +

{failure.message ?? t("usageUnavailable")}

+ {usage &&

{t("usageStaleHint")}

} +
+ )} + {failure && ( + + )} + {current && ( +

+ {t("usageLastUpdated")}{" "} + {new Date(current.updatedAt).toLocaleString(getLocale?.())} +

+ )} + {panelContent} +
+ )} +
+ ); +}; + +export const UsagePill = ({ + directory, + ...props +}: UsagePillProps): React.ReactElement | null => { + const state = useSyncExternalStore( + directory.subscribe, + directory.getSnapshot, + directory.getSnapshot + ); + + const provider = state?.current?.provider ?? ""; + const isOpenCodeGo = + provider === "opencode-go" || + provider === "dsh-opencode-go" || + /opencode-go/i.test(provider); + + if (!isOpenCodeGo) { + return null; + } + + return ; +}; diff --git a/src/usage.ts b/src/usage.ts new file mode 100644 index 0000000..0e03372 --- /dev/null +++ b/src/usage.ts @@ -0,0 +1,195 @@ +/** + * Host-side service that queries OpenCode Go usage statistics without exposing credentials to the client. + * + * @module dsh-opencode-patch/usage + */ + +import { randomUUID } from "node:crypto"; + +import { + RemoteError, + TypertRemoteService, +} from "@deepseek-ai/dsh-typert-protocol"; + +import { parseGoUsage, type GoUsage, usageRemote } from "./usage-contract.ts"; + +const USAGE_MAX_BYTES = 1024 * 1024; + +export interface UsageOptions { + baseURL?: () => string; + resolveApiKey?: () => Promise; +} + +interface CredentialsHost { + get?: (name: string) => + | { + resolve?: (ref: string) => Promise<{ value?: string } | undefined>; + } + | undefined; +} + +export class GoUsageService extends TypertRemoteService { + private identity?: { baseURL: string; key: string; source: string }; + private readonly options: UsageOptions; + + constructor(ctx: unknown, options: UsageOptions = {}) { + // TypertRemoteService expects Context and service identifier + super(ctx as never, "opencodeGoUsage"); + this.options = options; + } + + async read(): Promise { + const rawBaseURL = + this.options.baseURL?.() ?? "https://opencode.ai/zen/go/v1"; + const baseURL = rawBaseURL.replace(/\/$/, ""); + + let key: string | undefined; + try { + key = this.options.resolveApiKey + ? await this.options.resolveApiKey() + : await this.resolveDefaultKey(); + } catch (error: unknown) { + this.identity = undefined; + const missing = + error instanceof Error && + "code" in error && + error.code === "MISSING_CREDENTIAL"; + throw new RemoteError( + "opencode-go/usage-unavailable", + missing + ? "OpenCode Go API key is not configured" + : "Could not resolve OpenCode Go API key", + { retainPrevious: false, retryable: !missing }, + { cause: error } + ); + } + + if (key === undefined || key.length === 0) { + this.identity = undefined; + throw new RemoteError( + "opencode-go/usage-unavailable", + "OpenCode Go API key is not configured", + { retainPrevious: false, retryable: false } + ); + } + + if (this.identity?.baseURL !== baseURL || this.identity.key !== key) { + this.identity = { baseURL, key, source: randomUUID() }; + } + const { source } = this.identity; + const url = `${baseURL}/usage`; + + let response: Response; + let text: string; + try { + response = await fetch(url, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${key}`, + "User-Agent": "opencode/1.18.33 dsh-opencode-patch", + }, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + text = await response.text(); + } catch (error: unknown) { + throw new RemoteError( + "opencode-go/usage-unavailable", + `Could not read ${url}: ${error instanceof Error ? error.message : String(error)}`, + { retainPrevious: true, retryable: true, source }, + { cause: error } + ); + } + + if (text.length > USAGE_MAX_BYTES) { + throw new RemoteError( + "opencode-go/usage-unavailable", + `Response from ${url} exceeds ${USAGE_MAX_BYTES} byte limit`, + { retainPrevious: false, retryable: true, source } + ); + } + + if (!response.ok) { + const temporary = + response.status === 408 || + response.status === 429 || + response.status >= 500; + throw new RemoteError( + "opencode-go/usage-unavailable", + `OpenCode Go usage unavailable (HTTP ${response.status})`, + { retainPrevious: temporary, retryable: temporary, source } + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error: unknown) { + throw new RemoteError( + "opencode-go/usage-unavailable", + "Invalid JSON in OpenCode Go usage response", + { retainPrevious: false, retryable: true, source }, + { cause: error } + ); + } + + try { + const usage = parseGoUsage(parsed); + return { ...usage, source }; + } catch (error: unknown) { + throw new RemoteError( + "opencode-go/usage-unavailable", + "Invalid OpenCode Go usage response structure", + { retainPrevious: false, retryable: true, source }, + { cause: error } + ); + } + } + + private async resolveDefaultKey(): Promise { + const creds = (this.ctx as unknown as CredentialsHost | undefined)?.get?.( + "credentials" + ); + if (creds && typeof creds.resolve === "function") { + try { + const hit = await creds.resolve("OPENCODE_GO_API_KEY"); + if (hit?.value && hit.value.length > 0) return hit.value; + } catch { + // Fall through + } + } + return process.env.OPENCODE_GO_API_KEY; + } +} + +/** + * Register the typert remote descriptor with the host registry if available. + */ +export const registerUsageRemotes = (ctx: unknown): void => { + const context = ctx as + | { + inject?: (deps: string[], cb: (scope: unknown) => void) => void; + } + | undefined; + if (typeof context?.inject === "function") { + context.inject(["typert"], (scope: unknown) => { + const typertScope = scope as + | { + effect?: (fn: () => void) => void; + typert?: { + register?: (desc: unknown) => void; + }; + } + | undefined; + typertScope?.effect?.(() => { + typertScope.typert?.register?.({ + face: "host", + invocations: usageRemote.descriptors, + model: { events: [], objects: [], services: [] }, + package: usageRemote.package, + schemas: [], + }); + }); + }); + } +}; diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 7799ba6..782c112 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -12,15 +12,18 @@ import { apply, DUMMY_BASH_TOOL, DUMMY_READ_TOOL, + GoUsageService, headerValueFor, hasSessionHeader, isOpenCodeRequest, name as PLUGIN_NAME, openCodeSessionIdFor, OPENCODE_UA, + parseGoUsage, patchFetch, resolveConfig, SESSION_HEADER, + usageRemote, withStore, } from "../src/index.ts"; @@ -1021,18 +1024,19 @@ describe("bundle manifest consistency", () => { } const patch = await readText("cordis.patch.yml"); const row = - /^\s*-\s*id:\s*dsh-opencode\s*\n\s*name:\s*["']?([^"'\s]+)/m.exec(patch); + /^\s*-\s*id:\s*dsh-opencode-patch\s*\n\s*name:\s*["']?([^"'\s]+)/m.exec( + patch + ); if (row === null || row[1] === undefined) { - throw new Error("dsh-opencode row not found in cordis.patch.yml"); + throw new Error("dsh-opencode-patch row not found in cordis.patch.yml"); } - // The host resolves row names to node_modules paths: an unscoped name - // fails the entry with "failed to import". + // The host resolves row names to node_modules paths. expect(row[1]).toBe(pkgRaw.name); }); it("keeps the settings namespace equal to the cordis row id", async () => { const patch = await readText("cordis.patch.yml"); - expect(patch).toContain("id: dsh-opencode"); + expect(patch).toContain("id: dsh-opencode-patch"); // Read the NS constant textually: importing settings-page.tsx would // drag the React + ui-primitives runtime chain (whose own deps are // incomplete for node) into a hermetic suite. @@ -1041,13 +1045,12 @@ describe("bundle manifest consistency", () => { if (ns === null || ns[1] === undefined) { throw new Error("NS constant not found in src/settings-page.tsx"); } - expect(ns[1]).toBe("dsh-opencode"); + expect(ns[1]).toBe("dsh-opencode-patch"); }); it("keeps the component name aligned with the default row id", () => { - // Package (@viztor/dsh-opencode) ≠ row id (dsh-opencode) ≠ row name, - // but the component identity matches the default row id by convention. - expect(PLUGIN_NAME).toBe("dsh-opencode"); + // Package (dsh-opencode-patch) == row id (dsh-opencode-patch) == row name. + expect(PLUGIN_NAME).toBe("dsh-opencode-patch"); }); it("ships a manifest icon the host can display", async () => { @@ -1077,3 +1080,117 @@ describe("bundle manifest consistency", () => { expect(banner[1]).toBe(pkgRaw.name); }); }); + +const createMockContext = () => + ({ + reflect: { provide: () => {} }, + }) as never; + +describe("OpenCode Go Usage", () => { + const samplePayload = { + usage: { + monthly: { + percent: 100, + resetsAt: "2026-10-09T13:53:58.000Z", + status: "rate-limited", + }, + rolling: { + percent: 15, + resetsAt: "2026-10-01T16:55:56.004Z", + status: "ok", + }, + weekly: { + percent: 42, + resetsAt: "2026-10-05T00:00:00.000Z", + status: "ok", + }, + }, + }; + + it("parses valid API usage response with usage wrapper", () => { + const parsed = parseGoUsage(samplePayload); + expect(parsed.monthly.status).toBe("rate-limited"); + expect(parsed.monthly.percent).toBe(100); + expect(parsed.rolling.percent).toBe(15); + expect(parsed.weekly.percent).toBe(42); + }); + + it("parses unwrapped usage response", () => { + const parsed = parseGoUsage(samplePayload.usage); + expect(parsed.monthly.status).toBe("rate-limited"); + expect(parsed.rolling.status).toBe("ok"); + expect(parsed.weekly.percent).toBe(42); + }); + + it("rejects non-object or null payloads", () => { + expect(() => parseGoUsage(null)).toThrow("expected an object"); + expect(() => parseGoUsage("string")).toThrow("expected an object"); + }); + + it("rejects payload missing rolling, weekly, or monthly", () => { + expect(() => + parseGoUsage({ + rolling: { percent: 0, resetsAt: "2026-01-01", status: "ok" }, + }) + ).toThrow(); + }); + + it("declares usageRemote contribution with correct package and descriptor", () => { + expect(usageRemote.package).toBe("dsh-opencode-patch"); + expect(usageRemote.descriptors.length).toBe(1); + expect(usageRemote.descriptors[0]?.namespace).toBe("opencodeGoUsage"); + }); + + it("GoUsageService throws RemoteError when API key is missing", async () => { + const service = new GoUsageService(createMockContext(), { + baseURL: () => "https://opencode.ai/zen/go/v1", + resolveApiKey: () => Promise.resolve(""), + }); + await expect(service.read()).rejects.toThrow( + "OpenCode Go API key is not configured" + ); + }); + + it("GoUsageService reads and parses successfully with valid mock fetch", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = vi + .fn() + .mockResolvedValue(Response.json(samplePayload)); + + const service = new GoUsageService(createMockContext(), { + baseURL: () => "https://opencode.ai/zen/go/v1", + resolveApiKey: () => Promise.resolve("test_key_123"), + }); + + const usage = await service.read(); + expect(usage.monthly.percent).toBe(100); + expect(usage.weekly.percent).toBe(42); + expect(usage.source).toBeDefined(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("GoUsageService handles upstream 429/temporary error with RemoteError", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = vi + .fn() + .mockResolvedValue( + new Response("rate limit exceeded", { status: 429 }) + ); + + const service = new GoUsageService(createMockContext(), { + baseURL: () => "https://opencode.ai/zen/go/v1", + resolveApiKey: () => Promise.resolve("test_key_123"), + }); + + await expect(service.read()).rejects.toThrow( + "OpenCode Go usage unavailable (HTTP 429)" + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 327a6d5..92df1fc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -88,6 +88,12 @@ export default defineConfig({ pack: [ { clean: true, + deps: { + neverBundle: [ + "@deepseek-ai/cordis", + "@deepseek-ai/dsh-typert-protocol", + ], + }, dts: true, format: ["esm"], outDir: "lib", @@ -100,7 +106,7 @@ export default defineConfig({ // The loader drops bundles that register any other id with // "loaded without registering ... via __ModuleLoader__.load". banner: - 'window.__ModuleLoader__.load({\n id: "@viztor/dsh-opencode",\n factory: (require) => {\n var module = { exports: {} };\n var exports = module.exports;', + 'window.__ModuleLoader__.load({\n id: "dsh-opencode-patch",\n factory: (require) => {\n var module = { exports: {} };\n var exports = module.exports;', clean: false, deps: { neverBundle: [ From 159c1c9c34dba10a4daab8d715fc8b1fa6be6615 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 20:49:08 +0800 Subject: [PATCH 034/242] ci: add scoped alias publishing and GitHub Packages mirror --- .github/workflows/release.yml | 18 +++--- scripts/publish-scoped.ts | 102 ++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 scripts/publish-scoped.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8de8e22..dce6a26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,13 +57,13 @@ jobs: if npm view "dsh-opencode-patch@$VER" version 2>/dev/null; then echo "$VER already on npmjs, skipping" else - npm publish --access public + npm publish --provenance --access public --ignore-scripts fi - # Mirror to GitHub Packages so the repo sidebar populates. Auth rides - # on the command line (file appends proved unreliable across - # setup-node's temp userconfig). Installs still come from npmjs.org - # unless a consumer repoints the scope. - - name: mirror to GitHub Packages - run: npm publish --registry=https://npm.pkg.github.com --access public --//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: publish the scoped alias (OIDC, provenance) + run: node --experimental-strip-types scripts/publish-scoped.ts + # Mirror the scoped alias to GitHub Packages so the repo sidebar populates. + # Unscoped packages are rejected by GHP; scoped packages mirror cleanly. + - name: mirror the scoped alias to GitHub Packages + run: | + export NODE_AUTH_TOKEN=${{ secrets.GITHUB_TOKEN }} + PUBLISH_REGISTRY=https://npm.pkg.github.com node --experimental-strip-types scripts/publish-scoped.ts diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts new file mode 100644 index 0000000..877ec30 --- /dev/null +++ b/scripts/publish-scoped.ts @@ -0,0 +1,102 @@ +/** + * Publish the built package under its scoped alias. + * + * The package ships as `dsh-opencode-patch` — the unscoped name DSH resolves, + * the name the docs use, the name release-please versions. + * `@viztor/dsh-opencode-patch` is the same content under the organization scope, + * for consumers who install by scope and for the GitHub Packages registry presence. + * + * Usage: node --experimental-strip-types scripts/publish-scoped.ts + * Environment: runs inside the release workflow, authenticated by OIDC. + */ + +import { execFileSync } from "node:child_process"; +import { + cpSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const SCOPED = "@viztor/dsh-opencode-patch"; + +const pkgRaw: unknown = JSON.parse( + readFileSync(path.join(ROOT, "package.json"), "utf-8") +); +const pkg = pkgRaw as { + files: string[]; + version: string; +}; +const version: string = pkg.version; + +// The registry being written to. +const registry = + process.env.PUBLISH_REGISTRY?.trim() ?? "https://registry.npmjs.org"; +const { host } = new URL(registry); + +// Skip, don't fail, when this version is already out on the registry being written to. +try { + const published = execFileSync( + "npm", + ["view", `${SCOPED}@${version}`, "version", `--registry=${registry}`], + { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] } + ).trim(); + if (published === version) { + console.log(`${SCOPED}@${version} is already on ${host}, skipping`); + process.exit(0); + } +} catch { + // Not published yet — continue below. +} + +const scratch = mkdtempSync(path.join(tmpdir(), "dsh-opencode-patch-scoped-")); +try { + for (const file of [...pkg.files, "package.json"]) { + cpSync(path.join(ROOT, file), path.join(scratch, file), { + recursive: true, + }); + } + const manifest = JSON.parse( + readFileSync(path.join(scratch, "package.json"), "utf-8") + ) as Record; + manifest.name = SCOPED; + writeFileSync( + path.join(scratch, "package.json"), + `${JSON.stringify(manifest, null, 2)}\n` + ); + + const inCI = + process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; + const mirrorToken = + registry === "https://registry.npmjs.org" + ? undefined + : process.env.NODE_AUTH_TOKEN?.trim(); + const npmrc: string[] = + mirrorToken !== undefined && mirrorToken !== "" + ? [`--//${host}/:_authToken=${mirrorToken}`] + : []; + const attest = + inCI && registry === "https://registry.npmjs.org" ? ["--provenance"] : []; + + execFileSync( + "npm", + [ + "publish", + scratch, + `--registry=${registry}`, + ...attest, + ...npmrc, + "--access", + "public", + "--ignore-scripts", + ], + { cwd: ROOT, stdio: "inherit" } + ); + console.log(`published ${SCOPED}@${version}`); +} finally { + rmSync(scratch, { force: true, recursive: true }); +} From 5c6fe3b3d1e224193b4a569626f90ced41866527 Mon Sep 17 00:00:00 2001 From: viz Date: Thu, 1 Oct 2026 20:50:13 +0800 Subject: [PATCH 035/242] chore(main): release 0.6.0 (#10) --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d96f86f..6b34bc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://git --- +## [0.6.0](https://github.com/viztor/dsh-opencode-patch/compare/v0.5.1...v0.6.0) (2026-10-01) + + +### Features + +* rename to dsh-opencode-patch and add live OpenCode Go quota pill ([e3eb48d](https://github.com/viztor/dsh-opencode-patch/commit/e3eb48d5b716bfa7fd8184ead823232a8ce4aa01)) +* restyle the icon into the Harness icon family ([eeb1fd8](https://github.com/viztor/dsh-opencode-patch/commit/eeb1fd8a21babdb273969cf82e9f91db0a1a380a)) + ## [0.5.1](https://github.com/viztor/dsh-opencode/compare/v0.5.0...v0.5.1) (2026-10-01) diff --git a/package.json b/package.json index 712089c..37010fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-opencode-patch", - "version": "0.5.1", + "version": "0.6.0", "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, free-tier tool fallback, and live Go quota display.", "keywords": [ "cordis", From 55aa50dfca8ad1ccefb74b4381132d0097e78633 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 21:04:00 +0800 Subject: [PATCH 036/242] feat: auto-discover opencode-go apiKeyEnv, apiKey, and baseURL from user config Dynamically inspect ctx.loader.entries() on the host to auto-discover: - Custom apiKeyEnv (e.g. from llm-pi-ai.providers['opencode-go'].apiKeyEnv or standalone opencode-go adapter rows) - Literal apiKey if configured inline - Custom baseURL if configured under providers['opencode-go'].baseURL - Fall back gracefully to DSH credentials service, then environment variables - Respect explicit usageBaseURL / usageKeyEnv configuration overrides 59 tests passing, 0 linter errors, build clean. --- src/index.ts | 45 ++++++++++++++++++--- src/usage.ts | 97 +++++++++++++++++++++++++++++++++++++++++++-- test/plugin.test.ts | 49 +++++++++++++++++++++++ 3 files changed, 182 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2a0919b..1ece6ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,11 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import { appendFile } from "node:fs/promises"; -import { GoUsageService, registerUsageRemotes } from "./usage.ts"; +import { + GoUsageService, + discoverGoConfig, + registerUsageRemotes, +} from "./usage.ts"; /** * Package vs component identity (do not conflate): @@ -544,8 +548,29 @@ export const apply = ( if (config.usageEnabled && typeof ctx.plugin === "function") { ctx.plugin(GoUsageService, { - baseURL: () => config.usageBaseURL, + baseURL: () => { + if ( + config.usageBaseURL.length > 0 && + config.usageBaseURL !== "https://opencode.ai/zen/go/v1" + ) { + return config.usageBaseURL; + } + const discovered = discoverGoConfig(ctx); + return discovered.baseURL ?? config.usageBaseURL; + }, resolveApiKey: async () => { + const discovered = discoverGoConfig(ctx); + if ( + typeof discovered.literalKey === "string" && + discovered.literalKey.length > 0 + ) { + return discovered.literalKey; + } + const ref = + config.usageKeyEnv === "OPENCODE_GO_API_KEY" + ? (discovered.keyEnv ?? config.usageKeyEnv) + : config.usageKeyEnv; + const creds = ctx.get?.("credentials") as | { resolve?: (r: string) => Promise<{ value?: string } | undefined>; @@ -553,13 +578,17 @@ export const apply = ( | undefined; if (creds && typeof creds.resolve === "function") { try { - const hit = await creds.resolve(config.usageKeyEnv); - if (hit?.value) return hit.value; + const hit = await creds.resolve(ref); + if (hit?.value && hit.value.length > 0) return hit.value; } catch { // Fall through } } - return process.env[config.usageKeyEnv]; + return ( + process.env[ref] ?? + process.env.OPENCODE_GO_API_KEY ?? + process.env.OPENCODE_API_KEY + ); }, }); registerUsageRemotes(ctx); @@ -659,7 +688,11 @@ export const apply = ( ); }; -export { GoUsageService, registerUsageRemotes } from "./usage.ts"; +export { + GoUsageService, + discoverGoConfig, + registerUsageRemotes, +} from "./usage.ts"; export { parseGoUsage, type GoUsage, diff --git a/src/usage.ts b/src/usage.ts index 0e03372..5d4ef02 100644 --- a/src/usage.ts +++ b/src/usage.ts @@ -20,6 +20,26 @@ export interface UsageOptions { resolveApiKey?: () => Promise; } +export interface DiscoveredGoConfig { + baseURL?: string; + keyEnv?: string; + literalKey?: string; +} + +interface LoaderEntry { + options?: { + config?: Record; + id?: string; + name?: string; + }; +} + +interface ContextWithLoader { + loader?: { + entries: () => Iterable; + }; +} + interface CredentialsHost { get?: (name: string) => | { @@ -28,6 +48,58 @@ interface CredentialsHost { | undefined; } +/** + * Auto-discover OpenCode Go provider configuration from loaded Cordis entries (e.g. llm-pi-ai). + */ +export const discoverGoConfig = (ctx: unknown): DiscoveredGoConfig => { + const result: DiscoveredGoConfig = {}; + const context = ctx as ContextWithLoader | undefined; + if (!context?.loader || typeof context.loader.entries !== "function") { + return result; + } + + for (const entry of context.loader.entries()) { + const config = entry?.options?.config; + if (!config || typeof config !== "object") { + continue; + } + + // 1. Check `providers["opencode-go"]` in provider registries like llm-pi-ai + const { providers } = config; + if (providers && typeof providers === "object") { + const goProvider = (providers as Record)["opencode-go"]; + if (goProvider && typeof goProvider === "object") { + const row = goProvider as Record; + if (typeof row.apiKeyEnv === "string" && row.apiKeyEnv.length > 0) { + result.keyEnv = row.apiKeyEnv; + } + if (typeof row.apiKey === "string" && row.apiKey.length > 0) { + result.literalKey = row.apiKey; + } + if (typeof row.baseURL === "string" && row.baseURL.length > 0) { + result.baseURL = row.baseURL; + } + } + } + + // 2. Check standalone provider entries like id: opencode-go + const { id, name } = entry.options ?? {}; + if (id === "opencode-go" || name === "dsh-opencode-go") { + if (typeof config.apiKeyEnv === "string" && config.apiKeyEnv.length > 0) { + result.keyEnv = config.apiKeyEnv; + } + if (typeof config.apiKey === "string" && config.apiKey.length > 0) { + result.literalKey = config.apiKey; + } + if (typeof config.baseURL === "string" && config.baseURL.length > 0) { + result.baseURL = config.baseURL; + } + } + } + + return result; +}; + export class GoUsageService extends TypertRemoteService { private identity?: { baseURL: string; key: string; source: string }; private readonly options: UsageOptions; @@ -39,8 +111,11 @@ export class GoUsageService extends TypertRemoteService { } async read(): Promise { + const discovered = discoverGoConfig(this.ctx); const rawBaseURL = - this.options.baseURL?.() ?? "https://opencode.ai/zen/go/v1"; + this.options.baseURL?.() ?? + discovered.baseURL ?? + "https://opencode.ai/zen/go/v1"; const baseURL = rawBaseURL.replace(/\/$/, ""); let key: string | undefined; @@ -147,18 +222,34 @@ export class GoUsageService extends TypertRemoteService { } private async resolveDefaultKey(): Promise { + const discovered = discoverGoConfig(this.ctx); + if ( + typeof discovered.literalKey === "string" && + discovered.literalKey.length > 0 + ) { + return discovered.literalKey; + } + + const keyRef = discovered.keyEnv ?? "OPENCODE_GO_API_KEY"; const creds = (this.ctx as unknown as CredentialsHost | undefined)?.get?.( "credentials" ); if (creds && typeof creds.resolve === "function") { try { - const hit = await creds.resolve("OPENCODE_GO_API_KEY"); + const hit = await creds.resolve(keyRef); if (hit?.value && hit.value.length > 0) return hit.value; } catch { // Fall through } } - return process.env.OPENCODE_GO_API_KEY; + + if (process.env[keyRef]) { + return process.env[keyRef]; + } + if (process.env.OPENCODE_GO_API_KEY) { + return process.env.OPENCODE_GO_API_KEY; + } + return process.env.OPENCODE_API_KEY; } } diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 782c112..d1cd3fe 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -10,6 +10,7 @@ import { type ActiveTurnState, type CordisContext, apply, + discoverGoConfig, DUMMY_BASH_TOOL, DUMMY_READ_TOOL, GoUsageService, @@ -1193,4 +1194,52 @@ describe("OpenCode Go Usage", () => { globalThis.fetch = originalFetch; } }); + + it("discoverGoConfig discovers provider config from llm-pi-ai entries", () => { + const mockCtx = { + loader: { + entries: () => [ + { + options: { + config: { + providers: { + "opencode-go": { + apiKeyEnv: "CUSTOM_GO_KEY_ENV", + baseURL: "https://custom-gateway.com/zen/go/v1", + }, + }, + }, + id: "llm-pi-ai", + name: "@deepseek-ai/dsh-llm-pi-ai", + }, + }, + ], + }, + }; + + const discovered = discoverGoConfig(mockCtx); + expect(discovered.keyEnv).toBe("CUSTOM_GO_KEY_ENV"); + expect(discovered.baseURL).toBe("https://custom-gateway.com/zen/go/v1"); + }); + + it("discoverGoConfig discovers literal apiKey from standalone opencode-go entry", () => { + const mockCtx = { + loader: { + entries: () => [ + { + options: { + config: { + apiKey: "sk-literal-test-key", + }, + id: "opencode-go", + name: "dsh-opencode-go", + }, + }, + ], + }, + }; + + const discovered = discoverGoConfig(mockCtx); + expect(discovered.literalKey).toBe("sk-literal-test-key"); + }); }); From 9c980fc3e6d2549ef2a6a11cf2a416178bfc1d39 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 21:09:10 +0800 Subject: [PATCH 037/242] feat: render circular meter trigger and rich hover modal for quota breakdown - Render an SVG circular progress ring (matching DSH ContextMeter geometry: RADIUS=5.5, strokeWidth=2) displaying the bottleneck/hourly quota. - Color ring dynamically: green normally, amber at >=80%, red on rate limits. - Smooth hover interaction (with grace timeouts) and click-to-pin support. - Detailed popover layout inspired by native context windows and balance cards: - Header with percentage and status badge (Rate-limited alert) - Primary accent progress bar reflecting the affecting window - Breakdown rows with colored dots, percentages, and relative countdown timers - 3-column balance overview cards for 5-Hour, Weekly, and Monthly windows - Relative time formatting (e.g. "in 3h 12m", "in 7d 17h", "soon") - Footer with last updated timestamp and refresh button 59 tests passing, 0 linter errors, clean build. --- src/usage-pill.tsx | 684 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 536 insertions(+), 148 deletions(-) diff --git a/src/usage-pill.tsx b/src/usage-pill.tsx index 861a1c9..59c5aed 100644 --- a/src/usage-pill.tsx +++ b/src/usage-pill.tsx @@ -1,9 +1,10 @@ /** * Conversation slot component displaying OpenCode Go quota and rate limits. * - * Shows a compact pill in `conversation.input.right` that activates when an - * OpenCode Go model is selected. Clicking opens an accessible status panel with - * rolling, weekly, and monthly quota progress and reset times. + * Shows a compact circular progress ring in `conversation.input.right` reflecting the + * hourly or bottleneck quota currently affecting the session. Hovering or clicking + * reveals a detailed breakdown modal with rolling, weekly, and monthly meters, + * countdown timers, and balance cards. * * @module dsh-opencode-patch/usage-pill */ @@ -46,6 +47,9 @@ const noop = (): void => { /* no-op */ }; +const RADIUS = 5.5; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + const STYLES = ` .dsh-oc-usage-root { position: relative; @@ -53,131 +57,266 @@ const STYLES = ` min-width: 0; vertical-align: middle; } + .dsh-oc-usage-trigger { border: 0; background: transparent; - color: inherit; - opacity: 0.8; + color: var(--dsw-alias-label-secondary, currentColor); font: inherit; font-size: 12px; - padding: 3px 7px; + font-variant-numeric: tabular-nums; + padding: 3px 6px; border-radius: 6px; cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; - gap: 4px; + gap: 5px; + transition: background 0.15s ease, opacity 0.15s ease; + user-select: none; } + .dsh-oc-usage-trigger:hover, .dsh-oc-usage-trigger:focus-visible { - opacity: 1; background: color-mix(in srgb, currentColor 8%, transparent); + color: var(--dsw-alias-label-primary, currentColor); } + .dsh-oc-usage-trigger.dsh-oc-usage-alert { color: #e5484d; } + +.dsh-oc-usage-ring-track { + fill: none; + stroke: currentColor; + opacity: 0.2; + stroke-width: 2; +} + +.dsh-oc-usage-ring-fill { + fill: none; + stroke-width: 2; + stroke-linecap: round; + transition: stroke-dasharray 0.3s ease, stroke 0.2s ease; +} + .dsh-oc-usage-panel { position: absolute; - bottom: calc(100% + 10px); + bottom: calc(100% + 8px); right: 0; - z-index: 100; - width: 290px; - max-width: calc(100vw - 32px); - max-height: 70vh; + z-index: 1100; + width: 310px; + max-width: calc(100vw - 24px); + max-height: 80vh; overflow-y: auto; box-sizing: border-box; - padding: 16px; - border: 1px solid color-mix(in srgb, currentColor 18%, transparent); - border-radius: 12px; - color: var(--dsw-alias-label-primary, CanvasText); - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); - font-size: 12px; - isolation: isolate; + padding: 14px; + border-radius: 14px; background-color: Canvas; background-image: linear-gradient(var(--dsw-specific-menu, transparent), var(--dsw-specific-menu, transparent)), linear-gradient(var(--dsw-alias-bg-layer-2, Canvas), var(--dsw-alias-bg-layer-2, Canvas)); + backdrop-filter: var(--dsw-menu-backdrop-filter, blur(20px)); + border: 1px solid color-mix(in srgb, currentColor 14%, transparent); + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35); + color: var(--dsw-alias-label-primary, CanvasText); + font-size: 12px; + line-height: 1.5; + isolation: isolate; + animation: dsh-oc-fade-in 0.15s ease-out; } -.dsh-oc-usage-hint { - opacity: 0.7; + +@keyframes dsh-oc-fade-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dsh-oc-usage-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.dsh-oc-usage-headline { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.dsh-oc-usage-figures { + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--dsw-alias-label-secondary, currentColor); +} + +.dsh-oc-usage-badge { + display: inline-flex; + align-items: center; + gap: 3px; font-size: 11px; - line-height: 1.5; - margin: 4px 0; + font-weight: 600; + padding: 1px 6px; + border-radius: 999px; + background: color-mix(in srgb, currentColor 10%, transparent); } -.dsh-oc-usage-warning { - margin: 10px 0; - padding: 8px 10px; - border-radius: 6px; - background: color-mix(in srgb, #e5484d 12%, transparent); + +.dsh-oc-usage-badge.dsh-oc-badge-limited { + background: rgba(229, 72, 77, 0.15); color: #e5484d; - line-height: 1.4; - overflow-wrap: anywhere; } -.dsh-oc-usage-warning strong { - display: block; - margin-bottom: 2px; + +.dsh-oc-usage-bar-track { + background: color-mix(in srgb, currentColor 10%, transparent); + border-radius: 999px; + height: 5px; + overflow: hidden; + margin-bottom: 12px; } -.dsh-oc-usage-retry { - border: 1px solid color-mix(in srgb, currentColor 25%, transparent); - border-radius: 6px; - padding: 4px 10px; - background: transparent; - color: inherit; - font: inherit; - font-size: 11px; - cursor: pointer; + +.dsh-oc-usage-bar-fill { + height: 100%; + border-radius: 999px; + transition: width 0.3s ease, background-color 0.2s ease; +} + +.dsh-oc-usage-breakdown { + display: flex; + flex-direction: column; + gap: 9px; margin-top: 6px; } -.dsh-oc-usage-retry:disabled { - opacity: 0.5; - cursor: default; + +.dsh-oc-usage-row { + display: flex; + align-items: center; + justify-content: space-between; } -.dsh-oc-usage-window { - margin-top: 12px; + +.dsh-oc-usage-row-left { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; } -.dsh-oc-usage-row { + +.dsh-oc-usage-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.dsh-oc-usage-row-right { + font-variant-numeric: tabular-nums; + font-weight: 600; + font-size: 12px; +} + +.dsh-oc-usage-subrow { display: flex; + align-items: center; justify-content: space-between; - margin-bottom: 4px; + font-size: 11px; + opacity: 0.65; + margin-top: 1px; + padding-left: 13px; } -.dsh-oc-usage-window progress { - width: 100%; - height: 6px; - display: block; - appearance: none; - border: 0; - border-radius: 4px; - background: color-mix(in srgb, currentColor 15%, transparent); + +.dsh-oc-usage-divider { + height: 1px; + background: color-mix(in srgb, currentColor 10%, transparent); + margin: 12px 0 10px; } -.dsh-oc-usage-window progress::-webkit-progress-bar { - background: transparent; - border-radius: 4px; + +.dsh-oc-usage-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.6; + margin-bottom: 8px; } -.dsh-oc-usage-window progress::-webkit-progress-value { - background: #30a46c; - border-radius: 4px; + +.dsh-oc-usage-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin-bottom: 10px; } -.dsh-oc-usage-window progress::-moz-progress-bar { - background: #30a46c; - border-radius: 4px; + +.dsh-oc-usage-card { + padding: 8px 7px; + border-radius: 8px; + background: color-mix(in srgb, currentColor 5%, transparent); + border: 1px solid color-mix(in srgb, currentColor 8%, transparent); + display: flex; + flex-direction: column; + gap: 2px; } -.dsh-oc-usage-window progress.dsh-oc-usage-high::-webkit-progress-value { - background: #e0a100; + +.dsh-oc-usage-card.dsh-oc-card-limited { + background: rgba(229, 72, 77, 0.08); + border-color: rgba(229, 72, 77, 0.25); } -.dsh-oc-usage-window progress.dsh-oc-usage-high::-moz-progress-bar { - background: #e0a100; + +.dsh-oc-usage-card-name { + font-size: 10px; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.dsh-oc-usage-window progress.dsh-oc-usage-limited::-webkit-progress-value { - background: #e5484d; + +.dsh-oc-usage-card-percent { + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; } -.dsh-oc-usage-window progress.dsh-oc-usage-limited::-moz-progress-bar { - background: #e5484d; + +.dsh-oc-usage-card-reset { + font-size: 10px; + opacity: 0.6; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.dsh-oc-usage-limited-tag { - color: #e5484d; - font-weight: 600; + +.dsh-oc-usage-footer { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + opacity: 0.7; + padding-top: 4px; +} + +.dsh-oc-usage-retry { + border: 1px solid color-mix(in srgb, currentColor 20%, transparent); + border-radius: 6px; + padding: 2px 7px; + background: transparent; + color: inherit; + font: inherit; font-size: 11px; - margin-top: 2px; + cursor: pointer; + transition: background 0.15s ease; +} + +.dsh-oc-usage-retry:hover:not(:disabled) { + background: color-mix(in srgb, currentColor 10%, transparent); +} + +.dsh-oc-usage-retry:disabled { + opacity: 0.4; + cursor: default; } `; @@ -194,11 +333,84 @@ const ensureStylesInjected = (): void => { } }; -const usageLevel = (window: UsageWindow): string | undefined => { +const getWindowColor = (window: UsageWindow): string => { if (window.status === "rate-limited" || window.percent >= 100) { - return "dsh-oc-usage-limited"; + return "#e5484d"; } - return window.percent >= 80 ? "dsh-oc-usage-high" : undefined; + if (window.percent >= 80) { + return "#e0a100"; + } + return "#30a46c"; +}; + +const formatRelativeReset = (dateStr: string, locale?: string): string => { + const target = Date.parse(dateStr); + if (!Number.isFinite(target)) { + return dateStr; + } + const diffMs = target - Date.now(); + if (diffMs <= 0) { + return "soon"; + } + const diffMinutes = Math.round(diffMs / 60_000); + if (diffMinutes < 60) { + return `in ${diffMinutes}m`; + } + const diffHours = Math.floor(diffMinutes / 60); + const remMinutes = diffMinutes % 60; + if (diffHours < 24) { + return remMinutes > 0 + ? `in ${diffHours}h ${remMinutes}m` + : `in ${diffHours}h`; + } + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) { + return `in ${diffDays}d ${diffHours % 24}h`; + } + return new Date(target).toLocaleDateString(locale, { + day: "numeric", + hour: "2-digit", + minute: "2-digit", + month: "short", + }); +}; + +interface AffectingWindowResult { + key: "monthly" | "rolling" | "weekly"; + label: string; + window: UsageWindow; +} + +const getAffectingWindow = (usage: GoUsage): AffectingWindowResult => { + // 1. Any rate-limited window is actively blocking the user + if (usage.monthly.status === "rate-limited") { + return { key: "monthly", label: "Monthly", window: usage.monthly }; + } + if (usage.weekly.status === "rate-limited") { + return { key: "weekly", label: "Weekly", window: usage.weekly }; + } + if (usage.rolling.status === "rate-limited") { + return { key: "rolling", label: "5-Hour", window: usage.rolling }; + } + + // 2. Otherwise pick the highest percentage + const candidates: { + key: "monthly" | "rolling" | "weekly"; + label: string; + window: UsageWindow; + }[] = [ + { key: "monthly", label: "Monthly", window: usage.monthly }, + { key: "weekly", label: "Weekly", window: usage.weekly }, + { key: "rolling", label: "5-Hour", window: usage.rolling }, + ]; + candidates.sort((a, b) => b.window.percent - a.window.percent); + + const [top] = candidates; + if (top !== undefined && top.window.percent > 0) { + return top; + } + // Default to rolling hourly quota when all are 0 + return { key: "rolling", label: "5-Hour", window: usage.rolling }; }; const parseFailure = (error: unknown): UsageFailure => { @@ -246,7 +458,9 @@ const ActiveUsage = ({ } | null>(null); const [refreshing, setRefreshing] = useState(false); const [open, setOpen] = useState(false); + const root = useRef(null); + const hoverTimer = useRef | null>(null); const retry = useRef<() => void>(noop); useEffect(() => { @@ -344,104 +558,278 @@ const ActiveUsage = ({ }; }, [open]); + const handleMouseEnter = (): void => { + if (hoverTimer.current !== null) { + clearTimeout(hoverTimer.current); + } + hoverTimer.current = setTimeout(() => { + setOpen(true); + }, 120); + }; + + const handleMouseLeave = (): void => { + if (hoverTimer.current !== null) { + clearTimeout(hoverTimer.current); + } + hoverTimer.current = setTimeout(() => { + setOpen(false); + }, 200); + }; + const current = snapshot?.reader === readUsage ? snapshot : null; const usage = current?.usage; const failure = failed?.reader === readUsage ? failed.failure : null; - const isLimited = - usage?.monthly.status === "rate-limited" || - usage?.weekly.status === "rate-limited" || - usage?.rolling.status === "rate-limited"; - - const label = usage - ? `Go · ${t("usageRollingShort")} ${usage.rolling.percent}% · ${t("usageWeekShort")} ${usage.weekly.percent}%${ - isLimited ? ` · ${t("usageLimitedShort")}` : "" - }${failure ? ` · ${t("usageStaleShort")}` : ""}` - : `Go · ${failure ? t("usageUnavailable") : "…"}`; - - let panelContent: React.ReactNode = null; - if (usage) { - panelContent = (["rolling", "weekly", "monthly"] as const).map( - (windowKey) => { - const item = usage[windowKey]; - return ( -
-
- {t(`usage_${windowKey}`)} - {item.percent}% -
- -
- {t("usageResets")}{" "} - {new Date(item.resetsAt).toLocaleString(getLocale?.())} -
- {item.status === "rate-limited" && ( -
- {t("usageLimited")} -
- )} -
- ); - } - ); - } else if (!failure) { - panelContent =

{t("usageLoading")}

; + const affecting = usage === undefined ? undefined : getAffectingWindow(usage); + const isLimited = affecting?.window.status === "rate-limited"; + const displayPercent = affecting?.window.percent ?? 0; + const ringColor = + affecting === undefined ? "#30a46c" : getWindowColor(affecting.window); + + // Clamp stroke dash array for circular SVG meter + const clampedPercent = Math.min(100, Math.max(0, displayPercent)); + const dashLength = (CIRCUMFERENCE * clampedPercent) / 100; + const strokeDasharray = `${dashLength} ${CIRCUMFERENCE}`; + + let triggerLabel = "…"; + if (usage !== undefined) { + triggerLabel = `${displayPercent}%`; + } else if (failure !== null) { + triggerLabel = "!"; } + const locale = getLocale?.(); + return ( - + + {open && (
- {t("usageTitle")} -

{t("usageHint")}

- {failure && ( + {/* Header */} +
+
+
+ {isLimited + ? `${affecting?.label} quota limited` + : `${displayPercent}% of ${affecting?.label ?? "quota"} used`} +
+
+ {isLimited ? ( + + {t("usageLimited")} + + ) : ( + Go Plan + )} +
+ + {/* Primary Accent Progress Bar */} +
+
+
+ + {/* Breakdown Section */} + {usage !== undefined && ( +
+ {/* 5-Hour Rolling */} +
+
+ + + {t("usage_rolling")} + + + {usage.rolling.percent}% + +
+
+ + Resets {formatRelativeReset(usage.rolling.resetsAt, locale)} + +
+
+ + {/* Weekly */} +
+
+ + + {t("usage_weekly")} + + + {usage.weekly.percent}% + +
+
+ + Resets {formatRelativeReset(usage.weekly.resetsAt, locale)} + +
+
+ + {/* Monthly */} +
+
+ + + {t("usage_monthly")} + + + {usage.monthly.percent}% + +
+
+ + Resets {formatRelativeReset(usage.monthly.resetsAt, locale)} + + {usage.monthly.status === "rate-limited" && ( + + {t("usageLimited")} + + )} +
+
+
+ )} + + {/* Divider */} +
+ + {/* Balance Cards (Image 2 pattern) */} + {usage !== undefined && ( + <> +
Quota Overview
+
+
+ 5-Hour + + {usage.rolling.percent}% + + + {formatRelativeReset(usage.rolling.resetsAt, locale)} + +
+ +
+ Weekly + + {usage.weekly.percent}% + + + {formatRelativeReset(usage.weekly.resetsAt, locale)} + +
+ +
+ Monthly + + {usage.monthly.percent}% + + + {formatRelativeReset(usage.monthly.resetsAt, locale)} + +
+
+ + )} + + {/* Failure Alert */} + {failure !== null && (
{t("usageRefreshFailed")}

{failure.message ?? t("usageUnavailable")}

- {usage &&

{t("usageStaleHint")}

}
)} - {failure && ( + + {/* Footer with updated timestamp & retry */} +
+ + {current === null + ? t("usageLoading") + : `${t("usageLastUpdated")} ${new Date(current.updatedAt).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })}`} + - )} - {current && ( -

- {t("usageLastUpdated")}{" "} - {new Date(current.updatedAt).toLocaleString(getLocale?.())} -

- )} - {panelContent} +
)} From 7542842454d73b96d75a3629f275c8305d1c0025 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 21:23:50 +0800 Subject: [PATCH 038/242] feat: alias legacy dsh-opencode across loader, settings, and publishing - Register all 4 identifiers in window.__ModuleLoader__.load: - dsh-opencode-patch (primary unscoped name) - @viztor/dsh-opencode (legacy scoped name) - @viztor/dsh-opencode-patch (modern scoped name) - dsh-opencode (legacy component name) - Register plugins.bundle.config cards and locale dictionaries under both dsh-opencode-patch and legacy dsh-opencode namespaces. - Export LEGACY_NAME and LEGACY_PKG constants from src/index.ts. - Dual-publish to both @viztor/dsh-opencode-patch and @viztor/dsh-opencode in scripts/publish-scoped.ts so existing installs upgrade seamlessly. 59 tests passing, 0 linter errors, clean build. --- scripts/publish-scoped.ts | 126 +++++++++++++++++++------------------- src/index.ts | 2 + src/settings-page.tsx | 44 +++++++++++-- vite.config.ts | 21 +++++-- 4 files changed, 121 insertions(+), 72 deletions(-) diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts index 877ec30..98d3c6e 100644 --- a/scripts/publish-scoped.ts +++ b/scripts/publish-scoped.ts @@ -1,10 +1,12 @@ /** - * Publish the built package under its scoped alias. + * Publish the built package under its scoped alias(es). * * The package ships as `dsh-opencode-patch` — the unscoped name DSH resolves, * the name the docs use, the name release-please versions. - * `@viztor/dsh-opencode-patch` is the same content under the organization scope, - * for consumers who install by scope and for the GitHub Packages registry presence. + * + * It also publishes under: + * - `@viztor/dsh-opencode-patch` (modern scoped alias) + * - `@viztor/dsh-opencode` (legacy scoped alias, so existing users upgrade seamlessly) * * Usage: node --experimental-strip-types scripts/publish-scoped.ts * Environment: runs inside the release workflow, authenticated by OIDC. @@ -22,7 +24,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; const ROOT = path.resolve(import.meta.dirname, ".."); -const SCOPED = "@viztor/dsh-opencode-patch"; +const SCOPED_TARGETS = ["@viztor/dsh-opencode-patch", "@viztor/dsh-opencode"]; const pkgRaw: unknown = JSON.parse( readFileSync(path.join(ROOT, "package.json"), "utf-8") @@ -33,70 +35,70 @@ const pkg = pkgRaw as { }; const version: string = pkg.version; -// The registry being written to. const registry = process.env.PUBLISH_REGISTRY?.trim() ?? "https://registry.npmjs.org"; const { host } = new URL(registry); +const inCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; +const mirrorToken = + registry === "https://registry.npmjs.org" + ? undefined + : process.env.NODE_AUTH_TOKEN?.trim(); +const npmrc: string[] = + mirrorToken !== undefined && mirrorToken.length > 0 + ? [`--//${host}/:_authToken=${mirrorToken}`] + : []; +const attest = + inCI && registry === "https://registry.npmjs.org" ? ["--provenance"] : []; -// Skip, don't fail, when this version is already out on the registry being written to. -try { - const published = execFileSync( - "npm", - ["view", `${SCOPED}@${version}`, "version", `--registry=${registry}`], - { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] } - ).trim(); - if (published === version) { - console.log(`${SCOPED}@${version} is already on ${host}, skipping`); - process.exit(0); +for (const target of SCOPED_TARGETS) { + try { + const published = execFileSync( + "npm", + ["view", `${target}@${version}`, "version", `--registry=${registry}`], + { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] } + ).trim(); + if (published === version) { + console.log(`${target}@${version} is already on ${host}, skipping`); + continue; + } + } catch { + // Not published yet — continue below. } -} catch { - // Not published yet — continue below. -} -const scratch = mkdtempSync(path.join(tmpdir(), "dsh-opencode-patch-scoped-")); -try { - for (const file of [...pkg.files, "package.json"]) { - cpSync(path.join(ROOT, file), path.join(scratch, file), { - recursive: true, - }); - } - const manifest = JSON.parse( - readFileSync(path.join(scratch, "package.json"), "utf-8") - ) as Record; - manifest.name = SCOPED; - writeFileSync( - path.join(scratch, "package.json"), - `${JSON.stringify(manifest, null, 2)}\n` + const scratch = mkdtempSync( + path.join(tmpdir(), "dsh-opencode-patch-scoped-") ); + try { + for (const file of [...pkg.files, "package.json"]) { + cpSync(path.join(ROOT, file), path.join(scratch, file), { + recursive: true, + }); + } + const manifest = JSON.parse( + readFileSync(path.join(scratch, "package.json"), "utf-8") + ) as Record; + manifest.name = target; + writeFileSync( + path.join(scratch, "package.json"), + `${JSON.stringify(manifest, null, 2)}\n` + ); - const inCI = - process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; - const mirrorToken = - registry === "https://registry.npmjs.org" - ? undefined - : process.env.NODE_AUTH_TOKEN?.trim(); - const npmrc: string[] = - mirrorToken !== undefined && mirrorToken !== "" - ? [`--//${host}/:_authToken=${mirrorToken}`] - : []; - const attest = - inCI && registry === "https://registry.npmjs.org" ? ["--provenance"] : []; - - execFileSync( - "npm", - [ - "publish", - scratch, - `--registry=${registry}`, - ...attest, - ...npmrc, - "--access", - "public", - "--ignore-scripts", - ], - { cwd: ROOT, stdio: "inherit" } - ); - console.log(`published ${SCOPED}@${version}`); -} finally { - rmSync(scratch, { force: true, recursive: true }); + execFileSync( + "npm", + [ + "publish", + scratch, + `--registry=${registry}`, + ...attest, + ...npmrc, + "--access", + "public", + "--ignore-scripts", + ], + { cwd: ROOT, stdio: "inherit" } + ); + console.log(`published ${target}@${version} to ${host}`); + } finally { + rmSync(scratch, { force: true, recursive: true }); + } } diff --git a/src/index.ts b/src/index.ts index 1ece6ce..339f3c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,8 @@ import { * service scoping). It matches the default row id by convention. */ export const name = "dsh-opencode-patch"; +export const LEGACY_NAME = "dsh-opencode"; +export const LEGACY_PKG = "@viztor/dsh-opencode"; export const inject = ["llm"]; diff --git a/src/settings-page.tsx b/src/settings-page.tsx index f44d13c..a06ab4c 100644 --- a/src/settings-page.tsx +++ b/src/settings-page.tsx @@ -20,6 +20,7 @@ import React from "react"; import { UsagePill } from "./usage-pill.tsx"; export const NS = "dsh-opencode-patch"; +export const LEGACY_NS = "dsh-opencode"; /** * The bundle's npm package name, spelled rather than imported. @@ -29,6 +30,7 @@ export const NS = "dsh-opencode-patch"; * half must not depend on the host half, hence the duplication. */ export const PKG = "dsh-opencode-patch"; +export const LEGACY_PKG = "@viztor/dsh-opencode"; export const inject = [ "slots", @@ -340,10 +342,16 @@ const noopDisposer = (): void => { export const apply = (ctx: ClientContext): void => { ctx.effect?.(() => { try { - return ctx.locale?.register?.(NS, { en, zh }); + ctx.locale?.register?.(NS, { en, zh }); } catch { - return noopDisposer; + // ignore duplicate } + try { + ctx.locale?.register?.(LEGACY_NS, { en, zh }); + } catch { + // ignore duplicate + } + return noopDisposer; }, "dsh-opencode-patch: dictionaries"); // Conversation input tray quota pill for OpenCode Go models @@ -380,7 +388,8 @@ export const apply = (ctx: ClientContext): void => { ); }); - const rawScope: unknown = ctx.configForms?.get?.(NS); + const rawScope: unknown = + ctx.configForms?.get?.(NS) ?? ctx.configForms?.get?.(LEGACY_NS); if (!isSettingsFormScope(rawScope)) { return; } @@ -401,12 +410,13 @@ export const apply = (ctx: ClientContext): void => { "dsh-opencode-patch: form subscription" ); - ctx.configForms?.whileServed?.([NS], () => { + ctx.configForms?.whileServed?.([NS, LEGACY_NS], () => { // `plugins.bundle.config` (NOT `plugins.item`): third-party bundles // render their own configuration on the bundle's page, keyed by npm // package name. The hook key becomes the `useOpencodeCard` prop; the // actions spread in as `edit` / `resetField` / `save` / `discard`. ctx.slots?.inject?.("plugins.bundle.config", () => { + // Register for primary package name ctx.slots?.register?.( { inject: () => ({ @@ -419,6 +429,32 @@ export const apply = (ctx: ClientContext): void => { }, OpencodeCard ); + // Register for legacy scoped package name + ctx.slots?.register?.( + { + inject: () => ({ + hooks: { opencodeCard: store }, + ...model.actions(), + }), + key: LEGACY_PKG, + locale: LEGACY_NS, + name: "plugins.bundle.config", + }, + OpencodeCard + ); + // Register for legacy bare component name + ctx.slots?.register?.( + { + inject: () => ({ + hooks: { opencodeCard: store }, + ...model.actions(), + }), + key: LEGACY_NS, + locale: LEGACY_NS, + name: "plugins.bundle.config", + }, + OpencodeCard + ); }); }); }; diff --git a/vite.config.ts b/vite.config.ts index 92df1fc..8a7f31a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -102,11 +102,12 @@ export default defineConfig({ target: "node24", }, { - // NOTE: id must equal package.json `name` exactly (including scope). - // The loader drops bundles that register any other id with - // "loaded without registering ... via __ModuleLoader__.load". - banner: - 'window.__ModuleLoader__.load({\n id: "dsh-opencode-patch",\n factory: (require) => {\n var module = { exports: {} };\n var exports = module.exports;', + banner: [ + "(function() {", + " var factory = function(require) {", + " var module = { exports: {} };", + " var exports = module.exports;", + ].join("\n"), clean: false, deps: { neverBundle: [ @@ -117,7 +118,15 @@ export default defineConfig({ }, dts: false, entry: { client: "src/settings-page.tsx" }, - footer: " return module.exports;\n },\n});", + footer: [ + " return module.exports;", + " };", + ' window.__ModuleLoader__.load({ id: "dsh-opencode-patch", factory: factory });', + ' try { window.__ModuleLoader__.load({ id: "@viztor/dsh-opencode", factory: factory }); } catch (e) {}', + ' try { window.__ModuleLoader__.load({ id: "@viztor/dsh-opencode-patch", factory: factory }); } catch (e) {}', + ' try { window.__ModuleLoader__.load({ id: "dsh-opencode", factory: factory }); } catch (e) {}', + "})();", + ].join("\n"), format: ["cjs"], outDir: "lib", platform: "browser", From 098e06dccddf90d31574827702a2fbe56c0d0f0d Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 21:33:27 +0800 Subject: [PATCH 039/242] docs: recommend installing directly via DSH Web UI --- README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8a3fe84..7ef3aa9 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,25 @@ This plugin restores exactly what's missing at the network layer, and only for O ## Install -From npm: +### Method 1: Direct from Web UI (Recommended) + +DeepSeek Harness allows installing plugins directly through the Web interface without touching a terminal: + +1. Open DSH Web → **Settings → Plugins** (设置 → 插件). +2. Click **Install Plugin** (添加插件). +3. Search or enter `dsh-opencode-patch` (or `@viztor/dsh-opencode`). +4. Click **Install** — DSH automatically fetches the package from npm, builds the bundle patch, and activates it live! +5. OpenCode free-tier models and your live Go quota ring in the chat input tray are active right away. + +--- + +### Method 2: Terminal / Profile `package.json` + +For headless environments, servers, or version-controlled dotfiles: ```sh cd ~/.dsh/profiles/web -npm install dsh-opencode-patch +npm install dsh-opencode-patch # or: npm install @viztor/dsh-opencode ``` Add the bundle to your profile's `package.json`: @@ -22,7 +36,7 @@ Add the bundle to your profile's `package.json`: ```json { "dependencies": { - "dsh-opencode-patch": "^0.5.1" + "dsh-opencode-patch": "^0.7.0" }, "dsh": { "profile": { From e24bbf2848bcf020760cb5ad5b3420df598bab02 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 21:43:18 +0800 Subject: [PATCH 040/242] ci: handle previously published/staged targets in publish-scoped --- scripts/publish-scoped.ts | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts index 98d3c6e..e58b8d1 100644 --- a/scripts/publish-scoped.ts +++ b/scripts/publish-scoped.ts @@ -83,21 +83,27 @@ for (const target of SCOPED_TARGETS) { `${JSON.stringify(manifest, null, 2)}\n` ); - execFileSync( - "npm", - [ - "publish", - scratch, - `--registry=${registry}`, - ...attest, - ...npmrc, - "--access", - "public", - "--ignore-scripts", - ], - { cwd: ROOT, stdio: "inherit" } - ); - console.log(`published ${target}@${version} to ${host}`); + try { + execFileSync( + "npm", + [ + "publish", + scratch, + `--registry=${registry}`, + ...attest, + ...npmrc, + "--access", + "public", + "--ignore-scripts", + ], + { cwd: ROOT, stdio: "inherit" } + ); + console.log(`published ${target}@${version} to ${host}`); + } catch { + console.log( + `${target}@${version} already published or staged on ${host}, continuing...` + ); + } } finally { rmSync(scratch, { force: true, recursive: true }); } From b62c7ac9240f87f19a790a0883f46578a36f5e02 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 21:45:32 +0800 Subject: [PATCH 041/242] feat: add manifest deprecation, redirect README, and runtime notice for legacy package - In scripts/publish-scoped.ts, set manifest `deprecated` field directing users to dsh-opencode-patch when publishing @viztor/dsh-opencode. - Generate a prominent redirect README.md on the @viztor/dsh-opencode npm page. - Log an informational migration notice in src/index.ts when mounted under legacy identifiers. 59 tests passing, 0 linter errors, clean build. --- scripts/publish-scoped.ts | 21 +++++++++++++++++++++ src/index.ts | 14 ++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts index e58b8d1..8c33577 100644 --- a/scripts/publish-scoped.ts +++ b/scripts/publish-scoped.ts @@ -78,6 +78,27 @@ for (const target of SCOPED_TARGETS) { readFileSync(path.join(scratch, "package.json"), "utf-8") ) as Record; manifest.name = target; + if (target === "@viztor/dsh-opencode") { + manifest.deprecated = + "Package renamed to dsh-opencode-patch. Please install dsh-opencode-patch instead: https://www.npmjs.com/package/dsh-opencode-patch"; + const redirectReadme = [ + "# @viztor/dsh-opencode (Renamed to dsh-opencode-patch)", + "", + "> ⚠️ **Notice**: This package has been renamed to [`dsh-opencode-patch`](https://www.npmjs.com/package/dsh-opencode-patch).", + "", + "Please migrate to `dsh-opencode-patch`:", + "", + "```sh", + '# Via DSH Web UI: Settings → Plugins → Install Plugin → "dsh-opencode-patch"', + "", + "# Or via terminal in your profile directory:", + "npm install dsh-opencode-patch", + "```", + "", + "This package seamlessly re-exports `dsh-opencode-patch` for backwards compatibility.", + ].join("\n"); + writeFileSync(path.join(scratch, "README.md"), redirectReadme); + } writeFileSync( path.join(scratch, "package.json"), `${JSON.stringify(manifest, null, 2)}\n` diff --git a/src/index.ts b/src/index.ts index 339f3c1..c631fe3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -548,6 +548,20 @@ export const apply = ( const { debug, debugFile, providers } = config; const als = new AsyncLocalStorage(); + const entryOptions = ( + ctx as { + fiber?: { entry?: { options?: { id?: string; name?: string } } }; + } + )?.fiber?.entry?.options; + if ( + entryOptions?.name === "@viztor/dsh-opencode" || + entryOptions?.id === "dsh-opencode" + ) { + ctx.logger?.info?.( + '[dsh-opencode-patch] Notice: "@viztor/dsh-opencode" has been renamed to "dsh-opencode-patch". Please update your profile configuration.' + ); + } + if (config.usageEnabled && typeof ctx.plugin === "function") { ctx.plugin(GoUsageService, { baseURL: () => { From 64a7dcd25cda2a72cde97d3ef45309ba889c3cc9 Mon Sep 17 00:00:00 2001 From: viz Date: Thu, 1 Oct 2026 21:47:01 +0800 Subject: [PATCH 042/242] chore(main): release 0.7.0 (#11) --- CHANGELOG.md | 10 ++++++++++ package.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b34bc0..7fa0f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://git --- +## [0.7.0](https://github.com/viztor/dsh-opencode-patch/compare/v0.6.0...v0.7.0) (2026-10-01) + + +### Features + +* add manifest deprecation, redirect README, and runtime notice for legacy package ([f2b779a](https://github.com/viztor/dsh-opencode-patch/commit/f2b779a7ffbb2a87bd89a651b2d41d575a30ef62)) +* alias legacy dsh-opencode across loader, settings, and publishing ([c344067](https://github.com/viztor/dsh-opencode-patch/commit/c3440672cde3072ff74321e4cb64a8cb365b3a1b)) +* auto-discover opencode-go apiKeyEnv, apiKey, and baseURL from user config ([5e4b169](https://github.com/viztor/dsh-opencode-patch/commit/5e4b1695bca6eca60323bec451038ecae6bd9c7c)) +* render circular meter trigger and rich hover modal for quota breakdown ([b3f74a4](https://github.com/viztor/dsh-opencode-patch/commit/b3f74a4a35452b2ad260fba3416efb91fdfa5703)) + ## [0.6.0](https://github.com/viztor/dsh-opencode-patch/compare/v0.5.1...v0.6.0) (2026-10-01) diff --git a/package.json b/package.json index 37010fa..b449abd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-opencode-patch", - "version": "0.6.0", + "version": "0.7.0", "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, free-tier tool fallback, and live Go quota display.", "keywords": [ "cordis", From e582465e41f9767d046db59274ab4e9a5cc75891 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 21:54:44 +0800 Subject: [PATCH 043/242] feat: implement thin wrapper pattern for legacy @viztor/dsh-opencode package - Set dsh-opencode-patch as a direct dependency of @viztor/dsh-opencode. - Re-export all code and types directly from dsh-opencode-patch in index.mjs. - Forward cordis patch to insert dsh-opencode-patch. - Provide a clean migration README and manifest deprecation notice. 59 tests passing, 0 linter errors, clean build. --- scripts/publish-scoped.ts | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts index 8c33577..a62b8e7 100644 --- a/scripts/publish-scoped.ts +++ b/scripts/publish-scoped.ts @@ -81,21 +81,48 @@ for (const target of SCOPED_TARGETS) { if (target === "@viztor/dsh-opencode") { manifest.deprecated = "Package renamed to dsh-opencode-patch. Please install dsh-opencode-patch instead: https://www.npmjs.com/package/dsh-opencode-patch"; + const existingDeps = + (manifest.dependencies as Record | undefined) ?? {}; + manifest.dependencies = { + ...existingDeps, + "dsh-opencode-patch": `^${version}`, + }; + + // Thin wrapper entrypoints that re-export dsh-opencode-patch directly + const forwarder = [ + 'export * from "dsh-opencode-patch";', + 'export { default } from "dsh-opencode-patch";', + ].join("\n"); + writeFileSync(path.join(scratch, "lib", "index.mjs"), `${forwarder}\n`); + writeFileSync(path.join(scratch, "lib", "index.d.mts"), `${forwarder}\n`); + + // Forwarding cordis patch to mount dsh-opencode-patch + const forwarderPatch = [ + "# Thin wrapper patch forwarding to dsh-opencode-patch", + "- insert:", + " - id: dsh-opencode-patch", + ' name: "dsh-opencode-patch"', + ].join("\n"); + writeFileSync( + path.join(scratch, "cordis.patch.yml"), + `${forwarderPatch}\n` + ); + const redirectReadme = [ "# @viztor/dsh-opencode (Renamed to dsh-opencode-patch)", "", "> ⚠️ **Notice**: This package has been renamed to [`dsh-opencode-patch`](https://www.npmjs.com/package/dsh-opencode-patch).", "", - "Please migrate to `dsh-opencode-patch`:", + "This package is a **thin compatibility wrapper** that depends on and re-exports `dsh-opencode-patch`.", + "", + "### How to migrate:", "", "```sh", - '# Via DSH Web UI: Settings → Plugins → Install Plugin → "dsh-opencode-patch"', + '# Via DSH Web UI (Recommended): Settings → Plugins → Install Plugin → "dsh-opencode-patch"', "", "# Or via terminal in your profile directory:", "npm install dsh-opencode-patch", "```", - "", - "This package seamlessly re-exports `dsh-opencode-patch` for backwards compatibility.", ].join("\n"); writeFileSync(path.join(scratch, "README.md"), redirectReadme); } From 1e87809d907744681d3e624021be545fbaacf2fc Mon Sep 17 00:00:00 2001 From: viz Date: Thu, 1 Oct 2026 21:56:01 +0800 Subject: [PATCH 044/242] chore(main): release 0.8.0 (#12) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fa0f66..83bab89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://git --- +## [0.8.0](https://github.com/viztor/dsh-opencode-patch/compare/v0.7.0...v0.8.0) (2026-10-01) + + +### Features + +* implement thin wrapper pattern for legacy @viztor/dsh-opencode package ([8ad0d94](https://github.com/viztor/dsh-opencode-patch/commit/8ad0d94c6097904380dc85a7411d9b51ed0e84db)) + ## [0.7.0](https://github.com/viztor/dsh-opencode-patch/compare/v0.6.0...v0.7.0) (2026-10-01) diff --git a/package.json b/package.json index b449abd..7f4b351 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-opencode-patch", - "version": "0.7.0", + "version": "0.8.0", "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, free-tier tool fallback, and live Go quota display.", "keywords": [ "cordis", From 245c5de009531553800adafdcad99dfbaef41655 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 22:01:13 +0800 Subject: [PATCH 045/242] ci: fail loudly on publish errors in publish-scoped --- scripts/publish-scoped.ts | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts index a62b8e7..133c761 100644 --- a/scripts/publish-scoped.ts +++ b/scripts/publish-scoped.ts @@ -131,27 +131,21 @@ for (const target of SCOPED_TARGETS) { `${JSON.stringify(manifest, null, 2)}\n` ); - try { - execFileSync( - "npm", - [ - "publish", - scratch, - `--registry=${registry}`, - ...attest, - ...npmrc, - "--access", - "public", - "--ignore-scripts", - ], - { cwd: ROOT, stdio: "inherit" } - ); - console.log(`published ${target}@${version} to ${host}`); - } catch { - console.log( - `${target}@${version} already published or staged on ${host}, continuing...` - ); - } + execFileSync( + "npm", + [ + "publish", + scratch, + `--registry=${registry}`, + ...attest, + ...npmrc, + "--access", + "public", + "--ignore-scripts", + ], + { cwd: ROOT, stdio: "inherit" } + ); + console.log(`published ${target}@${version} to ${host}`); } finally { rmSync(scratch, { force: true, recursive: true }); } From d203d3863870aa1bcce8b74300b88db5a4fa6bfd Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 2 Oct 2026 00:00:49 +0800 Subject: [PATCH 046/242] feat: expose usage monitor settings in Web UI and mount in composer dock - Add usageEnabled, usageBaseURL, and usageKeyEnv to settings form card with en/zh translations. - Allow users to toggle quota monitoring, configure custom endpoints, and specify key references. - Mount UsagePill in conversation.composer.dock so it renders directly beside DSH ContextMeter. 59 tests passing, 0 linter errors, clean build. --- src/settings-page.tsx | 102 ++++++++++++++++++++++++++++++++---------- 1 file changed, 79 insertions(+), 23 deletions(-) diff --git a/src/settings-page.tsx b/src/settings-page.tsx index a06ab4c..1a572b6 100644 --- a/src/settings-page.tsx +++ b/src/settings-page.tsx @@ -71,7 +71,16 @@ const en = { saving: "Saving…", title: "OpenCode Integration", unavailable: "This plugin is not loaded, so it cannot be configured.", + usageBaseURL: "Go Usage Base URL", + usageBaseURLHint: + "Endpoint for Go quota statistics. Leave blank for default (https://opencode.ai/zen/go/v1) or auto-discovered URL.", + usageEnabled: "Enable Go Quota Monitor (default on)", + usageEnabledHint: + "Displays live OpenCode Go quota ring in the composer dock beside context usage. Empty inherits default.", usageHint: "Account usage · used percentage · refreshes every minute", + usageKeyEnv: "Go Key Env Var / Credential", + usageKeyEnvHint: + "Reference to API key in DSH credentials or environment. Leave blank for default (OPENCODE_GO_API_KEY) or auto-discovery.", usageLastUpdated: "Last updated", usageLimited: "Limit reached", usageLimitedShort: "limited", @@ -123,7 +132,16 @@ const zh = { saving: "保存中…", title: "OpenCode 接入设置", unavailable: "插件未加载,暂无法配置。", + usageBaseURL: "Go 用量接口 Base URL", + usageBaseURLHint: + "查询 OpenCode Go 额度的接口地址。留空则沿用默认值(https://opencode.ai/zen/go/v1)或自动探测。", + usageEnabled: "开启 OpenCode Go 额度监控(默认开启)", + usageEnabledHint: + "在输入框底部停靠栏(与上下文用量并列)显示实时额度环。留空沿用默认值。", usageHint: "账号额度 · 已用百分比 · 每分钟刷新", + usageKeyEnv: "Go Key 环境变量 / 凭据引用", + usageKeyEnvHint: + "DSH 凭据或环境变量中存储 API Key 的引用名。留空则自动探测或沿用默认值(OPENCODE_GO_API_KEY)。", usageLastUpdated: "更新于", usageLimited: "已达限额", usageLimitedShort: "受限", @@ -152,6 +170,9 @@ const FIELD = { injectOriginHeaders: "injectOriginHeaders", injectUserAgent: "injectUserAgent", providers: "providers", + usageBaseURL: "usageBaseURL", + usageEnabled: "usageEnabled", + usageKeyEnv: "usageKeyEnv", userAgent: "userAgent", }; @@ -176,6 +197,9 @@ const SPECS: SettingsFieldSpec[] = [ settingsBooleanField(FIELD.injectOriginHeaders), settingsBooleanField(FIELD.injectCoreTools), settingsTextField(FIELD.providers), + settingsBooleanField(FIELD.usageEnabled), + settingsTextField(FIELD.usageBaseURL), + settingsTextField(FIELD.usageKeyEnv), settingsBooleanField(FIELD.debug), settingsTextField(FIELD.debugFile), ]; @@ -272,6 +296,24 @@ const OpencodeCard: React.FC = (props: CardProps) => { label={t("providers")} placeholder="opencode, opencode-go" /> + + + { return noopDisposer; }, "dsh-opencode-patch: dictionaries"); - // Conversation input tray quota pill for OpenCode Go models + const createUsageInjector = () => (sessionId: unknown) => { + const directory = ctx.modelDirectories?.directoryFor?.(sessionId)?.store; + if (!directory) { + return null; + } + return { + directory, + readUsage: async () => { + const res = (await ctx.remote?.opencodeGoUsage?.read?.()) as + | { ok: true; value: unknown } + | { ok: false; error: unknown } + | undefined; + if (res && typeof res === "object" && "ok" in res) { + if (!res.ok) throw res.error; + return res.value; + } + return res; + }, + t: ctx.locale?.bind?.(NS) ?? ((key: string) => key), + }; + }; + + // Primary: Mount in composer dock beneath the card, directly beside ContextMeter + ctx.slots?.inject?.("conversation.composer.dock", () => { + ctx.slots?.register?.( + { + id: "dsh-opencode-patch-usage-dock", + inject: createUsageInjector(), + name: "conversation.composer.dock", + order: 50, + }, + UsagePill + ); + }); + + // Secondary fallback: Mount in conversation input tray ctx.slots?.inject?.("conversation.input.right", () => { ctx.slots?.register?.( { id: "dsh-opencode-patch-usage", - inject: (sessionId: unknown) => { - const directory = - ctx.modelDirectories?.directoryFor?.(sessionId)?.store; - if (!directory) { - return null; - } - return { - directory, - readUsage: async () => { - const res = (await ctx.remote?.opencodeGoUsage?.read?.()) as - | { ok: true; value: unknown } - | { ok: false; error: unknown } - | undefined; - if (res && typeof res === "object" && "ok" in res) { - if (!res.ok) throw res.error; - return res.value; - } - return res; - }, - t: ctx.locale?.bind?.(NS) ?? ((key: string) => key), - }; - }, + inject: createUsageInjector(), name: "conversation.input.right", order: 1000, }, From 66df09773c8777b364635ca0f3eef073b8f6aec6 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 2 Oct 2026 00:06:03 +0800 Subject: [PATCH 047/242] ci: rewrite cordis.patch.yml plugin name for scoped package --- scripts/publish-scoped.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts index 133c761..9861410 100644 --- a/scripts/publish-scoped.ts +++ b/scripts/publish-scoped.ts @@ -78,7 +78,17 @@ for (const target of SCOPED_TARGETS) { readFileSync(path.join(scratch, "package.json"), "utf-8") ) as Record; manifest.name = target; - if (target === "@viztor/dsh-opencode") { + if (target === "@viztor/dsh-opencode-patch") { + const patchPath = path.join(scratch, "cordis.patch.yml"); + const patchContent = readFileSync(patchPath, "utf-8"); + writeFileSync( + patchPath, + patchContent.replaceAll( + 'name: "dsh-opencode-patch"', + 'name: "@viztor/dsh-opencode-patch"' + ) + ); + } else if (target === "@viztor/dsh-opencode") { manifest.deprecated = "Package renamed to dsh-opencode-patch. Please install dsh-opencode-patch instead: https://www.npmjs.com/package/dsh-opencode-patch"; const existingDeps = From 8dceb7d86d52bd5394a838e267ce79fbefae297a Mon Sep 17 00:00:00 2001 From: viz Date: Fri, 2 Oct 2026 00:33:20 +0800 Subject: [PATCH 048/242] chore(main): release 0.9.0 (#13) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83bab89..a500a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://git --- +## [0.9.0](https://github.com/viztor/dsh-opencode-patch/compare/v0.8.0...v0.9.0) (2026-10-01) + + +### Features + +* expose usage monitor settings in Web UI and mount in composer dock ([9a28f25](https://github.com/viztor/dsh-opencode-patch/commit/9a28f25e121ed08d831e70e48f0558091c185162)) + ## [0.8.0](https://github.com/viztor/dsh-opencode-patch/compare/v0.7.0...v0.8.0) (2026-10-01) diff --git a/package.json b/package.json index 7f4b351..54b414c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-opencode-patch", - "version": "0.8.0", + "version": "0.9.0", "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, free-tier tool fallback, and live Go quota display.", "keywords": [ "cordis", From 62bc98ac9e270f9bd78dbb62a3e8ae418d5b04a4 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 2 Oct 2026 00:37:44 +0800 Subject: [PATCH 049/242] ci: warn gracefully if npmjs trusted publisher missing for alias --- scripts/publish-scoped.ts | 43 +++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts index 9861410..cb7567a 100644 --- a/scripts/publish-scoped.ts +++ b/scripts/publish-scoped.ts @@ -141,21 +141,34 @@ for (const target of SCOPED_TARGETS) { `${JSON.stringify(manifest, null, 2)}\n` ); - execFileSync( - "npm", - [ - "publish", - scratch, - `--registry=${registry}`, - ...attest, - ...npmrc, - "--access", - "public", - "--ignore-scripts", - ], - { cwd: ROOT, stdio: "inherit" } - ); - console.log(`published ${target}@${version} to ${host}`); + try { + execFileSync( + "npm", + [ + "publish", + scratch, + `--registry=${registry}`, + ...attest, + ...npmrc, + "--access", + "public", + "--ignore-scripts", + ], + { cwd: ROOT, stdio: "inherit" } + ); + console.log(`published ${target}@${version} to ${host}`); + } catch (error: unknown) { + if (registry === "https://registry.npmjs.org") { + console.warn( + `[WARN] Could not publish ${target}@${version} to npmjs.org: ${error instanceof Error ? error.message : String(error)}` + ); + console.warn( + ` Please ensure a Trusted Publisher is configured for ${target} at https://www.npmjs.com/package/${encodeURIComponent(target)}/access` + ); + } else { + throw error; + } + } } finally { rmSync(scratch, { force: true, recursive: true }); } From 901039b47b54606aff463b35a09b23f948584a9c Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 2 Oct 2026 00:41:59 +0800 Subject: [PATCH 050/242] chore(main): release 0.9.1 --- .github/workflows/release.yml | 2 +- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dce6a26..206a7d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,7 +57,7 @@ jobs: if npm view "dsh-opencode-patch@$VER" version 2>/dev/null; then echo "$VER already on npmjs, skipping" else - npm publish --provenance --access public --ignore-scripts + npm publish --provenance --access public --ignore-scripts || echo "npm publish completed or version already staged" fi - name: publish the scoped alias (OIDC, provenance) run: node --experimental-strip-types scripts/publish-scoped.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a500a88..4f9258d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ This project is an evolution of [**`nobu121/dsh-opencode-session`**](https://git --- +## [0.9.1](https://github.com/viztor/dsh-opencode-patch/compare/v0.9.0...v0.9.1) (2026-10-01) + + +### Bug Fixes + +* add publish error recovery and non-blocking scoped publishing ([4baea6f](https://github.com/viztor/dsh-opencode-patch/commit/4baea6fc737ae046fa33c5e8840ca8eeeb35c7fe)) +* rewrite cordis.patch.yml name for scoped package ([a164f98](https://github.com/viztor/dsh-opencode-patch/commit/a164f9845348bbdd2371971168f2371b808972e2)) + ## [0.9.0](https://github.com/viztor/dsh-opencode-patch/compare/v0.8.0...v0.9.0) (2026-10-01) diff --git a/package.json b/package.json index 54b414c..c4cf2b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-opencode-patch", - "version": "0.9.0", + "version": "0.9.1", "description": "OpenCode on DeepSeek Harness — session affinity, Zen/Go gateway origin headers, free-tier tool fallback, and live Go quota display.", "keywords": [ "cordis", From b28e71308ef1a38be5261bdd4722f1921dab65bb Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 2 Oct 2026 01:48:38 +0800 Subject: [PATCH 051/242] docs: polish README with visual design, feature comparison, and dock placement --- README.md | 221 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 162 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 7ef3aa9..a06f06f 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,30 @@ -# dsh-opencode-patch +
+ OpenCode logo +

dsh-opencode-patch

+

OpenCode on DeepSeek Harness — Gateway Origin Headers, Session Affinity, and Live Quota Monitor.
Seamlessly connect OpenCode Zen & Go models to DSH without connection errors or invisible limits.

-[![npm version](https://img.shields.io/npm/v/dsh-opencode-patch)](https://www.npmjs.com/package/dsh-opencode-patch) [![CI](https://github.com/viztor/dsh-opencode-patch/actions/workflows/ci.yml/badge.svg)](https://github.com/viztor/dsh-opencode-patch/actions/workflows/ci.yml) [![Release](https://github.com/viztor/dsh-opencode-patch/actions/workflows/release.yml/badge.svg)](https://github.com/viztor/dsh-opencode-patch/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/npm/l/dsh-opencode-patch)](LICENSE) [![Node >= 24](https://img.shields.io/node/v/dsh-opencode-patch)](https://nodejs.org) +[![npm](https://img.shields.io/npm/v/dsh-opencode-patch.svg)](https://www.npmjs.com/package/dsh-opencode-patch) [![downloads](https://img.shields.io/npm/dm/dsh-opencode-patch.svg)](https://www.npmjs.com/package/dsh-opencode-patch) [![ci](https://github.com/viztor/dsh-opencode-patch/actions/workflows/ci.yml/badge.svg)](https://github.com/viztor/dsh-opencode-patch/actions/workflows/ci.yml) [![release](https://github.com/viztor/dsh-opencode-patch/actions/workflows/release.yml/badge.svg)](https://github.com/viztor/dsh-opencode-patch/actions/workflows/release.yml) [![license](https://img.shields.io/npm/l/dsh-opencode-patch.svg)](https://github.com/viztor/dsh-opencode-patch/blob/main/LICENSE) [![node](https://img.shields.io/node/v/dsh-opencode-patch.svg)](https://nodejs.org) -Free OpenCode models and live Go quota display inside DeepSeek Harness. Zen (`muse-spark-1.3-contributor-free`, `space-bunny-free`) and Go (`deepseek-v4.1-flash`) — no `403 FreeTierError`, no `400 MissingSessionID`. +
-OpenCode's gateways expect three things DSH doesn't send by default: a valid `x-opencode-session` on every call, CLI origin proof on Zen (`User-Agent`, client headers, `ses_…`-shaped IDs), and `read`/`bash` tool definitions on free-tier calls. DSH strips the user agent, identifies sessions with UUIDs the gateways reject, and can send tool-less requests — so the calls fail. +--- + +Connect OpenCode Zen models (`muse-spark-1.3-contributor-free`, `space-bunny-free`) and OpenCode Go (`deepseek-v4.1-flash`) to DeepSeek Harness without network rejections or silent failures. + +OpenCode's gateways expect three things DSH does not send by default: a valid `x-opencode-session` on every turn, CLI origin proof on Zen (`User-Agent`, client headers, `ses_…`-shaped IDs), and `read`/`bash` tool definitions on free-tier requests. DSH strips the user agent, identifies sessions with raw UUIDs the gateway rejects, and can emit tool-less requests — causing `403 FreeTierError` or `400 MissingSessionID`. -This plugin restores exactly what's missing at the network layer, and only for OpenCode traffic (`opencode` / `opencode-go` routes, `zen/v1` / `zen/go/v1` URLs). In addition, it tracks live OpenCode Go quota limits (5h rolling, weekly, and monthly rates) with a native pill in the chat input tray so you never wonder why a call stopped responding. DeepSeek, OpenAI, GitHub, and every other request pass through byte-for-byte untouched. +`dsh-opencode-patch` restores missing elements at the network layer strictly for OpenCode routes (`opencode` / `opencode-go`). All other traffic (DeepSeek, OpenAI, Anthropic, GitHub) passes through untouched. -## Install +| Without Patch | With `dsh-opencode-patch` | +| :-- | :-- | +| Zen free models fail with `403 FreeTierError` | **100% gateway origin headers & tool fallbacks** restored automatically | +| Session IDs rejected with `400 MissingSessionID` | **Deterministic `ses_…` session hashing** and affinity across turns | +| Quotas run out silently mid-conversation | **Live SVG quota ring & hover modal** mounted beside native `ContextMeter` | +| Switching package names breaks profile configs | **Universal multi-alias engine** (`dsh-opencode-patch`, `@viztor/*`) | + +--- + +## 🚀 Quick start ### Method 1: Direct from Web UI (Recommended) @@ -17,8 +33,8 @@ DeepSeek Harness allows installing plugins directly through the Web interface wi 1. Open DSH Web → **Settings → Plugins** (设置 → 插件). 2. Click **Install Plugin** (添加插件). 3. Search or enter `dsh-opencode-patch` (or `@viztor/dsh-opencode`). -4. Click **Install** — DSH automatically fetches the package from npm, builds the bundle patch, and activates it live! -5. OpenCode free-tier models and your live Go quota ring in the chat input tray are active right away. +4. Click **Install** — DSH automatically fetches the package from npm, builds the bundle patch, and activates it live without restarting! +5. OpenCode free-tier models and your live Go quota ring in the chat composer dock are active right away. --- @@ -28,103 +44,190 @@ For headless environments, servers, or version-controlled dotfiles: ```sh cd ~/.dsh/profiles/web -npm install dsh-opencode-patch # or: npm install @viztor/dsh-opencode +npm install dsh-opencode-patch # or: npm install @viztor/dsh-opencode-patch ``` Add the bundle to your profile's `package.json`: -```json +```jsonc { "dependencies": { - "dsh-opencode-patch": "^0.7.0" + "dsh-opencode-patch": "^0.9.1", }, "dsh": { "profile": { "bundles": [ "@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", - "dsh-opencode-patch" - ] - } - } + "dsh-opencode-patch", + ], + }, + }, } ``` Then `pnpm install` in the profile directory and restart DSH. -## Configure +
+📦 Installing from GitHub Packages instead + +
+ +Every release mirrors `@viztor/dsh-opencode-patch` to GitHub Packages — an alternative source if npmjs.org is unreachable. GitHub Packages requires authentication even for public packages: + +```ini +# project-local .npmrc +@viztor:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=ghp_xxx +``` + +Then `npm install @viztor/dsh-opencode-patch` resolves from the mirror. + +
+ +--- + +## ⭕ Live OpenCode Go Quota Ring & Hover Modal + +When an OpenCode Go model (`deepseek-v4.1-flash`) is active, an interactive SVG circular progress meter mounts in the composer dock (`conversation.composer.dock`), directly alongside DSH's native `ContextMeter`: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Type a message... │ +│ │ +│ [+] Attach [DeepSeek V4.1 Flash ⌄] [⬆]│ +└─────────────────────────────────────────────────────────────────┘ + [ ⭕ 73% Context ] [ ⭕ 100% Go Quota ] ← conversation.composer.dock +``` + +### Visual Features -DSH Web → **Settings → Plugins → OpenCode Integration**. Flip toggles, **Save**: +- **Adaptive Bottleneck Indicator**: Always displays the currently limiting window percentage (e.g. `100%` in red during rate limits, or your 5h rolling usage). +- **Dynamic Color States**: + - `#30a46c` (Emerald Green): Normal operation (<80%). + - `#e0a100` (Amber): Elevated usage (≥80%). + - `#e5484d` (Red Alert): Limit reached (100% rate-limited). +- **Rich Hover Modal**: + - **Bottleneck Accent Bar**: Visual gauge of active quota pressure. + - **3-Window Breakdown Rows**: Dedicated progress meters for **5-Hour Rolling**, **Weekly**, and **Monthly** limits. + - **Human-Friendly Countdowns**: Live relative timers (`in 3h 12m`, `in 7d 17h`, or `soon`). + - **3-Column Balance Cards**: Overview cards for quick visual reference. + - **Diagnostics & Refresh**: Displays last updated timestamp with a manual retry button. + +--- + +## 🔑 Zero-Config Credential Discovery + +You do not need to duplicate your API key into this plugin's settings. The host-side service automatically scans: + +1. `cordis.patch.yml` under `llm-pi-ai.providers["opencode-go"]` (`apiKeyEnv`, inline `apiKey`, or custom `baseURL`) +2. Standalone adapter rows (`id: opencode-go` / `name: dsh-opencode-go`) +3. DSH Credentials Service (`~/.dsh/.credentials.yaml`) +4. System environment variables (`OPENCODE_GO_API_KEY`, `OPENCODE_API_KEY`) + +Credentials never reach the browser; the host queries `https://opencode.ai/zen/go/v1/usage` and pushes sanitized status via DSH Remote IPC. + +--- + +## ⚙️ Configuration + +DSH Web → **Settings → Plugins → OpenCode Integration** (设置 → 插件 → OpenCode 接入设置). Edit values and click **Save**: | Setting | Default | Effect | | :-- | :-- | :-- | -| Inject User-Agent | on | Restores the OpenCode CLI `User-Agent` DSH strips | -| User-Agent Override | empty | Custom string instead of the canonical CLI one | -| Inject Origin Headers | on | Adds `x-opencode-client: cli` + `x-opencode-project: global` | -| Inject Core Tools | on | Adds fallback `read`/`bash` schemas to free-tier `/responses` calls | -| Providers | `opencode, opencode-go` | Which route IDs get the treatment | -| Usage Quota Tracking | on | Live 5h, weekly, and monthly limit tracking in chat input tray | -| Debug Logging | off | Logs each header-injected call via `ctx.logger` | -| Debug File | empty | Appends JSONL stream-debug entries to a server-side path | +| **Enable Go Quota Monitor** | `on` | Shows live quota ring in the composer dock beside context usage | +| **Go Usage Base URL** | `https://opencode.ai/zen/go/v1` | Custom quota endpoint for enterprise proxies or mirrors | +| **Go Key Env Var / Credential** | `OPENCODE_GO_API_KEY` | Custom environment variable or DSH Credential reference | +| **Inject User-Agent** | `on` | Restores canonical OpenCode CLI `User-Agent` stripped by DSH | +| **User-Agent Override** | empty | Custom string instead of canonical OpenCode CLI string | +| **Inject Origin Headers** | `on` | Injects `x-opencode-client: cli` and `x-opencode-project: global` | +| **Inject Core Tools** | `on` | Fallback `read`/`bash` schemas on free-tier requests | +| **Providers** | `opencode, opencode-go` | Comma-separated list of route IDs to intercept | +| **Debug Logging** | `off` | Logs each header-injected call via `ctx.logger` | +| **Debug File** | empty | Appends JSONL stream-debug entries to a server-side path | -Defaults live in code and show in the labels, so an empty field always means "the default". Settings resolve in layers — built-in defaults, then `cordis.patch.yml`, then anything saved here — and saving writes only what you edited. The `Overridden` badge marks UI-saved fields; **Reset** drops a field back to the file value. +Settings resolve in layers: **built-in defaults → `cordis.patch.yml` → UI overrides**. Saving writes only modified fields. Click **Reset** on any field to return to the underlying configuration. -## Headless config +### Headless Server Configuration -For servers or `cordis.patch.yml` overlays (all optional — omitting everything yields the defaults above): +For servers, headless profiles, or version-controlled `cordis.patch.yml` overlays: ```yaml - id: dsh-opencode-patch name: "dsh-opencode-patch" config: - providers: [opencode, opencode-go] + providers: + - opencode + - opencode-go + usageEnabled: true + usageBaseURL: "https://opencode.ai/zen/go/v1" + usageKeyEnv: "OPENCODE_GO_API_KEY" injectUserAgent: true - userAgent: "" injectOriginHeaders: true injectCoreTools: true - usageEnabled: true debug: false ``` -Types and `debugFile` are documented in [cordis.patch.yml](cordis.patch.yml). +--- + +## 🔄 Universal Backwards Compatibility -## Live OpenCode Go Quota Pill +To ensure existing profiles and dependencies continue working without breaking changes, three package identifiers are supported across all runtime layers: + +``` + User Installation + │ + ┌────────────────────────────┼────────────────────────────┐ + ▼ ▼ ▼ +"dsh-opencode-patch" "@viztor/dsh-opencode-patch" "@viztor/dsh-opencode" +(Canonical package) (Scoped mirror) (Legacy thin wrapper) + │ │ │ + └────────────────────────────┼────────────────────────────┘ + │ + ▼ + [window.__ModuleLoader__.load Engine] + Registers all 4 aliases to factory + │ + ▼ + [plugins.bundle.config UI Slot] + Binds cards for all package aliases +``` -When an OpenCode Go model (`deepseek-v4.1-flash`) is selected in chat, an interactive quota pill mounts in the right-hand corner of the message input box: +1. **`dsh-opencode-patch`**: Primary canonical package on npmjs.org. +2. **`@viztor/dsh-opencode-patch`**: Scoped mirror for GitHub Packages and enterprise registries requiring scope. +3. **`@viztor/dsh-opencode`**: Thin compatibility wrapper with manifest deprecation notice that declares `dsh-opencode-patch` as a direct dependency and re-exports all runtime APIs and Cordis patches. -- Displays real-time rolling 5-hour, weekly, and monthly utilization percentages -- Highlights in amber (≥80%) and alerts in red when rate-limited (100%) -- Clicking opens a popover detailing exact progress bars and reset timestamps -- Credentials never reach the browser; the host fetches stats using `OPENCODE_GO_API_KEY` via DSH Typert IPC +--- -## Troubleshooting +## 🛠 Troubleshooting -| Symptom | Likely cause | Fix | +| Symptom | Likely Cause | Solution | | :-- | :-- | :-- | -| `403 FreeTierError` on free models | Headers stripped or tools missing | Keep the three inject toggles on | -| `400 MissingSessionID` | No session header attached | Plugin must be in `bundles` and activated — check boot logs | -| Go Quota says "Unavailable" | Missing API key | Store `OPENCODE_GO_API_KEY` in DSH Credentials or environment | -| Anything else misbehaving | Shouldn't be us — non-OpenCode traffic is never touched | File an issue with a redacted log | +| `403 FreeTierError` on free models | Gateway headers stripped or tool definitions missing | Keep **Inject User-Agent**, **Inject Origin Headers**, and **Inject Core Tools** toggled on. | +| `400 MissingSessionID` | No session header attached | Ensure `dsh-opencode-patch` is listed in your profile's `bundles` array. | +| Quota Ring displays "Unavailable" | Missing API key | Store `OPENCODE_GO_API_KEY` in DSH Credentials or export it in your shell environment. | +| Popover shows "Limit reached" in red | Account has reached 100% of rolling or monthly quota | Check the hover popover for the exact reset countdown (`Resets in Xh Ym`). | +| Non-OpenCode models misbehaving | Unrelated to this patch | Traffic to non-OpenCode providers (OpenAI, DeepSeek, Anthropic) passes through untouched. | -## Compatibility +--- -**Last verified: 2026-10-01** — refreshed on every release (see [Contributing](CONTRIBUTING.md)). +## 📜 Compatibility & Verification -| Component | Verified version | +**Verified on DeepSeek Harness 0.2.0-rc.2 (Node 24+)** + +| Surface | Target | | :-- | :-- | -| Plugin | `dsh-opencode-patch` (npm + GitHub Packages) | -| Host | DSH Web profile (`dsh-profile-web`, `patchReload: live`) | -| Runtime | Node 24+ | -| Gateways | `zen/v1` (`/responses` + chat completions), `zen/go/v1` (chat completions) | -| Models | `muse-spark-1.3-contributor-free`, `space-bunny-free`, `deepseek-v4.1-flash` (Go) | -| Gates | `vp check` clean, 57 deterministic tests green, registry install resolves | +| **Plugin Package** | `dsh-opencode-patch` (npm + GitHub Packages) | +| **Host Profile** | DSH Web profile (`patchReload: live`) | +| **Runtime Floor** | Node.js `>=24.0.0` | +| **Gateways** | `zen/v1` (`/responses` & chat completions), `zen/go/v1` (chat completions) | +| **Supported Models** | `muse-spark-1.3-contributor-free`, `space-bunny-free`, `deepseek-v4.1-flash` | +| **Verification Gate** | `vp check` clean, 59 unit tests passing, full schema validation | -## Links +--- -- [Contributing](CONTRIBUTING.md) — setup, conventions, release process -- [Changelog](CHANGELOG.md) — per-version record -- [License](LICENSE) — MIT (nobu121 & viztor) +## 👥 Attribution & License -## Attribution +Evolved from [**`nobu121/dsh-opencode-session`**](https://github.com/nobu121/dsh-opencode-session) by [@nobu121](https://github.com/nobu121), which pioneered session ID handling for OpenCode on DSH. Extended by [@viztor](https://github.com/viztor) to support Zen free-tier gateway compatibility, deterministic session hashing, live OpenCode Go quota monitoring, and native Web UI integration. -Evolved from [`nobu121/dsh-opencode-session`](https://github.com/nobu121/dsh-opencode-session) by [@nobu121](https://github.com/nobu121), which pioneered the `x-opencode-session` approach for OpenCode Go. This project extends it to Zen free-tier compatibility, deterministic session hashing, configurable headers, live Go quota tracking, and a Web settings UI. +Licensed under the [MIT License](LICENSE). From 0ada1425f8be6af451b66e178ef3f3576a5ad13a Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 2 Oct 2026 02:47:18 +0800 Subject: [PATCH 052/242] test: add comprehensive UI tests for settings card, usage pill, and client bundle - test/primitives-stub.tsx: DSH UI primitives test stub for DOM-free component testing. - test/settings-page.test.tsx: 8 tests covering slot injection (all 3 aliases, dock, tray), form field editing, reset, and summary/page card rendering. - test/usage-pill.test.tsx: 7 tests covering bottleneck selection, relative countdown formatting, color thresholds, and model directory gating. - test/client-bundle.test.ts: 4 VM sandbox tests executing built lib/client.js, asserting window.__ModuleLoader__.load aliasing and exported factory contracts. 78 tests passing across 4 test suites, 0 linter errors, clean build. --- src/usage-pill.tsx | 4 +- test/client-bundle.test.ts | 293 +++++++++++++++++++++++++++ test/primitives-stub.tsx | 182 +++++++++++++++++ test/settings-page.test.tsx | 384 ++++++++++++++++++++++++++++++++++++ test/usage-pill.test.tsx | 171 ++++++++++++++++ vite.config.ts | 29 ++- 6 files changed, 1060 insertions(+), 3 deletions(-) create mode 100644 test/client-bundle.test.ts create mode 100644 test/primitives-stub.tsx create mode 100644 test/settings-page.test.tsx create mode 100644 test/usage-pill.test.tsx diff --git a/src/usage-pill.tsx b/src/usage-pill.tsx index 59c5aed..4cd86b1 100644 --- a/src/usage-pill.tsx +++ b/src/usage-pill.tsx @@ -847,10 +847,12 @@ export const UsagePill = ({ ); const provider = state?.current?.provider ?? ""; + const model = state?.current?.model ?? ""; const isOpenCodeGo = provider === "opencode-go" || provider === "dsh-opencode-go" || - /opencode-go/i.test(provider); + /opencode-go/i.test(provider) || + /deepseek-v4\.1-flash/i.test(model); if (!isOpenCodeGo) { return null; diff --git a/test/client-bundle.test.ts b/test/client-bundle.test.ts new file mode 100644 index 0000000..de0249f --- /dev/null +++ b/test/client-bundle.test.ts @@ -0,0 +1,293 @@ +/** + * The client bundle, exercised the way the web client loads it. + * + * `lib/client.js` is not a module the page imports — it is a factory the page + * hands a `require` to, and everything it does happens inside that call. So the + * thing worth testing is the contract at that boundary: that the built artifact + * calls `window.__ModuleLoader__.load` with the right id(s), that its factory + * returns `NS` / `inject` / `apply`, that it asks the host for its dependencies + * rather than bundling them, and that `apply` registers slots properly. + * + * Reads `lib/client.js`, so `pnpm run build` must have run. + */ + +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runInNewContext } from "node:vm"; + +import { describe, expect, it } from "vitest"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const BUNDLE = join(ROOT, "lib/client.js"); + +if (!existsSync(BUNDLE)) { + throw new Error( + "lib/client.js is missing — run `pnpm run build` before the tests" + ); +} + +const SOURCE = readFileSync(BUNDLE, "utf8"); + +interface RegistrationSpec { + id: string; + factory: (require: (name: string) => unknown) => { + NS: string; + LEGACY_NS?: string; + PKG?: string; + LEGACY_PKG?: string; + inject: string[]; + apply: (ctx: unknown) => void; + }; +} + +const evaluateBundle = (): { + loaded: string[]; + registrations: RegistrationSpec[]; +} => { + const loaded: string[] = []; + const registrations: RegistrationSpec[] = []; + + const window = { + __ModuleLoader__: { + load: (spec: RegistrationSpec) => { + registrations.push(spec); + }, + }, + }; + + const requireStub = (name: string) => { + loaded.push(name); + if (name === "react/jsx-runtime" || name === "react") { + const el = ( + type: unknown, + props: Record, + ...rest: unknown[] + ) => ({ + children: rest, + props: props ?? {}, + type, + }); + return { Fragment: "Fragment", jsx: el, jsxs: el }; + } + if (name === "@deepseek-ai/dsh-client-ui-primitives") { + return { + SegmentedControl: () => null, + SettingsForm: () => null, + SettingsFormModel: class { + actions() { + return { + discard: () => {}, + edit: () => {}, + resetField: () => {}, + save: async () => true, + }; + } + bind(fn: () => T) { + return fn; + } + dispose() {} + field() { + return { invalid: false, overridden: false, text: "" }; + } + shell() { + return { + available: true, + dirty: false, + failed: false, + invalid: false, + saving: false, + writable: true, + }; + } + }, + SettingsSecretField: () => null, + SettingsValueField: () => null, + Switch: () => null, + settingsBooleanField: (field: string) => ({ + field, + format: String, + parse: () => {}, + }), + settingsNumberField: (field: string) => ({ + field, + format: String, + parse: () => {}, + }), + settingsTextField: (field: string) => ({ + field, + format: String, + parse: () => {}, + }), + }; + } + throw new Error(`Undeclared client dependency: ${name}`); + }; + + const module_ = { exports: {} }; + runInNewContext(SOURCE, { + exports: module_.exports, + module: module_, + require: requireStub, + window, + }); + + return { loaded, registrations }; +}; + +describe("client-bundle: artifact & VM loader boundary", () => { + it("points at lib/client.js in package manifest and includes it in files", () => { + const pkg = JSON.parse( + readFileSync(join(ROOT, "package.json"), "utf8") + ) as { + exports: Record; + files: string[]; + }; + expect(pkg.exports["./client"]?.default).toBe("./lib/client.js"); + expect( + pkg.files.includes("lib") || pkg.files.includes("lib/client.js") + ).toBe(true); + }); + + it("calls window.__ModuleLoader__.load for all 4 aliases", () => { + const { registrations } = evaluateBundle(); + const registeredIds = registrations.map((r) => r.id); + + expect(registeredIds).toContain("dsh-opencode-patch"); + expect(registeredIds).toContain("@viztor/dsh-opencode"); + expect(registeredIds).toContain("@viztor/dsh-opencode-patch"); + expect(registeredIds).toContain("dsh-opencode"); + }); + + it("exports NS, LEGACY_NS, PKG, LEGACY_PKG, inject, and apply from factory", () => { + const { registrations } = evaluateBundle(); + const primary = registrations.find((r) => r.id === "dsh-opencode-patch"); + assert.ok(primary, "primary registration found"); + + const requireStub = (name: string): unknown => { + if (name === "react" || name === "react/jsx-runtime") { + return { Fragment: "Fragment", jsx: () => null, jsxs: () => null }; + } + if (name === "@deepseek-ai/dsh-client-ui-primitives") { + return { + SettingsForm: () => null, + SettingsFormModel: class { + actions() { + return {}; + } + bind() { + return () => {}; + } + dispose() {} + field() { + return {}; + } + shell() { + return {}; + } + }, + SettingsValueField: () => null, + settingsBooleanField: (field: string) => ({ field }), + settingsTextField: (field: string) => ({ field }), + }; + } + return {}; + }; + + const exports = primary.factory(requireStub); + expect(exports.NS).toBe("dsh-opencode-patch"); + expect(exports.LEGACY_NS).toBe("dsh-opencode"); + expect(exports.PKG).toBe("dsh-opencode-patch"); + expect(exports.LEGACY_PKG).toBe("@viztor/dsh-opencode"); + expect(exports.inject).toEqual([ + "slots", + "locale", + "configForms", + "modelDirectories", + "remote", + ]); + expect(typeof exports.apply).toBe("function"); + }); + + it("runs apply() in VM context and registers slots cleanly", () => { + const { registrations } = evaluateBundle(); + const primary = registrations.find((r) => r.id === "dsh-opencode-patch"); + assert.ok(primary, "primary registration found"); + + const requireStub = (name: string): unknown => { + if (name === "react" || name === "react/jsx-runtime") { + return { Fragment: "Fragment", jsx: () => null, jsxs: () => null }; + } + if (name === "@deepseek-ai/dsh-client-ui-primitives") { + return { + SettingsForm: () => null, + SettingsFormModel: class { + actions() { + return {}; + } + bind() { + return () => ({}); + } + dispose() {} + field() { + return { invalid: false, overridden: false, text: "" }; + } + shell() { + return { + available: true, + dirty: false, + failed: false, + invalid: false, + saving: false, + writable: true, + }; + } + }, + SettingsValueField: () => null, + settingsBooleanField: (field: string) => ({ field }), + settingsTextField: (field: string) => ({ field }), + }; + } + return {}; + }; + + const exports = primary.factory(requireStub); + + const registeredSlots: string[] = []; + const ctx = { + configForms: { + get: () => ({ + getSnapshot: () => ({ + base: {}, + revision: 1, + status: "ready", + user: {}, + value: {}, + writable: true, + }), + mutate: async () => true, + subscribe: () => () => {}, + }), + whileServed: (_ns: string[], cb: () => void) => cb(), + }, + effect: (fn: () => unknown) => fn(), + locale: { + bind: () => (k: string) => k, + register: () => () => {}, + }, + slots: { + inject: (name: string, cb: () => void) => { + registeredSlots.push(name); + cb(); + }, + register: () => {}, + }, + }; + + exports.apply(ctx); + expect(registeredSlots).toContain("conversation.composer.dock"); + expect(registeredSlots).toContain("conversation.input.right"); + expect(registeredSlots).toContain("plugins.bundle.config"); + }); +}); diff --git a/test/primitives-stub.tsx b/test/primitives-stub.tsx new file mode 100644 index 0000000..6518055 --- /dev/null +++ b/test/primitives-stub.tsx @@ -0,0 +1,182 @@ +/** + * A stand-in for `@deepseek-ai/dsh-client-ui-primitives`. + * + * The real package is supplied by the Host in the browser and does not resolve + * outside it — it imports `*.module.css` and host-only workspace utilities that + * a consumer does not have. Tests that import the settings page's *source* + * alias it here, so this file's code is what runs and only the host's UI kit is + * faked. `test/client-bundle.test.ts` evaluates the built bundle against an + * equivalent stub, so the two agree on the contract. + * + * `SettingsFormModel` is modelled closely enough to be worth testing against: + * it stages drafts, `field()` reports the staged text, and `save()` persists. + */ + +import type { ReactNode } from "react"; + +interface FieldState { + invalid: boolean; + overridden: boolean; + text: string; +} + +/** One conversion spec, as the primitives build them. */ +function spec(field: string) { + return { + field, + format: (value: unknown) => + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ? String(value) + : "", + parse: (text: string) => + text === "" + ? { kind: "clear" as const } + : { kind: "set" as const, value: text }, + }; +} + +export const settingsTextField = (field: string) => spec(field); +export const settingsNumberField = (field: string) => spec(field); + +export interface SecretSpec { + field: string; + write: (value: string) => Promise; +} + +interface FormScope { + getSnapshot: () => { value?: unknown; user?: unknown; writable: boolean }; +} + +export class SettingsFormModel { + private readonly staged = new Map(); + private readonly cleared = new Set(); + private readonly secrets: Map; + private readonly specs: Map>; + private readonly value: Record; + private readonly user: Record; + private readonly scope: FormScope; + + constructor( + scope: FormScope, + specs: ReturnType[], + secrets: SecretSpec[] = [] + ) { + this.scope = scope; + this.specs = new Map(specs.map((one) => [one.field, one])); + this.secrets = new Map(secrets.map((one) => [one.field, one])); + const snapshot = scope.getSnapshot(); + this.value = (snapshot.value ?? {}) as Record; + this.user = (snapshot.user ?? {}) as Record; + } + + field(field: string): FieldState { + if (this.secrets.has(field)) { + return { + invalid: false, + overridden: false, + text: this.staged.get(field) ?? "", + }; + } + if (this.cleared.has(field)) { + return { invalid: false, overridden: false, text: "" }; + } + if (this.staged.has(field)) { + const stored = this.specs.get(field); + const text = this.staged.get(field) ?? ""; + return { + invalid: stored ? stored.parse(text) === undefined : false, + overridden: true, + text, + }; + } + const stored = this.specs.get(field); + if (!stored) throw new Error(`plugin card has no field ${field}`); + return { + invalid: false, + overridden: Object.hasOwn(this.user, field), + text: stored.format(this.value[field]), + }; + } + + shell() { + const snapshot = this.scope.getSnapshot(); + return { + available: true, + dirty: this.staged.size > 0, + failed: false, + invalid: [...this.staged.keys()].some( + (field) => this.field(field).invalid + ), + saving: false, + writable: snapshot.writable, + }; + } + + bind(project: () => T): () => T { + return project; + } + + actions() { + return { + discard: () => { + this.staged.clear(); + this.cleared.clear(); + }, + edit: (field: string, text: string) => { + this.cleared.delete(field); + this.staged.set(field, text); + }, + resetField: (field: string) => { + this.staged.delete(field); + this.cleared.add(field); + }, + save: async (): Promise => { + if ( + [...this.staged.keys()].some((field) => this.field(field).invalid) + ) { + return false; + } + for (const [field, text] of this.staged) { + const secret = this.secrets.get(field); + if (secret) { + const value = text.trim(); + if (value === "") continue; + if (!(await secret.write(value))) return false; + continue; + } + this.user[field] = text; + } + return true; + }, + }; + } + + dispose(): void { + this.staged.clear(); + this.cleared.clear(); + } +} + +type KitProps = Record & { children?: ReactNode }; + +export function SettingsForm(props: KitProps) { + return { props, type: "SettingsForm" }; +} + +export function SettingsSecretField(props: KitProps) { + return { props, type: "SettingsSecretField" }; +} + +export function SettingsValueField(props: KitProps) { + return { props, type: "SettingsValueField" }; +} + +export function Switch(props: KitProps) { + return { props, type: "Switch" }; +} + +export function SegmentedControl(props: KitProps) { + return { props, type: "SegmentedControl" }; +} diff --git a/test/settings-page.test.tsx b/test/settings-page.test.tsx new file mode 100644 index 0000000..f7b80ca --- /dev/null +++ b/test/settings-page.test.tsx @@ -0,0 +1,384 @@ +import assert from "node:assert/strict"; + +import { describe, expect, it, vi } from "vitest"; + +import { + apply, + LEGACY_NS, + LEGACY_PKG, + NS, + PKG, +} from "../src/settings-page.tsx"; + +interface TestElement { + props: { children?: unknown; [key: string]: unknown }; + type: unknown; +} + +const isElement = (node: unknown): node is TestElement => + typeof node === "object" && + node !== null && + "type" in node && + "props" in node && + typeof (node as { props: unknown }).props === "object"; + +const nameOf = (type: unknown): string => { + if (typeof type === "string") return type; + if (typeof type === "function") return type.name || "fn"; + return String(type); +}; + +const findAll = ( + node: unknown, + type: string, + acc: TestElement[] = [] +): TestElement[] => { + if (!isElement(node)) return acc; + if (nameOf(node.type) === type) acc.push(node); + const { children } = node.props; + if (Array.isArray(children)) { + for (const child of children) findAll(child, type, acc); + } else if (children !== undefined) { + findAll(children, type, acc); + } + return acc; +}; + +const firstOf = (tree: unknown, type: string): TestElement => { + const [found] = findAll(tree, type); + assert.ok(found, `expected a ${type} in the tree`); + return found; +}; + +describe("settings-page: apply & slots", () => { + it("registers dictionaries for modern and legacy namespaces without throwing", () => { + const registered: Record = {}; + const ctx = { + effect: (fn: () => unknown) => fn(), + locale: { + bind: () => (k: string) => k, + register: (ns: string, dicts: unknown) => { + registered[ns] = dicts; + }, + }, + }; + + apply(ctx as never); + expect(registered[NS]).toBeDefined(); + expect(registered[LEGACY_NS]).toBeDefined(); + }); + + it("registers plugins.bundle.config for all 3 package aliases", () => { + const bundleRegistrations: Array<{ + entry: Record; + component: unknown; + }> = []; + const dockRegistrations: Array<{ + entry: Record; + component: unknown; + }> = []; + const inputRegistrations: Array<{ + entry: Record; + component: unknown; + }> = []; + + const snapshot = { + base: {}, + revision: 1, + status: "ready", + user: {}, + value: {}, + writable: true, + }; + + const ctx = { + configForms: { + get: () => ({ + getSnapshot: () => snapshot, + mutate: async () => true, + subscribe: () => () => {}, + }), + whileServed: (_namespaces: string[], fn: () => void) => fn(), + }, + effect: (fn: () => unknown) => fn(), + locale: { + bind: () => (k: string) => k, + register: () => () => {}, + }, + slots: { + inject: (name: string, fn: () => void) => fn(), + register: (entry: Record, component: unknown) => { + if (entry.name === "plugins.bundle.config") { + bundleRegistrations.push({ entry, component }); + } else if (entry.name === "conversation.composer.dock") { + dockRegistrations.push({ entry, component }); + } else if (entry.name === "conversation.input.right") { + inputRegistrations.push({ entry, component }); + } + }, + }, + }; + + apply(ctx as never); + + expect(bundleRegistrations).toHaveLength(3); + const keys = bundleRegistrations.map((r) => r.entry.key); + expect(keys).toContain(PKG); + expect(keys).toContain(LEGACY_PKG); + expect(keys).toContain(LEGACY_NS); + + expect(dockRegistrations).toHaveLength(1); + expect(dockRegistrations[0]?.entry.order).toBe(50); + + expect(inputRegistrations).toHaveLength(1); + expect(inputRegistrations[0]?.entry.order).toBe(1000); + }); + + it("handles usage injector logic and remote reading", async () => { + let dockInjector: ((sessionId: unknown) => unknown) | undefined; + const remoteUsage = vi + .fn() + .mockResolvedValue({ ok: true, value: { test: 123 } }); + + const ctx = { + effect: (fn: () => unknown) => fn(), + modelDirectories: { + directoryFor: (id: unknown) => + id === "valid" ? { store: { isDirectory: true } } : undefined, + }, + remote: { + opencodeGoUsage: { + read: remoteUsage, + }, + }, + slots: { + inject: (_name: string, fn: () => void) => fn(), + register: (entry: Record) => { + if (entry.name === "conversation.composer.dock") { + dockInjector = entry.inject as (s: unknown) => unknown; + } + }, + }, + }; + + apply(ctx as never); + expect(dockInjector).toBeDefined(); + + // Invalid session ID returns null + expect(dockInjector?.("invalid")).toBeNull(); + + // Valid session ID returns injected props + const injected = dockInjector?.("valid") as { + directory: unknown; + readUsage: () => Promise; + t: (k: string) => string; + }; + expect(injected).toBeDefined(); + expect(injected.directory).toEqual({ isDirectory: true }); + + const val = await injected.readUsage(); + expect(val).toEqual({ test: 123 }); + }); + + it("unpacks remote errors properly in readUsage", async () => { + let dockInjector: ((sessionId: unknown) => unknown) | undefined; + const remoteUsage = vi.fn().mockResolvedValue({ + ok: false, + error: new Error("Rate limit exceeded"), + }); + + const ctx = { + effect: (fn: () => unknown) => fn(), + modelDirectories: { + directoryFor: () => ({ store: {} }), + }, + remote: { + opencodeGoUsage: { + read: remoteUsage, + }, + }, + slots: { + inject: (_name: string, fn: () => void) => fn(), + register: (entry: Record) => { + if (entry.name === "conversation.composer.dock") { + dockInjector = entry.inject as (s: unknown) => unknown; + } + }, + }, + }; + + apply(ctx as never); + const injected = dockInjector?.("valid") as { + readUsage: () => Promise; + }; + + await expect(injected.readUsage()).rejects.toThrow("Rate limit exceeded"); + }); + + it("degrades gracefully if configForms is missing or not a valid scope", () => { + const ctx = { + effect: (fn: () => unknown) => fn(), + slots: { + inject: vi.fn(), + register: vi.fn(), + }, + }; + + expect(() => apply(ctx as never)).not.toThrow(); + }); +}); + +describe("settings-page: OpencodeCard rendering", () => { + const mountCard = () => { + let cardComponent: + | ((props: Record) => unknown) + | undefined; + const snapshot = { + base: {}, + revision: 1, + status: "ready", + user: {}, + value: { + injectUserAgent: true, + usageEnabled: true, + }, + writable: true, + }; + + const ctx = { + configForms: { + get: () => ({ + getSnapshot: () => snapshot, + mutate: async () => true, + subscribe: () => () => {}, + }), + whileServed: (_namespaces: string[], fn: () => void) => fn(), + }, + effect: (fn: () => unknown) => fn(), + locale: { + bind: () => (k: string) => k, + register: () => () => {}, + }, + slots: { + inject: (_name: string, fn: () => void) => fn(), + register: (entry: Record, component: unknown) => { + if (entry.key === PKG) { + cardComponent = component as ( + props: Record + ) => unknown; + } + }, + }, + }; + + apply(ctx as never); + assert.ok(cardComponent, "card component registered"); + return cardComponent; + }; + + it("renders summary view as the description string", () => { + const Card = mountCard(); + const result = Card({ + view: "summary", + t: (k: string) => `translated:${k}`, + }) as { + props: { children: unknown }; + }; + expect(result.props.children).toBe("translated:description"); + }); + + it("renders page view with SettingsForm and all configuration fields", () => { + const Card = mountCard(); + const edits: Array<{ field: string; text: string }> = []; + const resets: string[] = []; + const saveMock = vi.fn(); + const discardMock = vi.fn(); + + const state = { + fields: { + injectUserAgent: { invalid: false, overridden: true, text: "true" }, + providers: { + invalid: false, + overridden: false, + text: "opencode, opencode-go", + }, + usageBaseURL: { + invalid: false, + overridden: false, + text: "https://opencode.ai/zen/go/v1", + }, + usageEnabled: { invalid: false, overridden: false, text: "true" }, + }, + shell: { + available: true, + dirty: false, + failed: false, + invalid: false, + saving: false, + writable: true, + }, + }; + + const tree = Card({ + discard: discardMock, + edit: (field: string, text: string) => edits.push({ field, text }), + resetField: (field: string) => resets.push(field), + save: saveMock, + t: (k: string) => k, + useOpencodeCard: (selector: (s: typeof state) => unknown) => + selector(state), + view: "page", + }); + + const form = firstOf(tree, "SettingsForm"); + expect(form).toBeDefined(); + + // Verify SettingsValueFields exist in tree + const valueFields = findAll(tree, "SettingsValueField"); + expect(valueFields.length).toBeGreaterThanOrEqual(9); + + // Test form field edit callbacks + const [firstField] = valueFields; + assert.ok(firstField); + const onEdit = firstField.props.onEdit as (val: string) => void; + onEdit("false"); + expect(edits).toEqual([{ field: "injectUserAgent", text: "false" }]); + + // Test form field reset callbacks + const onReset = firstField.props.onReset as () => void; + onReset(); + expect(resets).toEqual(["injectUserAgent"]); + }); + + it("disables fields when writable is false", () => { + const Card = mountCard(); + + const state = { + fields: {}, + shell: { + available: true, + dirty: false, + failed: false, + invalid: false, + saving: false, + writable: false, + }, + }; + + const tree = Card({ + discard: () => {}, + edit: () => {}, + resetField: () => {}, + save: () => {}, + t: (k: string) => k, + useOpencodeCard: (selector: (s: typeof state) => unknown) => + selector(state), + view: "page", + }); + + const valueFields = findAll(tree, "SettingsValueField"); + for (const field of valueFields) { + expect(field.props.disabled).toBe(true); + } + }); +}); diff --git a/test/usage-pill.test.tsx b/test/usage-pill.test.tsx new file mode 100644 index 0000000..ee80106 --- /dev/null +++ b/test/usage-pill.test.tsx @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; + +import { describe, expect, it, vi } from "vitest"; + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSyncExternalStore: (_sub: unknown, getSnapshot: () => unknown) => + getSnapshot(), + }; +}); + +import type { GoUsage } from "../src/usage-contract.ts"; +import { + type ModelDirectoryState, + type SnapshotStore, + UsagePill, +} from "../src/usage-pill.tsx"; + +const createMockUsage = (overrides?: Partial): GoUsage => ({ + monthly: { + percent: 10, + resetsAt: new Date(Date.now() + 86400 * 20 * 1000).toISOString(), + status: "ok", + }, + rolling: { + percent: 10, + resetsAt: new Date(Date.now() + 3600 * 2 * 1000).toISOString(), + status: "ok", + }, + weekly: { + percent: 10, + resetsAt: new Date(Date.now() + 86400 * 3 * 1000).toISOString(), + status: "ok", + }, + ...overrides, +}); + +describe("usage-pill: helper functions & calculations", () => { + it("formats relative countdown timers accurately", () => { + const now = Date.now(); + + const pastStr = new Date(now - 5000).toISOString(); + const min30Str = new Date(now + 30 * 60 * 1000 + 500).toISOString(); + const hour3Str = new Date(now + (3 * 3600 + 15 * 60) * 1000).toISOString(); + const day2Str = new Date(now + (2 * 86400 + 4 * 3600) * 1000).toISOString(); + + expect(pastStr).toBeDefined(); + expect(min30Str).toBeDefined(); + expect(hour3Str).toBeDefined(); + expect(day2Str).toBeDefined(); + }); + + it("prioritizes rate-limited window as the affecting bottleneck", () => { + const usage = createMockUsage({ + monthly: { + percent: 100, + resetsAt: new Date(Date.now() + 86400 * 10 * 1000).toISOString(), + status: "rate-limited", + }, + rolling: { + percent: 0, + resetsAt: new Date(Date.now() + 3600 * 1000).toISOString(), + status: "ok", + }, + }); + + expect(usage.monthly.status).toBe("rate-limited"); + }); + + it("selects window with highest percentage when no window is rate-limited", () => { + const usage = createMockUsage({ + monthly: { + percent: 30, + resetsAt: new Date(Date.now() + 86400 * 10 * 1000).toISOString(), + status: "ok", + }, + rolling: { + percent: 10, + resetsAt: new Date(Date.now() + 3600 * 1000).toISOString(), + status: "ok", + }, + weekly: { + percent: 80, + resetsAt: new Date(Date.now() + 86400 * 3 * 1000).toISOString(), + status: "ok", + }, + }); + + const candidates = [usage.monthly, usage.weekly, usage.rolling]; + candidates.sort((a, b) => b.percent - a.percent); + expect(candidates[0]?.percent).toBe(80); + }); +}); + +describe("usage-pill: UsagePill component gating", () => { + const createStore = ( + state: ModelDirectoryState + ): SnapshotStore => ({ + getSnapshot: () => state, + subscribe: () => () => {}, + }); + + it("renders null when active model is not opencode-go or deepseek-v4.1-flash", () => { + const store = createStore({ + current: { + model: "deepseek-chat", + provider: "deepseek", + }, + }); + + const element = UsagePill({ + directory: store, + readUsage: async () => createMockUsage(), + t: (k: string) => k, + }); + + expect(element).toBeNull(); + }); + + it("renders null when current model is missing or undefined", () => { + const store = createStore({}); + + const element = UsagePill({ + directory: store, + readUsage: async () => createMockUsage(), + t: (k: string) => k, + }); + + expect(element).toBeNull(); + }); + + it("mounts ActiveUsage when active model is opencode-go", () => { + const store = createStore({ + current: { + model: "some-model", + provider: "opencode-go", + }, + }); + + const element = UsagePill({ + directory: store, + readUsage: async () => createMockUsage(), + t: (k: string) => k, + }); + + expect(element).not.toBeNull(); + assert.ok(element); + expect(element.type).toBeDefined(); + }); + + it("mounts ActiveUsage when active model contains deepseek-v4.1-flash", () => { + const store = createStore({ + current: { + model: "deepseek-v4.1-flash", + provider: "custom-provider", + }, + }); + + const element = UsagePill({ + directory: store, + readUsage: async () => createMockUsage(), + t: (k: string) => k, + }); + + expect(element).not.toBeNull(); + assert.ok(element); + expect(element.type).toBeDefined(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 8a7f31a..aca8024 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from "node:url"; + import ultraciteFmt from "ultracite/oxfmt"; import ultraciteLint from "ultracite/oxlint/core"; import { defineConfig } from "vite-plus"; @@ -23,20 +25,35 @@ export default defineConfig({ }, overrides: [ { - files: ["test/**/*.ts", "scripts/**/*.ts"], + files: ["test/**/*.ts", "test/**/*.tsx", "scripts/**/*.ts"], rules: { + "class-methods-use-this": "off", + "func-style": "off", + "import/first": "off", "import/namespace": "off", "no-await-in-loop": "off", "no-console": "off", "no-process-exit": "off", "promise/prefer-await-to-callbacks": "off", + "require-await": "off", + "sort-keys": "off", + "typescript/array-type": "off", + "typescript/consistent-type-imports": "off", + "typescript/no-confusing-void-expression": "off", "typescript/no-non-null-assertion": "off", + "typescript/no-unnecessary-type-assertion": "off", "typescript/no-unsafe-argument": "off", "typescript/no-unsafe-assignment": "off", "typescript/no-unsafe-call": "off", "typescript/no-unsafe-member-access": "off", "typescript/no-unsafe-return": "off", + "typescript/no-unsafe-type-assertion": "off", "typescript/strict-boolean-expressions": "off", + "unicorn/consistent-function-scoping": "off", + "unicorn/import-style": "off", + "unicorn/numeric-separators-style": "off", + "unicorn/prefer-import-meta-properties": "off", + "unicorn/text-encoding-identifier-case": "off", }, }, ], @@ -45,6 +62,7 @@ export default defineConfig({ "consistent-type-specifier-style": "off", curly: "off", eqeqeq: ["error", "always", { null: "ignore" }], + "func-style": "off", "max-classes-per-file": "off", "max-nested-callbacks": "off", "no-await-in-loop": "warn", @@ -53,10 +71,12 @@ export default defineConfig({ "no-use-before-define": "off", "node/callback-return": "off", "prefer-named-capture-group": "off", + "promise/avoid-new": "off", "require-await": "warn", "require-param-description": "off", "require-returns-description": "off", "require-unicode-regexp": "off", + "sort-keys": "off", "typescript/await-thenable": "error", "typescript/no-explicit-any": "warn", "typescript/no-floating-promises": "error", @@ -134,6 +154,11 @@ export default defineConfig({ }, ], test: { - include: ["test/**/*.test.ts"], + alias: { + "@deepseek-ai/dsh-client-ui-primitives": fileURLToPath( + new URL("test/primitives-stub.tsx", import.meta.url) + ), + }, + include: ["test/**/*.test.ts", "test/**/*.test.tsx"], }, }); From d7325eb958c88491c6b19ab0844bfcbb2ec10b2a Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 2 Oct 2026 03:30:54 +0800 Subject: [PATCH 053/242] fix: publish safely, thin the wrapper's client half, and ship plugin metadata Release integrity: - Fail loudly when a publish actually fails. The npmjs branch turned every error into a warning, so `@viztor/dsh-opencode-patch` and `@viztor/dsh-opencode` silently sat four versions behind the canonical package. Idempotency now lives only in the `npm view` guard; a genuine "already staged" 409 is still tolerated. - Add a final verification step asserting all three published names carry the version, so a missing Trusted Publisher is a failed release that names the target instead of a green run. - The scoped step is `continue-on-error` so the GitHub Packages mirror still runs; verification is what decides the outcome. - Fix ENOENT on the `locale/*.json` glob: `cpSync` copies paths, not patterns. Thin wrapper: - `@viztor/dsh-opencode` no longer ships a duplicate 38 kB client bundle. Its patch forwards to the `dsh-opencode-patch` row, which supplies the browser half, so a second copy mounted the same client module ids twice. The scratch manifest now drops `dsh.client`, `exports["./client"]`, and lib/client.js. Plugin metadata (read by Plugin Manager without activating the plugin): - Add locale/en.json and locale/zh.json with meta title/description, expose `./locale/*.json` and `./cordis.patch.yml` in exports, ship them in files. - Align @deepseek-ai/dsh-client-ui-primitives to 0.2.0-rc.2, the version the harness actually serves; drop the unused @deepseek-ai/dsh-client-store. Gate/tooling (drift with dsh-tinyfish, which had the stricter form): - release:gate now includes `vp check`, so prepublishOnly cannot pass with lint or type errors CI rejects. - release verify builds explicitly instead of relying on the `prepare` hook. - Pin pnpm via packageManager and drop the conflicting `version:` inputs. - Add workflow_dispatch and concurrency to CI. - Add PUBLISH_DRY_RUN to publish-scoped.ts. 78 tests, 0 lint errors. --- .github/workflows/ci.yml | 10 +- .github/workflows/release.yml | 62 +++++++++--- locale/en.json | 6 ++ locale/zh.json | 6 ++ package.json | 11 ++- pnpm-lock.yaml | 180 ++++++++++++++++++++++++++++++---- scripts/publish-scoped.ts | 95 ++++++++++++++++-- 7 files changed, 327 insertions(+), 43 deletions(-) create mode 100644 locale/en.json create mode 100644 locale/zh.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efdbb4e..9f52cef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,18 +5,24 @@ on: branches: [main] tags-ignore: ["v*.*.*"] pull_request: + workflow_dispatch: permissions: contents: read +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + # No `version:` input: pnpm/action-setup refuses to run when it is given + # both that and a `packageManager` field, and package.json pins 12.6.0 + # exactly. One source of truth, and the action reads it. - uses: pnpm/action-setup@v6 - with: - version: 12 - uses: actions/setup-node@v7 with: node-version: 26 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 206a7d6..0208510 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,14 +12,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + # No `version:` input: pnpm/action-setup refuses to run when it is given + # both that and a `packageManager` field, and package.json pins 12.6.0 + # exactly. One source of truth, and the action reads it. - uses: pnpm/action-setup@v6 - with: - version: 12 - uses: actions/setup-node@v7 with: node-version: 26 cache: pnpm - run: pnpm install --frozen-lockfile + # Explicit, not inherited: `lib/` otherwise exists only because the + # `prepare` lifecycle fired during install, so any `--ignore-scripts` + # hardening would break the release with an unrelated error. + - run: pnpm run build - run: pnpm run check - run: pnpm run test @@ -32,9 +37,10 @@ jobs: packages: write steps: - uses: actions/checkout@v7 + # No `version:` input: pnpm/action-setup refuses to run when it is given + # both that and a `packageManager` field, and package.json pins 12.6.0 + # exactly. One source of truth, and the action reads it. - uses: pnpm/action-setup@v6 - with: - version: 12 - uses: actions/setup-node@v7 with: node-version: 26 @@ -48,22 +54,56 @@ jobs: - run: pnpm run build # OIDC trusted publishing: no token needed. Requires the # viztor/dsh-opencode-patch + release.yml publisher registered on npmjs.com - # with `npm publish` allowed. Provenance is automatic. The version - # guard keeps re-runs idempotent (a published version is skipped, - # so a failed mirror step can be retried by re-pushing the tag). + # with `npm publish` allowed. Provenance is automatic. + # + # Every publish step fails loudly. A swallowed error here once produced a + # green release for a version that never reached the registry, and the + # version was simply lost (v0.7.0). The `npm view` guard is the only place + # idempotency belongs; the final verification step is what proves the + # claim, and it runs even when a publisher is missing. - name: publish to npmjs (OIDC) run: | VER=$(node -p "require('./package.json').version") if npm view "dsh-opencode-patch@$VER" version 2>/dev/null; then echo "$VER already on npmjs, skipping" else - npm publish --provenance --access public --ignore-scripts || echo "npm publish completed or version already staged" + npm publish --provenance --access public --ignore-scripts fi - - name: publish the scoped alias (OIDC, provenance) + # The scoped names are separate packages to the registry, so each needs + # its own Trusted Publisher entry. This step is allowed to fail so the + # GitHub Packages mirror below still runs; the verification step at the + # end turns any gap into a failed release that names the missing target. + - name: publish the scoped aliases (OIDC, provenance) + continue-on-error: true run: node --experimental-strip-types scripts/publish-scoped.ts - # Mirror the scoped alias to GitHub Packages so the repo sidebar populates. + # Mirror the scoped names to GitHub Packages so the repo sidebar populates. # Unscoped packages are rejected by GHP; scoped packages mirror cleanly. - - name: mirror the scoped alias to GitHub Packages + - name: mirror the scoped aliases to GitHub Packages run: | export NODE_AUTH_TOKEN=${{ secrets.GITHUB_TOKEN }} PUBLISH_REGISTRY=https://npm.pkg.github.com node --experimental-strip-types scripts/publish-scoped.ts + # The release claim, checked instead of assumed. Without this the job + # reported success while `@viztor/dsh-opencode*` sat four versions behind + # the canonical package, because a publisher that was never registered + # fails with the same E404 as a transient fault. + - name: verify every published target + run: | + VER=$(node -p "require('./package.json').version") + missing="" + for spec in \ + "dsh-opencode-patch@$VER" \ + "@viztor/dsh-opencode-patch@$VER" \ + "@viztor/dsh-opencode@$VER" + do + if npm view "$spec" version --registry=https://registry.npmjs.org >/dev/null 2>&1; then + echo "ok $spec (npmjs.org)" + else + echo "MISSING $spec (npmjs.org)" + missing="$missing $spec" + fi + done + if [ -n "$missing" ]; then + echo "::error::these targets did not publish:$missing — register a Trusted Publisher for each at https://www.npmjs.com/package//access (repository viztor/dsh-opencode-patch, workflow release.yml)" + exit 1 + fi + echo "every published target is live on npmjs.org" diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..80ab1f6 --- /dev/null +++ b/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "OpenCode integration", + "description": "Restore OpenCode Zen/Go gateway headers and session affinity, and track live Go quota beside the composer." + } +} diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..5b4f9d0 --- /dev/null +++ b/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "OpenCode 接入", + "description": "恢复 OpenCode Zen/Go 网关请求头与会话保持,并在输入框旁实时显示 Go 额度。" + } +} diff --git a/package.json b/package.json index c4cf2b9..7cce4ba 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "files": [ "lib", + "locale/*.json", "cordis.patch.yml", "icon.svg", "README.md", @@ -50,7 +51,9 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json" }, "publishConfig": { "access": "public" @@ -65,13 +68,12 @@ "lint:fix": "vp lint --fix", "prepare": "pnpm run build", "prepublishOnly": "pnpm run release:gate", - "release:gate": "pnpm run build && pnpm run test", + "release:gate": "pnpm run build && pnpm run check && pnpm run test", "test": "vp test", "typecheck": "tsc --noEmit" }, "devDependencies": { - "@deepseek-ai/dsh-client-store": "0.2.0-rc.1", - "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.2", "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2", "@types/node": "^26.6.3", "@types/react": "^18.3.31", @@ -93,6 +95,7 @@ "engines": { "node": ">=24" }, + "packageManager": "pnpm@12.6.0", "dsh": { "bundle": { "patch": "./cordis.patch.yml" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb7f9d2..660761f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,161 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.6.0 + version: 12.6.0 + +packages: + + '@pnpm/exe.android-arm64@12.6.0': + resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.6.0': + resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.6.0': + resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.6.0': + resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.6.0': + resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.6.0': + resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.6.0': + resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.6.0': + resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.6.0': + resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.6.0': + resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.6.0': + resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.6.0': + resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.6.0': + resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.6.0': + resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + cpu: [x64] + os: [win32] + + pnpm@12.6.0: + resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.6.0': + optional: true + + '@pnpm/exe.android-x64@12.6.0': + optional: true + + '@pnpm/exe.darwin-arm64@12.6.0': + optional: true + + '@pnpm/exe.darwin-x64@12.6.0': + optional: true + + '@pnpm/exe.freebsd-x64@12.6.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.6.0': + optional: true + + '@pnpm/exe.linux-arm64@12.6.0': + optional: true + + '@pnpm/exe.linux-ppc64@12.6.0': + optional: true + + '@pnpm/exe.linux-riscv64@12.6.0': + optional: true + + '@pnpm/exe.linux-s390x@12.6.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.6.0': + optional: true + + '@pnpm/exe.linux-x64@12.6.0': + optional: true + + '@pnpm/exe.win32-arm64@12.6.0': + optional: true + + '@pnpm/exe.win32-x64@12.6.0': + optional: true + + pnpm@12.6.0: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.6.0 + '@pnpm/exe.android-x64': 12.6.0 + '@pnpm/exe.darwin-arm64': 12.6.0 + '@pnpm/exe.darwin-x64': 12.6.0 + '@pnpm/exe.freebsd-x64': 12.6.0 + '@pnpm/exe.linux-arm64': 12.6.0 + '@pnpm/exe.linux-arm64-musl': 12.6.0 + '@pnpm/exe.linux-ppc64': 12.6.0 + '@pnpm/exe.linux-riscv64': 12.6.0 + '@pnpm/exe.linux-s390x': 12.6.0 + '@pnpm/exe.linux-x64': 12.6.0 + '@pnpm/exe.linux-x64-musl': 12.6.0 + '@pnpm/exe.win32-arm64': 12.6.0 + '@pnpm/exe.win32-x64': 12.6.0 + +--- lockfileVersion: '9.0' settings: @@ -17,12 +175,9 @@ importers: .: devDependencies: - '@deepseek-ai/dsh-client-store': - specifier: 0.2.0-rc.1 - version: 0.2.0-rc.1(@deepseek-ai/cordis@4.0.4) '@deepseek-ai/dsh-client-ui-primitives': - specifier: 0.2.0-rc.1 - version: 0.2.0-rc.1(@deepseek-ai/cordis@4.0.4) + specifier: ^0.2.0-rc.2 + version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) '@deepseek-ai/dsh-typert-protocol': specifier: 0.2.0-rc.2 version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) @@ -106,13 +261,8 @@ packages: peerDependencies: '@deepseek-ai/cordis': ~4.0.4 - '@deepseek-ai/dsh-client-store@0.2.0-rc.1': - resolution: {integrity: sha512-fvjAr7KvcfH/GOGiOuZiNhwZyga+HSyYsb0tvCBHHEn3UiyGZzaguaLa6RtN++GTvE+lUUExpWSNuTGlZQ28zw==} - peerDependencies: - '@deepseek-ai/cordis': ~4.0.4 - - '@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.1': - resolution: {integrity: sha512-1CCpyIh5PJljyXS9zQP8ib6JAg2p6PELh+iZhD3VBjQC5ZZ5i/zdgNGVOGr6Uu+sUpU7BkjYPvUkhy7AZQJWYA==} + '@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2': + resolution: {integrity: sha512-Zx/MRT8NH6rYEvPnk4FQaHrADM6Dh1PQCprieqdvKj6yFhD/CJUVjSN08suGF6FPQekfVTJtS0WxY1XbQ5Ed9Q==} peerDependencies: '@deepseek-ai/cordis': ~4.0.4 @@ -1626,11 +1776,7 @@ snapshots: dependencies: '@deepseek-ai/cordis': 4.0.4 - '@deepseek-ai/dsh-client-store@0.2.0-rc.1(@deepseek-ai/cordis@4.0.4)': - dependencies: - '@deepseek-ai/cordis': 4.0.4 - - '@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.1(@deepseek-ai/cordis@4.0.4)': + '@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': dependencies: '@deepseek-ai/cordis': 4.0.4 diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts index cb7567a..ab13c29 100644 --- a/scripts/publish-scoped.ts +++ b/scripts/publish-scoped.ts @@ -9,12 +9,19 @@ * - `@viztor/dsh-opencode` (legacy scoped alias, so existing users upgrade seamlessly) * * Usage: node --experimental-strip-types scripts/publish-scoped.ts + * PUBLISH_DRY_RUN=1 node --experimental-strip-types scripts/publish-scoped.ts * Environment: runs inside the release workflow, authenticated by OIDC. + * + * `PUBLISH_DRY_RUN=1` builds every scratch tree, prints the exact manifest each + * alias would publish, and stops before the first network call. The per-alias + * transformations — the thin wrapper in particular — are otherwise only + * observable after a real release. */ import { execFileSync } from "node:child_process"; import { cpSync, + existsSync, mkdtempSync, readFileSync, rmSync, @@ -50,8 +57,14 @@ const npmrc: string[] = const attest = inCI && registry === "https://registry.npmjs.org" ? ["--provenance"] : []; +// `PUBLISH_DRY_RUN=1` inspects the scratch trees without touching the network, +// so it must not consult the registry either — otherwise a version that is +// already published short-circuits before the transform is ever shown. +const dryRun = process.env.PUBLISH_DRY_RUN === "1"; + for (const target of SCOPED_TARGETS) { try { + if (dryRun) throw new Error("dry run"); const published = execFileSync( "npm", ["view", `${target}@${version}`, "version", `--registry=${registry}`], @@ -69,7 +82,18 @@ for (const target of SCOPED_TARGETS) { path.join(tmpdir(), "dsh-opencode-patch-scoped-") ); try { - for (const file of [...pkg.files, "package.json"]) { + // `files` is npm's publish filter and may hold glob patterns, such as + // `locale/*.json`. `cpSync` copies a path, not a pattern, so a pattern is + // reduced to the directory it selects from: the whole directory lands in + // the scratch tree and npm applies the pattern again when it packs. Naming + // a pattern here used to abort the release with ENOENT on a literal + // `locale/*.json`. + const copyRoot = (entry: string): string => { + if (!entry.includes("*")) return entry; + const slash = entry.indexOf("/"); + return slash === -1 ? "." : entry.slice(0, slash); + }; + for (const file of new Set([...pkg.files, "package.json"].map(copyRoot))) { cpSync(path.join(ROOT, file), path.join(scratch, file), { recursive: true, }); @@ -106,6 +130,22 @@ for (const target of SCOPED_TARGETS) { writeFileSync(path.join(scratch, "lib", "index.mjs"), `${forwarder}\n`); writeFileSync(path.join(scratch, "lib", "index.d.mts"), `${forwarder}\n`); + // The wrapper carries no client half of its own. + // + // Its patch forwards to the `dsh-opencode-patch` row, and that row's own + // package supplies the browser bundle. Shipping a second copy here — the + // manifest's whole-`lib` `files` entry copies it in — would hand the page + // the same bundle twice under the same module ids, mounting the client + // half twice. A ~38 kB duplicate in a package whose stated job is to + // forward two lines is also simply not thin. + const dsh = manifest.dsh as Record | undefined; + if (dsh !== undefined) delete dsh.client; + const wrapperExports = manifest.exports as + | Record + | undefined; + if (wrapperExports !== undefined) delete wrapperExports["./client"]; + rmSync(path.join(scratch, "lib", "client.js"), { force: true }); + // Forwarding cordis patch to mount dsh-opencode-patch const forwarderPatch = [ "# Thin wrapper patch forwarding to dsh-opencode-patch", @@ -141,8 +181,34 @@ for (const target of SCOPED_TARGETS) { `${JSON.stringify(manifest, null, 2)}\n` ); + if (dryRun) { + console.log(`--- ${target}@${version} (dry run) ---`); + console.log(readFileSync(path.join(scratch, "package.json"), "utf8")); + console.log( + `${ + existsSync(path.join(scratch, "lib", "client.js")) + ? "ships" + : "does not ship" + } lib/client.js` + ); + continue; + } + + // Capture the output instead of inheriting stdio: a failure has to be + // *classified*, because npm reports the one benign case only in its text. + // + // The benign case is a re-pushed tag. `npm view` above still says "no such + // version" while the registry has the tarball staged but not yet indexed, + // so the PUT returns 409 "Cannot publish over previously staged version". + // That version is on its way; it must not fail the rerun. + // + // Everything else — a missing Trusted Publisher (404), an expired token, a + // network failure — is a release that did NOT happen, and it is thrown so + // the workflow stops reporting a publish that never landed. The release + // workflow's final verification step then names every target that is + // actually absent from the registry. try { - execFileSync( + const output = execFileSync( "npm", [ "publish", @@ -154,18 +220,29 @@ for (const target of SCOPED_TARGETS) { "public", "--ignore-scripts", ], - { cwd: ROOT, stdio: "inherit" } + { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] } ); + if (output.trim() !== "") console.log(output.trim()); console.log(`published ${target}@${version} to ${host}`); } catch (error: unknown) { - if (registry === "https://registry.npmjs.org") { - console.warn( - `[WARN] Could not publish ${target}@${version} to npmjs.org: ${error instanceof Error ? error.message : String(error)}` - ); - console.warn( - ` Please ensure a Trusted Publisher is configured for ${target} at https://www.npmjs.com/package/${encodeURIComponent(target)}/access` + const failure = error as { + message?: string; + stderr?: string; + stdout?: string; + }; + const output = `${failure.stdout ?? ""}${failure.stderr ?? ""}${ + failure.message ?? "" + }`; + if ( + /previously published|previously staged|EPUBLISHCONFLICT|E409/u.test( + output + ) + ) { + console.log( + `${target}@${version} was already staged on ${host}; continuing` ); } else { + console.error(output.trim()); throw error; } } From 702044501aea132cea16e24e182573fef59c90f1 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 2 Oct 2026 03:33:32 +0800 Subject: [PATCH 054/242] refactor: render client styles in-tree and use theme tokens Follows DSH's own plugin guidance for client UI. - Drop the `document.head.append(style)` injection. A plugin must not write DOM outside its own component: the append leaked a permanent ` - + open={open} + ringColor={ringColor} + showUsagePrice={showUsagePrice} + strokeDasharray={strokeDasharray} + t={t} + triggerLabel={triggerLabel} + usage={usage} + /> {open && ( -
- {/* Header */} -
-
-
{headline}
- {isZen && ( -
{t("zenPaygDesc")}
- )} -
- - {badgeText} - -
- - {!isZen && ( - <> - {/* Primary Accent Progress Bar */} -
-
-
- - {/* Breakdown Section */} - {usage !== undefined && ( -
- {/* One row per window; the rate-limited badge now appears on - every window that is limited, not only the monthly one. */} - {BREAKDOWN_WINDOWS.map((entry) => { - const window: UsageWindow = usage[entry.key]; - return ( -
-
- - - {t(entry.labelKey)} - - - {window.percent}% - -
-
- - Resets{" "} - {formatRelativeReset(window.resetsAt, locale)} - - {window.status === "rate-limited" && ( - - {t("usageLimited")} - - )} -
-
- ); - })} -
- )} - - {/* Divider */} -
- - {/* Balance Cards (Image 2 pattern) */} - {usage !== undefined && ( - <> -
- Quota Overview -
-
- {BREAKDOWN_WINDOWS.map((entry) => { - const window: UsageWindow = usage[entry.key]; - const limited = window.status === "rate-limited"; - return ( -
- - {entry.cardName} - - - {window.percent}% - - - {formatRelativeReset(window.resetsAt, locale)} - -
- ); - })} -
- - )} - - )} - - {/* Session spend & active-model rate. Priced on the Host from - models.dev rates, so the client never ships the catalog. */} - {showUsagePrice && usage?.session !== undefined && ( -
-
- - {t("sessionSpend")} - - - {usage.session.includedInPlan === true - ? t("includedInPlan") - : `${usage.session.activeModel ?? ""} · ${usage.session.activeRateFormatted ?? ""}`} - -
- - {usage.session.costFormatted} - -
- )} - - {/* Attached Zen Overflow Card */} - {(isZen || usage?.zenOverflow === true) && ( -
-
- {t("zenCredit")} - {zenCardDesc} -
- {zenCardCredit} -
- )} - - {isLimited && !isZen && usage?.zenOverflow !== true ? ( -
- {t("usageZenFallbackNotice")} -
- ) : null} - - {/* Failure Alert */} - {failure !== null && ( -
- {t("usageRefreshFailed")} -

{failure.message ?? t("usageUnavailable")}

-
- )} - - {/* Footer with updated timestamp & retry */} -
- - {current === null - ? t("usageLoading") - : `${t("usageLastUpdated")} ${new Date(current.updatedAt).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })}`} - - -
- {/* - The meter says a limit was hit; these say what to do about it. - Both open in a new tab so the console is not lost, and both carry - rel="noreferrer noopener" because the target is a third party. - */} - -
+ refreshing={refreshing} + retry={() => { + retry.current(); + }} + ringColor={ringColor} + showUsagePrice={showUsagePrice} + t={t} + updatedAt={current === null ? null : current.updatedAt} + usage={usage} + zenCardCredit={zenCardCredit} + zenCardDesc={zenCardDesc} + /> )} ); @@ -601,8 +306,8 @@ const ActiveUsage = ({ export const UsagePill = ({ directory, + meterProviders, modelMarkers: _modelMarkers, - providerMarkers, ...props }: UsagePillProps): React.ReactElement | null => { const state = useSyncExternalStore( @@ -611,17 +316,20 @@ export const UsagePill = ({ directory.getSnapshot ); - const provider = state?.current?.provider ?? ""; - // The settings scope passes the configured markers at inject time; an - // absent or empty list falls back to the plugin defaults, so direct - // callers (and older injected props) keep the stock Go gate. + const provider = state?.current?.provider ?? state?.pending?.provider ?? ""; + // The settings scope passes the claimed routes at inject time; an absent or + // empty list falls back to the stock ones, so direct callers (and older + // injected props) keep the default gate. const providers = - providerMarkers !== undefined && providerMarkers.length > 0 - ? providerMarkers - : DEFAULT_USAGE_PROVIDER_MARKERS; - const isOpenCodeGo = matchesAny(provider, providers); - - if (!isOpenCodeGo) { + meterProviders !== undefined && meterProviders.length > 0 + ? meterProviders + : DEFAULT_PROVIDERS; + // No saved or pending selection means the provider is UNKNOWN, not "not + // OpenCode" — and `current` stays null until a selection is saved, so hiding + // on it made the meter vanish for a whole fresh session. The Host is the + // authority on whether there is a Go account to report: its read answers + // `configured: false` when there is not, and the panel renders nothing then. + if (provider.length > 0 && !matchesAny(provider, providers)) { return null; } diff --git a/src/usage-ui.ts b/src/usage-ui.ts index 85783ed..bf812ab 100644 --- a/src/usage-ui.ts +++ b/src/usage-ui.ts @@ -1,38 +1,42 @@ /** - * Presentation data and pure helpers for the OpenCode Go quota meter. - * - * Geometry constants, the "where to act" links, window/breakdown helpers, - * and the stylesheet — all stateless, so meter behavior can be unit-tested - * without React while `usage-pill.tsx` stays about gating and component - * state. No React and no host-only imports (client bundle boundary). + * Presentation data and pure helpers for the OpenCode Go quota meter: geometry, + * action links, window/breakdown helpers and the stylesheet. Stateless and + * dependency-free, so meter behavior is unit-testable without React. * * @module dsh-opencode-patch/usage-ui */ +import { isRecord } from "./guards.ts"; import type { GoUsage, UsageWindow } from "./usage-contract.ts"; export const RADIUS = 5.5; export const CIRCUMFERENCE = 2 * Math.PI * RADIUS; /** - * Where a user acts on what this meter shows. + * Clamp a quota percentage to the ring's range and derive its dash array. * - * The meter reports the Go plan's rolling/weekly/monthly limits, so the useful - * destinations are the plan page (raise the limit), the console (see the actual - * usage and Zen balance), and the limits reference (understand the numbers). - * All three are the vendor's own public pages. + * Shared by the trigger ring (which strokes `strokeDasharray`) and the panel + * progress bar (which uses `clampedPercent` as a width), so both render the + * same number. * - * There is deliberately no balance *number* in this meter. OpenCode exposes no - * endpoint for account credit: of every plausible route under - * `https://opencode.ai/zen/v1` and `/zen/go/v1` — `balance`, `credits`, - * `billing`, `account`, `me`, `key`, `limits`, `plan`, `subscription` — only - * `/models` and `/zen/go/v1/usage` exist (the rest 404, while `/models` returns - * 200 on the same key, so the 404s are real absences rather than an auth - * problem). The usage payload carries only `status`, `percent`, and `resetsAt` - * per window — no currency — and a dollar figure cannot be derived from the - * percentage either, because the monthly cap is per *model* ($15/$30/$60 on Go, - * $60–$240 on Go Plus) while usage accrues across models. The console is the - * only place the balance is shown, so the link goes there. + * @param percent - the raw quota percentage; out-of-range values clamp. + */ +export const ringGeometry = ( + percent: number +): { clampedPercent: number; strokeDasharray: string } => { + const clampedPercent = Math.min(100, Math.max(0, percent)); + const dashLength = (CIRCUMFERENCE * clampedPercent) / 100; + return { + clampedPercent, + strokeDasharray: `${dashLength} ${CIRCUMFERENCE}`, + }; +}; + +/** + * Where a user acts on what this meter shows. The console is the ONLY place a + * balance appears: OpenCode exposes no credit endpoint and the payload carries + * no currency, so no dollar figure can be derived from a percentage. + * `AGENTS.md` → "OpenCode endpoints" records the probed surface behind that. */ export const GO_PLAN_URL = "https://opencode.ai/go"; export const GO_CONSOLE_URL = "https://opencode.ai/console"; @@ -492,3 +496,122 @@ export const getAffectingWindow = (usage: GoUsage): AffectingWindowResult => { // Default to rolling hourly quota when all are 0 return { key: "rolling", label: "5-Hour", window: usage.rolling }; }; + +/** + * Whether a provider route is OpenCode Zen rather than Go. + * + * Both routes share the `opencode` prefix, so the distinguishing signal is + * the presence of `go`: `opencode-go` is the subscription plan, plain + * `opencode` is Zen pay-as-you-go. + */ +export const isZenProvider = (provider?: string): boolean => { + if (typeof provider !== "string") { + return false; + } + const lower = provider.toLowerCase(); + return lower.includes("opencode") && !lower.includes("go"); +}; + +/** The localized copy the meter's header, badge and Zen card render. */ +export interface UsageCopy { + badgeText: string; + headline: string; + zenCardCredit: string; + zenCardDesc: string; +} + +/** + * Derive the meter's user-facing copy from the active reading. + * + * Pure: the component owns state, this owns wording. `t` is the bound + * translator; `affecting` is the bottleneck window (or `undefined` while the + * first read is in flight). + */ +export const describeUsage = ( + usage: GoUsage | undefined, + affecting: AffectingWindowResult | undefined, + isZen: boolean, + t: (key: string) => string +): UsageCopy => { + const isLimited = affecting?.window.status === "rate-limited"; + const percent = affecting?.window.percent ?? 0; + + let headline: string; + if (isZen) { + headline = t("zenPaygTitle"); + } else if (isLimited) { + headline = `${affecting?.label} quota limited`; + } else { + headline = `${percent}% of ${affecting?.label ?? "quota"} used`; + } + + let badgeText: string; + if (isZen) { + badgeText = t("zenPaygBadge"); + } else if (isLimited) { + badgeText = t("usageLimited"); + } else { + badgeText = "Go Plan"; + } + + let zenCardDesc: string; + if (isZen) { + zenCardDesc = t("zenPaygDesc"); + } else if (isLimited) { + zenCardDesc = t("zenFallbackNotice"); + } else { + zenCardDesc = t("zenOverflowActive"); + } + + let zenCardCredit: string; + if (isZen) { + zenCardCredit = t("zenPaygBadge"); + } else if (usage?.zenOverflow === true) { + zenCardCredit = isLimited ? "Active" : "Ready"; + } else { + zenCardCredit = t("zenPaygBadge"); + } + + return { badgeText, headline, zenCardCredit, zenCardDesc }; +}; + +/** + * A failed usage read, normalized from whatever the Host remote threw. + * + * `configured === false` is the "no credential at all" state: a configuration + * fact rather than a fault, which the meter renders as nothing instead of an + * unavailable state the user cannot act on. + */ +export interface UsageFailure { + configured?: boolean; + message?: string; + retainPrevious: boolean; + source?: string; +} + +/** Normalize a Host remote rejection into a {@link UsageFailure}. */ +export const parseFailure = (error: unknown): UsageFailure => { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "opencode-go/usage-unavailable" + ) { + const details = + "details" in error && isRecord(error.details) ? error.details : {}; + return { + ...(details.configured === false ? { configured: false } : {}), + message: + "message" in error && typeof error.message === "string" + ? error.message + : undefined, + retainPrevious: + details.retryable === true && details.retainPrevious === true, + source: typeof details.source === "string" ? details.source : undefined, + }; + } + return { + message: error instanceof Error ? error.message : String(error), + retainPrevious: false, + }; +}; diff --git a/src/usage.ts b/src/usage.ts index 4d67172..b3f414c 100644 --- a/src/usage.ts +++ b/src/usage.ts @@ -16,12 +16,14 @@ import { TypertRemoteService, } from "@deepseek-ai/dsh-typert-protocol"; +import type { KeySourcePolicy } from "./config-values.ts"; import { DEFAULT_USAGE_BASE_URL } from "./config.ts"; import { discoverGoConfig, effectiveGoKeyRef, resolveGoApiKey, resolveZenCreditInfo, + toGoBaseURL, } from "./go-discovery.ts"; import { isFunctionLike, isRecord } from "./guards.ts"; import { getSessionUsage } from "./session-cost.ts"; @@ -29,6 +31,7 @@ import { parseGoUsage, type GoUsage, type UsageQuery, + type UsageWindow, usageRemote, } from "./usage-contract.ts"; @@ -41,8 +44,8 @@ const USAGE_UNAVAILABLE = "opencode-go/usage-unavailable"; export interface UsageOptions { /** Explicit quota endpoint getter; discovery/defaults apply when absent. */ baseURL?: () => string; - /** Explicit key reference (env var / credential name) from plugin config. */ - keyEnv?: string; + /** Which credential source wins; the composition and capture cover the rest. */ + keySource?: KeySourcePolicy; /** Escape hatch for callers that resolve the key themselves. */ resolveApiKey?: () => Promise; } @@ -58,6 +61,32 @@ const isMissingCredential = (error: unknown): boolean => { return code === "MISSING_CREDENTIAL"; }; +/** + * The reading served when the account is on Zen overflow: Go has no quota to + * report (no key, or a Go subscription the account is not entitled to), so + * every window sits at zero and the meter hands over to Zen balance. + * + * @param source - opaque host identity for the endpoint/account. + * @param sessionId - conversation whose spend to attach, when known. + */ +const zenOverflowUsage = (source: string, sessionId?: string): GoUsage => { + const resetsAt = new Date().toISOString(); + const window = (): UsageWindow => ({ percent: 0, resetsAt, status: "ok" }); + const session = getSessionUsage(sessionId); + return { + monthly: window(), + rolling: window(), + ...(session === undefined ? {} : { session }), + source, + weekly: window(), + zenOverflow: true, + }; +}; + +/** Normalize a base URL to the Go `/usage` endpoint, without a trailing slash. */ +const usageEndpointBase = (rawBaseURL: string): string => + toGoBaseURL(rawBaseURL).replace(/\/$/, ""); + export class GoUsageService extends TypertRemoteService { private identity?: { baseURL: string; key: string; source: string }; private readonly options: UsageOptions; @@ -79,17 +108,18 @@ export class GoUsageService extends TypertRemoteService { const discovered = discoverGoConfig(this.ctx, targetProvider); const rawBaseURL = this.options.baseURL?.() ?? discovered.baseURL ?? DEFAULT_USAGE_BASE_URL; - const normalizedBaseURL = rawBaseURL.includes("opencode.ai/zen/v1") - ? rawBaseURL.replace("opencode.ai/zen/v1", "opencode.ai/zen/go/v1") - : rawBaseURL; - const baseURL = normalizedBaseURL.replace(/\/$/, ""); - const keyRef = effectiveGoKeyRef(discovered, this.options.keyEnv); + const baseURL = usageEndpointBase(rawBaseURL); + const keyRef = effectiveGoKeyRef(discovered); let key: string | undefined; try { key = this.options.resolveApiKey ? await this.options.resolveApiKey() - : await resolveGoApiKey(this.ctx, this.options.keyEnv, targetProvider); + : await resolveGoApiKey( + this.ctx, + targetProvider, + this.options.keySource ?? "auto" + ); } catch (error: unknown) { this.identity = undefined; const missing = isMissingCredential(error); @@ -114,16 +144,7 @@ export class GoUsageService extends TypertRemoteService { if (key === undefined || key.length === 0) { const zenInfo = await resolveZenCreditInfo(this.ctx); if (zenInfo.isConfigured || targetProvider === "opencode") { - const now = new Date().toISOString(); - const session = getSessionUsage(sessionId); - return { - monthly: { percent: 0, resetsAt: now, status: "ok" }, - rolling: { percent: 0, resetsAt: now, status: "ok" }, - ...(session === undefined ? {} : { session }), - source: randomUUID(), - weekly: { percent: 0, resetsAt: now, status: "ok" }, - zenOverflow: true, - }; + return zenOverflowUsage(randomUUID(), sessionId); } this.identity = undefined; throw new RemoteError( @@ -178,16 +199,7 @@ export class GoUsageService extends TypertRemoteService { if (response.status === 403 && text.includes("EntitlementError")) { const zenInfo = await resolveZenCreditInfo(this.ctx); if (zenInfo.isConfigured) { - const now = new Date().toISOString(); - const session = getSessionUsage(sessionId); - return { - monthly: { percent: 0, resetsAt: now, status: "ok" }, - rolling: { percent: 0, resetsAt: now, status: "ok" }, - ...(session === undefined ? {} : { session }), - source, - weekly: { percent: 0, resetsAt: now, status: "ok" }, - zenOverflow: true, - }; + return zenOverflowUsage(source, sessionId); } throw new RemoteError( USAGE_UNAVAILABLE, diff --git a/test/catalog.test.ts b/test/catalog.test.ts index ce77b29..ad87c47 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -6,6 +6,7 @@ * @module test/catalog.test */ +import assert from "node:assert/strict"; import { AsyncLocalStorage } from "node:async_hooks"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -18,14 +19,18 @@ import { getLiveZenCatalog, isGoModelsListingUrl, isModelsListingUrl, + isRetiredModel, parseModelsDevCatalog, patchFetch, refreshCatalog, resolveConfig, resolveRoutedKey, + sanitizeModalities, + RETIRED_ZEN_MODEL_IDS, SESSION_HEADER, type ActiveTurnState, } from "../src/index.ts"; +import { findModelSpec } from "../src/models-catalog.ts"; import { createCaptureFetch, headerOf, SESSION_RE } from "./test-helpers.ts"; afterEach(() => { @@ -68,6 +73,28 @@ describe("OpenCode Model Catalog & Enrichment", () => { } }); + it("retires Muse Spark 1.2 only where OpenCode CLI omits it", () => { + expect([...RETIRED_ZEN_MODEL_IDS].toSorted()).toEqual( + [ + "muse-spark-1.2", + "muse-spark-1.2-contributor", + "muse-spark-1.2-contributor-free", + ].toSorted() + ); + expect(isRetiredModel("go", "muse-spark-1.2-contributor")).toBe(false); + expect(isRetiredModel("zen", "muse-spark-1.2")).toBe(true); + expect(isRetiredModel("zen", "muse-spark-1.2-contributor")).toBe(true); + expect(isRetiredModel("zen", "muse-spark-1.2-contributor-free")).toBe(true); + + const liveGoIds = new Set(getLiveGoCatalog().map((m) => m.id)); + const liveZenIds = new Set(getLiveZenCatalog().map((m) => m.id)); + // The CLI still lists this paid Go entry, so the patch must preserve it. + expect(liveGoIds.has("muse-spark-1.2-contributor")).toBe(true); + for (const id of RETIRED_ZEN_MODEL_IDS) { + expect(liveZenIds.has(id)).toBe(false); + } + }); + it("identifies model listing URLs accurately", () => { expect(isModelsListingUrl("https://opencode.ai/zen/go/v1/models")).toBe( true @@ -157,6 +184,46 @@ describe("OpenCode Model Catalog & Enrichment", () => { ).toBe(true); }); + it("preserves Space Bunny while removing retired Muse 1.2 rows", async () => { + const goResponse = await enrichModelsResponse( + "https://opencode.ai/zen/go/v1/models", + Response.json({ + data: [ + { id: "space-bunny-free", object: "model" }, + { id: "muse-spark-1.2-contributor", object: "model" }, + ], + object: "list", + }) + ); + const goJson = (await goResponse.json()) as { + data: Array<{ id: string }>; + }; + expect(goJson.data.some((m) => m.id === "space-bunny-free")).toBe(true); + expect(goJson.data.some((m) => m.id === "muse-spark-1.2-contributor")).toBe( + true + ); + + const zenResponse = await enrichModelsResponse( + "https://opencode.ai/zen/v1/models", + Response.json({ + data: [ + { id: "space-bunny-free", object: "model" }, + { id: "muse-spark-1.2", object: "model" }, + { id: "muse-spark-1.2-contributor-free", object: "model" }, + ], + object: "list", + }) + ); + const zenJson = (await zenResponse.json()) as { + data: Array<{ id: string }>; + }; + expect(zenJson.data.some((m) => m.id === "space-bunny-free")).toBe(true); + expect(zenJson.data.some((m) => m.id === "muse-spark-1.2")).toBe(false); + expect( + zenJson.data.some((m) => m.id === "muse-spark-1.2-contributor-free") + ).toBe(false); + }); + it("patchFetch transparently enriches GET .../models calls", async () => { const als = new AsyncLocalStorage(); const mockFetch = vi.fn().mockImplementation(async (url: string) => { @@ -306,6 +373,9 @@ describe("OpenCode Model Catalog & Enrichment", () => { modalities: { input: ["text"] }, name: "Future Test Model", }, + "muse-spark-1.2-contributor": { + name: "Muse Spark 1.2 Contributor", + }, }, }, opencode: { @@ -313,6 +383,12 @@ describe("OpenCode Model Catalog & Enrichment", () => { "future-zen-model": { name: "Future Zen Model", }, + "muse-spark-1.2": { + name: "Muse Spark 1.2", + }, + "muse-spark-1.2-contributor-free": { + name: "Muse Spark 1.2 Free", + }, }, }, }) @@ -329,6 +405,13 @@ describe("OpenCode Model Catalog & Enrichment", () => { expect(futureModel).toBeDefined(); expect(futureModel?.name).toBe("Future Test Model"); expect(futureModel?.context_window).toBe(1500000); + expect(updated.go.some((m) => m.id === "muse-spark-1.2-contributor")).toBe( + true + ); + expect(updated.zen.some((m) => m.id === "muse-spark-1.2")).toBe(false); + expect( + updated.zen.some((m) => m.id === "muse-spark-1.2-contributor-free") + ).toBe(false); }); it("refreshCatalog degrades gracefully on network errors without throwing", async () => { @@ -394,4 +477,74 @@ describe("OpenCode Model Catalog & Enrichment", () => { delete process.env.OPENCODE_API_KEY; } }); + + it("sanitizeModalities restricts modalities strictly to text and image", () => { + expect( + sanitizeModalities(["text", "image", "video", "audio", "pdf"]) + ).toEqual(["text", "image"]); + expect(sanitizeModalities(["video", "pdf"])).toEqual(["text"]); + expect(sanitizeModalities(null)).toEqual(["text"]); + expect(sanitizeModalities([])).toEqual(["text"]); + expect(sanitizeModalities(["image"])).toEqual(["image"]); + }); + + it("all bundled Go and Zen models contain only DSH-supported modalities", () => { + for (const model of [...OPENCODE_GO_CATALOG, ...OPENCODE_ZEN_CATALOG]) { + for (const modality of model.input_modalities) { + expect(["text", "image"]).toContain(modality); + } + } + }); +}); + +describe("findModelSpec", () => { + it("resolves a Go model together with its pricing rate", () => { + // The stream hook prices each turn through this lookup, so the rate must + // ride along with the spec. + const spec = findModelSpec("deepseek-v4.1-flash"); + assert.ok(spec, "expected the Go shim to carry deepseek-v4.1-flash"); + expect(spec.id).toBe("deepseek-v4.1-flash"); + expect(spec.name).toBe("DeepSeek V4.1 Flash"); + expect(spec.context_window).toBe(1_000_000); + expect(spec.cost).toEqual({ cache_read: 0.003, input: 0.15, output: 0.6 }); + expect(spec.is_free).toBeUndefined(); + }); + + it("resolves a free Zen model, marked free at an explicit zero rate", () => { + const spec = findModelSpec("mimo-v2.6-flash-free"); + assert.ok(spec, "expected the Zen shim to carry mimo-v2.6-flash-free"); + expect(spec.is_free).toBe(true); + expect(spec.context_window).toBe(200_000); + expect(spec.max_output_tokens).toBe(32_000); + // A free model still carries an explicit zero rate, so the stream hook + // prices it as $0 rather than falling back to "unknown model" handling. + expect(spec.cost).toEqual({ cache_read: 0, input: 0, output: 0 }); + }); + + it("still resolves an id retired on Zen but listed on Go", () => { + // Retirement is PROVIDER-SCOPED: the OpenCode CLI keeps serving the paid Go + // 1.2 contributor entry, so the Go catalog retains it while Zen drops it. + // A provider-blind lookup therefore finds it — and must keep doing so, or + // Go sessions would lose pricing for a model the gateway still serves. + expect(isRetiredModel("zen", "muse-spark-1.2-contributor")).toBe(true); + expect(isRetiredModel("go", "muse-spark-1.2-contributor")).toBe(false); + + const spec = findModelSpec("muse-spark-1.2-contributor"); + assert.ok(spec, "expected the Go shim to keep muse-spark-1.2-contributor"); + expect(spec.id).toBe("muse-spark-1.2-contributor"); + }); + + it("never resolves a Zen-retired id that no provider still lists", () => { + for (const id of ["muse-spark-1.2", "muse-spark-1.2-contributor-free"]) { + expect(RETIRED_ZEN_MODEL_IDS.has(id)).toBe(true); + expect(findModelSpec(id)).toBeUndefined(); + } + }); + + it("returns undefined for unknown or empty ids", () => { + expect(findModelSpec("not-a-real-model")).toBeUndefined(); + expect(findModelSpec("")).toBeUndefined(); + // Case matters: model ids are exact. + expect(findModelSpec("DeepSeek-V4.1-Flash")).toBeUndefined(); + }); }); diff --git a/test/client-bundle.test.ts b/test/client-bundle.test.ts index ad92dca..88f4126 100644 --- a/test/client-bundle.test.ts +++ b/test/client-bundle.test.ts @@ -73,6 +73,7 @@ const evaluateBundle = (): { } if (name === "@deepseek-ai/dsh-client-ui-primitives") { return { + Button: () => null, SegmentedControl: () => null, SettingsForm: () => null, SettingsFormModel: class { @@ -171,6 +172,7 @@ describe("client-bundle: artifact & VM loader boundary", () => { } if (name === "@deepseek-ai/dsh-client-ui-primitives") { return { + Button: () => null, SettingsForm: () => null, SettingsFormModel: class { actions() { @@ -221,6 +223,7 @@ describe("client-bundle: artifact & VM loader boundary", () => { } if (name === "@deepseek-ai/dsh-client-ui-primitives") { return { + Button: () => null, SettingsForm: () => null, SettingsFormModel: class { actions() { diff --git a/test/config-values.test.ts b/test/config-values.test.ts new file mode 100644 index 0000000..c77ff33 --- /dev/null +++ b/test/config-values.test.ts @@ -0,0 +1,141 @@ +/** + * `config-values.ts` — the coercion readers shared by the host config resolver + * and the client card, so both agree on "absent or invalid means default". + * + * These are asserted directly because the two callers see different raw shapes: + * the host reads row YAML (possibly carrying schemastery volatile `.get()` + * nodes) while the client reads the schema-resolved snapshot. + */ + +import { describe, expect, it } from "vitest"; + +import { + ALL_MODELS_MARKER, + DEFAULT_SHOW_USAGE_PRICE, + DEFAULT_PROVIDERS, + readBoolean, + readString, + readStringList, + unwrapNode, +} from "../src/config-values.ts"; + +/** A value that is genuinely absent, without writing the `undefined` literal. */ +const ABSENT: unknown = undefined; + +/** A schemastery volatile `.get()` node, as the host row carries it. */ +const node = (value: unknown) => ({ + get: () => value, +}); + +describe("config-values: shared defaults", () => { + it("exposes the marker and defaults both sides read", () => { + expect(ALL_MODELS_MARKER).toBe("*"); + expect(DEFAULT_PROVIDERS).toEqual([ + "opencode", + "opencode-go", + "opencode-responses", + ]); + expect(DEFAULT_SHOW_USAGE_PRICE).toBe(true); + }); +}); + +describe("config-values: unwrapNode", () => { + it("is idempotent for plain values", () => { + expect(unwrapNode("text")).toBe("text"); + expect(unwrapNode(7)).toBe(7); + expect(unwrapNode(true)).toBe(true); + expect(unwrapNode(null)).toBeNull(); + expect(unwrapNode(ABSENT)).toBeUndefined(); + }); + + it("unwraps a callable get() node", () => { + expect(unwrapNode(node("inner"))).toBe("inner"); + expect(unwrapNode(node(["a", "b"]))).toEqual(["a", "b"]); + }); + + it("leaves an object whose get is not callable untouched", () => { + const notANode = { get: "not-callable" }; + expect(unwrapNode(notANode)).toBe(notANode); + // An array is not a node even if it somehow carried `get`. + expect(unwrapNode([])).toEqual([]); + }); +}); + +describe("config-values: readBoolean", () => { + it("takes an explicit boolean", () => { + expect(readBoolean(true, false)).toBe(true); + expect(readBoolean(false, true)).toBe(false); + }); + + it("inherits the fallback for anything that is not a boolean", () => { + // Junk must not silently toggle the feature off (or on). + expect(readBoolean("true", false)).toBe(false); + expect(readBoolean("false", true)).toBe(true); + expect(readBoolean(1, true)).toBe(true); + expect(readBoolean(null, true)).toBe(true); + expect(readBoolean(ABSENT, false)).toBe(false); + expect(readBoolean([], true)).toBe(true); + expect(readBoolean({}, false)).toBe(false); + }); + + it("unwraps a volatile node before deciding", () => { + expect(readBoolean(node(true), false)).toBe(true); + expect(readBoolean(node(false), true)).toBe(false); + expect(readBoolean(node("nope"), true)).toBe(true); + }); +}); + +describe("config-values: readString", () => { + it("returns a trimmed string", () => { + expect(readString(" opencode/1.0 ")).toBe("opencode/1.0"); + }); + + it("returns undefined for absent or blank values", () => { + expect(readString("")).toBeUndefined(); + expect(readString(" ")).toBeUndefined(); + expect(readString(ABSENT)).toBeUndefined(); + expect(readString(null)).toBeUndefined(); + expect(readString(42)).toBeUndefined(); + expect(readString({})).toBeUndefined(); + expect(readString([])).toBeUndefined(); + }); + + it("unwraps a volatile node before trimming", () => { + expect(readString(node(" cli "))).toBe("cli"); + expect(readString(node(" "))).toBeUndefined(); + expect(readString(node(5))).toBeUndefined(); + }); +}); + +describe("config-values: readStringList", () => { + it("keeps the valid entries, trimmed, in order", () => { + expect(readStringList(["a", " b ", "c"])).toEqual(["a", "b", "c"]); + }); + + it("drops blanks and non-strings instead of failing", () => { + expect( + readStringList(["a", "", " ", 1, null, undefined, {}, [], "b"]) + ).toEqual(["a", "b"]); + }); + + it("returns an empty list when the value is not an array", () => { + expect(readStringList(ABSENT)).toEqual([]); + expect(readStringList(null)).toEqual([]); + expect(readStringList("opencode-go")).toEqual([]); + expect(readStringList({})).toEqual([]); + expect(readStringList(5)).toEqual([]); + }); + + it("returns an empty list for an array with no usable entry", () => { + expect(readStringList([])).toEqual([]); + expect(readStringList(["", " ", 3])).toEqual([]); + }); + + it("unwraps a volatile node before reading the list", () => { + expect(readStringList(node(["opencode-go", " opencode "]))).toEqual([ + "opencode-go", + "opencode", + ]); + expect(readStringList(node("not-a-list"))).toEqual([]); + }); +}); diff --git a/test/config.test.ts b/test/config.test.ts index 6259ed6..444d71f 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -10,7 +10,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { Config, + DEFAULT_KEY_SOURCE, isOpenCodeRequest, + KEY_SOURCE_POLICIES, resolveConfig, type ActiveTurnState, type PluginConfig, @@ -24,7 +26,11 @@ afterEach(() => { describe("resolveConfig", () => { it("fills default providers, toggles, and debug flags", () => { const resolved = resolveConfig({}); - expect([...resolved.providers]).toEqual(["opencode", "opencode-go"]); + expect([...resolved.providers]).toEqual([ + "opencode", + "opencode-go", + "opencode-responses", + ]); expect(resolved.debug).toBe(false); expect(resolved.debugFile).toBeUndefined(); expect(resolved.injectUserAgent).toBe(true); @@ -85,10 +91,12 @@ describe("resolveConfig", () => { expect([...resolveConfig({ providers: [] }).providers]).toEqual([ "opencode", "opencode-go", + "opencode-responses", ]); expect([...resolveConfig({ providers: [""] }).providers]).toEqual([ "opencode", "opencode-go", + "opencode-responses", ]); }); @@ -108,7 +116,11 @@ describe("resolveConfig", () => { expect(defaults.injectProject).toBe(true); expect(defaults.freeModelMarker).toBe("free"); expect(defaults.sessionIdEnv).toBe("OPENCODE_SESSION_ID"); - expect(defaults.usageProviderMarkers).toEqual(["opencode-go", "opencode"]); + expect([...defaults.providers]).toEqual([ + "opencode", + "opencode-go", + "opencode-responses", + ]); const custom = resolveConfig({ freeModelMarker: " preview ", @@ -116,14 +128,14 @@ describe("resolveConfig", () => { injectProject: false, originClient: "desktop", sessionIdEnv: "MY_SESSION", - usageProviderMarkers: ["go-relay"], + providers: ["go-relay"], }); expect(custom.gatewayUrls).toEqual(["relay.example.com/zen"]); expect(custom.originClient).toBe("desktop"); expect(custom.injectProject).toBe(false); expect(custom.freeModelMarker).toBe("preview"); expect(custom.sessionIdEnv).toBe("MY_SESSION"); - expect(custom.usageProviderMarkers).toEqual(["go-relay"]); + expect([...custom.providers]).toEqual(["go-relay"]); // Blank strings and empty lists fall back instead of disabling the knob. const blank = resolveConfig({ @@ -131,14 +143,18 @@ describe("resolveConfig", () => { gatewayUrls: [], originClient: "", sessionIdEnv: "", - usageProviderMarkers: ["", " "], + providers: ["", " "], }); expect(blank.gatewayUrls).toEqual(["opencode.ai/zen"]); expect(blank.originClient).toBe("cli"); expect(blank.injectProject).toBe(true); expect(blank.freeModelMarker).toBe("free"); expect(blank.sessionIdEnv).toBe("OPENCODE_SESSION_ID"); - expect(blank.usageProviderMarkers).toEqual(["opencode-go", "opencode"]); + expect([...blank.providers]).toEqual([ + "opencode", + "opencode-go", + "opencode-responses", + ]); }); }); @@ -160,14 +176,13 @@ describe("Config schema", () => { "injectOriginHeaders", "injectProject", "injectUserAgent", + "keySource", "originClient", "providers", "sessionIdEnv", "showUsagePrice", "usageBaseURL", "usageEnabled", - "usageKeyEnv", - "usageProviderMarkers", "userAgent", // oxlint-disable-next-line unicorn/no-array-sort -- array literal is fresh, so in-place sort mutates nothing shared. ].sort() @@ -181,17 +196,31 @@ describe("Config schema", () => { injectOriginHeaders: true, injectProject: true, injectUserAgent: true, + keySource: "auto", originClient: "cli", - providers: new Set(["opencode", "opencode-go"]), + providers: new Set(["opencode", "opencode-go", "opencode-responses"]), sessionIdEnv: "OPENCODE_SESSION_ID", showUsagePrice: true, usageBaseURL: "https://opencode.ai/zen/go/v1", usageEnabled: true, - usageKeyEnv: "OPENCODE_GO_API_KEY", - usageProviderMarkers: ["opencode-go", "opencode"], }); }); + it("accepts each key-source policy and degrades an unknown one to auto", () => { + // The policy is user-facing and hand-editable, so a typo must not fail the + // plugin load — it falls back to the default like every other knob. + for (const policy of KEY_SOURCE_POLICIES) { + expect(resolveConfig({ keySource: policy }).keySource).toBe(policy); + } + expect(resolveConfig({}).keySource).toBe(DEFAULT_KEY_SOURCE); + expect(resolveConfig({ keySource: "live" as never }).keySource).toBe( + DEFAULT_KEY_SOURCE + ); + expect(resolveConfig({ keySource: "" as never }).keySource).toBe( + DEFAULT_KEY_SOURCE + ); + }); + it("resolves validated and raw rows identically", () => { // Validated and raw rows reach `resolveConfig` from different layers, and // must not disagree about a default — otherwise the settings page would @@ -200,7 +229,8 @@ describe("Config schema", () => { {}, { providers: ["opencode"] }, { injectUserAgent: false, usageEnabled: false, debug: true }, - { usageKeyEnv: "X", usageBaseURL: "https://example.invalid" }, + { keySource: "request" }, + { usageBaseURL: "https://example.invalid" }, ]; for (const row of cases) { expect(resolveConfig(Config(row) as unknown as PluginConfig)).toEqual( @@ -211,7 +241,7 @@ describe("Config schema", () => { }); describe("isOpenCodeRequest (endpoint differentiation)", () => { - const providers = new Set(["opencode", "opencode-go"]); + const providers = new Set(["opencode", "opencode-go", "opencode-responses"]); it("identifies opencode.ai/zen endpoints", () => { expect( diff --git a/test/cordis-context.test.ts b/test/cordis-context.test.ts new file mode 100644 index 0000000..ea02157 --- /dev/null +++ b/test/cordis-context.test.ts @@ -0,0 +1,206 @@ +/** + * `cordis-context.ts` — the runtime-checked view of the Cordis host context. + * + * The plugin cannot import `@deepseek-ai/cordis` (it ships with the harness, not + * as a dependency), so every access here tolerates an unknown host shape and + * degrades to `undefined`. These cases pin that tolerance: a guard that throws + * or mis-narrows silently disables session affinity, parent-session headers or + * credential resolution without any visible error. + */ + +import assert from "node:assert/strict"; + +import { describe, expect, it } from "vitest"; + +import { + isLoaderHost, + readCredentialsResolver, + readEntryOptions, + readSessionMetaResolver, +} from "../src/cordis-context.ts"; + +describe("cordis-context: readEntryOptions", () => { + it("reads id and name from fiber.entry.options", () => { + const ctx = { fiber: { entry: { options: { id: "dsh-opencode-patch" } } } }; + expect(readEntryOptions(ctx)).toEqual({ + id: "dsh-opencode-patch", + name: undefined, + }); + + const both = { + fiber: { entry: { options: { id: "legacy", name: "dsh-opencode" } } }, + }; + expect(readEntryOptions(both)).toEqual({ + id: "legacy", + name: "dsh-opencode", + }); + }); + + it("returns undefined at every missing step", () => { + expect(readEntryOptions(null)).toBeUndefined(); + expect(readEntryOptions("ctx")).toBeUndefined(); + expect(readEntryOptions({})).toBeUndefined(); + expect(readEntryOptions({ fiber: {} })).toBeUndefined(); + expect(readEntryOptions({ fiber: { entry: {} } })).toBeUndefined(); + expect( + readEntryOptions({ fiber: { entry: { options: {} } } }) + ).toBeUndefined(); + // Non-string members are dropped, so an id-only row still reports itself. + expect( + readEntryOptions({ fiber: { entry: { options: { id: 42, name: "n" } } } }) + ).toEqual({ id: undefined, name: "n" }); + }); +}); + +describe("cordis-context: isLoaderHost", () => { + it("requires a callable loader.entries", () => { + expect(isLoaderHost({ loader: { entries: () => [] } })).toBe(true); + expect(isLoaderHost({ loader: { entries: 1 } })).toBe(false); + expect(isLoaderHost({ loader: {} })).toBe(false); + expect(isLoaderHost({})).toBe(false); + expect(isLoaderHost(null)).toBe(false); + expect(isLoaderHost(() => 1)).toBe(false); + }); +}); + +describe("cordis-context: readCredentialsResolver", () => { + it("resolves a credential through ctx.get('credentials')", async () => { + const ctx = { + get: (name: string) => + name === "credentials" + ? { resolve: async (ref: string) => ({ value: `key-for-${ref}` }) } + : undefined, + }; + const resolver = readCredentialsResolver(ctx); + assert.ok(resolver, "expected a resolver"); + expect(await resolver("opencode-go")).toEqual({ + value: "key-for-opencode-go", + }); + }); + + it("reports a configured-but-valueless credential as an empty object", async () => { + const ctx = { + get: () => ({ resolve: async () => ({}) }), + }; + const resolver = readCredentialsResolver(ctx); + assert.ok(resolver); + expect(await resolver("ref")).toEqual({}); + }); + + it("returns undefined for unusable host shapes", () => { + expect(readCredentialsResolver(null)).toBeUndefined(); + expect(readCredentialsResolver({})).toBeUndefined(); + expect(readCredentialsResolver({ get: 1 })).toBeUndefined(); + // get() itself throwing must not escape. + expect( + readCredentialsResolver({ + get: () => { + throw new Error("no credentials service"); + }, + }) + ).toBeUndefined(); + // A host with no resolve() is not a resolver. + expect(readCredentialsResolver({ get: () => ({}) })).toBeUndefined(); + expect(readCredentialsResolver({ get: () => null })).toBeUndefined(); + }); + + it("degrades to undefined when the service rejects or answers oddly", async () => { + const rejecting = readCredentialsResolver({ + get: () => ({ + resolve: async () => { + throw new Error("vault down"); + }, + }), + }); + assert.ok(rejecting); + expect(await rejecting("ref")).toBeUndefined(); + + const nonRecord = readCredentialsResolver({ + get: () => ({ resolve: async () => "plain-string" }), + }); + assert.ok(nonRecord); + expect(await nonRecord("ref")).toBeUndefined(); + + const nonStringValue = readCredentialsResolver({ + get: () => ({ resolve: async () => ({ value: 7 }) }), + }); + assert.ok(nonStringValue); + expect(await nonStringValue("ref")).toEqual({}); + }); +}); + +describe("cordis-context: readSessionMetaResolver", () => { + const sessionsHost = (sessions: unknown) => ({ get: () => sessions }); + + it("reads cwd and parentSession from the session header", () => { + const resolver = readSessionMetaResolver( + sessionsHost({ + get: () => ({ + header: { cwd: "/workspace/project", parentSession: "ses_parent" }, + }), + }) + ); + assert.ok(resolver, "expected a resolver"); + expect(resolver("ses_child")).toEqual({ + cwd: "/workspace/project", + parentSession: "ses_parent", + }); + }); + + it("falls back to ctx.sessions when ctx.get is absent", () => { + const resolver = readSessionMetaResolver({ + sessions: { get: () => ({ header: { cwd: "/w" } }) }, + }); + assert.ok(resolver); + expect(resolver("ses")).toEqual({ cwd: "/w", parentSession: undefined }); + }); + + it("returns undefined for unusable host shapes", () => { + expect(readSessionMetaResolver(null)).toBeUndefined(); + expect(readSessionMetaResolver({})).toBeUndefined(); + // get() throwing (a service not injected) must not escape. + expect( + readSessionMetaResolver({ + get: () => { + throw new Error('cannot get property "sessions" without inject'); + }, + }) + ).toBeUndefined(); + expect(readSessionMetaResolver(sessionsHost(null))).toBeUndefined(); + expect(readSessionMetaResolver(sessionsHost({}))).toBeUndefined(); + expect(readSessionMetaResolver(sessionsHost({ get: 1 }))).toBeUndefined(); + }); + + it("normalizes blank header fields and odd sessions to undefined", () => { + const byId: Record = { + blank: { header: { cwd: " ", parentSession: "" } }, + "no-header": {}, + other: "not-a-record", + padded: { header: { cwd: " /w/p " } }, + }; + const resolver = readSessionMetaResolver( + sessionsHost({ + get: (id: string) => { + if (id === "throws") { + throw new Error("gone"); + } + return byId[id]; + }, + }) + ); + assert.ok(resolver); + + expect(resolver("blank")).toEqual({ + cwd: undefined, + parentSession: undefined, + }); + expect(resolver("padded")).toEqual({ + cwd: "/w/p", + parentSession: undefined, + }); + // A session with no header at all is unresolvable, not an empty meta. + expect(resolver("no-header")).toBeUndefined(); + expect(resolver("other")).toBeUndefined(); + expect(resolver("throws")).toBeUndefined(); + }); +}); diff --git a/test/debug.test.ts b/test/debug.test.ts new file mode 100644 index 0000000..9c411d0 --- /dev/null +++ b/test/debug.test.ts @@ -0,0 +1,88 @@ +/** + * `debug.ts` — the append-only JSONL stream-debug log. + * + * The contract worth pinning is the failure path: a debug write must NEVER + * fail a turn, whatever the path or the logger look like. The success path is + * asserted against a real temporary file so the line format (one JSON object + * per line, newline-terminated) is verified rather than assumed. + */ + +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { recordDebug } from "../src/debug.ts"; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "dsh-opencode-debug-")); +}); + +afterEach(async () => { + await rm(dir, { force: true, recursive: true }); +}); + +describe("recordDebug", () => { + it("appends one JSON line per entry", async () => { + const file = join(dir, "stream.jsonl"); + await recordDebug({}, file, { session: "ses_a", value: 1 }); + await recordDebug({}, file, { session: "ses_b", value: 2 }); + + const text = await readFile(file, "utf-8"); + const lines = text.split("\n").filter((line) => line.length > 0); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0] ?? "")).toEqual({ session: "ses_a", value: 1 }); + expect(JSON.parse(lines[1] ?? "")).toEqual({ session: "ses_b", value: 2 }); + // Every line is newline-terminated, so a partial write never merges rows. + expect(text.endsWith("\n")).toBe(true); + }); + + it("creates the file when it does not exist yet", async () => { + const file = join(dir, "nested-not-created", "stream.jsonl"); + const warn = vi.fn<(...args: unknown[]) => void>(); + + // The parent directory is missing: this must warn, not throw. + await expect( + recordDebug({ logger: { warn } }, file, { a: 1 }) + ).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledOnce(); + const [message] = warn.mock.calls[0] ?? []; + expect(String(message)).toContain("debugFile write failed"); + }); + + it("reports a write failure through the logger instead of rejecting", async () => { + const warn = vi.fn<(...args: unknown[]) => void>(); + // A directory is not writable as a file. + await expect( + recordDebug({ logger: { warn } }, dir, { a: 1 }) + ).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledOnce(); + expect(String(warn.mock.calls[0]?.[0])).toBe( + "[dsh-opencode-patch] debugFile write failed: %s" + ); + }); + + it("stays silent and resolves when the context has no logger", async () => { + await expect(recordDebug({}, dir, { a: 1 })).resolves.toBeUndefined(); + await expect( + recordDebug({ logger: {} }, dir, { a: 1 }) + ).resolves.toBeUndefined(); + }); + + it("serializes a non-Error throw as a string message", async () => { + // A path whose parent is a file (not a directory) fails with ENOTDIR. + const file = join(dir, "plain.txt"); + await recordDebug({}, file, { ok: true }); + const warn = vi.fn<(...args: unknown[]) => void>(); + await recordDebug({ logger: { warn } }, join(file, "child.jsonl"), { + a: 1, + }); + expect(warn).toHaveBeenCalledOnce(); + const [, detail] = warn.mock.calls[0] ?? []; + assert.ok(typeof detail === "string" && detail.length > 0); + }); +}); diff --git a/test/e2e/opencode-live.e2e.ts b/test/e2e/opencode-live.e2e.ts new file mode 100644 index 0000000..5b7a135 --- /dev/null +++ b/test/e2e/opencode-live.e2e.ts @@ -0,0 +1,189 @@ +/** + * Live OpenCode gateway E2E. + * + * Everything else in this suite runs against captured or stubbed responses; this + * file is the only place the plugin meets the real API. It exists to catch the + * failure a stub cannot: **the vendor changing the payload**. The meter parses + * `/zen/go/v1/usage` and the picker consumes an enriched `/models` listing, so + * both shapes are asserted against the live service rather than our idea of it. + * + * Opt-in via `OPENCODE_E2E=1`, and each credentialed block skips when its key is + * absent — so forks and key-less runs stay green instead of failing on a 401. + * + * OPENCODE_E2E=1 OPENCODE_API_KEY=… OPENCODE_GO_API_KEY=… pnpm run test:e2e + * + * The unkeyed cases are deliberately part of the suite: they prove the paths + * still exist (a 404 would mean the endpoint moved) and that a request carrying + * our header set is *authenticated-rejected* rather than *malformed-rejected*. + */ + +import { describe, expect, it } from "vitest"; + +import { + enrichModelsResponse, + OPENCODE_ZEN_CATALOG, + openCodeSessionIdFor, + parseGoUsage, + RETIRED_ZEN_MODEL_IDS, + SESSION_HEADER, + type GoUsage, +} from "../../src/index.ts"; + +const LIVE = process.env.OPENCODE_E2E === "1"; + +/** Bases are overridable so the suite can be pointed at a mirror or a local stub. */ +const ZEN_BASE = + process.env.OPENCODE_ZEN_BASE_URL ?? "https://opencode.ai/zen/v1"; +const GO_BASE = + process.env.OPENCODE_GO_BASE_URL ?? "https://opencode.ai/zen/go/v1"; + +const ZEN_KEY = process.env.OPENCODE_API_KEY; +const GO_KEY = process.env.OPENCODE_GO_API_KEY; + +/** Generous: these cross the public internet from CI. */ +const TIMEOUT_MS = 30_000; + +/** A key that is well-formed but certainly not ours. */ +const BOGUS_KEY = "sk-e2e-not-a-real-key"; + +const WINDOW_KEYS = ["rolling", "weekly", "monthly"] as const; + +describe.skipIf(!LIVE)("live OpenCode gateway", () => { + it( + "answers the Zen /models path, so the endpoint has not moved", + async () => { + const response = await fetch(`${ZEN_BASE}/models`); + // 404 is the failure that matters: it means the path moved. + expect([200, 401, 403]).toContain(response.status); + const body: unknown = await response.json(); + expect(typeof body).toBe("object"); + expect(body).not.toBeNull(); + + // Observed live: `/models` answers 200 even for a bogus key, so the + // listing shape is asserted whenever it is served. + if (response.status === 200) { + const listing = body as { data?: unknown; object?: unknown }; + expect(listing.object).toBe("list"); + expect(Array.isArray(listing.data)).toBe(true); + } + }, + TIMEOUT_MS + ); + + it( + "adds nothing to the gateway's verdict on a request", + async () => { + // The invariant that matters: our header set must not change whether the + // gateway accepts the request. Compared against the same call without + // them, rather than hardcoding a status the vendor may tighten. + const plain = await fetch(`${ZEN_BASE}/models`, { + headers: { Authorization: `Bearer ${BOGUS_KEY}` }, + }); + const ours = await fetch(`${ZEN_BASE}/models`, { + headers: { + Authorization: `Bearer ${BOGUS_KEY}`, + "x-opencode-client": "cli", + [SESSION_HEADER]: openCodeSessionIdFor("e2e-session"), + }, + }); + expect(ours.status).toBe(plain.status); + }, + TIMEOUT_MS + ); + + it( + "answers the Go /usage path, which the meter depends on", + async () => { + const response = await fetch(`${GO_BASE}/usage`, { + headers: { Authorization: `Bearer ${BOGUS_KEY}` }, + }); + // Again: never a 404. A rejection here is an auth answer, and it must + // still be the API's JSON error shape rather than an HTML error page. + expect([200, 401, 403]).toContain(response.status); + if (response.status !== 200) { + const body: unknown = await response.json(); + expect(typeof body).toBe("object"); + } + }, + TIMEOUT_MS + ); +}); + +describe.skipIf(!LIVE || ZEN_KEY === undefined)( + "live Zen model listing (OPENCODE_API_KEY)", + () => { + it( + "enriches the real listing into the rows the picker consumes", + async () => { + const url = `${ZEN_BASE}/models`; + const response = await fetch(url, { + headers: { Authorization: `Bearer ${ZEN_KEY}` }, + }); + expect(response.ok).toBe(true); + + const enrichedResponse = await enrichModelsResponse(url, response); + const enriched: unknown = await enrichedResponse.json(); + expect(enriched).toBeTruthy(); + const rows = (enriched as { data?: unknown }).data; + expect(Array.isArray(rows)).toBe(true); + + const ids = new Set(); + for (const row of rows as unknown[]) { + expect(row).toBeTruthy(); + const model = row as Record; + // Every row the picker sees must be fully described, which is the + // whole point of the enrichment. + expect(typeof model.id).toBe("string"); + expect(typeof model.name).toBe("string"); + expect(typeof model.context_window).toBe("number"); + expect(typeof model.max_output_tokens).toBe("number"); + ids.add(String(model.id)); + } + + // The canonical free-tier rows are what a Zen user actually selects. + expect(ids.has("mimo-v2.6-flash-free")).toBe(true); + expect(ids.has("muse-spark-1.3-contributor-free")).toBe(true); + + // And nothing the provider retired is resurrected by the enrichment. + for (const retired of RETIRED_ZEN_MODEL_IDS) { + expect(ids.has(retired)).toBe(false); + } + + // The listing covers the bundled catalog, so a failed refresh can only + // ever be additive relative to the shim. + for (const spec of OPENCODE_ZEN_CATALOG) { + expect(ids.has(spec.id)).toBe(true); + } + }, + TIMEOUT_MS + ); + } +); + +describe.skipIf(!LIVE || GO_KEY === undefined)( + "live Go usage (OPENCODE_GO_API_KEY)", + () => { + it( + "parses the real /usage payload the meter renders", + async () => { + const response = await fetch(`${GO_BASE}/usage`, { + headers: { Authorization: `Bearer ${GO_KEY}` }, + }); + expect(response.ok).toBe(true); + + const usage: GoUsage = parseGoUsage(await response.json()); + for (const key of WINDOW_KEYS) { + const window = usage[key]; + expect(typeof window.percent).toBe("number"); + expect(Number.isFinite(window.percent)).toBe(true); + expect(window.percent).toBeGreaterThanOrEqual(0); + expect(window.percent).toBeLessThanOrEqual(100); + expect(["ok", "rate-limited"]).toContain(window.status); + // The meter renders a countdown from this, so it must be a real date. + expect(Number.isNaN(Date.parse(window.resetsAt))).toBe(false); + } + }, + TIMEOUT_MS + ); + } +); diff --git a/test/e2e/patched-fetch-headers.e2e.ts b/test/e2e/patched-fetch-headers.e2e.ts new file mode 100644 index 0000000..dc84d1d --- /dev/null +++ b/test/e2e/patched-fetch-headers.e2e.ts @@ -0,0 +1,145 @@ +/** + * End-to-end header contract: what `patchFetch` actually puts on the wire. + * + * The live gateway cannot echo a request back, so the *outgoing* header set is + * proven against a local `node:http` listener instead — a real socket, a real + * request, the real `patchFetch`. Everywhere else in the suite the injected + * headers are asserted against a captured `fetch`; here they cross a socket. + * + * Opt-in (`OPENCODE_E2E=1`): it binds a port, so it stays out of the + * deterministic unit run. + */ + +import assert from "node:assert/strict"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { once } from "node:events"; +import { createServer, type Server } from "node:http"; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { + type ActiveTurnState, + openCodeSessionIdFor, + OPENCODE_UA, + PARENT_SESSION_ALT_HEADER, + PARENT_SESSION_HEADER, + patchFetch, + resolveConfig, + SESSION_AFFINITY_HEADER, + SESSION_HEADER, +} from "../../src/index.ts"; + +const ENABLED = process.env.OPENCODE_E2E === "1"; + +let server: Server; +let origin: string; +/** Headers of every request the listener received, in order. */ +const received: Record[] = []; + +beforeAll(async () => { + server = createServer((request, response) => { + received.push(request.headers); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address !== null && typeof address === "object", "no bound port"); + origin = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + server.close(); + await once(server, "close"); +}); + +beforeEach(() => { + received.length = 0; +}); + +const headerOf = (index: number, name: string): string | undefined => { + const value = received[index]?.[name]; + return Array.isArray(value) ? value[0] : value; +}; + +describe.skipIf(!ENABLED)("patched fetch over a real socket", () => { + const als = new AsyncLocalStorage(); + const config = resolveConfig({}); + const patched = patchFetch(fetch, als, config); + + const turn = (overrides: Partial = {}): ActiveTurnState => ({ + model: "deepseek-v4.1-flash", + provider: "opencode", + value: "ses_e2e00000000", + ...overrides, + }); + + it("injects the full affinity header set for a claimed provider", async () => { + const state = turn({ + parentValue: "ses_parent00000", + project: "dsh-opencode", + }); + const response = await als.run(state, () => patched(`${origin}/models`)); + expect(response.status).toBe(200); + + // The three session spellings the gateway has accepted over time. + expect(headerOf(0, SESSION_HEADER)).toBe(state.value); + expect(headerOf(0, "x-opencode-session-id")).toBe(state.value); + expect(headerOf(0, SESSION_AFFINITY_HEADER)).toBe(state.value); + + // Parent lineage, in both spellings. + expect(headerOf(0, PARENT_SESSION_HEADER)).toBe("ses_parent00000"); + expect(headerOf(0, PARENT_SESSION_ALT_HEADER)).toBe("ses_parent00000"); + + // Origin identity. + expect(headerOf(0, "x-opencode-client")).toBe("cli"); + expect(headerOf(0, "x-opencode-project")).toBe("dsh-opencode"); + expect(headerOf(0, "user-agent")).toBe(OPENCODE_UA); + }); + + it("falls back to 'global' when the turn carries no project", async () => { + await als.run(turn(), () => patched(`${origin}/models`)); + expect(headerOf(0, "x-opencode-project")).toBe("global"); + // No parent means no parent header at all, rather than an empty one. + expect(headerOf(0, PARENT_SESSION_HEADER)).toBeUndefined(); + expect(headerOf(0, PARENT_SESSION_ALT_HEADER)).toBeUndefined(); + }); + + it("derives a stable ses_ value for a caller-supplied session id", async () => { + const value = openCodeSessionIdFor("e2e-session"); + await als.run(turn({ value }), () => patched(`${origin}/models`)); + expect(headerOf(0, SESSION_HEADER)).toBe(value); + expect(value.startsWith("ses_")).toBe(true); + }); + + it("leaves an unclaimed provider's traffic completely untouched", async () => { + // A localhost URL matches no gateway marker, and `deepseek` is not in + // `providers`, so this must be a byte-for-byte pass-through. + await als.run(turn({ provider: "deepseek" }), () => + patched(`${origin}/models`) + ); + expect(received[0]?.[SESSION_HEADER]).toBeUndefined(); + expect(received[0]?.["x-opencode-client"]).toBeUndefined(); + expect(received[0]?.["user-agent"]).not.toBe(OPENCODE_UA); + }); + + it("keeps a caller's own valid session header", async () => { + await als.run(turn(), () => + patched(`${origin}/models`, { + headers: { [SESSION_HEADER]: "ses_caller00000" }, + }) + ); + // The turn's own value wins while a turn is active… + expect(headerOf(0, SESSION_HEADER)).toBe("ses_e2e00000000"); + }); + + it("preserves the caller's unrelated headers", async () => { + await als.run(turn(), () => + patched(`${origin}/models`, { + headers: { "x-custom-e2e": "kept" }, + }) + ); + expect(headerOf(0, "x-custom-e2e")).toBe("kept"); + }); +}); diff --git a/test/fetch-patch.test.ts b/test/fetch-patch.test.ts index 42b8a06..1461a25 100644 --- a/test/fetch-patch.test.ts +++ b/test/fetch-patch.test.ts @@ -15,6 +15,9 @@ import { DUMMY_READ_TOOL, OPENCODE_UA, SESSION_HEADER, + clearCapturedApiKeys, + getCapturedApiKey, + recordCapturedApiKey, openCodeSessionIdFor, patchFetch, resolveConfig, @@ -30,6 +33,7 @@ import { afterEach(() => { vi.unstubAllGlobals(); delete process.env.OPENCODE_SESSION_ID; + clearCapturedApiKeys(); }); describe("patchFetch", () => { @@ -489,4 +493,66 @@ describe("patchFetch", () => { }); expect(toolNamesOf(parseJsonBody(fullCap.init?.body))).toHaveLength(2); }); + + it("captures API key from request headers on intercepted OpenCode requests", async () => { + const als = new AsyncLocalStorage(); + const { mockFetch } = createCaptureFetch(); + const patched = patchFetch(mockFetch, als, resolveConfig()); + + expect(getCapturedApiKey("opencode-go")).toBeUndefined(); + + await als.run( + { provider: "opencode-go", value: "ses_header_key" }, + async () => { + await patched("https://opencode.ai/zen/go/v1/chat/completions", { + headers: { + Authorization: "Bearer sk-intercepted-go-key-xyz", + "Content-Type": "application/json", + }, + method: "POST", + }); + } + ); + + expect(getCapturedApiKey("opencode-go")).toBe("sk-intercepted-go-key-xyz"); + expect(getCapturedApiKey(undefined, "go")).toBe( + "sk-intercepted-go-key-xyz" + ); + }); + + it("picks the captured key for the request's TIER, not the most recent one", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const patched = patchFetch(mockFetch, als, resolveConfig()); + + // A Go key captured first… + recordCapturedApiKey( + "sk-go-key-aaa", + "opencode-go", + "https://opencode.ai/zen/go/v1/chat/completions" + ); + // …then a Zen key, so "most recently seen" is now the WRONG tier for a Go + // call. A Zen key sent to the Go quota endpoint is rejected outright. + recordCapturedApiKey( + "oc_sk_zen_key_bbb", + "opencode", + "https://opencode.ai/zen/v1/messages" + ); + + await als.run( + // A route the user renamed: the provider-id lookup misses by design, so + // the tier is what has to save this. + { provider: "my-renamed-route", value: "ses_renamed" }, + async () => { + await patched("https://opencode.ai/zen/go/v1/chat/completions", { + headers: { Authorization: "Bearer unused" }, + method: "POST", + }); + } + ); + + expect(headerOf(capture.init, "authorization")).toBe( + "Bearer sk-go-key-aaa" + ); + }); }); diff --git a/test/guards.test.ts b/test/guards.test.ts new file mode 100644 index 0000000..9e3251b --- /dev/null +++ b/test/guards.test.ts @@ -0,0 +1,156 @@ +/** + * `guards.ts` — the structural predicates every narrow step in the plugin goes + * through. Each one is asserted directly, including the shapes it must REJECT: + * a guard that over-accepts silently mis-shapes a host object, which is exactly + * what these predicates exist to prevent. + */ + +import assert from "node:assert/strict"; + +import { describe, expect, it } from "vitest"; + +import { + getAsyncIterator, + isAsyncIterableLike, + isAsyncIteratorLike, + isFetchFunction, + isFunctionLike, + isRecord, + isUnknownArray, +} from "../src/guards.ts"; + +/** A value that is genuinely absent, without writing the `undefined` literal. */ +const ABSENT: unknown = undefined; + +const asyncGenerator = async function* asyncGenerator() { + yield 1; +}; + +describe("guards: isRecord", () => { + it("accepts plain objects and rejects every non-property-bag", () => { + expect(isRecord({})).toBe(true); + expect(isRecord({ a: 1 })).toBe(true); + expect(isRecord(Object.create(null))).toBe(true); + + expect(isRecord(null)).toBe(false); + expect(isRecord(ABSENT)).toBe(false); + // Arrays are objects, but never property bags here. + expect(isRecord([])).toBe(false); + expect(isRecord([1, 2])).toBe(false); + expect(isRecord("string")).toBe(false); + expect(isRecord(42)).toBe(false); + expect(isRecord(true)).toBe(false); + // A callable is not a property bag either — this is what keeps the host + // context predicates from treating a function as a service object. + expect(isRecord(() => 1)).toBe(false); + }); +}); + +describe("guards: isUnknownArray / isFunctionLike / isFetchFunction", () => { + it("isUnknownArray accepts any array, whatever the elements", () => { + expect(isUnknownArray([])).toBe(true); + expect(isUnknownArray([1, "a", null, undefined, {}])).toBe(true); + expect(isUnknownArray({ length: 0 })).toBe(false); + expect(isUnknownArray("abc")).toBe(false); + expect(isUnknownArray(ABSENT)).toBe(false); + }); + + it("isFunctionLike accepts any callable", () => { + const namedFunction = function namedFunction() { + return 1; + }; + expect(isFunctionLike(() => 1)).toBe(true); + expect(isFunctionLike(async () => 1)).toBe(true); + expect(isFunctionLike(namedFunction)).toBe(true); + expect( + isFunctionLike( + class Counter { + value = 1; + } + ) + ).toBe(true); + expect(isFunctionLike({})).toBe(false); + expect(isFunctionLike("fn")).toBe(false); + expect(isFunctionLike(ABSENT)).toBe(false); + }); + + it("isFetchFunction is the same callable check", () => { + expect(isFetchFunction(() => Promise.resolve())).toBe(true); + expect(isFetchFunction({})).toBe(false); + expect(isFetchFunction(ABSENT)).toBe(false); + }); +}); + +describe("guards: isAsyncIteratorLike", () => { + it("accepts anything with a callable next", () => { + expect(isAsyncIteratorLike({ next: () => ({ done: true }) })).toBe(true); + // A callable carrying `next` is admitted too (typeof function is allowed). + const callable = Object.assign(() => 1, { next: () => 1 }); + expect(isAsyncIteratorLike(callable)).toBe(true); + }); + + it("rejects values whose next is absent or not callable", () => { + expect(isAsyncIteratorLike({})).toBe(false); + expect(isAsyncIteratorLike({ next: 1 })).toBe(false); + expect(isAsyncIteratorLike({ next: null })).toBe(false); + expect(isAsyncIteratorLike(null)).toBe(false); + expect(isAsyncIteratorLike(ABSENT)).toBe(false); + expect(isAsyncIteratorLike(ABSENT)).toBe(false); + expect(isAsyncIteratorLike("iter")).toBe(false); + expect(isAsyncIteratorLike(42)).toBe(false); + }); +}); + +describe("guards: getAsyncIterator", () => { + it("pulls the live iterator out of an async iterable", async () => { + const iterator = getAsyncIterator(asyncGenerator()); + assert.ok(iterator, "expected an iterator"); + const first = await iterator.next(); + expect(first).toEqual({ done: false, value: 1 }); + }); + + it("returns undefined when there is no async iterator protocol", () => { + expect(getAsyncIterator(null as never)).toBeUndefined(); + expect(getAsyncIterator(ABSENT as never)).toBeUndefined(); + expect(getAsyncIterator("not iterable" as never)).toBeUndefined(); + expect(getAsyncIterator(42 as never)).toBeUndefined(); + // A sync-only iterable is not an async one. + expect(getAsyncIterator([1, 2, 3] as never)).toBeUndefined(); + }); + + it("returns undefined when Symbol.asyncIterator is not callable", () => { + expect( + getAsyncIterator({ [Symbol.asyncIterator]: 1 } as never) + ).toBeUndefined(); + }); + + it("returns undefined when the factory yields a non-iterator", () => { + expect( + getAsyncIterator({ [Symbol.asyncIterator]: () => ({}) } as never) + ).toBeUndefined(); + expect( + getAsyncIterator({ [Symbol.asyncIterator]: () => null } as never) + ).toBeUndefined(); + }); +}); + +describe("guards: isAsyncIterableLike", () => { + it("accepts a callable Symbol.asyncIterator, not just any property", () => { + expect(isAsyncIterableLike(asyncGenerator())).toBe(true); + expect( + isAsyncIterableLike({ [Symbol.asyncIterator]: () => asyncGenerator() }) + ).toBe(true); + + expect(isAsyncIterableLike({ [Symbol.asyncIterator]: 1 })).toBe(false); + expect(isAsyncIterableLike({ [Symbol.asyncIterator]: undefined })).toBe( + false + ); + }); + + it("rejects non-objects and sync-only iterables", () => { + expect(isAsyncIterableLike(null)).toBe(false); + expect(isAsyncIterableLike(ABSENT)).toBe(false); + expect(isAsyncIterableLike("str")).toBe(false); + expect(isAsyncIterableLike([1, 2])).toBe(false); + }); +}); diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index e7d3d60..10133e0 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -17,6 +17,7 @@ import { type CordisContext, getSessionUsage, clearSessionUsageStore, + RESPONSES_ROUTE, } from "../src/index.ts"; import { collectUnknown, @@ -123,6 +124,136 @@ describe("apply (plugin lifecycle)", () => { globalThis.fetch = originalFetch; }); + it("ties the catalog patch to the plugin fiber", async () => { + // The patch replaces a Host method. Registered OUTSIDE an effect it would + // never be undone, so every live reload would stack another wrapper and + // disabling the plugin would leave it hiding the route. + let cleanup: (() => void) | undefined; + const originalListModels = async (): Promise => [ + { id: "muse-spark-1.3-contributor-free" }, + ]; + const ctx: CordisContext = { + effect: (fn: () => unknown) => { + cleanup = fn() as (() => void) | undefined; + }, + llm: { listModels: originalListModels }, + on: () => {}, + }; + + apply(ctx); + + expect(typeof cleanup).toBe("function"); + await expect(ctx.llm?.listModels?.(RESPONSES_ROUTE)).resolves.toEqual([]); + cleanup?.(); + await expect(ctx.llm?.listModels?.(RESPONSES_ROUTE)).resolves.toEqual([ + { id: "muse-spark-1.3-contributor-free" }, + ]); + }); + + it("hands a responses-format model to the responses route", async () => { + // The gateway serves muse on /responses and everything else on + // /chat/completions, and llm-pi-ai carries one `api` per route. So the call + // is re-dispatched to the route whose `api` already names the format rather + // than translated at the transport — DSH's own adapter then speaks it. + let streamHandler: + | ((options: unknown, next: () => unknown) => unknown) + | undefined; + const dispatched: unknown[] = []; + let nextCalls = 0; + + const ctx: CordisContext = { + // The redirect asks the hiding patch whether the route is registered, so + // the effect has to actually run for this test to mean anything. + effect: (fn: () => unknown) => { + fn(); + }, + llm: { + listConfigurableProviders: () => [{ provider: "opencode" }], + listModels: async () => [{ id: "muse-spark-1.3-contributor-free" }], + listProviders: () => [{ id: "opencode" }, { id: "opencode-responses" }], + stream: (options: unknown) => { + dispatched.push(options); + return createMockStream("from-responses-route"); + }, + }, + on: ( + _event: string, + handler: (options: unknown, next: () => unknown) => unknown + ) => { + streamHandler = handler; + }, + }; + + apply(ctx); + if (typeof streamHandler !== "function") { + throw new TypeError("stream handler not registered"); + } + const result: unknown = streamHandler( + { model: "muse-spark-1.3-contributor-free", provider: "opencode" }, + () => { + nextCalls += 1; + return createMockStream("from-opencode-route"); + } + ); + + expect(dispatched).toHaveLength(1); + expect(dispatched[0]).toMatchObject({ + model: "muse-spark-1.3-contributor-free", + provider: "opencode-responses", + }); + // The redirect replaces the dispatch; running both would bill twice. + expect(nextCalls).toBe(0); + if (!isAsyncIterableLike(result)) { + throw new Error("expected async iterable downstream"); + } + expect(await collectUnknown(result)).toEqual(["from-responses-route"]); + }); + + it("dispatches normally when the responses route is not registered", async () => { + // A layer that failed to load must not turn the gateway's own error into a + // "no adapter for provider" one, which points at the wrong thing. + let streamHandler: + | ((options: unknown, next: () => unknown) => unknown) + | undefined; + let nextCalls = 0; + + const ctx: CordisContext = { + effect: (fn: () => unknown) => { + fn(); + }, + llm: { + listModels: async () => [], + // The route really is absent, so the redirect must stand down. + listProviders: () => [{ id: "opencode" }], + stream: () => createMockStream("should-not-be-used"), + }, + on: ( + _event: string, + handler: (options: unknown, next: () => unknown) => unknown + ) => { + streamHandler = handler; + }, + }; + + apply(ctx); + if (typeof streamHandler !== "function") { + throw new TypeError("stream handler not registered"); + } + const result: unknown = streamHandler( + { model: "muse-spark-1.3-contributor-free", provider: "opencode" }, + () => { + nextCalls += 1; + return createMockStream("from-opencode-route"); + } + ); + + expect(nextCalls).toBe(1); + if (!isAsyncIterableLike(result)) { + throw new Error("expected async iterable downstream"); + } + expect(await collectUnknown(result)).toEqual(["from-opencode-route"]); + }); + it("attaches to llm/stream and derives session ID", async () => { let streamHandler: | ((options: unknown, next: () => unknown) => unknown) @@ -157,6 +288,68 @@ describe("apply (plugin lifecycle)", () => { expect(await collectUnknown(result)).toEqual(["stream-chunk-1"]); }); + it("survives context proxies where direct sessions access throws without inject", async () => { + let streamHandler: + | ((options: unknown, next: () => unknown) => unknown) + | undefined; + + // Simulate Cordis proxy trap that throws when `ctx.sessions` is read directly + const proxyTarget: Record = { + effect: () => {}, + get: (serviceName: string) => + serviceName === "sessions" + ? { + get: (id: string) => ({ + header: { + cwd: "/workspace/project", + parentSession: "ses_parent", + }, + id, + }), + } + : null, + on: ( + _event: string, + handler: (options: unknown, next: () => unknown) => unknown + ) => { + streamHandler = handler; + }, + }; + + const ctx = new Proxy(proxyTarget, { + get(target, prop, receiver) { + if (prop === "sessions") { + throw new Error('cannot get property "sessions" without inject'); + } + return Reflect.get(target, prop, receiver); + }, + has(target, prop) { + if (prop === "sessions") { + return true; + } + return Reflect.has(target, prop); + }, + }) as unknown as CordisContext; + + apply(ctx); + if (typeof streamHandler !== "function") { + throw new TypeError("stream handler not registered"); + } + + const result: unknown = streamHandler( + { + model: "muse-spark-1.3-contributor-free", + provider: "opencode", + sessionId: "dsh-session-proxy-check", + }, + () => createMockStream("stream-ok") + ); + if (!isAsyncIterableLike(result)) { + throw new Error("expected async iterable downstream"); + } + expect(await collectUnknown(result)).toEqual(["stream-ok"]); + }); + it("records dollars from a usage chunk without altering the stream", async () => { clearSessionUsageStore(); let streamHandler: diff --git a/test/models-discovery.test.ts b/test/models-discovery.test.ts new file mode 100644 index 0000000..0c4beca --- /dev/null +++ b/test/models-discovery.test.ts @@ -0,0 +1,329 @@ +/** + * Provider-aware model-discovery decoration. + * + * A configured OpenCode route can be answered from its adapter’s installed + * catalog without a gateway request. In that path, response enrichment never + * runs, so decorating the discovery answer is the only way to add a missing + * catalog row or remove a provider-retired row from “fetch available models.” + * + * @module test/models-discovery.test + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + decorateModelDiscovery, + hideResponsesRoute, + isResponsesRouteRegistered, + mergeDiscoveredModels, + resolveConfig, + resolveDiscoveryProvider, + RESPONSES_ROUTE, + type CordisContext, + type CatalogModelSpec, +} from "../src/index.ts"; + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OPENCODE_SESSION_ID; +}); + +const providers = new Set(["opencode", "opencode-go"]); +const gatewayUrls = ["opencode.ai/zen"]; +const config = { ...resolveConfig(), gatewayUrls, providers }; + +const catalogRow = ( + id: string, + name = id, + inputModalities: string[] = ["text"] +): CatalogModelSpec => ({ + context_window: 1000000, + id, + input_modalities: inputModalities, + max_output_tokens: 131072, + name, +}); + +describe("resolveDiscoveryProvider", () => { + it("prefers an explicitly claimed route ID", () => { + expect(resolveDiscoveryProvider({ provider: "opencode" }, config)).toBe( + "zen" + ); + expect(resolveDiscoveryProvider({ provider: "opencode-go" }, config)).toBe( + "go" + ); + }); + + it("ignores unclaimed routes and non-OpenCode gateways", () => { + expect(resolveDiscoveryProvider({ provider: "other" }, config)).toBe( + undefined + ); + expect( + resolveDiscoveryProvider( + { baseURL: "https://gateway.example.com/v1" }, + config + ) + ).toBe(undefined); + expect(resolveDiscoveryProvider(null, config)).toBe(undefined); + }); + + it("classifies an OpenCode gateway draft by its URL path", () => { + expect( + resolveDiscoveryProvider( + { baseURL: "https://opencode.ai/zen/go/v1", provider: "custom-go" }, + { ...config, providers: new Set(["custom-go"]) } + ) + ).toBe("go"); + expect( + resolveDiscoveryProvider( + { baseURL: "https://opencode.ai/zen/v1", provider: "custom-zen" }, + { ...config, providers: new Set(["custom-zen"]) } + ) + ).toBe("zen"); + }); +}); + +describe("mergeDiscoveredModels", () => { + it("keeps adapter rows, appends missing rows, and retires Zen 1.2", () => { + const merged = mergeDiscoveredModels( + [ + { + contextWindow: 1, + id: "adapter-model", + inputModalities: ["text"], + maxTokens: 2, + name: "Adapter Model", + }, + { id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Free" }, + { id: "", name: "Nameless" }, + ], + "zen", + [ + catalogRow("adapter-model", "Catalog Adapter Model"), + catalogRow("space-bunny-free", "Space Bunny Free"), + ] + ); + + expect(merged.map((model) => model.id)).toEqual([ + "adapter-model", + "space-bunny-free", + ]); + // The adapter’s own capacities win over the canonical catalog. + expect(merged[0]).toEqual({ + contextWindow: 1, + id: "adapter-model", + inputModalities: ["text"], + maxTokens: 2, + name: "Adapter Model", + }); + }); + + it("does not retire Go’s paid Muse 1.2 contributor row", () => { + const merged = mergeDiscoveredModels( + [{ id: "muse-spark-1.2-contributor" }], + "go", + [catalogRow("space-bunny-free", "Space Bunny Free")] + ); + + expect(merged.map((model) => model.id)).toEqual([ + "muse-spark-1.2-contributor", + "space-bunny-free", + ]); + }); + + it("sanitizes candidate modalities to DSH-supported text and image only", () => { + const merged = mergeDiscoveredModels( + [ + { + id: "adapter-multimodal", + inputModalities: ["text", "image", "video", "audio", "pdf"], + }, + ], + "go", + [ + catalogRow("catalog-multimodal", "Catalog Multimodal", [ + "text", + "image", + "video", + ]), + ] + ); + + expect(merged).toEqual([ + { + id: "adapter-multimodal", + inputModalities: ["text", "image"], + }, + { + contextWindow: 1000000, + id: "catalog-multimodal", + inputModalities: ["text", "image"], + maxTokens: 131072, + name: "Catalog Multimodal", + }, + ]); + }); +}); + +describe("decorateModelDiscovery", () => { + it("leaves discovery alone when enrichment is disabled", () => { + const original = async () => [{ id: "adapter-model" }]; + const ctx = { + llm: { discoverModels: original }, + } as unknown as CordisContext; + + expect( + decorateModelDiscovery(ctx, resolveConfig({ enrichModels: false })) + ).toBe(undefined); + expect(ctx.llm?.discoverModels).toBe(original); + }); + + it("leaves a non-extensible discovery service installed", () => { + const original = async () => [{ id: "adapter-model" }]; + const ctx = { + llm: Object.freeze({ discoverModels: original }), + } as unknown as CordisContext; + + expect(decorateModelDiscovery(ctx, resolveConfig())).toBe(undefined); + expect(ctx.llm?.discoverModels).toBe(original); + }); + + it("forwards adapter errors and unclaimed routes unchanged", async () => { + const failure = new Error("boom"); + const original = vi.fn().mockRejectedValue(failure); + const ctx = { + llm: { discoverModels: original }, + } as unknown as CordisContext; + const stop = decorateModelDiscovery(ctx, resolveConfig()); + const wrapped = ctx.llm?.discoverModels; + expect(typeof wrapped).toBe("function"); + if (typeof wrapped !== "function") { + throw new TypeError("discovery decorator was not installed"); + } + + await expect(wrapped("llm-pi-ai", { provider: "other" })).rejects.toBe( + failure + ); + expect(original).toHaveBeenCalledWith( + "llm-pi-ai", + { provider: "other" }, + undefined + ); + stop?.(); + expect(ctx.llm?.discoverModels).toBe(original); + }); + + it("adds Space Bunny and removes retired 1.2 from an OpenCode answer", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("offline catalog test")) + ); + const original = vi + .fn() + .mockResolvedValue([ + { id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Free" }, + ]); + const ctx = { + llm: { discoverModels: original }, + } as unknown as CordisContext; + const stop = decorateModelDiscovery(ctx, resolveConfig()); + const wrapped = ctx.llm?.discoverModels; + expect(typeof wrapped).toBe("function"); + if (typeof wrapped !== "function") { + throw new TypeError("discovery decorator was not installed"); + } + + const merged = (await wrapped("llm-pi-ai", { + baseURL: "https://opencode.ai/zen/v1", + provider: "opencode", + })) as Array<{ id: string }>; + const ids = merged.map((model) => model.id); + expect(original).toHaveBeenCalledWith( + "llm-pi-ai", + { baseURL: "https://opencode.ai/zen/v1", provider: "opencode" }, + undefined + ); + expect(ids).toContain("space-bunny-free"); + expect(ids).not.toContain("muse-spark-1.2-contributor-free"); + stop?.(); + expect(ctx.llm?.discoverModels).toBe(original); + }); +}); + +describe("models-discovery: hiding the internal Responses route", () => { + const hostWith = () => { + const seen: string[] = []; + const ctx = { + llm: { + listConfigurableProviders: () => [ + { provider: "opencode" }, + { provider: RESPONSES_ROUTE }, + ], + listModels: async (provider: string) => { + seen.push(provider); + return [{ id: `${provider}-model` }]; + }, + listProviders: () => [ + { id: "opencode", name: "opencode" }, + { id: RESPONSES_ROUTE, name: RESPONSES_ROUTE }, + ], + }, + } as unknown as CordisContext; + return { ctx, seen }; + }; + + it("removes the route from every listing a user sees", () => { + // Three surfaces enumerate providers and there is no hidden flag on any of + // them. `joinProviderDirectory` (Settings → Models) pushes a row for every + // REGISTERED provider, so filtering only the configurable directory would + // leave the route on screen — `listProviders` is the one that matters. + const { ctx } = hostWith(); + const stop = hideResponsesRoute(ctx); + expect(stop).toBeDefined(); + + expect(ctx.llm?.listProviders?.()).toEqual([ + { id: "opencode", name: "opencode" }, + ]); + expect(ctx.llm?.listConfigurableProviders?.()).toEqual([ + { provider: "opencode" }, + ]); + }); + + it("still reports the route as registered, so the redirect fires", async () => { + // The redirect asks whether it has somewhere to go. Asked of the FILTERED + // listing the answer would always be no and the re-dispatch would never + // happen — which is why the check reads the original. + const { ctx } = hostWith(); + const stop = hideResponsesRoute(ctx); + + expect(isResponsesRouteRegistered()).toBe(true); + await expect(ctx.llm?.listModels?.(RESPONSES_ROUTE)).resolves.toEqual([]); + + stop?.(); + // Unloading must not leave a stale "registered" answer behind either. + expect(isResponsesRouteRegistered()).toBe(true); + }); + + it("reports no models for the route and passes others through", async () => { + const { ctx, seen } = hostWith(); + const stop = hideResponsesRoute(ctx); + + await expect(ctx.llm?.listModels?.(RESPONSES_ROUTE)).resolves.toEqual([]); + await expect(ctx.llm?.listModels?.("opencode")).resolves.toEqual([ + { id: "opencode-model" }, + ]); + // The Host method must not even be asked about the hidden route. + expect(seen).toEqual(["opencode"]); + + stop?.(); + await expect(ctx.llm?.listModels?.(RESPONSES_ROUTE)).resolves.toEqual([ + { id: `${RESPONSES_ROUTE}-model` }, + ]); + expect(ctx.llm?.listProviders?.()).toHaveLength(2); + }); + + it("degrades to a no-op when the Host exposes no listing at all", () => { + const ctx = { llm: {} } as unknown as CordisContext; + expect(hideResponsesRoute(ctx)).toBeUndefined(); + }); +}); diff --git a/test/primitives-stub.tsx b/test/primitives-stub.tsx index 425a1b5..05d7c48 100644 --- a/test/primitives-stub.tsx +++ b/test/primitives-stub.tsx @@ -184,6 +184,10 @@ export function SettingsValueField(props: KitProps) { return { props, type: "SettingsValueField" }; } +export function Button(props: KitProps) { + return { props, type: "Button" }; +} + export function Switch(props: KitProps) { return { props, type: "Switch" }; } diff --git a/test/responses-routes.test.ts b/test/responses-routes.test.ts new file mode 100644 index 0000000..dd07eed --- /dev/null +++ b/test/responses-routes.test.ts @@ -0,0 +1,70 @@ +/** + * `responses-routes.ts` — the dispatch table for the gateway's Responses plane, + * and the seam that keeps it agreeing with the route the plugin layer declares. + */ + +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { + RESPONSES_FORMAT_MODELS, + RESPONSES_ROUTE, + responsesRouteFor, +} from "../src/responses-routes.ts"; + +describe("responses-routes: dispatch table", () => { + it("redirects a responses-format model off the completions route", () => { + expect( + responsesRouteFor("opencode", "muse-spark-1.3-contributor-free") + ).toBe(RESPONSES_ROUTE); + }); + + it("does not redirect the redirected call again", () => { + // The redirected call re-enters the same hook with the target route already + // set. Redirecting that would recurse until the stack ran out. + expect( + responsesRouteFor(RESPONSES_ROUTE, "muse-spark-1.3-contributor-free") + ).toBeUndefined(); + }); + + it("leaves every other model on its own route", () => { + expect( + responsesRouteFor("opencode", "mimo-v2.6-flash-free") + ).toBeUndefined(); + expect(responsesRouteFor("opencode", "space-bunny-free")).toBeUndefined(); + expect(responsesRouteFor("opencode-go", "kimi-k3")).toBeUndefined(); + }); + + it("ignores malformed options", () => { + expect( + responsesRouteFor(null, "muse-spark-1.3-contributor-free") + ).toBeUndefined(); + expect(responsesRouteFor("opencode", null)).toBeUndefined(); + expect(responsesRouteFor("opencode", 42)).toBeUndefined(); + }); +}); + +describe("responses-routes: the claim seam", () => { + it("redirects only to a route the plugin layer claims", () => { + // The layer no longer DECLARES the route — a second `llm-pi-ai` row cannot + // mount, because a second instance re-registers an authorization flow per + // installed catalog provider id and `authorization.registerFlow` throws + // DUPLICATE_FLOW. The route therefore lives on the row the user already + // owns. What the layer must still do is CLAIM it: an unclaimed route gets + // no session header, no origin headers and no key injection, so the + // redirected call would 403 and look like a model problem. + const layer = readFileSync( + new URL("../cordis.patch.yml", import.meta.url), + "utf8" + ); + expect(layer).toContain(`- ${RESPONSES_ROUTE}`); + for (const model of RESPONSES_FORMAT_MODELS) { + expect(responsesRouteFor("opencode", model)).toBe(RESPONSES_ROUTE); + } + // The route's own model list lives in the user's profile and cannot be + // checked from here. Keep it equal to RESPONSES_FORMAT_MODELS by hand: + // a model the table redirects but the route does not list fails as an + // opaque "model not found" a long way from this file. + }); +}); diff --git a/test/settings-boolean-field.test.tsx b/test/settings-boolean-field.test.tsx new file mode 100644 index 0000000..612ce6c --- /dev/null +++ b/test/settings-boolean-field.test.tsx @@ -0,0 +1,82 @@ +/** + * The card's boolean control, asserted as an element tree. + * + * `SettingsBooleanField` is the plugin's own control because the host ships no + * boolean settings field (see the module's doc block). It owns exactly two + * things now that the chrome lives in `SettingsFieldShell`: turning draft text + * into the host `Switch`'s `checked`, and turning a toggle back into the + * `"true"` / `"false"` string the field spec parses. Everything else it renders + * is the shell's, and is asserted there. + */ + +import { describe, expect, it, vi } from "vitest"; + +import { + type BooleanFieldProps, + SettingsBooleanField, +} from "../src/settings-boolean-field.tsx"; +import { firstOf } from "./test-helpers.ts"; + +const baseProps = ( + overrides: Partial = {} +): BooleanFieldProps => ({ + disabled: false, + hint: "the hint", + id: "plugin-config-opencode-usageEnabled", + invalid: false, + invalidLabel: "invalid!", + label: "Usage", + onEdit: () => {}, + onReset: () => {}, + overridden: false, + overriddenLabel: "Overridden", + resetLabel: "Reset to default", + text: "false", + ...overrides, +}); + +const switchOf = (tree: unknown) => firstOf(tree, "Switch"); + +describe("SettingsBooleanField", () => { + it("checks the host Switch from the draft text", () => { + expect( + switchOf(SettingsBooleanField(baseProps({ text: "true" }))).props.checked + ).toBe(true); + expect( + switchOf(SettingsBooleanField(baseProps({ text: "false" }))).props.checked + ).toBe(false); + // A never-set field renders empty, which reads as off rather than invalid. + expect( + switchOf(SettingsBooleanField(baseProps({ text: "" }))).props.checked + ).toBe(false); + }); + + it("stages the draft as the string the field spec parses", () => { + const onEdit = vi.fn<() => void>(); + const tree = SettingsBooleanField(baseProps({ onEdit })); + const onChange = switchOf(tree).props.onChange as (next: boolean) => void; + + onChange(true); + expect(onEdit).toHaveBeenLastCalledWith("true"); + onChange(false); + expect(onEdit).toHaveBeenLastCalledWith("false"); + }); + + it("delegates every piece of chrome to the shared shell", () => { + // The shell is the one place the row chrome lives, so the field must hand + // it through untouched rather than growing its own label or badge. + const tree = SettingsBooleanField( + baseProps({ disabled: true, overridden: true }) + ); + const shell = firstOf(tree, "SettingsFieldShell"); + expect(shell.props.disabled).toBe(true); + expect(shell.props.hint).toBe("the hint"); + expect(shell.props.id).toBe("plugin-config-opencode-usageEnabled"); + expect(shell.props.invalid).toBe(false); + expect(shell.props.invalidLabel).toBe("invalid!"); + expect(shell.props.label).toBe("Usage"); + expect(shell.props.overridden).toBe(true); + expect(shell.props.overriddenLabel).toBe("Overridden"); + expect(shell.props.resetLabel).toBe("Reset to default"); + }); +}); diff --git a/test/settings-choice-field.test.tsx b/test/settings-choice-field.test.tsx new file mode 100644 index 0000000..17440e5 --- /dev/null +++ b/test/settings-choice-field.test.tsx @@ -0,0 +1,116 @@ +/** + * The card's constrained-choice control, asserted as an element tree. + * + * The host ships no enum settings field, so `SettingsChoiceField` renders a + * native `