diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml
new file mode 100644
index 0000000000..97793a906a
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md
+2026-07-31-web-telemetry-default-mount.md: 6c1fdaa8719ee01726b51db9a469ff659cbac476
+2026-07-31-web-telemetry-default-mount.zh.md: b447832527ba9731097cd0776060db11ee4dfc30
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md
new file mode 100644
index 0000000000..6c1fdaa871
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md
@@ -0,0 +1,39 @@
+# Agent Note: Default session-telemetry mount (OTel reporting) in the dsh web composition
+
+Status: implemented
+
+English | [中文](2026-07-31-web-telemetry-default-mount.zh.md)
+
+## Problem
+
+The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry-otel-revival.md)) had never been wired into any deployment composition since completion: no roster row, no switch, no cadence ruling, and zero observability over user sessions for the internal deployment. A deployment decision was needed: which surfaces report, to where, on what cadence, how to opt out, and how CI stays isolated.
+
+## Decision
+
+The shared `dsh` core (`apps/cli/config/base.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint, so every surface — TUI, web, and headless — reports; this is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. Each surface's exit path drains the queue: web/headless dispose on SIGINT/SIGTERM (headless gained those handlers in this change), and the TUI's normal exit runs `disposeRootAndExit` (root dispose, 5s bounded — above the ~1s drain ceiling configured here) while its `/resume` handoff disposes the root before `execve`.
+
+| Ruling | Value | Rationale |
+|---|---|---|
+| Mount surface | base.cordis.yml (TUI + web + headless) | One deployment stance for every surface; per-surface divergence would need a reason, and none exists |
+| Endpoint | `DSH_TELEMETRY_OTLP_URL`, default `https://harness-telemetry.deepseeksvc.com/v1/logs` | Internal collector; the env override serves local/dev runs |
+| Opt-out switch | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables | A privacy switch prefers off-by-mistake over on-by-mistake; a row can only be disabled at AppCLIEntry's patch layer (config has no disable semantic, and the switch must precede the load-time `exporter.url` validation) |
+| Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval |
+| Exit-drain bound | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048` (== maxQueueSize) + `exportTimeoutMillis: 1500` | Dispose must release within ~1s against an unreachable collector: timeoutMillis doubles as the per-attempt socket timeout and the retry deadline (1s effectively disables the SDK's 5-try backoff), and aligning batch size with the queue cap makes the drain a single batch; SDK defaults can stall 40s+ |
+| Compression | `compression: gzip` | Event bodies carry full content; cross-datacenter bandwidth |
+| CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in all 8 GitHub workflows | Every CI channel that boots the web composition (e2e/snapshot/built smokes) must not stream test sessions to the production endpoint |
+
+The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the deployment-level behavior: an in-test OTLP collector plus a mock LLM server, a real `dsh web` boot, asserting ledger coverage, seq monotonicity, the first-of-step chunk projection, and the ops `shutdown` marker arriving through the SIGINT drain.
+
+## Alternatives considered
+
+**No default mount; deployments add the row themselves (continuing the SDK stance).** Rejected for this stage: this repo's web/headless composition IS the internal deployment, and default-on reporting is that deployment's product requirement; the SDK stance survives in the seam packages (unmounted = nothing leaves).
+
+**A config field instead of an env patch for the switch.** Infeasible: cordis rows have no config-level disable semantic, and `exporter.url` validation fails loud at plugin construction, so the switch must take effect before the Loader — AppCLIEntry's patch layer is the only seat.
+
+**A `Promise.race` timeout backstop around exit.** Deferred: the parameter set already bounds the worst-case drain to ~1.5-3s (typically <100ms), measured SIGINT-to-exit 110ms-1.1s; the unbounded drip-feed-response risk stays under observation, and on real evidence the race lands inside the backend's `shutdown()` (never the coordinator — that would decide loss semantics for every backend).
+
+## Consequences
+
+- A developer running `dsh web` without a local collector POSTs to the production endpoint every 10s (silent failure when unreachable; no OTel diag logger is registered); local development sets `DSH_TELEMETRY_DISABLED=1` or points `DSH_TELEMETRY_OTLP_URL` locally.
+- **No redaction rule is mounted yet**: exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `telemetry/record` rules first — the redaction rule, identity Resource attributes (hostname / anonymous user id / surface), and the usage-metrics track are the explicit follow-ups of this decision.
+- Test rigs reusing this tree (e.g. `apps/web/tests/scaffold.ts`) must explicitly disable the row, or fixture sessions stream to whatever collector the environment happens to name.
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md
new file mode 100644
index 0000000000..b447832527
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md
@@ -0,0 +1,39 @@
+# Agent Note: dsh web 组合默认挂载会话遥测(OTel 上报)
+
+Status: implemented
+
+[English](2026-07-31-web-telemetry-default-mount.md) | 中文
+
+## Problem
+
+遥测 seam 与 OTel backend([revival Note](2026-07-23-session-telemetry-otel-revival.md))自完成以来从未接入任何部署组合:没有 roster 行、没有开关、没有节奏口径,内部部署对用户会话零可观测。需要一个部署决策:哪些 surface 上报、报到哪、什么节奏、怎么关、CI 怎么隔离。
+
+## Decision
+
+`dsh` 共享核心(`apps/cli/config/base.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint,因此所有 surface——TUI、web、headless——都上报;这是**内部测试期的部署立场**——有 endpoint 就报,用户可经环境变量退出。各 surface 的退出路径都会排空队列:web/headless 在 SIGINT/SIGTERM 上 dispose(headless 的信号处理是本次补上的),TUI 的正常退出走 `disposeRootAndExit`(根 dispose,5s 兜底——高于此处配置的 ~1s drain 上界),其 `/resume` 移交也在 `execve` 前 dispose 根。
+
+| 决策项 | 取值 | 理由 |
+|---|---|---|
+| 挂载面 | base.cordis.yml(TUI + web + headless) | 所有 surface 一个部署立场;按 surface 分化需要理由,而当前没有 |
+| endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collector;env 覆盖供本地/联调 |
+| 退出开关 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即关 | 隐私向开关取「宁关勿误开」;行级 disable 只能在 AppCLIEntry 的 patch 层做(config 无 disable 语义,且必须先于 `exporter.url` 的加载期校验生效) |
+| 上报节奏 | `processor.scheduledDelayMillis: 10000`(10s/批) | 流式回流,非退出才报;崩溃至多丢最后一个未导出间隔 |
+| 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048(== maxQueueSize)` + `exportTimeoutMillis: 1500` | collector 不可达时 dispose 必须 ~1s 内放行:timeoutMillis 同时是单次 socket 超时与重试 deadline(1s 等效关掉 SDK 5 次 backoff),批大小对齐队列上限使 drain 恒为单批;默认参数下最坏可卡 40s+ |
+| 压缩 | `compression: gzip` | 事件 body 含全文,跨机房带宽 |
+| CI 隔离 | 全部 8 个 GitHub workflow 顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | CI 启动 web 组合的所有通道(e2e/snapshot/built smoke)不得向生产 endpoint 泄测试会话 |
+
+集成测试 `apps/cli/tests/telemetry-web.e2e.ts`(keyless)钉住部署级行为:测试内 OTLP collector + mock LLM,真启动 `dsh web`,断言 ledger 覆盖、seq 单调、chunk 首条投影、以及 SIGINT drain 后 ops `shutdown` 标记到达。
+
+## Alternatives considered
+
+**默认不挂载,部署方自行加行(SDK 立场的延续)。** 否决于当前阶段:本仓的 web/headless 组合就是内部部署本身,「上报默认开」是这个部署的产品要求;SDK 立场仍由 seam 包保持(不挂 = 零外发)。
+
+**开关做成 config 字段而非 env patch。** 不可行:cordis 行没有 config 层的 disable 语义,且 `exporter.url` 校验在插件构造期 fail-loud,开关必须在 Loader 之前生效——AppCLIEntry patch 层是唯一落点。
+
+**退出时 `Promise.race` 兜底超时。** 暂缓:参数组合已把最坏 drain 压到 ~1.5-3s(典型 <100ms),实测 SIGINT→退出 110ms-1.1s;drip-feed 慢滴响应的无界等待风险留观,出现实证再在 backend `shutdown()` 内加 race(不放 coordinator——那会替所有 backend 决定丢失语义)。
+
+## Consequences
+
+- 无本地 collector 的开发者跑 `dsh web` 会对生产 endpoint 每 10s 发一次 POST(联不通则静默失败,OTel diag logger 未注册);本地开发设 `DSH_TELEMETRY_DISABLED=1` 或 `DSH_TELEMETRY_OTLP_URL` 指本地。
+- **当前零脱敏规则挂载**:导出即原始捕获副本(用户/助手消息全文、工具参数与结果、system prompt、`session.cwd` 本地路径)。跨信任边界前必须挂 `telemetry/record` 规则——脱敏规则、身份 Resource 维度(hostname/匿名 user id/surface)、使用数据 metrics 轨三件是本决策明确的后续工作。
+- 复用这棵树的测试载具(如 `apps/web/tests/scaffold.ts`)须显式关停该行,否则 fixture 会话会流向 env 里碰巧存在的 collector。
diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml
index 5965707630..017b77ee75 100644
--- a/.github/workflows/build-exe-for-python-sdk.yml
+++ b/.github/workflows/build-exe-for-python-sdk.yml
@@ -28,6 +28,11 @@ concurrency:
permissions:
contents: read
+env:
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
+
jobs:
# Job-level conditions cannot inspect `matrix`, so validate target names and
# construct the matrix before the dependent jobs.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1f6ec52f0a..6b2a5dd9d5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,6 +24,9 @@ permissions:
env:
PRIMARY_NODE_VERSION: '24'
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
jobs:
diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml
index ab56636fed..6336089a41 100644
--- a/.github/workflows/docs-pages.yml
+++ b/.github/workflows/docs-pages.yml
@@ -23,6 +23,9 @@ permissions:
env:
PRIMARY_NODE_VERSION: '24'
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
jobs:
build:
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index c445034a8c..d72e7bfee4 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -46,6 +46,11 @@ concurrency:
permissions:
contents: read
+env:
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
+
jobs:
e2e:
runs-on: ubuntu-latest
diff --git a/.github/workflows/expected-filenames.yml b/.github/workflows/expected-filenames.yml
index 328da95529..59320b9261 100644
--- a/.github/workflows/expected-filenames.yml
+++ b/.github/workflows/expected-filenames.yml
@@ -10,6 +10,11 @@ on:
permissions:
contents: read
+env:
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
+
jobs:
expected-filenames:
name: no golden filenames
diff --git a/.github/workflows/landlock-run.yml b/.github/workflows/landlock-run.yml
index 8916f59a56..dad9638761 100644
--- a/.github/workflows/landlock-run.yml
+++ b/.github/workflows/landlock-run.yml
@@ -19,6 +19,11 @@ concurrency:
permissions:
contents: read
+env:
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
+
defaults:
run:
working-directory: native/landlock-run
diff --git a/.github/workflows/pi-ai-provider-e2e.yml b/.github/workflows/pi-ai-provider-e2e.yml
index 1306754d4c..255c7654e7 100644
--- a/.github/workflows/pi-ai-provider-e2e.yml
+++ b/.github/workflows/pi-ai-provider-e2e.yml
@@ -19,6 +19,11 @@ on:
permissions:
contents: read
+env:
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
+
jobs:
e2e:
runs-on: ubuntu-latest
diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml
index 36f58cc75b..939ca2f6ab 100644
--- a/.github/workflows/sandbox.yml
+++ b/.github/workflows/sandbox.yml
@@ -19,6 +19,11 @@ concurrency:
permissions:
contents: read
+env:
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
+
jobs:
# Keyless real-kernel sandbox proofs (sandbox Agent Note § Testing): each ladder
# rung is only provable on a host where it enforces, so this job fans out
diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml
index 256587556f..26395105b7 100644
--- a/apps/cli/README.i18n.yaml
+++ b/apps/cli/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
-README.md: d783d75cc9747d13887386fcf7609a6778e5dfb5
-README.zh.md: 3f5ce7e7a3a302fd9e255c1042ccb7b03deb59d9
+README.md: e56b726029c5bba9ba769c6dd3493d913f0129d7
+README.zh.md: 24ff9a6e8d48016d213e877e23768332d86cccde
diff --git a/apps/cli/README.md b/apps/cli/README.md
index d783d75cc9..e56b726029 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -24,6 +24,8 @@ The shipped TUI and Web compositions register the native DeepSeek adapter plus p
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
+Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md).
+
## Install (developer machine)
Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step:
diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md
index 3f5ce7e7a3..24ff9a6e8d 100644
--- a/apps/cli/README.zh.md
+++ b/apps/cli/README.zh.md
@@ -24,6 +24,8 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用
`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
+每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。
+
## 安装(开发机)
将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout,因此代码更改会在下次启动时生效,无需构建:
diff --git a/apps/cli/composition.md b/apps/cli/composition.md
index 2e71c6c07b..870b926054 100644
--- a/apps/cli/composition.md
+++ b/apps/cli/composition.md
@@ -38,6 +38,8 @@ flowchart LR
cfg --> plugin_tui_session_persistence_jsonl
plugin_tui_session_query_sqlite["session-query-sqlite
@deepseek-ai/dsh-session-query-sqlite"]
cfg --> plugin_tui_session_query_sqlite
+ plugin_tui_telemetry_otel["telemetry-otel
@deepseek-ai/dsh-session-telemetry-otel"]
+ cfg --> plugin_tui_telemetry_otel
plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"]
cfg --> plugin_tui_subprocess
plugin_tui_bash_local["bash-local
@deepseek-ai/dsh-bash-local"]
@@ -123,6 +125,7 @@ flowchart LR
| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
+| `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
| `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
| `bash-local` | `@deepseek-ai/dsh-bash-local` |
| `tool-bash` | `@deepseek-ai/dsh-tool-bash` |
diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml
index 16d4e12471..20d3255a7c 100644
--- a/apps/cli/config/base.cordis.yml
+++ b/apps/cli/config/base.cordis.yml
@@ -94,6 +94,38 @@
config:
path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db'
+# Session telemetry, on for every dsh surface: mirrors every session-log
+# event (assistant/chunk projected to first-of-step) plus ops markers onto
+# OTLP/HTTP log records, streaming on the batch processor's cadence
+# (10s/batch here) — not at exit; a crash loses at most the last unexported
+# interval. No telemetry/record redaction rule is mounted yet, so exports
+# are the raw captured copy; the deployment stance, env seams, and
+# follow-ups are pinned in the web-telemetry-default-mount Agent Note.
+# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty
+# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
+# process out (the launchers patch the row disabled; config cannot disable
+# a row). The exporter/processor values bound the shutdown drain to ~1s
+# against an unreachable collector: exporter.timeoutMillis is both the
+# per-attempt socket timeout and the retry deadline (1s effectively
+# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
+# (both explicit) makes the drain a single batch, and exportTimeoutMillis
+# is the processor's own cap on that one export cycle — the second bound
+# when the exporter's clock alone does not fire. Every surface's exit path
+# drains it: web/headless dispose on SIGINT/SIGTERM, and the TUI's normal
+# exit and /resume handoff both dispose the root.
+- id: telemetry-otel
+ name: '@deepseek-ai/dsh-session-telemetry-otel'
+ config:
+ exporter:
+ url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
+ compression: gzip
+ timeoutMillis: 1000
+ processor:
+ scheduledDelayMillis: 10000
+ maxQueueSize: 2048
+ maxExportBatchSize: 2048
+ exportTimeoutMillis: 1500
+
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 94cf6da9c2..37cb4138a0 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -81,6 +81,7 @@
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
+ "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-settings-local": "workspace:^",
@@ -123,7 +124,9 @@
"js-yaml": "^4.2.0"
},
"devDependencies": {
+ "@deepseek-ai/dsh-llm-mock-server": "workspace:^",
"@types/js-yaml": "^4.0.9",
+ "execa": "^10.0.0",
"node-pty": "1.1.0"
}
}
diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts
index f18399e27b..6ac88d23c5 100644
--- a/apps/cli/src/app-cli-entry.ts
+++ b/apps/cli/src/app-cli-entry.ts
@@ -24,6 +24,9 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
const PROFILE_DIR = '.dsh-tmp-profile'
const PROFILE_FILE = 'config.json'
+/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */
+const TELEMETRY_ROW_ID = 'telemetry-otel'
+
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
const ALL_INTERFACES_HOST = '0.0.0.0'
@@ -59,6 +62,38 @@ export function resolveLanTrust(
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
}
+/**
+ * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
+ * value (including `'0'`/`'false'`) disables: a privacy switch prefers
+ * off-by-mistake over on-by-mistake. Throws when the switch is set but the
+ * row is absent — a silently no-op "disabled" privacy switch would keep
+ * exporting while the user believes it is off.
+ * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
+ * @param hasRow - whether the composition carries the {@link TELEMETRY_ROW_ID} row.
+ * @returns the disable patch, or `undefined` when telemetry stays enabled.
+ */
+export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
+ if ((disabledEnv ?? '') === '') return undefined
+ if (!hasRow) {
+ throw new Error(`dsh: DSH_TELEMETRY_DISABLED is set but row "${TELEMETRY_ROW_ID}" is not in this composition`)
+ }
+ return { id: TELEMETRY_ROW_ID, disabled: true }
+}
+
+/**
+ * Whether a config file carries the telemetry row, parsed under the same
+ * `!!js`-tolerant dialect the boot uses — the `hasRow` input for launchers
+ * that compose their patch lists outside {@link AppCLIEntry} (the TUI).
+ * @param file - absolute path of the config or overlay file.
+ * @returns true when a top-level (or inserted) row has the telemetry id.
+ */
+export function configHasTelemetryRow(file: string): boolean {
+ const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
+ if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
+ return (doc as { id?: string; insert?: { id?: string }[] }[]).some(row =>
+ row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID))
+}
+
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
@@ -203,6 +238,12 @@ export class AppCLIEntry {
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
return { id, config: { ...(yml.config ?? {}) as Record, ...bag } }
})
+
+ // Telemetry opt-out: a row can only be turned off at the patch layer
+ // (config cannot disable an entry), and the switch must hold BEFORE the
+ // plugin constructs — its exporter.url validation is load-time fail-loud.
+ const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
+ if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
}
/** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */
diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts
index 5fef797cc5..3ec2792e8e 100644
--- a/apps/cli/src/headless.ts
+++ b/apps/cli/src/headless.ts
@@ -82,6 +82,17 @@ export async function runHeadless(task: string): Promise {
})
const { ctx, port } = await entry.run()
const dispose = async (): Promise => { await ctx.fiber.dispose() }
+ // Signal exits must still dispose the tree: the composition mounts
+ // exit-drained plugins (telemetry's queued tail and shutdown marker would
+ // otherwise be lost), and Node's default signal exit skips disposal.
+ let signalled = false
+ const disposeAndExit = (code: number): void => {
+ if (signalled) return
+ signalled = true
+ void dispose().finally(() => { process.exit(code) })
+ }
+ process.on('SIGTERM', () => { disposeAndExit(143) })
+ process.on('SIGINT', () => { disposeAndExit(130) })
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts
index 3469a737c1..dee865a0e1 100644
--- a/apps/cli/src/tui.ts
+++ b/apps/cli/src/tui.ts
@@ -31,6 +31,7 @@ import {
resolveConfigPath,
} from '@deepseek-ai/dsh-app-boot'
import { SessionId } from '@deepseek-ai/dsh-session'
+import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite'
import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop'
import type { Context } from 'cordis'
@@ -196,16 +197,26 @@ export async function runTui(
// demo or test config would silently run on the user's provider and model.
// `--config-replace` additionally discards the base and the surface overlay.
const replaceTree = configReplace !== undefined
- const patches = replaceTree ? [] : [
- ...loadOverlayPatches(NAME, TUI_OVERLAY),
- ...resolvedConfig === undefined
- ? loadPersonalPatches(NAME) ?? []
- : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
+ const bootConfig = resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined)
+ // Same opt-out semantics as the web surface (resolveTelemetryPatch: any
+ // non-empty value disables; setting the switch against a tree without the
+ // row fails loud rather than silently no-opping a privacy switch). The row
+ // presence is checked against the tree actually booting, so a
+ // --config-replace tree is judged on its own rows, not the shipped base's.
+ const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig))
+ const patches = [
+ ...replaceTree ? [] : [
+ ...loadOverlayPatches(NAME, TUI_OVERLAY),
+ ...resolvedConfig === undefined
+ ? loadPersonalPatches(NAME) ?? []
+ : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
+ ],
+ ...telemetryPatch === undefined ? [] : [telemetryPatch],
]
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
const ctx = await boot(
NAME,
- resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined),
+ bootConfig,
patches,
(hostCtx) => {
// The launcher owns session identity and the exit line: a config-mounted
diff --git a/apps/cli/tests/telemetry-switch.spec.ts b/apps/cli/tests/telemetry-switch.spec.ts
new file mode 100644
index 0000000000..0735aa93c7
--- /dev/null
+++ b/apps/cli/tests/telemetry-switch.spec.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+import { resolveTelemetryPatch } from '../src/app-cli-entry.ts'
+
+describe('resolveTelemetryPatch', () => {
+ it('keeps telemetry enabled when the switch is unset or empty', () => {
+ expect(resolveTelemetryPatch(undefined, true)).toBeUndefined()
+ expect(resolveTelemetryPatch('', true)).toBeUndefined()
+ })
+
+ it('disables on ANY non-empty value, including falsy-looking ones', () => {
+ for (const value of ['1', '0', 'false', 'no']) {
+ expect(resolveTelemetryPatch(value, true)).toEqual({ id: 'telemetry-otel', disabled: true })
+ }
+ })
+
+ it('fails loud when the switch is set but the row is absent', () => {
+ expect(() => resolveTelemetryPatch('1', false)).toThrow('DSH_TELEMETRY_DISABLED is set but row "telemetry-otel" is not in this composition')
+ })
+
+ it('ignores a missing row while the switch is unset', () => {
+ expect(resolveTelemetryPatch(undefined, false)).toBeUndefined()
+ })
+})
diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts
index d8966ee081..359fe6338e 100644
--- a/apps/cli/tests/tui-keyless-smoke.e2e.ts
+++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts
@@ -130,7 +130,9 @@ function smoke(overrides: Partial & { label: string }): Prom
tempDirPrefix: 'dsh-tui-smoke-',
binScript: dshBinScript,
tsconfigPath,
- env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' },
+ // Telemetry now mounts in the shared base: keep fixture sessions from
+ // POSTing to the production endpoint when run outside CI's workflow env.
+ env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call', DSH_TELEMETRY_DISABLED: '1' },
// Artifact CI builds and smokes concurrently on a contended runner.
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
...overrides,
diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts
index 0bab153419..0d53815e09 100644
--- a/apps/web/tests/scaffold.ts
+++ b/apps/web/tests/scaffold.ts
@@ -221,6 +221,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise=20'}
- node-addon-native-custom-loader@0.1.0:
- resolution: {integrity: sha512-LtkRZWBshiGdWB9K7yuQuEeQaoYfWSFMwV52wi4kKsQSRCSjB4Sf4lEgQTiOlVmOznM0Bg9EABLKBowkCDucQQ==}
+ node-addon-native-custom-loader@0.1.3:
+ resolution: {integrity: sha512-uMG8D3aOtEMgh7dkNWAJP0fSpmpMwUf6Cj5JePQQqtxt72sW7RDzRetaLjKIKl3+DZtBX2FobAgRS6U3LO2qXQ==}
engines: {node: '>=20'}
- node-addon-require-builtin-darwin-arm64@0.1.0:
- resolution: {integrity: sha512-KXmOO2gs5um5HXt8k9sZMnNwrgTdnRKOf5NMXLyrY6pc3QCJrdCY6J6STgurjoM4GXOJlfo2emEfOxrIS0sCbg==}
+ node-addon-require-builtin-darwin-arm64@0.1.3:
+ resolution: {integrity: sha512-uBZIRpq3gVG/lg4SV1w8xNfgoaAWZiv7B8Gn38/wd0uUE0LSTRikY69al3Yb4gMDmJPWBoXPN3gzTxAjhllzGg==}
engines: {node: '>=20'}
cpu: [arm64]
os: [darwin]
- node-addon-require-builtin-darwin-x64@0.1.0:
- resolution: {integrity: sha512-m1JkvBslC4ooNUvlvQoOx96d0qk2M1e+bO2gVkl+TT0SD07VGAPXNkxZvyT+O3A63i/1j0rjJ88B78sSdyDiVA==}
+ node-addon-require-builtin-darwin-x64@0.1.3:
+ resolution: {integrity: sha512-BLaBoaBjI7mpsgTpXvn444vCQSmrb1AC2t0tAHFuxwBtN56UT9Zaxl+gS2KZGrq5fShTPxFAUIiTcwroFXWWhg==}
engines: {node: '>=20'}
cpu: [x64]
os: [darwin]
- node-addon-require-builtin-linux-arm64-gnu@0.1.0:
- resolution: {integrity: sha512-76fYWMzYBeT6eunBUrxAUleyMZwQfp8FgB3XLDCrQAsDtH0UVdg+zgmbVIQeJyJQdxTnfsQ9sfvdymG96ZeZew==}
+ node-addon-require-builtin-linux-arm64-gnu@0.1.3:
+ resolution: {integrity: sha512-L+qNUfBarYxE0HSZjf2KGymS6ZKMieLs5esRXbAbO+q1k4L1t9oBNGpQuFe7/a1a98YrfHnkAg9Q6US5j/xnIw==}
engines: {node: '>=20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- node-addon-require-builtin-linux-x64-gnu@0.1.0:
- resolution: {integrity: sha512-dDOumCPgJheVfcHOVq2nCQUp3mRU0Qsu6MfnZzVZMA73tSeQwA+yoUuQW3oPz/wuE51LwXEkm6Se9aerawi0Ng==}
+ node-addon-require-builtin-linux-x64-gnu@0.1.3:
+ resolution: {integrity: sha512-Cy2ua4yy44GE5HAtf/o4LjzTa5aUJt5m0YLMjZCa8lRte5hU+C7aWm6bVkmKY82b1JnnQnHwUMEoFugLqVybSQ==}
engines: {node: '>=20'}
cpu: [x64]
os: [linux]
libc: [glibc]
- node-addon-require-builtin-win32-arm64-msvc@0.1.0:
- resolution: {integrity: sha512-OJ7m8r074Wbtc8mLsh+ugIP4KCwsTyDzfB7FE+7eeSSYRgQlbSOC11jMOYIWqMalLhAWCLkRBw7fYJDty3sSAw==}
+ node-addon-require-builtin-win32-arm64-msvc@0.1.3:
+ resolution: {integrity: sha512-8j/VcAmgT6HPQzwUo1kBNzLE2d5iVmwfraEre5KAoznuBeOiuU12oqDYpkuHGIzSjSDJiVOj/SqOe5mUMRaZOg==}
engines: {node: '>=20'}
cpu: [arm64]
os: [win32]
- node-addon-require-builtin-win32-ia32-msvc@0.1.0:
- resolution: {integrity: sha512-qUhC7MEP0NhuNMwlnPudYIBtPKlUo9McRi3PWsv4539hFar1RfxGmU4DZYtDQk07Bms/NAlIE8X7mxKVu8E+OQ==}
+ node-addon-require-builtin-win32-ia32-msvc@0.1.3:
+ resolution: {integrity: sha512-Iqh+Wxmbu6SaP2lEJpEpIMkusVZeVljn914CIcz7HZtzvxTgxHZAmfsZuRMDtRhDg2Yf4AFBHLWpcJn7SeBQ/A==}
engines: {node: '>=20 <23'}
cpu: [ia32]
os: [win32]
- node-addon-require-builtin-win32-x64-msvc@0.1.0:
- resolution: {integrity: sha512-JHiuwzW6jz6K8UxzoFmthDCUyZcbXlPIim0LuH7rlgz7ebVW7791lJThZp4WYGHWMiHzhljEfVG9YW4DuEwEmA==}
+ node-addon-require-builtin-win32-x64-msvc@0.1.3:
+ resolution: {integrity: sha512-5iI7C/BwwRemDNKXO2b1J/iK1gTRp1278Cwfoy92zgn7KXhv7xsAP8klk/fDu8RWX/o9zk736tRSFTBBGSHf/Q==}
engines: {node: '>=20'}
cpu: [x64]
os: [win32]
- node-addon-require-builtin@0.1.0:
- resolution: {integrity: sha512-HGlhjpNtFP7qtbBIBQ2+eXDe1qXcX4RQa426IMQ+SKoLCQS9AcHYl0kwJCERvG821wfRlJOzGBoREtBOwvUGeg==}
+ node-addon-require-builtin@0.1.3:
+ resolution: {integrity: sha512-u9ZRdwDCx+ksIcYwoLeoe5Rj3151GrzSF8ln9jp7P/Zhf0OrPs1X6a8wYw6brc532ypAwjwKlhruT/V3m8MCbg==}
engines: {node: '>=20'}
node-domexception@1.0.0:
@@ -12144,7 +12153,7 @@ snapshots:
'@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7)':
dependencies:
- '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0)
+ '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3)
cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
cosmokit: 1.8.1
js-yaml: 4.2.0
@@ -12157,12 +12166,12 @@ snapshots:
js-yaml: 4.2.0
optional: true
- '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0)':
+ '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3)':
dependencies:
cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
cosmokit: 1.8.1
optionalDependencies:
- node-addon-require-builtin: 0.1.0
+ node-addon-require-builtin: 0.1.3
'@cordisjs/plugin-timer@1.1.2(cordis@4.0.0-rc.7)':
dependencies:
@@ -14110,7 +14119,7 @@ snapshots:
cosmokit: 1.8.1
optionalDependencies:
'@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7)
- '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0)
+ '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3)
cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader):
dependencies:
@@ -15803,54 +15812,54 @@ snapshots:
node-addon-landlock-run-linux-arm64: 0.0.0-test.0
node-addon-landlock-run-linux-x64: 0.0.0-test.0
- node-addon-native-custom-loader@0.1.0: {}
+ node-addon-native-custom-loader@0.1.3: {}
- node-addon-require-builtin-darwin-arm64@0.1.0:
+ node-addon-require-builtin-darwin-arm64@0.1.3:
dependencies:
- node-addon-native-custom-loader: 0.1.0
+ node-addon-native-custom-loader: 0.1.3
optional: true
- node-addon-require-builtin-darwin-x64@0.1.0:
+ node-addon-require-builtin-darwin-x64@0.1.3:
dependencies:
- node-addon-native-custom-loader: 0.1.0
+ node-addon-native-custom-loader: 0.1.3
optional: true
- node-addon-require-builtin-linux-arm64-gnu@0.1.0:
+ node-addon-require-builtin-linux-arm64-gnu@0.1.3:
dependencies:
- node-addon-native-custom-loader: 0.1.0
+ node-addon-native-custom-loader: 0.1.3
optional: true
- node-addon-require-builtin-linux-x64-gnu@0.1.0:
+ node-addon-require-builtin-linux-x64-gnu@0.1.3:
dependencies:
- node-addon-native-custom-loader: 0.1.0
+ node-addon-native-custom-loader: 0.1.3
optional: true
- node-addon-require-builtin-win32-arm64-msvc@0.1.0:
+ node-addon-require-builtin-win32-arm64-msvc@0.1.3:
dependencies:
- node-addon-native-custom-loader: 0.1.0
+ node-addon-native-custom-loader: 0.1.3
optional: true
- node-addon-require-builtin-win32-ia32-msvc@0.1.0:
+ node-addon-require-builtin-win32-ia32-msvc@0.1.3:
dependencies:
- node-addon-native-custom-loader: 0.1.0
+ node-addon-native-custom-loader: 0.1.3
optional: true
- node-addon-require-builtin-win32-x64-msvc@0.1.0:
+ node-addon-require-builtin-win32-x64-msvc@0.1.3:
dependencies:
- node-addon-native-custom-loader: 0.1.0
+ node-addon-native-custom-loader: 0.1.3
optional: true
- node-addon-require-builtin@0.1.0:
+ node-addon-require-builtin@0.1.3:
dependencies:
- node-addon-native-custom-loader: 0.1.0
+ node-addon-native-custom-loader: 0.1.3
optionalDependencies:
- node-addon-require-builtin-darwin-arm64: 0.1.0
- node-addon-require-builtin-darwin-x64: 0.1.0
- node-addon-require-builtin-linux-arm64-gnu: 0.1.0
- node-addon-require-builtin-linux-x64-gnu: 0.1.0
- node-addon-require-builtin-win32-arm64-msvc: 0.1.0
- node-addon-require-builtin-win32-ia32-msvc: 0.1.0
- node-addon-require-builtin-win32-x64-msvc: 0.1.0
+ node-addon-require-builtin-darwin-arm64: 0.1.3
+ node-addon-require-builtin-darwin-x64: 0.1.3
+ node-addon-require-builtin-linux-arm64-gnu: 0.1.3
+ node-addon-require-builtin-linux-x64-gnu: 0.1.3
+ node-addon-require-builtin-win32-arm64-msvc: 0.1.3
+ node-addon-require-builtin-win32-ia32-msvc: 0.1.3
+ node-addon-require-builtin-win32-x64-msvc: 0.1.3
node-domexception@1.0.0: {}
diff --git a/vendor/loader/package.json b/vendor/loader/package.json
index ad5f14f7cd..c7bbaf5176 100644
--- a/vendor/loader/package.json
+++ b/vendor/loader/package.json
@@ -24,7 +24,7 @@
"license": "MIT",
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
- "node-addon-require-builtin": "^0.1.0"
+ "node-addon-require-builtin": "^0.1.3"
},
"peerDependenciesMeta": {
"node-addon-require-builtin": {