diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4bdb7d64f..b898d575a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -92,6 +92,10 @@ jobs:
--exclude 'packages/renderer-extension/test/**'
--exclude 'tools/gate-claude-code/run.test.mjs'
+ # Real Launcher + Node service, with isolated HOME/data and fake Harnesses.
+ - name: Test independent Host lifecycle
+ run: npm run test:shared-host
+
# Rust formatting is already covered by the Linux x64 formatting step.
- name: Check Rust
run: >
@@ -146,6 +150,8 @@ jobs:
run: npm ci --prefer-offline
# Linux x64 owns TS tests; ARM64 verifies native code and the installed package.
+ - name: Test independent Host lifecycle
+ run: npm run test:shared-host
- name: Check Rust
run: >
diff --git a/.gitignore b/.gitignore
index 50003d2c8..959629564 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,9 @@
# Dependencies and tool caches
node_modules/
+.work/
+.dsh-build/
+/apps/web-ui/**/lib/
.npm/
.cache/
.wix/
diff --git a/.prettierignore b/.prettierignore
index ebadfaa33..101680903 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -15,3 +15,8 @@ package-lock.json
Cargo.lock
# Generated models.dev price snapshot.
packages/host-runtime/src/model-prices.generated.ts
+# Pinned upstream browser protocol codecs.
+apps/web-ui/**/generated/
+# Imported browser build outputs and local diagnostics.
+apps/web-ui/**/lib/
+.work/
diff --git a/AGENTS.md b/AGENTS.md
index 3ed051185..04a4edb06 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,7 +83,8 @@ Existing Harness integration and Desktop update audit skills remain applicable t
## Testing & Completion
- Choose build and validation commands from `package.json`, not a copied command catalog. For focused tests, use the repository configuration in `tests/vitest.config.js` or `tests/e2e/playwright.config.js`, and the owning `Cargo.toml` for Rust test features.
-- To build and launch from source, run `npm start` at the repository root; `npm start -- --no-build` reuses existing artifacts. On macOS/Windows this stops running Codex Desktop processes before launch. Use it when launching is intended, not as a routine validation command; implementation is in `tools/dev-desktop/run.mjs`.
+- To build and launch from source, run `npm start` at the repository root for Desktop or `npm start web` for Web; both restart the corresponding interface after building, reusing an unchanged shared Host. A changed Host restarts automatically only when the other interface is closed; otherwise a post-build prompt defaults to keeping Host (non-interactive runs keep it and warn). `--restart-host` / `--keep-host` override this choice; no running Host is left to the interface's `host ensure`. `--no-build` reuses existing artifacts and applies the same Host decision. Restarting Host interrupts its tasks. `npm start web` rebuilds Web UI sources on edit and swaps them into open pages by default; `--no-watch` disables this, while explicit `--watch` remains supported. `--no-build` does not disable watching. On macOS/Windows the Desktop command stops running Codex Desktop processes before launch. Use it when launching is intended, not as a routine validation command; implementation is in `tools/dev-desktop/run.mjs`.
+- `npm stop` stops the current user's identifiable local codexhost services across worktrees (Web/watchers, managed Desktop, shared Hosts and Console), interrupting tasks and Web terminals without deleting data. It signals verified service processes and waits for native supervisors to finish cleanup, without using the invoking checkout's build. Run only when stopping is intended, from a separate terminal; never use it as routine validation or from a Host-owned task. Implementation: `tools/dev-desktop/stop.mjs`.
- Small, low-risk changes do not require tests. For high-risk or cross-package changes, or when explicitly requested, add focused tests for changed behavior and boundary conditions; do not run full test suites by default.
- Do not claim a check passed unless it was executed. Report skipped or blocked checks and the reason.
- A change is complete only when implementation, contracts, tests, and affected documentation agree.
diff --git a/README.md b/README.md
index 21019d14c..f124de9ff 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,10 @@ https://github.com/user-attachments/assets/c48192d7-23ff-4f6e-b61a-6345a655bb76
+## Standalone Web development
+
+This checkout also contains a Web/PWA application. Run `npm ci`, then `npm start web` to build and start an authenticated Web client with Web UI hot reload enabled by default. Use `npm start web -- --no-watch` to disable hot reload. The development command restarts this checkout's Web service on the selected port and reuses an unchanged shared Host. If the Host changes, it restarts automatically when Desktop is closed; otherwise a post-build prompt defaults to keeping it (non-interactive runs keep it and warn). Restarting Host interrupts its tasks. Web and Desktop can each be used alone, share canonical external Threads when both are open, and close without stopping the other or its tasks. Existing saved GUI project metadata remains read-only; no GUI projects are created. This does not launch or restart Desktop. A running older Desktop-owned Host is not taken over; update/restart it separately to transition. See [the Web guide](docs/product/standalone-web.md) for connection requirements, plugin distribution and current limitations.
+
## Quick Start
**Option 1: npm** (macOS / Windows / Linux)
@@ -84,7 +88,7 @@ Run `codexhost update` to check for the latest release and prepare an upgrade fr
**codexhost does not start, or Codex opens without codexhost features**
-Run `codexhost console` (Windows: Start Menu → "codexhost console") to open the local console at `http://127.0.0.1:4399/`. It shows why the last start failed, the Codex Desktop version, Host Runtime logs, and can update codexhost while Codex is not running. It starts together with codexhost: installer launches open it in your browser, and terminal launches print its address.
+Run `codexhost console` (Windows: Start Menu → "codexhost console") to open the local console at `http://127.0.0.1:4399/`. It shows why the last start failed, the Codex Desktop version, Host Runtime logs, and can update codexhost while Codex is not running. It starts together with codexhost: installer launches open it in your browser only when startup or Codex UI integration fails, and terminal launches print its address only when startup fails.
**macOS: "App can't be verified" on first launch**
@@ -296,6 +300,21 @@ npm ci
npm start
```
+| Command | Purpose |
+| --- | --- |
+| `npm start` | Build and start Desktop. |
+| `npm start web` | Build and start Web with Web UI hot reload (default). |
+| `npm start web -- --no-watch` | Build and start Web without hot reload. |
+| `npm stop` | Stop the current user's local codexhost services across worktrees. |
+
+Explicit `--watch` remains supported; it cannot be combined with `--no-watch`. `--no-build` skips the initial full build but does not disable watching.
+
+Web UI hot reload swaps rebuilt client plugins in open pages; shell changes reload the page. Plugin React state may reset. Changes to Host, Web server or the CodexHost settings page still require restarting. See [Web UI hot reload](docs/product/standalone-web.md#前端开发热更新) for details.
+
+`npm start` rebuilds and restarts Desktop; `npm start web` rebuilds and restarts Web. Both reuse an unchanged shared Host. A changed Host is restarted if the other interface is closed; otherwise a post-build prompt defaults to keeping it. `--restart-host` / `--keep-host` override that decision. `--no-build` skips building but applies the same decision. Restarting Host interrupts its tasks.
+
+Run `npm stop` from a separate terminal to stop the current user's local codexhost services across worktrees, including managed Desktop, Web/watchers, shared Hosts and Console. It interrupts tasks and Web terminals but preserves data and build artifacts, and requires no build in the invoking checkout. See [cross-worktree shutdown](docs/product/standalone-web.md#跨工作树停机) for scope and limitations; `npm stop -- --help` is read-only.
+
To run the current source with a specific runtime version, use `npm start 0.12.0` (prereleases such as `0.13.0-rc.1` are also accepted). Host status, console version and update-check version reporting use this value. Without a version argument the version remains `-dev`; `npm start 0.12.0 -- --no-build` reuses artifacts after building once. The override lasts for this launch and does not modify version files or publish packages. Source installations still lack packaged self-update resources; remote npm installation downloads the published package with that version, not the local source.
diff --git a/apps/web-ui/LICENSE b/apps/web-ui/LICENSE
new file mode 100644
index 000000000..c1f7a78e8
--- /dev/null
+++ b/apps/web-ui/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 DeepSeek
+
+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/apps/web-ui/README.md b/apps/web-ui/README.md
new file mode 100644
index 000000000..8bc346bb0
--- /dev/null
+++ b/apps/web-ui/README.md
@@ -0,0 +1,15 @@
+# CodexHost Web UI
+
+Private browser workspaces imported from the MIT-licensed `deepseek-harness` 0.2.1-alpha.1 Web UI and the local `codexhost-web` fork. The import keeps the selected client plugins, their browser runtime and protocol dependencies, and local CodexHost/mobile changes. It does not include the upstream CLI, release machinery, independent agent execution, or a second Git repository.
+
+The source roster is in `import-manifest.json`. `packages/` and `vendor/` are npm workspaces of the parent CodexHost repository; install dependencies once at that repository root. `npm run build:web:ui` compiles plugin factories with the imported CSS/module-loader preset and builds `apps/web` with Vite. `lib/` and `dist/` are generated and ignored. All Harness execution belongs to the parent repository's Adapters and Web Server, not these browser packages.
+
+The `generated/remote.js` and matching declarations in selected packages are pinned DSH protocol codecs from the imported fork's generated Host descriptors. They are protocol source snapshots, not copies of Harness SDKs or bundled applications. Keep them aligned with the Web Server's DSH protocol compatibility layer; do not generate them from the Desktop Host. The full upstream frontend type-check and test programs are not imported yet; the current integration uses source compilation and the Web Server's browser regression tests.
+
+The right Sidebar also embeds upstream host services: `packages/fs/fs`, `packages/fs/fs-local`, `packages/util/timeout`, `packages/sandbox/sandbox-policy`, `packages/core/scope`, `packages/subprocess/*`, `packages/util/http-proxy`, `packages/util/lazy-require`, and `packages/document/office-to-pdf` are the imported local-filesystem, sandbox-policy, subprocess/PTY, and Office-conversion sources. `packages/host/dsh-web-backend` is the CodexHost adapter that mounts the Typert registry, local filesystem, Connection, gateway, workspace files, Office conversion, subprocess, and terminal services for `packages/web-server`; it supplies only a local Thread's working directory, the sandbox policy's workspace root, and the four Agent fields the terminal controller reads. The imported client plugins are the file tree, document preview, terminal, and sandboxed browser tabs; `packages/test-support/remote-mock` supports their client tests. The one upstream source adaptation in the gateway is `packages/api/gateway/src/index.ts`, which imports the runtime `OperatorPeer` from `src/operator-peer.ts` rather than the Connection plugin root so the embedded backend does not pull the upstream browser-auth and credentials chain; `subprocess-local/src/runner-launch.ts` additionally honors `CODEXHOST_SUBPROCESS_RUNNER` so a packed distribution can run its bundled runner. Dependency patches for FortuneSheet, ExcelJS, and node-pty are copied to the repository `patches/` directory and applied by `scripts/apply-dependency-patches.mjs` after install. `tests/dsh` holds the imported workspace file and terminal-controller tests; they run under Vitest with the same standard-decorator pre-transform the upstream repository uses.
+
+`packages/client/hmr` is the browser half of the upstream `@deepseek-ai/dsh-client-hmr` plugin; its DSH Host half is replaced by `packages/web-server/src/web-hmr.ts`, and the plugin is composed only when the Web Server runs with `--dev-web-ui` (`npm start web` enables this by default and runs `scripts/build.ts --watch`; `--no-watch` disables it). Its one local adaptation is a `shell` frame that reloads the page, because the statically linked shell cannot be swapped.
+
+The upstream MIT license is retained in `LICENSE`, framework package licenses remain under `vendor/`, and upstream dependency notices are retained in `THIRD_PARTY_NOTICES.md`. Local import adaptations include private npm manifests, source exports, the selected source build, and removal of the upstream standalone Web Worker preview entry. The root LGPL license does not replace these notices.
+
+See [the Web feature document](../../docs/product/standalone-web.md) for runtime isolation, commands and supported behavior.
diff --git a/apps/web-ui/THIRD_PARTY_NOTICES.md b/apps/web-ui/THIRD_PARTY_NOTICES.md
new file mode 100644
index 000000000..3602ce87c
--- /dev/null
+++ b/apps/web-ui/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,297 @@
+
+
+# Third-Party Notices
+
+DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
+
+This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check.
+
+The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python SDK closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock).
+
+## Vendored source (`vendor/`)
+
+The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the `@deepseek-ai` scope. All are MIT-licensed; each directory preserves its upstream `LICENSE` file. Exact upstream commits and local modifications are recorded in [`vendor/README.md`](vendor/README.md).
+
+| Package | Upstream name | Source | License |
+| --- | --- | --- | --- |
+| `@deepseek-ai/cosmokit` | `cosmokit` | [vendor/cosmokit](vendor/cosmokit/) | MIT |
+| `@deepseek-ai/schemastery` | `schemastery` | [vendor/schemastery](vendor/schemastery/) | MIT |
+| `@deepseek-ai/cordis` | `cordis` | [vendor/cordis](vendor/cordis/) | MIT |
+| `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | [vendor/loader](vendor/loader/) | MIT |
+| `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | [vendor/include](vendor/include/) | MIT |
+| `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | [vendor/group](vendor/group/) | MIT |
+| `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | [vendor/timer](vendor/timer/) | MIT |
+| `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | [vendor/hmr](vendor/hmr/) | MIT |
+| `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | [vendor/logger-console](vendor/logger-console/) | MIT |
+
+## Runtime npm dependencies
+
+External packages installed for runtime use or distributed inside the prebuilt browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default.
+
+| Package | License |
+| --- | --- |
+| [`@agentclientprotocol/sdk`](https://github.com/agentclientprotocol/typescript-sdk) | Apache-2.0 |
+| [`@anthropic-ai/claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-typescript) | SEE LICENSE IN README.md |
+| [`@anthropic-ai/sdk`](https://github.com/anthropics/anthropic-sdk-typescript) | MIT |
+| [`@babel/code-frame`](https://github.com/babel/babel) | MIT |
+| [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT |
+| [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) | MPL-2.0 |
+| [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT |
+| [`@eslint-community/regexpp`](https://github.com/eslint-community/regexpp) | MIT |
+| [`@fortune-sheet/core`](https://github.com/ruilisi/fortune-sheet) | MIT |
+| [`@fortune-sheet/react`](https://github.com/ruilisi/fortune-sheet) | MIT |
+| [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT |
+| [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT |
+| [`@js-temporal/polyfill`](https://github.com/js-temporal/temporal-polyfill) | ISC |
+| [`@lexical/history`](https://github.com/facebook/lexical) | MIT |
+| [`@lexical/plain-text`](https://github.com/facebook/lexical) | MIT |
+| [`@lexical/text`](https://github.com/facebook/lexical) | MIT |
+| [`@lexical/utils`](https://github.com/facebook/lexical) | MIT |
+| [`@modelcontextprotocol/client`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |
+| [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |
+| [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | MIT |
+| [`@octokit/webhooks`](https://github.com/octokit/webhooks.js) | MIT |
+| [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 |
+| [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@opentelemetry/core`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@opentelemetry/otlp-transformer`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@playwright/mcp`](https://github.com/microsoft/playwright-mcp) | Apache-2.0 |
+| [`@puppeteer/browsers`](https://github.com/puppeteer/puppeteer/tree/main/packages/browsers) | Apache-2.0 |
+| [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
+| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
+| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
+| [`@trycua/cua-driver`](https://github.com/trycua/cua) | MIT |
+| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
+| [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) | MIT |
+| [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize) | MIT |
+| [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT |
+| [`@xterm/xterm`](https://github.com/xtermjs/xterm.js) | MIT |
+| [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
+| [`acorn`](https://github.com/acornjs/acorn) | MIT |
+| [`ajv`](https://github.com/ajv-validator/ajv) | MIT |
+| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
+| [`big.js`](https://github.com/MikeMcl/big.js) | MIT |
+| [`buffer`](https://github.com/feross/buffer) | MIT |
+| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
+| [`chrome-devtools-frontend`](https://github.com/ChromeDevTools/devtools-frontend) | BSD-3-Clause |
+| [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) | Apache-2.0 |
+| [`clsx`](https://github.com/lukeed/clsx) | MIT |
+| [`commander`](https://github.com/tj/commander.js) | MIT |
+| [`compression`](https://github.com/expressjs/compression) | MIT |
+| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
+| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) |
+| [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT |
+| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
+| [`exceljs`](https://github.com/exceljs/exceljs) | MIT |
+| [`execa`](https://github.com/sindresorhus/execa) | MIT |
+| [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT |
+| [`fflate`](https://github.com/101arrowz/fflate) | MIT |
+| [`got`](https://github.com/sindresorhus/got) | MIT |
+| [`immer`](https://github.com/immerjs/immer) | MIT |
+| [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT |
+| [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT |
+| [`katex`](https://github.com/KaTeX/KaTeX) | MIT |
+| [`koffi`](https://github.com/Koromix/koffi) | MIT |
+| [`lexical`](https://github.com/facebook/lexical) | MIT |
+| [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT |
+| [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT |
+| [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT |
+| [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT |
+| [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT |
+| [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT |
+| [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT |
+| [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT |
+| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT |
+| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
+| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
+| [`mime-types`](https://github.com/jshttp/mime-types) | MIT |
+| [`negotiator`](https://github.com/jshttp/negotiator) | MIT |
+| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
+| [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
+| [`open`](https://github.com/sindresorhus/open) | MIT |
+| [`papaparse`](https://github.com/mholt/PapaParse) | MIT |
+| [`pdfjs-dist`](https://github.com/mozilla/pdf.js) | Apache-2.0 |
+| [`picomatch`](https://github.com/micromatch/picomatch) | MIT |
+| [`react`](https://github.com/facebook/react) | MIT |
+| [`react-dom`](https://github.com/facebook/react) | MIT |
+| [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT |
+| [`resolve.exports`](https://github.com/lukeed/resolve.exports) | MIT |
+| [`semver`](https://github.com/npm/node-semver) | ISC |
+| [`serve-static`](https://github.com/expressjs/serve-static) | MIT |
+| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
+| [`sherpa-onnx-node`](https://github.com/csukuangfj/sherpa-onnx) | Apache-2.0 |
+| [`shiki`](https://github.com/shikijs/shiki) | MIT |
+| [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 |
+| [`supports-color`](https://github.com/chalk/supports-color) | MIT |
+| [`tsx`](https://github.com/privatenumber/tsx) | MIT |
+| [`turndown`](https://github.com/mixmark-io/turndown) | MIT |
+| [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 |
+| [`undici`](https://github.com/nodejs/undici) | MIT |
+| [`use-sync-external-store`](https://github.com/facebook/react) | MIT |
+| [`ws`](https://github.com/websockets/ws) | MIT |
+| [`xlsx`](https://git.sheetjs.com/SheetJS/sheetjs) | Apache-2.0 |
+| [`yaml`](https://github.com/eemeli/yaml) | ISC |
+| [`zod`](https://github.com/colinhacks/zod) | MIT |
+| [`zustand`](https://github.com/pmndrs/zustand) | MIT |
+
+pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
+
+- `@earendil-works/pi-ai@0.87.1` — [`patches/@earendil-works__pi-ai@0.87.1.patch`](patches/@earendil-works__pi-ai@0.87.1.patch)
+- `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch)
+- `@fortune-sheet/core@1.0.4` — [`patches/@fortune-sheet__core@1.0.4.patch`](patches/@fortune-sheet__core@1.0.4.patch)
+- `@fortune-sheet/react@1.0.4` — [`patches/@fortune-sheet__react@1.0.4.patch`](patches/@fortune-sheet__react@1.0.4.patch)
+- `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch)
+- `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch)
+- `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch)
+
+## Chrome DevTools frontend
+
+The optional experimental Inspector distributes a locally compiled copy of [chrome-devtools-frontend 1.0.1638082](https://www.npmjs.com/package/chrome-devtools-frontend/v/1.0.1638082), from upstream revision [0e1186138ed519d9659c1874bf6375eca4483c72](https://chromium.googlesource.com/devtools/devtools-frontend/+/0e1186138ed519d9659c1874bf6375eca4483c72). The build includes the Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) and the third-party license and notice files supplied by the npm source. The Chromium root license does not replace those dependencies' licenses.
+
+
+## Official Claude Code platform payloads
+
+The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review.
+
+The installed SDK 0.3.263 declares the following optional platform packages. Each carries the official Claude Code 2.1.263 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host.
+
+| Optional platform package | Version | Declared license |
+| --- | --- | --- |
+| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.263 | SEE LICENSE IN LICENSE.md |
+| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md |
+| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.263 | SEE LICENSE IN LICENSE.md |
+| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.263 | SEE LICENSE IN LICENSE.md |
+| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md |
+| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.263 | SEE LICENSE IN LICENSE.md |
+| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.263 | SEE LICENSE IN LICENSE.md |
+| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md |
+
+
+## LibreOffice conversion kit
+
+`@deepseek-ai/libreoffice-kit`, `@deepseek-ai/libreoffice-kit-wasm`, `@deepseek-ai/libreoffice-kit-darwin-arm64`, `@deepseek-ai/libreoffice-kit-darwin-x64`, `@deepseek-ai/libreoffice-kit-win32-arm64`, `@deepseek-ai/libreoffice-kit-win32-x64` declare MPL-2.0, which remains outside the permissive-license allowlist; the notices check accepts only these package identities at those terms. The [distribution decision](.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) records the source obligations.
+
+The [kit repository](https://github.com/deepseek-ai/dsh-libreoffice-kit) supplies the corresponding LibreOffice source pin, modifications, build instructions, Node API, and artifact validation. Its engine packages retain their license and third-party notices; the Node API retains its MPL-2.0 declaration and NOTICE. Recipients must have access to those corresponding sources and notices.
+
+
+## Development-only npm dependencies
+
+External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure.
+
+| Package | License |
+| --- | --- |
+| [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT |
+| [`@electron/get`](https://github.com/electron/get) | MIT |
+| [`@electron/notarize`](https://github.com/electron/notarize) | MIT |
+| [`@lexical/headless`](https://github.com/facebook/lexical) | MIT |
+| [`@modelcontextprotocol/node`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |
+| [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |
+| [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
+| [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
+| [`@panzoom/panzoom`](https://github.com/timmywil/panzoom) | MIT |
+| [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT |
+| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT |
+| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT |
+| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/big.js`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/papaparse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/readable-stream`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/semver`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/serve-static`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT |
+| [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT |
+| [`@vitest/spy`](https://github.com/vitest-dev/vitest) | MIT |
+| [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT |
+| [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
+| [`app-builder-lib`](https://github.com/electron-userland/electron-builder) | MIT |
+| [`cos-nodejs-sdk-v5`](https://github.com/tencentyun/cos-nodejs-sdk-v5) | ISC |
+| [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT |
+| [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT |
+| [`dayjs`](https://github.com/iamkun/dayjs) | MIT |
+| [`debug`](https://github.com/debug-js/debug) | MIT |
+| [`electron`](https://github.com/electron/electron) | MIT |
+| [`electron-builder`](https://github.com/electron-userland/electron-builder) | MIT |
+| [`esbuild`](https://github.com/evanw/esbuild) | MIT |
+| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
+| [`extract-zip`](https://github.com/maxogden/extract-zip) | BSD-2-Clause |
+| [`fast-check`](https://github.com/dubzzz/fast-check) | MIT |
+| [`http-server`](https://github.com/http-party/http-server) | MIT |
+| [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause |
+| [`jscpd`](https://github.com/kucherenko/jscpd) | MIT |
+| [`jsdom`](https://github.com/jsdom/jsdom) | MIT |
+| [`lefthook`](https://github.com/evilmartians/lefthook) | MIT |
+| [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 |
+| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
+| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
+| [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
+| [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT |
+| [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 |
+| [`pnpm`](https://github.com/pnpm/pnpm) | MIT |
+| [`publint`](https://github.com/publint/publint) | MIT |
+| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause |
+| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT |
+| [`tar`](https://github.com/isaacs/node-tar) | BlueOak-1.0.0 |
+| [`tsdown`](https://github.com/rolldown/tsdown) | MIT |
+| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 |
+| [`vite`](https://github.com/vitejs/vite) | MIT |
+| [`vite-tsconfig-paths`](https://github.com/aleclarson/vite-tsconfig-paths) | MIT |
+| [`vitepress`](https://github.com/vuejs/vitepress) | MIT |
+| [`vitepress-plugin-mermaid`](https://github.com/emersonbottero/vitepress-plugin-mermaid) | MIT |
+| [`vitest`](https://github.com/vitest-dev/vitest) | MIT |
+| [`vue`](https://github.com/vuejs/core) | MIT |
+
+`eslint-plugin-sonarjs` (LGPL-3.0-only) and `lightningcss` (MPL-2.0) run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact.
+
+## Python SDK dependencies (`python/`)
+
+Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the development workflow tool.
+
+| Package | License | Role |
+| --- | --- | --- |
+| [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend |
+| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness-sdk` |
+| [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only |
+| [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
+
+## Bundled Python distributions
+
+The [shared runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels.
+
+| Distribution | Locked version | Project license |
+| --- | --- | --- |
+| [`et-xmlfile`](https://foss.heptapod.net/openpyxl/et_xmlfile) | 2.0.0 | MIT |
+| [`lxml`](https://github.com/lxml/lxml) | 6.1.3 | BSD-3-Clause |
+| [`numpy`](https://github.com/numpy/numpy) | 2.3.5 | BSD-3-Clause |
+| [`openpyxl`](https://foss.heptapod.net/openpyxl/openpyxl) | 3.1.5 | MIT |
+| [`pandas`](https://github.com/pandas-dev/pandas) | 3.0.1 | BSD-3-Clause |
+| [`pillow`](https://github.com/python-pillow/Pillow) | 12.3.0 | MIT-CMU |
+| [`python-dateutil`](https://github.com/dateutil/dateutil) | 2.9.0.post0 | Apache-2.0 OR BSD-3-Clause |
+| [`python-docx`](https://github.com/python-openxml/python-docx) | 1.2.0 | MIT |
+| [`python-pptx`](https://github.com/scanny/python-pptx) | 1.0.2 | MIT |
+| [`six`](https://github.com/benjaminp/six) | 1.17.0 | MIT |
+| [`typing-extensions`](https://github.com/python/typing_extensions) | 4.16.0 | PSF-2.0 |
+| [`tzdata`](https://github.com/python/tzdata) | 2025.2 | Apache-2.0 |
+| [`xlsxwriter`](https://github.com/jmcnamara/XlsxWriter) | 3.2.9 | BSD-2-Clause |
+
+## First-party native packages
+
+`@deepseek-ai/node-addon-system` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.
diff --git a/apps/web-ui/apps/web/.npmignore b/apps/web-ui/apps/web/.npmignore
new file mode 100644
index 000000000..46af539c1
--- /dev/null
+++ b/apps/web-ui/apps/web/.npmignore
@@ -0,0 +1 @@
+*.map
\ No newline at end of file
diff --git a/apps/web-ui/apps/web/index.html b/apps/web-ui/apps/web/index.html
new file mode 100644
index 000000000..6b3458d2d
--- /dev/null
+++ b/apps/web-ui/apps/web/index.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DSH Local Build
+
+
+
+
+
+
diff --git a/apps/web-ui/apps/web/package.json b/apps/web-ui/apps/web/package.json
new file mode 100644
index 000000000..2fd3e24a3
--- /dev/null
+++ b/apps/web-ui/apps/web/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@codexhost/web-ui-shell",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "license": "MIT",
+ "scripts": {
+ "build": "vite build"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-client-web": "*",
+ "@deepseek-ai/dsh-client-ui-theme": "*",
+ "@vitejs/plugin-react": "^4.0.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/apps/web-ui/apps/web/product-isolation.ts b/apps/web-ui/apps/web/product-isolation.ts
new file mode 100644
index 000000000..6b54d4258
--- /dev/null
+++ b/apps/web-ui/apps/web/product-isolation.ts
@@ -0,0 +1,121 @@
+/** Vite adapters for recording product chunk, asset, CSS, and worker inputs. */
+
+import type { Plugin } from "vite";
+import { WebProductBundleIsolation } from "../../scripts/web-product-bundle-isolation.ts";
+
+const DEPENDENCY_ANALYSIS_PLUGIN = "dsh-browser-dependency-analysis";
+
+/**
+ * Identify a dependency-disclosure walk that deliberately leaves third-party imports external.
+ * @returns Analysis intent that refuses builds which can write output.
+ */
+export function browserDependencyAnalysis(): Plugin {
+ return {
+ name: DEPENDENCY_ANALYSIS_PLUGIN,
+ apply: "build",
+ configResolved(config) {
+ requireNonWritingAnalysis(config.build.write);
+ },
+ };
+}
+
+function requireNonWritingAnalysis(write: boolean): void {
+ if (write !== false) throw new Error("browser dependency analysis requires build.write: false");
+}
+
+/**
+ * Reject experimental inputs reachable from the built default Web page.
+ * @param repository - repository root supplying package ownership.
+ * @param webRoot - Vite root containing index.html and public assets.
+ * @returns Build plugins that preserve Vite's output and fail before publication on a violation.
+ */
+export function productWebBundleIsolation(repository: string, webRoot: string): Plugin[] {
+ const inputs = new WebProductBundleIsolation(repository, webRoot);
+ let dependencyAnalysis = false;
+ return [
+ {
+ name: "dsh-product-web-chunk-inputs",
+ apply: "build",
+ generateBundle: {
+ order: "pre",
+ handler(_options, bundle) {
+ if (!dependencyAnalysis) inputs.captureChunks(bundle);
+ },
+ },
+ },
+ {
+ name: "dsh-product-web-bundle-isolation",
+ apply: "build",
+ enforce: "post",
+ config(config) {
+ const renderBuiltUrl = config.experimental?.renderBuiltUrl;
+ const workerPlugins = config.worker?.plugins;
+ return {
+ experimental: {
+ renderBuiltUrl(filename, context) {
+ inputs.assetReference(filename, context.hostId, context.type);
+ return renderBuiltUrl?.(filename, context);
+ },
+ },
+ worker: {
+ plugins: () => [
+ ...(workerPlugins?.() ?? []),
+ {
+ name: "dsh-worker-build-inputs",
+ generateBundle: {
+ order: "post",
+ handler(_options, bundle) {
+ inputs.workerBundle(bundle, this.getWatchFiles());
+ },
+ },
+ },
+ ],
+ },
+ };
+ },
+ configResolved(config) {
+ dependencyAnalysis = config.plugins.some(
+ (plugin) => plugin.name === DEPENDENCY_ANALYSIS_PLUGIN,
+ );
+ if (dependencyAnalysis) {
+ requireNonWritingAnalysis(config.build.write);
+ return;
+ }
+ const cssPlugins = config.plugins.filter((plugin) => plugin.name === "vite:css");
+ const css = cssPlugins[0];
+ if (cssPlugins.length !== 1 || css?.transform === undefined) {
+ throw new Error("Web product isolation: Vite CSS input instrumentation is unavailable");
+ }
+ const transform =
+ typeof css.transform === "function" ? css.transform : css.transform.handler;
+ css.transform = {
+ ...(typeof css.transform === "function" ? {} : css.transform),
+ handler(code, id, options) {
+ inputs.cssTransform(id);
+ const context = new Proxy(this, {
+ get(target, property) {
+ if (property === "addWatchFile")
+ return (file: string): void => {
+ inputs.cssDependency(id, file);
+ target.addWatchFile(file);
+ };
+ const value: unknown = Reflect.get(target, property, target);
+ return typeof value === "function" ? value.bind(target) : value;
+ },
+ });
+ return transform.call(context, code, id, options);
+ },
+ };
+ },
+ buildStart() {
+ inputs.reset();
+ },
+ generateBundle: {
+ order: "post",
+ handler(_options, bundle) {
+ if (!dependencyAnalysis) inputs.verify(bundle, (id) => this.getModuleInfo(id));
+ },
+ },
+ },
+ ];
+}
diff --git a/apps/web-ui/apps/web/public/apple-touch-icon.png b/apps/web-ui/apps/web/public/apple-touch-icon.png
new file mode 100644
index 000000000..8f3593311
Binary files /dev/null and b/apps/web-ui/apps/web/public/apple-touch-icon.png differ
diff --git a/apps/web-ui/apps/web/public/favicon-dark.svg b/apps/web-ui/apps/web/public/favicon-dark.svg
new file mode 100644
index 000000000..70ac67657
--- /dev/null
+++ b/apps/web-ui/apps/web/public/favicon-dark.svg
@@ -0,0 +1 @@
+
diff --git a/apps/web-ui/apps/web/public/favicon.svg b/apps/web-ui/apps/web/public/favicon.svg
new file mode 100644
index 000000000..9d2c7bd33
--- /dev/null
+++ b/apps/web-ui/apps/web/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/apps/web-ui/apps/web/public/icon-192.png b/apps/web-ui/apps/web/public/icon-192.png
new file mode 100644
index 000000000..a51963c11
Binary files /dev/null and b/apps/web-ui/apps/web/public/icon-192.png differ
diff --git a/apps/web-ui/apps/web/public/icon-512.png b/apps/web-ui/apps/web/public/icon-512.png
new file mode 100644
index 000000000..d5bae8560
Binary files /dev/null and b/apps/web-ui/apps/web/public/icon-512.png differ
diff --git a/apps/web-ui/apps/web/public/manifest.webmanifest b/apps/web-ui/apps/web/public/manifest.webmanifest
new file mode 100644
index 000000000..8a5e08237
--- /dev/null
+++ b/apps/web-ui/apps/web/public/manifest.webmanifest
@@ -0,0 +1,15 @@
+{
+ "name": "CodexHost",
+ "short_name": "CodexHost",
+ "description": "Run Claude Code, Pi and other coding agents from any browser.",
+ "start_url": "./",
+ "scope": "./",
+ "display": "standalone",
+ "background_color": "#ffffff",
+ "theme_color": "#ffffff",
+ "icons": [
+ { "src": "favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
+ { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
+ { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
+ ]
+}
diff --git a/apps/web-ui/apps/web/public/sw.js b/apps/web-ui/apps/web/public/sw.js
new file mode 100644
index 000000000..7643251df
--- /dev/null
+++ b/apps/web-ui/apps/web/public/sw.js
@@ -0,0 +1,58 @@
+/* CodexHost service worker: Web Push notifications and notification clicks. */
+
+self.addEventListener("install", () => {
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(self.clients.claim());
+});
+
+self.addEventListener("push", (event) => {
+ let message = {};
+ try {
+ message = event.data ? event.data.json() : {};
+ } catch {
+ message = { title: "CodexHost", body: event.data ? event.data.text() : "" };
+ }
+ event.waitUntil(
+ (async () => {
+ // Skip the notification when the user is already looking at CodexHost.
+ const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
+ const visible = windows.some(
+ (client) => client.visibilityState === "visible" && client.focused,
+ );
+ if (visible && message.kind === "turn") return;
+ await self.registration.showNotification(message.title || "CodexHost", {
+ body: message.body || "",
+ tag: message.tag || undefined,
+ renotify: Boolean(message.tag),
+ icon: "icon-192.png",
+ badge: "icon-192.png",
+ data: { url: message.url || "./", sessionId: message.sessionId },
+ requireInteraction: message.kind === "approval" || message.kind === "question",
+ });
+ })(),
+ );
+});
+
+self.addEventListener("notificationclick", (event) => {
+ event.notification.close();
+ const target = new URL(event.notification.data?.url || "./", self.registration.scope).href;
+ event.waitUntil(
+ (async () => {
+ const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
+ for (const client of windows) {
+ if (client.url.startsWith(self.registration.scope)) {
+ await client.focus();
+ client.postMessage({
+ type: "codexhost/open-session",
+ sessionId: event.notification.data?.sessionId,
+ });
+ return;
+ }
+ }
+ await self.clients.openWindow(target);
+ })(),
+ );
+});
diff --git a/apps/web-ui/apps/web/src/main.ts b/apps/web-ui/apps/web/src/main.ts
new file mode 100644
index 000000000..8cca8a3e2
--- /dev/null
+++ b/apps/web-ui/apps/web/src/main.ts
@@ -0,0 +1,61 @@
+/** Browser entry for the Web client. */
+import "@deepseek-ai/dsh-client-ui-theme/brand-font.css";
+import { AppWebEntry, applyIndexInjections } from "@deepseek-ai/dsh-client-web";
+
+interface DesktopBootGlobal {
+ dshDesktopBoot?: {
+ failed(message: string): Promise;
+ ready(): Promise<{
+ injections: Parameters[0];
+ streamBaseUrl: string;
+ }>;
+ };
+}
+const desktop = (globalThis as DesktopBootGlobal).dshDesktopBoot;
+const reportFailure = (reason: unknown): void => {
+ if (desktop === undefined) throw reason;
+ void desktop
+ .failed(reason instanceof Error ? reason.message : String(reason))
+ .catch(console.error);
+};
+
+try {
+ const el = document.getElementById("root");
+ if (el === null) throw new Error("web app: missing #root");
+ const entry = new AppWebEntry(el);
+ if (desktop !== undefined) {
+ const gate = (globalThis as { __DSH_BOOT_READY__?: PromiseWithResolvers })
+ .__DSH_BOOT_READY__;
+ if (gate === undefined) throw new Error("desktop web: boot readiness is missing");
+ void desktop
+ .ready()
+ .then(async ({ injections, streamBaseUrl }) => {
+ const transport = globalThis as {
+ __DSH_TRANSPORT__?: { ownsHost: boolean; streamBaseUrl: string };
+ };
+ transport.__DSH_TRANSPORT__ = { ownsHost: true, streamBaseUrl };
+ await applyIndexInjections(
+ injections,
+ (src) =>
+ new Promise((resolve, reject) => {
+ const script = document.createElement("script");
+ script.src = src;
+ script.onload = () => {
+ resolve();
+ };
+ script.onerror = () => {
+ reject(new Error(`desktop web: failed to load ${src}`));
+ };
+ document.head.append(script);
+ }),
+ );
+ gate.resolve();
+ })
+ .catch((error: unknown) => {
+ gate.reject(error);
+ });
+ }
+ void entry.run(desktop === undefined ? undefined : reportFailure);
+} catch (reason) {
+ reportFailure(reason);
+}
diff --git a/apps/web-ui/apps/web/src/node-module-stub.ts b/apps/web-ui/apps/web/src/node-module-stub.ts
new file mode 100644
index 000000000..bffb6e247
--- /dev/null
+++ b/apps/web-ui/apps/web/src/node-module-stub.ts
@@ -0,0 +1,12 @@
+/**
+ * Browser stand-in for `node:module`. `createRequire` is unreachable in the
+ * configured loader path and fails loud if that assumption changes.
+ */
+
+/** Fail if browser boot reaches Node's module loader. */
+export const createRequire = (): never => {
+ throw new Error("node:module is not available in the browser");
+};
+
+/** Type-only peer for the vendored loader. */
+export type LoadHookContext = never;
diff --git a/apps/web-ui/apps/web/src/preview.ts b/apps/web-ui/apps/web/src/preview.ts
new file mode 100644
index 000000000..07fcecc7d
--- /dev/null
+++ b/apps/web-ui/apps/web/src/preview.ts
@@ -0,0 +1,16 @@
+/**
+ * Worker-preview bootstrap: the one module preview.html adds ahead of the
+ * stock entry tag. The runtime's optional source stage owns the pre-Cordis
+ * chooser; the unchanged Host connector then owns the Worker handshake.
+ * Everything after those calls is the served startup chain verbatim.
+ */
+import DshWorker from "@deepseek-ai/dsh-experimental-webworker-runtime/worker?worker";
+import {
+ chooseWorkerHostSource,
+ connectWorkerHost,
+ IMAGE_FILE_NAME,
+} from "@deepseek-ai/dsh-experimental-webworker-runtime/client";
+
+const image = `preview/${IMAGE_FILE_NAME}`;
+const source = await chooseWorkerHostSource({ image });
+await connectWorkerHost(new DshWorker({ name: "dsh-host" }), { image, overlays: source.overlays });
diff --git a/apps/web-ui/apps/web/src/vite-env.d.ts b/apps/web-ui/apps/web/src/vite-env.d.ts
new file mode 100644
index 000000000..11f02fe2a
--- /dev/null
+++ b/apps/web-ui/apps/web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/web-ui/apps/web/stress-tests/reasoning-chunks.stress.ts b/apps/web-ui/apps/web/stress-tests/reasoning-chunks.stress.ts
new file mode 100644
index 000000000..0fe35acad
--- /dev/null
+++ b/apps/web-ui/apps/web/stress-tests/reasoning-chunks.stress.ts
@@ -0,0 +1,269 @@
+/**
+ * Opt-in browser stress reproduction for reasoning-stream renderer stalls.
+ * A test-owned model adapter emits 100,000 individual chunks through the real
+ * Host, Gateway, and browser carriers; the test measures event-loop and
+ * scheduled-interaction delay while the assembled React surface keeps a
+ * collapsed Think row live.
+ */
+import type { Browser, Page } from "playwright";
+import { chromium } from "playwright";
+import { expect, it, onTestFailed } from "vitest";
+import { LlmAdapter, type GenerateOptions, type StreamChunk } from "@deepseek-ai/dsh-llm";
+import type {} from "@deepseek-ai/dsh-agent-default-model";
+import { launchWebScaffold, watchConsole, type WebScaffold } from "../tests/scaffold.ts";
+import {
+ connectFreshWorkspace,
+ newEnglishPage,
+ saveFailureShot,
+ writeComposerDraft,
+} from "../tests/support.ts";
+
+const CHUNK_COUNT = 100_000;
+const CHUNKS_PER_INTERVAL = 128;
+const CHUNK_INTERVAL_MS = 16;
+const MAIN_THREAD_DELAY_BUDGET_MS = 250;
+const PROVIDER = "reasoning-stress-test";
+const MODEL = "reasoning-storm";
+const MARKER = `REASONING_STRESS_COMPLETE:${String(CHUNK_COUNT)}`;
+
+interface ReasoningChunkStormState {
+ chunkCount: number;
+ chunksPerInterval: number;
+ intervalMs: number;
+ emitted: number;
+ marker: string;
+ emitting: boolean;
+}
+
+/** Externally paced model stream whose counters are observed outside the browser. */
+class ReasoningStressAdapter extends LlmAdapter {
+ private finishStream!: () => void;
+ private resolveStarted!: () => void;
+ private resolveCompleted!: () => void;
+ private releaseBatch: (() => void) | undefined;
+ private resolveBatch: (() => void) | undefined;
+ private batchInFlight = false;
+ private finishRequested = false;
+ private readonly finishGate = new Promise((resolve) => {
+ this.finishStream = resolve;
+ });
+ readonly started = new Promise((resolve) => {
+ this.resolveStarted = resolve;
+ });
+ readonly completed = new Promise((resolve) => {
+ this.resolveCompleted = resolve;
+ });
+ private emitted = 0;
+ private emitting = false;
+
+ finish(): void {
+ this.finishRequested = true;
+ this.releaseBatch?.();
+ this.finishStream();
+ }
+
+ async emitNextBatch(): Promise {
+ if (!this.emitting || this.releaseBatch === undefined) {
+ throw new Error("reasoning stress adapter has no batch awaiting release");
+ }
+ if (this.batchInFlight)
+ throw new Error("reasoning stress adapter accepts one batch release at a time");
+ this.batchInFlight = true;
+ const delivered = new Promise((resolve) => {
+ this.resolveBatch = resolve;
+ });
+ const release = this.releaseBatch;
+ this.releaseBatch = undefined;
+ release();
+ await delivered;
+ }
+
+ state(): ReasoningChunkStormState {
+ return {
+ chunkCount: CHUNK_COUNT,
+ chunksPerInterval: CHUNKS_PER_INTERVAL,
+ intervalMs: CHUNK_INTERVAL_MS,
+ emitted: this.emitted,
+ marker: MARKER,
+ emitting: this.emitting,
+ };
+ }
+
+ override async *stream(options: GenerateOptions): AsyncIterable {
+ if (this.emitting || this.emitted !== 0)
+ throw new Error("reasoning stress adapter accepts one model call");
+ this.emitting = true;
+ const parts: string[] = [];
+ yield { type: "block-start", index: 0, blockType: "reasoning" };
+ while (this.emitted < CHUNK_COUNT) {
+ await new Promise((resolve) => {
+ this.releaseBatch = resolve;
+ if (this.emitted === 0) this.resolveStarted();
+ if (this.finishRequested) resolve();
+ });
+ if (this.finishRequested) break;
+ options.signal?.throwIfAborted();
+ const end = Math.min(this.emitted + CHUNKS_PER_INTERVAL, CHUNK_COUNT);
+ while (this.emitted < end) {
+ const text =
+ this.emitted === CHUNK_COUNT - 1
+ ? `\n${MARKER}`
+ : this.emitted % 64 === 63
+ ? "推理\n"
+ : "推理";
+ parts.push(text);
+ yield { type: "reasoning-delta", index: 0, text };
+ this.emitted += 1;
+ }
+ this.batchInFlight = false;
+ this.resolveBatch?.();
+ this.resolveBatch = undefined;
+ }
+ this.emitting = false;
+ if (this.emitted === CHUNK_COUNT) this.resolveCompleted();
+ await this.finishGate;
+ options.signal?.throwIfAborted();
+ yield { type: "block-end", index: 0, block: { type: "reasoning", text: parts.join("") } };
+ yield { type: "finish", reason: { kind: "stop" } };
+ }
+}
+
+interface StressProbe {
+ intervalId: number;
+ intervalMs: number;
+ lastTickAt: number;
+ maxDelayMs: number;
+ samples: number;
+ interactionDueAt: number;
+ interactionHandledAt: number | null;
+}
+
+interface StressWindow extends Window {
+ __reasoningStressProbe?: StressProbe;
+}
+
+it("keeps the browser responsive while rendering 100,000 reasoning chunks", async () => {
+ let scaffold: WebScaffold | undefined;
+ let browser: Browser | undefined;
+ let page: Page | undefined;
+ const adapter = new ReasoningStressAdapter();
+ try {
+ scaffold = await launchWebScaffold();
+ scaffold.ctx.effect(
+ () => scaffold!.ctx.llm.registerAdapter([PROVIDER], adapter),
+ "reasoning stress adapter",
+ );
+ await scaffold.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL });
+ browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== "1" });
+ page = await newEnglishPage(browser);
+ const activePage = page;
+ const tripwire = watchConsole(activePage);
+ onTestFailed(() => saveFailureShot(activePage, "web-stress-reasoning-chunks"));
+ await activePage.goto(scaffold.authenticatedUrl, { waitUntil: "load" });
+ await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 });
+ await connectFreshWorkspace(activePage, scaffold.workspaceCwd);
+
+ await activePage.evaluate(() => {
+ const intervalMs = 50;
+ const now = performance.now();
+ const probe: StressProbe = {
+ intervalId: 0,
+ intervalMs,
+ lastTickAt: now,
+ maxDelayMs: 0,
+ samples: 0,
+ interactionDueAt: now + 1_000,
+ interactionHandledAt: null,
+ };
+ probe.intervalId = window.setInterval(() => {
+ const tickAt = performance.now();
+ probe.maxDelayMs = Math.max(probe.maxDelayMs, tickAt - probe.lastTickAt - intervalMs);
+ probe.lastTickAt = tickAt;
+ probe.samples++;
+ }, intervalMs);
+ document.body.addEventListener(
+ "reasoning-stress-interaction",
+ () => {
+ probe.interactionHandledAt = performance.now();
+ },
+ { once: true },
+ );
+ window.setTimeout(() => {
+ document.body.dispatchEvent(new CustomEvent("reasoning-stress-interaction"));
+ }, 1_000);
+ (window as StressWindow).__reasoningStressProbe = probe;
+ });
+
+ const settled = scaffold.whenTurnSettled(540_000);
+ const input = activePage.locator("[data-composer-input]").first();
+ await writeComposerDraft(activePage, input, `Render ${String(CHUNK_COUNT)} reasoning chunks.`);
+ await input.press("Enter");
+ await adapter.started;
+
+ await adapter.emitNextBatch();
+ const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last();
+ await liveThink.waitFor({ timeout: 60_000 });
+ await activePage.evaluate(
+ (intervalMs) =>
+ new Promise((resolve) => {
+ window.setTimeout(resolve, intervalMs);
+ }),
+ CHUNK_INTERVAL_MS,
+ );
+ while (adapter.state().emitted < CHUNK_COUNT) {
+ await adapter.emitNextBatch();
+ await activePage.evaluate(
+ (intervalMs) =>
+ new Promise((resolve) => {
+ window.setTimeout(resolve, intervalMs);
+ }),
+ CHUNK_INTERVAL_MS,
+ );
+ }
+ await adapter.completed;
+ await expect
+ .poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 })
+ .toContain(MARKER);
+
+ const browserReport = await activePage.evaluate(() => {
+ const win = window as StressWindow;
+ const probe = win.__reasoningStressProbe;
+ if (probe === undefined) throw new Error("reasoning stress metrics unavailable");
+ window.clearInterval(probe.intervalId);
+ const interactionDelayMs =
+ probe.interactionHandledAt === null
+ ? null
+ : probe.interactionHandledAt - probe.interactionDueAt;
+ return {
+ maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs),
+ interactionDelayMs,
+ heartbeatSamples: probe.samples,
+ };
+ });
+ const report = { ...adapter.state(), ...browserReport };
+ process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`);
+
+ expect(report).toMatchObject({
+ chunkCount: CHUNK_COUNT,
+ chunksPerInterval: CHUNKS_PER_INTERVAL,
+ intervalMs: CHUNK_INTERVAL_MS,
+ emitted: CHUNK_COUNT,
+ });
+ expect(report.heartbeatSamples).toBeGreaterThan(0);
+ const interactionDelayMs = report.interactionDelayMs;
+ if (interactionDelayMs === null)
+ throw new Error(`scheduled interaction was not handled: ${JSON.stringify(report)}`);
+ expect(report.maxMainThreadDelayMs, JSON.stringify(report)).toBeLessThan(
+ MAIN_THREAD_DELAY_BUDGET_MS,
+ );
+ expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS);
+ expect(tripwire.pageErrors).toEqual([]);
+ expect(tripwire.warnings).toEqual([]);
+ adapter.finish();
+ await settled;
+ } finally {
+ adapter.finish();
+ await browser?.close();
+ await scaffold?.close();
+ }
+}, 600_000);
diff --git a/apps/web-ui/apps/web/tsconfig.json b/apps/web-ui/apps/web/tsconfig.json
new file mode 100644
index 000000000..310434a3e
--- /dev/null
+++ b/apps/web-ui/apps/web/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/main.ts", "src/node-module-stub.ts"]
+}
diff --git a/apps/web-ui/apps/web/vite.config.ts b/apps/web-ui/apps/web/vite.config.ts
new file mode 100644
index 000000000..005109f16
--- /dev/null
+++ b/apps/web-ui/apps/web/vite.config.ts
@@ -0,0 +1,274 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vite";
+import type { Plugin } from "vite";
+import react from "@vitejs/plugin-react";
+import { clientBuildEnvironmentDefines } from "../../scripts/client-build-environment.ts";
+import { productWebBundleIsolation } from "./product-isolation.ts";
+
+const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url));
+const STANDALONE_ERROR =
+ "apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. " +
+ "From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. " +
+ "For client-plugin HMR, run `pnpm run dev:web`, which starts `dsh web` and the rebuild watchers together.";
+const DEFAULT_CLIENT_TITLE = "DSH Local Build";
+
+/** Escape build-time text before placing it in the HTML title element. */
+function escapeHtmlText(value: string): string {
+ return value.replace(/&/g, "&").replace(//g, ">");
+}
+
+/** Project the public build title into the initial HTML document. */
+function clientDocumentTitle(): Plugin {
+ const title = escapeHtmlText(process.env.DSH_CLIENT_TITLE ?? DEFAULT_CLIENT_TITLE);
+ return {
+ name: "dsh-client-document-title",
+ transformIndexHtml(html) {
+ return html.replace("DSH Local Build", `${title}`);
+ },
+ };
+}
+
+/** Keep the redistribution license beside the bundled brand font. */
+function brandFontLicense(): Plugin {
+ return {
+ name: "dsh-brand-font-license",
+ async generateBundle() {
+ this.emitFile({
+ type: "asset",
+ fileName: "assets/fonts/Montserrat-OFL.txt",
+ source: await readFile(src("../../packages/client/ui-theme/src/styles/Montserrat-OFL.txt")),
+ });
+ },
+ };
+}
+
+/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
+function rejectStandaloneServe(): Plugin {
+ return {
+ name: "dsh-reject-standalone-web-serve",
+ config(_config, env) {
+ if (env.command === "serve") throw new Error(STANDALONE_ERROR);
+ },
+ };
+}
+
+/**
+ * Emit preview.html beside index.html: the built index page with one module
+ * script — the worker bootstrap entry — spliced ahead of its entry tag. Both
+ * pages share every chunk; the extra tag is the only difference, so the
+ * static worker deployment ships the served page verbatim plus its
+ * bootstrap.
+ */
+function emitPreviewPage(): Plugin {
+ let bootstrapFile: string | undefined;
+ let write = true;
+ let written = false;
+ let outputDirectory = "";
+ return {
+ name: "dsh-emit-preview-page",
+ configResolved(config) {
+ write = config.build.write;
+ outputDirectory = resolve(config.root, config.build.outDir);
+ },
+ buildStart() {
+ bootstrapFile = undefined;
+ written = false;
+ },
+ generateBundle(_options, bundle) {
+ if (!write) return;
+ for (const item of Object.values(bundle)) {
+ if (item.type === "chunk" && item.isEntry && item.name === "bootstrap")
+ bootstrapFile = item.fileName;
+ }
+ if (bootstrapFile === undefined)
+ throw new Error("vite: preview bootstrap entry missing from the bundle");
+ },
+ writeBundle() {
+ written = true;
+ },
+ async closeBundle() {
+ if (!write || !written || bootstrapFile === undefined) return;
+ const page = await readFile(resolve(outputDirectory, "index.html"), "utf8");
+ const anchor = page.indexOf('`;
+ await writeFile(
+ resolve(outputDirectory, "preview.html"),
+ `${page.slice(0, anchor)}${tag}${page.slice(anchor)}`,
+ );
+ },
+ };
+}
+
+/**
+ * Vendor-chunk membership, by exact npm package name — the heavy render
+ * families (math, highlight, markdown) that change only on dependency bumps.
+ * Only packages workspace code imports DIRECTLY need listing: their private
+ * transitive dependencies (oniguruma machinery, character tables, …) are
+ * imported solely by these and rollup's chunk coloring pulls them into
+ * vendor automatically. A dependency shared with index-side code falls back
+ * to index — a few kB of dilution, never a correctness problem. Anything not
+ * listed (react family, the vendored cordis workspace, tiny helpers like
+ * anser/clsx, all workspace code) stays in the default `index` chunk, so
+ * editing shell code re-hashes only index and returning clients keep the
+ * cached vendor chunk.
+ *
+ * Every member must be React-free. A package that
+ * imports react/jsx-runtime must never be listed — rollup folds a module
+ * shared between the entry and a manual chunk into the manual chunk, so one
+ * react-importing member would drag the single shared react copy into
+ * vendor. The React side of markdown/math rendering is workspace code and
+ * rides index.
+ */
+const VENDOR_PACKAGES: ReadonlySet = new Set([
+ // math
+ "katex",
+ // syntax highlight (@shikijs/langs is handled separately below —
+ // lazy grammars must not land here)
+ "shiki",
+ // markdown parse pipeline (micromark/mdast; the incremental React renderer
+ // over it is workspace code)
+ "mdast-util-from-markdown",
+ "mdast-util-gfm",
+ "mdast-util-math",
+ "micromark-core-commonmark",
+ "micromark-extension-gfm",
+ "micromark-extension-math",
+ "micromark-factory-space",
+ "micromark-util-character",
+ "micromark-util-classify-character",
+ "micromark-util-sanitize-uri",
+ "micromark-util-symbol",
+ "micromark-util-types",
+]);
+
+/**
+ * Boot grammars statically imported by ui-primitives' highlight.ts
+ * (`@shikijs/langs/typescript` → `dist/typescript.mjs`, etc.). They live in
+ * the same package as the lazy read-card grammars, but unlike those they are
+ * part of the initial load and belong in the vendor chunk; the lazy ones must
+ * stay unassigned so each keeps its own on-demand chunk.
+ */
+const BOOT_GRAMMAR_FILES: readonly string[] = [
+ "dist/typescript.mjs",
+ "dist/shellscript.mjs",
+ "dist/json.mjs",
+];
+
+/** Font asset extensions routed to assets/fonts/ (KaTeX's woff2/woff/ttf faces). */
+const FONT_EXTENSIONS: readonly string[] = [".woff2", ".woff", ".ttf"];
+
+/**
+ * npm package name of a resolved module id: the segment after the last
+ * `node_modules/`. pnpm nests the real package under an inner node_modules.
+ */
+function npmPackageOf(id: string): string | undefined {
+ const parts = id.split("/node_modules/");
+ if (parts.length === 1) return undefined;
+ const [first, second] = parts[parts.length - 1].split("/");
+ if (first.startsWith(".")) return undefined; // .pnpm store segment, not a package
+ if (first.startsWith("@")) return second === undefined ? undefined : `${first}/${second}`;
+ return first;
+}
+
+export default defineConfig({
+ // Relative asset URLs: preview.html mounts the same output under any base
+ // directory, and the served index resolves identically from the site root.
+ base: "./",
+ plugins: [
+ rejectStandaloneServe(),
+ clientDocumentTitle(),
+ brandFontLicense(),
+ react(),
+ productWebBundleIsolation(src("../.."), src(".")),
+ ],
+ build: {
+ // The worker bootstrap holds its page at top-level await; Vite's default
+ // `modules` target (es2020-era) rejects that syntax.
+ target: "es2022",
+ sourcemap: true,
+ rollupOptions: {
+ input: {
+ index: src("./index.html"),
+ // Standalone entry, not an index.html script tag: Vite folds every
+ // module tag of one page into a single synthetic entry, and only a
+ // separate input keeps the shared page chunks bootstrap-free.
+ },
+ output: {
+ // The worker-preview surface groups under dist/preview/ (the page
+ // itself stays at dist/preview.html), so the published payload can
+ // exclude it as one directory.
+ entryFileNames(chunk): string {
+ return chunk.name === "bootstrap"
+ ? "preview/[name]-[hash].js"
+ : "assets/[name]-[hash].js";
+ },
+ // Output layout: the two main chunks stay at assets/ root; lazy
+ // @shikijs/langs grammar chunks group under assets/langs/; fonts
+ // (all KaTeX faces referenced by vendor.css) group under
+ // assets/fonts/. Sourcemaps need no arrangement: rollup writes each
+ // .map next to its js and references it by bare relative filename.
+ chunkFileNames(chunk): string {
+ // Grammar chunks are recognized by their member modules, not the
+ // facade: shared embedded-grammar chunks (e.g. html+javascript,
+ // split out because php/ruby/mdx embed them) have no facade at all.
+ // index and vendor are excluded by name — vendor legitimately
+ // carries the three boot grammars.
+ if (chunk.name === "index" || chunk.name === "vendor") return "assets/[name]-[hash].js";
+ const isLangChunk = chunk.moduleIds.some((id) =>
+ id.includes("/node_modules/@shikijs/langs/"),
+ );
+ return isLangChunk ? "assets/langs/[name]-[hash].js" : "assets/[name]-[hash].js";
+ },
+ assetFileNames(asset): string {
+ const fileName = asset.names[0] ?? "";
+ const isFont = FONT_EXTENSIONS.some((ext) => fileName.endsWith(ext));
+ return isFont ? "assets/fonts/[name]-[hash][extname]" : "assets/[name]-[hash][extname]";
+ },
+ manualChunks(id: string): string | undefined {
+ const pkg = npmPackageOf(id);
+ if (pkg === undefined) return undefined; // workspace + vendored cordis: index
+ if (pkg === "@shikijs/langs") {
+ return BOOT_GRAMMAR_FILES.some((file) => id.endsWith(`/${file}`))
+ ? "vendor"
+ : undefined;
+ }
+ return VENDOR_PACKAGES.has(pkg) ? "vendor" : undefined;
+ },
+ },
+ },
+ },
+ worker: {
+ // The preview worker rides dist/preview/ with the rest of that surface.
+ rollupOptions: { output: { entryFileNames: "preview/[name]-[hash].js" } },
+ },
+ resolve: {
+ // One instance per shared npm identity: a bare specifier otherwise resolves
+ // from the importer's directory, so a diverging range ships a second React
+ // and splits hook and element identity. Entries are package ids — they cover
+ // react/jsx-runtime and react-dom/client — and resolve from this package's
+ // node_modules, so react must stay a devDependency here and any watcher must
+ // run vite from this directory (scripts/dev-web.ts). Workspace packages need
+ // no entry: pnpm links each of them to a single directory.
+ dedupe: ["react", "react-dom"],
+ // Workspace packages are consumed as built lib products: each resolves
+ // through its own package.json exports from the importer's directory, and
+ // CSS still rides Vite's pipeline because the client build preset emits it
+ // beside the bundle. Plugin packages never enter this graph; they arrive as
+ // runtime bundles through the client module system. The remaining alias
+ // browserizes the vendored Cordis Loader's only Node import.
+ alias: [{ find: /^node:module$/, replacement: src("./src/node-module-stub.ts") }],
+ },
+ define: {
+ ...clientBuildEnvironmentDefines(process.env),
+ // vendored loader internal.ts: fromInternal() probes the Node major —
+ // "0.0.0" takes neither branch, returning undefined (exactly the empty
+ // internal slot the shell boot fills with the client module loader).
+ "process.versions.node": '"0.0.0"',
+ "process.execArgv": "[]",
+ // vendored loader index.ts: envData falls to its default branch.
+ "process.env.CORDIS_SHARED": "undefined",
+ },
+});
diff --git a/apps/web-ui/import-manifest.json b/apps/web-ui/import-manifest.json
new file mode 100644
index 000000000..bfaf57ba0
--- /dev/null
+++ b/apps/web-ui/import-manifest.json
@@ -0,0 +1,161 @@
+{
+ "source": "deepseek-harness (codexhost-web fork)",
+ "version": "0.2.1-alpha.1",
+ "plugins": [
+ "@deepseek-ai/dsh-api-gateway",
+ "@deepseek-ai/dsh-client-modules",
+ "@deepseek-ai/dsh-client-ui-theme",
+ "@deepseek-ai/dsh-client-locale",
+ "@deepseek-ai/dsh-client-shortcuts",
+ "@deepseek-ai/dsh-client-ui-shortcuts",
+ "@deepseek-ai/dsh-client-ui-layout",
+ "@deepseek-ai/dsh-client-ui-renderer",
+ "@deepseek-ai/dsh-client-ui-session",
+ "@deepseek-ai/dsh-client-resources",
+ "@deepseek-ai/dsh-client-ui-sidebar",
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
+ "@deepseek-ai/dsh-client-ui-sidebar-files",
+ "@deepseek-ai/dsh-client-ui-sidebar-documentpreview",
+ "@deepseek-ai/dsh-client-ui-sidebar-terminal",
+ "@deepseek-ai/dsh-client-ui-sidebar-browser",
+ "@deepseek-ai/dsh-client-ui-settings",
+ "@deepseek-ai/dsh-client-ui-settings-general",
+ "@deepseek-ai/dsh-client-ui-conversation",
+ "@deepseek-ai/dsh-client-ui-approval",
+ "@deepseek-ai/dsh-client-ui-chat",
+ "@deepseek-ai/dsh-client-ui-brand-official",
+ "@deepseek-ai/dsh-client-ui-attachment",
+ "@deepseek-ai/dsh-client-ui-tool",
+ "@deepseek-ai/dsh-client-ui-workspace",
+ "@deepseek-ai/dsh-client-ui-input-trigger",
+ "@deepseek-ai/dsh-client-ui-commands",
+ "@deepseek-ai/dsh-client-ui-subagent",
+ "@deepseek-ai/dsh-client-ui-model-selection",
+ "@deepseek-ai/dsh-client-ui-permission-presets",
+ "@deepseek-ai/dsh-client-ui-plan",
+ "@deepseek-ai/dsh-client-ui-user-questions",
+ "@deepseek-ai/dsh-typert-registry",
+ "@deepseek-ai/dsh-client-file-upload",
+ "@deepseek-ai/dsh-api-remotes",
+ "@deepseek-ai/dsh-api-session-controller",
+ "@deepseek-ai/dsh-api-terminal-controller",
+ "@deepseek-ai/dsh-client-connection",
+ "@deepseek-ai/dsh-api-workspace-controller",
+ "@deepseek-ai/dsh-client-ui-directory-picker-browse",
+ "@deepseek-ai/dsh-client-ui-codexhost",
+ "@deepseek-ai/dsh-api-workspace-files",
+ "@deepseek-ai/dsh-client-hmr"
+ ],
+ "packages": [
+ "@deepseek-ai/cordis",
+ "@deepseek-ai/cordis-plugin-loader",
+ "@deepseek-ai/cosmokit",
+ "@deepseek-ai/dsh-agent-preset-registry",
+ "@deepseek-ai/dsh-api-account-controller",
+ "@deepseek-ai/dsh-api-gateway",
+ "@deepseek-ai/dsh-api-job-controller",
+ "@deepseek-ai/dsh-api-remotes",
+ "@deepseek-ai/dsh-api-session-controller",
+ "@deepseek-ai/dsh-api-settings-controller",
+ "@deepseek-ai/dsh-api-terminal-controller",
+ "@deepseek-ai/dsh-api-workspace-controller",
+ "@deepseek-ai/dsh-api-workspace-files",
+ "@deepseek-ai/dsh-brand",
+ "@deepseek-ai/dsh-client-connection",
+ "@deepseek-ai/dsh-client-file-upload",
+ "@deepseek-ai/dsh-client-hmr",
+ "@deepseek-ai/dsh-client-locale",
+ "@deepseek-ai/dsh-client-modules",
+ "@deepseek-ai/dsh-client-product-analytics",
+ "@deepseek-ai/dsh-client-resources",
+ "@deepseek-ai/dsh-client-shortcuts",
+ "@deepseek-ai/dsh-client-store",
+ "@deepseek-ai/dsh-client-ui-approval",
+ "@deepseek-ai/dsh-client-ui-attachment",
+ "@deepseek-ai/dsh-client-ui-brand-official",
+ "@deepseek-ai/dsh-client-ui-chat",
+ "@deepseek-ai/dsh-client-ui-codexhost",
+ "@deepseek-ai/dsh-client-ui-commands",
+ "@deepseek-ai/dsh-client-ui-conversation",
+ "@deepseek-ai/dsh-client-ui-directory-picker-browse",
+ "@deepseek-ai/dsh-client-ui-dockkit",
+ "@deepseek-ai/dsh-client-ui-input-trigger",
+ "@deepseek-ai/dsh-client-ui-layout",
+ "@deepseek-ai/dsh-client-ui-model-selection",
+ "@deepseek-ai/dsh-client-ui-permission-presets",
+ "@deepseek-ai/dsh-client-ui-plan",
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
+ "@deepseek-ai/dsh-client-ui-primitives",
+ "@deepseek-ai/dsh-client-ui-renderer",
+ "@deepseek-ai/dsh-client-ui-session",
+ "@deepseek-ai/dsh-client-ui-settings",
+ "@deepseek-ai/dsh-client-ui-settings-general",
+ "@deepseek-ai/dsh-client-ui-shortcuts",
+ "@deepseek-ai/dsh-client-ui-sidebar",
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
+ "@deepseek-ai/dsh-client-ui-slots",
+ "@deepseek-ai/dsh-client-ui-subagent",
+ "@deepseek-ai/dsh-client-ui-theme",
+ "@deepseek-ai/dsh-client-ui-tool",
+ "@deepseek-ai/dsh-client-ui-user-questions",
+ "@deepseek-ai/dsh-client-ui-workspace",
+ "@deepseek-ai/dsh-client-web",
+ "@deepseek-ai/dsh-command-feedback",
+ "@deepseek-ai/dsh-commands",
+ "@deepseek-ai/dsh-cordis-host-runner",
+ "@deepseek-ai/dsh-deque",
+ "@deepseek-ai/dsh-file-reference",
+ "@deepseek-ai/dsh-goal",
+ "@deepseek-ai/dsh-host-plugin-inventory",
+ "@deepseek-ai/dsh-llm",
+ "@deepseek-ai/dsh-message-feedback",
+ "@deepseek-ai/dsh-office-to-pdf",
+ "@deepseek-ai/dsh-output-retention",
+ "@deepseek-ai/dsh-permission-presets",
+ "@deepseek-ai/dsh-plugin-manager",
+ "@deepseek-ai/dsh-schedule",
+ "@deepseek-ai/dsh-session",
+ "@deepseek-ai/dsh-session-reference",
+ "@deepseek-ai/dsh-spill-policy",
+ "@deepseek-ai/dsh-subagent",
+ "@deepseek-ai/dsh-token-meter",
+ "@deepseek-ai/dsh-typert-protocol",
+ "@deepseek-ai/dsh-typert-registry",
+ "@deepseek-ai/dsh-user-questions",
+ "@deepseek-ai/dsh-util-code-language",
+ "@deepseek-ai/dsh-util-crypto",
+ "@deepseek-ai/dsh-util-values",
+ "@deepseek-ai/dsh-util-workspace-path",
+ "@deepseek-ai/schemastery",
+ "@deepseek-ai/dsh-client-ui-sidebar-terminal",
+ "@deepseek-ai/dsh-client-ui-sidebar-documentpreview",
+ "@deepseek-ai/dsh-client-ui-sidebar-browser"
+ ],
+ "generatedCodecs": [
+ "@deepseek-ai/dsh-agent-preset-registry",
+ "@deepseek-ai/dsh-api-account-controller",
+ "@deepseek-ai/dsh-api-job-controller",
+ "@deepseek-ai/dsh-api-session-controller",
+ "@deepseek-ai/dsh-api-settings-controller",
+ "@deepseek-ai/dsh-api-terminal-controller",
+ "@deepseek-ai/dsh-api-workspace-controller",
+ "@deepseek-ai/dsh-api-workspace-files",
+ "@deepseek-ai/dsh-client-file-upload",
+ "@deepseek-ai/dsh-client-product-analytics",
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
+ "@deepseek-ai/dsh-command-feedback",
+ "@deepseek-ai/dsh-commands",
+ "@deepseek-ai/dsh-cordis-host-runner",
+ "@deepseek-ai/dsh-goal",
+ "@deepseek-ai/dsh-host-plugin-inventory",
+ "@deepseek-ai/dsh-llm",
+ "@deepseek-ai/dsh-message-feedback",
+ "@deepseek-ai/dsh-office-to-pdf",
+ "@deepseek-ai/dsh-permission-presets",
+ "@deepseek-ai/dsh-plugin-manager",
+ "@deepseek-ai/dsh-schedule",
+ "@deepseek-ai/dsh-session-reference",
+ "@deepseek-ai/dsh-subagent",
+ "@deepseek-ai/dsh-user-questions"
+ ]
+}
diff --git a/apps/web-ui/package.json b/apps/web-ui/package.json
new file mode 100644
index 000000000..342cbc05b
--- /dev/null
+++ b/apps/web-ui/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@codexhost/web-ui",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "license": "MIT",
+ "scripts": {
+ "build": "node --import tsx scripts/build.ts"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.0.0",
+ "vite": "^6.0.0",
+ "lightningcss": "^1.32.0",
+ "immer": "^10.1.1",
+ "zustand": "^4.4.7",
+ "tsdown": "^0.22.2",
+ "tsx": "4.22.4"
+ }
+}
diff --git a/apps/web-ui/packages/api/account-controller/generated/remote.d.ts b/apps/web-ui/packages/api/account-controller/generated/remote.d.ts
new file mode 100644
index 000000000..2b084287a
--- /dev/null
+++ b/apps/web-ui/packages/api/account-controller/generated/remote.d.ts
@@ -0,0 +1,43 @@
+/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */
+import type {
+ RemoteResult,
+ RemoteStreamHandle,
+ TypertRemoteContribution,
+} from '@deepseek-ai/dsh-typert-protocol'
+import type { AccountBonusBatch, AccountBonusOrderId, AccountClientMetadata, AccountDetails, AccountUserId, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
+
+declare module '@deepseek-ai/dsh-typert-protocol' {
+ interface TypertRemoteNamespace$6163636f756e74 {
+ ackBonusNotified: (accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata) => Promise>
+ cancelSignIn: (attemptId: SignInAttemptId) => Promise>
+ getBalance: (client: AccountClientMetadata) => Promise>
+ getProfile: (client: AccountClientMetadata) => Promise>
+ getState: () => Promise>
+ getUnnotifiedBonuses: (client: AccountClientMetadata) => Promise>
+ hasRunningAccountTasks: () => Promise>
+ signOut: (client: AccountClientMetadata) => Promise>
+ startSignIn: (client: AccountClientMetadata, callbackOrigin: string, loginSource: 'web' | 'desktop') => Promise>
+ watch: (signal?: AbortSignal) => RemoteStreamHandle
+ watchExpiry: (signal?: AbortSignal) => RemoteStreamHandle<'session-expired', never>
+ }
+ interface TypertRemoteMap {
+ 'account/ackBonusNotified': (accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata) => Promise>
+ 'account/cancelSignIn': (attemptId: SignInAttemptId) => Promise>
+ 'account/getBalance': (client: AccountClientMetadata) => Promise>
+ 'account/getProfile': (client: AccountClientMetadata) => Promise>
+ 'account/getState': () => Promise>
+ 'account/getUnnotifiedBonuses': (client: AccountClientMetadata) => Promise>
+ 'account/hasRunningAccountTasks': () => Promise>
+ 'account/signOut': (client: AccountClientMetadata) => Promise>
+ 'account/startSignIn': (client: AccountClientMetadata, callbackOrigin: string, loginSource: 'web' | 'desktop') => Promise>
+ 'account/watch': (signal?: AbortSignal) => RemoteStreamHandle
+ 'account/watchExpiry': (signal?: AbortSignal) => RemoteStreamHandle<'session-expired', never>
+ }
+ interface TypertRemoteNamespaceMap {
+ 'account': TypertRemoteNamespace$6163636f756e74
+ }
+}
+
+export declare const TYPERT_REMOTE: TypertRemoteContribution
+export default TYPERT_REMOTE
+//# sourceMappingURL=typert.remote-client.d.ts.map
diff --git a/apps/web-ui/packages/api/account-controller/generated/remote.js b/apps/web-ui/packages/api/account-controller/generated/remote.js
new file mode 100644
index 000000000..2cb9a6b39
--- /dev/null
+++ b/apps/web-ui/packages/api/account-controller/generated/remote.js
@@ -0,0 +1,456 @@
+/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */
+import { z } from 'zod'
+
+let _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_0$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown()))
+let _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_1$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_1$schema = () => (_deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown()))
+let _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_2$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_2$schema = () => (_deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_2$schema$value ??= z.object({
+ 'version': z.string().readonly(),
+ 'locale': z.string().readonly(),
+ 'timezoneOffsetSeconds': z.number().readonly(),
+}))
+let _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_result$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_result$schema$value ??= z.boolean())
+let _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_parameter_0$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_cancelSignIn_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown()))
+let _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_result$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_cancelSignIn_result$schema$value ??= z.object({
+ 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(),
+ 'links': z.object({
+ 'usageUrl': z.string().readonly(),
+ 'topUpUrl': z.string().readonly(),
+}).readonly(),
+ 'attempt': z.union([z.literal(null), z.object({
+ 'id': z.intersection(z.string(), z.unknown()).readonly(),
+ 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(),
+ 'authorizeUrl': z.string().readonly().optional(),
+ 'expiresAt': z.number().readonly().optional(),
+ 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(),
+})]).readonly(),
+}))
+let _deepseek_ai_dsh_api_account_controller_account_getBalance_parameter_0$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_getBalance_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getBalance_parameter_0$schema$value ??= z.object({
+ 'version': z.string().readonly(),
+ 'locale': z.string().readonly(),
+ 'timezoneOffsetSeconds': z.number().readonly(),
+}))
+let _deepseek_ai_dsh_api_account_controller_account_getBalance_result$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_getBalance_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getBalance_result$schema$value ??= z.union([z.literal(null), z.object({
+ 'status': z.literal("ready").readonly(),
+ 'value': z.array(z.object({
+ 'currency': z.union([z.literal("CNY"), z.literal("USD")]).readonly(),
+ 'balance': z.string().readonly(),
+})).readonly(),
+ 'bonusWallets': z.array(z.object({
+ 'currency': z.union([z.literal("CNY"), z.literal("USD")]).readonly(),
+ 'balance': z.string().readonly(),
+})).readonly(),
+}), z.object({
+ 'status': z.literal("failed").readonly(),
+})]))
+let _deepseek_ai_dsh_api_account_controller_account_getProfile_parameter_0$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_getProfile_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getProfile_parameter_0$schema$value ??= z.object({
+ 'version': z.string().readonly(),
+ 'locale': z.string().readonly(),
+ 'timezoneOffsetSeconds': z.number().readonly(),
+}))
+let _deepseek_ai_dsh_api_account_controller_account_getProfile_result$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_getProfile_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getProfile_result$schema$value ??= z.union([z.literal(null), z.object({
+ 'status': z.literal("ready").readonly(),
+ 'value': z.object({
+ 'id': z.union([z.literal(null), z.intersection(z.string(), z.unknown())]).readonly(),
+ 'name': z.union([z.literal(null), z.string()]).readonly(),
+ 'contact': z.union([z.literal(null), z.string()]).readonly(),
+ 'avatarUrl': z.union([z.literal(null), z.string()]).readonly().optional(),
+}).readonly(),
+}), z.object({
+ 'status': z.literal("failed").readonly(),
+})]))
+let _deepseek_ai_dsh_api_account_controller_account_getState_result$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_getState_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getState_result$schema$value ??= z.object({
+ 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(),
+ 'links': z.object({
+ 'usageUrl': z.string().readonly(),
+ 'topUpUrl': z.string().readonly(),
+}).readonly(),
+ 'attempt': z.union([z.literal(null), z.object({
+ 'id': z.intersection(z.string(), z.unknown()).readonly(),
+ 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(),
+ 'authorizeUrl': z.string().readonly().optional(),
+ 'expiresAt': z.number().readonly().optional(),
+ 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(),
+})]).readonly(),
+}))
+let _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_parameter_0$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_parameter_0$schema$value ??= z.object({
+ 'version': z.string().readonly(),
+ 'locale': z.string().readonly(),
+ 'timezoneOffsetSeconds': z.number().readonly(),
+}))
+let _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_result$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_result$schema$value ??= z.union([z.literal(null), z.object({
+ 'accountId': z.intersection(z.string(), z.unknown()).readonly(),
+ 'bonuses': z.array(z.object({
+ 'orderId': z.intersection(z.string(), z.unknown()).readonly(),
+ 'campaign': z.string().readonly(),
+ 'amount': z.string().readonly(),
+ 'currency': z.union([z.literal("CNY"), z.literal("USD")]).readonly(),
+ 'grantedAt': z.string().readonly(),
+ 'expiresAt': z.string().readonly(),
+ 'message': z.string().readonly(),
+})).readonly(),
+})]))
+let _deepseek_ai_dsh_api_account_controller_account_hasRunningAccountTasks_result$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_hasRunningAccountTasks_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_hasRunningAccountTasks_result$schema$value ??= z.boolean())
+let _deepseek_ai_dsh_api_account_controller_account_signOut_parameter_0$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_signOut_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_signOut_parameter_0$schema$value ??= z.object({
+ 'version': z.string().readonly(),
+ 'locale': z.string().readonly(),
+ 'timezoneOffsetSeconds': z.number().readonly(),
+}))
+let _deepseek_ai_dsh_api_account_controller_account_signOut_result$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_signOut_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_signOut_result$schema$value ??= z.object({
+ 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(),
+ 'links': z.object({
+ 'usageUrl': z.string().readonly(),
+ 'topUpUrl': z.string().readonly(),
+}).readonly(),
+ 'attempt': z.union([z.literal(null), z.object({
+ 'id': z.intersection(z.string(), z.unknown()).readonly(),
+ 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(),
+ 'authorizeUrl': z.string().readonly().optional(),
+ 'expiresAt': z.number().readonly().optional(),
+ 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(),
+})]).readonly(),
+}))
+let _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_0$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_0$schema$value ??= z.object({
+ 'version': z.string().readonly(),
+ 'locale': z.string().readonly(),
+ 'timezoneOffsetSeconds': z.number().readonly(),
+}))
+let _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_1$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_1$schema = () => (_deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_1$schema$value ??= z.string())
+let _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_2$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_2$schema = () => (_deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_2$schema$value ??= z.union([z.literal("web"), z.literal("desktop")]))
+let _deepseek_ai_dsh_api_account_controller_account_startSignIn_result$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_startSignIn_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_startSignIn_result$schema$value ??= z.object({
+ 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(),
+ 'links': z.object({
+ 'usageUrl': z.string().readonly(),
+ 'topUpUrl': z.string().readonly(),
+}).readonly(),
+ 'attempt': z.union([z.literal(null), z.object({
+ 'id': z.intersection(z.string(), z.unknown()).readonly(),
+ 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(),
+ 'authorizeUrl': z.string().readonly().optional(),
+ 'expiresAt': z.number().readonly().optional(),
+ 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(),
+})]).readonly(),
+}))
+let _deepseek_ai_dsh_api_account_controller_account_watch_result$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_watch_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_watch_result$schema$value ??= z.object({
+ 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(),
+ 'links': z.object({
+ 'usageUrl': z.string().readonly(),
+ 'topUpUrl': z.string().readonly(),
+}).readonly(),
+ 'attempt': z.union([z.literal(null), z.object({
+ 'id': z.intersection(z.string(), z.unknown()).readonly(),
+ 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(),
+ 'authorizeUrl': z.string().readonly().optional(),
+ 'expiresAt': z.number().readonly().optional(),
+ 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(),
+})]).readonly(),
+}))
+let _deepseek_ai_dsh_api_account_controller_account_watchExpiry_result$schema$value
+const _deepseek_ai_dsh_api_account_controller_account_watchExpiry_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_watchExpiry_result$schema$value ??= z.literal("session-expired"))
+
+export const TYPERT_REMOTE = {
+ package: '@deepseek-ai/dsh-api-account-controller',
+ descriptors: [
+ {
+ id: '@deepseek-ai/dsh-api-account-controller#account/ackBonusNotified',
+ service: 'accountController',
+ namespace: 'account',
+ method: 'ackBonusNotified',
+ invocation: { kind: 'direct' },
+ parameters: [
+ {
+ name: 'accountId',
+ wire: 'accountId',
+ source: 'json',
+ codec: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountUserId',
+ create: _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_0$schema,
+ },
+ },
+ {
+ name: 'orderId',
+ wire: 'orderId',
+ source: 'json',
+ codec: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountBonusOrderId',
+ create: _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_1$schema,
+ },
+ },
+ {
+ name: 'client',
+ wire: 'client',
+ source: 'json',
+ codec: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata',
+ create: _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_2$schema,
+ },
+ },
+ ],
+ result: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/ackBonusNotified:result',
+ create: _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_result$schema,
+ },
+ sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":55,"column":3},
+ },
+ {
+ id: '@deepseek-ai/dsh-api-account-controller#account/cancelSignIn',
+ service: 'accountController',
+ namespace: 'account',
+ method: 'cancelSignIn',
+ invocation: { kind: 'direct' },
+ parameters: [
+ {
+ name: 'attemptId',
+ wire: 'attemptId',
+ source: 'json',
+ codec: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#SignInAttemptId',
+ create: _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_parameter_0$schema,
+ },
+ },
+ ],
+ result: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView',
+ create: _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_result$schema,
+ },
+ sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":75,"column":3},
+ },
+ {
+ id: '@deepseek-ai/dsh-api-account-controller#account/getBalance',
+ service: 'accountController',
+ namespace: 'account',
+ method: 'getBalance',
+ invocation: { kind: 'direct' },
+ parameters: [
+ {
+ name: 'client',
+ wire: 'client',
+ source: 'json',
+ codec: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata',
+ create: _deepseek_ai_dsh_api_account_controller_account_getBalance_parameter_0$schema,
+ },
+ },
+ ],
+ result: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/getBalance:result',
+ create: _deepseek_ai_dsh_api_account_controller_account_getBalance_result$schema,
+ },
+ sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":35,"column":3},
+ },
+ {
+ id: '@deepseek-ai/dsh-api-account-controller#account/getProfile',
+ service: 'accountController',
+ namespace: 'account',
+ method: 'getProfile',
+ invocation: { kind: 'direct' },
+ parameters: [
+ {
+ name: 'client',
+ wire: 'client',
+ source: 'json',
+ codec: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata',
+ create: _deepseek_ai_dsh_api_account_controller_account_getProfile_parameter_0$schema,
+ },
+ },
+ ],
+ result: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/getProfile:result',
+ create: _deepseek_ai_dsh_api_account_controller_account_getProfile_result$schema,
+ },
+ sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":26,"column":3},
+ },
+ {
+ id: '@deepseek-ai/dsh-api-account-controller#account/getState',
+ service: 'accountController',
+ namespace: 'account',
+ method: 'getState',
+ invocation: { kind: 'direct' },
+ parameters: [
+ ],
+ result: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView',
+ create: _deepseek_ai_dsh_api_account_controller_account_getState_result$schema,
+ },
+ sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":19,"column":3},
+ },
+ {
+ id: '@deepseek-ai/dsh-api-account-controller#account/getUnnotifiedBonuses',
+ service: 'accountController',
+ namespace: 'account',
+ method: 'getUnnotifiedBonuses',
+ invocation: { kind: 'direct' },
+ parameters: [
+ {
+ name: 'client',
+ wire: 'client',
+ source: 'json',
+ codec: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata',
+ create: _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_parameter_0$schema,
+ },
+ },
+ ],
+ result: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/getUnnotifiedBonuses:result',
+ create: _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_result$schema,
+ },
+ sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":44,"column":3},
+ },
+ {
+ id: '@deepseek-ai/dsh-api-account-controller#account/hasRunningAccountTasks',
+ service: 'accountController',
+ namespace: 'account',
+ method: 'hasRunningAccountTasks',
+ invocation: { kind: 'direct' },
+ parameters: [
+ ],
+ result: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/hasRunningAccountTasks:result',
+ create: _deepseek_ai_dsh_api_account_controller_account_hasRunningAccountTasks_result$schema,
+ },
+ sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":81,"column":3},
+ },
+ {
+ id: '@deepseek-ai/dsh-api-account-controller#account/signOut',
+ service: 'accountController',
+ namespace: 'account',
+ method: 'signOut',
+ invocation: { kind: 'direct' },
+ parameters: [
+ {
+ name: 'client',
+ wire: 'client',
+ source: 'json',
+ codec: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata',
+ create: _deepseek_ai_dsh_api_account_controller_account_signOut_parameter_0$schema,
+ },
+ },
+ ],
+ result: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView',
+ create: _deepseek_ai_dsh_api_account_controller_account_signOut_result$schema,
+ },
+ sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":90,"column":3},
+ },
+ {
+ id: '@deepseek-ai/dsh-api-account-controller#account/startSignIn',
+ service: 'accountController',
+ namespace: 'account',
+ method: 'startSignIn',
+ invocation: { kind: 'direct' },
+ parameters: [
+ {
+ name: 'client',
+ wire: 'client',
+ source: 'json',
+ codec: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata',
+ create: _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_0$schema,
+ },
+ },
+ {
+ name: 'callbackOrigin',
+ wire: 'callbackOrigin',
+ source: 'json',
+ codec: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/startSignIn:callbackOrigin',
+ create: _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_1$schema,
+ },
+ },
+ {
+ name: 'loginSource',
+ wire: 'loginSource',
+ source: 'json',
+ codec: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/startSignIn:loginSource',
+ create: _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_2$schema,
+ },
+ },
+ ],
+ result: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView',
+ create: _deepseek_ai_dsh_api_account_controller_account_startSignIn_result$schema,
+ },
+ sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":66,"column":3},
+ },
+ {
+ id: '@deepseek-ai/dsh-api-account-controller#account/watch',
+ service: 'accountController',
+ namespace: 'account',
+ method: 'watch',
+ mode: 'stream',
+ invocation: { kind: 'direct' },
+ parameters: [
+ ],
+ cancellation: { parameter: 'signal' },
+ result: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView',
+ create: _deepseek_ai_dsh_api_account_controller_account_watch_result$schema,
+ },
+ sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":119,"column":3},
+ },
+ {
+ id: '@deepseek-ai/dsh-api-account-controller#account/watchExpiry',
+ service: 'accountController',
+ namespace: 'account',
+ method: 'watchExpiry',
+ mode: 'stream',
+ invocation: { kind: 'direct' },
+ parameters: [
+ ],
+ cancellation: { parameter: 'signal' },
+ result: {
+ mode: 'strict',
+ typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/watchExpiry:result',
+ create: _deepseek_ai_dsh_api_account_controller_account_watchExpiry_result$schema,
+ },
+ sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":97,"column":10},
+ },
+ ],
+}
+
+export default TYPERT_REMOTE
diff --git a/apps/web-ui/packages/api/account-controller/package.json b/apps/web-ui/packages/api/account-controller/package.json
new file mode 100644
index 000000000..c866d38d5
--- /dev/null
+++ b/apps/web-ui/packages/api/account-controller/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@deepseek-ai/dsh-api-account-controller",
+ "version": "0.2.1-alpha.1",
+ "private": true,
+ "type": "module",
+ "license": "MIT",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "default": "./src/index.ts"
+ },
+ "./types": {
+ "types": "./src/types.ts",
+ "default": "./src/types.ts"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json",
+ "./typert": {
+ "types": "./src/typert.host.ts",
+ "default": "./src/typert.host.ts"
+ },
+ "./remote": {
+ "types": "./generated/remote.d.ts",
+ "default": "./generated/remote.js"
+ }
+ },
+ "dependencies": {
+ "zod": "^4.4.3"
+ }
+}
diff --git a/apps/web-ui/packages/api/account-controller/src/index.ts b/apps/web-ui/packages/api/account-controller/src/index.ts
new file mode 100644
index 000000000..9b2ca1dcd
--- /dev/null
+++ b/apps/web-ui/packages/api/account-controller/src/index.ts
@@ -0,0 +1,156 @@
+/** Authenticated Remote operations for account UI consumers. */
+import { Context } from "@deepseek-ai/cordis";
+import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
+import { isRunningAccountTask } from "@deepseek-ai/dsh-deepseek-account";
+import type {} from "@deepseek-ai/dsh-agent";
+import type {
+ AccountBonusBatch,
+ AccountBonusOrderId,
+ AccountClientMetadata,
+ AccountDetails,
+ AccountUserId,
+} from "@deepseek-ai/dsh-deepseek-account/types";
+import type { AccountView, SignInAttemptId } from "./types.ts";
+
+/** Account commands and reconnect-safe state stream. */
+export class AccountController extends TypertRemoteService {
+ static inject = ["deepseekAccount", "agents"];
+ /** @param ctx - Host with the account provider mounted. */
+ constructor(ctx: Context) {
+ super(ctx, "accountController", { namespace: "account" });
+ }
+ /**
+ * Read the safe account projection.
+ * @returns current account and attempt state.
+ */
+ @Remote
+ getState(): Promise {
+ return this.ctx.deepseekAccount.getState();
+ }
+ /**
+ * Query display-safe Platform profile data.
+ * @param client - identity of the requesting UI; the Host derives Platform request headers from it.
+ * @returns profile outcome, or null when the account grant is absent or changed.
+ */
+ @Remote
+ getProfile(client: AccountClientMetadata): Promise {
+ return this.ctx.deepseekAccount.getProfile(client);
+ }
+ /**
+ * Query Platform recharge-wallet balances.
+ * @param client - identity of the requesting UI; the Host derives Platform request headers from it.
+ * @returns balance outcome, or null when the account grant is absent or changed.
+ */
+ @Remote
+ getBalance(client: AccountClientMetadata): Promise {
+ return this.ctx.deepseekAccount.getBalance(client);
+ }
+ /**
+ * Query the granted bonuses Platform has not yet recorded as displayed.
+ * @param client - identity of the requesting UI; its language selects the server-authored message.
+ * @returns bonuses with their account, or null when the account grant is absent or changed.
+ */
+ @Remote
+ getUnnotifiedBonuses(client: AccountClientMetadata): Promise {
+ return this.ctx.deepseekAccount.getUnnotifiedBonuses(client);
+ }
+ /**
+ * Record one displayed bonus as notified for the account it belongs to.
+ * @param accountId - account the notification was read for.
+ * @param orderId - granted bonus order the user saw.
+ * @param client - identity of the requesting UI; the Host derives Platform request headers from it.
+ * @returns true once Platform records the acknowledgement; false when the account is absent or changed.
+ */
+ @Remote
+ ackBonusNotified(
+ accountId: AccountUserId,
+ orderId: AccountBonusOrderId,
+ client: AccountClientMetadata,
+ ): Promise {
+ return this.ctx.deepseekAccount.ackBonusNotified(accountId, orderId, client);
+ }
+ /**
+ * Begin browser sign-in.
+ * @param client - identity of the requesting UI, captured by a new attempt.
+ * @param callbackOrigin - browser-accessible loopback HTTP origin.
+ * @param loginSource - initiating UI, used to return from a failed exchange.
+ * @returns a new or already-active login attempt.
+ */
+ @Remote
+ startSignIn(
+ client: AccountClientMetadata,
+ callbackOrigin: string,
+ loginSource: "web" | "desktop",
+ ): Promise {
+ return this.ctx.deepseekAccount.startSignIn(client, callbackOrigin, loginSource);
+ }
+ /**
+ * Cancel the named local attempt.
+ * @param attemptId - attempt to cancel.
+ * @returns settled cancellation or commit state.
+ */
+ @Remote
+ cancelSignIn(attemptId: SignInAttemptId): Promise {
+ return this.ctx.deepseekAccount.cancelSignIn(attemptId);
+ }
+ /**
+ * Inspect the latest logged request providers of running tasks, including tools and retries.
+ * @returns whether running work has a latest request context on the account route.
+ */
+ @Remote
+ hasRunningAccountTasks(): boolean {
+ return this.ctx.agents.list().some(isRunningAccountTask);
+ }
+ /**
+ * Remove the local account grant and revoke it through Platform in the background, without deleting API keys.
+ * @param client - identity of the requesting UI, captured for the background revocation retries.
+ * @returns state after removing the local account grant.
+ */
+ @Remote
+ signOut(client: AccountClientMetadata): Promise {
+ return this.ctx.deepseekAccount.signOut(client);
+ }
+ /**
+ * Subscribe to credential expiry without replaying prior notifications.
+ * @param signal - stream lifetime.
+ * @returns notifications emitted while subscribed.
+ */
+ @Remote({ mode: "stream" })
+ async *watchExpiry(signal: AbortSignal): AsyncIterable<"session-expired"> {
+ let pending = 0;
+ let wake: (() => void) | undefined;
+ const stop = this.ctx.on("deepseek-account/session-expired", () => {
+ pending++;
+ wake?.();
+ });
+ const abort = (): void => {
+ wake?.();
+ };
+ signal.addEventListener("abort", abort, { once: true });
+ try {
+ while (!signal.aborted) {
+ if (pending > 0) {
+ pending--;
+ yield "session-expired";
+ continue;
+ }
+ await new Promise((resolve) => {
+ wake = resolve;
+ });
+ }
+ } finally {
+ stop();
+ signal.removeEventListener("abort", abort);
+ }
+ }
+ /**
+ * Stream the safe account projection.
+ * @param signal - stream lifetime.
+ * @returns initial snapshot and subsequent changes.
+ */
+ @Remote({ mode: "stream" })
+ watch(signal: AbortSignal): AsyncIterable {
+ return this.ctx.deepseekAccount.watch(signal);
+ }
+}
+export default AccountController;
diff --git a/apps/web-ui/packages/api/account-controller/src/types.ts b/apps/web-ui/packages/api/account-controller/src/types.ts
new file mode 100644
index 000000000..dc358c025
--- /dev/null
+++ b/apps/web-ui/packages/api/account-controller/src/types.ts
@@ -0,0 +1,12 @@
+/** Account Remote values contain no credential payloads. */
+export type {
+ AccountBonusBatch,
+ AccountBonusNotification,
+ AccountBonusOrderId,
+ AccountClientMetadata,
+ AccountDetails,
+ AccountProfile,
+ AccountUserId,
+ AccountView,
+ SignInAttemptId,
+} from "@deepseek-ai/dsh-deepseek-account/types";
diff --git a/apps/web-ui/packages/api/gateway/package.json b/apps/web-ui/packages/api/gateway/package.json
new file mode 100644
index 000000000..8ea02634c
--- /dev/null
+++ b/apps/web-ui/packages/api/gateway/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@deepseek-ai/dsh-api-gateway",
+ "version": "0.2.1-alpha.1",
+ "private": true,
+ "type": "module",
+ "license": "MIT",
+ "scripts": {
+ "test": "node --import tsx --test test/*.test.ts"
+ },
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "default": "./src/index.ts"
+ },
+ "./client": {
+ "types": "./src/client/index.ts",
+ "default": "./lib/client.js"
+ },
+ "./types": {
+ "types": "./src/types.ts",
+ "default": "./src/types.ts"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json",
+ "./stream-protocol": {
+ "types": "./src/stream-protocol.ts",
+ "default": "./src/stream-protocol.ts"
+ }
+ },
+ "dependencies": {
+ "@deepseek-ai/cordis": "*",
+ "@deepseek-ai/dsh-typert-protocol": "*",
+ "@deepseek-ai/dsh-deque": "*",
+ "@deepseek-ai/dsh-util-crypto": "*"
+ },
+ "dsh": {
+ "client": {
+ "inject": [
+ "@deepseek-ai/dsh-typert-registry",
+ "@deepseek-ai/dsh-client-connection"
+ ],
+ "platform": "web",
+ "immediately": true
+ }
+ }
+}
diff --git a/apps/web-ui/packages/api/gateway/src/client/index.ts b/apps/web-ui/packages/api/gateway/src/client/index.ts
new file mode 100644
index 000000000..d98bf0b68
--- /dev/null
+++ b/apps/web-ui/packages/api/gateway/src/client/index.ts
@@ -0,0 +1,969 @@
+/**
+ * Client projection of generated Typert Remote descriptors. Contributions
+ * install traced `remote.` services; no JavaScript Proxy
+ * participates in method lookup, invocation, or type exposure.
+ */
+
+import { Service } from "@deepseek-ai/cordis";
+import { RemoteError, isRemoteUplinkItem, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol";
+export type { TypertGatewayFaultDetails } from "../remote-error-codes.ts";
+import type { Context } from "@deepseek-ai/cordis";
+import type { ConnectionHandle } from "@deepseek-ai/dsh-client-connection/client";
+import type {
+ InvocationDescriptor,
+ TypertClientEventListener,
+ TypertClientRemote,
+ RemoteFailure,
+ RemoteResult,
+ RemoteStreamHandle,
+ TypertCodec,
+ TypertDisposer,
+ TypertRemoteContribution,
+ TypertRemoteEvent,
+} from "@deepseek-ai/dsh-typert-protocol";
+import {
+ ClientUplinkQueue,
+ RemoteStreamCarrierError,
+ RemoteStreamMuxClient,
+} from "./stream-client.ts";
+import { ClientRemoteEvents } from "./remote-events.ts";
+import { RemoteStream, type RemoteStreamOptions } from "./remote-stream.ts";
+
+export { RemoteStreamCarrierError } from "./stream-client.ts";
+export { RemoteJournalStream } from "./journal-stream.ts";
+export type {
+ RemoteJournalChange,
+ RemoteJournalFrame,
+ RemoteJournalStreamOptions,
+ RemoteStreamFactory,
+} from "./journal-stream.ts";
+export { RemoteStream } from "./remote-stream.ts";
+export type { RemoteStreamItem, RemoteStreamOptions } from "./remote-stream.ts";
+export { RemoteSnapshotStream } from "./snapshot-stream.ts";
+export type { RemoteSnapshotStreamOptions } from "./snapshot-stream.ts";
+
+interface MountToken {
+ active: boolean;
+ readonly abort: AbortController;
+}
+
+interface ScopedProjection {
+ readonly context: string;
+ readonly wire: string;
+ readonly parameterIndex?: number;
+}
+
+interface DirectMethod {
+ readonly descriptor: InvocationDescriptor;
+ readonly token: MountToken;
+}
+
+interface ScopedMethod extends DirectMethod {
+ readonly projection: ScopedProjection;
+}
+
+interface RemoteMethodRecord {
+ direct?: DirectMethod;
+ scoped?: ScopedMethod;
+}
+
+interface BoundContextIdentity {
+ readonly value: unknown;
+}
+
+interface PreparedClientInvocation {
+ readonly endpoint: string;
+ readonly args: Readonly>;
+ readonly signal: AbortSignal;
+}
+
+interface RemoteNamespaceHandle {
+ readonly service: RemoteNamespaceService;
+ readonly dispose: TypertDisposer;
+}
+
+interface LoaderReadiness {
+ await(): Promise;
+}
+
+/** One descriptor's mounted variants, for the group disposer to unwind. */
+interface InstalledMethod {
+ readonly descriptor: InvocationDescriptor;
+ readonly token: MountToken;
+ direct: boolean;
+ scoped: boolean;
+}
+
+/** Typed Remote service augmented by generated direct namespaces and Gateway stream supervision. */
+export interface ClientRemote extends TypertClientRemote {
+ /**
+ * Create one independently cancellable, reconnecting logical stream.
+ * @param options - domain-owned opener and generation-end classification.
+ * @returns a single-consumer stream annotated with physical generation ids.
+ */
+ $stream- (options: RemoteStreamOptions
- ): RemoteStream
- ;
+ /**
+ * Fixed Host facts as plain reads: no store, no subscription, no generation
+ * counter. `home` stays undefined until the first ready frame and reflects
+ * the latest one afterwards.
+ */
+ readonly $host: RemoteHostFacts;
+}
+
+/** The fixed Host facts exposed on `ctx.remote.$host`. */
+export interface RemoteHostFacts {
+ /** Host home directory from the ready frame, undefined before it. */
+ readonly home: string | undefined;
+ /** Whether the carrier connects to the local Host. */
+ readonly isLoopback: boolean;
+}
+
+declare module "@deepseek-ai/cordis" {
+ interface Context {
+ /** Generated Remote namespaces selected by the Client assembly. */
+ remote: ClientRemote;
+ }
+}
+
+/** Required Client services: the Typert registry and the existing Connection carrier. */
+export const inject = ["typert", "connection"];
+
+/**
+ * Install the typed Client Remote service.
+ * @param ctx - Client Cordis root.
+ */
+export function apply(ctx: Context): void {
+ new ClientRemoteService(ctx);
+}
+
+class ClientRemoteService extends Service implements ClientRemote {
+ private readonly ownerCtx: Context;
+ private readonly connection: ConnectionHandle;
+ private readonly namespaces = new Map();
+ private hostFacts: RemoteHostFacts | undefined;
+ private readonly streams = new RemoteStreamMuxClient();
+ private readonly events: ClientRemoteEvents;
+ private mutations = Promise.resolve();
+
+ constructor(ctx: Context) {
+ super(ctx, "remote");
+ this.ownerCtx = ctx;
+ const connection = ctx.get("connection") as ConnectionHandle;
+ this.connection = connection;
+ this.events = new ClientRemoteEvents(ctx, connection, (endpoint, payload, signal) =>
+ this.openRemoteStream(endpoint, payload, signal),
+ );
+ if (connection.rpc.open === undefined) this.streams.start();
+ let disposed = false;
+ let loop: ReturnType | undefined;
+ const start = (): void => {
+ if (disposed) return;
+ if (connection.rpc.open === undefined) this.streams.start();
+ loop = connection.start({
+ onConnected: () => {
+ this.ownerCtx.emit("connection/reset");
+ },
+ onReconnectRequested: () => {
+ if (connection.rpc.open === undefined) this.streams.reconnect();
+ },
+ });
+ };
+ const loader = ctx.get("loader") as LoaderReadiness | undefined;
+ if (loader === undefined) start();
+ else void loader.await().then(start, () => {});
+ ctx.effect(
+ () => async () => {
+ disposed = true;
+ loop?.stop();
+ await this.events.dispose();
+ await this.streams.close();
+ },
+ "api-gateway.client.transport",
+ );
+ }
+
+ $stream
- (options: RemoteStreamOptions
- ): RemoteStream
- {
+ return new RemoteStream(this.connection, options);
+ }
+
+ get $host(): RemoteHostFacts {
+ // Identity-stable: readers (useSyncExternalStore snapshots, memo inputs)
+ // compare by reference, so a fresh object is minted only when the fact
+ // itself changed. isLoopback is fixed for the page lifetime.
+ const home = this.connection.generation.getSnapshot()?.host.home;
+ if (this.hostFacts === undefined || this.hostFacts.home !== home) {
+ this.hostFacts = { home, isLoopback: this.connection.isLoopback };
+ }
+ return this.hostFacts;
+ }
+
+ async $mount(contribution: TypertRemoteContribution): ReturnType {
+ const callerCtx = this.ctx;
+ const owned = callerCtx.effect(
+ async () => {
+ const dispose = await this.enqueue(() => this.mountContribution(callerCtx, contribution));
+ return () => this.enqueue(dispose);
+ },
+ `api-gateway.client.$mount(${JSON.stringify(contribution.package)})`,
+ );
+ await owned;
+ return async () => {
+ await owned();
+ };
+ }
+
+ $on(
+ event: Event,
+ listener: TypertClientEventListener,
+ ): () => void {
+ return this.events.subscribe(this.ctx, event, listener);
+ }
+
+ /** Open one Remote stream and normalize a worker-local carrier's structural failures. */
+ private openRemoteStream(
+ endpoint: string,
+ payload: unknown,
+ signal: AbortSignal,
+ uplink?: AsyncIterable,
+ noConnection = `client api: ${endpoint} has no active Connection`,
+ ): AsyncIterable {
+ const connection = this.ownerCtx.get("connection") as ConnectionHandle | undefined;
+ if (connection === undefined) throw new Error(noConnection);
+ const local = connection.rpc.open?.("/api", endpoint, payload, signal, uplink);
+ return local === undefined
+ ? this.streams.open(endpoint, payload, signal, uplink)
+ : normalizeConnectionStream(local);
+ }
+
+ private enqueue(operation: () => T | Promise): Promise {
+ const result = this.mutations.then(operation, operation);
+ this.mutations = result.then(
+ () => undefined,
+ () => undefined,
+ );
+ return result;
+ }
+
+ private async mountContribution(
+ callerCtx: Context,
+ contribution: TypertRemoteContribution,
+ ): Promise {
+ this.validateContribution(contribution);
+ const disposeRemote = callerCtx.typert.remotes.register(contribution);
+ const groups = new Map();
+ for (const descriptor of contribution.descriptors) {
+ const group = groups.get(descriptor.namespace);
+ if (group === undefined) groups.set(descriptor.namespace, [descriptor]);
+ else group.push(descriptor);
+ }
+ const installed: TypertDisposer[] = [];
+ try {
+ for (const [namespace, descriptors] of groups) {
+ installed.push(await this.installNamespace(namespace, descriptors));
+ }
+ } catch (error) {
+ for (const dispose of installed.reverse()) await dispose();
+ await disposeRemote();
+ throw error;
+ }
+ return async () => {
+ for (const dispose of installed.reverse()) await dispose();
+ await disposeRemote();
+ };
+ }
+
+ private validateContribution(contribution: TypertRemoteContribution): void {
+ const direct = new Map>();
+ const scoped = new Map>();
+ const add = (
+ table: Map>,
+ descriptor: InvocationDescriptor,
+ kind: "direct" | "scoped",
+ ): void => {
+ const methods = table.get(descriptor.namespace) ?? new Set();
+ if (methods.has(descriptor.method)) {
+ throw new Error(
+ `client api: contribution repeats ${kind} method ${endpointOf(descriptor)}`,
+ );
+ }
+ methods.add(descriptor.method);
+ table.set(descriptor.namespace, methods);
+ const namespace = this.namespaces.get(descriptor.namespace)?.service;
+ if (namespace?.has(kind, descriptor.method) === true) {
+ throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`);
+ }
+ };
+ for (const descriptor of contribution.descriptors) {
+ requireStrictInputs(descriptor);
+ if (descriptor.invocation.kind === "direct") add(direct, descriptor, "direct");
+ if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, "scoped");
+ }
+ const namespaces = new Set([...direct.keys(), ...scoped.keys()]);
+ for (const namespace of namespaces) {
+ const service = this.namespaces.get(namespace)?.service;
+ if (service === undefined) {
+ if (namespace in this) {
+ throw new Error(
+ `client api: namespace ${JSON.stringify(namespace)} conflicts with the Remote service`,
+ );
+ }
+ const serviceKey = remoteServiceKey(namespace);
+ const property = this.ownerCtx.reflect.props[serviceKey];
+ if (property?.type === "accessor" || this.ownerCtx.get(serviceKey) !== undefined) {
+ throw new Error(
+ `client api: namespace ${JSON.stringify(namespace)} conflicts with an existing Remote namespace`,
+ );
+ }
+ }
+ for (const method of new Set([
+ ...(direct.get(namespace) ?? []),
+ ...(scoped.get(namespace) ?? []),
+ ])) {
+ if (service === undefined) RemoteNamespaceService.assertMethodAvailable(namespace, method);
+ else service.assertMethodAvailable(method);
+ }
+ }
+ }
+
+ /**
+ * Mount one namespace's descriptor group with no visibility gap: a fresh
+ * namespace installs its whole group synchronously inside its fiber's
+ * apply, so a plugin parked on the namespace service never observes it
+ * without the methods the same contribution carries; an existing namespace
+ * takes the group in one synchronous step.
+ * @param name - Remote namespace.
+ * @param descriptors - Every contribution descriptor naming that namespace.
+ * @returns disposer unmounting the group and the namespace once empty.
+ */
+ private async installNamespace(
+ name: string,
+ descriptors: readonly InvocationDescriptor[],
+ ): Promise {
+ let namespace = this.namespaces.get(name);
+ let installed: InstalledMethod[];
+ if (namespace === undefined) {
+ ({ namespace, installed } = await this.createNamespace(name, descriptors));
+ } else {
+ installed = installMethods(namespace.service, descriptors);
+ }
+ const handle = namespace;
+ return async () => {
+ for (const method of [...installed].reverse()) {
+ /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
+ if (!method.token.active) continue;
+ method.token.active = false;
+ method.token.abort.abort();
+ if (method.scoped) handle.service.remove("scoped", method.descriptor.method, method.token);
+ if (method.direct) handle.service.remove("direct", method.descriptor.method, method.token);
+ }
+ await this.disposeNamespace(name, handle);
+ };
+ }
+
+ private async createNamespace(
+ name: string,
+ descriptors: readonly InvocationDescriptor[],
+ ): Promise<{ namespace: RemoteNamespaceHandle; installed: InstalledMethod[] }> {
+ let service: RemoteNamespaceService | undefined;
+ let installed: InstalledMethod[] | undefined;
+ const fiber = this.ownerCtx.plugin({
+ name: remoteServiceKey(name),
+ apply: (ctx: Context) => {
+ service = new RemoteNamespaceService(ctx, name, (direct, scoped, caller, args) =>
+ this.invokeMethod(direct, scoped, caller, args),
+ );
+ // Same synchronous window as the service registration: a dependent the
+ // new service unparks runs only after the methods exist.
+ installed = installMethods(service, descriptors);
+ },
+ });
+ try {
+ await fiber;
+ } catch (error) {
+ await fiber.dispose();
+ throw error;
+ }
+ /* v8 ignore next 3 -- a settled namespace fiber synchronously constructs its Service and installs the group. */
+ if (service === undefined || installed === undefined) {
+ throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`);
+ }
+ const namespace = { service, dispose: fiber.dispose };
+ this.namespaces.set(name, namespace);
+ return { namespace, installed };
+ }
+
+ private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise {
+ if (!namespace.service.empty || this.namespaces.get(name) !== namespace) return;
+ this.namespaces.delete(name);
+ await namespace.dispose();
+ }
+
+ private invokeMethod(
+ direct: DirectMethod | undefined,
+ scoped: ScopedMethod | undefined,
+ callerCtx: Context,
+ values: readonly unknown[],
+ ): Promise> | RemoteStreamHandle {
+ if (scoped !== undefined) {
+ const adapter = this.ownerCtx.typert.contexts.getClient(scoped.projection.context);
+ const identity = adapter?.identity(callerCtx);
+ if (identity !== undefined) {
+ return this.invokeSelected(
+ scoped.descriptor,
+ scoped.projection,
+ scoped.token,
+ callerCtx,
+ values,
+ { value: identity },
+ );
+ }
+ }
+ if (direct !== undefined) {
+ return this.invokeSelected(direct.descriptor, undefined, direct.token, callerCtx, values);
+ }
+ if (scoped !== undefined) {
+ return this.invokeSelected(
+ scoped.descriptor,
+ scoped.projection,
+ scoped.token,
+ callerCtx,
+ values,
+ );
+ }
+ throw new Error("client api: Remote method is no longer mounted");
+ }
+
+ private invokeSelected(
+ descriptor: InvocationDescriptor,
+ projection: ScopedProjection | undefined,
+ token: MountToken,
+ callerCtx: Context,
+ values: readonly unknown[],
+ boundIdentity?: BoundContextIdentity,
+ ): Promise> | RemoteStreamHandle {
+ if (descriptor.mode !== undefined) {
+ return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity);
+ }
+ return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity);
+ }
+
+ private async invoke(
+ descriptor: InvocationDescriptor,
+ projection: ScopedProjection | undefined,
+ token: MountToken,
+ callerCtx: Context,
+ values: readonly unknown[],
+ boundIdentity?: BoundContextIdentity,
+ ): Promise> {
+ const endpoint = endpointOf(descriptor);
+ if (!token.active) return withdrawn(endpoint);
+ const prepared = this.prepareInvocation(
+ descriptor,
+ projection,
+ token,
+ callerCtx,
+ values,
+ boundIdentity,
+ );
+ const connection = this.ownerCtx.get("connection") as ConnectionHandle | undefined;
+ if (connection === undefined)
+ throw new Error(`client api: ${endpoint} has no active Connection`);
+ try {
+ const result = await connection.rpc.call(
+ "/api",
+ endpoint,
+ { args: prepared.args },
+ prepared.signal,
+ );
+ if (!mountActive(token)) return withdrawn(endpoint);
+ prepared.signal.throwIfAborted();
+ if (!result.ok) return { ok: false, error: rebuiltFailure(result.error) };
+ const value =
+ descriptor.result.mode === "strict" && descriptor.result.decode !== undefined
+ ? descriptor.result.decode(result.value)
+ : result.value;
+ return { ok: true, value };
+ } catch (error) {
+ // Carrier throws (offline or abort) are outcomes of the call, not assembly
+ // faults, so they join the same error branch. A caller-aborted call is a
+ // cancellation even when the local throw wins the race against the wire
+ // round-trip, so it gets the same code the Host would have produced.
+ if (prepared.signal.aborted) return cancelledFailure(endpoint, error);
+ return carrierFailure(endpoint, error);
+ }
+ }
+
+ /** Open the logical stream now and hand back its handle; `send()` before the first read queues behind the `open` frame. */
+ private invokeStream(
+ descriptor: InvocationDescriptor,
+ projection: ScopedProjection | undefined,
+ token: MountToken,
+ callerCtx: Context,
+ values: readonly unknown[],
+ boundIdentity?: BoundContextIdentity,
+ ): RemoteStreamHandle {
+ const endpoint = endpointOf(descriptor);
+ if (!token.active) throw new Error(withdrawn(endpoint).error.message);
+ const prepared = this.prepareInvocation(
+ descriptor,
+ projection,
+ token,
+ callerCtx,
+ values,
+ boundIdentity,
+ );
+ const generation = new AbortController();
+ const uplink = new ClientUplinkQueue(endpoint);
+ const downlink = this.openRemoteStream(
+ endpoint,
+ { args: prepared.args },
+ AbortSignal.any([prepared.signal, generation.signal]),
+ uplink,
+ );
+ return new ClientStreamHandle(endpoint, downlink, uplink, generation, token);
+ }
+
+ private prepareInvocation(
+ descriptor: InvocationDescriptor,
+ projection: ScopedProjection | undefined,
+ token: MountToken,
+ callerCtx: Context,
+ values: readonly unknown[],
+ boundIdentity?: BoundContextIdentity,
+ ): PreparedClientInvocation {
+ const endpoint = endpointOf(descriptor);
+ const expected =
+ descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1);
+ const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1;
+ if (values.length !== expected && !hasCallerSignal) {
+ const contract =
+ descriptor.cancellation === undefined
+ ? `${String(expected)} argument(s)`
+ : `${String(expected)} business argument(s) plus an optional AbortSignal`;
+ throw new Error(`client api: ${endpoint} expected ${contract}, got ${String(values.length)}`);
+ }
+ const args = Object.create(null) as Record;
+ if (projection !== undefined) {
+ const adapter =
+ boundIdentity === undefined
+ ? this.ownerCtx.typert.contexts.getClient(projection.context)
+ : undefined;
+ if (boundIdentity === undefined && adapter === undefined) {
+ throw new Error(
+ `client api: ${endpoint} has no Client Context adapter for ${JSON.stringify(projection.context)}`,
+ );
+ }
+ const identity =
+ boundIdentity === undefined ? adapter?.identity(callerCtx) : boundIdentity.value;
+ if (identity === undefined) {
+ throw new Error(
+ `client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`,
+ );
+ }
+ args[projection.wire] = identity;
+ }
+ let valueIndex = 0;
+ descriptor.parameters.forEach((parameter, parameterIndex) => {
+ if (parameterIndex === projection?.parameterIndex) return;
+ const value = values[valueIndex];
+ if (value !== undefined) args[parameter.wire] = value;
+ valueIndex += 1;
+ });
+ const callerSignal = hasCallerSignal
+ ? (values[expected] as AbortSignal | undefined)
+ : undefined;
+ const signal =
+ callerSignal === undefined
+ ? token.abort.signal
+ : AbortSignal.any([token.abort.signal, callerSignal]);
+ return { endpoint, args, signal };
+ }
+}
+
+/**
+ * The handle a generated stream method returns: one generation of one logical
+ * stream. The downlink is iterated once; `send`/`end` feed the uplink queue the
+ * carrier pump drains; `dispose` aborts the generation and returns the carrier
+ * iterator, which sends `cancel` unless a terminal frame arrived, drops what was
+ * buffered, and ends the iteration quietly.
+ */
+class ClientStreamHandle implements RemoteStreamHandle {
+ private readonly downlink: AsyncIterator;
+ private primed: Promise> | undefined;
+ private consumed = false;
+ private disposed = false;
+
+ constructor(
+ private readonly endpoint: string,
+ downlink: AsyncIterable,
+ private readonly uplink: ClientUplinkQueue,
+ private readonly generation: AbortController,
+ private readonly token: MountToken,
+ ) {
+ this.downlink = downlink[Symbol.asyncIterator]();
+ // The carrier opens the logical stream on the first pull; pulling now puts
+ // the `open` frame on the wire before any `send()`. The first read is kept
+ // for the consumer, and a failure waits for it instead of surfacing here;
+ // it also terminates the stream, so the queue closes and `send()` throws.
+ this.primed = this.downlink.next();
+ void this.primed.catch(() => {
+ this.uplink.close();
+ });
+ }
+
+ send(item: unknown): void {
+ if (!isRemoteUplinkItem(item))
+ throw new Error(`client api: ${this.endpoint} uplink item is not a lossless JSON value`);
+ this.uplink.push(item);
+ }
+
+ end(): void {
+ this.uplink.end();
+ }
+
+ dispose(): void {
+ if (this.disposed) return;
+ this.disposed = true;
+ this.uplink.close();
+ this.generation.abort(new Error(`client api: ${this.endpoint} stream disposed`));
+ // Returning the carrier iterator reaches its `finally` even while nobody
+ // reads the downlink, so `cancel` goes out now and the pump stops.
+ void Promise.resolve(this.downlink.return?.()).catch(() => undefined);
+ }
+
+ [Symbol.asyncIterator](): AsyncIterator {
+ if (this.consumed) throw new Error(`client api: ${this.endpoint} stream has one consumer`);
+ this.consumed = true;
+ const iteration = this.iterate();
+ return {
+ next: () => iteration.next(),
+ // A return before the first read ends a generator that never started, so its `finally` would not dispose.
+ return: (value?: unknown) => {
+ this.dispose();
+ return iteration.return(value);
+ },
+ };
+ }
+
+ private async *iterate(): AsyncGenerator {
+ try {
+ while (true) {
+ const next = await (this.primed ?? this.downlink.next());
+ this.primed = undefined;
+ if (this.disposed || next.done === true) return;
+ if (!mountActive(this.token)) throw new Error(withdrawn(this.endpoint).error.message);
+ yield next.value;
+ }
+ } catch (error) {
+ if (this.disposed) return;
+ throw error;
+ } finally {
+ // Ending early is a dispose; a terminated stream accepts no more uplink.
+ this.dispose();
+ await this.downlink.return?.();
+ }
+ }
+}
+
+type InvokeRemote = (
+ direct: DirectMethod | undefined,
+ scoped: ScopedMethod | undefined,
+ callerCtx: Context,
+ args: readonly unknown[],
+) => Promise> | RemoteStreamHandle;
+
+class RemoteNamespaceService extends Service {
+ private readonly methods = new Map();
+ private readonly namespace: string;
+
+ static assertMethodAvailable(namespace: string, method: string): void {
+ if (REMOTE_NAMESPACE_FIELDS.has(method) || method in RemoteNamespaceService.prototype) {
+ throw new Error(
+ `client api: method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`,
+ );
+ }
+ }
+
+ constructor(
+ ctx: Context,
+ name: string,
+ private readonly invokeRemote: InvokeRemote,
+ ) {
+ super(ctx, remoteServiceKey(name));
+ this.namespace = name;
+ }
+
+ assertMethodAvailable(method: string): void {
+ RemoteNamespaceService.assertMethodAvailable(this.namespace, method);
+ if (method in this && !this.methods.has(method)) {
+ throw new Error(
+ `client api: method ${JSON.stringify(`${this.namespace}/${method}`)} conflicts with its namespace service`,
+ );
+ }
+ }
+
+ get empty(): boolean {
+ return this.methods.size === 0;
+ }
+
+ has(kind: "direct" | "scoped", method: string): boolean {
+ return this.methods.get(method)?.[kind] !== undefined;
+ }
+
+ installDirect(descriptor: InvocationDescriptor, token: MountToken): void {
+ this.install(descriptor.method, "direct", { descriptor, token });
+ }
+
+ installScoped(
+ descriptor: InvocationDescriptor,
+ projection: ScopedProjection,
+ token: MountToken,
+ ): void {
+ this.install(descriptor.method, "scoped", { descriptor, projection, token });
+ }
+
+ private install(method: string, kind: "direct", value: DirectMethod): void;
+ private install(method: string, kind: "scoped", value: ScopedMethod): void;
+ private install(
+ method: string,
+ kind: "direct" | "scoped",
+ value: DirectMethod | ScopedMethod,
+ ): void {
+ this.assertMethodAvailable(method);
+ let record = this.methods.get(method);
+ const fresh = record === undefined;
+ record ??= {};
+ if (fresh) {
+ Object.defineProperty(this, method, {
+ configurable: true,
+ enumerable: true,
+ get: function (this: RemoteNamespaceService): (...args: unknown[]) => unknown {
+ const callerCtx = this.ctx;
+ const current = this.methods.get(method);
+ const direct = current?.direct;
+ const scoped = current?.scoped;
+ return (...args: unknown[]) => {
+ return this.invokeRemote(direct, scoped, callerCtx, args);
+ };
+ },
+ });
+ this.methods.set(method, record);
+ }
+ if (kind === "direct") record.direct = value;
+ else record.scoped = value as ScopedMethod;
+ }
+
+ remove(kind: "direct" | "scoped", method: string, token: MountToken): void {
+ const record = this.methods.get(method);
+ const current = record?.[kind];
+ /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */
+ if (record === undefined || current?.token !== token) return;
+ if (kind === "direct") delete record.direct;
+ else delete record.scoped;
+ if (record.direct !== undefined || record.scoped !== undefined) return;
+ this.methods.delete(method);
+ Reflect.deleteProperty(this, method);
+ }
+}
+
+/**
+ * Install one descriptor group on a namespace service, unwinding the partial
+ * group when a descriptor is refused.
+ * @param service - Namespace service taking the methods.
+ * @param descriptors - Descriptor group of one contribution.
+ * @returns per-descriptor records for the group disposer.
+ */
+function installMethods(
+ service: RemoteNamespaceService,
+ descriptors: readonly InvocationDescriptor[],
+): InstalledMethod[] {
+ const installed: InstalledMethod[] = [];
+ try {
+ for (const descriptor of descriptors) {
+ const method: InstalledMethod = {
+ descriptor,
+ token: { active: true, abort: new AbortController() },
+ direct: false,
+ scoped: false,
+ };
+ installed.push(method);
+ if (descriptor.invocation.kind === "direct") {
+ service.installDirect(descriptor, method.token);
+ method.direct = true;
+ }
+ const projection = scopedProjection(descriptor);
+ if (projection !== undefined) {
+ service.installScoped(descriptor, projection, method.token);
+ method.scoped = true;
+ }
+ }
+ } catch (error) {
+ for (const method of [...installed].reverse()) {
+ method.token.active = false;
+ method.token.abort.abort();
+ if (method.scoped) service.remove("scoped", method.descriptor.method, method.token);
+ if (method.direct) service.remove("direct", method.descriptor.method, method.token);
+ }
+ throw error;
+ }
+ return installed;
+}
+
+const REMOTE_NAMESPACE_FIELDS = new Set([
+ "ctx",
+ "empty",
+ "invokeRemote",
+ "methods",
+ "name",
+ "namespace",
+]);
+
+function remoteServiceKey(namespace: string): string {
+ return `remote.${namespace}`;
+}
+
+function endpointOf(descriptor: Pick): string {
+ return `${descriptor.namespace}/${descriptor.method}`;
+}
+
+function mountActive(token: MountToken): boolean {
+ return token.active;
+}
+
+function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | undefined {
+ if (descriptor.invocation.kind === "context") {
+ return {
+ context: descriptor.invocation.context,
+ wire: descriptor.invocation.wire,
+ };
+ }
+ if (descriptor.scope === undefined) return undefined;
+ const lookupParameters = descriptor.parameters
+ .map((parameter, index) => ({ parameter, index }))
+ .filter((candidate) => candidate.parameter.source === "lookup");
+ const selected = lookupParameters.length === 1 ? lookupParameters[0] : undefined;
+ if (
+ selected === undefined ||
+ selected.parameter.wire !== descriptor.scope.wire ||
+ selected.parameter.lookup !== descriptor.scope.context
+ ) {
+ throw new Error(
+ `client api: generated Remote ${endpointOf(descriptor)} scope must select its only lookup parameter`,
+ );
+ }
+ return {
+ context: descriptor.scope.context,
+ wire: descriptor.scope.wire,
+ parameterIndex: selected.index,
+ };
+}
+
+function requireStrictInputs(descriptor: InvocationDescriptor): void {
+ const endpoint = endpointOf(descriptor);
+ for (const parameter of descriptor.parameters) {
+ requireStrictCodec(parameter.codec, endpoint, parameter.wire);
+ }
+ if (descriptor.uplink !== undefined)
+ requireStrictCodec(descriptor.uplink.codec, endpoint, "uplink");
+ if (descriptor.invocation.kind === "context") {
+ requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire);
+ }
+}
+
+function requireStrictCodec(codec: TypertCodec, endpoint: string, field: string): void {
+ if (codec.mode !== "strict") {
+ throw new Error(
+ `client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`,
+ );
+ }
+}
+
+/** The namespace retired before or during the call, so no request outcome exists. */
+function withdrawn(endpoint: string): Extract, { readonly ok: false }> {
+ return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`);
+}
+
+/**
+ * The error branch a carrier throw (offline, transport fault) folds into: `gateway/internal` naming the endpoint and
+ * the thrown message. Exported so a stand-in for this face folds identically.
+ * @param endpoint - `/` that was called.
+ * @param error - what the carrier threw.
+ * @returns the failed result.
+ */
+export function carrierFailure(
+ endpoint: string,
+ error: unknown,
+): Extract, { readonly ok: false }> {
+ return internalFailure(
+ `client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`,
+ );
+}
+
+/**
+ * The error branch a call aborted by its caller folds into: `gateway/cancelled` with the carrier's throw as `cause`.
+ * @param endpoint - `/` that was called.
+ * @param cause - what the carrier threw when the signal aborted.
+ * @returns the failed result.
+ */
+export function cancelledFailure(
+ endpoint: string,
+ cause: unknown,
+): Extract, { readonly ok: false }> {
+ return {
+ ok: false,
+ error: new RemoteError(
+ "gateway/cancelled",
+ `client api: Remote invocation "${endpoint}" was aborted`,
+ {},
+ { cause },
+ ),
+ };
+}
+
+function internalFailure(message: string): Extract, { readonly ok: false }> {
+ return { ok: false, error: new RemoteError("gateway/internal", message, {}) };
+}
+
+/**
+ * Whether a caught value is a Remote failure this face delivered or threw.
+ * The one consumer-facing discrimination point: marked instances carry their
+ * Host code; anything else is a local fault the caller should let crash.
+ * @param error - a caught value.
+ * @returns true when the value narrows to RemoteFailure.
+ */
+export function isRemoteFailure(error: unknown): error is RemoteFailure {
+ return remoteErrorOf(error) !== undefined;
+}
+
+/**
+ * Rebuild the wire failure as a local RemoteError instance so the error branch
+ * carries a real Error and `throw result.error` keeps throw semantics. The code
+ * is passed through verbatim without runtime validation: a code outside this
+ * Client's merged map still surfaces as-is, so a newer Host stays readable.
+ */
+function rebuiltFailure(error: { code: string; message: string; details: object }): RemoteFailure {
+ return new RemoteError(error.code as never, error.message, error.details as never);
+}
+
+type MarkedConnectionStreamFailure = Error & {
+ readonly dshRemoteStreamFailure?:
+ | { readonly kind: "remote"; readonly code: string; readonly details: object }
+ | { readonly kind: "carrier" };
+};
+
+/** Preserve Gateway error classes across a worker transport's separately bundled page half. */
+async function* normalizeConnectionStream(source: AsyncIterable): AsyncGenerator {
+ try {
+ yield* source;
+ } catch (error) {
+ if (!(error instanceof Error)) throw error;
+ const marker = (error as MarkedConnectionStreamFailure).dshRemoteStreamFailure;
+ if (marker?.kind === "remote") {
+ throw new RemoteError(marker.code as never, error.message, marker.details as never);
+ }
+ if (marker?.kind === "carrier") {
+ throw new RemoteStreamCarrierError(error.message, { cause: error });
+ }
+ throw error;
+ }
+}
diff --git a/apps/web-ui/packages/api/gateway/src/client/journal-stream.ts b/apps/web-ui/packages/api/gateway/src/client/journal-stream.ts
new file mode 100644
index 000000000..952dfa33b
--- /dev/null
+++ b/apps/web-ui/packages/api/gateway/src/client/journal-stream.ts
@@ -0,0 +1,604 @@
+/** Cursor, page, and live-tail coordination over a reconnecting Remote stream. */
+
+import { RemoteError } from "@deepseek-ai/dsh-typert-protocol";
+import { RemoteStreamCarrierError } from "./stream-client.ts";
+import type { RemoteStream, RemoteStreamItem, RemoteStreamOptions } from "./remote-stream.ts";
+
+/** Host-side stream protocol violation, marked so consumers surface it as an error state. */
+function protocolViolation(message: string): RemoteError<"gateway/internal"> {
+ return new RemoteError("gateway/internal", message, {});
+}
+
+/** Transport-neutral opening snapshot, durable entry, or cursorless notification. */
+export type RemoteJournalFrame =
+ | { readonly type: "opened"; readonly cursor: Cursor; readonly page: Page }
+ | { readonly type: "entry"; readonly entry: Entry }
+ | ([Notification] extends [never]
+ ? never
+ : { readonly type: "notification"; readonly notification: Notification });
+
+/** One journal-window update or cursorless domain notification. */
+export type RemoteJournalChange =
+ | {
+ readonly type: "replace";
+ readonly page: Page;
+ readonly entries: readonly Entry[];
+ readonly hasMore: boolean;
+ }
+ | {
+ readonly type: "prepend";
+ readonly page: Page;
+ readonly entries: readonly Entry[];
+ readonly hasMore: boolean;
+ }
+ | { readonly type: "append"; readonly entry: Entry }
+ | ([Notification] extends [never]
+ ? never
+ : { readonly type: "notification"; readonly notification: Notification });
+
+type JournalStreamItem = RemoteStreamItem<
+ RemoteJournalFrame
+>;
+
+/** Gateway capability used to create one reconnecting Remote stream. */
+export interface RemoteStreamFactory {
+ /**
+ * Create one independently cancellable logical stream.
+ * @param options - domain-owned opener and generation-end classification.
+ * @returns a reconnecting single-consumer stream.
+ */
+ $stream
- (options: RemoteStreamOptions
- ): RemoteStream
- ;
+}
+
+/** Domain publication and cursor operations for one addressed journal stream. */
+export interface RemoteJournalStreamOptions {
+ /** Diagnostic stream name used in protocol failures. */
+ readonly name: string;
+ /** Cursor representing a journal with no entries. */
+ readonly emptyCursor: Cursor;
+ /** Read the ordered entries carried by a page. */
+ readonly entries: (page: Page) => readonly Entry[];
+ /** Read whether an older page exists. */
+ readonly hasMore: (page: Page) => boolean;
+ /** Read the inclusive first durable cursor covered by one entry. */
+ readonly first: (entry: Entry) => Cursor;
+ /** Read the inclusive final cursor, which must not precede the first. */
+ readonly last: (entry: Entry) => Cursor;
+ /** Compare two cursors. */
+ readonly compare: (left: Cursor, right: Cursor) => number;
+ /** Test whether the right cursor immediately follows the left cursor. */
+ readonly follows: (left: Cursor, right: Cursor) => boolean;
+ /** Apply one complete journal-window change or cursorless notification. */
+ readonly publish: (change: RemoteJournalChange) => void;
+ /** Observe a retryable carrier loss before reconnection. */
+ readonly carrierFailed?: (error: RemoteStreamCarrierError) => void;
+ /** Publish a terminal stream, page, or protocol failure after opening. */
+ readonly failed: (error: unknown) => void;
+}
+
+/**
+ * Owns snapshot-first opening, ordered live delivery, pagination, and repair.
+ *
+ * The domain retains its published window during reconnection. A replacement is
+ * published only after the opening page reaches the generation's cursor.
+ * Notifications never change a cursor and wait behind an in-flight gap repair.
+ */
+export abstract class RemoteJournalStream<
+ Page,
+ Entry,
+ Cursor,
+ PageRequest = void,
+ Notification = never,
+> {
+ private readonly stream: RemoteStream>;
+ private initialRequest!: PageRequest;
+ private resumeCursor: Cursor | undefined;
+ private hasResumeCursor = false;
+ private generation = 0;
+ private firstCursor: Cursor | undefined;
+ private lastCursor: Cursor | undefined;
+ private started = false;
+ private opened = false;
+ private disposed = false;
+ private done: Promise | undefined;
+ private closing: Promise | undefined;
+ private pendingNext:
+ Promise>> | undefined;
+
+ /**
+ * @param remote - Gateway factory for the reconnecting physical-generation stream.
+ * @param options - cursor algebra and domain publication sinks.
+ */
+ protected constructor(
+ remote: RemoteStreamFactory,
+ private readonly options: RemoteJournalStreamOptions,
+ ) {
+ this.stream = remote.$stream>({
+ name: options.name,
+ open: (signal) => this.follow(this.initialRequest, signal),
+ ended: (accepted) =>
+ accepted
+ ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`)
+ : protocolViolation(
+ `${this.hasResumeCursor ? "resumed " : ""}${options.name} ended before its opening cursor`,
+ ),
+ ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }),
+ });
+ }
+
+ /**
+ * Open one physical journal generation with a complete current snapshot.
+ * @param request - opening-window request retained for later repair.
+ * @param signal - cancellation lifetime of the physical generation.
+ * @returns opening cursor followed by live entries.
+ */
+ protected abstract follow(
+ request: PageRequest,
+ signal: AbortSignal,
+ ): AsyncIterable>;
+
+ /**
+ * Read one journal page through the addressed domain source.
+ * @param request - domain page request.
+ * @param through - inclusive journal cursor that fixes the source read.
+ * @param signal - cancellation lifetime shared with the logical stream.
+ * @returns the requested page, whose tail equals `through` unless the domain request selects older entries.
+ */
+ protected abstract readPage(
+ request: PageRequest,
+ through: Cursor,
+ signal: AbortSignal,
+ ): Promise;
+
+ /**
+ * Derive an unbounded-tail request from the initial page request.
+ * @param initial - request used to open the journal window.
+ * @returns request suitable for reconnect and gap repair.
+ */
+ protected abstract repairRequest(initial: PageRequest): PageRequest;
+
+ /** Cancellation lifetime shared by follow and page calls. */
+ get signal(): AbortSignal {
+ return this.stream.signal;
+ }
+
+ /**
+ * Establish follow and publish the opening snapshot carried by its first frame.
+ * @param request - initial tail-page request.
+ * @returns after the first complete window is published.
+ */
+ async open(request: PageRequest): Promise {
+ if (this.started) throw new Error(`${this.options.name} already opened`);
+ this.started = true;
+ this.initialRequest = request;
+ const iterator = this.stream[Symbol.asyncIterator]();
+ try {
+ const first = await this.takeNext(iterator);
+ if (first.done)
+ throw protocolViolation(`${this.options.name} ended before its opening cursor`);
+ this.replaceGeneration(first.value, false);
+ this.opened = true;
+ this.done = this.consume(iterator);
+ } catch (error) {
+ await this.stream.dispose();
+ throw error;
+ }
+ }
+
+ /**
+ * Read and prepend one older page after a successful open.
+ * @param request - domain page request bound to this stream's address.
+ * @returns after the page is applied or rejected as discontinuous.
+ */
+ async prepend(request: PageRequest): Promise {
+ if (!this.opened || this.disposed) throw new Error(`${this.options.name} is not open`);
+ const page = await this.readPage(request, this.currentCursor(), this.stream.signal);
+ this.stream.signal.throwIfAborted();
+ const entries = this.options.entries(page);
+ this.assertPage(entries);
+ const before = this.firstCursor;
+ const accepted =
+ before === undefined
+ ? [...entries]
+ : entries.filter((entry) => this.options.compare(this.options.first(entry), before) < 0);
+ const tail = accepted.at(-1);
+ if (
+ tail !== undefined &&
+ before !== undefined &&
+ !this.options.follows(this.options.last(tail), before)
+ ) {
+ this.options.publish({ type: "prepend", page, entries: [], hasMore: false });
+ throw protocolViolation(`${this.options.name} history page is discontinuous`);
+ }
+ const first = accepted[0];
+ if (first !== undefined) this.firstCursor = this.options.first(first);
+ this.options.publish({
+ type: "prepend",
+ page,
+ entries: accepted,
+ hasMore: this.options.hasMore(page),
+ });
+ }
+
+ /** Replace the active physical generation while retaining the published window. */
+ restart(): void {
+ this.stream.restart();
+ }
+
+ /**
+ * Permanently stop follow, page requests, and the background consumer.
+ * @returns when no stream work or publication callback can still run.
+ */
+ dispose(): Promise {
+ if (this.closing !== undefined) return this.closing;
+ this.disposed = true;
+ const done = this.done;
+ const closing = (async () => {
+ await this.stream.dispose();
+ await done;
+ })();
+ this.closing = closing;
+ return closing;
+ }
+
+ private async consume(
+ iterator: AsyncIterator>,
+ ): Promise {
+ try {
+ while (true) {
+ const next = await this.takeNext(iterator);
+ if (next.done) return;
+ const item = next.value;
+ if (item.generation !== this.generation) {
+ this.replaceGeneration(item, true);
+ continue;
+ }
+ if (item.value.type === "opened") {
+ throw protocolViolation(`${this.options.name} emitted more than one opening cursor`);
+ }
+ if (item.value.type === "notification") {
+ this.publishNotification(item.value.notification);
+ continue;
+ }
+ await this.acceptEntry(item.value.entry, item, iterator);
+ }
+ } catch (error) {
+ if (!this.disposed) this.options.failed(error);
+ }
+ }
+
+ private replaceGeneration(
+ initial: JournalStreamItem,
+ resumed: boolean,
+ ): void {
+ const opening = this.opening(initial, resumed);
+ this.replaceFromOpening(opening.page, opening.cursor);
+ }
+
+ private opening(
+ item: RemoteStreamItem>,
+ resumed: boolean,
+ ): { readonly cursor: Cursor; readonly page: Page } {
+ if (item.value.type !== "opened") {
+ throw protocolViolation(
+ `${resumed ? "resumed " : ""}${this.options.name} emitted an entry before its opening cursor`,
+ );
+ }
+ const cursor = item.value.cursor;
+ if (
+ resumed &&
+ this.lastCursor !== undefined &&
+ this.options.compare(cursor, this.lastCursor) < 0
+ ) {
+ throw protocolViolation(
+ `${this.options.name} resumed at a cursor behind the last applied entry`,
+ );
+ }
+ this.generation = item.generation;
+ item.accept();
+ return { cursor, page: item.value.page };
+ }
+
+ /** Publish a generation's opening page without issuing a second Remote call. */
+ private replaceFromOpening(page: Page, cursor: Cursor): void {
+ this.assertPageThrough(page, cursor);
+ const entries = [...this.options.entries(page)];
+ this.assertPage(entries);
+ const first = entries[0];
+ this.firstCursor = first === undefined ? undefined : this.options.first(first);
+ this.lastCursor = cursor;
+ this.setResumeCursor(cursor);
+ this.options.publish({
+ type: "replace",
+ page,
+ entries,
+ hasMore: this.options.hasMore(page),
+ });
+ }
+
+ private async acceptEntry(
+ entry: Entry,
+ item: JournalStreamItem,
+ iterator: AsyncIterator>,
+ ): Promise {
+ const { first, last: cursor } = this.entryRange(entry);
+ const last = this.lastCursor as Cursor;
+ if (this.options.compare(cursor, last) <= 0) return;
+ if (this.options.compare(first, last) <= 0) {
+ throw protocolViolation(`${this.options.name} emitted a partially overlapping entry`);
+ }
+ if (!this.options.follows(last, first)) {
+ const request = this.repairPageRequest();
+ const superseded = await this.replaceThrough(
+ request,
+ cursor,
+ item.generation,
+ item.signal,
+ iterator,
+ [entry],
+ [],
+ );
+ if (superseded !== undefined) {
+ this.replaceGeneration(superseded, true);
+ }
+ return;
+ }
+ if (this.firstCursor === undefined) this.firstCursor = first;
+ this.lastCursor = cursor;
+ this.setResumeCursor(cursor);
+ this.options.publish({ type: "append", entry });
+ }
+
+ private async replaceThrough(
+ request: PageRequest,
+ requiredCursor: Cursor,
+ generation: number,
+ signal: AbortSignal,
+ iterator: AsyncIterator>,
+ queued: Entry[],
+ notifications: Notification[],
+ ): Promise | undefined> {
+ let read = await this.readPageWhileFollowing(
+ request,
+ requiredCursor,
+ generation,
+ signal,
+ iterator,
+ queued,
+ notifications,
+ );
+ if (read.type === "superseded") return read.item;
+ let page = read.page;
+ this.assertPageThrough(page, requiredCursor);
+ let entries = this.mergeReplacement(page, queued);
+ let target = this.maxCursor(requiredCursor, queued);
+ if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) {
+ read = await this.readPageWhileFollowing(
+ this.repairPageRequest(),
+ target,
+ generation,
+ signal,
+ iterator,
+ queued,
+ notifications,
+ );
+ if (read.type === "superseded") return read.item;
+ page = read.page;
+ this.assertPageThrough(page, target);
+ entries = this.mergeReplacement(page, queued);
+ target = this.maxCursor(requiredCursor, queued);
+ }
+ if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) {
+ throw protocolViolation(`${this.options.name} page did not reach its opening cursor`);
+ }
+ const first = entries[0];
+ /* v8 ignore next -- a successful positive-cursor replacement page cannot be empty. */
+ this.firstCursor = first === undefined ? undefined : this.options.first(first);
+ this.lastCursor = this.tailCursor(entries);
+ this.setResumeCursor(this.lastCursor);
+ this.options.publish({
+ type: "replace",
+ page,
+ entries,
+ hasMore: this.options.hasMore(page),
+ });
+ for (const notification of notifications) {
+ this.publishNotification(notification);
+ }
+ return undefined;
+ }
+
+ private async readPageWhileFollowing(
+ request: PageRequest,
+ through: Cursor,
+ generation: number,
+ signal: AbortSignal,
+ iterator: AsyncIterator>,
+ queued: Entry[],
+ notifications: Notification[],
+ ): Promise<
+ | { readonly type: "page"; readonly page: Page }
+ | {
+ readonly type: "superseded";
+ readonly item: JournalStreamItem;
+ }
+ > {
+ const page = this.readPage(request, through, signal).then(
+ (value) => ({ type: "page" as const, value }),
+ (error: unknown) => ({ type: "page-error" as const, error }),
+ );
+ while (true) {
+ const pending = this.nextResult(iterator);
+ const next = pending.then(
+ (value) => ({ type: "next" as const, value }),
+ (error: unknown) => ({ type: "next-error" as const, error }),
+ );
+ const result = await Promise.race([page, next]);
+ if (result.type === "page") {
+ signal.throwIfAborted();
+ return { type: "page", page: result.value };
+ }
+ if (result.type === "page-error") {
+ if (!signal.aborted || this.stream.signal.aborted) throw result.error;
+ return this.awaitReplacementGeneration(generation, iterator, pending);
+ }
+ this.releaseNext();
+ if (result.type === "next-error") throw result.error;
+ if (result.value.done) {
+ signal.throwIfAborted();
+ throw protocolViolation(`${this.options.name} ended while reading its replacement page`);
+ }
+ const item = result.value.value;
+ if (item.generation !== generation) return { type: "superseded", item };
+ if (item.value.type === "opened") {
+ throw protocolViolation(`${this.options.name} emitted more than one opening cursor`);
+ }
+ if (item.value.type === "notification") {
+ notifications.push(item.value.notification);
+ continue;
+ }
+ queued.push(item.value.entry);
+ }
+ }
+
+ private async awaitReplacementGeneration(
+ generation: number,
+ iterator: AsyncIterator>,
+ initial: Promise>>,
+ ): Promise<{
+ readonly type: "superseded";
+ readonly item: JournalStreamItem;
+ }> {
+ let pending = initial;
+ while (true) {
+ let next: IteratorResult>;
+ try {
+ next = await pending;
+ } finally {
+ this.releaseNext();
+ }
+ if (next.done) {
+ this.stream.signal.throwIfAborted();
+ throw protocolViolation(
+ `${this.options.name} ended while replacing an aborted page generation`,
+ );
+ }
+ const item = next.value;
+ if (item.generation !== generation) return { type: "superseded", item };
+ if (item.value.type === "opened") {
+ throw protocolViolation(`${this.options.name} emitted more than one opening cursor`);
+ }
+ pending = this.nextResult(iterator);
+ }
+ }
+
+ private mergeReplacement(page: Page, queued: readonly Entry[]): Entry[] | undefined {
+ const entries = [...this.options.entries(page)];
+ this.assertPage(entries);
+ for (const entry of queued) this.entryRange(entry);
+ const sorted = [...queued].sort((left, right) =>
+ this.options.compare(this.options.first(left), this.options.first(right)),
+ );
+ let tail = this.tailCursor(entries);
+ for (const entry of sorted) {
+ const first = this.options.first(entry);
+ const last = this.options.last(entry);
+ if (this.options.compare(last, tail) <= 0) continue;
+ if (this.options.compare(first, tail) <= 0) {
+ throw protocolViolation(
+ `${this.options.name} replacement contains a partially overlapping entry`,
+ );
+ }
+ if (!this.options.follows(tail, first)) return undefined;
+ entries.push(entry);
+ tail = last;
+ }
+ return entries;
+ }
+
+ private maxCursor(cursor: Cursor, entries: readonly Entry[]): Cursor {
+ let result = cursor;
+ for (const entry of entries) {
+ const candidate = this.options.last(entry);
+ if (this.options.compare(candidate, result) > 0) result = candidate;
+ }
+ return result;
+ }
+
+ private nextResult(
+ iterator: AsyncIterator>,
+ ): Promise>> {
+ this.pendingNext ??= iterator.next();
+ return this.pendingNext;
+ }
+
+ private async takeNext(
+ iterator: AsyncIterator>,
+ ): Promise>> {
+ const pending = this.nextResult(iterator);
+ try {
+ return await pending;
+ } finally {
+ this.releaseNext();
+ }
+ }
+
+ private releaseNext(): void {
+ this.pendingNext = undefined;
+ }
+
+ private publishNotification(notification: Notification): void {
+ this.options.publish({
+ type: "notification",
+ notification,
+ } as RemoteJournalChange);
+ }
+
+ private repairPageRequest(): PageRequest {
+ return this.repairRequest(this.initialRequest);
+ }
+
+ private setResumeCursor(cursor: Cursor): void {
+ this.resumeCursor = cursor;
+ this.hasResumeCursor = true;
+ }
+
+ private currentCursor(): Cursor {
+ return this.resumeCursor as Cursor;
+ }
+
+ private tailCursor(entries: readonly Entry[]): Cursor {
+ const tail = entries.at(-1);
+ return tail === undefined ? this.options.emptyCursor : this.options.last(tail);
+ }
+
+ private assertPage(entries: readonly Entry[]): void {
+ const iterator = entries[Symbol.iterator]();
+ const first = iterator.next();
+ if (first.done) return;
+ let previousRange = this.entryRange(first.value);
+ for (const entry of iterator) {
+ const range = this.entryRange(entry);
+ if (!this.options.follows(previousRange.last, range.first)) {
+ throw protocolViolation(`${this.options.name} page contains discontinuous entries`);
+ }
+ previousRange = range;
+ }
+ }
+
+ private entryRange(entry: Entry): { readonly first: Cursor; readonly last: Cursor } {
+ const first = this.options.first(entry);
+ const last = this.options.last(entry);
+ if (this.options.compare(first, last) > 0) {
+ throw protocolViolation(`${this.options.name} entry has an inverted cursor range`);
+ }
+ return { first, last };
+ }
+
+ private assertPageThrough(page: Page, through: Cursor): void {
+ const tail = this.tailCursor(this.options.entries(page));
+ if (this.options.compare(tail, through) !== 0) {
+ throw protocolViolation(`${this.options.name} page did not end at its requested cursor`);
+ }
+ }
+}
diff --git a/apps/web-ui/packages/api/gateway/src/client/remote-events.ts b/apps/web-ui/packages/api/gateway/src/client/remote-events.ts
new file mode 100644
index 000000000..7d6cc1d52
--- /dev/null
+++ b/apps/web-ui/packages/api/gateway/src/client/remote-events.ts
@@ -0,0 +1,354 @@
+/** Client owner for forwarded Remote Event subscriptions and deliveries. */
+
+import type { Context } from "@deepseek-ai/cordis";
+import type {
+ ConnectionGenerationSource,
+ ConnectionHostInfo,
+ ConnectionHandle,
+} from "@deepseek-ai/dsh-client-connection/client";
+import type {
+ TypertClientEventListener,
+ TypertOwnedValue,
+ TypertRemoteEvent,
+} from "@deepseek-ai/dsh-typert-protocol";
+import { isRemoteJsonValue, isTypertOwnedValue } from "@deepseek-ai/dsh-typert-protocol";
+import { randomUUID } from "@deepseek-ai/dsh-util-crypto";
+import {
+ REMOTE_EVENT_RESULT_ENDPOINT,
+ REMOTE_EVENT_STREAM_ENDPOINT,
+ REMOTE_EVENT_STREAM_PAYLOAD,
+ isRemoteEventAgentId,
+ isRemoteEventClientId,
+ isRemoteEventId,
+ projectRemoteEventRejection,
+ type RemoteEventClientId,
+ type RemoteEventDownlinkFrame,
+ type RemoteEventEmitFrame,
+ type RemoteEventInvocationFrame,
+ type RemoteEventResult,
+} from "../stream-protocol.ts";
+
+/** Open the Gateway-internal forwarded-event stream on the selected carrier. */
+export type RemoteEventStreamOpener = (
+ endpoint: string,
+ payload: unknown,
+ signal: AbortSignal,
+) => AsyncIterable;
+
+/** One subscribed listener after its event-specific signature is erased. */
+type RemoteEventListener = (this: Context, ...args: unknown[]) => unknown;
+
+/** Untyped access used only for instance-private Cordis event keys. */
+interface PrivateEventContext {
+ on(name: string, listener: RemoteEventListener): () => boolean;
+ parallel(name: string, ...args: unknown[]): Promise;
+ waterfall(
+ thisArg: Context,
+ name: string,
+ request: Readonly>,
+ next: () => Promise,
+ ): unknown;
+}
+
+/** Transport outcome after one Client listener chain either claims or delegates. */
+type RemoteEventReplyOutcome =
+ | { readonly kind: "result"; readonly value: unknown }
+ | { readonly kind: "next" }
+ | { readonly kind: "rejected"; readonly error: ReturnType };
+
+/** Private end-of-chain marker that cannot collide with a JSON listener result. */
+const REMOTE_EVENT_NEXT = Symbol("api-gateway.remote-event.next");
+
+/** Own Cordis registrations, generation pumping, waterfall dispatch, and HTTP replies. */
+export class ClientRemoteEvents {
+ private readonly eventPrefix = `internal/api-gateway/remote-event/${randomUUID()}/`;
+ private readonly unregisterGeneration: () => void;
+ private activeGeneration: Promise | undefined;
+
+ /**
+ * @param ownerCtx - Client Gateway root used for Agent Context resolution.
+ * @param connection - Connection carrier used for HTTP result calls.
+ * @param openStream - selected in-process or WebSocket stream opener.
+ */
+ constructor(
+ private readonly ownerCtx: Context,
+ private readonly connection: ConnectionHandle,
+ private readonly openStream: RemoteEventStreamOpener,
+ ) {
+ this.unregisterGeneration = connection.registerGenerationSource(this.runGeneration);
+ }
+
+ /**
+ * Register one typed Remote Event listener in its calling fiber.
+ * @param callerCtx - fiber Context owning the registration.
+ * @param event - selected forwarded event.
+ * @param listener - listener derived from that event's declaration.
+ * @returns disposer for this exact registration.
+ */
+ subscribe(
+ callerCtx: Context,
+ event: Event,
+ listener: TypertClientEventListener,
+ ): () => void {
+ const dispose = privateEvents(callerCtx).on(
+ this.eventKey(event),
+ listener as RemoteEventListener,
+ );
+ return () => {
+ dispose();
+ };
+ }
+
+ /** Withdraw the generation source and wait for active listener work to quiesce. */
+ async dispose(): Promise {
+ this.unregisterGeneration();
+ await Promise.allSettled([this.activeGeneration]);
+ }
+
+ /** Track the current generation so plugin disposal waits for listener work to stop. */
+ private readonly runGeneration: ConnectionGenerationSource = (signal, ready) => {
+ const tracked = this.pumpEvents(signal, ready).finally(() => {
+ if (this.activeGeneration === tracked) this.activeGeneration = undefined;
+ });
+ this.activeGeneration = tracked;
+ return tracked;
+ };
+
+ /** Deliver one notification through Cordis while containing listener failures. */
+ private deliver(frame: RemoteEventEmitFrame): void {
+ void privateEvents(this.ownerCtx)
+ .parallel(this.eventKey(frame.event), ...frame.args)
+ .catch((error: unknown) => {
+ this.reportError(frame.event, error);
+ });
+ }
+
+ /** Run one Connection generation over the forwarded-event logical stream. */
+ private async pumpEvents(
+ signal: AbortSignal,
+ ready: (host: ConnectionHostInfo) => void,
+ ): Promise {
+ let clientId: RemoteEventClientId | undefined;
+ const failed = new AbortController();
+ const generationSignal = AbortSignal.any([signal, failed.signal]);
+ const active = new Map();
+ const tasks = new Set>();
+ const source = this.openStream(
+ REMOTE_EVENT_STREAM_ENDPOINT,
+ REMOTE_EVENT_STREAM_PAYLOAD,
+ generationSignal,
+ );
+ let streamFailed = false;
+ let streamError: unknown;
+ try {
+ for await (const value of source) {
+ if (clientId === undefined) {
+ const opening = parseRemoteEventReady(value);
+ clientId = opening.clientId;
+ ready(opening.host);
+ continue;
+ }
+ const frame = parseRemoteEventFrame(value);
+ if (frame.type === "cancel") {
+ active
+ .get(frame.eventId)
+ ?.abort(new Error("client api: Remote event was cancelled by the Host"));
+ continue;
+ }
+ if (frame.type === "emit") {
+ this.deliver(frame);
+ continue;
+ }
+ const controller = new AbortController();
+ active.set(frame.eventId, controller);
+ const deliverySignal = AbortSignal.any([generationSignal, controller.signal]);
+ const task = this.answer(frame, clientId, deliverySignal)
+ .catch((error: unknown) => {
+ if (!deliverySignal.aborted) failed.abort(error);
+ })
+ .finally(() => {
+ active.delete(frame.eventId);
+ tasks.delete(task);
+ });
+ tasks.add(task);
+ }
+ } catch (error) {
+ streamFailed = true;
+ streamError = error;
+ } finally {
+ for (const controller of active.values()) {
+ controller.abort(new Error("client api: Remote event generation ended"));
+ }
+ await Promise.allSettled(tasks);
+ }
+ if (failed.signal.aborted) {
+ throw toError(failed.signal.reason, "client api: Remote event result delivery failed");
+ }
+ if (signal.aborted) return;
+ if (streamFailed) throw streamError;
+ throw new Error("client api: forwarded Remote event stream ended unexpectedly");
+ }
+
+ private async answer(
+ frame: RemoteEventInvocationFrame,
+ clientId: RemoteEventClientId,
+ signal: AbortSignal,
+ ): Promise {
+ const adapter = this.ownerCtx.typert.contexts.getClient("agent");
+ let resolved: Context | TypertOwnedValue | undefined;
+ try {
+ resolved = adapter?.resolve(frame.agentId);
+ } catch (error) {
+ this.reportError(frame.event, error);
+ }
+ const owned = isTypertOwnedValue(resolved) ? resolved : undefined;
+ try {
+ const target = isTypertOwnedValue(resolved) ? resolved.value : resolved;
+ let outcome: RemoteEventReplyOutcome = { kind: "next" };
+ if (target !== undefined) {
+ try {
+ outcome = await this.dispatchWaterfall(target, frame, signal);
+ } catch (error) {
+ if (signal.aborted) return;
+ outcome = { kind: "rejected", error: projectRemoteEventRejection(error) };
+ }
+ }
+ if (signal.aborted) return;
+ const result: RemoteEventResult = {
+ clientId,
+ eventId: frame.eventId,
+ outcome:
+ outcome.kind === "result" && outcome.value === undefined ? { kind: "result" } : outcome,
+ };
+ const response = await this.connection.rpc.call(
+ "/api",
+ REMOTE_EVENT_RESULT_ENDPOINT,
+ { args: result },
+ signal,
+ );
+ if (!response.ok) throw new Error(response.error.message);
+ } finally {
+ owned?.[Symbol.dispose]();
+ }
+ }
+
+ private async dispatchWaterfall(
+ target: Context,
+ frame: RemoteEventInvocationFrame,
+ signal: AbortSignal,
+ ): Promise {
+ const request = {
+ ...frame.request,
+ agent: target,
+ signal,
+ };
+ // Cancellation reaches the handler, whose Context remains owned until it settles.
+ const value = await privateEvents(target).waterfall(
+ target,
+ this.eventKey(frame.event),
+ request,
+ () => Promise.resolve(REMOTE_EVENT_NEXT),
+ );
+ if (value !== REMOTE_EVENT_NEXT && value !== undefined && !isRemoteJsonValue(value)) {
+ throw new TypeError("Remote event listener result is not lossless JSON data");
+ }
+ return value === REMOTE_EVENT_NEXT ? { kind: "next" } : { kind: "result", value };
+ }
+
+ private eventKey(event: string): string {
+ return `${this.eventPrefix}${event}`;
+ }
+
+ private reportError(event: string, error: unknown): void {
+ console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error);
+ }
+}
+
+/** Validate and return one generation's Client identity and Host facts. */
+function parseRemoteEventReady(value: unknown): {
+ readonly clientId: RemoteEventClientId;
+ readonly host: ConnectionHostInfo;
+} {
+ if (
+ !isRemoteEventRecord(value) ||
+ !hasExactRemoteEventKeys(value, ["type", "clientId", "host"]) ||
+ value.type !== "ready" ||
+ !isRemoteEventClientId(value.clientId) ||
+ !isRemoteEventRecord(value.host) ||
+ !hasExactRemoteEventKeys(value.host, ["home"]) ||
+ typeof value.host.home !== "string"
+ ) {
+ throw new TypeError("client api: forwarded Remote event stream did not begin with ready");
+ }
+ return { clientId: value.clientId, host: { home: value.host.home } };
+}
+
+/** Validate one untrusted value from the Gateway-internal forwarded-event stream. */
+function parseRemoteEventFrame(
+ value: unknown,
+): Exclude {
+ if (!isRemoteEventRecord(value)) invalidRemoteEventFrame();
+ if (
+ value.type === "cancel" &&
+ hasExactRemoteEventKeys(value, ["type", "eventId"]) &&
+ isRemoteEventId(value.eventId)
+ ) {
+ return { type: "cancel", eventId: value.eventId };
+ }
+ if (
+ value.type === "emit" &&
+ hasExactRemoteEventKeys(value, ["type", "event", "args"]) &&
+ validRemoteEventName(value.event) &&
+ Array.isArray(value.args) &&
+ isRemoteJsonValue(value.args)
+ ) {
+ return { type: "emit", event: value.event, args: value.args };
+ }
+ if (
+ value.type === "waterfall" &&
+ hasExactRemoteEventKeys(value, ["type", "event", "eventId", "agentId", "request"]) &&
+ validRemoteEventName(value.event) &&
+ isRemoteEventId(value.eventId) &&
+ isRemoteEventAgentId(value.agentId) &&
+ isRemoteEventRecord(value.request) &&
+ !Object.hasOwn(value.request, "agent") &&
+ !Object.hasOwn(value.request, "signal") &&
+ isRemoteJsonValue(value.request)
+ ) {
+ return {
+ type: "waterfall",
+ event: value.event,
+ eventId: value.eventId,
+ agentId: value.agentId,
+ request: value.request,
+ };
+ }
+ invalidRemoteEventFrame();
+}
+
+function isRemoteEventRecord(value: unknown): value is Record {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
+ const prototype: unknown = Object.getPrototypeOf(value);
+ return prototype === Object.prototype || prototype === null;
+}
+
+function hasExactRemoteEventKeys(value: Record, keys: readonly string[]): boolean {
+ const ownKeys = Reflect.ownKeys(value);
+ return ownKeys.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
+}
+
+function validRemoteEventName(value: unknown): value is string {
+ return typeof value === "string" && value.length > 0;
+}
+
+function invalidRemoteEventFrame(): never {
+ throw new TypeError("client api: invalid forwarded Remote event frame");
+}
+
+function privateEvents(ctx: Context): PrivateEventContext {
+ return ctx;
+}
+
+function toError(reason: unknown, message: string): Error {
+ return reason instanceof Error ? reason : new Error(message, { cause: reason });
+}
diff --git a/apps/web-ui/packages/api/gateway/src/client/remote-stream.ts b/apps/web-ui/packages/api/gateway/src/client/remote-stream.ts
new file mode 100644
index 000000000..d1783f5ba
--- /dev/null
+++ b/apps/web-ui/packages/api/gateway/src/client/remote-stream.ts
@@ -0,0 +1,247 @@
+/** Reconnecting lifecycle for one single-consumer Remote stream. */
+
+import { RemoteError, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol";
+import type { ConnectionHandle } from "@deepseek-ai/dsh-client-connection/client";
+import { RemoteStreamCarrierError } from "./stream-client.ts";
+
+/** One item annotated with the physical Remote-stream generation that delivered it. */
+export interface RemoteStreamItem
- {
+ /** Monotone physical generation number within this logical stream. */
+ readonly generation: number;
+ /** Decoded item yielded by the generated Remote method. */
+ readonly value: Item;
+ /** Cancellation lifetime of the generation that delivered this item. */
+ readonly signal: AbortSignal;
+ /** Mark this generation's opening baseline or cursor as accepted. */
+ accept(): void;
+}
+
+/** Domain-owned operations used by {@link RemoteStream}. */
+export interface RemoteStreamOptions
- {
+ /** Diagnostic owner name used for cancellation failures. */
+ readonly name: string;
+ /** Open one physical generation of the logical stream. */
+ readonly open: (signal: AbortSignal) => AsyncIterable
- ;
+ /** Classify a normal generation end after or before its opening item was accepted. */
+ readonly ended: (accepted: boolean) => Error;
+ /** Observe a retryable carrier loss before the supervisor waits or reopens. */
+ readonly carrierFailed?: (error: RemoteStreamCarrierError) => void;
+}
+
+/**
+ * Reopens one logical Remote stream across carrier generations.
+ *
+ * Connection owns physical retry timing; Gateway performs each requested
+ * replacement. The domain consumer owns its opening item and every later
+ * item, and calls {@link RemoteStreamItem.accept} only after validating the
+ * opening baseline or cursor.
+ */
+export class RemoteStream
- implements AsyncIterable> {
+ private readonly lifetime = new AbortController();
+ private generationAbort: AbortController | undefined;
+ private iterator: AsyncGenerator> | undefined;
+ private closing: Promise | undefined;
+ private revision = 0;
+ private taken = false;
+
+ /**
+ * @param connection - observable Host generation source used to pace retries.
+ * @param options - domain stream opener, end classification, and diagnostics.
+ */
+ constructor(
+ private readonly connection: Pick,
+ private readonly options: RemoteStreamOptions
- ,
+ ) {}
+
+ /** Cancellation lifetime shared by the stream and sibling page requests. */
+ get signal(): AbortSignal {
+ return this.lifetime.signal;
+ }
+
+ /** Interrupt the current generation and immediately request a replacement. */
+ restart(): void {
+ if (this.lifetime.signal.aborted) return;
+ this.revision++;
+ this.generationAbort?.abort(new Error(`${this.options.name} generation restarted`));
+ }
+
+ /**
+ * Permanently stop this stream and wait for its iterator to close.
+ * @returns when the active generation and consumer iterator are quiescent.
+ */
+ dispose(): Promise {
+ if (this.closing !== undefined) return this.closing;
+ if (!this.lifetime.signal.aborted) {
+ const reason = new Error(`${this.options.name} disposed`);
+ this.lifetime.abort(reason);
+ this.generationAbort?.abort(reason);
+ }
+ const iterator = this.iterator;
+ if (iterator === undefined) return Promise.resolve();
+ const closing = closeRemoteStreamIterator(iterator);
+ this.closing = closing;
+ return closing;
+ }
+
+ /** @inheritdoc */
+ [Symbol.asyncIterator](): AsyncIterator> {
+ if (this.taken) throw new Error(`${this.options.name} already has a consumer`);
+ this.taken = true;
+ const iterator = this.read();
+ this.iterator = iterator;
+ return iterator;
+ }
+
+ private async *read(): AsyncGenerator> {
+ let attempt = 0;
+ let generation = 0;
+ let observedRevision = this.revision;
+ try {
+ while (!isAborted(this.lifetime.signal)) {
+ if (observedRevision !== this.revision) {
+ observedRevision = this.revision;
+ attempt = 0;
+ }
+ const revision = this.revision;
+ const generationAbort = new AbortController();
+ this.generationAbort = generationAbort;
+ const signal = AbortSignal.any([this.lifetime.signal, generationAbort.signal]);
+ const generationId = ++generation;
+ let accepted = false;
+ try {
+ for await (const value of this.options.open(signal)) {
+ if (isAborted(this.lifetime.signal)) return;
+ if (revision !== this.revision) break;
+ yield {
+ generation: generationId,
+ value,
+ signal,
+ accept: () => {
+ if (this.generationAbort !== generationAbort || revision !== this.revision) return;
+ accepted = true;
+ attempt = 0;
+ },
+ };
+ }
+ if (isAborted(this.lifetime.signal)) return;
+ if (revision !== this.revision) continue;
+ throw this.options.ended(accepted);
+ } catch (error) {
+ if (isAborted(this.lifetime.signal)) return;
+ if (revision !== this.revision) continue;
+ if (!(error instanceof RemoteStreamCarrierError)) throw terminalStreamFailure(error);
+ this.options.carrierFailed?.(error);
+ if (revision !== this.revision) continue;
+ attempt++;
+ try {
+ await waitForRemoteStreamRetry(this.connection, attempt, signal);
+ } catch (retryError) {
+ if (isAborted(this.lifetime.signal)) return;
+ if (revision !== this.revision) continue;
+ throw terminalStreamFailure(retryError);
+ }
+ } finally {
+ this.generationAbort = undefined;
+ if (!generationAbort.signal.aborted) {
+ generationAbort.abort(new Error(`${this.options.name} generation ended`));
+ }
+ }
+ }
+ } finally {
+ if (!this.lifetime.signal.aborted) {
+ this.lifetime.abort(new Error(`${this.options.name} consumer closed`));
+ }
+ this.generationAbort?.abort(this.lifetime.signal.reason);
+ this.generationAbort = undefined;
+ }
+ }
+}
+
+async function waitForRemoteStreamRetry(
+ connection: Pick,
+ attempt: number,
+ signal: AbortSignal,
+): Promise {
+ signal.throwIfAborted();
+ if (connection.generation.getSnapshot() !== undefined) {
+ // The mux can fail before the event stream withdraws the Host generation,
+ // or while that generation remains healthy. Neither makes a carrier error
+ // terminal. Back off instead of abandoning the only projection consumer.
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => {
+ signal.removeEventListener("abort", aborted);
+ resolve();
+ },
+ Math.min(100 * 2 ** Math.min(attempt - 1, 5), 3000),
+ );
+ const aborted = (): void => {
+ clearTimeout(timer);
+ signal.removeEventListener("abort", aborted);
+ reject(signal.reason);
+ };
+ signal.addEventListener("abort", aborted, { once: true });
+ if (signal.aborted) aborted();
+ });
+ return;
+ }
+ await new Promise((resolve, reject) => {
+ const subscription: {
+ dispose?: () => void;
+ finished: boolean;
+ } = { finished: false };
+ const finish = (failure?: Error): void => {
+ if (subscription.finished) return;
+ subscription.finished = true;
+ subscription.dispose?.();
+ signal.removeEventListener("abort", aborted);
+ if (failure === undefined) resolve();
+ else reject(failure);
+ };
+ const inspect = (): void => {
+ if (connection.generation.getSnapshot() !== undefined) finish();
+ };
+ const aborted = (): void => {
+ finish(new Error("Remote stream retry aborted", { cause: signal.reason }));
+ };
+ const dispose = connection.generation.subscribe(inspect);
+ subscription.dispose = dispose;
+ if (subscription.finished) dispose();
+ signal.addEventListener("abort", aborted, { once: true });
+ if (signal.aborted) aborted();
+ else inspect();
+ });
+}
+
+/**
+ * Mark a terminal escape before it crosses the stream boundary: consumers
+ * discriminate failures by code, so an unmarked throw reads as a local bug.
+ * Marked failures pass through verbatim. The carrier class never escapes as a
+ * terminal outcome — it stays the retry-internal signal fed to `carrierFailed`
+ * and the `ended(true)` retry trigger.
+ */
+function terminalStreamFailure(error: unknown): Error {
+ return (
+ remoteErrorOf(error) ??
+ new RemoteError(
+ "gateway/internal",
+ error instanceof Error ? error.message : String(error),
+ {},
+ { cause: error },
+ )
+ );
+}
+
+function isAborted(signal: AbortSignal): boolean {
+ return signal.aborted;
+}
+
+async function closeRemoteStreamIterator
- (
+ iterator: AsyncIterator>,
+): Promise {
+ try {
+ await iterator.return?.();
+ } catch {
+ // The disposed logical stream has no remaining consumer for cancellation failures.
+ }
+}
diff --git a/apps/web-ui/packages/api/gateway/src/client/snapshot-stream.ts b/apps/web-ui/packages/api/gateway/src/client/snapshot-stream.ts
new file mode 100644
index 000000000..764a4eb79
--- /dev/null
+++ b/apps/web-ui/packages/api/gateway/src/client/snapshot-stream.ts
@@ -0,0 +1,96 @@
+/** Baseline-and-delta protocol layered over a reconnecting Remote stream. */
+
+import { RemoteError } from "@deepseek-ai/dsh-typert-protocol";
+import type { RemoteStream } from "./remote-stream.ts";
+
+/** Host-side stream protocol violation, marked so consumers surface it as an error state. */
+function protocolViolation(message: string): RemoteError<"gateway/internal"> {
+ return new RemoteError("gateway/internal", message, {});
+}
+
+/** Domain operations for one snapshot stream. */
+export interface RemoteSnapshotStreamOptions {
+ /** Diagnostic stream name used in protocol failures. */
+ readonly name: string;
+ /** Distinguish the opening snapshot from later deltas. */
+ readonly isSnapshot: (value: Snapshot | Delta) => value is Snapshot;
+ /** Atomically replace the domain model from a complete snapshot. */
+ readonly replace: (snapshot: Snapshot) => void;
+ /** Apply one incremental update after the generation snapshot. */
+ readonly update: (delta: Delta) => void;
+ /** Publish a terminal business or protocol failure. */
+ readonly failed: (error: unknown) => void;
+}
+
+/**
+ * Consumes generations that each contain exactly one opening snapshot followed by deltas.
+ *
+ * The previous domain snapshot remains published while the underlying stream retries. A
+ * replacement becomes accepted only after the domain owner applies it successfully.
+ */
+export class RemoteSnapshotStream {
+ private started = false;
+ private disposed = false;
+ private done: Promise | undefined;
+
+ /**
+ * @param stream - reconnecting physical-generation stream.
+ * @param options - frame discriminator and domain state destinations.
+ */
+ constructor(
+ private readonly stream: RemoteStream,
+ private readonly options: RemoteSnapshotStreamOptions,
+ ) {}
+
+ /** Start the single consumer; repeated calls are inert. */
+ start(): void {
+ if (this.started) return;
+ this.started = true;
+ this.done = this.consume();
+ }
+
+ /** Replace the active physical generation without discarding the published snapshot. */
+ restart(): void {
+ this.stream.restart();
+ }
+
+ /**
+ * Permanently stop the stream and wait for its consumer to become quiescent.
+ * @returns when no generation or callback can still run.
+ */
+ async dispose(): Promise {
+ this.disposed = true;
+ await this.stream.dispose();
+ await this.done;
+ }
+
+ private async consume(): Promise {
+ let generation = 0;
+ let snapshotSeen = false;
+ try {
+ for await (const item of this.stream) {
+ if (item.generation !== generation) {
+ generation = item.generation;
+ snapshotSeen = false;
+ }
+ if (this.options.isSnapshot(item.value)) {
+ if (snapshotSeen) {
+ throw protocolViolation(`${this.options.name} emitted more than one opening snapshot`);
+ }
+ this.options.replace(item.value);
+ snapshotSeen = true;
+ item.accept();
+ continue;
+ }
+ if (!snapshotSeen) {
+ throw protocolViolation(
+ `${this.options.name} emitted an update before its opening snapshot`,
+ );
+ }
+ this.options.update(item.value);
+ }
+ } catch (error) {
+ if (!this.disposed) this.options.failed(error);
+ }
+ }
+}
diff --git a/apps/web-ui/packages/api/gateway/src/client/stream-client.ts b/apps/web-ui/packages/api/gateway/src/client/stream-client.ts
new file mode 100644
index 000000000..50ee9eca4
--- /dev/null
+++ b/apps/web-ui/packages/api/gateway/src/client/stream-client.ts
@@ -0,0 +1,515 @@
+import { RemoteError } from "@deepseek-ai/dsh-typert-protocol";
+/** Browser owner for the Gateway multiplexed Remote stream socket. */
+
+import {
+ parseRemoteStreamServerMessage,
+ REMOTE_STREAM_MUX_PATH,
+ type RemoteStreamClientMessage,
+ type RemoteStreamServerMessage,
+} from "../stream-protocol.ts";
+import { Deque } from "@deepseek-ai/dsh-deque";
+import { randomUUID } from "@deepseek-ai/dsh-util-crypto";
+
+/** Physical Remote stream socket failure that may be retried by a domain transport. */
+export class RemoteStreamCarrierError extends Error {
+ /**
+ * @param message - physical carrier failure description.
+ * @param options - optional causal error.
+ */
+ constructor(message: string, options?: ErrorOptions) {
+ super(message, options);
+ this.name = "RemoteStreamCarrierError";
+ }
+}
+
+interface SocketWaiter {
+ readonly revision: number;
+ resolve(socket: WebSocket): void;
+ reject(error: unknown): void;
+}
+
+interface UplinkPump {
+ /** Settles once the pump has stopped sending. */
+ readonly done: Promise;
+ /** Interrupt the pump, including a read blocked on the caller's iterator, and release the iterator. */
+ stop(): void;
+}
+
+/** One open logical stream: its downlink frames and, once the `open` frame is out, its uplink pump. */
+interface LogicalStream {
+ readonly inbox: StreamInbox;
+ pump: UplinkPump | undefined;
+}
+
+const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true };
+
+/**
+ * Keep one physical WebSocket and share it among independently cancellable
+ * Remote streams. A carrier that supplies an in-process stream opener never
+ * starts one.
+ */
+export class RemoteStreamMuxClient {
+ private socket: WebSocket | undefined;
+ private cancelCandidate: ((error: Error) => void) | undefined;
+ private keepAlive: Promise | undefined;
+ private revision = 0;
+ private readonly streams = new Map();
+ private readonly waiters = new Set();
+ private running = false;
+ private disposed = false;
+
+ /** Ensure a physical attempt exists, following the current attempt once if needed. */
+ start(): void {
+ if (this.disposed) return;
+ this.running = true;
+ if (this.socket?.readyState === WebSocket.OPEN) return;
+ const pending = this.keepAlive;
+ if (pending === undefined) this.maintain();
+ else
+ void pending.then(() => {
+ this.maintain();
+ });
+ }
+
+ /** Cancel the current socket or retry wait and start a fresh attempt immediately. */
+ reconnect(): void {
+ if (!this.running || this.disposed) return;
+ const failure = new RemoteStreamCarrierError("api gateway: Remote stream reconnect requested");
+ const pending = this.keepAlive;
+ this.revision++;
+ this.cancelCandidate?.(failure);
+ const socket = this.socket;
+ if (socket !== undefined) {
+ this.socket = undefined;
+ this.failAll(failure);
+ socket.close(4000, "reconnect requested");
+ }
+ if (pending === undefined) this.maintain();
+ else
+ void pending.then(() => {
+ this.maintain();
+ });
+ }
+
+ /**
+ * Open one logical stream on the persistent physical connection.
+ * If no physical attempt is active, opening waits for Connection to request
+ * one or for the signal to abort.
+ * @param endpoint - Typert Remote stream endpoint.
+ * @param payload - endpoint request encoded on the wire.
+ * @param signal - cancellation for this logical stream.
+ * @param uplink - the Client's items: each is sent as an `item` frame, its end as `end`; its `return()`
+ * runs when the stream finishes, and its failure cancels the stream and fails the downlink.
+ * @returns Host items until completion, cancellation, or failure.
+ */
+ async *open(
+ endpoint: string,
+ payload: unknown,
+ signal: AbortSignal,
+ uplink?: AsyncIterable,
+ ): AsyncGenerator {
+ signal.throwIfAborted();
+ const streamId = randomUUID();
+ const inbox = new StreamInbox();
+ const stream: LogicalStream = { inbox, pump: undefined };
+ let carrier: WebSocket | undefined;
+ let opened = false;
+ let terminal = false;
+ const abort = (): void => {
+ inbox.fail(signal.reason);
+ };
+ signal.addEventListener("abort", abort, { once: true });
+ try {
+ const socket = await this.waitForSocket(signal);
+ signal.throwIfAborted();
+ carrier = socket;
+ this.streams.set(streamId, stream);
+ this.send(socket, { type: "open", streamId, endpoint, payload });
+ opened = true;
+ if (uplink !== undefined)
+ stream.pump = this.pumpUplink(socket, streamId, uplink, signal, inbox);
+ while (true) {
+ const frame = await inbox.next();
+ signal.throwIfAborted();
+ if (frame.type === "item") {
+ yield frame.value;
+ continue;
+ }
+ terminal = true;
+ if (frame.type === "error") {
+ throw new RemoteError(
+ frame.error.code as never,
+ frame.error.message,
+ frame.error.details as never,
+ );
+ }
+ return;
+ }
+ } finally {
+ signal.removeEventListener("abort", abort);
+ this.streams.delete(streamId);
+ stream.pump?.stop();
+ if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) {
+ this.send(carrier, { type: "cancel", streamId });
+ }
+ // The old generation's pump has stopped before a supervisor reopens the next one.
+ if (stream.pump !== undefined) await stream.pump.done;
+ }
+ }
+
+ /**
+ * Send the caller's uplink items on this generation's socket. `stop()`
+ * interrupts a pump blocked on `uplink.next()` and releases the iterator: a
+ * handle's queue closes at once, so `send()` throws from then on, and any
+ * other iterator's `return()` is invoked without being awaited because a
+ * generator blocked in `next()` only completes it once it yields.
+ */
+ private pumpUplink(
+ socket: WebSocket,
+ streamId: string,
+ uplink: AsyncIterable,
+ signal: AbortSignal,
+ inbox: StreamInbox,
+ ): UplinkPump {
+ let stopped: PromiseWithResolvers> | undefined;
+ const interruption: IteratorReturnResult = { value: undefined, done: true };
+ const uplinkIterator = uplink[Symbol.asyncIterator]();
+ const state = { stopping: false, exhausted: false, released: false };
+ const release = (): void => {
+ if (state.released || state.exhausted) return;
+ state.released = true;
+ if (uplink instanceof ClientUplinkQueue) uplink.close();
+ void Promise.resolve()
+ .then(() => uplinkIterator.return?.())
+ .catch(() => undefined);
+ };
+ const done = (async (): Promise => {
+ try {
+ while (true) {
+ const stoppingBeforeRead = state.stopping;
+ if (stoppingBeforeRead) return;
+ // A stop promise belongs to one read, not the whole uplink history.
+ stopped = Promise.withResolvers>();
+ const next = await Promise.race([uplinkIterator.next(), stopped.promise]);
+ stopped = undefined;
+ if (state.stopping || next === interruption || signal.aborted || this.socket !== socket)
+ return;
+ if (next.done === true) {
+ state.exhausted = true;
+ break;
+ }
+ this.send(socket, { type: "item", streamId, value: next.value });
+ }
+ this.send(socket, { type: "end", streamId });
+ } catch (error) {
+ inbox.fail(error);
+ } finally {
+ stopped = undefined;
+ release();
+ }
+ })();
+ return {
+ done,
+ stop: () => {
+ if (state.stopping) return;
+ state.stopping = true;
+ stopped?.resolve(interruption);
+ release();
+ },
+ };
+ }
+
+ /**
+ * Permanently stop the carrier, close the physical socket, and fail every
+ * active logical stream.
+ * @returns once the active connection attempt has stopped.
+ */
+ async close(): Promise {
+ if (!this.disposed) {
+ this.disposed = true;
+ this.running = false;
+ const error = new Error("api gateway: Remote stream client disposed");
+ this.failAll(error);
+ for (const waiter of [...this.waiters]) waiter.reject(error);
+ this.cancelCandidate?.(error);
+ const socket = this.socket;
+ this.socket = undefined;
+ socket?.close(1000, "disposed");
+ }
+ await this.keepAlive;
+ }
+
+ private connect(): Promise {
+ const socket = new WebSocket(remoteStreamUrl());
+ const connecting = new Promise((resolve, reject) => {
+ let settled = false;
+ const rejectCandidate = (error: Error): void => {
+ settled = true;
+ socket.removeEventListener("open", opened);
+ socket.removeEventListener("error", failed);
+ socket.removeEventListener("message", received);
+ socket.removeEventListener("close", closed);
+ this.cancelCandidate = undefined;
+ socket.close();
+ reject(error);
+ };
+ const opened = (): void => {
+ settled = true;
+ this.cancelCandidate = undefined;
+ this.socket = socket;
+ for (const waiter of [...this.waiters]) waiter.resolve(socket);
+ resolve(socket);
+ };
+ const failed = (): void => {
+ if (!settled) {
+ rejectCandidate(
+ new RemoteStreamCarrierError("api gateway: Remote stream WebSocket failed to open"),
+ );
+ return;
+ }
+ const error = new RemoteStreamCarrierError("api gateway: Remote stream WebSocket failed");
+ this.lost(socket, error);
+ socket.close();
+ };
+ const closed = (): void => {
+ if (!settled) {
+ rejectCandidate(
+ new RemoteStreamCarrierError(
+ "api gateway: Remote stream WebSocket closed before opening",
+ ),
+ );
+ return;
+ }
+ this.lost(socket);
+ };
+ const received = (event: MessageEvent): void => {
+ this.receive(socket, event.data);
+ };
+ this.cancelCandidate = rejectCandidate;
+ socket.addEventListener("open", opened, { once: true });
+ socket.addEventListener("error", failed, { once: true });
+ socket.addEventListener("message", received);
+ socket.addEventListener("close", closed, { once: true });
+ });
+ return connecting;
+ }
+
+ private waitForSocket(signal: AbortSignal): Promise {
+ signal.throwIfAborted();
+ if (this.socket?.readyState === WebSocket.OPEN) return Promise.resolve(this.socket);
+ if (this.disposed)
+ return Promise.reject(new Error("api gateway: Remote stream client disposed"));
+ if (!this.running)
+ return Promise.reject(new Error("api gateway: Remote stream client not started"));
+ return new Promise((resolve, reject) => {
+ const aborted = (): void => {
+ waiter.reject(signal.reason);
+ };
+ const cleanup = (): void => {
+ this.waiters.delete(waiter);
+ signal.removeEventListener("abort", aborted);
+ };
+ const waiter: SocketWaiter = {
+ revision: this.revision,
+ resolve: (socket) => {
+ cleanup();
+ resolve(socket);
+ },
+ reject: (error) => {
+ cleanup();
+ // AbortSignal.reason belongs to the caller and may intentionally be a non-Error sentinel.
+ // oxlint-disable-next-line typescript/prefer-promise-reject-errors
+ reject(error);
+ },
+ };
+ this.waiters.add(waiter);
+ signal.addEventListener("abort", aborted, { once: true });
+ });
+ }
+
+ private receive(socket: WebSocket, data: unknown): void {
+ if (socket !== this.socket) return;
+ try {
+ if (typeof data !== "string")
+ throw new Error("api gateway: Remote stream WebSocket requires text messages");
+ const frame = parseRemoteStreamServerMessage(data);
+ const stream = this.streams.get(frame.streamId);
+ if (stream === undefined) return;
+ stream.inbox.push(frame);
+ // A terminal frame ends the uplink now, not on the consumer's next read.
+ if (frame.type !== "item") stream.pump?.stop();
+ } catch (error) {
+ const failure = new RemoteStreamCarrierError("api gateway: invalid Remote stream frame", {
+ cause: error,
+ });
+ this.failAll(failure);
+ this.lost(socket, failure);
+ socket.close(4002, "invalid Remote stream frame");
+ }
+ }
+
+ private lost(
+ socket: WebSocket,
+ error: RemoteStreamCarrierError = new RemoteStreamCarrierError(
+ "api gateway: Remote stream WebSocket closed",
+ ),
+ ): void {
+ if (this.socket !== socket) return;
+ this.socket = undefined;
+ this.failAll(error);
+ }
+
+ private maintain(): void {
+ if (!this.running || this.disposed) return;
+ if (this.socket?.readyState === WebSocket.OPEN || this.keepAlive !== undefined) return;
+ const revision = this.revision;
+ const task = this.connect().then(
+ () => undefined,
+ (error: unknown) => {
+ if (!this.running) return;
+ for (const waiter of [...this.waiters]) {
+ if (waiter.revision <= revision) waiter.reject(error);
+ }
+ },
+ );
+ this.keepAlive = task;
+ void task.then(() => {
+ this.keepAlive = undefined;
+ });
+ }
+
+ private failAll(error: unknown): void {
+ for (const stream of this.streams.values()) {
+ stream.inbox.fail(error);
+ stream.pump?.stop();
+ }
+ }
+
+ private send(socket: WebSocket, message: RemoteStreamClientMessage): void {
+ socket.send(JSON.stringify(message));
+ }
+}
+
+class StreamInbox {
+ private readonly frames = new Deque();
+ private wake: (() => void) | undefined;
+ private failure: Error | undefined;
+
+ push(frame: RemoteStreamServerMessage): void {
+ if (this.failure !== undefined) return;
+ this.frames.pushBack(frame);
+ this.wake?.();
+ this.wake = undefined;
+ }
+
+ fail(error: unknown): void {
+ if (this.failure !== undefined) return;
+ this.failure = error instanceof Error ? error : new Error(String(error), { cause: error });
+ this.frames.clear();
+ this.wake?.();
+ this.wake = undefined;
+ }
+
+ async next(): Promise {
+ while (this.frames.size === 0) {
+ if (this.failure !== undefined) throw this.failure;
+ await new Promise((resolve) => {
+ this.wake = resolve;
+ });
+ }
+ return this.frames.popFront() as RemoteStreamServerMessage;
+ }
+}
+
+/**
+ * Uplink items a stream handle queues for its carrier: the mux pump or the
+ * in-process Host decoder iterates it as the stream's uplink. `end()` is the
+ * Client half-close; `close()` marks the stream terminated, after which
+ * `push()` throws. One consumer reads it, one read at a time.
+ */
+export class ClientUplinkQueue implements AsyncIterable, AsyncIterator {
+ private readonly items = new Deque();
+ private ended = false;
+ private closed = false;
+ private wake: (() => void) | undefined;
+
+ /** @param endpoint - canonical Remote endpoint named by failures. */
+ constructor(private readonly endpoint: string) {}
+
+ /**
+ * Queue one item for the carrier.
+ * @param item - item the Host validates against the method's uplink codec.
+ * @throws {Error} after `end()` or once the stream has terminated.
+ */
+ push(item: unknown): void {
+ if (this.closed) throw new Error(`client api: ${this.endpoint} stream has terminated`);
+ if (this.ended) throw new Error(`client api: ${this.endpoint} uplink was ended`);
+ this.items.pushBack(item);
+ this.signal();
+ }
+
+ /** Half-close: the carrier reads the queued items, then `end`. Idempotent; ignored after termination. */
+ end(): void {
+ if (this.ended || this.closed) return;
+ this.ended = true;
+ this.signal();
+ }
+
+ /** The carrier stopped reading: the logical stream terminated or was disposed. Idempotent. */
+ close(): void {
+ if (this.closed) return;
+ this.closed = true;
+ this.items.clear();
+ this.signal();
+ }
+
+ [Symbol.asyncIterator](): AsyncIterator {
+ return this;
+ }
+
+ /**
+ * Take the next queued item, waiting for one; ends after `end()` or `close()`.
+ * @returns the next item, or the end of the uplink.
+ * @throws {Error} when a read is already pending.
+ */
+ async next(): Promise> {
+ while (true) {
+ if (this.closed) return UPLINK_DONE;
+ if (this.items.size > 0) return { value: this.items.popFront(), done: false };
+ if (this.ended) return UPLINK_DONE;
+ if (this.wake !== undefined)
+ throw new Error(`client api: ${this.endpoint} uplink has one pending read`);
+ await new Promise((resolve) => {
+ this.wake = resolve;
+ });
+ }
+ }
+
+ /**
+ * The carrier is done with the uplink: close it.
+ * @returns the end of the uplink.
+ */
+ return(): Promise> {
+ this.close();
+ return Promise.resolve(UPLINK_DONE);
+ }
+
+ private signal(): void {
+ const wake = this.wake;
+ this.wake = undefined;
+ wake?.();
+ }
+}
+
+function remoteStreamUrl(): string {
+ // The mux route is registered absolute; a page resolves its document-relative
+ // form against its own document base. A shell-owned Host on another origin
+ // supplies that base through the transport.
+ const globals = globalThis as { __DSH_TRANSPORT__?: { streamBaseUrl?: string } };
+ const url = new URL(
+ REMOTE_STREAM_MUX_PATH.slice(1),
+ globals.__DSH_TRANSPORT__?.streamBaseUrl ?? document.baseURI,
+ );
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
+ return url.href;
+}
diff --git a/apps/web-ui/packages/api/gateway/src/index.ts b/apps/web-ui/packages/api/gateway/src/index.ts
new file mode 100644
index 000000000..5a84e9e1d
--- /dev/null
+++ b/apps/web-ui/packages/api/gateway/src/index.ts
@@ -0,0 +1,1688 @@
+/**
+ * Live Typert Remote dispatch over Cordis Services and registered providers.
+ * Unary transport and response envelopes belong to Connection; live Remote
+ * streams use the Gateway-owned WebSocket mux.
+ * @module @deepseek-ai/dsh-api-gateway
+ */
+
+import { randomUUID } from "node:crypto";
+import { Context, Service, symbols } from "@deepseek-ai/cordis";
+// codexhost: import the runtime Peer from its module rather than the Connection
+// plugin root, whose browser-auth chain pulls the DSH credentials service that
+// the embedded Web backend does not mount.
+import { OperatorPeer } from "@deepseek-ai/dsh-client-connection/src/operator-peer.ts";
+import type {
+ ConnectionRpcAttachment,
+ ConnectionRpcHandler,
+} from "@deepseek-ai/dsh-client-connection/src/rpc.ts";
+import { Deque } from "@deepseek-ai/dsh-deque";
+import type { WebUpgradeRoute } from "@deepseek-ai/dsh-host-webserver";
+import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
+import type {} from "@deepseek-ai/dsh-cmdline";
+import z from "@deepseek-ai/schemastery";
+export type { TypertGatewayFaultDetails } from "./remote-error-codes.ts";
+import {
+ RemoteError,
+ isRemoteJsonValue,
+ remoteErrorOf,
+ remoteMethods,
+ type InvocationDescriptor,
+ type InvocationParameterDescriptor,
+ type PeerScope,
+ type RemoteInvocation,
+ type TypertCodec,
+ type TypertGatewayBinding,
+} from "@deepseek-ai/dsh-typert-protocol";
+import type {
+ InvokeRemoteRequest,
+ TypertGateway,
+ TypertGatewayErrorCode,
+ TypertGatewayWireStream,
+ TypertRemoteEventDispatch,
+ TypertRemoteEventFrame,
+ TypertRemoteEventInvocation,
+ TypertRemoteEventOutcome,
+ TypertRemoteEventSource,
+} from "./types.ts";
+import { RemoteStreamMuxServer, rejectRemoteStreamUpgrade } from "./stream-server.ts";
+import {
+ REMOTE_EVENT_STREAM_ENDPOINT,
+ REMOTE_EVENT_STREAM_READY,
+ REMOTE_EVENT_RESULT_ENDPOINT,
+ REMOTE_STREAM_MUX_PATH,
+ isRemoteEventAgentId,
+ parseRemoteEventResult,
+ projectRemoteEventRequest,
+ restoreRemoteEventRejection,
+ type RemoteEventCancellationFrame,
+ type RemoteEventClientId,
+ type RemoteEventEmitFrame,
+ type RemoteEventHostInfo,
+ type RemoteEventId,
+ type RemoteEventInvocationFrame,
+ type RemoteEventReadyFrame,
+ type RemoteStreamFailure,
+} from "./stream-protocol.ts";
+
+export type {
+ InvokeRemoteRequest,
+ TypertGateway,
+ TypertGatewayErrorCode,
+ TypertGatewayWireStream,
+ TypertRemoteEventContext,
+ TypertRemoteEventDispatch,
+ TypertRemoteEventFrame,
+ TypertRemoteEventInvocation,
+ TypertRemoteEventOutcome,
+ TypertRemoteEventSource,
+} from "./types.ts";
+export type { RemoteEventHostInfo } from "./stream-protocol.ts";
+
+interface GatewayErrorOptions {
+ readonly cause?: unknown;
+ readonly field?: string;
+}
+
+interface ResolvedBinding {
+ readonly binding: TypertGatewayBinding;
+ readonly original: object;
+}
+
+interface PreparedInvocation {
+ readonly endpoint: string;
+ readonly descriptor: InvocationDescriptor;
+ /** Service view bound to a Context carrying `invocation`, so the method reads it as `this.ctx.invocation`. */
+ readonly receiver: object;
+ readonly args: readonly unknown[];
+ readonly method: (...args: never[]) => unknown;
+ readonly invocation: GatewayInvocation;
+}
+
+/** Carrier inputs `GatewayInvocation.uplink()` decodes on first use. */
+interface UplinkSource {
+ /** Carrier items; an immediately ended iterable when the carrier has none. */
+ readonly source: AsyncIterable;
+ /** Descriptor codec, or the JSON-safety codec when the descriptor declares no uplink. */
+ readonly codec: TypertCodec;
+ readonly endpoint: string;
+ /** Fail the logical stream with a Remote failure as the reason. */
+ readonly abort: (reason: unknown) => void;
+}
+
+interface RegisteredRemoteEventSource {
+ readonly lifetime: AbortController;
+ readonly done: Promise;
+ readonly host: RemoteEventHostInfo;
+}
+
+interface RemoteEventClient {
+ readonly id: RemoteEventClientId;
+ readonly queue: RemoteEventQueue;
+ readonly signal: AbortSignal;
+ readonly deliveries: Map;
+}
+
+interface PendingRemoteEvent {
+ readonly id: RemoteEventId;
+ readonly source: TypertRemoteEventInvocation;
+ readonly frame: RemoteEventInvocationFrame;
+ readonly deliveries: Set;
+ releaseContext: () => void;
+ releaseSignal: () => void;
+}
+
+type ConnectionRpcResult = Awaited>;
+type ConnectionRpcError = Extract["error"];
+const NEVER_ABORTED_SIGNAL = new AbortController().signal;
+const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 2_000;
+const DEFAULT_STREAM_INBOX_BYTES = 262_144;
+const EMPTY_ASYNC_ITERABLE: AsyncIterable = {
+ [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ value: undefined, done: true }) }),
+};
+const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true };
+const SRC_JSON_CODEC: TypertCodec = { mode: "src-json" };
+
+/** Gateway transport configuration. */
+export interface Config {
+ /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */
+ readonly websocketHeartbeatIntervalMs?: number;
+ /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */
+ readonly streamInboxBytes?: number;
+}
+
+interface ResolvedConfig extends Config {
+ readonly websocketHeartbeatIntervalMs: number;
+ readonly streamInboxBytes: number;
+}
+
+/**
+ * Dispatch failure produced outside the invoked business method. Rides the
+ * shared Remote failure vocabulary, so its code crosses the wire instead of
+ * folding to `internal`.
+ */
+export class TypertGatewayError extends RemoteError {
+ /** Canonical `/` endpoint. */
+ readonly endpoint: string;
+ /** Affected wire field when the failure is field-specific. */
+ readonly field: string | undefined;
+
+ /**
+ * Construct a Gateway failure without embedding boundary values in its message.
+ * @param code - stable failure category.
+ * @param endpoint - canonical Remote endpoint.
+ * @param message - correction-oriented diagnostic without sensitive values.
+ * @param options - optional field and contained cause.
+ */
+ constructor(
+ code: TypertGatewayErrorCode,
+ endpoint: string,
+ message: string,
+ options: GatewayErrorOptions = {},
+ ) {
+ super(
+ code,
+ `typert gateway: ${endpoint}: ${message}`,
+ { endpoint, ...(options.field === undefined ? {} : { field: options.field }) },
+ options.cause === undefined ? undefined : { cause: options.cause },
+ );
+ this.name = "TypertGatewayError";
+ this.endpoint = endpoint;
+ this.field = options.field;
+ }
+}
+
+/**
+ * Resolve strict generated definitions or conservative SRC markers against
+ * current Cordis Services and Typert providers.
+ * @typert service typertGateway
+ */
+export class TypertGatewayService extends Service implements TypertGateway {
+ static inject = ["typert"];
+ static Config: z = z.object({
+ websocketHeartbeatIntervalMs: z
+ .number()
+ .step(1)
+ .min(1)
+ .max(MAX_TIMER_DELAY_MS)
+ .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS),
+ streamInboxBytes: z.number().step(1).min(1).default(DEFAULT_STREAM_INBOX_BYTES),
+ });
+
+ /** Carrier adapter shared by the WebSocket mux and local Host transports. */
+ readonly wireStream: TypertGatewayWireStream = {
+ open: (endpoint, payload, uplink, peer, signal) =>
+ this.openWireStream(endpoint, payload, uplink, peer, signal, new AbortController()),
+ failure: (error) => rpcError(error),
+ };
+
+ private srcClaims: ReadonlySet | undefined;
+ private inProcessOperator: PeerScope | undefined;
+ private remoteEvents: RegisteredRemoteEventSource | undefined;
+ private readonly remoteEventClients = new Map();
+ private readonly pendingRemoteEvents = new Map();
+
+ /**
+ * Register the Gateway against the active Typert registry.
+ * WebSocket admission waits for launcher-owned application readiness when supplied;
+ * direct invocation and in-process streams remain available independently.
+ * @param ctx - owning Host Context with Typert registry access.
+ * @param config - validated Gateway transport configuration.
+ */
+ constructor(ctx: Context, config: Config) {
+ super(ctx, "typertGateway");
+ const resolved = config as ResolvedConfig;
+ ctx.on("internal/service", () => {
+ this.srcClaims = undefined;
+ });
+ ctx.inject(["connection"], (connectionCtx) => {
+ connectionCtx.connection.rpc.intercept(
+ "/api",
+ (endpoint) => this.claimsEndpoint(endpoint),
+ (endpoint, payload, signal, peer) => this.dispatchRpc(endpoint, payload, signal, peer),
+ );
+ });
+ ctx.inject(["connection", "webServer"], (webCtx) => {
+ const listen = (): void => {
+ const mux = new RemoteStreamMuxServer(
+ (endpoint, payload, uplink, peer, control) =>
+ this.openWireStream(endpoint, payload, uplink, peer, control.signal, control),
+ this.wireStream.failure,
+ resolved.websocketHeartbeatIntervalMs,
+ resolved.streamInboxBytes,
+ );
+ webCtx.effect(function* () {
+ yield () => mux.close();
+ const route: WebUpgradeRoute = {
+ path: REMOTE_STREAM_MUX_PATH,
+ handler: (req, socket, head) => {
+ const admission = webCtx.connection.admit(req);
+ if ("rejection" in admission) {
+ rejectRemoteStreamUpgrade(socket, admission.rejection);
+ return;
+ }
+ mux.handleUpgrade(req, socket, head, admission.peer);
+ },
+ };
+ yield webCtx.webServer.registerUpgrade(route);
+ }, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`);
+ };
+ // Existing pages reconnect before the new Host prints its URL. No stream
+ // may enter until the launcher has activated and audited its controllers.
+ const ready = webCtx.get("appReady");
+ if (ready === undefined) listen();
+ else
+ webCtx.effect(() => {
+ let closed = false;
+ const cancel = ready.onReady(() => {
+ if (!closed) listen();
+ });
+ return () => {
+ closed = true;
+ cancel();
+ };
+ }, "api-gateway: application readiness");
+ });
+ }
+
+ /**
+ * Check for an active Client event stream.
+ * @returns whether a stream is open and has not been cancelled.
+ */
+ hasLiveClient(): boolean {
+ for (const client of this.remoteEventClients.values()) {
+ if (!client.signal.aborted) return true;
+ }
+ return false;
+ }
+
+ /**
+ * Register the sole application-selected forwarded-event source.
+ * @param source - stream factory installed by the Remote assembly.
+ * @param host - stable Host facts included in each Client generation's opening frame.
+ * @returns disposer removing this source and cancelling its active streams.
+ */
+ registerRemoteEvents(
+ source: TypertRemoteEventSource,
+ host: RemoteEventHostInfo,
+ ): () => Promise {
+ if (this.remoteEvents !== undefined) {
+ throw new Error("typert gateway: forwarded Remote event source is already registered");
+ }
+ const lifetime = new AbortController();
+ const stream = source(lifetime.signal);
+ const done = this.consumeRemoteEvents(stream, lifetime.signal).catch((error: unknown) => {
+ if (this.remoteEvents?.lifetime !== lifetime || lifetime.signal.aborted) return;
+ this.closeRemoteEvents(error);
+ this.remoteEvents = undefined;
+ lifetime.abort(error);
+ });
+ const registration: RegisteredRemoteEventSource = { lifetime, done, host: { home: host.home } };
+ this.remoteEvents = registration;
+ return async () => {
+ if (this.remoteEvents === registration) {
+ this.remoteEvents = undefined;
+ const error = new Error("typert gateway: forwarded Remote event source was removed");
+ registration.lifetime.abort(error);
+ this.closeRemoteEvents(error);
+ }
+ await registration.done;
+ };
+ }
+
+ private claimsEndpoint(endpoint: string): boolean {
+ if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) return true;
+ const segments = endpoint.split("/");
+ if (segments.length !== 2 || segments[0] === "" || segments[1] === "") return false;
+ if (
+ this.ctx.typert.local.get(endpoint) !== undefined ||
+ this.ctx.typert.local.hasSeen(endpoint)
+ )
+ return true;
+ this.srcClaims ??= this.collectSrcClaims();
+ return this.srcClaims.has(endpoint);
+ }
+
+ private collectSrcClaims(): ReadonlySet {
+ const claims = new Set();
+ for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
+ if (definition.type !== "service") continue;
+ const receiver: unknown = this.ctx.get(serviceKey);
+ if (!isObject(receiver)) continue;
+ const original = originalOf(receiver);
+ const binding: unknown = Reflect.get(original, "typertRemote");
+ if (!isObject(binding) || typeof Reflect.get(binding, "namespace") !== "string") continue;
+ const namespace = Reflect.get(binding, "namespace") as string;
+ for (const candidate of remoteMethods(original)) {
+ claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method));
+ }
+ }
+ return claims;
+ }
+
+ /**
+ * Invoke one live Remote method through strict generated reflection or SRC markers.
+ * @param request - decoded endpoint and exact named wire arguments.
+ * @returns the business result without output decoding.
+ * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
+ */
+ async invoke(request: InvokeRemoteRequest): Promise {
+ return this.invokePrepared(await this.prepareInvocation(request, new AbortController()));
+ }
+
+ private async invokePrepared(prepared: PreparedInvocation): Promise {
+ if (prepared.descriptor.mode !== undefined) {
+ throw new TypertGatewayError(
+ "gateway/signature-invalid",
+ prepared.endpoint,
+ "stream Remote methods must be opened through the stream carrier",
+ );
+ }
+
+ try {
+ return (await Reflect.apply(prepared.method, prepared.receiver, prepared.args)) as unknown;
+ } catch (error) {
+ if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error);
+ throw error;
+ } finally {
+ // A unary call's uplink is readable only while the method runs.
+ await prepared.invocation.close();
+ }
+ }
+
+ /**
+ * Open one live stream Remote method without assuming a physical carrier.
+ * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one.
+ * @returns a cancellation-aware iterable over the business results.
+ */
+ async stream(request: InvokeRemoteRequest): Promise> {
+ return this.openStream(request, new AbortController());
+ }
+
+ /**
+ * `control` belongs to the logical stream: a rejected uplink item aborts it
+ * with the Remote failure as the reason so the carrier delivers that failure.
+ */
+ private async openStream(
+ request: InvokeRemoteRequest,
+ control: AbortController,
+ ): Promise> {
+ const prepared = await this.prepareInvocation(request, control);
+ if (prepared.descriptor.mode === undefined) {
+ await prepared.invocation.close();
+ throw new TypertGatewayError(
+ "gateway/signature-invalid",
+ prepared.endpoint,
+ "unary Remote methods cannot be opened through the stream carrier",
+ );
+ }
+ let source: unknown;
+ try {
+ source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown;
+ } catch (error) {
+ await prepared.invocation.close();
+ if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error);
+ throw error;
+ }
+ if (!isIterable(source)) {
+ await prepared.invocation.close();
+ throw new TypertGatewayError(
+ "gateway/result-invalid",
+ prepared.endpoint,
+ "stream Remote method did not return Iterable or AsyncIterable",
+ { field: "result" },
+ );
+ }
+ return cancellableStream(source, prepared.endpoint, prepared.invocation);
+ }
+
+ private async dispatchRpc(
+ endpoint: string,
+ payload: unknown,
+ signal: AbortSignal,
+ peer: PeerScope,
+ ): Promise {
+ if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) {
+ try {
+ const result = parseRemoteEventResultPayload(payload);
+ const client = this.remoteEventClients.get(result.clientId);
+ if (client === undefined) {
+ throw new Error("typert gateway: Remote event result identifies no active event stream");
+ }
+ this.receiveRemoteEventResult(client, result);
+ return { ok: true, value: undefined };
+ } catch (error) {
+ return rpcFailure(error);
+ }
+ }
+ return this.invokeRpc(endpoint, payload, signal, peer);
+ }
+
+ private async openWireStream(
+ endpoint: string,
+ payload: unknown,
+ uplink: AsyncIterable,
+ peer: PeerScope | undefined,
+ signal: AbortSignal,
+ control: AbortController,
+ ): Promise> {
+ if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) {
+ // A Gateway-owned stream reads no uplink: releasing it now keeps its items out of the bounded inbox.
+ releaseUplink(uplink);
+ return this.openRemoteEvents(payload, signal);
+ }
+ return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink }, control);
+ }
+
+ /**
+ * The Peer an in-process carrier speaks for when it names none: the
+ * operator's Peer when Connection is mounted, otherwise an operator scope the
+ * Gateway owns for its own lifetime.
+ * @returns the operator Peer.
+ */
+ private operatorPeer(): PeerScope {
+ const connection = this.ctx.get("connection");
+ if (connection !== undefined) return connection.operator;
+ this.inProcessOperator ??= new OperatorPeer(this.ctx);
+ return this.inProcessOperator;
+ }
+
+ private async *openRemoteEvents(
+ payload: unknown,
+ signal: AbortSignal,
+ ): AsyncGenerator<
+ | RemoteEventEmitFrame
+ | RemoteEventInvocationFrame
+ | RemoteEventCancellationFrame
+ | RemoteEventReadyFrame
+ > {
+ if (
+ !isObject(payload) ||
+ !isPlainObject(payload) ||
+ Reflect.ownKeys(payload).length !== 1 ||
+ !Object.hasOwn(payload, "args") ||
+ !isObject(payload.args) ||
+ !isPlainObject(payload.args) ||
+ Reflect.ownKeys(payload.args).length !== 0
+ ) {
+ throw new TypertGatewayError(
+ "gateway/arguments-invalid",
+ REMOTE_EVENT_STREAM_ENDPOINT,
+ "forwarded Remote event stream requires an empty args object",
+ );
+ }
+ const registration = this.remoteEvents;
+ if (registration === undefined) {
+ throw new TypertGatewayError(
+ "gateway/service-unavailable",
+ REMOTE_EVENT_STREAM_ENDPOINT,
+ "forwarded Remote event source is unavailable",
+ );
+ }
+ const lifetime = AbortSignal.any([signal, registration.lifetime.signal]);
+ let clientId = randomUUID() as RemoteEventClientId;
+ while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId;
+ const client: RemoteEventClient = {
+ id: clientId,
+ queue: new RemoteEventQueue(),
+ signal: lifetime,
+ deliveries: new Map(),
+ };
+ this.remoteEventClients.set(clientId, client);
+ for (const pending of this.pendingRemoteEvents.values())
+ this.deliverRemoteEvent(pending, client);
+ try {
+ yield { ...REMOTE_EVENT_STREAM_READY, clientId, host: registration.host };
+ yield* client.queue.iterate(lifetime);
+ } finally {
+ this.removeRemoteEventClient(client);
+ }
+ }
+
+ private async consumeRemoteEvents(
+ source: AsyncIterable,
+ signal: AbortSignal,
+ ): Promise {
+ for await (const dispatch of source) {
+ if (signal.aborted) {
+ if ("context" in dispatch) dispatch.reject(signal.reason);
+ return;
+ }
+ if ("context" in dispatch) this.startRemoteEvent(dispatch);
+ else this.broadcastRemoteEvent(dispatch);
+ }
+ if (!signal.aborted) {
+ throw new Error("typert gateway: forwarded Remote event source ended unexpectedly");
+ }
+ }
+
+ private broadcastRemoteEvent(frame: TypertRemoteEventFrame): void {
+ assertRemoteEventFrame(frame);
+ const wire: RemoteEventEmitFrame = {
+ type: "emit",
+ event: frame.event,
+ args: frame.args,
+ };
+ for (const client of this.remoteEventClients.values()) client.queue.push(wire);
+ }
+
+ private startRemoteEvent(source: TypertRemoteEventInvocation): void {
+ try {
+ assertRemoteEventName(source);
+ if (!isRemoteEventAgentId(source.context.agentId)) {
+ throw new TypeError(
+ "typert gateway: scoped Remote events require a non-empty Agent identity",
+ );
+ }
+ const projected = projectRemoteEventRequest(source.request, source.context.subject);
+ let id = randomUUID() as RemoteEventId;
+ while (this.pendingRemoteEvents.has(id)) id = randomUUID() as RemoteEventId;
+ let releaseContext: () => void;
+ try {
+ const dispose = source.context.value.effect(
+ () => () => {
+ this.cancelRemoteEvent(
+ pending,
+ new Error("typert gateway: Remote event Agent Context was released"),
+ );
+ },
+ `api-gateway: Remote event ${JSON.stringify(source.event)}`,
+ );
+ releaseContext = () => {
+ void dispose();
+ };
+ } catch {
+ source.resolve({ kind: "next" });
+ return;
+ }
+ const signals = new Set(projected.signal === undefined ? [] : [projected.signal]);
+ const abort = (): void => {
+ const reason: unknown = [...signals].find((signal) => signal.aborted)?.reason;
+ this.cancelRemoteEvent(
+ pending,
+ reason instanceof Error
+ ? reason
+ : new Error("typert gateway: Remote event was cancelled", { cause: reason }),
+ );
+ };
+ const pending: PendingRemoteEvent = {
+ id,
+ source,
+ frame: {
+ type: "waterfall",
+ event: source.event,
+ eventId: id,
+ agentId: source.context.agentId,
+ request: projected.request,
+ },
+ deliveries: new Set(),
+ releaseContext,
+ releaseSignal: () => {
+ for (const signal of signals) signal.removeEventListener("abort", abort);
+ },
+ };
+ this.pendingRemoteEvents.set(id, pending);
+ for (const signal of signals) signal.addEventListener("abort", abort, { once: true });
+ if ([...signals].some((signal) => signal.aborted)) abort();
+ else
+ for (const client of this.remoteEventClients.values())
+ this.deliverRemoteEvent(pending, client);
+ } catch (error) {
+ source.reject(error);
+ }
+ }
+
+ private deliverRemoteEvent(pending: PendingRemoteEvent, client: RemoteEventClient): void {
+ pending.deliveries.add(client);
+ client.deliveries.set(pending.id, pending);
+ client.queue.push(pending.frame);
+ }
+
+ private receiveRemoteEventResult(
+ client: RemoteEventClient,
+ result: ReturnType,
+ ): void {
+ const pending = this.pendingRemoteEvents.get(result.eventId);
+ // Settlement and Client replacement may race the result request. Results
+ // from a completed event or a superseded delivery are idempotent no-ops.
+ if (pending === undefined || !pending.deliveries.has(client)) return;
+ this.removeRemoteEventDelivery(pending, client);
+ if (result.outcome.kind === "result") {
+ this.settleRemoteEvent(pending, {
+ kind: "result",
+ value: result.outcome.value,
+ });
+ } else if (result.outcome.kind === "rejected") {
+ this.cancelRemoteEvent(pending, restoreRemoteEventRejection(result.outcome.error));
+ } else if (pending.deliveries.size === 0) {
+ this.settleRemoteEvent(pending, { kind: "next" });
+ }
+ }
+
+ private removeRemoteEventDelivery(pending: PendingRemoteEvent, client: RemoteEventClient): void {
+ pending.deliveries.delete(client);
+ client.deliveries.delete(pending.id);
+ }
+
+ private removeRemoteEventClient(client: RemoteEventClient): void {
+ this.remoteEventClients.delete(client.id);
+ for (const pending of [...client.deliveries.values()])
+ this.removeRemoteEventDelivery(pending, client);
+ client.queue.end();
+ }
+
+ private settleRemoteEvent(pending: PendingRemoteEvent, outcome: TypertRemoteEventOutcome): void {
+ this.finishRemoteEvent(pending);
+ pending.source.resolve(outcome);
+ }
+
+ private cancelRemoteEvent(pending: PendingRemoteEvent, reason: unknown): void {
+ if (this.pendingRemoteEvents.get(pending.id) !== pending) return;
+ this.finishRemoteEvent(pending);
+ pending.source.reject(reason);
+ }
+
+ private finishRemoteEvent(pending: PendingRemoteEvent): void {
+ this.pendingRemoteEvents.delete(pending.id);
+ pending.releaseSignal();
+ pending.releaseContext();
+ const clients = new Set(pending.deliveries);
+ for (const client of clients) this.removeRemoteEventDelivery(pending, client);
+ const cancellation: RemoteEventCancellationFrame = {
+ type: "cancel",
+ eventId: pending.id,
+ };
+ for (const client of clients) client.queue.push(cancellation);
+ }
+
+ private closeRemoteEvents(reason: unknown): void {
+ for (const pending of [...this.pendingRemoteEvents.values()]) {
+ this.cancelRemoteEvent(pending, reason);
+ }
+ for (const client of [...this.remoteEventClients.values()]) client.queue.end();
+ }
+
+ private async invokeRpc(
+ endpoint: string,
+ payload: unknown,
+ signal: AbortSignal,
+ peer: PeerScope,
+ ): Promise {
+ try {
+ const prepared = await this.prepareInvocation(
+ remoteRequest(endpoint, payload, signal, peer),
+ new AbortController(),
+ );
+ const value = await this.invokePrepared(prepared);
+ // A void or explicitly absent business result carries no `value` field;
+ // JSON has no `undefined`, and the envelope's optional slot is the one
+ // representation of absence that both args and results already use.
+ return encodeRpcResult(value, prepared.descriptor.result);
+ } catch (error) {
+ return rpcFailure(error);
+ }
+ }
+
+ /** `control` fails the logical stream when an uplink item is rejected; unary calls hand over an inert one. */
+ private async prepareInvocation(
+ request: InvokeRemoteRequest,
+ control: AbortController,
+ ): Promise {
+ const endpoint = endpointOf(request.namespace, request.method);
+ const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint);
+ assertExactArguments(request.args, descriptor, endpoint);
+ const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint);
+ const receiver: unknown = receiverContext.get(descriptor.service);
+ if (!isObject(receiver)) {
+ throw new TypertGatewayError(
+ "gateway/service-unavailable",
+ endpoint,
+ `active Service ${JSON.stringify(descriptor.service)} is unavailable`,
+ );
+ }
+ validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint);
+ const args = await Promise.all(
+ descriptor.parameters.map((parameter) =>
+ this.resolveParameter(parameter, request.args, endpoint),
+ ),
+ );
+ const signal = methodSignal(request, control);
+ const invocation = new GatewayInvocation(
+ { namespace: request.namespace, method: request.method, args: request.args },
+ descriptor.service,
+ request.peer ?? this.operatorPeer(),
+ signal,
+ {
+ source: request.uplink ?? EMPTY_ASYNC_ITERABLE,
+ codec: descriptor.uplink?.codec ?? SRC_JSON_CODEC,
+ endpoint,
+ abort: (reason) => {
+ control.abort(reason);
+ },
+ },
+ );
+ if (descriptor.cancellation !== undefined) args.push(signal);
+ // The method runs on a Service view bound to a Context carrying this call:
+ // Cordis rebinds `this.ctx` to the accessing Context, so `this.ctx.invocation`
+ // is this call and nothing travels through the parameter list. The view
+ // resolves the Service the plain read above already found.
+ const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object;
+ const implementation = descriptor.implementation ?? descriptor.method;
+ const method: unknown = Reflect.get(callReceiver, implementation);
+ if (typeof method !== "function") {
+ throw new TypertGatewayError(
+ "gateway/method-unavailable",
+ endpoint,
+ `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,
+ );
+ }
+ return {
+ endpoint,
+ descriptor,
+ receiver: callReceiver,
+ args,
+ method: method as (...args: never[]) => unknown,
+ invocation,
+ };
+ }
+
+ private resolveDescriptor(
+ namespace: string,
+ method: string,
+ endpoint: string,
+ ): InvocationDescriptor {
+ const strict = this.ctx.typert.local.get(endpoint);
+ if (strict !== undefined) return strict;
+ if (this.ctx.typert.local.hasSeen(endpoint)) {
+ throw new TypertGatewayError(
+ "gateway/definition-unavailable",
+ endpoint,
+ "its strict definition was withdrawn and SRC fallback is forbidden",
+ );
+ }
+ return this.resolveSrcDescriptor(namespace, method, endpoint);
+ }
+
+ private resolveSrcDescriptor(
+ namespace: string,
+ method: string,
+ endpoint: string,
+ ): InvocationDescriptor {
+ const candidates: InvocationDescriptor[] = [];
+ for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
+ if (definition.type !== "service") continue;
+ const receiver: unknown = this.ctx.get(serviceKey);
+ if (!isObject(receiver)) continue;
+ const original = originalOf(receiver);
+ const value: unknown = Reflect.get(original, "typertRemote");
+ if (value === undefined) continue;
+ const binding = readBinding(value, original, serviceKey, endpoint);
+ if (binding.namespace !== namespace) continue;
+ const marker = remoteMethods(original).find(
+ (candidate) => (candidate.exportName ?? candidate.method) === method,
+ );
+ if (marker === undefined) continue;
+ candidates.push(this.srcDescriptor(binding, marker, method, endpoint));
+ }
+ if (candidates.length === 0) {
+ throw new TypertGatewayError(
+ "gateway/invocation-unavailable",
+ endpoint,
+ "no active Remote method exports this endpoint",
+ );
+ }
+ if (candidates.length > 1) {
+ throw new TypertGatewayError(
+ "gateway/ambiguous-endpoint",
+ endpoint,
+ `multiple active Services export this endpoint: ${candidates
+ .map((candidate) => candidate.service)
+ .sort()
+ .join(", ")}`,
+ );
+ }
+ return candidates[0] as InvocationDescriptor;
+ }
+
+ private srcDescriptor(
+ binding: TypertGatewayBinding,
+ marker: ReturnType[number],
+ method: string,
+ endpoint: string,
+ ): InvocationDescriptor {
+ const names = methodParameterNames(binding.service, marker.method, endpoint);
+ const signalIndex = names.indexOf("signal");
+ if (signalIndex >= 0 && signalIndex !== names.length - 1) {
+ throw new TypertGatewayError(
+ "gateway/signature-invalid",
+ endpoint,
+ "SRC cancellation parameter signal must be the final parameter",
+ { field: "signal" },
+ );
+ }
+ const cancellation = signalIndex >= 0 ? { parameter: "signal" as const } : undefined;
+ const businessNames = cancellation === undefined ? names : names.slice(0, -1);
+ const parameters: InvocationParameterDescriptor[] = [];
+ const wires = new Set();
+ for (const name of businessNames) {
+ const matches = this.ctx.typert.lookups
+ .definitions()
+ .filter((definition) => definition.parameter === name);
+ if (matches.length > 1) {
+ throw new TypertGatewayError(
+ "gateway/signature-invalid",
+ endpoint,
+ `parameter ${JSON.stringify(name)} matches multiple lookup providers`,
+ { field: name },
+ );
+ }
+ const match = matches[0];
+ const parameter: InvocationParameterDescriptor =
+ match === undefined
+ ? { name, wire: name, source: "json", codec: { mode: "src-json" } }
+ : {
+ name,
+ wire: match.wire,
+ source: "lookup",
+ lookup: match.key,
+ codec: { mode: "src-json" },
+ };
+ if (wires.has(parameter.wire)) {
+ throw new TypertGatewayError(
+ "gateway/signature-invalid",
+ endpoint,
+ `multiple parameters use wire field ${JSON.stringify(parameter.wire)}`,
+ { field: parameter.wire },
+ );
+ }
+ wires.add(parameter.wire);
+ parameters.push(parameter);
+ }
+
+ let receiver: InvocationDescriptor["invocation"] = { kind: "direct" };
+ if (marker.invocation.kind === "context") {
+ const provider = this.ctx.typert.contexts.getHost(marker.invocation.context);
+ if (provider === undefined) {
+ throw new TypertGatewayError(
+ "gateway/context-unavailable",
+ endpoint,
+ `Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`,
+ );
+ }
+ if (wires.has(provider.wire)) {
+ throw new TypertGatewayError(
+ "gateway/signature-invalid",
+ endpoint,
+ `Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`,
+ { field: provider.wire },
+ );
+ }
+ receiver = {
+ kind: "context",
+ context: marker.invocation.context,
+ wire: provider.wire,
+ codec: { mode: "src-json" },
+ };
+ }
+
+ return {
+ id: `src:${binding.serviceKey}#${endpoint}`,
+ service: binding.serviceKey,
+ namespace: binding.namespace,
+ method,
+ ...(marker.method === method ? {} : { implementation: marker.method }),
+ ...(marker.mode === undefined ? {} : { mode: marker.mode }),
+ invocation: receiver,
+ parameters,
+ ...(cancellation === undefined ? {} : { cancellation }),
+ result: { mode: "src-json" },
+ };
+ }
+
+ private async resolveReceiverContext(
+ descriptor: InvocationDescriptor,
+ args: Readonly>,
+ endpoint: string,
+ ): Promise {
+ if (descriptor.invocation.kind === "direct") return this.ctx;
+ const invocation = descriptor.invocation;
+ const provider = this.ctx.typert.contexts.getHost(invocation.context);
+ if (provider === undefined) {
+ throw new TypertGatewayError(
+ "gateway/context-unavailable",
+ endpoint,
+ `Context provider ${JSON.stringify(invocation.context)} is unavailable`,
+ );
+ }
+ if (
+ provider.wire !== invocation.wire ||
+ (invocation.codec.mode === "strict" &&
+ provider.wireTypeSymbol !== invocation.codec.typeSymbol)
+ ) {
+ throw new TypertGatewayError(
+ "gateway/provider-mismatch",
+ endpoint,
+ `Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`,
+ { field: invocation.wire },
+ );
+ }
+ const identity = decode(invocation.codec, args[invocation.wire], endpoint, invocation.wire);
+ let context: Context | undefined;
+ try {
+ context = await provider.resolve(identity);
+ } catch (cause) {
+ if (remoteErrorOf(cause) !== undefined) throw cause;
+ throw new TypertGatewayError(
+ "gateway/context-failed",
+ endpoint,
+ `Context provider ${JSON.stringify(invocation.context)} failed`,
+ { cause, field: invocation.wire },
+ );
+ }
+ if (context === undefined) {
+ throw new TypertGatewayError(
+ "gateway/context-not-found",
+ endpoint,
+ `Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`,
+ { field: invocation.wire },
+ );
+ }
+ return context;
+ }
+
+ private async resolveParameter(
+ parameter: InvocationParameterDescriptor,
+ args: Readonly>,
+ endpoint: string,
+ ): Promise {
+ // An absent field reached assertExactArguments' allowance, so this parameter
+ // takes undefined; a present-but-undefined field is not JSON-safe input and
+ // still fails decode. Lookup ids are never omissible, so absence here only
+ // ever belongs to a json parameter.
+ if (!Object.hasOwn(args, parameter.wire)) return undefined;
+ const value = decode(parameter.codec, args[parameter.wire], endpoint, parameter.wire);
+ if (parameter.source === "json") return value;
+ const key = parameter.lookup;
+ /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */
+ if (key === undefined) {
+ throw new TypertGatewayError(
+ "gateway/lookup-unavailable",
+ endpoint,
+ `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`,
+ { field: parameter.wire },
+ );
+ }
+ const provider = this.ctx.typert.lookups.get(key);
+ if (provider === undefined) {
+ throw new TypertGatewayError(
+ "gateway/lookup-unavailable",
+ endpoint,
+ `lookup provider ${JSON.stringify(key)} is unavailable`,
+ { field: parameter.wire },
+ );
+ }
+ if (
+ provider.wire !== parameter.wire ||
+ (parameter.codec.mode === "strict" && provider.wireTypeSymbol !== parameter.codec.typeSymbol)
+ ) {
+ throw new TypertGatewayError(
+ "gateway/provider-mismatch",
+ endpoint,
+ `lookup provider ${JSON.stringify(key)} does not match its strict definition`,
+ { field: parameter.wire },
+ );
+ }
+ let resolved: unknown;
+ try {
+ resolved = await provider.resolve(value);
+ } catch (cause) {
+ if (remoteErrorOf(cause) !== undefined) throw cause;
+ throw new TypertGatewayError(
+ "gateway/lookup-failed",
+ endpoint,
+ `lookup provider ${JSON.stringify(key)} failed`,
+ { cause, field: parameter.wire },
+ );
+ }
+ if (resolved === undefined) {
+ throw new TypertGatewayError(
+ "gateway/lookup-not-found",
+ endpoint,
+ `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`,
+ { field: parameter.wire },
+ );
+ }
+ return resolved;
+ }
+}
+
+function encodeRpcResult(value: unknown, codec: TypertCodec): ConnectionRpcResult {
+ const attachments: ConnectionRpcAttachment[] = [];
+ const writeBytes = (bytes: Uint8Array, path: readonly (string | number)[]): null => {
+ attachments.push({ path: [...path], bytes });
+ return null;
+ };
+ const encoded =
+ codec.mode === "strict"
+ ? (codec.encode?.(value, writeBytes) ?? value)
+ : encodeRuntimeResult(value, writeBytes);
+ return { ok: true, value: encoded, ...(attachments.length === 0 ? {} : { attachments }) };
+}
+
+function encodeRuntimeResult(
+ input: unknown,
+ writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null,
+): unknown {
+ const path: (string | number)[] = [];
+ const ancestors = new Set