dsh-remote-bridge 2.6.1 → 2.7.0

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/lib/index.d.ts CHANGED
@@ -1096,9 +1096,29 @@ interface LlmLike {
1096
1096
  * `this.ctx.llm.resolveModelInfo(...)` 再查 `inputModalities`)。入口要与内核
1097
1097
  * 得出同一个结论,只能查同一份元数据 —— 名字、前缀、家族表都是启发式,会漂。
1098
1098
  */
1099
- resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<{
1100
- inputModalities?: readonly string[];
1101
- } | undefined>;
1099
+ resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfoLike | undefined>;
1100
+ }
1101
+ /**
1102
+ * `ctx.llm.resolveModelInfo()` 返回值里桥消费的那几个字段。
1103
+ *
1104
+ * 内核的 `LlmResolvedModelInfo` 比这更宽(还有 `reasoning` / `systemPromptUpdate`
1105
+ * / `toolUpdate`),这里只声明真正读的:上下文容量与默认输出上限。
1106
+ *
1107
+ * **为什么必须从 `resolveModelInfo` 拿、而不是从 `modelCatalog()` 拿**:
1108
+ * 0.2.x 的 `ModelCatalogModel` 只有 `{ id, name, description?, reasoning? }` ——
1109
+ * 目录里**没有** `contextWindow` / `maxTokens` / `inputModalities`。这三个只在
1110
+ * `LlmResolvedModelInfo`(= `LlmModelInfo` + `context` + `defaultMaxTokens`)里。
1111
+ * 目录条目自带这两个字段是 1.x 的旧形状,2.x 拆开了。
1112
+ */
1113
+ interface LlmResolvedModelInfoLike {
1114
+ /** 输入模态;`undefined` = 未知,显式缺 `image` = 负能力。 */
1115
+ readonly inputModalities?: readonly string[];
1116
+ /** 上下文容量(请求 + 响应合计 token 上限);内核不知道时不存在。 */
1117
+ readonly context?: {
1118
+ readonly contextWindow: number;
1119
+ };
1120
+ /** 调用方不显式给上限时,adapter 会用的每请求输出上限。 */
1121
+ readonly defaultMaxTokens?: number;
1102
1122
  }
1103
1123
  /** Host services the bridge consumes; assembled by `src/index.ts` from `ctx`. */
1104
1124
  interface Dsh02HostServices {
@@ -1651,6 +1671,19 @@ interface ChatSessionView {
1651
1671
  origin?: 'subagent';
1652
1672
  pendingInteraction?: 'approval';
1653
1673
  }
1674
+ /**
1675
+ * 建会话的可选参数。
1676
+ *
1677
+ * `reuseBlank` 是**桥**加的语义,不是内核字段:DSH 0.2.1 起桌面端点「新会话」会回到
1678
+ * 那个工作区已有的空白会话,而内核 RPC 不做这层挑选(见 `reusableBlankId`)。
1679
+ * 桥默认照桌面语义走(省略即 `true`),但留一个 `false` 让调用方要「无论如何新建一个」
1680
+ * 时能要得到 —— 老客户端从来不带这个字段,行为因此是**新增**而不是**变更**。
1681
+ */
1682
+ interface CreateSessionOptions {
1683
+ sessionId?: string;
1684
+ agentPreset?: string;
1685
+ reuseBlank?: boolean;
1686
+ }
1654
1687
  interface PendingApprovalView {
1655
1688
  id: string;
1656
1689
  sessionId: string;
@@ -1806,20 +1839,49 @@ declare class DshChatAdapter {
1806
1839
  defaultPreset: string;
1807
1840
  }>;
1808
1841
  selectAgentPreset(principal: Principal, sessionId: string, agentPreset: string): Promise<string>;
1809
- createSessionInWorkspace(principal: Principal, workspaceId: string, options: {
1810
- sessionId?: string;
1811
- agentPreset?: string;
1812
- }): Promise<{
1842
+ createSessionInWorkspace(principal: Principal, workspaceId: string, options: CreateSessionOptions): Promise<{
1813
1843
  id: string;
1814
1844
  agentPreset?: string;
1815
1845
  }>;
1816
- createSession(principal: Principal, rootId: string, relativePath: string, options: {
1817
- sessionId?: string;
1818
- agentPreset?: string;
1819
- }): Promise<{
1846
+ createSession(principal: Principal, rootId: string, relativePath: string, options: CreateSessionOptions): Promise<{
1820
1847
  id: string;
1821
1848
  agentPreset?: string;
1822
1849
  }>;
1850
+ /**
1851
+ * 挑一个可复用的空白会话 —— DSH 0.2.1 起桌面端的「New Session 回到空白会话」语义。
1852
+ *
1853
+ * **内核自己不做这件事。** `sessionController.create` 只有调用方显式传了 `sessionId`
1854
+ * 才会走 `claim` / `reuseLive` 去复用;不传就无条件 `createMinted` 新建一个。
1855
+ * 「跳过 preset 不匹配的空白会话」那层挑选写在**客户端**(`dsh-client-ui-workspace`
1856
+ * 的 `connectWorkspace` → `createInWorkspace`)里,而桥走 REST 直连内核,没有这一层 ——
1857
+ * 于是手机端每点一次「新会话」就多一个空白会话,攒成一串永远没人用的空壳。
1858
+ *
1859
+ * 这里把客户端那条判据原样搬过来(`blank && cwd 匹配 && 未归档 && preset 匹配`),
1860
+ * 取用顺序也照抄:**按内核返回的列表顺序取第一个**,不自己按时间排序 —— 同一份数据
1861
+ * 在桌面与手机上应当落到同一条会话上,桥没有理由发明第三种顺序。
1862
+ *
1863
+ * preset 的口径也与内核一致:空白会话**没声明** preset 时对任何预设都算可用
1864
+ * (只有 `typeof listed === 'string'` 才比较)。「没声明」不等于「声明了别的」——
1865
+ * 把前者当成不匹配会让所有老会话都挑不中。
1866
+ */
1867
+ private reusableBlankId;
1868
+ /**
1869
+ * 工作区维度上找一个可复用的空白会话。
1870
+ *
1871
+ * 归属判据用**内核自己的两个字段**:`session.cwd === workspace.path` 且
1872
+ * `workspace.sessionIds` 含它 —— 与客户端 `createInWorkspace` 逐字同义。
1873
+ * 这里重新拉一次原始 workspace 列表(而不是复用 `listWorkspaces` 的投影结果),
1874
+ * 因为 `ChatWorkspaceView` 刻意不含 `sessionIds` 与绝对路径。
1875
+ */
1876
+ private blankSessionInWorkspace;
1877
+ /**
1878
+ * 绝对路径维度上找一个可复用的空白会话(老客户端的 `rootId` + `path` 入口)。
1879
+ *
1880
+ * 这里没有 workspace 摘要可用,判据就只剩「同一 cwd」—— 与客户端按 workspace
1881
+ * 挑选相比少了两道(`sessionIds` 归属、workspace 存在性),但那两道在
1882
+ * `rootId` + `path` 这条路上本来就由授权根解析替代了。
1883
+ */
1884
+ private blankSessionAtPath;
1823
1885
  history(principal: Principal, sessionId: string, options: {
1824
1886
  beforeSeq?: number;
1825
1887
  maxMessages?: number;
package/lib/index.js CHANGED
@@ -2748,6 +2748,92 @@ function readArchivedSessionIds(registry) {
2748
2748
  return value.filter((item) => typeof item === "string");
2749
2749
  }
2750
2750
  /**
2751
+ * 容量元数据的缓存寿命。
2752
+ *
2753
+ * 与内核 `dsh-llm` 的 `DEFAULT_MODELS_MAX_AGE_MS = 600000` 对齐:那边是模型
2754
+ * **列表**的新鲜度,这边是**单个模型容量**的新鲜度,同一批配置变动(改
2755
+ * baseURL、换 API key、升级内核)会同时让两者失效,跟齐一个数量级最不容易
2756
+ * 出现「列表变了容量没变」的错配。
2757
+ */
2758
+ const MODEL_CAPACITY_TTL_MS = 6e5;
2759
+ /** 缓存条目上限;只是防内存无界,正常部署里 provider×model 远小于这个数。 */
2760
+ const MODEL_CAPACITY_CACHE_LIMIT = 512;
2761
+ const modelCapacityCache = /* @__PURE__ */ new Map();
2762
+ /**
2763
+ * 把一个模型条目的容量字段补齐。
2764
+ *
2765
+ * **为什么不能直接从 `modelCatalog()` 拿**:0.2.x 的 `ModelCatalogModel` 只有
2766
+ * `{ id, name, description?, reasoning? }` —— 目录里没有 `contextWindow` /
2767
+ * `maxTokens` / `inputModalities`。这三个只在 `LlmResolvedModelInfo` 里,得走
2768
+ * `llm.resolveModelInfo(provider, model)`。目录条目自带容量是 1.x 的旧形状。
2769
+ *
2770
+ * 失败一律退回「不填」:模型选择器少显示一行容量,比整张目录报错好得多。
2771
+ * 注意**不缓存失败**——一次瞬时 RPC 抖动不该让某个模型十分钟没有容量。
2772
+ */
2773
+ async function modelCapacityOf(host, provider, model, signal) {
2774
+ const key = `${provider}\u0000${model}`;
2775
+ const cached = modelCapacityCache.get(key);
2776
+ if (cached !== void 0 && Date.now() - cached.at < MODEL_CAPACITY_TTL_MS) return cached.capacity;
2777
+ let capacity = {};
2778
+ try {
2779
+ const info = await host.llm.resolveModelInfo(provider, model, signal);
2780
+ const contextWindow = info?.context?.contextWindow;
2781
+ const maxTokens = info?.defaultMaxTokens;
2782
+ capacity = {
2783
+ ...typeof contextWindow === "number" && contextWindow > 0 ? { contextWindow } : {},
2784
+ ...typeof maxTokens === "number" && maxTokens > 0 ? { maxTokens } : {}
2785
+ };
2786
+ } catch {
2787
+ return {};
2788
+ }
2789
+ if (modelCapacityCache.size >= MODEL_CAPACITY_CACHE_LIMIT) {
2790
+ const oldest = modelCapacityCache.keys().next();
2791
+ if (oldest.done !== true) modelCapacityCache.delete(oldest.value);
2792
+ }
2793
+ modelCapacityCache.set(key, {
2794
+ at: Date.now(),
2795
+ capacity
2796
+ });
2797
+ return capacity;
2798
+ }
2799
+ /**
2800
+ * 给一张 provider group 列表补容量字段。
2801
+ *
2802
+ * 并发查而不是串行:目录动辄几十个模型,串行 RPC 会把模型选择器拖成几秒空白。
2803
+ * 单个模型失败不影响其他模型(`modelCapacityOf` 内部吞掉)。
2804
+ *
2805
+ * 对**形状**宽容:`groups` 是 `unknown`(内核给什么形状就是什么形状,我们只
2806
+ * 投影自己认识的字段),认不出来的条目原样透传,保证向前兼容。
2807
+ */
2808
+ async function enrichModelGroups(host, groups, signal) {
2809
+ return Promise.all(groups.map(async (group) => {
2810
+ if (typeof group !== "object" || group === null) return group;
2811
+ const view = group;
2812
+ const providerId = typeof view.id === "string" ? view.id : void 0;
2813
+ if (providerId === void 0 || !Array.isArray(view.models)) return group;
2814
+ const models = await Promise.all(view.models.map(async (model) => {
2815
+ if (typeof model !== "object" || model === null) return model;
2816
+ const entry = model;
2817
+ const modelId = typeof entry.id === "string" ? entry.id : void 0;
2818
+ if (modelId === void 0) return model;
2819
+ const capacity = await modelCapacityOf(host, providerId, modelId, signal);
2820
+ const filled = {
2821
+ ...entry.contextWindow === void 0 && capacity.contextWindow !== void 0 ? { contextWindow: capacity.contextWindow } : {},
2822
+ ...entry.maxTokens === void 0 && capacity.maxTokens !== void 0 ? { maxTokens: capacity.maxTokens } : {}
2823
+ };
2824
+ if (filled.contextWindow === void 0 && filled.maxTokens === void 0) return model;
2825
+ return {
2826
+ ...model,
2827
+ ...filled
2828
+ };
2829
+ }));
2830
+ return {
2831
+ ...group,
2832
+ models
2833
+ };
2834
+ }));
2835
+ }
2836
+ /**
2751
2837
  * Build the `apiProxy` replacement out of 0.2.x host services.
2752
2838
  *
2753
2839
  * Every method keeps the old `{ rpcId, payload }` request shape and the old
@@ -2942,10 +3028,12 @@ function createDsh02ApiProxy(host, options = {}) {
2942
3028
  } catch {}
2943
3029
  const catalog = await host.sessionController.modelCatalog();
2944
3030
  const selection = (await host.sessionController.projections({ sessionId }, signal))?.values?.["modelSelection"];
3031
+ const current = selection?.next ?? selection?.lastUsed ?? catalog.default;
3032
+ const groups = Array.isArray(catalog.groups) ? catalog.groups : [];
2945
3033
  return {
2946
- current: selection?.next ?? selection?.lastUsed ?? catalog.default,
3034
+ current,
2947
3035
  routable: Array.isArray(catalog.routableProviders) && catalog.routableProviders.length > 0,
2948
- groups: Array.isArray(catalog.groups) ? catalog.groups : [],
3036
+ groups: await enrichModelGroups(host, groups, signal),
2949
3037
  failures: Array.isArray(catalog.failures) ? catalog.failures.map((entry) => ({
2950
3038
  provider: typeof entry.id === "string" ? entry.id : String(entry.name ?? ""),
2951
3039
  message: typeof entry.message === "string" ? entry.message : ""
@@ -3106,7 +3194,7 @@ function createDsh02ApiProxy(host, options = {}) {
3106
3194
  models: () => call("llm.models", async (signal) => {
3107
3195
  const view = await host.sessionController.modelCatalog();
3108
3196
  return {
3109
- groups: Array.isArray(view.groups) ? view.groups : [],
3197
+ groups: await enrichModelGroups(host, Array.isArray(view.groups) ? view.groups : [], signal),
3110
3198
  failures: Array.isArray(view.failures) ? view.failures : []
3111
3199
  };
3112
3200
  }),
@@ -3123,7 +3211,7 @@ function createDsh02ApiProxy(host, options = {}) {
3123
3211
  }
3124
3212
  //#endregion
3125
3213
  //#region src/shared/version.ts
3126
- const PLUGIN_VERSION = "2.6.1";
3214
+ const PLUGIN_VERSION = "2.7.0";
3127
3215
  //#endregion
3128
3216
  //#region src/host/capabilities.ts
3129
3217
  /**
@@ -3638,9 +3726,10 @@ var DshChatAdapter = class {
3638
3726
  }
3639
3727
  async createSessionInWorkspace(principal, workspaceId, options) {
3640
3728
  if ((await this.listWorkspaces(principal)).find((item) => item.id === workspaceId) === void 0) throw new ApiError(404, "WORKSPACE_NOT_FOUND", "The DSH workspace is not visible to this device.");
3729
+ const sessionId = options.sessionId ?? (options.reuseBlank === false ? void 0 : await this.blankSessionInWorkspace(workspaceId, options.agentPreset));
3641
3730
  const payload = {
3642
3731
  workspaceId,
3643
- ...options.sessionId === void 0 ? {} : { sessionId: options.sessionId },
3732
+ ...sessionId === void 0 ? {} : { sessionId },
3644
3733
  ...options.agentPreset === void 0 ? {} : { agentPreset: options.agentPreset }
3645
3734
  };
3646
3735
  const value = unwrapDsh(await this.api.sessions.create({
@@ -3653,9 +3742,11 @@ var DshChatAdapter = class {
3653
3742
  };
3654
3743
  }
3655
3744
  async createSession(principal, rootId, relativePath, options) {
3745
+ const resolved = await resolveAuthorizedPath(this.requireAuthorizedRoot(principal, rootId).realPath, relativePath);
3746
+ const sessionId = options.sessionId ?? (options.reuseBlank === false ? void 0 : await this.blankSessionAtPath(resolved.absolutePath, options.agentPreset));
3656
3747
  const payload = {
3657
- cwd: (await resolveAuthorizedPath(this.requireAuthorizedRoot(principal, rootId).realPath, relativePath)).absolutePath,
3658
- ...options.sessionId === void 0 ? {} : { sessionId: options.sessionId },
3748
+ cwd: resolved.absolutePath,
3749
+ ...sessionId === void 0 ? {} : { sessionId },
3659
3750
  ...options.agentPreset === void 0 ? {} : { agentPreset: options.agentPreset }
3660
3751
  };
3661
3752
  const value = unwrapDsh(await this.api.sessions.create({
@@ -3667,6 +3758,69 @@ var DshChatAdapter = class {
3667
3758
  ...value.agentPreset === void 0 ? {} : { agentPreset: value.agentPreset }
3668
3759
  };
3669
3760
  }
3761
+ /**
3762
+ * 挑一个可复用的空白会话 —— DSH 0.2.1 起桌面端的「New Session 回到空白会话」语义。
3763
+ *
3764
+ * **内核自己不做这件事。** `sessionController.create` 只有调用方显式传了 `sessionId`
3765
+ * 才会走 `claim` / `reuseLive` 去复用;不传就无条件 `createMinted` 新建一个。
3766
+ * 「跳过 preset 不匹配的空白会话」那层挑选写在**客户端**(`dsh-client-ui-workspace`
3767
+ * 的 `connectWorkspace` → `createInWorkspace`)里,而桥走 REST 直连内核,没有这一层 ——
3768
+ * 于是手机端每点一次「新会话」就多一个空白会话,攒成一串永远没人用的空壳。
3769
+ *
3770
+ * 这里把客户端那条判据原样搬过来(`blank && cwd 匹配 && 未归档 && preset 匹配`),
3771
+ * 取用顺序也照抄:**按内核返回的列表顺序取第一个**,不自己按时间排序 —— 同一份数据
3772
+ * 在桌面与手机上应当落到同一条会话上,桥没有理由发明第三种顺序。
3773
+ *
3774
+ * preset 的口径也与内核一致:空白会话**没声明** preset 时对任何预设都算可用
3775
+ * (只有 `typeof listed === 'string'` 才比较)。「没声明」不等于「声明了别的」——
3776
+ * 把前者当成不匹配会让所有老会话都挑不中。
3777
+ */
3778
+ reusableBlankId(sessions, archived, agentPreset, matches) {
3779
+ for (const session of sessions) {
3780
+ if (!session.blank || archived.has(session.sessionId) || !matches(session)) continue;
3781
+ const listed = session.agentPreset ?? session.projections?.values["agentPreset"];
3782
+ if (agentPreset !== void 0 && typeof listed === "string" && listed !== agentPreset) continue;
3783
+ return session.sessionId;
3784
+ }
3785
+ }
3786
+ /**
3787
+ * 工作区维度上找一个可复用的空白会话。
3788
+ *
3789
+ * 归属判据用**内核自己的两个字段**:`session.cwd === workspace.path` 且
3790
+ * `workspace.sessionIds` 含它 —— 与客户端 `createInWorkspace` 逐字同义。
3791
+ * 这里重新拉一次原始 workspace 列表(而不是复用 `listWorkspaces` 的投影结果),
3792
+ * 因为 `ChatWorkspaceView` 刻意不含 `sessionIds` 与绝对路径。
3793
+ */
3794
+ async blankSessionInWorkspace(workspaceId, agentPreset) {
3795
+ const [sessionsResponse, workspaceResponse] = await Promise.all([this.api.sessions.list({
3796
+ rpcId: randomUUID(),
3797
+ payload: {}
3798
+ }), this.api.workspace.list({
3799
+ rpcId: randomUUID(),
3800
+ payload: {}
3801
+ })]);
3802
+ const workspaceValue = unwrapDsh(workspaceResponse);
3803
+ const summary = workspaceValue.items.find((item) => item.workspaceId === workspaceId);
3804
+ if (summary === void 0) return void 0;
3805
+ return this.reusableBlankId(unwrapDsh(sessionsResponse).items, new Set(workspaceValue.archivedSessionIds), agentPreset, (session) => session.cwd === summary.path && summary.sessionIds.includes(session.sessionId));
3806
+ }
3807
+ /**
3808
+ * 绝对路径维度上找一个可复用的空白会话(老客户端的 `rootId` + `path` 入口)。
3809
+ *
3810
+ * 这里没有 workspace 摘要可用,判据就只剩「同一 cwd」—— 与客户端按 workspace
3811
+ * 挑选相比少了两道(`sessionIds` 归属、workspace 存在性),但那两道在
3812
+ * `rootId` + `path` 这条路上本来就由授权根解析替代了。
3813
+ */
3814
+ async blankSessionAtPath(cwd, agentPreset) {
3815
+ const [sessionsResponse, workspaceResponse] = await Promise.all([this.api.sessions.list({
3816
+ rpcId: randomUUID(),
3817
+ payload: {}
3818
+ }), this.api.workspace.list({
3819
+ rpcId: randomUUID(),
3820
+ payload: {}
3821
+ })]);
3822
+ return this.reusableBlankId(unwrapDsh(sessionsResponse).items, new Set(unwrapDsh(workspaceResponse).archivedSessionIds), agentPreset, (session) => session.cwd === cwd);
3823
+ }
3670
3824
  async history(principal, sessionId, options) {
3671
3825
  await this.requireSession(principal, sessionId);
3672
3826
  const payload = {
@@ -6347,6 +6501,40 @@ function defaultDisplayName() {
6347
6501
  //#endregion
6348
6502
  //#region src/host/plugin-inventory.ts
6349
6503
  /**
6504
+ * 插件清单(plugin inventory)。
6505
+ *
6506
+ * ## 为什么需要它
6507
+ *
6508
+ * 用户在手机上使用时的反馈是「功能单调点,无法查看装的插件情况」:想知道这台机器上
6509
+ * 装了哪些插件、有没有出问题的,必须回到电脑前的 WebUI。
6510
+ *
6511
+ * 而插件的 API 面(`/api/v1`)此前有 10 个端点,**没有任何一个与插件相关**;
6512
+ * loopback 管理面(`/manage/status`)也只回 `remote` / `roots` / `devices`。
6513
+ * 于是这个信息在远程侧完全不可见。本模块补上这个缺口。
6514
+ *
6515
+ * ## 数据源为什么是这两个文件
6516
+ *
6517
+ * DSH 把每个 profile 的插件状态放在两个地方,缺一不可:
6518
+ *
6519
+ * - `<profiledir>/package.json` 的 `dependencies`:**装了哪些包**(含版本声明);
6520
+ * - 同一个 `package.json` 的 `dsh.profile.bundles`:**实际加载了哪些包**。
6521
+ *
6522
+ * 两者之差是有意义的信息,而不是噪声:
6523
+ *
6524
+ * - 在 `dependencies` 里但不在 `bundles` 里 → **装了但没启用**(例如用户装了备用插件先不加载);
6525
+ * - 在 `bundles` 里但 `node_modules` 下找不到 → **声明加载却缺失**,这通常意味着启动会出问题,
6526
+ * 是用户最需要被提前告知的状态。
6527
+ *
6528
+ * 另外,`node_modules/<name>/package.json` 里的 `version` 才是**实际装上的版本**。
6529
+ * 只看 `dependencies` 的 `^0.4.5` 无法回答「我现在跑的是哪个版本」。
6530
+ *
6531
+ * ## 安全边界
6532
+ *
6533
+ * 本模块只读两个位置的文件,并把结果**限制为**「包名 + 版本 + 状态」三种信息,
6534
+ * 不回传任何文件内容、绝对路径之外的系统信息、或 `.env` / 凭据相关内容。
6535
+ * `profilePath` 会返回,因为用户需要它来定位问题,且它本来就是本机路径。
6536
+ */
6537
+ /**
6350
6538
  * 读包清单时的并发上限。
6351
6539
  *
6352
6540
  * 为什么不是 `Promise.all` 全放:`names` 的长度由用户装了多少插件决定,
@@ -6660,6 +6848,40 @@ function unavailable(profile, profilePath, reason) {
6660
6848
  items: []
6661
6849
  };
6662
6850
  }
6851
+ /**
6852
+ * 启用 / 停用插件(「插件」页的写操作)。
6853
+ *
6854
+ * ## 为什么不是调 `dsh plugin`
6855
+ *
6856
+ * `dsh plugin <anything>` 会**原样转发给 pnpm**。实测踩过一次:本想查
6857
+ * `dsh plugin enable --help`,结果 pnpm 直接进入安装流程(打出 `Packages: +4 -85`)——
6858
+ * 它在**动依赖树**。启用/停用插件根本不需要碰依赖(包已经在 `node_modules` 里了),
6859
+ * 走 pnpm 等于为了挪一个字符串去重算整棵依赖,风险与收益完全不成比例。
6860
+ *
6861
+ * ## 真正该改的是什么
6862
+ *
6863
+ * 只改 **`dsh.profile.bundles` 数组**。内核源码注释写明:
6864
+ *
6865
+ * > stack its patch layers (bundle layers in `dsh.profile.bundles` order …)
6866
+ *
6867
+ * 也就是说这个数组**就是加载清单,且顺序即加载顺序**。加一个名字 = 启用,
6868
+ * 删一个名字 = 停用。依赖声明(`dependencies`)原封不动 —— 包仍在磁盘上,
6869
+ * 随时可以再启用,不必重新下载。
6870
+ *
6871
+ * ## 三道安全阀
6872
+ *
6873
+ * 1. **拒绝改官方包**(`@deepseek-ai/` 前缀):它们随 DSH 主包安装、不由 profile 管理,
6874
+ * 改了 bundles 也加载不出来,反而把清单搞成与实际不符。
6875
+ * 2. **写前必备份**:`package.json.bak-<时间戳>` 落在同目录。profile 的 package.json
6876
+ * 一旦写坏,dsh 下次**起不来**,而用户此刻在手机上、够不着电脑 —— 备份是唯一退路。
6877
+ * 3. **先写临时文件再 rename**:`rename` 在同分区是原子的。直接写原文件的话,
6878
+ * 写到一半断电/被杀会留下一个残缺的 JSON,后果同上。
6879
+ *
6880
+ * ## 必须重启才生效
6881
+ *
6882
+ * bundles 只在**启动时**读一次。改了文件不等于改了运行中的进程 —— 这一点会原样
6883
+ * 回给调用方(`restartRequired: true`),由界面明确告诉用户,别让人以为点了没反应。
6884
+ */
6663
6885
  var PluginToggleError = class extends Error {
6664
6886
  code;
6665
6887
  constructor(code, message) {
@@ -7423,7 +7645,8 @@ var ApiRouter = class {
7423
7645
  }
7424
7646
  const options = {
7425
7647
  ...typeof body.sessionId === "string" ? { sessionId: body.sessionId } : {},
7426
- ...typeof body.agentPreset === "string" ? { agentPreset: body.agentPreset } : {}
7648
+ ...typeof body.agentPreset === "string" ? { agentPreset: body.agentPreset } : {},
7649
+ ...typeof body.reuseBlank === "boolean" ? { reuseBlank: body.reuseBlank } : {}
7427
7650
  };
7428
7651
  const usesWorkspace = typeof body.workspaceId === "string";
7429
7652
  const usesLegacyPath = typeof body.rootId === "string" && typeof body.path === "string";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-bridge",
3
- "version": "2.6.1",
3
+ "version": "2.7.0",
4
4
  "description": "Versioned REST bridge for DeepSeek Harness: remote access for trusted clients (chat, files, devices) plus a Phone-settings panel inside the DSH WebUI.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.19.0",