dsh-custom-mode 2.3.0 → 2.3.1

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: c45d995f478c01972b08ea681831a593531c279a
5
- README.zh.md: f2a7906bddc3559d96585c8474a0a102d74cf44c
4
+ README.md: 3c201bfc473b860dec48b561a8467590683b3394
5
+ README.zh.md: 7e1fdbb9503908d8e285d9087653aa964510fd3c
package/README.md CHANGED
@@ -10,9 +10,21 @@ Edit your DeepSeek Harness (`dsh`) assistants' system prompts, base modes and pl
10
10
 
11
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)
12
12
 
13
+ ## Editing and draft safety
14
+
15
+ This section describes editing and draft protection in `2.3.1`. Confirm that exact version is available in [Releases](https://github.com/BOWLUNA/dsh-custom-mode/releases) before installing.
16
+
17
+ Select **New assistant…** in the page header to move to the existing creation form and focus its name input. Creating the assistant still requires choosing its starting settings, naming it and submitting the form. The currently selected assistant remains the editing target until creation succeeds. Enter is ignored while text is being composed; ordinary Enter submits the completed name.
18
+
19
+ Drafts remain in memory for the **current browser document**, including when you leave and return to the settings page. They are not a disk backup: reloading the document, closing the tab or restarting/unloading the plugin can lose unsaved edits. A leave-page warning cannot restore text after you confirm leaving. Copy or export important draft text first. When duplicating with pending edits, confirm that the copy uses the saved configuration; the original draft is retained.
20
+
21
+ Restoring the starting prompt, importing a file or loading saved history replaces only the prompt draft. If that would overwrite unsaved prompt text, confirm the replacement first; canceling keeps the text. Saving is still required to write the files.
22
+
23
+ If a save reports a conflict, copy the complete draft before selecting **Discard edits and reload**. A routine refresh keeps the dirty draft and its original baseline; it does not resolve the conflict. Compare the newly loaded files with your copied text before applying the intended edits again.
24
+
13
25
  ## Install and make the first request
14
26
 
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.
27
+ These instructions describe version `2.3.1`. 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.
16
28
 
17
29
  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.
18
30
 
@@ -22,21 +34,21 @@ Pin the plugin version; a bare name can resolve an older build during pnpm's rel
22
34
 
23
35
  ```sh
24
36
  dsh --version
25
- dsh plugin --profile web add dsh-custom-mode@2.3.0
37
+ dsh plugin --profile web add dsh-custom-mode@2.3.1
26
38
  ```
27
39
 
28
40
  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.
29
41
 
30
42
  ### Desktop and Windows
31
43
 
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.
44
+ In the desktop app, use its **Plugins** page to install `dsh-custom-mode@2.3.1`, 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.
33
45
 
34
46
  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.
35
47
 
36
48
  ### Verify the whole path
37
49
 
38
50
  1. Open Settings → Custom mode and check the displayed version. A page that opens is only the first check.
39
- 2. Scroll to **New assistant at the bottom of the page**. When the built-in `custom` assistant already exists, this section follows its editor. Choose a starting setting such as Writing, enter a recognizable name, and select New assistant. The created assistant becomes the editing target.
51
+ 2. Select **New assistant…** in the page header to focus the creation form. Choose a starting setting such as Writing, enter a recognizable name and select New assistant. The created assistant becomes the editing target.
40
52
  3. Set a temporary prompt such as `For the next readiness check, reply with exactly CUSTOM_MODE_READY.` Save and wait for the page to confirm that it was applied. If it says the file was saved but application failed, select Retry activation before continuing.
41
53
  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.
42
54
  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).
@@ -76,7 +88,7 @@ The prompt is stored in `prompt.md` and reread when the next model step is assem
76
88
 
77
89
  ## Upgrade, clone development, and removal
78
90
 
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.
91
+ The page header shows the **running plugin version**. When the host provides its 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
92
 
81
93
  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.
82
94
 
@@ -88,13 +100,13 @@ That command targets Web/CLI only. Desktop users must use an update path actuall
88
100
 
89
101
  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).
90
102
 
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.
103
+ `./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. In `2.3.1`, a failed or unverifiable host removal stops cleanup and purge, preserves the remaining files and exits with an error. Resolve the reported host error before retrying; desktop removal stays in the app's Plugins page.
92
104
 
93
105
  ## Compatibility and versioning
94
106
 
95
107
  Declared host range: `>=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0 || >=0.2.0-0 <0.3.0-0 || >=0.2.1-alpha.1 <0.3.0-0 || >=0.2.2-alpha.2 <0.3.0-0`
96
108
 
97
- The plugin version is its own stable `x.y.z` line; it does not mirror DSH. On 2026-10-27 the public host tags were `latest=0.2.1-rc.2`, `next=0.2.1-rc.4`, and `alpha=0.2.2-alpha.2`. Tags move; the declared range and completed checks are separate facts. See [exact versions, acceptance limits and historical evidence](docs/CURRENT_COMPATIBILITY.md) for the tested scope.
109
+ The plugin version is its own stable `x.y.z` line; it does not mirror DSH. On 2026-11-04 the public host tags were `latest=0.2.1-rc.5`, `next=0.2.2-rc.1`, and `alpha=0.2.2-alpha.3`. Tags move; the declared range and completed checks are separate facts. See [exact versions, acceptance limits and historical evidence](docs/CURRENT_COMPATIBILITY.md) for the tested scope.
98
110
 
99
111
  Release requires the compatibility and browser workflows. The unit runner, real prompt-component checks, packed-install checks and browser checks cover different boundaries. Windows/Electron, a real model response and GPU execution must each have their own evidence; they are not inferred from a page screenshot or unit count. Headless mode does not start an agent-preset session.
100
112
 
package/README.zh.md CHANGED
@@ -10,9 +10,21 @@
10
10
 
11
11
  [安装](#安装并完成首次请求) · [首次使用](#验证完整路径) · [更新](#plugin-updates) · [截图](#使用截图) · [兼容](#兼容与版本) · [求助](#帮助与文档)
12
12
 
13
+ ## 编辑与草稿保护
14
+
15
+ 本节说明`2.3.1`的编辑与草稿保护行为;安装前请先确认[发布版本](https://github.com/BOWLUNA/dsh-custom-mode/releases)中已有该精确版本。
16
+
17
+ 点击页首「**新建助手…**」即可定位到既有新建表单并聚焦名称输入框。仍需选择起步设置、填写名称并提交表单;创建成功前,当前选中的助手仍是编辑对象。文字仍在组合输入时不会用Enter提交,普通Enter可提交完成的名称。
18
+
19
+ 草稿仅保存在**当前浏览器文档的内存**中,离开并返回设置页时仍可继续编辑。它不是磁盘备份:刷新文档、关闭标签页或重启/卸载插件可能丢失未保存编辑;确认离开后的文本无法靠离页提醒恢复。先复制或导出重要草稿;有待保存编辑时复制助手,须确认副本使用已保存配置,原草稿继续保留。
20
+
21
+ 恢复起步提示词、导入文件或载入已保存历史只替换提示词草稿;会覆盖未保存提示词时,先确认替换,取消则保留原文。仍需保存才写入文件。
22
+
23
+ 保存报告冲突时,先复制完整草稿,再明确选择「**放弃修改并重新读取**」。普通读取会保留脏草稿及其原基线,不能解除冲突;请将重新载入的文件与复制文本对比后,再应用确实需要的编辑。
24
+
13
25
  ## 安装并完成首次请求
14
26
 
15
- 本说明描述版本 `2.3.0`。安装或更新前,先确认[发布版本](https://github.com/BOWLUNA/dsh-custom-mode/releases)中已有该精确版本,并核对[精确宿主结果与验收边界](docs/CURRENT_COMPATIBILITY.zh.md)。
27
+ 本说明描述版本 `2.3.1`。安装或更新前,先确认[发布版本](https://github.com/BOWLUNA/dsh-custom-mode/releases)中已有该精确版本,并核对[精确宿主结果与验收边界](docs/CURRENT_COMPATIBILITY.zh.md)。
16
28
 
17
29
  先安装 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),并在宿主自己的界面中配置可用模型。用 `dsh --version` 对照兼容记录中的精确宿主版本。本插件不安装宿主,也不配置模型凭据。
18
30
 
@@ -22,21 +34,21 @@
22
34
 
23
35
  ```sh
24
36
  dsh --version
25
- dsh plugin --profile web add dsh-custom-mode@2.3.0
37
+ dsh plugin --profile web add dsh-custom-mode@2.3.1
26
38
  ```
27
39
 
28
40
  用原有启动方式重启为该 Web profile 提供服务的进程。本地 CLI 的既有启动形式是 `dsh web --port 3081 --no-open`;选择空闲端口,打开该进程打印的地址。只重启这个实例。安装 bundle 不会刷新已经运行的宿主。
29
41
 
30
42
  ### 桌面端与 Windows
31
43
 
32
- 在桌面应用的 **Plugins(插件)** 页安装 `dsh-custom-mode@2.3.0`,然后重启应用。本项目脚本明确拒绝 `--profile desktop`,不要手改它的 profile 文件。Windows/Electron 与 Web 分别验收,详见兼容记录。
44
+ 在桌面应用的 **Plugins(插件)** 页安装 `dsh-custom-mode@2.3.1`,然后重启应用。本项目脚本明确拒绝 `--profile desktop`,不要手改它的 profile 文件。Windows/Electron 与 Web 分别验收,详见兼容记录。
33
45
 
34
46
  Windows 上可在 PowerShell 中用宿主提供的 `dsh` 执行上面的 npm 安装命令。只有可选的 clone 安装脚本需要 Git Bash,另需 Node.js 和 npm。PATH 中没有 `dsh` 时,使用宿主自带启动器,或将 `dsh` 替换为 `node "<host-install>/node_modules/@deepseek-ai/dsh/lib/bin.js"`。除非宿主确实装在本插件目录中,否则不要把这个路径指向插件目录。
35
47
 
36
48
  ### 验证完整路径
37
49
 
38
50
  1. 打开设置 → 自定义模式,检查显示版本。页面能打开只是第一步。
39
- 2. 滚动到**页面底部的「新建助手」区**。已有内置 `custom` 助手时,该区位于编辑区之后。选择写作等起步设置,输入容易辨认的名称,再点「新增助手」;新建的助手会成为编辑对象。
51
+ 2. 点击页首「**新建助手…**」聚焦新建表单,选择写作等起步设置,输入容易辨认的名称,再点「新增助手」;新建的助手会成为编辑对象。
40
52
  3. 临时把提示词设为 `下一次就绪检查只回复 CUSTOM_MODE_READY。`,保存并等页面确认已应用。若提示文件已保存但应用失败,先点「重试生效」。
41
53
  4. 新建一个**会话**,打开该会话的模式/助手选择器,明确选择刚才命名的助手。通过 DSH 中已经配置的模型发送 `就绪检查。`
42
54
  5. 成功意味着助手可以选择、没有预设损坏告警,并返回预期就绪回复且不出现提示词组装错误。检查后恢复自己的提示词。失败时先看[当前自救步骤](docs/TROUBLESHOOTING.zh.md#当前自救步骤)。
@@ -76,7 +88,7 @@ Windows 上可在 PowerShell 中用宿主提供的 `dsh` 执行上面的 npm 安
76
88
 
77
89
  ## 升级、clone 开发与卸载
78
90
 
79
- 页首显示**运行中的插件版本**。宿主提供DSH `0.2.2-alpha.2`的原生更新能力时,点「**检查更新**」让宿主查询可用版本。有推荐版本后,「**在插件管理中更新**」会打开官方插件详情,由它承接确认、进度与结果;自定义页不直接安装包。离开前先保存待处理草稿,并处理生效/读取告警。
91
+ 页首显示**运行中的插件版本**。宿主提供原生更新能力时,点「**检查更新**」让宿主查询可用版本。有推荐版本后,「**在插件管理中更新**」会打开官方插件详情,由它承接确认、进度与结果;自定义页不直接安装包。离开前先保存待处理草稿,并处理生效/读取告警。
80
92
 
81
93
  `0.2.1-rc.2`、`0.2.1-rc.4`、`0.2.0-rc.2`和`0.1.7-rc.2`没有原生版本查询API,可用「**打开插件详情**」或「**更新指引**」,再按下方固定版本方式手动更新。文件、tarball与源码链接安装继续由原来源管理,不会因检查更新而悄悄转成npm。权限拒绝、网络失败、版本被筛除或结果不完整都不代表已是最新。「**磁盘安装版本**」与运行版本不同,不能据此认定新代码已运行。
82
94
 
@@ -88,13 +100,13 @@ Windows 上可在 PowerShell 中用宿主提供的 `dsh` 执行上面的 npm 安
88
100
 
89
101
  源码开发按[隔离准备步骤](CONTRIBUTING.zh.md#隔离开发)进行。可选的`./install.sh --profile web`保留源码链接方式,已有提示词、元数据和组成不被覆盖。本地包/代码回滚与恢复路径见[包与发布资产](docs/PUBLISHING.zh.md#包与发布资产)。
90
102
 
91
- `./uninstall.sh` 移除插件但保留助手数据。`--purge` 明确删除受管助手目录及提示词,使用前请自行备份。
103
+ `./uninstall.sh` 移除插件但保留助手数据。`--purge` 明确删除受管助手目录及提示词,使用前请自行备份。`2.3.1`中,宿主卸载失败或其结果无法核实时,会停止清理与purge、保留剩余文件并以错误退出;处理报告的宿主错误后再重试。桌面端卸载仍使用应用内Plugins页。
92
104
 
93
105
  ## 兼容与版本
94
106
 
95
107
  声明的宿主范围:`>=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0 || >=0.2.0-0 <0.3.0-0 || >=0.2.1-alpha.1 <0.3.0-0 || >=0.2.2-alpha.2 <0.3.0-0`
96
108
 
97
- 插件版本走独立的稳定 `x.y.z` 版本线,不镜像 DSH。2026-10-27 公开宿主标签为 `latest=0.2.1-rc.2`、`next=0.2.1-rc.4`、`alpha=0.2.2-alpha.2`。标签会移动,声明范围与已完成检查是两件事。实测范围见[精确版本、验收边界与历史证据](docs/CURRENT_COMPATIBILITY.zh.md)。
109
+ 插件版本走独立的稳定 `x.y.z` 版本线,不镜像 DSH。2026-11-04 公开宿主标签为 `latest=0.2.1-rc.5`、`next=0.2.2-rc.1`、`alpha=0.2.2-alpha.3`。标签会移动,声明范围与已完成检查是两件事。实测范围见[精确版本、验收边界与历史证据](docs/CURRENT_COMPATIBILITY.zh.md)。
98
110
 
99
111
  发布必须通过兼容性与浏览器工作流。单元、真实提示词组件、打包安装和浏览器检查分别覆盖不同边界。Windows/Electron、真实模型回复、GPU 执行都必须单独留证,不能从页面截图或单元计数推断。headless 不启动使用 agent preset 的会话。
100
112
 
package/client.js CHANGED
@@ -139,6 +139,9 @@ try {
139
139
  "assistant.brokenHint": "此模式在本机无法启动,不会出现在新会话菜单里。点「按本线修复」只关闭那些行。",
140
140
  "assistant.switchHint": "切换助手保留各自的未保存草稿;列表中的「未保存」标记即为此。",
141
141
  "btn.create": "新增助手",
142
+ "btn.newAssistant": "新建助手…",
143
+ "assistant.duplicateSavedConfirm": "复制使用磁盘上已保存的配置,不包含当前未保存的修改。继续复制已保存版本?当前草稿会保留。",
144
+ "prompt.replaceConfirm": "替换当前提示词草稿?尚未保存的提示词会被替换,无法从保存历史中恢复。请先复制保留需要的内容。",
142
145
  "create.heading": "新建助手",
143
146
  "btn.creating": "创建中…",
144
147
  "btn.duplicate": "复制",
@@ -188,7 +191,7 @@ try {
188
191
  "meta.version": "插件版本",
189
192
  "meta.versionHint": "未钉版本时,可能装到较旧的发布。要换版本,按 README 钉版本重装,然后重启。",
190
193
  "api.saved": "已保存({name},{mode})。下一步组装时会重读已保存的提示词。",
191
- "api.conflict": "磁盘上的提示词已经变了,这次没有写入。先重新读取,再决定要不要覆盖。",
194
+ "api.conflict": "磁盘上的助手配置已经变了,这次没有写入。请先导出或复制保留草稿,再重新读取并合并修改。",
192
195
  "api.savedWithExclusiveRows": "已保存({name},{mode})。已关闭不能同时启用的行:{rows}。",
193
196
  "api.created": "已创建「{name}」。可继续编辑;使用时请在新会话的模式菜单选择它。",
194
197
  "api.savedPrompt": "已保存系统提示词({name})。下一步组装时会重读这个文件。",
@@ -441,6 +444,9 @@ try {
441
444
  "assistant.brokenHint": "This mode cannot start here, so it will not appear for a new session. Fix for this line turns off only those rows.",
442
445
  "assistant.switchHint": "Switching assistants keeps each draft; the \"Unsaved\" marker in the list is exactly that.",
443
446
  "btn.create": "New assistant",
447
+ "btn.newAssistant": "New assistant…",
448
+ "assistant.duplicateSavedConfirm": "Copy the saved configuration from disk? Unsaved changes are not included, and your current draft will be kept.",
449
+ "prompt.replaceConfirm": "Replace the current prompt draft? Unsaved prompt text will be replaced and cannot be recovered from saved history. Copy any content you need to keep first.",
444
450
  "create.heading": "New assistant",
445
451
  "btn.creating": "Creating…",
446
452
  "btn.duplicate": "Duplicate",
@@ -490,7 +496,7 @@ try {
490
496
  "meta.version": "Plugin version",
491
497
  "meta.versionHint": "Without a pinned version, an older release may be installed. To change it, reinstall with the version pin from the README, then restart.",
492
498
  "api.saved": "Saved ({name}, {mode}). The next model step rereads the saved prompt.",
493
- "api.conflict": "The prompt on disk has changed, so this save was not written. Reload, then decide whether to overwrite.",
499
+ "api.conflict": "The assistant configuration on disk has changed. Nothing was written. Export or copy your draft, then reload and merge your changes.",
494
500
  "api.savedWithExclusiveRows": "Saved ({name}, {mode}). Turned off rows that cannot be on together: {rows}.",
495
501
  "api.created": "Created {name}. Continue editing here, then choose it in the mode menu for a new session.",
496
502
  "api.savedPrompt": "System prompt saved ({name}). The next model step rereads this file.",
@@ -1139,6 +1145,39 @@ try {
1139
1145
  )
1140
1146
  }
1141
1147
 
1148
+ // Private to this module instance in this document. Leaving a settings
1149
+ // section must not lose a draft; no prompt is written to browser storage.
1150
+ const draftContexts = new WeakMap()
1151
+ function mountDraftProtection(ctx) {
1152
+ let session = draftContexts.get(ctx)
1153
+ if (session === undefined) {
1154
+ const memory = { entries: {}, selected: "", newName: "", syncNeeded: false, refreshNeeded: null }
1155
+ session = { memory, owners: new Set(), beforeUnload: (event) => {
1156
+ if (memory.newName === "" && Object.keys(memory.entries).length === 0) return
1157
+ event.preventDefault()
1158
+ event.returnValue = ""
1159
+ } }
1160
+ draftContexts.set(ctx, session)
1161
+ }
1162
+ ctx.effect(() => {
1163
+ const owner = {}
1164
+ session.owners.add(owner)
1165
+ if (session.owners.size === 1 && typeof window.addEventListener === "function") window.addEventListener("beforeunload", session.beforeUnload)
1166
+ return () => {
1167
+ session.owners.delete(owner)
1168
+ if (session.owners.size !== 0) return
1169
+ if (typeof window.removeEventListener === "function") window.removeEventListener("beforeunload", session.beforeUnload)
1170
+ session.memory.entries = {}
1171
+ session.memory.selected = ""
1172
+ session.memory.newName = ""
1173
+ session.memory.syncNeeded = false
1174
+ session.memory.refreshNeeded = null
1175
+ draftContexts.delete(ctx)
1176
+ }
1177
+ }, "custom-mode: draft protection")
1178
+ return session.memory
1179
+ }
1180
+
1142
1181
  /** `「名称」副本`-style copy name, from a locale template. */
1143
1182
  function copyName(template, name) {
1144
1183
  return String(template).replace("{name}", name)
@@ -1325,6 +1364,7 @@ try {
1325
1364
  }
1326
1365
 
1327
1366
  function CustomModeSection(props) {
1367
+ const draftMemory = props.draftMemory
1328
1368
  // Compatibility: a shell that does not hand us a bound `t` (older or
1329
1369
  // changed settings contract) must still render real copy. Falling back to
1330
1370
  // the Chinese dictionary means the worst case is "Chinese text", never a
@@ -1390,28 +1430,38 @@ try {
1390
1430
  const [list, setListState] = react.useState(null)
1391
1431
  const heldRoster = react.useRef(list)
1392
1432
  const setList = (value) => { heldRoster.current = value; setListState(value) }
1393
- const [selected, setSelected] = react.useState("")
1433
+ const [selected, setSelectedState] = react.useState(draftMemory.selected)
1434
+ const setSelected = (value) => { draftMemory.selected = value; setSelectedState(value) }
1394
1435
  /** id → { payload, saved, value }: loaded state, baseline and live draft. */
1395
- const [entries, setEntriesState] = react.useState({})
1436
+ const [entries, setEntriesState] = react.useState(draftMemory.entries)
1396
1437
  const heldEntries = react.useRef(entries)
1397
1438
  const setEntries = (next) => {
1398
1439
  heldEntries.current = typeof next === "function" ? next(heldEntries.current) : next
1440
+ draftMemory.entries = Object.fromEntries(Object.entries(heldEntries.current).filter(([, held]) => !sameDraft(held.value, held.saved)))
1399
1441
  setEntriesState(heldEntries.current)
1400
1442
  }
1401
1443
  const [status, setStatus] = react.useState("")
1402
1444
  const [failed, setFailed] = react.useState(false)
1403
1445
  const [busy, setBusy] = react.useState(false)
1404
1446
  const [pending, setPending] = react.useState("")
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) }
1447
+ const [syncNeeded, setSyncNeededState] = react.useState(draftMemory.syncNeeded)
1448
+ const [refreshNeeded, setRefreshNeededState] = react.useState(draftMemory.refreshNeeded)
1449
+ const navigationState = react.useRef({ syncNeeded, refreshNeeded, newName: draftMemory.newName })
1450
+ const setSyncNeeded = (value) => { draftMemory.syncNeeded = value; navigationState.current.syncNeeded = value; setSyncNeededState(value) }
1451
+ const setRefreshNeeded = (value) => { draftMemory.refreshNeeded = value; navigationState.current.refreshNeeded = value; setRefreshNeededState(value) }
1410
1452
  // A ref closes the interval before React renders disabled controls. Only the
1411
1453
  // owning operation may apply a response or release the busy state.
1412
1454
  const operation = react.useRef({ alive: true, current: null })
1413
- const [newName, setNewNameState] = react.useState("")
1414
- const setNewName = (value) => { navigationState.current.newName = value; setNewNameState(value) }
1455
+ const [newName, setNewNameState] = react.useState(draftMemory.newName)
1456
+ const setNewName = (value) => { draftMemory.newName = value; navigationState.current.newName = value; setNewNameState(value) }
1457
+ const createSection = react.useRef(null)
1458
+ const createNameInput = react.useRef(null)
1459
+ const editorSection = react.useRef(null)
1460
+ const [focusCreated, setFocusCreated] = react.useState("")
1461
+ const focusCreate = () => {
1462
+ createSection.current?.scrollIntoView?.({ block: "start", behavior: "auto" })
1463
+ createNameInput.current?.focus?.({ preventScroll: true })
1464
+ }
1415
1465
  const [runtimeVersion, setRuntimeVersion] = react.useState("")
1416
1466
  const [updateResult, setUpdateResult] = react.useState(null)
1417
1467
  const [updateNotice, setUpdateNotice] = react.useState("")
@@ -1444,7 +1494,9 @@ try {
1444
1494
  const repairRowsNow = async () => {
1445
1495
  if (draft === null) return
1446
1496
  return runOperation("status.repairing", "msg.saveFailed", async (token) => {
1447
- const result = await postJson(ROUTES.repair, { id: selected })
1497
+ const baseRevision = entry?.payload?.revision
1498
+ const result = await postJson(ROUTES.repair, { id: selected,
1499
+ ...(typeof baseRevision === "string" ? { baseRevision } : {}) })
1448
1500
  if (!ownsOperation(token)) return
1449
1501
  showResult(result, "msg.repaired", "msg.saveFailed")
1450
1502
  if (result?.ok === true || result?.persisted === true) {
@@ -1457,9 +1509,17 @@ try {
1457
1509
  const repairedIds = Array.isArray(result.params?.repairedIds) ? result.params.repairedIds : []
1458
1510
  if (repairedIds.length > 0 && draft !== null) {
1459
1511
  // 草稿里的 `overrides` 是"显式**启用**"语义,所以关闭 = false。
1460
- const nextOverrides = { ...draft.overrides }
1461
- for (const rowId of repairedIds) nextOverrides[rowId] = false
1462
- update({ overrides: nextOverrides }, { force: true })
1512
+ setEntries((previous) => {
1513
+ const before = previous[selected]
1514
+ const overrides = { ...before.value.overrides }
1515
+ const savedOverrides = { ...before.saved.overrides }
1516
+ for (const rowId of repairedIds) { overrides[rowId] = false; savedOverrides[rowId] = false }
1517
+ const ownRevision = typeof result.revision === "string" && before.payload.revision === baseRevision
1518
+ return { ...previous, [selected]: { ...before,
1519
+ value: { ...before.value, overrides },
1520
+ ...(ownRevision ? { payload: { ...before.payload, revision: result.revision },
1521
+ saved: { ...before.saved, overrides: savedOverrides } } : {}) } }
1522
+ })
1463
1523
  }
1464
1524
  await refreshAfterWrite(result, "msg.repaired", selected, token, () => reload(selected, undefined, token))
1465
1525
  }
@@ -1497,6 +1557,12 @@ try {
1497
1557
  const payload = entry === undefined ? null : entry.payload
1498
1558
  const editorReady = draft !== null && payload !== null
1499
1559
  const dirty = editorReady && !sameDraft(draft, entry.saved)
1560
+ react.useEffect(() => {
1561
+ if (focusCreated === "" || focusCreated !== selected || !editorReady) return
1562
+ editorSection.current?.scrollIntoView?.({ block: "start", behavior: "auto" })
1563
+ editorSection.current?.focus?.({ preventScroll: true })
1564
+ setFocusCreated("")
1565
+ }, [focusCreated, selected, editorReady])
1500
1566
 
1501
1567
  /** Which assistants hold unsaved edits — the list marks them. */
1502
1568
  const dirtyIds = new Set()
@@ -1575,8 +1641,14 @@ try {
1575
1641
  const fresh = draftOf(result)
1576
1642
  setEntries((previous) => {
1577
1643
  const before = previous[id]
1578
- const keep = discard !== true && before !== undefined && !sameDraft(before.value, before.saved) ? before.value : fresh
1579
- return { ...previous, [id]: { payload: result, saved: fresh, value: keep } }
1644
+ const keep = discard !== true && before !== undefined && !sameDraft(before.value, before.saved)
1645
+ // A dirty draft owns the revision it was loaded from. A read may
1646
+ // discover someone else's change, but cannot authorize overwriting it.
1647
+ if (keep && typeof before.payload?.revision === "string") return { ...previous, [id]: { ...before,
1648
+ // Diagnostics may recover independently of this draft. Refresh
1649
+ // those fields without changing its editable baseline or token.
1650
+ value: { ...before.value, warnings: fresh.warnings, broken: fresh.broken, baseUnavailable: fresh.baseUnavailable } } }
1651
+ return { ...previous, [id]: { payload: result, saved: fresh, value: keep ? before.value : fresh } }
1580
1652
  })
1581
1653
  }
1582
1654
 
@@ -1856,13 +1928,17 @@ try {
1856
1928
  if (!ownsOperation(token)) return
1857
1929
  showResult(result, from === undefined ? "msg.created" : "msg.duplicated", "msg.createFailed")
1858
1930
  if (result?.ok === true || result?.persisted === true) {
1859
- setNewName("")
1931
+ if (from === undefined) setNewName("")
1932
+ setFocusCreated(result.id)
1860
1933
  await refreshAfterWrite(result, from === undefined ? "msg.created" : "msg.duplicated", result.id, token, () => reload(result.id, undefined, token))
1861
1934
  }
1862
1935
  })
1863
1936
  }
1864
1937
 
1865
- const duplicate = () => create({ from: draft.id, name: copyName(t("assistant.copyName"), draft.name || draft.id) })
1938
+ const duplicate = () => {
1939
+ if (dirty && (typeof window.confirm !== "function" || !window.confirm(t("assistant.duplicateSavedConfirm")))) return
1940
+ return create({ from: draft.id, name: copyName(t("assistant.copyName"), draft.name || draft.id) })
1941
+ }
1866
1942
 
1867
1943
  /**
1868
1944
  * Move the selected assistant one slot in the picker order.
@@ -1872,10 +1948,18 @@ try {
1872
1948
  */
1873
1949
  const move = async (direction) => {
1874
1950
  return runOperation("status.reordering", "msg.reorderFailed", async (token) => {
1875
- const result = await postJson(ROUTES.reorder, { id: draft.id, direction })
1951
+ const baseRevisions = Object.fromEntries(Object.entries(heldEntries.current)
1952
+ .filter(([, held]) => typeof held.payload?.revision === "string")
1953
+ .map(([id, held]) => [id, held.payload.revision]))
1954
+ const result = await postJson(ROUTES.reorder, { id: draft.id, direction, baseRevisions })
1876
1955
  if (!ownsOperation(token)) return
1877
1956
  showResult(result, "msg.reordered", "msg.reorderFailed")
1878
1957
  if (result?.ok === true || result?.persisted === true) {
1958
+ setEntries((previous) => Object.fromEntries(Object.entries(previous).map(([id, held]) => {
1959
+ const revision = result.revisions?.[id]
1960
+ return [id, typeof revision === "string" && typeof baseRevisions[id] === "string" &&
1961
+ held.payload?.revision === baseRevisions[id] ? { ...held, payload: { ...held.payload, revision } } : held]
1962
+ })))
1879
1963
  await refreshAfterWrite(result, "msg.reordered", draft.id, token, () => reload(draft.id, undefined, token))
1880
1964
  }
1881
1965
  })
@@ -1888,16 +1972,21 @@ try {
1888
1972
  * 只改**草稿**,不写盘:这样"一键回到官方"既立刻可见,又可被「重新读取」撤销 ——
1889
1973
  * 与页面其余部分一样,一切改动都要点保存才落盘。
1890
1974
  */
1975
+ const allowPromptReplacement = () => {
1976
+ const held = heldEntries.current[selected]
1977
+ return held === undefined || sameDraft(held.value, held.saved) ||
1978
+ (typeof window.confirm === "function" && window.confirm(t("prompt.replaceConfirm")))
1979
+ }
1891
1980
  const resetPrompt = () => {
1892
1981
  const factory = typeof draft.factoryPrompt === "string" ? draft.factoryPrompt : ""
1893
- if (factory === "") return
1982
+ if (factory === "" || !allowPromptReplacement()) return
1894
1983
  update({ prompt: factory })
1895
1984
  }
1896
1985
 
1897
1986
  /** 把某个历史版本载入编辑器。与「恢复出厂」同一条纪律:只改草稿,保存前不落盘。 */
1898
1987
  const loadVersion = async () => {
1899
1988
  const picked = typeof draft.historyPick === "string" ? draft.historyPick : ""
1900
- if (picked === "" || draft.id === undefined) return
1989
+ if (picked === "" || draft.id === undefined || !allowPromptReplacement()) return
1901
1990
  return runOperation("status.reading", "msg.loadFailed", async (token) => {
1902
1991
  // 版本键是序号(时间戳会在同一毫秒内撞车 —— 单测就是那样抓到它的)。
1903
1992
  const response = await fetch(ROUTES.history + "?id=" + encodeURIComponent(draft.id) + "&n=" + encodeURIComponent(picked))
@@ -1952,6 +2041,7 @@ try {
1952
2041
  setStatus(t("msg.importTooLarge"))
1953
2042
  return
1954
2043
  }
2044
+ if (!allowPromptReplacement()) return
1955
2045
  return runOperation("status.importing", "msg.importFailed", async (token) => {
1956
2046
  const text = await new Promise((resolve, reject) => {
1957
2047
  const reader = new FileReader()
@@ -2014,7 +2104,7 @@ try {
2014
2104
  }
2015
2105
 
2016
2106
  const save = async () => {
2017
- if (draft === null) return
2107
+ if (draft === null || navigationState.current.refreshNeeded !== null || navigationState.current.syncNeeded) return
2018
2108
  return runOperation("status.saving", "msg.saveFailed", async (token) => {
2019
2109
  const savedHistory = entry?.saved?.history
2020
2110
  const newest = Array.isArray(savedHistory) && savedHistory.length > 0 ? savedHistory[0].n : undefined
@@ -2025,11 +2115,16 @@ try {
2025
2115
  prompt: draft.prompt,
2026
2116
  name: draft.name,
2027
2117
  description: draft.description,
2028
- ...(typeof newest === "number" ? { baseN: newest } : {}),
2118
+ ...(typeof entry?.payload?.revision === "string" ? { baseRevision: entry.payload.revision }
2119
+ : typeof newest === "number" ? { baseN: newest } : {}),
2029
2120
  })
2030
2121
  if (!ownsOperation(token)) return
2031
2122
  if (result?.synchronized === false) setSyncNeeded(true)
2032
2123
  else if (result?.synchronized === true) setSyncNeeded(false)
2124
+ if ((result?.ok === true || result?.persisted === true) && typeof result.revision === "string") {
2125
+ setEntries((previous) => ({ ...previous, [draft.id]: { ...previous[draft.id],
2126
+ payload: { ...previous[draft.id].payload, revision: result.revision }, saved: draft } }))
2127
+ }
2033
2128
  if (result !== null && result !== undefined && result.ok === true) {
2034
2129
  setFailed(false)
2035
2130
  const compositionChanged =
@@ -2104,7 +2199,7 @@ try {
2104
2199
  ]
2105
2200
  const createGroup = react.createElement(
2106
2201
  "section",
2107
- { className: "cpfe-create" },
2202
+ { className: "cpfe-create", ref: createSection },
2108
2203
  react.createElement("h2", { className: "cpfe-h" }, t("create.heading")),
2109
2204
  react.createElement(SectionHint, { text: t("posture.hint") }),
2110
2205
  react.createElement(
@@ -2145,13 +2240,15 @@ try {
2145
2240
  "div",
2146
2241
  { className: "cpfe-newrow" },
2147
2242
  react.createElement(A.Input, {
2243
+ ref: createNameInput,
2148
2244
  className: "cpfe-newinput",
2149
2245
  value: newName,
2150
2246
  disabled: busy,
2151
2247
  placeholder: t("assistant.newPlaceholder"),
2152
2248
  "aria-label": t("assistant.newPlaceholder"),
2153
2249
  onKeyDown: (event) => {
2154
- if (event.key === "Enter" && busy !== true) create()
2250
+ if (event.key === "Enter" && !event.isComposing && !event.nativeEvent?.isComposing &&
2251
+ event.keyCode !== 229 && event.nativeEvent?.keyCode !== 229 && busy !== true) create()
2155
2252
  },
2156
2253
  onChange: (event) => {
2157
2254
  setNewName(event.target.value)
@@ -2241,7 +2338,9 @@ try {
2241
2338
  const assistantList = react.createElement(
2242
2339
  "section",
2243
2340
  null,
2244
- react.createElement("h2", { className: "cpfe-h" }, t("assistant.heading")),
2341
+ react.createElement("div", { className: "cpfe-sec-head" },
2342
+ react.createElement("h2", { className: "cpfe-h" }, t("assistant.heading")),
2343
+ react.createElement(A.Button, { variant: "outline", disabled: busy, onClick: focusCreate, "data-create-entry": true }, t("btn.newAssistant"))),
2245
2344
  react.createElement(SectionHint, { text: t("assistant.hint") }),
2246
2345
  list === null
2247
2346
  ? react.createElement(
@@ -2713,7 +2812,7 @@ try {
2713
2812
  editorReady
2714
2813
  ? react.createElement(
2715
2814
  "section",
2716
- null,
2815
+ { ref: editorSection, tabIndex: -1, "data-assistant-editor": true },
2717
2816
  react.createElement("h2", { className: "cpfe-h" }, t("editor.heading")),
2718
2817
  react.createElement(SectionHint, { text: fillPlaceholders(t("editor.hint"), { name: draft.name || draft.id }) }),
2719
2818
  react.createElement(
@@ -2728,7 +2827,7 @@ try {
2728
2827
  {
2729
2828
  variant: "primary",
2730
2829
  icon: A.IconCheckOutline14 === undefined ? null : react.createElement(A.IconCheckOutline14),
2731
- disabled: busy || syncNeeded || editorReady === false || dirty === false,
2830
+ disabled: busy || syncNeeded || refreshNeeded !== null || editorReady === false || dirty === false,
2732
2831
  onClick: save,
2733
2832
  },
2734
2833
  pending === "status.saving" ? t("btn.saving") : t("btn.save"),
@@ -2863,6 +2962,7 @@ try {
2863
2962
  console.warn("dsh-custom-mode: slots service unavailable; settings page not registered")
2864
2963
  return
2865
2964
  }
2965
+ const draftMemory = mountDraftProtection(ctx)
2866
2966
  try { mountUpdateServices(ctx) }
2867
2967
  catch { console.warn("dsh-custom-mode: optional native update services unavailable") }
2868
2968
  slots.inject("settings.section", () => {
@@ -2880,7 +2980,7 @@ try {
2880
2980
  label: () => translate("nav", undefined, navT === null ? undefined : navT),
2881
2981
  locale: NS,
2882
2982
  },
2883
- CustomModeSection,
2983
+ function CustomModeSettings(props) { return CustomModeSection({ ...props, draftMemory }) },
2884
2984
  )
2885
2985
  })
2886
2986
  } catch (error) {
package/composition.mjs CHANGED
@@ -24,6 +24,18 @@
24
24
 
25
25
  import { readFileSync, existsSync, readdirSync } from 'node:fs'
26
26
  import { join } from 'node:path'
27
+ import { AsyncLocalStorage } from 'node:async_hooks'
28
+ import { decodeJsExpression, evaluateDisabledPredicate } from './preset-backend/parse-composition.mjs'
29
+
30
+ // Capabilities belong to the request that observed them, never to a module-global
31
+ // host context. Store only known booleans, not service objects or arbitrary ctx access.
32
+ const predicateCapabilities = new AsyncLocalStorage()
33
+ export function withPredicateCapabilities(snapshot, work) {
34
+ const capabilities = typeof snapshot?.profileContext === 'boolean'
35
+ ? Object.freeze({ profileContext: snapshot.profileContext })
36
+ : Object.freeze({})
37
+ return predicateCapabilities.run(capabilities, work)
38
+ }
27
39
 
28
40
  // 出厂组成「从哪来」是两条 dsh 线唯一还没收进后端的一处:旧线是文件,0.1.7 是
29
41
  // agentPresets.readDocument()。解析链整个搬到了 base-composition.mjs(含它的理由与实测),
@@ -460,7 +472,7 @@ function setDisabled(segmentText, disabled) {
460
472
  const shipped = lines[index].trim().replace(/^disabled:\s*/, '')
461
473
  if (disabled) {
462
474
  lines[index] = `${' '.repeat(indent)}disabled: true${carriage}`
463
- } else if (shipped.startsWith('!!js') && evalDisabledExpression(shipped) !== true) {
475
+ } else if (shipped.startsWith('!!js') && evalDisabledExpression(shipped) === false) {
464
476
  // 显式打开,但出厂那行是平台表达式、且它在本机求值就是"开":这不是覆盖,而是**撤销覆盖** ——
465
477
  // 保持出厂表达式原样,这一行就回到"跟随平台"。(原先这里删掉整行,结果是文件既不是出厂原样、
466
478
  // 也不是显式覆盖,页面却显示"未拨过" —— 文件与显示同时失真。)
@@ -496,9 +508,8 @@ function setDisabled(segmentText, disabled) {
496
508
  function evalDisabledExpression(expression) {
497
509
  if (typeof expression !== 'string' || !expression.startsWith('!!js')) return undefined
498
510
  try {
499
- // eslint-disable-next-line no-new-func -- evaluating the composition's own predicate is the point
500
- const fn = new Function('process', `return (${expression.slice(4).trim()});`)
501
- return fn(process) === true
511
+ const source = decodeJsExpression(expression.slice(4))
512
+ return evaluateDisabledPredicate(source, predicateCapabilities.getStore())
502
513
  } catch {
503
514
  // 求值失败与「表达式不存在」都是 undefined。调用方若已经确认这是 `!!js`,
504
515
  // 必须把 undefined 当成关闭:挂载侧同样按关闭处理,两边不能一个开一个关。
@@ -1020,13 +1031,19 @@ export function overridesOf(text, modeId) {
1020
1031
  // 某个字面量一致 —— 只看求值结果,会把"表达式被换成字面量"误判成"没动过"。
1021
1032
  const sameForm = baseRow !== undefined
1022
1033
  && baseRow.disabled === row.disabled
1023
- && baseRow.disabledExpression === row.disabledExpression
1034
+ && predicateForm(baseRow.disabledExpression) === predicateForm(row.disabledExpression)
1024
1035
  if (sameForm) continue
1025
1036
  overrides[id] = row.disabled !== true
1026
1037
  }
1027
1038
  return overrides
1028
1039
  }
1029
1040
 
1041
+ /** YAML quoting is transport syntax, while literal versus expression remains significant. */
1042
+ function predicateForm(expression) {
1043
+ if (typeof expression !== 'string') return expression
1044
+ try { return decodeJsExpression(expression.slice(4)) } catch { return expression }
1045
+ }
1046
+
1030
1047
  /**
1031
1048
  * 按允许名单生成显式开关。
1032
1049
  *
package/index.mjs CHANGED
@@ -55,7 +55,8 @@ import {
55
55
  effectiveRosterRows,
56
56
  } from './preset-backend/index.mjs'
57
57
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
58
- import { dirname, join } from 'node:path'
58
+ import { createHash } from 'node:crypto'
59
+ import { basename, dirname, join } from 'node:path'
59
60
  import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR, dshHome } from './paths.mjs'
60
61
  import { ensureUnsetDefaultPreset } from './defaults.mjs'
61
62
  import {
@@ -76,6 +77,7 @@ import {
76
77
  setBaseCompositions,
77
78
  setShippedPresetsDir,
78
79
  unresolvableRows,
80
+ withPredicateCapabilities,
79
81
  } from './composition.mjs'
80
82
  import { presetMetaText, readPresetMeta, writePresetMeta, presetMetaPath, PRESET_META_PATH } from './meta.mjs'
81
83
  import { listHistory, readVersion, recordExternalChange, recordPrompt, HISTORY_SOURCE } from './journal.mjs'
@@ -373,11 +375,42 @@ export function brokenRowIds(message) {
373
375
  return [...new Set(ids)]
374
376
  }
375
377
 
378
+ /**
379
+ * An opaque content revision of the complete editable state. Length framing and
380
+ * an explicit missing marker distinguish absent files from empty ones. History
381
+ * is deliberately excluded: losing an audit write must not disable conflict checks.
382
+ */
383
+ export function stateRevision(directory) {
384
+ return revisionWithEntries(directory)
385
+ }
386
+
387
+ function revisionWithEntries(directory, entries = []) {
388
+ const replacements = new Map(entries.map(([file, text]) => [basename(file), Buffer.from(text, 'utf8')]))
389
+ const hash = createHash('sha256')
390
+ for (const name of ['prompt.md', 'preset.yml', 'agent.cordis.yml']) {
391
+ hash.update(name + '\0')
392
+ let bytes
393
+ try {
394
+ bytes = replacements.has(name) ? replacements.get(name) : readFileSync(join(directory, name))
395
+ } catch (error) {
396
+ if (error.code !== 'ENOENT') throw error
397
+ hash.update('missing\0')
398
+ continue
399
+ }
400
+ hash.update('present:' + String(bytes.length) + '\0')
401
+ hash.update(bytes)
402
+ }
403
+ return hash.digest('hex')
404
+ }
405
+
376
406
  export function readState(rows, id, options = {}) {
377
407
  const directory = assistantDir(rows, id)
378
408
  if (directory === undefined) return unknownAssistant(id)
379
409
  const recovery = restoreBackups(directory)
380
410
  if (recovery.ok !== true) return { ok: false, code: 'recoveryRequired', params: { path: directory, detail: recovery.detail }, error: '助手文件需要恢复:' + recovery.detail }
411
+ // Take the token before reading the displayed fields. A later external edit
412
+ // can then cause a conservative conflict, never bless an older displayed value.
413
+ const revision = stateRevision(directory)
381
414
  const composition = compositionFile(directory)
382
415
  if (!existsSync(composition)) return { ok: false, code: 'compositionMissing', params: { path: composition }, error: '找不到组成文件:' + composition }
383
416
  const text = readFileSync(composition, 'utf8')
@@ -423,6 +456,7 @@ export function readState(rows, id, options = {}) {
423
456
  return {
424
457
  ok: true,
425
458
  id,
459
+ revision,
426
460
  mode,
427
461
  modes: BASE_MODES,
428
462
  rows: rowTree,
@@ -503,33 +537,75 @@ const writeChains = new Map()
503
537
  /**
504
538
  * 打开页面之后、按下保存之前,`prompt.md` 可能已被会话工具或手工改过。
505
539
  *
506
- * 先把磁盘上的那一版记进历史。页面带了 JSON 数字 `baseN`,且那个版本不是磁盘上这一版时,
507
- * 拒绝写入:磁盘保持外部文本。不带 `baseN` 的旧客户端仍覆盖,但原文已经在历史里。
508
- * 数字字符串不算 `baseN`。留痕自己失败时不挡住这次保存。
540
+ * 新页面用 baseRevision 比较三个编辑文件的原始内容。旧页面的 JSON 数字
541
+ * baseN 仍比较提示词历史;没有 token 的旧 API 保留覆盖语义。
542
+ * 冲突判定与审计写入隔离:历史不可写时也不能放行过期草稿。
509
543
  *
510
544
  * @param {string} directory
511
545
  * @param {string} prompt - the text this save wants to write.
512
546
  * @param {unknown} baseN
547
+ * @param {unknown} baseRevision - opaque revision returned when the draft was loaded.
513
548
  * @returns {object|null} a `conflict` result, or null when the write may proceed.
514
549
  */
515
- function refuseStalePrompt(directory, prompt, baseN) {
516
- try {
517
- const diskPrompt = readPrompt(directory)
518
- if (diskPrompt.ok !== true || diskPrompt.text === prompt) return null
519
- recordExternalChange(directory, diskPrompt.text)
520
- if (typeof baseN === 'number' && readVersion(directory, baseN) !== diskPrompt.text) {
521
- return {
522
- ok: false,
523
- code: 'conflict',
524
- params: { baseN },
525
- error: '磁盘上的提示词已经变了,这次保存没有写入。',
526
- note: '磁盘上的提示词已经变了,这次保存没有写入。',
550
+ function refuseStaleState(directory, prompt, baseN, baseRevision) {
551
+ const diskPrompt = readPrompt(directory)
552
+ let stale = false
553
+ if (typeof baseRevision === 'string') {
554
+ try {
555
+ stale = stateRevision(directory) !== baseRevision
556
+ } catch {
557
+ // An unreadable editable file cannot prove the loaded baseline is current.
558
+ stale = true
559
+ }
560
+ } else if (typeof baseN === 'number') {
561
+ stale = diskPrompt.ok !== true || (diskPrompt.text !== prompt && readVersion(directory, baseN) !== diskPrompt.text)
562
+ }
563
+ if (diskPrompt.ok === true && diskPrompt.text !== prompt) {
564
+ try {
565
+ recordExternalChange(directory, diskPrompt.text)
566
+ } catch {
567
+ /* 审计留痕失败不改变上面已经完成的冲突判定 */
568
+ }
569
+ }
570
+ return stale ? {
571
+ ok: false,
572
+ code: 'conflict',
573
+ params: typeof baseRevision === 'string' ? {} : { baseN },
574
+ error: '磁盘上的助手文件已经变了或无法核对,这次保存没有写入。',
575
+ note: '磁盘上的助手文件已经变了或无法核对,这次保存没有写入。',
576
+ } : null
577
+ }
578
+
579
+ /** Reordering writes every assistant's metadata, so validate all held baselines first. */
580
+ function reorderState(rows, input) {
581
+ const baselines = input?.baseRevisions
582
+ if (baselines !== null && typeof baselines === 'object') {
583
+ for (const [id, revision] of Object.entries(baselines)) {
584
+ const directory = assistantDir(rows, id)
585
+ if (directory === undefined || typeof revision !== 'string') {
586
+ return { ok: false, code: 'conflict', params: {}, error: '助手列表或文件已经变了,这次排序没有写入。' }
527
587
  }
588
+ const conflict = refuseStaleState(directory, readPrompt(directory).text ?? '', undefined, revision)
589
+ if (conflict !== null) return conflict
528
590
  }
529
- } catch {
530
- /* 审计留痕失败不挡住这次保存 */
531
591
  }
532
- return null
592
+ const ids = new Map(assistantsFromRoster(rows).map(({ id }) => [assistantDir(rows, id), id]))
593
+ const revisions = {}
594
+ const result = reorderAssistant(rows, input, (name, description, directory, options) => {
595
+ const rendered = presetMetaText(name, description, directory, options)
596
+ if (rendered.ok !== true) return rendered
597
+ try {
598
+ const entries = [[presetMetaPath(directory), rendered.text]]
599
+ const revision = revisionWithEntries(directory, entries)
600
+ writeAtomic(presetMetaPath(directory), rendered.text)
601
+ revisions[ids.get(directory)] = revision
602
+ return { ok: true, name: rendered.name }
603
+ } catch (error) {
604
+ return { ok: false, code: 'metaWriteFailed', params: { detail: describe(error) }, error: '写入 preset.yml 失败:' + describe(error) }
605
+ }
606
+ })
607
+ // Failed reorders can roll back earlier writes; none of their tokens may be adopted.
608
+ return result.ok === true ? { ...result, revisions } : result
533
609
  }
534
610
 
535
611
  function serializedWrite(key, work) {
@@ -549,13 +625,15 @@ function serializedWrite(key, work) {
549
625
  * 的行集合也就不变,这是所有选项里破坏性最小的一个。
550
626
  *
551
627
  * @param {string} directory - the assistant's preset directory.
552
- * @param {{id: string, mode: string, prompt: string, name: string, description: string, displayName?: string, baseN?: number}} input
628
+ * @param {{id: string, mode: string, prompt: string, name: string, description: string, displayName?: string, baseN?: number, baseRevision?: string}} input
553
629
  * - the validated save; `name` decides whether `preset.yml` is written, `displayName` is what the response
554
630
  * reports (the disk name when the request omitted one — issue #9). `baseN` is the same stale-save
555
631
  * token as {@link saveState}: a JSON number only.
556
632
  * @returns {object} the shape `saveState` returns, with `code: 'savedPromptOnly'`.
557
633
  */
558
- export function savePromptOnly(directory, { id, mode, prompt, name, description, displayName, baseN }) {
634
+ export function savePromptOnly(directory, { id, mode, prompt, name, description, displayName, baseN, baseRevision }) {
635
+ const recovery = restoreBackups(directory)
636
+ if (recovery.ok !== true) return { ok: false, code: 'recoveryRequired', params: { path: directory, detail: recovery.detail }, error: '助手文件需要恢复:' + recovery.detail }
559
637
  // 与正常保存同一条纪律:提示词与 preset.yml 一起换名,失败则两个都不动。
560
638
  let metaText = null
561
639
  if (name !== '') {
@@ -563,13 +641,18 @@ export function savePromptOnly(directory, { id, mode, prompt, name, description,
563
641
  if (rendered.ok !== true) return rendered
564
642
  metaText = rendered.text
565
643
  }
566
- const conflict = refuseStalePrompt(directory, prompt, baseN)
644
+ const conflict = refuseStaleState(directory, prompt, baseN, baseRevision)
567
645
  if (conflict !== null) return conflict
646
+ let revision
568
647
  try {
569
- writeAtomicPair([
648
+ const entries = [
570
649
  [promptFile(directory), prompt],
571
650
  ...metaText === null ? [] : [[presetMetaPath(directory), metaText]],
572
- ])
651
+ ]
652
+ // Return our exact submitted generation, without a post-commit read that
653
+ // could fail or accidentally adopt a different writer's subsequent change.
654
+ revision = revisionWithEntries(directory, entries)
655
+ writeAtomicPair(entries)
573
656
  } catch (error) {
574
657
  return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
575
658
  }
@@ -583,6 +666,7 @@ export function savePromptOnly(directory, { id, mode, prompt, name, description,
583
666
  id,
584
667
  mode,
585
668
  code: 'savedPromptOnly',
669
+ revision,
586
670
  // 响应里的名字用**磁盘上的那个**(issue #9):请求省略 name 时不该把 params.name 报成裸目录 id。
587
671
  params: { name: (displayName === undefined || displayName === '' ? name : displayName) === '' ? id : (displayName === undefined || displayName === '' ? name : displayName), mode },
588
672
  note: '已保存系统提示词(本机取不到基础模式的出厂组成,插件开关与基础模式未改动)。下一步组装时会重读这个文件。',
@@ -669,6 +753,7 @@ export function saveState(rows, input) {
669
753
  description,
670
754
  displayName: effectiveName,
671
755
  baseN: input !== null && typeof input === 'object' ? input.baseN : undefined,
756
+ baseRevision: input !== null && typeof input === 'object' ? input.baseRevision : undefined,
672
757
  })
673
758
  }
674
759
  return {
@@ -701,14 +786,17 @@ export function saveState(rows, input) {
701
786
  if (rendered.ok !== true) return rendered
702
787
  metaText = rendered.text
703
788
  }
704
- const conflict = refuseStalePrompt(directory, prompt, input !== null && typeof input === 'object' ? input.baseN : undefined)
789
+ const conflict = refuseStaleState(directory, prompt, input?.baseN, input?.baseRevision)
705
790
  if (conflict !== null) return conflict
791
+ let revision
706
792
  try {
707
- writeAtomicPair([
793
+ const entries = [
708
794
  [compositionFile(directory), composition],
709
795
  [promptFile(directory), prompt],
710
796
  ...metaText === null ? [] : [[presetMetaPath(directory), metaText]],
711
- ])
797
+ ]
798
+ revision = revisionWithEntries(directory, entries)
799
+ writeAtomicPair(entries)
712
800
  } catch (error) {
713
801
  return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
714
802
  }
@@ -732,6 +820,7 @@ export function saveState(rows, input) {
732
820
  id,
733
821
  mode,
734
822
  code: 'savedWithExclusiveRows',
823
+ revision,
735
824
  // `rows` 用行 id:页面自己按 `row.<id>.label` 渲染,英文界面不会因为这条掉回中文。
736
825
  params: { name: displayName, mode, rows: autoOff.join(', ') },
737
826
  note:
@@ -744,6 +833,7 @@ export function saveState(rows, input) {
744
833
  id,
745
834
  mode,
746
835
  code: 'saved',
836
+ revision,
747
837
  params: { name: displayName, mode },
748
838
  note: '已保存(' + displayName + ',基础模式 ' + mode + ')。下一步组装时会重读已保存的提示词。',
749
839
  }
@@ -976,6 +1066,8 @@ export function repairComposition(rows, input) {
976
1066
  const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
977
1067
  const directory = assistantDir(rows, id)
978
1068
  if (directory === undefined) return unknownAssistant(id)
1069
+ const conflict = refuseStaleState(directory, readPrompt(directory).text ?? '', undefined, input?.baseRevision)
1070
+ if (conflict !== null) return conflict
979
1071
  const file = compositionFile(directory)
980
1072
  if (!existsSync(file)) return { ok: false, code: 'compositionMissing', params: { path: file }, error: '找不到组成文件:' + file }
981
1073
  const text = readFileSync(file, 'utf8')
@@ -1006,7 +1098,9 @@ export function repairComposition(rows, input) {
1006
1098
  }
1007
1099
  // **就地**关闭那几行,不重渲染:重渲染会顺手丢掉 base 之外的自有行,而用户的数据不该被这样动。
1008
1100
  const rendered = disableRowsInPlace(text, bad.map((row) => row.id))
1101
+ let revision
1009
1102
  try {
1103
+ revision = revisionWithEntries(directory, [[file, rendered]])
1010
1104
  writeAtomic(file, rendered)
1011
1105
  } catch (error) {
1012
1106
  return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
@@ -1016,6 +1110,7 @@ export function repairComposition(rows, input) {
1016
1110
  ok: true,
1017
1111
  id,
1018
1112
  code: 'repaired',
1113
+ revision,
1019
1114
  // `ids` 是给人看的一串;`repairedIds` 是给**页面**用的数组 —— 页面必须把这几行写进它自己的
1020
1115
  // 草稿,否则下一次保存会按草稿重渲染,把刚才的修复原样撤销(issue #8 实测:
1021
1116
  // 修复写进了磁盘,但草稿里那几行仍是启用的,于是保存后 ghost 行又回来了)。
@@ -1422,7 +1517,15 @@ export function apply(ctx) {
1422
1517
  detail: outcome.failures.map((failure) => failure.error ?? failure.code ?? 'unknown').join('; '),
1423
1518
  })
1424
1519
 
1425
- const handle = async (request, pathname) => {
1520
+ const capabilitySnapshot = () => {
1521
+ try {
1522
+ if (typeof scope.get === 'function') return { profileContext: Boolean(scope.get('profileContext')) }
1523
+ } catch {
1524
+ /* Optional host capability is unknown; keep predicate warning/fail-safe. */
1525
+ }
1526
+ return {}
1527
+ }
1528
+ const handle = (request, pathname) => withPredicateCapabilities(capabilitySnapshot(), async () => {
1426
1529
  try {
1427
1530
  const url = new URL(request.url)
1428
1531
  if (request.method === 'GET' && pathname === ROUTE_PATH) {
@@ -1488,7 +1591,7 @@ export function apply(ctx) {
1488
1591
  const result = pathname === STATE_PATH ? saveState(rows, parsed)
1489
1592
  : pathname === CREATE_PATH ? createAssistant(rows, parsed)
1490
1593
  : pathname === REPAIR_PATH ? repairComposition(rows, parsed)
1491
- : pathname === REORDER_PATH ? reorderAssistant(rows, parsed)
1594
+ : pathname === REORDER_PATH ? reorderState(rows, parsed)
1492
1595
  : await deleteAssistant(rows, parsed, presetRemover())
1493
1596
  if (result.ok !== true) return json(result, 400)
1494
1597
  const synced = await resyncPresets()
@@ -1507,7 +1610,7 @@ export function apply(ctx) {
1507
1610
  // ——中文界面里会蹦出一句英文。带 code 后页面用自己的词典渲染,原始串只作为 `detail` 参数。
1508
1611
  return json({ ok: false, code: 'internalError', params: { detail: describe(error) }, error: describe(error) }, 500)
1509
1612
  }
1510
- }
1613
+ })
1511
1614
 
1512
1615
  // One registration per exact path — the platform's Fetch registry matches exact paths, not prefixes —
1513
1616
  // each declaring the methods it owns. The method table doubles as the guarantee that a prefetched
package/locales.mjs CHANGED
@@ -101,6 +101,9 @@ export const zh = {
101
101
  'assistant.brokenHint': '此模式在本机无法启动,不会出现在新会话菜单里。点「按本线修复」只关闭那些行。',
102
102
  'assistant.switchHint': '切换助手保留各自的未保存草稿;列表中的「未保存」标记即为此。',
103
103
  'btn.create': '新增助手',
104
+ 'btn.newAssistant': '新建助手…',
105
+ 'assistant.duplicateSavedConfirm': '复制使用磁盘上已保存的配置,不包含当前未保存的修改。继续复制已保存版本?当前草稿会保留。',
106
+ 'prompt.replaceConfirm': '替换当前提示词草稿?尚未保存的提示词会被替换,无法从保存历史中恢复。请先复制保留需要的内容。',
104
107
  'create.heading': '新建助手',
105
108
  'btn.creating': '创建中…',
106
109
  'btn.duplicate': '复制',
@@ -150,7 +153,7 @@ export const zh = {
150
153
  'meta.version': '插件版本',
151
154
  'meta.versionHint': '未钉版本时,可能装到较旧的发布。要换版本,按 README 钉版本重装,然后重启。',
152
155
  'api.saved': '已保存({name},{mode})。下一步组装时会重读已保存的提示词。',
153
- 'api.conflict': '磁盘上的提示词已经变了,这次没有写入。先重新读取,再决定要不要覆盖。',
156
+ 'api.conflict': '磁盘上的助手配置已经变了,这次没有写入。请先导出或复制保留草稿,再重新读取并合并修改。',
154
157
  'api.savedWithExclusiveRows': '已保存({name},{mode})。已关闭不能同时启用的行:{rows}。',
155
158
  'api.created': '已创建「{name}」。可继续编辑;使用时请在新会话的模式菜单选择它。',
156
159
  'api.savedPrompt': '已保存系统提示词({name})。下一步组装时会重读这个文件。',
@@ -425,6 +428,9 @@ export const en = {
425
428
  'assistant.brokenHint': 'This mode cannot start here, so it will not appear for a new session. Fix for this line turns off only those rows.',
426
429
  'assistant.switchHint': 'Switching assistants keeps each draft; the "Unsaved" marker in the list is exactly that.',
427
430
  'btn.create': 'New assistant',
431
+ 'btn.newAssistant': 'New assistant…',
432
+ 'assistant.duplicateSavedConfirm': 'Copy the saved configuration from disk? Unsaved changes are not included, and your current draft will be kept.',
433
+ 'prompt.replaceConfirm': 'Replace the current prompt draft? Unsaved prompt text will be replaced and cannot be recovered from saved history. Copy any content you need to keep first.',
428
434
  'create.heading': 'New assistant',
429
435
  'btn.creating': 'Creating…',
430
436
  'btn.duplicate': 'Duplicate',
@@ -474,7 +480,7 @@ export const en = {
474
480
  'meta.version': 'Plugin version',
475
481
  'meta.versionHint': 'Without a pinned version, an older release may be installed. To change it, reinstall with the version pin from the README, then restart.',
476
482
  'api.saved': 'Saved ({name}, {mode}). The next model step rereads the saved prompt.',
477
- 'api.conflict': 'The prompt on disk has changed, so this save was not written. Reload, then decide whether to overwrite.',
483
+ 'api.conflict': 'The assistant configuration on disk has changed. Nothing was written. Export or copy your draft, then reload and merge your changes.',
478
484
  'api.savedWithExclusiveRows': 'Saved ({name}, {mode}). Turned off rows that cannot be on together: {rows}.',
479
485
  'api.created': 'Created {name}. Continue editing here, then choose it in the mode menu for a new session.',
480
486
  'api.savedPrompt': 'System prompt saved ({name}). The next model step rereads this file.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "description": "Customize DeepSeek Harness assistants: edit system prompts, base modes, and plugin switches.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,20 +26,17 @@
26
26
  import { existsSync, readFileSync, renameSync, rmSync } from 'node:fs'
27
27
  import { basename, dirname, join } from 'node:path'
28
28
  import { pathToFileURL } from 'node:url'
29
- import { parseComposition } from './parse-composition.mjs'
29
+ import { parseComposition, evaluateDisabledPredicate, profileContextPredicate } from './parse-composition.mjs'
30
30
 
31
31
  export const BACKEND_ID = 'declarative'
32
32
 
33
- /** Evaluate a `!!js` predicate. Only ever applied to text this feature itself wrote. */
33
+ /** Preserve the host-native predicate node so the loader evaluates it against its real Entry.ctx. */
34
34
  function resolveDisabled(value) {
35
35
  if (value !== null && typeof value === 'object' && typeof value.__js === 'string') {
36
- try {
37
- // eslint-disable-next-line no-new-func
38
- return Boolean(new Function(`"use strict"; return (${value.__js});`)())
39
- } catch {
40
- // 表达式求值不了时按"关掉"处理:宁可少一行能力,也不要让整条 preset 挂载失败。
41
- return true
42
- }
36
+ // An unknown ctx service can return undefined rather than throw, which would
37
+ // make the native loader enable it. Keep unsupported conditions truly off.
38
+ if (profileContextPredicate(value.__js) === null && evaluateDisabledPredicate(value.__js) === undefined) return true
39
+ return { __jsExpr: value.__js }
43
40
  }
44
41
  return value === true
45
42
  }
@@ -39,6 +39,56 @@ export class CompositionParseError extends Error {
39
39
  const isBlank = (line) => line.trim() === '' || line.trim().startsWith('#')
40
40
  const indentOf = (line) => line.length - line.trimStart().length
41
41
 
42
+ /** Decode the YAML scalar around a tagged JS source; never execute the expression. */
43
+ export function decodeJsExpression(raw, lineNo = 0) {
44
+ const source = String(raw).trim()
45
+ if (source === '') throw new CompositionParseError('!!js 后面没有表达式', lineNo)
46
+ if (source.startsWith("'")) {
47
+ if (!/^'(?:[^']|'')*'$/.test(source)) throw new CompositionParseError('!!js 单引号标量无效', lineNo)
48
+ return source.slice(1, -1).replace(/''/g, "'")
49
+ }
50
+ if (source.startsWith('"')) {
51
+ try {
52
+ const decoded = JSON.parse(source)
53
+ if (typeof decoded !== 'string') throw new Error('Expected a string')
54
+ return decoded
55
+ } catch {
56
+ throw new CompositionParseError('!!js 双引号标量无效', lineNo)
57
+ }
58
+ }
59
+ return source
60
+ }
61
+
62
+ /** The only context-service predicate currently understood by the editor. */
63
+ export function profileContextPredicate(source) {
64
+ const match = /^(!{0,2})\s*ctx\s*\.\s*get\s*\(\s*(['"])profileContext\2\s*\)$/.exec(source)
65
+ return match === null ? null : match[1].length
66
+ }
67
+
68
+ /**
69
+ * Preserve existing non-context evaluation semantics for installed composition
70
+ * code, while unknown context references stay unsupported rather than guessed.
71
+ */
72
+ export function evaluateDisabledPredicate(source, capabilities = {}) {
73
+ const negations = profileContextPredicate(source)
74
+ if (negations !== null) {
75
+ if (typeof capabilities?.profileContext !== 'boolean') return undefined
76
+ return negations === 1 ? !capabilities.profileContext : capabilities.profileContext
77
+ }
78
+ const code = source
79
+ .replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, '')
80
+ .replace(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g, '')
81
+ if (/\bctx\b/.test(code)) return undefined
82
+ try {
83
+ // Composition files are installed executable code. No real host context is
84
+ // supplied, and the ctx parameter shadows any unrelated global of that name.
85
+ const evaluate = new Function('process', 'ctx', `return (${source});`)
86
+ return Boolean(evaluate(process, undefined))
87
+ } catch {
88
+ return undefined
89
+ }
90
+ }
91
+
42
92
  /**
43
93
  * Parse a composition document into plain JS.
44
94
  *
@@ -156,9 +206,7 @@ export function parseScalar(raw, lineNo = 0) {
156
206
  const value = raw.trim()
157
207
  if (value === '') return null
158
208
  if (value.startsWith('!!js')) {
159
- const source = value.slice(4).trim()
160
- if (source === '') throw new CompositionParseError('!!js 后面没有表达式', lineNo)
161
- return { __js: source }
209
+ return { __js: decodeJsExpression(value.slice(4), lineNo) }
162
210
  }
163
211
  if (value === 'true') return true
164
212
  if (value === 'false') return false