dsh-custom-mode 2.2.6 → 2.3.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/README.i18n.yaml CHANGED
@@ -1,5 +1,5 @@
1
1
  # 双语配对一致性记录:两侧在「上次确认一致」时的 git blob 哈希。
2
2
  # 两份文档权威相同——改完任意一侧,请把另一侧也改掉,然后重新记录:
3
3
  # node tools/verify-translation-pairing.mjs --write
4
- README.md: f43403111d49476d68271c74036aec063e8b3417
5
- README.zh.md: 43c26010c77fca3121c1c1906c40880c710c16e9
4
+ README.md: c45d995f478c01972b08ea681831a593531c279a
5
+ README.zh.md: f2a7906bddc3559d96585c8474a0a102d74cf44c
package/README.md CHANGED
@@ -2,26 +2,17 @@
2
2
 
3
3
  English | [中文](README.zh.md)
4
4
 
5
- Edit a DeepSeek Harness (`dsh`) custom mode: system prompt, base mode, and plugin switches. Keep more than one assistant. 自定义模式、系统提示词、提示词编辑。
5
+ Edit your DeepSeek Harness (`dsh`) assistants' system prompts, base modes and plugin switches. Keep separate assistants for different tasks.
6
6
 
7
7
  <p align="center">
8
8
  <img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/header.png" width="820" alt="dsh-custom-mode">
9
9
  </p>
10
10
 
11
- <table align="center">
12
- <tr>
13
- <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/05-assistant-manager.png" width="360" alt="Assistants"></td>
14
- <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/03-system-prompt.png" width="360" alt="System prompt"></td>
15
- </tr>
16
- <tr>
17
- <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/01-mode-switch.png" width="360" alt="Base mode"></td>
18
- <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/02-plugin-switches.png" width="360" alt="Plugin switches"></td>
19
- </tr>
20
- </table>
11
+ [Install](#install-and-make-the-first-request) · [First use](#verify-the-whole-path) · [Updates](#plugin-updates) · [Screenshots](#screenshots) · [Compatibility](#compatibility-and-versioning) · [Help](#help-and-docs)
21
12
 
22
13
  ## Install and make the first request
23
14
 
24
- These instructions are for version `2.2.6`. Check the [exact-host results and acceptance limits](docs/CURRENT_COMPATIBILITY.md) before installing or updating.
15
+ These instructions describe version `2.3.0`. Confirm that the exact version is available in [Releases](https://github.com/BOWLUNA/dsh-custom-mode/releases), and check the [exact-host results and acceptance limits](docs/CURRENT_COMPATIBILITY.md) before installing or updating.
25
16
 
26
17
  Start with an installed [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) and a model configured through its own interface. Check `dsh --version` against the exact hosts in the compatibility record. This package does not install the host or configure model credentials.
27
18
 
@@ -31,14 +22,14 @@ Pin the plugin version; a bare name can resolve an older build during pnpm's rel
31
22
 
32
23
  ```sh
33
24
  dsh --version
34
- dsh plugin --profile web add dsh-custom-mode@2.2.6
25
+ dsh plugin --profile web add dsh-custom-mode@2.3.0
35
26
  ```
36
27
 
37
28
  Restart the process that serves this Web profile using its usual launcher. For a local CLI instance, the existing launch form is `dsh web --port 3081 --no-open`; use a free port and open the URL printed by that process. Restart only that instance. Installing a bundle does not refresh an already running host.
38
29
 
39
30
  ### Desktop and Windows
40
31
 
41
- In the desktop app, use its **Plugins** page to install `dsh-custom-mode@2.2.6`, then restart the app. This project's scripts deliberately refuse `--profile desktop`; do not modify its profile files by hand. Windows/Electron acceptance remains separate from Web acceptance; see the compatibility record.
32
+ In the desktop app, use its **Plugins** page to install `dsh-custom-mode@2.3.0`, then restart the app. This project's scripts deliberately refuse `--profile desktop`; do not modify its profile files by hand. Windows/Electron acceptance remains separate from Web acceptance; see the compatibility record.
42
33
 
43
34
  On Windows, the npm installation above can use the host's `dsh` command from PowerShell. Only the optional clone installer requires Git Bash (plus Node.js and npm). If `dsh` is not on PATH, use the host's supplied launcher, or replace `dsh` with `node "<host-install>/node_modules/@deepseek-ai/dsh/lib/bin.js"`. Do not point that path at this plugin's directory unless the host is actually installed there.
44
35
 
@@ -50,6 +41,23 @@ On Windows, the npm installation above can use the host's `dsh` command from Pow
50
41
  4. Start a **new session**, open its mode/assistant selector, and explicitly choose that named assistant. Send `Readiness check.` through a model already configured in DSH.
51
42
  5. Success means the assistant is selectable without a broken-preset warning and returns the expected readiness reply without a prompt-assembly error. Restore your intended prompt after the check. If it fails, use the [current recovery steps](docs/TROUBLESHOOTING.md#current-recovery-steps).
52
43
 
44
+ ## Screenshots
45
+
46
+ Captured from plugin `2.2.6` in an isolated DSH Web instance with synthetic assistants. These images show the editor, not the new update controls. [Capture sources and limits](docs/images/README.md).
47
+
48
+ <table align="center">
49
+ <tr>
50
+ <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/05-assistant-manager.png" width="360" alt="Create an assistant"><br>Create an assistant</td>
51
+ <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/03-system-prompt.png" width="360" alt="System prompt editor"><br>Edit its system prompt</td>
52
+ </tr>
53
+ <tr>
54
+ <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/01-mode-switch.png" width="360" alt="Base-mode choices"><br>Choose a base mode</td>
55
+ <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/02-plugin-switches.png" width="360" alt="Native plugin switches"><br>Adjust plugin switches</td>
56
+ </tr>
57
+ </table>
58
+
59
+ [View the new-session assistant selector](https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/04-preset-picker.png).
60
+
53
61
  ## What each choice means
54
62
 
55
63
  The assistant selected on the settings page is the **one being edited**. Selecting or creating it does not change the assistant used by a new session. On first activation, the plugin may fill a missing default with the built-in `custom` assistant; an existing default is preserved. A newly created Writing assistant still needs to be chosen in the session selector.
@@ -64,11 +72,21 @@ Choose the creation starting settings first; a new assistant starts from Standar
64
72
 
65
73
  The prompt is stored in `prompt.md` and reread when the next model step is assembled. History and file import load a draft; saving writes it. A stale save is refused when the underlying file changed. `custom_prompt` asks for approval before it writes the same file. Turning off the Identity/persona row also turns off this prompt's injection.
66
74
 
75
+ <a id="plugin-updates"></a>
76
+
67
77
  ## Upgrade, clone development, and removal
68
78
 
69
- The fixed-version npm path above is the normal installation path. Release assets, once published, also include `dsh-custom-mode-2.2.6.tgz`, a preset-only import archive, and `SHA256SUMS`. Verify a downloaded archive's hash before installing it; the preset-only archive does not include the settings page.
79
+ The page header shows the **running plugin version**. With DSH `0.2.2-alpha.2`'s native update capability, select **Check for updates** to ask the host for eligible versions. A recommended version offers **Update in Plugins**, which opens the official plugin details for its own confirmation, progress and result. The custom page does not install a package itself. Save pending drafts and resolve activation/refresh warnings before leaving the page.
80
+
81
+ On `0.2.1-rc.2`, `0.2.1-rc.4`, `0.2.0-rc.2` and `0.1.7-rc.2`, the native version-query API is absent: use **Open plugin details** or **Update guide** and the manual fixed-version path below. File, tarball and source-linked installations remain managed by their original source; checking does not silently convert them to npm. Permission denial, network failure, filtered versions or a partial result do not mean the plugin is current. A separate **Installed on disk** version does not prove that the running code has changed.
70
82
 
71
- For a clone, `./install.sh --profile web` keeps the source-linked development workflow. It requires Node.js, npm, Bash and the host CLI. Before changing the profile, it prepares the new package and an archive of the **actual installed old package** with offline `npm pack --ignore-scripts`. It preserves existing prompt, metadata and composition, and refreshes managed code only after installation checks. A failed replacement reinstalls the local old archive and restores refreshed code. Files newly created by the attempt are retained at printed recovery paths when rolled back. An unverified rollback exits with an error and keeps the archive and logs at the printed transaction path. A broken old install that cannot be archived is rejected before replacement. The script is not a substitute for the desktop plugin manager.
83
+ When you update from 2.2.6 through a Web host's native Plugins page, or a failed update restores 2.2.6, restart that host once, then check the running version and assistant availability. Hot-stopping the old version can leave assistant registrations behind.
84
+
85
+ For a manual registry upgrade, use the fixed-version npm command above with a published version, then restart the host and verify the running version. [GitHub Releases](https://github.com/BOWLUNA/dsh-custom-mode/releases) also provides the plugin `.tgz`, a preset-only import archive and `SHA256SUMS`. Verify downloaded hashes; the preset-only archive does not include the settings page.
86
+
87
+ That command targets Web/CLI only. Desktop users must use an update path actually offered by their app. If it has none, do not substitute the Web CLI command or edit the desktop profile files.
88
+
89
+ For source development, follow [the isolated setup](CONTRIBUTING.md#isolated-development). The optional `./install.sh --profile web` keeps the source-linked workflow and preserves existing prompt, metadata and composition. Its local package/code rollback and recovery paths are explained in [package and release assets](docs/PUBLISHING.md#package-and-release-assets).
72
90
 
73
91
  `./uninstall.sh` removes the plugin but keeps assistant data. `--purge` explicitly deletes the managed assistant directories and their prompts; keep your own backup before using it.
74
92
 
@@ -84,14 +102,17 @@ Release requires the compatibility and browser workflows. The unit runner, real
84
102
 
85
103
  The documented catalogs use different discovery and approval rules. Their 2026-10-09 listing results are historical, so a package may not be searchable in every market today. The fixed-version installation above remains the direct path. [Market interfaces and limits](docs/MARKETPLACES.md) explains the read-only adapter; it never executes catalog-provided shell commands or submits a listing.
86
104
 
87
- ## Docs
105
+ ## Help and docs
88
106
 
89
107
  - [Troubleshooting](docs/TROUBLESHOOTING.md) — current recovery first, historical incidents below.
108
+ - [Report a bug](https://github.com/BOWLUNA/dsh-custom-mode/issues/new?template=bug_report.yml) or [suggest a feature](https://github.com/BOWLUNA/dsh-custom-mode/issues/new?template=feature_request.yml).
109
+ - [Security reports](SECURITY.md) — request a private channel without posting vulnerability details.
110
+ - [Releases](https://github.com/BOWLUNA/dsh-custom-mode/releases) and [changelog](CHANGELOG.md).
111
+ - [Contributing](CONTRIBUTING.md) — isolated development and checks.
90
112
  - [Architecture](docs/ARCHITECTURE.md) — prompt reading and host coupling points.
91
113
  - [Publishing](docs/PUBLISHING.md) — existing gated release path and read-back evidence.
92
- - [Contributing](CONTRIBUTING.md) — isolated development and checks.
93
114
  - [AGENTS.md](AGENTS.md) — repository instructions for agents.
94
115
 
95
116
  ## License
96
117
 
97
- MIT
118
+ [MIT](LICENSE)
package/README.zh.md CHANGED
@@ -2,26 +2,17 @@
2
2
 
3
3
  [English](README.md) | 中文
4
4
 
5
- 在 DeepSeek Harness(`dsh`)里改自定义模式的系统提示词、基础模式和插件开关。可以有多个助手。custom mode、system prompt、prompt editor。
5
+ 在 DeepSeek Harness(`dsh`)里编辑助手的系统提示词、基础模式和插件开关,为不同任务保留独立助手。
6
6
 
7
7
  <p align="center">
8
8
  <img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/header.png" width="820" alt="dsh-custom-mode">
9
9
  </p>
10
10
 
11
- <table align="center">
12
- <tr>
13
- <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/05-assistant-manager.png" width="360" alt="助手"></td>
14
- <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/03-system-prompt.png" width="360" alt="系统提示词"></td>
15
- </tr>
16
- <tr>
17
- <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/01-mode-switch.png" width="360" alt="基础模式"></td>
18
- <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/02-plugin-switches.png" width="360" alt="插件开关"></td>
19
- </tr>
20
- </table>
11
+ [安装](#安装并完成首次请求) · [首次使用](#验证完整路径) · [更新](#plugin-updates) · [截图](#使用截图) · [兼容](#兼容与版本) · [求助](#帮助与文档)
21
12
 
22
13
  ## 安装并完成首次请求
23
14
 
24
- 本说明适用于版本 `2.2.6`。安装或更新前,先核对[精确宿主结果与验收边界](docs/CURRENT_COMPATIBILITY.zh.md)。
15
+ 本说明描述版本 `2.3.0`。安装或更新前,先确认[发布版本](https://github.com/BOWLUNA/dsh-custom-mode/releases)中已有该精确版本,并核对[精确宿主结果与验收边界](docs/CURRENT_COMPATIBILITY.zh.md)。
25
16
 
26
17
  先安装 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),并在宿主自己的界面中配置可用模型。用 `dsh --version` 对照兼容记录中的精确宿主版本。本插件不安装宿主,也不配置模型凭据。
27
18
 
@@ -31,14 +22,14 @@
31
22
 
32
23
  ```sh
33
24
  dsh --version
34
- dsh plugin --profile web add dsh-custom-mode@2.2.6
25
+ dsh plugin --profile web add dsh-custom-mode@2.3.0
35
26
  ```
36
27
 
37
28
  用原有启动方式重启为该 Web profile 提供服务的进程。本地 CLI 的既有启动形式是 `dsh web --port 3081 --no-open`;选择空闲端口,打开该进程打印的地址。只重启这个实例。安装 bundle 不会刷新已经运行的宿主。
38
29
 
39
30
  ### 桌面端与 Windows
40
31
 
41
- 在桌面应用的 **Plugins(插件)** 页安装 `dsh-custom-mode@2.2.6`,然后重启应用。本项目脚本明确拒绝 `--profile desktop`,不要手改它的 profile 文件。Windows/Electron 与 Web 分别验收,详见兼容记录。
32
+ 在桌面应用的 **Plugins(插件)** 页安装 `dsh-custom-mode@2.3.0`,然后重启应用。本项目脚本明确拒绝 `--profile desktop`,不要手改它的 profile 文件。Windows/Electron 与 Web 分别验收,详见兼容记录。
42
33
 
43
34
  Windows 上可在 PowerShell 中用宿主提供的 `dsh` 执行上面的 npm 安装命令。只有可选的 clone 安装脚本需要 Git Bash,另需 Node.js 和 npm。PATH 中没有 `dsh` 时,使用宿主自带启动器,或将 `dsh` 替换为 `node "<host-install>/node_modules/@deepseek-ai/dsh/lib/bin.js"`。除非宿主确实装在本插件目录中,否则不要把这个路径指向插件目录。
44
35
 
@@ -50,6 +41,23 @@ Windows 上可在 PowerShell 中用宿主提供的 `dsh` 执行上面的 npm 安
50
41
  4. 新建一个**会话**,打开该会话的模式/助手选择器,明确选择刚才命名的助手。通过 DSH 中已经配置的模型发送 `就绪检查。`
51
42
  5. 成功意味着助手可以选择、没有预设损坏告警,并返回预期就绪回复且不出现提示词组装错误。检查后恢复自己的提示词。失败时先看[当前自救步骤](docs/TROUBLESHOOTING.zh.md#当前自救步骤)。
52
43
 
44
+ ## 使用截图
45
+
46
+ 截图来自隔离DSH Web实例中的插件 `2.2.6`,助手内容均为合成数据。它们展示编辑页,不展示新增的更新控件。[截图来源与边界](docs/images/README.md)。
47
+
48
+ <table align="center">
49
+ <tr>
50
+ <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/05-assistant-manager.png" width="360" alt="新建助手"><br>新建助手</td>
51
+ <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/03-system-prompt.png" width="360" alt="系统提示词编辑区"><br>编辑系统提示词</td>
52
+ </tr>
53
+ <tr>
54
+ <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/01-mode-switch.png" width="360" alt="基础模式选择"><br>选择基础模式</td>
55
+ <td align="center"><img src="https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/02-plugin-switches.png" width="360" alt="原生插件开关"><br>调整插件开关</td>
56
+ </tr>
57
+ </table>
58
+
59
+ [查看新会话的助手选择器](https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/04-preset-picker.png)。
60
+
53
61
  ## 各项选择分别做什么
54
62
 
55
63
  设置页选中的助手是**正在编辑的对象**。选中或新建它不会改变新会话使用的助手。首次激活时,插件可能把缺失的默认值补为内置 `custom`;已经存在的默认值保持不变。新建写作助手后,仍需在会话选择器中选它。
@@ -64,11 +72,21 @@ Windows 上可在 PowerShell 中用宿主提供的 `dsh` 执行上面的 npm 安
64
72
 
65
73
  提示词保存在 `prompt.md`,下一步组装模型输入时会重读。历史与文件导入先载入草稿,保存才落盘。底层文件已变化时,过期保存会被拒绝。`custom_prompt` 写同一个文件前需要批准。关闭身份/persona 行也会关闭该提示词的注入。
66
74
 
75
+ <a id="plugin-updates"></a>
76
+
67
77
  ## 升级、clone 开发与卸载
68
78
 
69
- 上面的固定版本 npm 命令是普通安装入口。发布完成后,Release 还会提供 `dsh-custom-mode-2.2.6.tgz`、仅供预设导入的归档和 `SHA256SUMS`。安装下载的归档前先核对散列;仅供预设导入的归档没有设置页。
79
+ 页首显示**运行中的插件版本**。宿主提供DSH `0.2.2-alpha.2`的原生更新能力时,点「**检查更新**」让宿主查询可用版本。有推荐版本后,「**在插件管理中更新**」会打开官方插件详情,由它承接确认、进度与结果;自定义页不直接安装包。离开前先保存待处理草稿,并处理生效/读取告警。
80
+
81
+ `0.2.1-rc.2`、`0.2.1-rc.4`、`0.2.0-rc.2`和`0.1.7-rc.2`没有原生版本查询API,可用「**打开插件详情**」或「**更新指引**」,再按下方固定版本方式手动更新。文件、tarball与源码链接安装继续由原来源管理,不会因检查更新而悄悄转成npm。权限拒绝、网络失败、版本被筛除或结果不完整都不代表已是最新。「**磁盘安装版本**」与运行版本不同,不能据此认定新代码已运行。
70
82
 
71
- clone 后可用 `./install.sh --profile web` 保留源码链接的开发方式,需要 Node.js、npm、Bash 和宿主 CLI。它在改动 profile 前,通过离线 `npm pack --ignore-scripts` 准备新包,并归档**实际已安装的旧包**。已有提示词、元数据与组成保持原样,安装自检通过后才刷新受管代码。替换失败时,用本地旧归档重新安装并恢复已刷新的代码;本次新建文件在回滚时留有恢复副本,路径会打印。回滚未验证则报错,并在打印的事务目录保留旧包和日志。旧安装损坏、无法归档时,脚本在替换前中止。这个脚本不能代替桌面应用的插件管理器。
83
+ 通过Web宿主的原生插件页从2.2.6更新,或更新失败后恢复到2.2.6时,请重启该宿主一次,再核对运行版本与助手是否可用。旧版热停止可能留下助手注册记录。
84
+
85
+ 手动更新注册表安装时,使用上面的固定版本npm命令并选择已发布版本,再重启宿主、核对运行版本。[GitHub Releases](https://github.com/BOWLUNA/dsh-custom-mode/releases)也提供插件`.tgz`、仅供预设导入的归档和`SHA256SUMS`。安装下载的归档前先核对散列;仅供预设导入的归档没有设置页。
86
+
87
+ 该命令仅适用于Web/CLI。桌面用户必须使用应用实际提供的更新路径;应用没有更新入口时,不要改用Web CLI命令或手改桌面profile文件。
88
+
89
+ 源码开发按[隔离准备步骤](CONTRIBUTING.zh.md#隔离开发)进行。可选的`./install.sh --profile web`保留源码链接方式,已有提示词、元数据和组成不被覆盖。本地包/代码回滚与恢复路径见[包与发布资产](docs/PUBLISHING.zh.md#包与发布资产)。
72
90
 
73
91
  `./uninstall.sh` 移除插件但保留助手数据。`--purge` 明确删除受管助手目录及提示词,使用前请自行备份。
74
92
 
@@ -84,14 +102,17 @@ clone 后可用 `./install.sh --profile web` 保留源码链接的开发方式
84
102
 
85
103
  各目录的发现与审核规则不同。2026-10-09 的上架结果属于历史记录,不能据此保证今天每个市场都能搜索到。上面的固定版本安装仍是直接入口。[市场接口与限制](docs/MARKETPLACES.zh.md) 说明只读适配器;它不执行目录提供的 shell 命令,也不提交收录。
86
104
 
87
- ## 文档
105
+ ## 帮助与文档
88
106
 
89
107
  - [故障排查](docs/TROUBLESHOOTING.zh.md) — 当前自救在前,历史故障在后。
108
+ - [报告故障](https://github.com/BOWLUNA/dsh-custom-mode/issues/new?template=bug_report.yml)或[建议功能](https://github.com/BOWLUNA/dsh-custom-mode/issues/new?template=feature_request.yml)。
109
+ - [安全报告](SECURITY.zh.md) — 仅公开请求私下渠道,不发布漏洞细节。
110
+ - [发布版本](https://github.com/BOWLUNA/dsh-custom-mode/releases)与[变更记录](CHANGELOG.zh.md)。
111
+ - [贡献指南](CONTRIBUTING.zh.md) — 隔离开发与检查。
90
112
  - [架构](docs/ARCHITECTURE.zh.md) — 提示词读取与宿主耦合点。
91
113
  - [发布](docs/PUBLISHING.zh.md) — 既有发布门禁与读回证据。
92
- - [贡献指南](CONTRIBUTING.zh.md) — 隔离开发与检查。
93
114
  - [AGENTS.md](AGENTS.md) — 仓库代理约定。
94
115
 
95
116
  ## 许可证
96
117
 
97
- MIT
118
+ [MIT](LICENSE)
package/client.js CHANGED
@@ -58,11 +58,49 @@ try {
58
58
  * truth, checked for key parity by test/locales.test.mjs).
59
59
  */
60
60
  const ZH = {
61
+ "update.heading": "插件更新",
62
+ "update.orphanHint": "以下助手已不在当前列表,但本页仍保留其未保存草稿,因此暂不能离页。",
63
+ "update.orphanName": "{name}(ID:{id})",
64
+ "update.orphanView": "查看并保留草稿",
65
+ "update.orphanHide": "收起草稿",
66
+ "update.orphanCopy": "下方包含完整提示词、名称、描述、模式和覆盖项,可选中复制到本地文件保留。放弃仅移除本页内存草稿,不修改磁盘文件。",
67
+ "update.orphanDiscard": "放弃此草稿…",
68
+ "update.orphanConfirm": "确认放弃“{name}”(ID:{id})的未保存草稿?请先复制保留需要的内容。放弃后无法从本页恢复;磁盘文件不会改动。",
69
+ "update.orphanAcknowledge": "我已保留需要的内容,确认放弃后无法从本页恢复。",
70
+ "update.orphanChanged": "草稿或助手列表已变化,未放弃任何内容。请重新查看并确认。",
71
+ "update.orphanDiscarded": "已放弃所确认的本页草稿;磁盘文件未改动。",
72
+ "update.running": "运行中的插件版本:{version}",
73
+ "update.unknown": "未知",
74
+ "update.check": "检查更新",
75
+ "update.checking": "正在检查…",
76
+ "update.inPlugins": "在插件管理中更新",
77
+ "update.details": "打开插件详情",
78
+ "update.guide": "更新指引",
79
+ "update.idle": "仅在点击检查时查询宿主配置的包仓库;不会自动安装。",
80
+ "update.unavailable": "当前连接未提供原生版本检查能力。可打开插件详情或查看更新指引。",
81
+ "update.permission": "此操作缺少宿主授权,或权限服务暂不可用。",
82
+ "update.updatePermission": "可以查看版本,但当前连接没有更新权限。",
83
+ "update.available": "可用更新:{version}。安装由官方插件管理处理。",
84
+ "update.current": "此次检查未返回更高版本。",
85
+ "update.filtered": "发现较新版本,但没有符合官方默认条件的稳定兼容版本。",
86
+ "update.partial": "结果未覆盖全部新版本,暂未找到可推荐的更新。",
87
+ "update.omitted": "另有 {count} 个较新版本未列出。",
88
+ "update.disk": "磁盘安装版本:{version};它可能尚未在运行中加载。",
89
+ "update.managed": "此安装无法使用原生包仓库更新,请通过原来源或插件详情管理;不会自动切换来源。",
90
+ "update.notFound": "宿主插件列表中没有找到此插件,无法确认更新。",
91
+ "update.network": "宿主查询包仓库失败,请稍后重试;尚不能确认更新状态。",
92
+ "update.refused": "宿主未提供可用的版本列表,尚不能确认更新状态。",
93
+ "update.failed": "未能完成更新检查,请重试或打开插件详情查看。",
94
+ "update.finishOperation": "请等待当前操作完成后再离开此页。",
95
+ "update.finishRecovery": "请先完成重试读取或重试生效,再离开此页。",
96
+ "update.saveDrafts": "请先保存或主动放弃所有助手草稿及未提交的新建输入,再离开此页。",
97
+ "update.navigationFailed": "未能打开插件详情;草稿已保留,可稍后重试。",
61
98
  "create.nextStep": "创建后可在上方继续编辑并保存;使用时,在新会话的模式菜单选择它。",
62
99
  "btn.retryRead": "重试读取",
63
100
  "btn.retrySync": "重试生效",
64
101
  "api.savedButSyncFailed": "文件已保存,但这些助手尚未生效:{ids}。请点「重试生效」,无需重复原操作。",
65
102
  "api.syncFailed": "这些助手仍未生效:{ids}。请重试;仍失败时查看宿主日志。",
103
+ "api.presetRegistrationSyncFailed": "尚未确认已保存的助手配置在宿主注册表中生效,请处理已有告警或查看宿主日志。",
66
104
  "api.synchronized": "配置已生效。新会话请从模式菜单选择助手。",
67
105
  "api.recoveryRequired": "助手文件需要恢复:{path}。请保留目录中的备份,并查看宿主日志中的恢复说明。",
68
106
  "msg.completedReadFailed": "{result} 页面刷新失败。请点「重试读取」,无需重复原操作。",
@@ -188,6 +226,8 @@ try {
188
226
  "warn.approvalGateMissing": "当前版本没有审批事件。会话内改写提示词不会请求确认。",
189
227
  "warn.unresolvableRows": "有的行在本机无法启动,此模式不会出现在新会话菜单里。点「按本线修复」只关闭那些行。",
190
228
  "warn.presetBroken": "此模式无法启动,不会出现在新会话菜单里。点「按本线修复」。原因见下一行。",
229
+ "warn.presetRegistrationSyncFailed": "尚未确认这位助手已保存的配置在宿主注册表中生效。请先处理已有告警或查看宿主日志;无需重复保存。",
230
+ "warn.presetRegistrationRestartSuggested": "尚未确认这位助手已保存的配置在宿主注册表中生效。如刚更新或恢复插件,建议重启 DeepSeek Harness 后重新检查;无需重复保存。",
191
231
  "warn.badPromptVariable": "提示词里有不可用的变量。改掉后再保存,否则此模式的请求会失败。",
192
232
  "warn.duplicateRowIds": "组成文件里有重复的行 id。开关只作用于第一处。",
193
233
  "warn.predicateFailed": "有一行的平台条件无法计算,已按关闭处理。",
@@ -320,11 +360,49 @@ try {
320
360
  }
321
361
 
322
362
  const EN = {
363
+ "update.heading": "Plugin updates",
364
+ "update.orphanHint": "These assistants left the current list, but this page still holds their unsaved drafts. Resolve them before leaving.",
365
+ "update.orphanName": "{name} (ID: {id})",
366
+ "update.orphanView": "View and keep draft",
367
+ "update.orphanHide": "Hide draft",
368
+ "update.orphanCopy": "The text below includes the full prompt, name, description, mode and overrides. Select and copy it to a local file to keep it. Discarding only removes this page's in-memory draft; files on disk are unchanged.",
369
+ "update.orphanDiscard": "Discard this draft…",
370
+ "update.orphanConfirm": "Discard the unsaved draft for \"{name}\" (ID: {id})? First copy any content you need to keep. This page cannot recover it after discarding; files on disk will remain unchanged.",
371
+ "update.orphanAcknowledge": "I have kept the content I need and understand this page cannot recover the discarded draft.",
372
+ "update.orphanChanged": "The draft or assistant list changed. Nothing was discarded. Review it again before confirming.",
373
+ "update.orphanDiscarded": "The confirmed draft was discarded from this page; files on disk are unchanged.",
374
+ "update.running": "Running plugin version: {version}",
375
+ "update.unknown": "unknown",
376
+ "update.check": "Check for updates",
377
+ "update.checking": "Checking…",
378
+ "update.inPlugins": "Update in Plugins",
379
+ "update.details": "Open plugin details",
380
+ "update.guide": "Update guide",
381
+ "update.idle": "Only a click checks the registries configured by the host; installation is never automatic.",
382
+ "update.unavailable": "This connection does not offer native version checks. Open plugin details or the update guide.",
383
+ "update.permission": "Host permission is missing for this action, or the access service is unavailable.",
384
+ "update.updatePermission": "You can view versions, but this connection does not have update permission.",
385
+ "update.available": "Update available: {version}. Official plugin management handles installation.",
386
+ "update.current": "This check returned no newer releases.",
387
+ "update.filtered": "Newer releases exist, but none meet the official default conditions for a stable compatible version.",
388
+ "update.partial": "The result omits some newer releases; no recommended update was found.",
389
+ "update.omitted": "{count} additional newer releases were omitted.",
390
+ "update.disk": "Installed on disk: {version}; this version may not be loaded in the running host yet.",
391
+ "update.managed": "Manage this installation through its original source or plugin details; it cannot use this native registry update path. The source will not be changed automatically.",
392
+ "update.notFound": "The host did not list this plugin, so its update status could not be determined.",
393
+ "update.network": "The host could not query the registry. Try again later; update status is unknown.",
394
+ "update.refused": "The host did not provide a usable version list; update status is unknown.",
395
+ "update.failed": "The update check could not finish. Retry or open plugin details.",
396
+ "update.finishOperation": "Wait for the current operation to finish before leaving this page.",
397
+ "update.finishRecovery": "Complete Retry refresh or Retry activation before leaving this page.",
398
+ "update.saveDrafts": "Save or explicitly discard all assistant drafts and unsubmitted new-assistant input before leaving this page.",
399
+ "update.navigationFailed": "Plugin details could not open. Your drafts are preserved; try again later.",
323
400
  "create.nextStep": "After creating, edit and save above. To use the assistant, choose it in the mode menu for a new session.",
324
401
  "btn.retryRead": "Retry refresh",
325
402
  "btn.retrySync": "Retry activation",
326
403
  "api.savedButSyncFailed": "Files are saved, but these assistants are not active yet: {ids}. Select Retry activation; you do not need to repeat the original action.",
327
404
  "api.syncFailed": "These assistants are still not active: {ids}. Retry, then check the host log if the problem continues.",
405
+ "api.presetRegistrationSyncFailed": "The saved assistant configuration has not been confirmed in the host registry. Address existing warnings or check the host log.",
328
406
  "api.synchronized": "Configuration is active. Choose an assistant in the mode menu for a new session.",
329
407
  "api.recoveryRequired": "Assistant files need recovery: {path}. Keep the backups in that directory and check the host log for recovery details.",
330
408
  "msg.completedReadFailed": "{result} The page could not refresh. Select Retry refresh; do not repeat the original action.",
@@ -453,6 +531,8 @@ try {
453
531
  "warn.approvalGateMissing": "This version has no approval event. In-session prompt edits are not confirmed.",
454
532
  "warn.unresolvableRows": "Some rows cannot start here, so this mode is absent from the new-session menu. Fix for this line turns off only those rows.",
455
533
  "warn.presetBroken": "This mode cannot start, so it will not appear for a new session. Use Fix for this line. The reason is on the next line.",
534
+ "warn.presetRegistrationSyncFailed": "This assistant's saved configuration has not been confirmed in the host registry. Address existing warnings or check the host log; you do not need to save again.",
535
+ "warn.presetRegistrationRestartSuggested": "This assistant's saved configuration has not been confirmed in the host registry. If the plugin was just updated or restored, consider restarting DeepSeek Harness, then check again; you do not need to save again.",
456
536
  "warn.badPromptVariable": "The prompt uses a variable that is not available. Fix it before saving.",
457
537
  "warn.duplicateRowIds": "A row id is repeated. A switch applies only to the first one.",
458
538
  "warn.predicateFailed": "A platform condition could not be evaluated. That row is treated as off.",
@@ -644,6 +724,47 @@ try {
644
724
  */
645
725
  let localeRef
646
726
 
727
+ const UPDATE_PACKAGE = "dsh-custom-mode"
728
+ let updateServices = { manager: null, navigation: null, list: false, versions: false, update: false, accessKnown: false, revision: 0 }
729
+ const updateListeners = new Set()
730
+ const publishUpdateServices = (patch) => {
731
+ updateServices = { ...updateServices, ...patch, revision: updateServices.revision + 1 }
732
+ for (const listener of updateListeners) listener()
733
+ }
734
+
735
+ /** Optional Cordis scopes own these reads; old hosts never block the editor. */
736
+ function mountUpdateServices(ctx) {
737
+ if (typeof ctx.inject !== "function") return
738
+ ctx.inject(["remote", "remote.pluginManager"], (scope) => {
739
+ const manager = scope.remote.pluginManager
740
+ if (manager === null || manager === undefined) return
741
+ publishUpdateServices({ manager, list: false, versions: false, update: false, accessKnown: false })
742
+ scope.effect(() => () => {
743
+ if (updateServices.manager === manager) publishUpdateServices({ manager: null, list: false, versions: false, update: false, accessKnown: false })
744
+ })
745
+ scope.inject(["remoteAccess"], (accessScope) => {
746
+ if (typeof accessScope.remoteAccess.whenAccess !== "function") return
747
+ if (updateServices.manager === manager) publishUpdateServices({ accessKnown: true })
748
+ for (const [key, action] of [["list", "listBundles"], ["versions", "listVersions"], ["update", "updateBundle"]]) {
749
+ if (typeof manager[action] !== "function") continue
750
+ accessScope.effect(() => accessScope.remoteAccess.whenAccess("pluginManager/" + action, () => {
751
+ if (updateServices.manager === manager) publishUpdateServices({ [key]: true })
752
+ return () => {
753
+ if (updateServices.manager === manager) publishUpdateServices({ [key]: false })
754
+ }
755
+ }))
756
+ }
757
+ })
758
+ })
759
+ ctx.inject(["pluginNavigation"], (scope) => {
760
+ const navigation = scope.get("pluginNavigation")
761
+ publishUpdateServices({ navigation })
762
+ scope.effect(() => () => {
763
+ if (updateServices.navigation === navigation) publishUpdateServices({ navigation: null })
764
+ })
765
+ })
766
+ }
767
+
647
768
  /**
648
769
  * Plain-element stand-ins for the shell's atoms.
649
770
  *
@@ -847,6 +968,14 @@ try {
847
968
  ".cpfe-detail-value{min-width:0;overflow-wrap:anywhere}",
848
969
  ".cpfe-sec-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}",
849
970
  ".cpfe-sec-head .cpfe-actions{margin-top:0}",
971
+ ".cpfe-update{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:16px 0;border-bottom:1px solid var(--dsw-alias-border-l1);margin-bottom:16px}",
972
+ ".cpfe-update-meta{flex:1 1 220px;min-width:0}",
973
+ ".cpfe-update-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;max-width:100%}",
974
+ // The shared atoms expose no Link or anchor-shaped Button. Keep a real anchor;
975
+ // use official settings UI typography/link tokens, not Markdown prose styles.
976
+ ".cpfe-update-guide{max-width:100%;overflow-wrap:anywhere;font:var(--dsw-font-s-strong-14);color:var(--dsw-alias-link);text-decoration:none}",
977
+ "@media (hover:hover){.cpfe-update-guide:hover{text-decoration:underline}}",
978
+ ".cpfe-update-note{overflow-wrap:anywhere}",
850
979
  ".cpfe-newinput{box-sizing:border-box;flex:1 1 200px;min-width:0;max-width:100%}",
851
980
  // 单列、行高统一(对齐官方插件页的形态);之前的自适应多列网格会让行高参差不齐。
852
981
  // ★ 对齐官方设置行(实测 0.1.7-rc.2 的行:padding 16px 0、无背景、无圆角,行间只有一条 1px
@@ -902,11 +1031,15 @@ try {
902
1031
  ".cpfe-warn svg{flex:0 0 auto;margin-top:2px}",
903
1032
  ].join("")
904
1033
 
905
- /** Apply the page stylesheet once, keyed so a re-mount never duplicates it. */
1034
+ /** Keep one stylesheet, replacing stale content when an HMR bundle arrives. */
906
1035
  function ensureStyles() {
907
1036
  if (typeof document === "undefined") return
908
1037
  const tagId = "dsh-custom-mode/system-prompt"
909
- if (document.querySelector('style[data-plugin-css="' + tagId + '"]') !== null) return
1038
+ const existing = document.querySelector('style[data-plugin-css="' + tagId + '"]')
1039
+ if (existing !== null) {
1040
+ if (existing.textContent !== CSS) existing.textContent = CSS
1041
+ return
1042
+ }
910
1043
  const tag = document.createElement("style")
911
1044
  tag.dataset.plugin = "dsh-custom-mode"
912
1045
  tag.dataset.pluginCss = tagId
@@ -1254,20 +1387,42 @@ try {
1254
1387
  return t(fallbackKey)
1255
1388
  }
1256
1389
 
1257
- const [list, setList] = react.useState(null)
1390
+ const [list, setListState] = react.useState(null)
1391
+ const heldRoster = react.useRef(list)
1392
+ const setList = (value) => { heldRoster.current = value; setListState(value) }
1258
1393
  const [selected, setSelected] = react.useState("")
1259
1394
  /** id → { payload, saved, value }: loaded state, baseline and live draft. */
1260
- const [entries, setEntries] = react.useState({})
1395
+ const [entries, setEntriesState] = react.useState({})
1396
+ const heldEntries = react.useRef(entries)
1397
+ const setEntries = (next) => {
1398
+ heldEntries.current = typeof next === "function" ? next(heldEntries.current) : next
1399
+ setEntriesState(heldEntries.current)
1400
+ }
1261
1401
  const [status, setStatus] = react.useState("")
1262
1402
  const [failed, setFailed] = react.useState(false)
1263
1403
  const [busy, setBusy] = react.useState(false)
1264
1404
  const [pending, setPending] = react.useState("")
1265
- const [syncNeeded, setSyncNeeded] = react.useState(false)
1266
- const [refreshNeeded, setRefreshNeeded] = react.useState(null)
1405
+ const [syncNeeded, setSyncNeededState] = react.useState(false)
1406
+ const [refreshNeeded, setRefreshNeededState] = react.useState(null)
1407
+ const navigationState = react.useRef({ syncNeeded: false, refreshNeeded: null, newName: "" })
1408
+ const setSyncNeeded = (value) => { navigationState.current.syncNeeded = value; setSyncNeededState(value) }
1409
+ const setRefreshNeeded = (value) => { navigationState.current.refreshNeeded = value; setRefreshNeededState(value) }
1267
1410
  // A ref closes the interval before React renders disabled controls. Only the
1268
1411
  // owning operation may apply a response or release the busy state.
1269
1412
  const operation = react.useRef({ alive: true, current: null })
1270
- const [newName, setNewName] = react.useState("")
1413
+ const [newName, setNewNameState] = react.useState("")
1414
+ const setNewName = (value) => { navigationState.current.newName = value; setNewNameState(value) }
1415
+ const [runtimeVersion, setRuntimeVersion] = react.useState("")
1416
+ const [updateResult, setUpdateResult] = react.useState(null)
1417
+ const [updateNotice, setUpdateNotice] = react.useState("")
1418
+ const [updateChecking, setUpdateChecking] = react.useState(false)
1419
+ const [, setUpdateRevision] = react.useState(0)
1420
+ const updateOperation = react.useRef({ alive: true, current: null })
1421
+ const [orphanView, setOrphanView] = react.useState("")
1422
+ const [orphanDialog, setOrphanDialog] = react.useState(null)
1423
+ const [orphanAcknowledged, setOrphanAcknowledged] = react.useState(false)
1424
+ const orphanConfirmation = react.useRef(null)
1425
+ const orphanAcknowledgement = react.useRef(false)
1271
1426
  const [postureChoices, setPostureChoices] = react.useState([])
1272
1427
  const [posture, setPosture] = react.useState("develop")
1273
1428
  const [deleteOpen, setDeleteOpen] = react.useState(false)
@@ -1437,6 +1592,7 @@ try {
1437
1592
  const assistants = Array.isArray(result.assistants) ? result.assistants : []
1438
1593
  const choices = Array.isArray(result.postures) ? result.postures.filter((item) => item !== null && typeof item === "object" && typeof item.id === "string") : []
1439
1594
  setListFailed(false)
1595
+ if (typeof result.version === "string" && result.version !== "") setRuntimeVersion(result.version)
1440
1596
  setList(assistants)
1441
1597
  setPostureChoices(choices)
1442
1598
  setPosture((current) => (choices.some((item) => item.id === current) ? current : choices.length > 0 ? choices[0].id : "develop"))
@@ -1450,7 +1606,8 @@ try {
1450
1606
  : ""
1451
1607
  setSelected(wanted)
1452
1608
  if (wanted === "") {
1453
- setEntries({})
1609
+ // An empty remote roster does not discard the only in-memory copy of another draft.
1610
+ setEntries((previous) => Object.fromEntries(Object.entries(previous).filter(([, held]) => !sameDraft(held.value, held.saved))))
1454
1611
  return
1455
1612
  }
1456
1613
  await open(wanted, { force: true, discard: options !== null && typeof options === "object" && options.discard === true }, token)
@@ -1466,6 +1623,7 @@ try {
1466
1623
  const result = await fetchList()
1467
1624
  if (!ownsOperation(token)) return
1468
1625
  if (result !== null && result !== undefined && result.ok === true) {
1626
+ if (typeof result.version === "string" && result.version !== "") setRuntimeVersion(result.version)
1469
1627
  setList(Array.isArray(result.assistants) ? result.assistants : [])
1470
1628
  } else throw new Error(apiText(result, "msg.readFailed"))
1471
1629
  }
@@ -1494,6 +1652,149 @@ try {
1494
1652
  }
1495
1653
  }, [])
1496
1654
 
1655
+ react.useEffect(() => {
1656
+ updateOperation.current.alive = true
1657
+ const changed = () => {
1658
+ updateOperation.current.current = null
1659
+ setUpdateChecking(false)
1660
+ setUpdateResult(null)
1661
+ setUpdateNotice("")
1662
+ setUpdateRevision((value) => value + 1)
1663
+ }
1664
+ updateListeners.add(changed)
1665
+ return () => {
1666
+ updateListeners.delete(changed)
1667
+ updateOperation.current.alive = false
1668
+ updateOperation.current.current = null
1669
+ }
1670
+ }, [])
1671
+
1672
+ const hasNativeUpdates = (services) => typeof services.manager?.listBundles === "function" &&
1673
+ typeof services.manager?.listVersions === "function" && typeof services.manager?.updateBundle === "function"
1674
+ const ownsUpdateCheck = (token, services) => updateOperation.current.alive &&
1675
+ updateOperation.current.current === token && updateServices.revision === services.revision
1676
+ const checkUpdates = async () => {
1677
+ if (!updateOperation.current.alive || operation.current.current !== null || updateOperation.current.current !== null) return
1678
+ const services = updateServices
1679
+ if (!hasNativeUpdates(services) || !services.list || !services.versions) return
1680
+ const token = {}
1681
+ updateOperation.current.current = token
1682
+ setUpdateChecking(true)
1683
+ setUpdateResult(null)
1684
+ setUpdateNotice("")
1685
+ const show = (key, details) => setUpdateResult({ key, ...details, revision: services.revision })
1686
+ try {
1687
+ const inventory = await services.manager.listBundles()
1688
+ if (!ownsUpdateCheck(token, services)) return
1689
+ if (inventory?.ok !== true || !Array.isArray(inventory.value)) throw new Error("inventory")
1690
+ const bundle = inventory.value.find((item) => item?.name === UPDATE_PACKAGE)
1691
+ if (bundle === undefined) { show("update.notFound"); return }
1692
+ if (bundle.updatable !== true || bundle.installed !== true || bundle.availability !== "profile" ||
1693
+ bundle.official !== false || bundle.readOnlyReason !== undefined || typeof bundle.source !== "string" ||
1694
+ typeof bundle.version !== "string" || bundle.version === "") {
1695
+ show("update.managed", { installed: bundle.version }); return
1696
+ }
1697
+ const answer = await services.manager.listVersions(UPDATE_PACKAGE, {})
1698
+ if (!ownsUpdateCheck(token, services)) return
1699
+ if (answer?.ok !== true || answer.value === null || typeof answer.value !== "object") throw new Error("versions")
1700
+ const listing = answer.value
1701
+ if (listing.status === "refused") {
1702
+ show(listing.problem === "network" ? "update.network" : listing.problem === "not-updatable" ? "update.managed" : "update.refused")
1703
+ return
1704
+ }
1705
+ if (listing.status !== "listed" || typeof listing.installed !== "string" || listing.installed !== bundle.version ||
1706
+ !(listing.registry === null || typeof listing.registry === "string") || !Array.isArray(listing.attempts) ||
1707
+ !Number.isInteger(listing.omitted) || listing.omitted < 0 || !Array.isArray(listing.versions) ||
1708
+ !listing.versions.every((item) => item !== null && typeof item === "object" && typeof item.version === "string" &&
1709
+ item.version !== "" && typeof item.bundle === "boolean" && typeof item.prerelease === "boolean" && Array.isArray(item.tags))) {
1710
+ throw new Error("incomplete listing")
1711
+ }
1712
+ // Match the official default candidate and trust the Host's semver ordering.
1713
+ const candidate = listing.versions.find((item) => item.bundle && item.incompatible === undefined && item.deprecated === undefined && !item.prerelease)
1714
+ show(candidate ? "update.available" : listing.omitted > 0 ? "update.partial" : listing.versions.length > 0 ? "update.filtered" : "update.current", {
1715
+ candidate: candidate?.version, installed: listing.installed, omitted: listing.omitted,
1716
+ })
1717
+ } catch {
1718
+ if (ownsUpdateCheck(token, services)) show("update.failed")
1719
+ } finally {
1720
+ if (ownsUpdateCheck(token, services)) {
1721
+ updateOperation.current.current = null
1722
+ setUpdateChecking(false)
1723
+ }
1724
+ }
1725
+ }
1726
+
1727
+ /** Latest refs also guard a click before React commits the last keystroke. */
1728
+ const updateNavigationBlock = () => {
1729
+ if (operation.current.current !== null || busy || updateOperation.current.current !== null) return "update.finishOperation"
1730
+ if (navigationState.current.syncNeeded || navigationState.current.refreshNeeded !== null) return "update.finishRecovery"
1731
+ if (navigationState.current.newName !== "" || Object.values(heldEntries.current).some((held) => !sameDraft(held.value, held.saved))) return "update.saveDrafts"
1732
+ return ""
1733
+ }
1734
+ const guardUpdateNavigation = () => {
1735
+ if (!updateOperation.current.alive) return false
1736
+ const block = updateNavigationBlock()
1737
+ if (block !== "") { setUpdateNotice(block); return false }
1738
+ return true
1739
+ }
1740
+ const openPluginDetails = () => {
1741
+ if (!guardUpdateNavigation()) return
1742
+ const services = updateServices
1743
+ if (((hasNativeUpdates(services) || services.accessKnown) && !services.list) || typeof services.navigation?.openBundle !== "function" ||
1744
+ (updateResult?.candidate && (updateResult.revision !== services.revision || !services.update))) {
1745
+ setUpdateNotice("update.permission"); return
1746
+ }
1747
+ try { services.navigation.openBundle(UPDATE_PACKAGE) }
1748
+ catch { setUpdateNotice("update.navigationFailed") }
1749
+ }
1750
+
1751
+ const orphanDrafts = () => {
1752
+ if (!Array.isArray(heldRoster.current)) return []
1753
+ const visible = new Set(heldRoster.current.map((item) => item.id))
1754
+ return Object.entries(heldEntries.current).filter(([id, held]) => !visible.has(id) && !sameDraft(held.value, held.saved))
1755
+ }
1756
+ const closeOrphanConfirmation = () => {
1757
+ orphanConfirmation.current = null
1758
+ orphanAcknowledgement.current = false
1759
+ setOrphanAcknowledged(false)
1760
+ setOrphanDialog(null)
1761
+ }
1762
+ const orphanActionBlock = () => operation.current.current !== null || busy || updateOperation.current.current !== null
1763
+ ? "update.finishOperation"
1764
+ : navigationState.current.syncNeeded || navigationState.current.refreshNeeded !== null ? "update.finishRecovery" : ""
1765
+ const orphanSnapshotCurrent = (snapshot) => heldEntries.current === snapshot.entries && heldRoster.current === snapshot.roster &&
1766
+ navigationState.current.newName === snapshot.newName && orphanDrafts().some(([id]) => id === snapshot.id)
1767
+ const discardOrphan = (snapshot) => {
1768
+ if (!updateOperation.current.alive || orphanConfirmation.current !== snapshot || !orphanAcknowledgement.current) return
1769
+ const blocked = orphanActionBlock()
1770
+ if (blocked || !orphanSnapshotCurrent(snapshot)) {
1771
+ closeOrphanConfirmation()
1772
+ setUpdateNotice(blocked || "update.orphanChanged")
1773
+ return
1774
+ }
1775
+ setEntries((previous) => { const next = { ...previous }; delete next[snapshot.id]; return next })
1776
+ closeOrphanConfirmation()
1777
+ setOrphanView("")
1778
+ setUpdateNotice("update.orphanDiscarded")
1779
+ }
1780
+ const askDiscardOrphan = (id, shown) => {
1781
+ if (!updateOperation.current.alive) return
1782
+ const snapshot = { ...shown, id }
1783
+ const blocked = orphanActionBlock()
1784
+ if (blocked || !orphanSnapshotCurrent(snapshot)) { setUpdateNotice(blocked || "update.orphanChanged"); return }
1785
+ const value = snapshot.entries[id].value
1786
+ snapshot.name = value.name || id
1787
+ orphanConfirmation.current = snapshot
1788
+ orphanAcknowledgement.current = false
1789
+ setOrphanAcknowledged(false)
1790
+ if (renderable(A.RiskConfirmation)) setOrphanDialog(snapshot)
1791
+ else if (typeof window !== "undefined" && typeof window.confirm === "function" &&
1792
+ window.confirm(fillPlaceholders(t("update.orphanConfirm"), { name: snapshot.name, id }))) {
1793
+ orphanAcknowledgement.current = true
1794
+ discardOrphan(snapshot)
1795
+ } else closeOrphanConfirmation()
1796
+ }
1797
+
1497
1798
  /** Change one field of the selected assistant's draft. */
1498
1799
  const update = (patch, options) => {
1499
1800
  // ★ 忙碌期间**不接受**对草稿的修改(issue #6)。
@@ -1873,6 +2174,65 @@ try {
1873
2174
  )
1874
2175
 
1875
2176
  // ── assistant list ────────────────────────────────────────────────────
2177
+ const nativeUpdates = hasNativeUpdates(updateServices)
2178
+ const updateAllowed = nativeUpdates && updateServices.list && updateServices.versions
2179
+ const checkedUpdate = updateResult?.revision === updateServices.revision ? updateResult : null
2180
+ const navigationBlock = updateNavigationBlock()
2181
+ const updateText = updateChecking ? t("update.checking") : checkedUpdate
2182
+ ? fillPlaceholders(t(checkedUpdate.key), { version: checkedUpdate.candidate })
2183
+ : !nativeUpdates ? t("update.unavailable") : !updateAllowed ? t("update.permission") : t("update.idle")
2184
+ const shownOrphans = orphanDrafts()
2185
+ const orphanSnapshot = { entries: heldEntries.current, roster: heldRoster.current, newName: navigationState.current.newName }
2186
+ const updateSection = react.createElement(
2187
+ "section", { className: "cpfe-update", "aria-label": t("update.heading"), "aria-busy": updateChecking },
2188
+ react.createElement("div", { className: "cpfe-update-meta" },
2189
+ react.createElement("h2", { className: "cpfe-h" }, t("update.heading")),
2190
+ react.createElement("p", { className: "cpfe-version" }, fillPlaceholders(t("update.running"), { version: runtimeVersion || t("update.unknown") })),
2191
+ react.createElement("p", { id: "cpfe-update-status", className: "cpfe-sub cpfe-update-note", role: "status", "aria-live": "polite", "aria-atomic": true }, updateText),
2192
+ checkedUpdate?.installed && checkedUpdate.installed !== runtimeVersion
2193
+ ? react.createElement("p", { className: "cpfe-sub cpfe-update-note" }, fillPlaceholders(t("update.disk"), { version: checkedUpdate.installed })) : null,
2194
+ checkedUpdate?.omitted > 0
2195
+ ? react.createElement("p", { className: "cpfe-sub" }, fillPlaceholders(t("update.omitted"), { count: checkedUpdate.omitted })) : null,
2196
+ checkedUpdate?.candidate && !updateServices.update ? react.createElement("p", { className: "cpfe-sub" }, t("update.updatePermission")) : null,
2197
+ updateNotice || navigationBlock ? react.createElement("p", { className: "cpfe-sub", role: "status" }, t(updateNotice || navigationBlock)) : null,
2198
+ shownOrphans.length > 0 ? react.createElement("div", { className: "cpfe-warns cpfe-update-note" },
2199
+ react.createElement("p", { className: "cpfe-sub" }, t("update.orphanHint")),
2200
+ ...shownOrphans.map(([id, held]) => react.createElement("div", { key: id, className: "cpfe-node" },
2201
+ react.createElement("span", { className: "cpfe-row-head" }, fillPlaceholders(t("update.orphanName"), { name: held.value.name || id, id })),
2202
+ react.createElement(A.Button, { variant: "ghost", className: "cpfe-disclosure", "aria-expanded": orphanView === id,
2203
+ onClick: () => setOrphanView(orphanView === id ? "" : id) }, t(orphanView === id ? "update.orphanHide" : "update.orphanView")),
2204
+ orphanView === id ? react.createElement("div", { className: "cpfe-node" },
2205
+ react.createElement("p", { className: "cpfe-sub" }, t("update.orphanCopy")),
2206
+ react.createElement("textarea", { className: "cpfe-editor cpfe-orphan-editor", readOnly: true, spellCheck: false,
2207
+ "aria-label": fillPlaceholders(t("update.orphanName"), { name: held.value.name || id, id }),
2208
+ value: JSON.stringify({ id, name: held.value.name, description: held.value.description, mode: held.value.mode, overrides: held.value.overrides, prompt: held.value.prompt }, null, 2) }),
2209
+ react.createElement(A.Button, { variant: "outline", className: "cpfe-disclosure", disabled: orphanActionBlock() !== "",
2210
+ onClick: () => askDiscardOrphan(id, orphanSnapshot) }, t("update.orphanDiscard")),
2211
+ ) : null,
2212
+ )),
2213
+ ) : null,
2214
+ ),
2215
+ react.createElement("div", { className: "cpfe-update-actions" },
2216
+ react.createElement(A.Button, {
2217
+ variant: "outline", disabled: busy || updateChecking || !updateAllowed,
2218
+ "aria-describedby": "cpfe-update-status", onClick: checkUpdates,
2219
+ }, t(updateChecking ? "update.checking" : "update.check")),
2220
+ react.createElement(A.Button, {
2221
+ variant: "outline",
2222
+ disabled: navigationBlock !== "" || ((nativeUpdates || updateServices.accessKnown) && !updateServices.list) || typeof updateServices.navigation?.openBundle !== "function" ||
2223
+ (!!checkedUpdate?.candidate && !updateServices.update),
2224
+ title: navigationBlock ? t(navigationBlock) : undefined,
2225
+ onClick: openPluginDetails,
2226
+ }, t(checkedUpdate?.candidate ? "update.inPlugins" : "update.details")),
2227
+ react.createElement("a", {
2228
+ className: "cpfe-update-guide",
2229
+ href: "https://github.com/BOWLUNA/dsh-custom-mode/blob/main/README" + (activeLanguage(localeRef) === "zh" ? ".zh" : "") + ".md#plugin-updates",
2230
+ target: "_blank", rel: "noopener noreferrer", "aria-disabled": navigationBlock !== "",
2231
+ onClick: (event) => { if (!guardUpdateNavigation()) event.preventDefault() },
2232
+ }, t("update.guide")),
2233
+ ),
2234
+ )
2235
+
1876
2236
  const selectedAssistantLabel = (assistants.find((item) => item.id === selected) || {}).name || selected || t("assistant.heading")
1877
2237
  const pickedHistoryEntry = draft === null || draft.historyPick === "" ? undefined : draft.history.find((item) => String(item.n) === draft.historyPick)
1878
2238
  const historySelectionLabel = pickedHistoryEntry === undefined
@@ -2295,6 +2655,7 @@ try {
2295
2655
  // `cpfe-busy` 是 issue #6 的兜底:面板里**所有**会改草稿的控件在忙碌期间一律不可交互。
2296
2656
  // 逐个控件加 `disabled` 会漏(本轮就漏了行开关与描述框),一个根类 + 一条 CSS 反而漏不掉。
2297
2657
  { className: busy === true ? "cpfe cpfe-busy" : "cpfe", "aria-busy": busy === true },
2658
+ updateSection,
2298
2659
  assistantList,
2299
2660
  editorReady ? null : react.createElement("div", { className: "cpfe-bar" }, ...feedback),
2300
2661
  // 配了却不生效的项:主动点名,而不是让用户对着"我明明写了"发呆。
@@ -2391,16 +2752,6 @@ try {
2391
2752
  ...feedback,
2392
2753
  ),
2393
2754
  ...(editorReady ? editorSections.filter((section) => section.key === "name") : []),
2394
- editorReady
2395
- ? react.createElement(
2396
- "p",
2397
- {
2398
- className: "cpfe-version",
2399
- title: String(payload.compositionPath ?? "") + "\n" + t("meta.versionHint"),
2400
- },
2401
- "v" + String(payload.version ?? "?"),
2402
- )
2403
- : null,
2404
2755
  ...(editorReady ? editorSections.filter((section) => section.key === "mode") : []),
2405
2756
  editorReady
2406
2757
  ? react.createElement(
@@ -2440,6 +2791,21 @@ try {
2440
2791
  createGroup,
2441
2792
  // The confirmation is a portal: rendering it here keeps every piece of this page's
2442
2793
  // state in one component.
2794
+ renderable(A.RiskConfirmation) && orphanDialog !== null
2795
+ ? react.createElement(A.RiskConfirmation, {
2796
+ open: true, title: t("update.orphanDiscard"),
2797
+ description: fillPlaceholders(t("update.orphanConfirm"), { name: orphanDialog.name, id: orphanDialog.id }),
2798
+ acknowledgeLabel: t("update.orphanAcknowledge"), acknowledged: orphanAcknowledged,
2799
+ onAcknowledgedChange: (value) => {
2800
+ if (!updateOperation.current.alive || orphanConfirmation.current !== orphanDialog) return
2801
+ orphanAcknowledgement.current = value === true
2802
+ setOrphanAcknowledged(value === true)
2803
+ },
2804
+ cancelLabel: t("btn.cancel"), closeLabel: t("btn.cancel"), confirmLabel: t("update.orphanDiscard"),
2805
+ disabled: orphanActionBlock() !== "", onCancel: () => { if (updateOperation.current.alive && orphanConfirmation.current === orphanDialog) closeOrphanConfirmation() },
2806
+ onConfirm: () => discardOrphan(orphanDialog),
2807
+ })
2808
+ : null,
2443
2809
  renderable(A.RiskConfirmation) && editorReady
2444
2810
  ? react.createElement(A.RiskConfirmation, {
2445
2811
  open: deleteOpen,
@@ -2497,6 +2863,8 @@ try {
2497
2863
  console.warn("dsh-custom-mode: slots service unavailable; settings page not registered")
2498
2864
  return
2499
2865
  }
2866
+ try { mountUpdateServices(ctx) }
2867
+ catch { console.warn("dsh-custom-mode: optional native update services unavailable") }
2500
2868
  slots.inject("settings.section", () => {
2501
2869
  // `locale: NS` is the shipped contract: the shell then hands the
2502
2870
  // component a bound `t` in props.
package/index.mjs CHANGED
@@ -468,7 +468,7 @@ export function readState(rows, id, options = {}) {
468
468
  }
469
469
 
470
470
  /**
471
- * The list payload: every managed assistant, plus the root they live in.
471
+ * The list payload: managed assistants, their root, and the running plugin version.
472
472
  *
473
473
  * @param {Array<object>} rows - the current roster.
474
474
  */
@@ -478,7 +478,7 @@ export function readList(rows) {
478
478
  const list = assistantsFromRoster(rows).map((item) =>
479
479
  typeof item.broken === 'string' ? { ...item, brokenRows: brokenRowIds(item.broken) } : item,
480
480
  )
481
- return { ok: true, assistants: list, postures: publicPostures(), root: userPresetRoot(rows) }
481
+ return { ok: true, assistants: list, postures: publicPostures(), root: userPresetRoot(rows), version: PLUGIN_VERSION }
482
482
  }
483
483
 
484
484
  /**
@@ -1222,6 +1222,9 @@ export function apply(ctx) {
1222
1222
  backendVerdict.id === BACKEND_DECLARATIVE
1223
1223
  ? createDeclarativeBackend({ scope, log: console.log, warn: console.error })
1224
1224
  : null
1225
+ if (declarative !== null) {
1226
+ scope.effect(() => () => declarative.disposeAll(), 'custom-mode.presets')
1227
+ }
1225
1228
 
1226
1229
  /**
1227
1230
  * 删除入口,按后端选。新线上服务**没有** `remove()`(唯一手段是 `register()` 的 disposer),
@@ -1363,6 +1366,26 @@ export function apply(ctx) {
1363
1366
  * 写操作之后的重同步由 `resyncPresets` 负责,不该让每个请求都等一遍。
1364
1367
  */
1365
1368
  let startupSync = null
1369
+ let latestSync = { ok: true, failures: [] }
1370
+ const synchronize = async () => {
1371
+ const outcome = await doSync()
1372
+ latestSync = outcome
1373
+ return outcome
1374
+ }
1375
+ // Public synchronization evidence contains only bounded preset identifiers and
1376
+ // typed codes. Raw registration errors stay in host logs.
1377
+ const registrationSync = (assistantId) => {
1378
+ const failures = assistantId === undefined ? latestSync.failures
1379
+ : latestSync.failures.filter(({ id }) => typeof id !== 'string' || id === '' || id === assistantId)
1380
+ if (latestSync.ok === true || failures.length === 0) return { ok: true, code: 'synchronized', ids: [] }
1381
+ return {
1382
+ ok: false,
1383
+ code: 'presetRegistrationSyncFailed',
1384
+ ids: [...new Set(failures.map(({ id }) => id)
1385
+ .filter((id) => typeof id === 'string' && /^[A-Za-z0-9_.-]{1,128}$/.test(id)))].slice(0, 32),
1386
+ restartSuggested: failures.some(({ code }) => code === 'registrationConflict' || code === 'registrationCleanupFailed'),
1387
+ }
1388
+ }
1366
1389
  const roster = async () => {
1367
1390
  if (startupSync !== null) await startupSync
1368
1391
  return rawRoster()
@@ -1370,12 +1393,12 @@ export function apply(ctx) {
1370
1393
 
1371
1394
  /** Called inside the write queue; readers also await the current synchronization. */
1372
1395
  const resyncPresets = () => {
1373
- startupSync = doSync()
1396
+ startupSync = synchronize()
1374
1397
  return startupSync
1375
1398
  }
1376
1399
 
1377
1400
  // 启动即同步:新线不是"写盘即生效",第一次进来必须先注册。
1378
- startupSync = doSync()
1401
+ startupSync = synchronize()
1379
1402
 
1380
1403
  /**
1381
1404
  * One Fetch-shaped handler for the whole surface.
@@ -1404,11 +1427,20 @@ export function apply(ctx) {
1404
1427
  const url = new URL(request.url)
1405
1428
  if (request.method === 'GET' && pathname === ROUTE_PATH) {
1406
1429
  await ensureShipped()
1407
- return json(readList(await roster()))
1430
+ const rows = await roster()
1431
+ return json({ ...readList(rows), registrationSync: registrationSync() })
1408
1432
  }
1409
1433
  if (request.method === 'GET' && pathname === STATE_PATH) {
1410
1434
  await ensureShipped()
1411
- return json(readState(await roster(), url.searchParams.get('id') ?? '', { factoryPrompt: packagedPrompt() }))
1435
+ const state = readState(await roster(), url.searchParams.get('id') ?? '', { factoryPrompt: packagedPrompt() })
1436
+ const synchronization = registrationSync(state.id)
1437
+ return json({
1438
+ ...state,
1439
+ registrationSync: synchronization,
1440
+ ...(state.ok === true && synchronization.ok === false
1441
+ ? { warnings: [...state.warnings, synchronization.restartSuggested ? 'presetRegistrationRestartSuggested' : 'presetRegistrationSyncFailed'] }
1442
+ : {}),
1443
+ })
1412
1444
  }
1413
1445
 
1414
1446
  if (request.method === 'GET' && pathname === HISTORY_PATH) {
package/locales.mjs CHANGED
@@ -16,11 +16,49 @@
16
16
 
17
17
  /** Simplified Chinese dictionary — the key source of truth. */
18
18
  export const zh = {
19
+ 'update.heading': '插件更新',
20
+ 'update.orphanHint': '以下助手已不在当前列表,但本页仍保留其未保存草稿,因此暂不能离页。',
21
+ 'update.orphanName': '{name}(ID:{id})',
22
+ 'update.orphanView': '查看并保留草稿',
23
+ 'update.orphanHide': '收起草稿',
24
+ 'update.orphanCopy': '下方包含完整提示词、名称、描述、模式和覆盖项,可选中复制到本地文件保留。放弃仅移除本页内存草稿,不修改磁盘文件。',
25
+ 'update.orphanDiscard': '放弃此草稿…',
26
+ 'update.orphanConfirm': '确认放弃“{name}”(ID:{id})的未保存草稿?请先复制保留需要的内容。放弃后无法从本页恢复;磁盘文件不会改动。',
27
+ 'update.orphanAcknowledge': '我已保留需要的内容,确认放弃后无法从本页恢复。',
28
+ 'update.orphanChanged': '草稿或助手列表已变化,未放弃任何内容。请重新查看并确认。',
29
+ 'update.orphanDiscarded': '已放弃所确认的本页草稿;磁盘文件未改动。',
30
+ 'update.running': '运行中的插件版本:{version}',
31
+ 'update.unknown': '未知',
32
+ 'update.check': '检查更新',
33
+ 'update.checking': '正在检查…',
34
+ 'update.inPlugins': '在插件管理中更新',
35
+ 'update.details': '打开插件详情',
36
+ 'update.guide': '更新指引',
37
+ 'update.idle': '仅在点击检查时查询宿主配置的包仓库;不会自动安装。',
38
+ 'update.unavailable': '当前连接未提供原生版本检查能力。可打开插件详情或查看更新指引。',
39
+ 'update.permission': '此操作缺少宿主授权,或权限服务暂不可用。',
40
+ 'update.updatePermission': '可以查看版本,但当前连接没有更新权限。',
41
+ 'update.available': '可用更新:{version}。安装由官方插件管理处理。',
42
+ 'update.current': '此次检查未返回更高版本。',
43
+ 'update.filtered': '发现较新版本,但没有符合官方默认条件的稳定兼容版本。',
44
+ 'update.partial': '结果未覆盖全部新版本,暂未找到可推荐的更新。',
45
+ 'update.omitted': '另有 {count} 个较新版本未列出。',
46
+ 'update.disk': '磁盘安装版本:{version};它可能尚未在运行中加载。',
47
+ 'update.managed': '此安装无法使用原生包仓库更新,请通过原来源或插件详情管理;不会自动切换来源。',
48
+ 'update.notFound': '宿主插件列表中没有找到此插件,无法确认更新。',
49
+ 'update.network': '宿主查询包仓库失败,请稍后重试;尚不能确认更新状态。',
50
+ 'update.refused': '宿主未提供可用的版本列表,尚不能确认更新状态。',
51
+ 'update.failed': '未能完成更新检查,请重试或打开插件详情查看。',
52
+ 'update.finishOperation': '请等待当前操作完成后再离开此页。',
53
+ 'update.finishRecovery': '请先完成重试读取或重试生效,再离开此页。',
54
+ 'update.saveDrafts': '请先保存或主动放弃所有助手草稿及未提交的新建输入,再离开此页。',
55
+ 'update.navigationFailed': '未能打开插件详情;草稿已保留,可稍后重试。',
19
56
  'create.nextStep': '创建后可在上方继续编辑并保存;使用时,在新会话的模式菜单选择它。',
20
57
  'btn.retryRead': '重试读取',
21
58
  'btn.retrySync': '重试生效',
22
59
  'api.savedButSyncFailed': '文件已保存,但这些助手尚未生效:{ids}。请点「重试生效」,无需重复原操作。',
23
60
  'api.syncFailed': '这些助手仍未生效:{ids}。请重试;仍失败时查看宿主日志。',
61
+ 'api.presetRegistrationSyncFailed': '尚未确认已保存的助手配置在宿主注册表中生效,请处理已有告警或查看宿主日志。',
24
62
  'api.synchronized': '配置已生效。新会话请从模式菜单选择助手。',
25
63
  'api.recoveryRequired': '助手文件需要恢复:{path}。请保留目录中的备份,并查看宿主日志中的恢复说明。',
26
64
  'msg.completedReadFailed': '{result} 页面刷新失败。请点「重试读取」,无需重复原操作。',
@@ -150,6 +188,8 @@ export const zh = {
150
188
  'warn.approvalGateMissing': '当前版本没有审批事件。会话内改写提示词不会请求确认。',
151
189
  'warn.unresolvableRows': '有的行在本机无法启动,此模式不会出现在新会话菜单里。点「按本线修复」只关闭那些行。',
152
190
  'warn.presetBroken': '此模式无法启动,不会出现在新会话菜单里。点「按本线修复」。原因见下一行。',
191
+ 'warn.presetRegistrationSyncFailed': '尚未确认这位助手已保存的配置在宿主注册表中生效。请先处理已有告警或查看宿主日志;无需重复保存。',
192
+ 'warn.presetRegistrationRestartSuggested': '尚未确认这位助手已保存的配置在宿主注册表中生效。如刚更新或恢复插件,建议重启 DeepSeek Harness 后重新检查;无需重复保存。',
153
193
  'warn.badPromptVariable': '提示词里有不可用的变量。改掉后再保存,否则此模式的请求会失败。',
154
194
  'warn.duplicateRowIds': '组成文件里有重复的行 id。开关只作用于第一处。',
155
195
  'warn.predicateFailed': '有一行的平台条件无法计算,已按关闭处理。',
@@ -302,11 +342,49 @@ export const zh = {
302
342
 
303
343
  /** English dictionary; the parity test keeps its key set equal to {@link zh}. */
304
344
  export const en = {
345
+ 'update.heading': 'Plugin updates',
346
+ 'update.orphanHint': 'These assistants left the current list, but this page still holds their unsaved drafts. Resolve them before leaving.',
347
+ 'update.orphanName': '{name} (ID: {id})',
348
+ 'update.orphanView': 'View and keep draft',
349
+ 'update.orphanHide': 'Hide draft',
350
+ 'update.orphanCopy': 'The text below includes the full prompt, name, description, mode and overrides. Select and copy it to a local file to keep it. Discarding only removes this page\'s in-memory draft; files on disk are unchanged.',
351
+ 'update.orphanDiscard': 'Discard this draft…',
352
+ 'update.orphanConfirm': 'Discard the unsaved draft for "{name}" (ID: {id})? First copy any content you need to keep. This page cannot recover it after discarding; files on disk will remain unchanged.',
353
+ 'update.orphanAcknowledge': 'I have kept the content I need and understand this page cannot recover the discarded draft.',
354
+ 'update.orphanChanged': 'The draft or assistant list changed. Nothing was discarded. Review it again before confirming.',
355
+ 'update.orphanDiscarded': 'The confirmed draft was discarded from this page; files on disk are unchanged.',
356
+ 'update.running': 'Running plugin version: {version}',
357
+ 'update.unknown': 'unknown',
358
+ 'update.check': 'Check for updates',
359
+ 'update.checking': 'Checking…',
360
+ 'update.inPlugins': 'Update in Plugins',
361
+ 'update.details': 'Open plugin details',
362
+ 'update.guide': 'Update guide',
363
+ 'update.idle': 'Only a click checks the registries configured by the host; installation is never automatic.',
364
+ 'update.unavailable': 'This connection does not offer native version checks. Open plugin details or the update guide.',
365
+ 'update.permission': 'Host permission is missing for this action, or the access service is unavailable.',
366
+ 'update.updatePermission': 'You can view versions, but this connection does not have update permission.',
367
+ 'update.available': 'Update available: {version}. Official plugin management handles installation.',
368
+ 'update.current': 'This check returned no newer releases.',
369
+ 'update.filtered': 'Newer releases exist, but none meet the official default conditions for a stable compatible version.',
370
+ 'update.partial': 'The result omits some newer releases; no recommended update was found.',
371
+ 'update.omitted': '{count} additional newer releases were omitted.',
372
+ 'update.disk': 'Installed on disk: {version}; this version may not be loaded in the running host yet.',
373
+ 'update.managed': 'Manage this installation through its original source or plugin details; it cannot use this native registry update path. The source will not be changed automatically.',
374
+ 'update.notFound': 'The host did not list this plugin, so its update status could not be determined.',
375
+ 'update.network': 'The host could not query the registry. Try again later; update status is unknown.',
376
+ 'update.refused': 'The host did not provide a usable version list; update status is unknown.',
377
+ 'update.failed': 'The update check could not finish. Retry or open plugin details.',
378
+ 'update.finishOperation': 'Wait for the current operation to finish before leaving this page.',
379
+ 'update.finishRecovery': 'Complete Retry refresh or Retry activation before leaving this page.',
380
+ 'update.saveDrafts': 'Save or explicitly discard all assistant drafts and unsubmitted new-assistant input before leaving this page.',
381
+ 'update.navigationFailed': 'Plugin details could not open. Your drafts are preserved; try again later.',
305
382
  'create.nextStep': 'After creating, edit and save above. To use the assistant, choose it in the mode menu for a new session.',
306
383
  'btn.retryRead': 'Retry refresh',
307
384
  'btn.retrySync': 'Retry activation',
308
385
  'api.savedButSyncFailed': 'Files are saved, but these assistants are not active yet: {ids}. Select Retry activation; you do not need to repeat the original action.',
309
386
  'api.syncFailed': 'These assistants are still not active: {ids}. Retry, then check the host log if the problem continues.',
387
+ 'api.presetRegistrationSyncFailed': 'The saved assistant configuration has not been confirmed in the host registry. Address existing warnings or check the host log.',
310
388
  'api.synchronized': 'Configuration is active. Choose an assistant in the mode menu for a new session.',
311
389
  'api.recoveryRequired': 'Assistant files need recovery: {path}. Keep the backups in that directory and check the host log for recovery details.',
312
390
  'msg.completedReadFailed': '{result} The page could not refresh. Select Retry refresh; do not repeat the original action.',
@@ -437,6 +515,8 @@ export const en = {
437
515
  'warn.approvalGateMissing': 'This version has no approval event. In-session prompt edits are not confirmed.',
438
516
  'warn.unresolvableRows': 'Some rows cannot start here, so this mode is absent from the new-session menu. Fix for this line turns off only those rows.',
439
517
  'warn.presetBroken': 'This mode cannot start, so it will not appear for a new session. Use Fix for this line. The reason is on the next line.',
518
+ 'warn.presetRegistrationSyncFailed': 'This assistant\'s saved configuration has not been confirmed in the host registry. Address existing warnings or check the host log; you do not need to save again.',
519
+ 'warn.presetRegistrationRestartSuggested': 'This assistant\'s saved configuration has not been confirmed in the host registry. If the plugin was just updated or restored, consider restarting DeepSeek Harness, then check again; you do not need to save again.',
440
520
  'warn.badPromptVariable': 'The prompt uses a variable that is not available. Fix it before saving.',
441
521
  'warn.duplicateRowIds': 'A row id is repeated. A switch applies only to the first one.',
442
522
  'warn.predicateFailed': 'A platform condition could not be evaluated. That row is treated as off.',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "2.2.6",
4
- "description": "Edit a DeepSeek Harness (dsh) custom mode: system prompt, base mode, and plugin switches.",
3
+ "version": "2.3.0",
4
+ "description": "Customize DeepSeek Harness assistants: edit system prompts, base modes, and plugin switches.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "author": "bowluna <ai@bowluna.com>",
@@ -103,6 +103,8 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
103
103
  /** presetId → { dispose, dir } —— 当前挂在注册表里的东西,注销的唯一凭据。 */
104
104
  const mounted = new Map()
105
105
  let mutations = Promise.resolve()
106
+ let stopped = false
107
+ const stoppedResult = () => ({ ok: false, code: 'backendStopped', error: 'backend stopped' })
106
108
  const serialize = (work) => {
107
109
  const next = mutations.then(work, work)
108
110
  mutations = next.then(() => undefined, () => undefined)
@@ -135,6 +137,7 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
135
137
  * registry refuses a duplicate id outright.
136
138
  */
137
139
  async function mountOne(assistant) {
140
+ if (stopped) return stoppedResult()
138
141
  let definition
139
142
  try {
140
143
  definition = buildDefinition(assistant)
@@ -142,26 +145,40 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
142
145
  // 解析失败是**用户可见**的问题(手改坏了 composition),要显式说,不能静默跳过。
143
146
  warn(`custom-mode: 预设 "${assistant.id}" 的 composition 解析失败 —— ${describe(error)}。该助手不会出现在选择器里。`)
144
147
  await unmount(assistant.id)
145
- return { ok: false, error: describe(error) }
148
+ return { ok: false, code: 'compositionParseFailed', error: describe(error) }
146
149
  }
147
150
  if (definition === null) {
148
151
  await unmount(assistant.id)
149
- return { ok: false, error: 'no composition' }
152
+ return { ok: false, code: 'noComposition', error: 'no composition' }
150
153
  }
154
+ let registering = false
151
155
  try {
152
156
  await unmount(assistant.id)
157
+ if (stopped) return stoppedResult()
158
+ registering = true
153
159
  const dispose = await scope.agentPresets.register(definition)
160
+ registering = false
154
161
  mounted.set(assistant.id, { dispose, dir: assistant.dir })
162
+ // register() can finish after the scope starts stopping. Keep its disposer until
163
+ // it succeeds, and release only this generation's own registration.
164
+ if (stopped) {
165
+ await unmount(assistant.id)
166
+ return stoppedResult()
167
+ }
155
168
  return { ok: true }
156
169
  } catch (error) {
157
170
  // register 会抛(Duplicate / id 空 / 已存在):说清是哪个助手、哪条错误。
158
171
  warn(`custom-mode: 注册预设 "${assistant.id}" 失败 —— ${describe(error)}`)
159
- return { ok: false, error: describe(error) }
172
+ const code = registering
173
+ ? describe(error) === 'Duplicate agent preset: ' + assistant.id ? 'registrationConflict' : 'registrationFailed'
174
+ : 'registrationCleanupFailed'
175
+ return { ok: false, code, error: describe(error) }
160
176
  }
161
177
  }
162
178
 
163
179
  /** Bring the registry in line with the given assistant list: mount all, drop the rest. */
164
180
  async function sync(assistants) {
181
+ if (stopped) return assistants.map(({ id }) => ({ id, ...stoppedResult() }))
165
182
  const results = []
166
183
  for (const assistant of assistants) results.push({ id: assistant.id, ...(await mountOne(assistant)) })
167
184
  const wanted = new Set(assistants.map((a) => a.id))
@@ -175,7 +192,11 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
175
192
  }
176
193
 
177
194
  async function disposeAll() {
178
- for (const id of [...mounted.keys()]) await unmount(id)
195
+ const errors = []
196
+ for (const id of [...mounted.keys()]) {
197
+ try { await unmount(id) } catch (error) { errors.push(error) }
198
+ }
199
+ if (errors.length > 0) throw new AggregateError(errors, 'preset registration cleanup failed')
179
200
  }
180
201
 
181
202
  /**
@@ -262,8 +283,13 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
262
283
  sync: (assistants) => serialize(() => sync(assistants)),
263
284
  mountOne: (assistant) => serialize(() => mountOne(assistant)),
264
285
  unmount: (id) => serialize(() => unmount(id)),
265
- remove: (id, dir) => serialize(() => remove(id, dir)),
266
- disposeAll: () => serialize(disposeAll),
286
+ remove: (id, dir) => serialize(() => stopped ? stoppedResult() : remove(id, dir)),
287
+ disposeAll: () => {
288
+ // Close admission immediately, before awaiting queued/in-flight mutations. A
289
+ // startup sync still reading host metadata must not resurrect this backend.
290
+ stopped = true
291
+ return serialize(disposeAll)
292
+ },
267
293
  fetchBaseCompositions,
268
294
  mountedIds: () => [...mounted.keys()],
269
295
  }