relay-dsh-plugin-manager 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [0.2.6] - 2026-09-07
6
+
7
+ ### Added
8
+
9
+ - Add the operator-only `RELAY_PLUGIN_MANAGER_TELEMETRY_TEST=1` acceptance mode.
10
+ Its schema `1.1.0` events carry only `is_test: true`, allowing growth dashboards
11
+ to exclude production acceptance without changing ordinary anonymous telemetry.
12
+
13
+ ### Compatibility
14
+
15
+ - Ordinary users remain on default-on anonymous telemetry with the same event
16
+ fields and opt-out. The Registry continues accepting legacy schema `1.0.0`.
17
+
18
+ ## [0.2.5] - 2026-09-07
19
+
20
+ ### Added
21
+
22
+ - Default-on anonymous operational analytics for plugin-manager use and
23
+ manager-executed install start, success, and failure outcomes, with explicit
24
+ opt-out through `RELAY_PLUGIN_MANAGER_TELEMETRY=0`.
25
+ - First-party delivery to the Registry's versioned telemetry endpoint; the npm
26
+ package contains no PostHog host or project key.
27
+ - A locally persisted random anonymous identifier; search text, installed
28
+ inventory, local paths, command output, error messages, IP geolocation, and
29
+ account identity are excluded.
30
+
5
31
  ## [0.2.4] - 2026-09-05
6
32
 
7
33
  ### Changed
@@ -130,3 +156,4 @@ All notable changes to this project are documented here.
130
156
  [0.1.0]: https://github.com/yangbobo2021/relay-dsh-plugin-manager/compare/v0.1.0-rc.4...v0.1.0
131
157
  [0.1.1]: https://github.com/yangbobo2021/relay-dsh-plugin-manager/compare/v0.1.0...v0.1.1
132
158
  [0.2.3]: https://github.com/yangbobo2021/relay-dsh-plugin-manager/compare/v0.2.2...v0.2.3
159
+ [0.2.6]: https://github.com/yangbobo2021/relay-dsh-plugin-manager/compare/v0.2.5...v0.2.6
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Manage DeepSeek Harness Plugins from Chat
2
2
 
3
- > **Now supports DSH `0.1.2-rc.1` while retaining `0.1.2-alpha.3` compatibility.** Stable `0.2.3` adds the default evidence-backed DSH Registry search provider. [Install it from npm](https://www.npmjs.com/package/relay-dsh-plugin-manager) · [Compatibility evidence](https://github.com/yangbobo2021/Relay/tree/codex/relay-foundation/dsh-lab/dsh-0.1.2-rc.1-20260903).
3
+ > **Now supports DSH `0.1.2-rc.1` while retaining `0.1.2-alpha.3` compatibility.** The stable channel includes the evidence-backed DSH Registry search provider and anonymous operational analytics. [Install it from npm](https://www.npmjs.com/package/relay-dsh-plugin-manager) · [Compatibility evidence](https://github.com/yangbobo2021/Relay/tree/codex/relay-foundation/dsh-lab/dsh-0.1.2-rc.1-20260903).
4
4
 
5
- > **Release channel:** `latest` → `0.2.3`.
5
+ > **Release channel:** install `relay-dsh-plugin-manager@latest` to receive the current stable manager.
6
6
 
7
7
  ```bash
8
- npx @deepseek-ai/dsh@0.1.2-rc.1 plugin --profile web add relay-dsh-plugin-manager@0.2.3
8
+ npx @deepseek-ai/dsh@0.1.2-rc.1 plugin --profile web add relay-dsh-plugin-manager@latest
9
9
  npx @deepseek-ai/dsh@0.1.2-rc.1 web
10
10
  ```
11
11
 
@@ -100,6 +100,41 @@ missing companion peers, waits for one confirmation, and runs the installs in
100
100
  sequence. Completed changes that still need restart are reported explicitly as
101
101
  restart-required or waiting for a manual restart.
102
102
 
103
+ ### Anonymous usage analytics
104
+
105
+ Anonymous operational analytics are enabled by default to measure plugin-manager
106
+ search and installation outcomes. To opt out without disabling plugin management,
107
+ set this when starting DSH Web:
108
+
109
+ ```bash
110
+ RELAY_PLUGIN_MANAGER_TELEMETRY=0 dsh web
111
+ ```
112
+
113
+ Registry operators performing a production acceptance run can mark only that
114
+ run as test traffic:
115
+
116
+ ```bash
117
+ RELAY_PLUGIN_MANAGER_TELEMETRY_TEST=1 dsh web
118
+ ```
119
+
120
+ This adds only `is_test: true`; ordinary users do not send the marker.
121
+
122
+ The manager sends events only to the first-party
123
+ `https://dsh-plugins.tech/v1/telemetry/events` endpoint. The Registry validates
124
+ the exact event schema and forwards accepted events to its PostHog analytics
125
+ project. The plugin records only the manager action type and plugin
126
+ install start, success, or failure events produced by the manager. Install
127
+ events contain the public package name, activation/restart booleans, and a
128
+ bounded error code. Search terms, conversation content, local paths, command
129
+ output, error text, IP geolocation, and account identity are not sent. A random
130
+ anonymous ID is stored in `.relay-plugin-manager/telemetry.json` inside the
131
+ current Profile to avoid counting one installation as many users; delete the
132
+ file to reset it.
133
+
134
+ These events measure manager use and manager-executed installs. They do not
135
+ claim that another plugin's feature was actually used merely because it loaded
136
+ or activated.
137
+
103
138
  ### DSH Registry discovery provider
104
139
 
105
140
  The source-only Registry search provider uses `https://dsh-plugins.tech` by
package/README.zh.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # 在 DeepSeek Harness 对话中管理插件
2
2
 
3
- > **现已支持 DSH `0.1.2-rc.1`,并保留对 `0.1.2-alpha.3` 的兼容。** 稳定版 `0.2.3` 默认接入了有证据边界的 DSH Registry 搜索。[从 npm 安装](https://www.npmjs.com/package/relay-dsh-plugin-manager) · [兼容性证据](https://github.com/yangbobo2021/Relay/tree/codex/relay-foundation/dsh-lab/dsh-0.1.2-rc.1-20260903)。
3
+ > **现已支持 DSH `0.1.2-rc.1`,并保留对 `0.1.2-alpha.3` 的兼容。** 稳定通道已接入有证据边界的 DSH Registry 搜索和匿名运行统计。[从 npm 安装](https://www.npmjs.com/package/relay-dsh-plugin-manager) · [兼容性证据](https://github.com/yangbobo2021/Relay/tree/codex/relay-foundation/dsh-lab/dsh-0.1.2-rc.1-20260903)。
4
4
 
5
- > **发布通道:** `latest` → `0.2.3`。
5
+ > **发布通道:** 安装 `relay-dsh-plugin-manager@latest` 即可获得当前稳定版管理器。
6
6
 
7
7
  ```bash
8
- npx @deepseek-ai/dsh@0.1.2-rc.1 plugin --profile web add relay-dsh-plugin-manager@0.2.3
8
+ npx @deepseek-ai/dsh@0.1.2-rc.1 plugin --profile web add relay-dsh-plugin-manager@latest
9
9
  npx @deepseek-ai/dsh@0.1.2-rc.1 web
10
10
  ```
11
11
 
@@ -95,6 +95,34 @@ dsh web
95
95
  一次确认后依次安装。修改完成但仍需重启时,状态会明确区分“需要另行重启”和
96
96
  “等待人工重启”。
97
97
 
98
+ ### 匿名使用统计
99
+
100
+ 匿名运行统计默认开启,用于衡量插件管理器的搜索和安装结果。如需关闭,可在启动
101
+ DSH Web 时设置以下变量;关闭统计不会影响插件管理功能:
102
+
103
+ ```bash
104
+ RELAY_PLUGIN_MANAGER_TELEMETRY=0 dsh web
105
+ ```
106
+
107
+ Registry 运营方执行生产验收时,可以只标记该次运行:
108
+
109
+ ```bash
110
+ RELAY_PLUGIN_MANAGER_TELEMETRY_TEST=1 dsh web
111
+ ```
112
+
113
+ 该模式只增加 `is_test: true`;普通用户不会发送这个标记。
114
+
115
+ 管理器只向第一方接口 `https://dsh-plugins.tech/v1/telemetry/events` 发送事件;Registry
116
+ 会校验严格的事件结构,再把通过校验的事件转发到自己的 PostHog 项目。统计只记录
117
+ 管理器操作类型,以及由管理器执行的插件安装开始、成功或失败;安装
118
+ 事件包含公开的插件包名、是否激活、是否需要重启和有限错误代码。不会发送搜索词、
119
+ 对话内容、本地路径、命令输出、错误文本、IP 地理位置或登录身份。一个随机匿名 ID
120
+ 保存在当前 Profile 的 `.relay-plugin-manager/telemetry.json`,用于避免把同一安装重复
121
+ 计算为多个用户;删除该文件即可重置。
122
+
123
+ 这些数据只能说明“插件管理器被使用”和“管理器执行了安装”,不会把插件加载或
124
+ 激活推断成其他插件的真实功能使用。
125
+
98
126
  ### DSH Registry 发现数据源
99
127
 
100
128
  来源级 Registry 搜索 provider 默认使用 `https://dsh-plugins.tech`。可设置插件
package/SPEC.md CHANGED
@@ -51,6 +51,7 @@ service.
51
51
  | PM-024 | GitHub owner discovery recognizes `owner:<name>`, owner-only GitHub identities, `<name> DSH plugins`, and conservative bare identifiers containing digits. It sends a typed owner intent to providers, uses GitHub's exact owner qualifier, case-insensitively verifies returned ownership, and ranks verified owner matches first. Owner-only `inspect` fails with an actionable error directing callers to search. Every emitted repository identity is accepted by `inspect`; every recommended immutable source is accepted by `plan`. |
52
52
  | PM-025 | Register a read-only DSH Registry provider against `https://dsh-plugins.tech` by default, with an explicit `registryUrl: false` opt-out and HTTPS-only configuration override. Send only the task query, inferred Chinese/English locale and result limit; accept only source-level untrusted DiscoveryEntry records, convert their npm/GitHub descriptors into core-owned source types, and submit them to the same mandatory local inspection used by all providers. Registry responses cannot supply exact versions, approval, plans, installers, or profile state, and provider failure remains isolated. |
53
53
  | PM-026 | Return search candidates as one explicit, one-based relevance-ranked result page with a default and maximum size of 20. The model-facing contract requires the Agent to preserve rank order, surface every possibly relevant returned candidate, exclude candidates whose purpose is clearly unrelated, and MUST NOT silently reduce the remaining page to a fixed top-N. Ranking remains distinct from compatibility, security, or installation approval. |
54
+ | PM-027 | Send default-on anonymous operational telemetry only to the Registry's canonical first-party HTTPS endpoint under schema `1.1.0`, with explicit `RELAY_PLUGIN_MANAGER_TELEMETRY=0` or `telemetry.enabled: false` opt-out. Events are limited to manager discovery/planning type and manager-executed install start/success/failure, including per-plugin batch outcomes. An operator-only `RELAY_PLUGIN_MANAGER_TELEMETRY_TEST=1` or `telemetry.test: true` mode adds only `is_test: true`; ordinary events omit the marker. Never send raw search text, conversation content, Profile state, installed inventory, paths, credentials, command output, error text, account identity, PostHog configuration, or client timestamps. Store only a stable random anonymous ID locally, create it lazily, and never let storage, validation, network, Registry, or analytics failure affect plugin management. |
54
55
 
55
56
  ## Command Grammar
56
57
 
@@ -39,6 +39,7 @@ This matrix is the release contract. Tests name the relevant `PM-*` ids.
39
39
  | A-031 search results feed one immutable multi-install plan through the real DSH tool and user-question registries; no mutation starts before one exact UI approval, then child installs execute serially and approval replay is rejected | PM-002, PM-005, PM-009, PM-010, PM-015, PM-023, PM-024 | combined host/tool/search/question/manager integration |
40
40
  | A-032 The default formal Registry searches at least 3,000 source records for Chinese task text, English task text, exact plugin names and an honest empty result; every returned source is locally inspected to an immutable npm version or GitHub commit, while Registry authority remains source-only, untrusted, non-recommending and non-approving | PM-005, PM-006, PM-025 | provider/unit/host integration tests plus `npm run acceptance:live:registry` |
41
41
  | A-033 Search defaults to a complete twenty-candidate result page, assigns stable one-based display ranks after merge and deduplication, and tells the Agent to exclude clearly unrelated purposes while surfacing every remaining possibly relevant candidate without silent fixed-count truncation | PM-005, PM-026 | search orchestration and conversation-tool contract unit tests plus Agent answer acceptance |
42
+ | A-034 Default manager actions send only schema-valid anonymous envelopes to the first-party Registry endpoint, reuse a lazily created random ID, record each attempted batch install outcome, and continue normally through storage or delivery failure; explicit opt-out creates no ID and makes no telemetry request | PM-027 | telemetry unit tests + manager integration event assertions + Registry endpoint contract and end-to-end PostHog verification |
42
43
 
43
44
  ## Test Layers
44
45
 
package/lib/index.d.ts CHANGED
@@ -2,6 +2,20 @@ import { a as PluginSearchRuntime, c as PluginSource, i as PluginSearchRequest,
2
2
  import { spawn } from "node:child_process";
3
3
  import { Context } from "@deepseek-ai/cordis";
4
4
 
5
+ //#region src/telemetry.d.ts
6
+ type TelemetryProperty = string | number | boolean;
7
+ interface Telemetry {
8
+ capture(event: string, properties?: Readonly<Record<string, TelemetryProperty>>): void;
9
+ }
10
+ interface TelemetryConfig {
11
+ /** Anonymous operational telemetry is enabled unless this is explicitly false. */
12
+ enabled?: boolean;
13
+ /** Registry telemetry endpoint. Only the canonical service or localhost is accepted. */
14
+ endpoint?: string;
15
+ /** Marks an operator-controlled acceptance run so analytics can exclude it. */
16
+ test?: boolean;
17
+ }
18
+ //#endregion
5
19
  //#region src/profile.d.ts
6
20
  interface PackageSurface {
7
21
  packageName: string;
@@ -238,6 +252,7 @@ interface PluginManagerDependencies {
238
252
  operations?: OperationTracker;
239
253
  fetchOptions?: Omit<FetchOptions, 'signal'>;
240
254
  hmrTimeoutMs?: number;
255
+ telemetry?: Telemetry;
241
256
  }
242
257
  interface DiscoverRequest {
243
258
  action: 'list' | 'search' | 'inspect' | 'status';
@@ -301,7 +316,9 @@ declare class PluginManager {
301
316
  private readonly operations;
302
317
  private readonly fetchOptions;
303
318
  private readonly hmrTimeoutMs;
319
+ private readonly telemetry;
304
320
  constructor(dependencies: PluginManagerDependencies);
321
+ private capture;
305
322
  private loaderEntries;
306
323
  list(): PluginStatus[];
307
324
  discover(request: DiscoverRequest, signal?: AbortSignal): Promise<unknown>;
@@ -331,8 +348,9 @@ declare const DEFAULT_REGISTRY_ORIGIN = "https://dsh-plugins.tech";
331
348
  interface Config {
332
349
  allowRestart?: boolean;
333
350
  registryUrl?: string | false;
351
+ telemetry?: TelemetryConfig;
334
352
  }
335
353
  declare function apply(ctx: Context, config?: Config): void;
336
354
  //#endregion
337
- export { Config, DEFAULT_REGISTRY_ORIGIN, type DiscoverRequest, type InstallManyItemResult, type InstallManyItemStatus, type InstallManyResult, type MutationResult, type PlanRequest, type PluginInspection, PluginManager, type PluginSearchCandidate, type PluginSearchMatch, type PluginSearchProvider, type PluginSearchRequest, type PluginSource, apply, inject, name };
355
+ export { Config, DEFAULT_REGISTRY_ORIGIN, type DiscoverRequest, type InstallManyItemResult, type InstallManyItemStatus, type InstallManyResult, type MutationResult, type PlanRequest, type PluginInspection, PluginManager, type PluginSearchCandidate, type PluginSearchMatch, type PluginSearchProvider, type PluginSearchRequest, type PluginSource, type TelemetryConfig, apply, inject, name };
338
356
  //# sourceMappingURL=index.d.ts.map
package/lib/index.js CHANGED
@@ -1223,11 +1223,56 @@ const PROTECTED_ENTRY_IDS = /* @__PURE__ */ new Set([
1223
1223
  "webserver",
1224
1224
  "web-runtime"
1225
1225
  ]);
1226
+ const TELEMETRY_ERROR_CODES = /* @__PURE__ */ new Set([
1227
+ "INVALID_SOURCE",
1228
+ "INVALID_NPM_SPEC",
1229
+ "INVALID_NPM_VERSION",
1230
+ "INVALID_GITHUB_SPEC",
1231
+ "INVALID_GITHUB_REF",
1232
+ "GITHUB_OWNER_REQUIRES_SEARCH",
1233
+ "IMMUTABLE_SOURCE_REQUIRED",
1234
+ "NETWORK_ERROR",
1235
+ "SOURCE_HTTP_ERROR",
1236
+ "INVALID_SOURCE_METADATA",
1237
+ "INVALID_PLUGIN_MANIFEST",
1238
+ "NOT_DSH_PLUGIN",
1239
+ "PACKAGE_NAME_MISMATCH",
1240
+ "NPM_INTEGRITY_MISSING",
1241
+ "INVALID_SEARCH_QUERY",
1242
+ "INVALID_ACTION",
1243
+ "INVALID_BATCH",
1244
+ "DUPLICATE_SEARCH_PROVIDER",
1245
+ "PROFILE_READ_FAILED",
1246
+ "PROFILE_WRITE_FAILED",
1247
+ "PLUGIN_NOT_INSTALLED",
1248
+ "PLUGIN_ALREADY_INSTALLED",
1249
+ "ENABLEMENT_UNSUPPORTED",
1250
+ "ENABLEMENT_CONFLICT",
1251
+ "PROTECTED_PLUGIN",
1252
+ "CONFIRMATION_REQUIRED",
1253
+ "CONFIRMATION_INVALID",
1254
+ "CONFIRMATION_EXPIRED",
1255
+ "CONFIRMATION_REPLAYED",
1256
+ "PLAN_STALE",
1257
+ "OPERATION_NOT_FOUND",
1258
+ "DSH_COMMAND_FAILED",
1259
+ "BATCH_INSTALL_FAILED",
1260
+ "POSTCONDITION_FAILED",
1261
+ "RESTART_UNAVAILABLE"
1262
+ ]);
1226
1263
  function safePackageName(value) {
1227
1264
  const name = value?.trim() ?? "";
1228
1265
  if (!NPM_NAME.test(name)) fail("INVALID_NPM_SPEC", "A valid installed package name is required.");
1229
1266
  return name;
1230
1267
  }
1268
+ function queryLengthBucket(value) {
1269
+ const length = value?.trim().length ?? 0;
1270
+ if (length === 0) return "empty";
1271
+ if (length <= 10) return "1-10";
1272
+ if (length <= 30) return "11-30";
1273
+ if (length <= 80) return "31-80";
1274
+ return "81+";
1275
+ }
1231
1276
  function commandResult(result) {
1232
1277
  return {
1233
1278
  exitCode: result.exitCode,
@@ -1241,6 +1286,10 @@ function operationError(error) {
1241
1286
  message: error instanceof Error ? error.message : String(error)
1242
1287
  };
1243
1288
  }
1289
+ function telemetryErrorCode(error) {
1290
+ const code = operationError(error).code;
1291
+ return code !== void 0 && TELEMETRY_ERROR_CODES.has(code) ? code : "UNKNOWN";
1292
+ }
1244
1293
  function installedPackageManifest(profileDir, packageName) {
1245
1294
  try {
1246
1295
  return JSON.parse(readFileSync(join(profileDir, "node_modules", packageName, "package.json"), "utf8"));
@@ -1265,6 +1314,7 @@ var PluginManager = class {
1265
1314
  operations;
1266
1315
  fetchOptions;
1267
1316
  hmrTimeoutMs;
1317
+ telemetry;
1268
1318
  constructor(dependencies) {
1269
1319
  this.profileDir = dependencies.profileDir;
1270
1320
  this.searchRuntime = dependencies.searchRuntime;
@@ -1277,6 +1327,12 @@ var PluginManager = class {
1277
1327
  this.operations = dependencies.operations ?? new OperationTracker();
1278
1328
  this.fetchOptions = dependencies.fetchOptions ?? {};
1279
1329
  this.hmrTimeoutMs = dependencies.hmrTimeoutMs ?? 5e3;
1330
+ this.telemetry = dependencies.telemetry ?? { capture() {} };
1331
+ }
1332
+ capture(event, properties = {}) {
1333
+ try {
1334
+ this.telemetry.capture(event, properties);
1335
+ } catch {}
1280
1336
  }
1281
1337
  loaderEntries() {
1282
1338
  if (this.loader === void 0) return [];
@@ -1297,6 +1353,14 @@ var PluginManager = class {
1297
1353
  return listPluginStatuses(this.profileDir, this.loaderEntries());
1298
1354
  }
1299
1355
  async discover(request, signal) {
1356
+ this.capture("plugin_manager_used", {
1357
+ surface: "discover",
1358
+ action: request.action,
1359
+ ...request.action === "search" ? {
1360
+ has_query: (request.query?.trim().length ?? 0) > 0,
1361
+ query_length_bucket: queryLengthBucket(request.query)
1362
+ } : {}
1363
+ });
1300
1364
  if (request.action === "list") return {
1301
1365
  profile: "web",
1302
1366
  plugins: this.list()
@@ -1360,6 +1424,11 @@ var PluginManager = class {
1360
1424
  });
1361
1425
  }
1362
1426
  async plan(request, signal) {
1427
+ this.capture("plugin_manager_used", {
1428
+ surface: "plan",
1429
+ action: request.operation,
1430
+ ...request.operation === "install_many" ? { batch_size: request.sources?.length ?? 0 } : {}
1431
+ });
1363
1432
  if (request.operation === "install_many") return await this.planInstallMany(request.sources, signal);
1364
1433
  if (request.operation === "restart") {
1365
1434
  if (!this.restarter.available()) fail("RESTART_UNAVAILABLE", "Automatic restart is unavailable in this deployment.");
@@ -1486,7 +1555,22 @@ var PluginManager = class {
1486
1555
  if (plan.packageName === void 0) fail("POSTCONDITION_FAILED", "Mutation plan has no package name.");
1487
1556
  if (plan.action === "install" || plan.action === "update") {
1488
1557
  if (plan.installSpec === void 0) fail("POSTCONDITION_FAILED", "Install/update plan has no immutable source.");
1489
- return this.withRestartGuidance(await this.installOrUpdate(plan.action, plan.packageName, plan.installSpec, context), automaticRestartAvailable);
1558
+ if (plan.action === "install") this.capture("plugin_install_started", { plugin_name: plan.packageName });
1559
+ try {
1560
+ const result = this.withRestartGuidance(await this.installOrUpdate(plan.action, plan.packageName, plan.installSpec, context), automaticRestartAvailable);
1561
+ if (plan.action === "install") this.capture("plugin_install_succeeded", {
1562
+ plugin_name: plan.packageName,
1563
+ activated: result.activated === true,
1564
+ restart_required: result.restartRequired
1565
+ });
1566
+ return result;
1567
+ } catch (error) {
1568
+ if (plan.action === "install") this.capture("plugin_install_failed", {
1569
+ plugin_name: plan.packageName,
1570
+ error_code: telemetryErrorCode(error)
1571
+ });
1572
+ throw error;
1573
+ }
1490
1574
  }
1491
1575
  if (plan.action === "remove") return this.withRestartGuidance(await this.remove(plan.packageName, context), automaticRestartAvailable);
1492
1576
  return this.withRestartGuidance(await this.toggle(plan.action, plan.packageName, context), automaticRestartAvailable);
@@ -1534,6 +1618,10 @@ var PluginManager = class {
1534
1618
  break;
1535
1619
  }
1536
1620
  context.progress(`install_many: ${index + 1}/${items.length} installing ${item.packageName}`);
1621
+ this.capture("plugin_install_started", {
1622
+ plugin_name: item.packageName,
1623
+ batch: true
1624
+ });
1537
1625
  try {
1538
1626
  const result = this.withRestartGuidance(await this.installOrUpdate("install", item.packageName, item.installSpec, context), automaticRestartAvailable);
1539
1627
  results.push({
@@ -1542,6 +1630,12 @@ var PluginManager = class {
1542
1630
  status: this.mutationCompletion(result, automaticRestartAvailable).status,
1543
1631
  result
1544
1632
  });
1633
+ this.capture("plugin_install_succeeded", {
1634
+ plugin_name: item.packageName,
1635
+ batch: true,
1636
+ activated: result.activated === true,
1637
+ restart_required: result.restartRequired
1638
+ });
1545
1639
  } catch (error) {
1546
1640
  results.push({
1547
1641
  packageName: item.packageName,
@@ -1549,6 +1643,11 @@ var PluginManager = class {
1549
1643
  status: context.signal.aborted ? "cancelled" : "failed",
1550
1644
  error: operationError(error)
1551
1645
  });
1646
+ this.capture("plugin_install_failed", {
1647
+ plugin_name: item.packageName,
1648
+ batch: true,
1649
+ error_code: telemetryErrorCode(error)
1650
+ });
1552
1651
  skipRemaining(index + 1);
1553
1652
  break;
1554
1653
  }
@@ -2078,6 +2177,108 @@ var DshCliRunner = class {
2078
2177
  }
2079
2178
  };
2080
2179
  //#endregion
2180
+ //#region src/telemetry.ts
2181
+ const DEFAULT_ENDPOINT = "https://dsh-plugins.tech/v1/telemetry/events";
2182
+ const STATE_DIRECTORY = ".relay-plugin-manager";
2183
+ const STATE_FILE = "telemetry.json";
2184
+ const SCHEMA_VERSION = "1.1.0";
2185
+ const EVENTS = /* @__PURE__ */ new Set([
2186
+ "plugin_manager_used",
2187
+ "plugin_install_started",
2188
+ "plugin_install_succeeded",
2189
+ "plugin_install_failed"
2190
+ ]);
2191
+ const noopTelemetry = Object.freeze({ capture() {} });
2192
+ function safeEndpoint(value) {
2193
+ try {
2194
+ const parsed = new URL(value ?? DEFAULT_ENDPOINT);
2195
+ const local = [
2196
+ "localhost",
2197
+ "127.0.0.1",
2198
+ "::1"
2199
+ ].includes(parsed.hostname);
2200
+ const canonical = parsed.protocol === "https:" && parsed.hostname === "dsh-plugins.tech";
2201
+ if (!local && !canonical || local && !["http:", "https:"].includes(parsed.protocol)) return null;
2202
+ if (parsed.username !== "" || parsed.password !== "" || parsed.pathname !== "/v1/telemetry/events" || parsed.search !== "" || parsed.hash !== "") return null;
2203
+ return parsed.href;
2204
+ } catch {
2205
+ return null;
2206
+ }
2207
+ }
2208
+ function anonymousId(profileDir, random) {
2209
+ const directory = join(profileDir, STATE_DIRECTORY);
2210
+ const path = join(directory, STATE_FILE);
2211
+ try {
2212
+ const existing = JSON.parse(readFileSync(path, "utf8"));
2213
+ if (typeof existing.anonymousId === "string" && /^[0-9a-f-]{36}$/iu.test(existing.anonymousId)) return existing.anonymousId;
2214
+ } catch {}
2215
+ const id = random();
2216
+ try {
2217
+ mkdirSync(directory, {
2218
+ recursive: true,
2219
+ mode: 448
2220
+ });
2221
+ writeFileSync(path, `${JSON.stringify({ anonymousId: id })}\n`, { mode: 384 });
2222
+ } catch {}
2223
+ return id;
2224
+ }
2225
+ function allowedProperties(event, properties) {
2226
+ const keys = new Set(Object.keys(properties));
2227
+ const exact = (required, optional = []) => {
2228
+ if (required.some((key) => !keys.has(key))) return false;
2229
+ return [...keys].every((key) => required.includes(key) || optional.includes(key));
2230
+ };
2231
+ if (event === "plugin_manager_used") {
2232
+ if (!exact(["surface", "action"], [
2233
+ "has_query",
2234
+ "query_length_bucket",
2235
+ "batch_size"
2236
+ ])) return false;
2237
+ if (!["discover", "plan"].includes(String(properties.surface))) return false;
2238
+ return typeof properties.action === "string";
2239
+ }
2240
+ if (event === "plugin_install_started") return exact(["plugin_name"], ["batch"]);
2241
+ if (event === "plugin_install_succeeded") return exact([
2242
+ "plugin_name",
2243
+ "activated",
2244
+ "restart_required"
2245
+ ], ["batch"]);
2246
+ if (event === "plugin_install_failed") return exact(["plugin_name", "error_code"], ["batch"]);
2247
+ return false;
2248
+ }
2249
+ function createTelemetry(profileDir, config, runtime = {
2250
+ fetch,
2251
+ random: randomUUID
2252
+ }) {
2253
+ if (config?.enabled === false) return noopTelemetry;
2254
+ const endpoint = safeEndpoint(config?.endpoint);
2255
+ if (endpoint === null) return noopTelemetry;
2256
+ let distinctId;
2257
+ return Object.freeze({ capture(event, properties = {}) {
2258
+ if (!EVENTS.has(event) || !allowedProperties(event, properties)) return;
2259
+ distinctId ??= anonymousId(profileDir, runtime.random);
2260
+ const controller = new AbortController();
2261
+ const timeout = setTimeout(() => controller.abort(), 5e3);
2262
+ timeout.unref?.();
2263
+ try {
2264
+ runtime.fetch(endpoint, {
2265
+ method: "POST",
2266
+ headers: { "content-type": "application/json" },
2267
+ body: JSON.stringify({
2268
+ schema_version: SCHEMA_VERSION,
2269
+ anonymous_id: distinctId,
2270
+ event,
2271
+ properties,
2272
+ ...config?.test === true ? { is_test: true } : {}
2273
+ }),
2274
+ signal: controller.signal
2275
+ }).catch(() => void 0).finally(() => clearTimeout(timeout));
2276
+ } catch {
2277
+ clearTimeout(timeout);
2278
+ }
2279
+ } });
2280
+ }
2281
+ //#endregion
2081
2282
  //#region src/index.ts
2082
2283
  const name = "relay-dsh-plugin-manager";
2083
2284
  const inject = [
@@ -2090,6 +2291,11 @@ const inject = [
2090
2291
  const DEFAULT_REGISTRY_ORIGIN = "https://dsh-plugins.tech";
2091
2292
  function apply(ctx, config = {}) {
2092
2293
  const profileDir = profileDirectory("web");
2294
+ const telemetry = config.telemetry ?? {
2295
+ enabled: process.env.RELAY_PLUGIN_MANAGER_TELEMETRY !== "0",
2296
+ endpoint: process.env.RELAY_PLUGIN_MANAGER_TELEMETRY_ENDPOINT,
2297
+ test: process.env.RELAY_PLUGIN_MANAGER_TELEMETRY_TEST === "1"
2298
+ };
2093
2299
  ctx.pluginSearch.register(npmSearchProvider());
2094
2300
  ctx.pluginSearch.register(githubSearchProvider());
2095
2301
  const configuredRegistryUrl = config.registryUrl ?? process.env.DSH_PLUGIN_REGISTRY_URL?.trim();
@@ -2101,7 +2307,8 @@ function apply(ctx, config = {}) {
2101
2307
  runner: new DshCliRunner(),
2102
2308
  hot: new HotRuntime(ctx, profileDir),
2103
2309
  restarter: new DshRestarter({ allowRestart: config.allowRestart }),
2104
- loader: ctx.loader
2310
+ loader: ctx.loader,
2311
+ telemetry: createTelemetry(profileDir, telemetry)
2105
2312
  }));
2106
2313
  }
2107
2314
  //#endregion