dsh-custom-mode 2.2.5 → 2.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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: f111de149dd80c8d3e73f5c6ef0d43e8af6d3748
5
- README.zh.md: ede6eabf8c6b7383e60158fbe76e6f86cb7c00d4
4
+ README.md: f43403111d49476d68271c74036aec063e8b3417
5
+ README.zh.md: 43c26010c77fca3121c1c1906c40880c710c16e9
package/README.md CHANGED
@@ -19,80 +19,78 @@ Edit a DeepSeek Harness (`dsh`) custom mode: system prompt, base mode, and plugi
19
19
  </tr>
20
20
  </table>
21
21
 
22
- ## Install
22
+ ## Install and make the first request
23
23
 
24
- Pin the version. A bare name can install an older build during pnpm's release cooldown. Details: [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
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.
25
+
26
+ 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
+
28
+ ### Web / CLI
29
+
30
+ Pin the plugin version; a bare name can resolve an older build during pnpm's release cooldown:
25
31
 
26
32
  ```sh
27
- dsh plugin --profile web add dsh-custom-mode@2.2.5
33
+ dsh --version
34
+ dsh plugin --profile web add dsh-custom-mode@2.2.6
28
35
  ```
29
36
 
30
- Restart the process that serves the profile. Success means Settings → Custom mode shows `v2.2.5`, and the session mode picker offers Custom mode without a broken-preset warning.
37
+ 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
+
39
+ ### Desktop and Windows
31
40
 
32
- Desktop app: its in-app Plugins page is the installation entry point; the CLI cannot modify `profiles/desktop`. This release has not been verified on Windows/Electron.
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.
33
42
 
34
- On Windows, `install.sh` is bash and it needs a `dsh` executable on `PATH`. Otherwise use the `dsh` shipped with DeepSeek Harness, or `node node_modules/@deepseek-ai/dsh/lib/bin.js`.
43
+ 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
44
 
36
- The GitHub Release also provides a packed plugin (`dsh-custom-mode-2.2.5.tgz`), a preset-only import archive, and `SHA256SUMS`. Verify the checksum, then install the packed plugin with `dsh plugin --profile web add ./dsh-custom-mode-2.2.5.tgz`. The preset-only archive does not include the settings plugin.
45
+ ### Verify the whole path
37
46
 
38
- ## Use
47
+ 1. Open Settings → Custom mode and check the displayed version. A page that opens is only the first check.
48
+ 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.
49
+ 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.
50
+ 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
+ 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).
39
52
 
40
- 1. Open Settings → Custom mode.
41
- 2. Edit the system prompt. Save is directly under the editor.
42
- 3. For example, select Writing, create an assistant, then edit its prompt; or change the current assistant’s base mode and tool switches, then save.
43
- 4. A new session uses this assistant when `settings.yaml` has no `agent-presets.default` yet. An existing value is left as it is. Flow-style, scalar, and alias `agent-presets` values are preserved without inserting YAML into them.
53
+ ## What each choice means
44
54
 
45
- The prompt is `prompt.md`. The next time a model step is assembled, that file is read again. History can load an older draft into the editor; loading does not write. A save computed against an older copy is refused when the file changed underneath. The model can change the same file only through `custom_prompt`, which asks first.
55
+ 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.
46
56
 
47
- The page is two branches. This assistant edits the one you have open, and all five base modes can be chosen. New assistant writes only when you click New assistant: Coding, Writing, and Chat stay selectable, and only the official Standard mode is lit. The other four official modes are dim and cannot be chosen there.
57
+ Choose the creation starting settings first; a new assistant starts from Standard. After creation, its base mode and individual plugin switches can be adjusted in its editor.
48
58
 
49
- | Posture | After create |
59
+ | Starting settings | Initial behavior |
50
60
  | --- | --- |
51
- | Develop | Official Standard mode, tools as shipped. |
52
- | Write | Official Standard mode. Terminal off, file tools on. |
53
- | Chat | Official Standard mode. Files and terminal off. |
61
+ | Coding | Official Standard mode, tools as shipped. |
62
+ | Writing | Official Standard mode; terminal off, file tools on. |
63
+ | Chat | Official Standard mode; files and terminal off. |
54
64
 
55
- ## Boundaries
65
+ 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.
56
66
 
57
- - It does not replace Standard, PTC, Minimal, or Creator.
58
- - It does not ship a role-play preset.
59
- - It does not run in the desktop profile from the CLI. Headless mode will not start a session that uses an agent preset.
67
+ ## Upgrade, clone development, and removal
60
68
 
61
- ## Compatibility
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.
62
70
 
63
- Declared 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`
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.
64
72
 
65
- On 2026-10-17, npm `latest` names `0.2.0-rc.2`, `alpha` names `0.2.1-alpha.5`, and `next` names `0.2.1-rc.2`. Upstream has not published a final stable version yet.
73
+ `./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.
66
74
 
67
- | dsh | Channel | Checked for 2.2.5 |
68
- | --- | --- | --- |
69
- | `0.2.0-rc.2` | npm `latest` | Exact npm host, packed plugin load, API lifecycle, settings and picker in an isolated Linux Web profile |
70
- | `0.2.1-alpha.5` | npm `alpha` | The same isolated checks, against this host's own preset declarations |
71
- | `0.2.1-rc.2` | npm `next` | The same isolated checks, against this host's own preset declarations |
72
- | `0.1.7-rc.2` | earlier declared line | Kept in the CI regression matrix |
75
+ ## Compatibility and versioning
73
76
 
74
- Release waits for the unit, boot, and browser workflows. To reproduce against an already installed exact host:
77
+ 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`
75
78
 
76
- ```sh
77
- node tools/compat-check.mjs --host-install /path/to/host --out /tmp/custom-mode-check
78
- node tools/runtime-check.mjs --host-install /path/to/host --out /tmp/custom-mode-check --browser /path/to/chrome
79
- ```
79
+ 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.
80
80
 
81
- Both tools use a generated home and ordinary mock prompts. The runtime check exercises all five base modes, repeated saves, uninstall, reinstall, data preservation, and repeated installation. No model calls are needed. Windows/Electron desktop and real model or GPU execution are unverified. See [the exact coverage](docs/CURRENT_COMPATIBILITY.md).
81
+ 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.
82
82
 
83
83
  ## Plugin markets
84
84
 
85
- `dshmarket`, DSH Plugin Hub (`dsh-plugin`), and DSH Plugin Store (`dshmarketplace-plugin`) use different catalogs but install the same profile bundle. The root npm manifest provides `dsh.bundle.patch`, the browser export, the public repository, and matching compatibility ranges; no extra runtime adapter or build script is needed.
86
-
87
- Run `node tools/market-check.mjs --out /tmp/custom-mode-markets.json` to check their public discovery data. An inaccessible catalog or an unverified/stale listing is reported explicitly. The tool never runs catalog-provided shell commands or submits a listing. [Market interfaces and limits](docs/MARKETPLACES.md).
85
+ 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.
88
86
 
89
87
  ## Docs
90
88
 
91
- - [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) — wrong version installed, base mode unavailable, half-finished upgrade
92
- - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — where the prompt is read, and which host calls this package depends on
93
- - [docs/PUBLISHING.md](docs/PUBLISHING.md) — tags and release
94
- - [CONTRIBUTING.md](CONTRIBUTING.md) — people
95
- - [AGENTS.md](AGENTS.md) — agents
89
+ - [Troubleshooting](docs/TROUBLESHOOTING.md) — current recovery first, historical incidents below.
90
+ - [Architecture](docs/ARCHITECTURE.md) — prompt reading and host coupling points.
91
+ - [Publishing](docs/PUBLISHING.md) — existing gated release path and read-back evidence.
92
+ - [Contributing](CONTRIBUTING.md) — isolated development and checks.
93
+ - [AGENTS.md](AGENTS.md) — repository instructions for agents.
96
94
 
97
95
  ## License
98
96
 
package/README.zh.md CHANGED
@@ -19,80 +19,78 @@
19
19
  </tr>
20
20
  </table>
21
21
 
22
- ## 安装
22
+ ## 安装并完成首次请求
23
23
 
24
- 钉版本。只写包名时,pnpm 的发布冷却期可能装到旧版。细节见 [docs/TROUBLESHOOTING.zh.md](docs/TROUBLESHOOTING.zh.md)。
24
+ 本说明适用于版本 `2.2.6`。安装或更新前,先核对[精确宿主结果与验收边界](docs/CURRENT_COMPATIBILITY.zh.md)。
25
+
26
+ 先安装 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),并在宿主自己的界面中配置可用模型。用 `dsh --version` 对照兼容记录中的精确宿主版本。本插件不安装宿主,也不配置模型凭据。
27
+
28
+ ### Web / CLI
29
+
30
+ 钉住插件版本;只写包名可能在 pnpm 发布冷却期解析到旧版:
25
31
 
26
32
  ```sh
27
- dsh plugin --profile web add dsh-custom-mode@2.2.5
33
+ dsh --version
34
+ dsh plugin --profile web add dsh-custom-mode@2.2.6
28
35
  ```
29
36
 
30
- 重启为这个 profile 提供服务的进程。成功判定:设置 → 自定义模式显示 `v2.2.5`,会话模式选择器出现自定义模式,且没有预设损坏告警。
37
+ 用原有启动方式重启为该 Web profile 提供服务的进程。本地 CLI 的既有启动形式是 `dsh web --port 3081 --no-open`;选择空闲端口,打开该进程打印的地址。只重启这个实例。安装 bundle 不会刷新已经运行的宿主。
38
+
39
+ ### 桌面端与 Windows
31
40
 
32
- 桌面端从应用里的插件页安装。CLI 不能改 `profiles/desktop`。本版未实测 Windows/Electron。
41
+ 在桌面应用的 **Plugins(插件)** 页安装 `dsh-custom-mode@2.2.6`,然后重启应用。本项目脚本明确拒绝 `--profile desktop`,不要手改它的 profile 文件。Windows/Electron 与 Web 分别验收,详见兼容记录。
33
42
 
34
- 在 Windows 上,`install.sh` 是 bash,而且 PATH 里要有 `dsh`。否则用 DeepSeek Harness 自带的 `dsh`,或 `node node_modules/@deepseek-ai/dsh/lib/bin.js`。
43
+ 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
44
 
36
- GitHub Release 另附插件包 `dsh-custom-mode-2.2.5.tgz`、仅供预设导入的归档与 `SHA256SUMS`。核对校验和后,可用 `dsh plugin --profile web add ./dsh-custom-mode-2.2.5.tgz` 安装插件包。仅供预设导入的归档不包含设置插件。
45
+ ### 验证完整路径
37
46
 
38
- ## 使用
47
+ 1. 打开设置 → 自定义模式,检查显示版本。页面能打开只是第一步。
48
+ 2. 滚动到**页面底部的「新建助手」区**。已有内置 `custom` 助手时,该区位于编辑区之后。选择写作等起步设置,输入容易辨认的名称,再点「新增助手」;新建的助手会成为编辑对象。
49
+ 3. 临时把提示词设为 `下一次就绪检查只回复 CUSTOM_MODE_READY。`,保存并等页面确认已应用。若提示文件已保存但应用失败,先点「重试生效」。
50
+ 4. 新建一个**会话**,打开该会话的模式/助手选择器,明确选择刚才命名的助手。通过 DSH 中已经配置的模型发送 `就绪检查。`
51
+ 5. 成功意味着助手可以选择、没有预设损坏告警,并返回预期就绪回复且不出现提示词组装错误。检查后恢复自己的提示词。失败时先看[当前自救步骤](docs/TROUBLESHOOTING.zh.md#当前自救步骤)。
39
52
 
40
- 1. 打开设置 → 自定义模式。
41
- 2. 改系统提示词。保存按钮在编辑器下面。
42
- 3. 典型用法:选写作姿态新增助手,再改提示词;或调整当前助手的基础模式和工具开关后保存。
43
- 4. `settings.yaml` 里还没有 `agent-presets.default` 时,新会话用这个助手。已经写过的值保持原样。`agent-presets` 使用行内对象、标量或别名时也原样保留,不向里面插入 YAML。
53
+ ## 各项选择分别做什么
44
54
 
45
- 提示词是 `prompt.md`。下一步组装模型输入时会重读这个文件。历史可以把旧稿载入编辑器;载入不落盘。打开页面之后文件被改过时,这一次保存会被拒绝。模型要改同一份文件,只能走 `custom_prompt`,并且先问过你。
55
+ 设置页选中的助手是**正在编辑的对象**。选中或新建它不会改变新会话使用的助手。首次激活时,插件可能把缺失的默认值补为内置 `custom`;已经存在的默认值保持不变。新建写作助手后,仍需在会话选择器中选它。
46
56
 
47
- 页面分成两支。当前助手改的是已经打开的这一位,基础模式五档都可以点。新建助手只在点「新增助手」时写入:开发、写作、聊天可以点,工具底只有官方标准模式是亮的。其余四档是暗的,在这一支里点不了。
57
+ 先选择新建时的起步设置;新助手以标准模式创建。创建后,可以在它的编辑区调整基础模式与各项插件开关。
48
58
 
49
- | 姿态 | 新建之后 |
59
+ | 起步设置 | 初始行为 |
50
60
  | --- | --- |
51
- | 开发 | 官方标准模式,工具按出厂。 |
52
- | 写作 | 官方标准模式。终端关,文件工具开。 |
53
- | 聊天 | 官方标准模式。文件和终端关。 |
61
+ | 开发 | 官方标准模式,工具按出厂设置。 |
62
+ | 写作 | 官方标准模式;终端关闭,文件工具开启。 |
63
+ | 聊天 | 官方标准模式;文件与终端关闭。 |
54
64
 
55
- ## 边界
65
+ 提示词保存在 `prompt.md`,下一步组装模型输入时会重读。历史与文件导入先载入草稿,保存才落盘。底层文件已变化时,过期保存会被拒绝。`custom_prompt` 写同一个文件前需要批准。关闭身份/persona 行也会关闭该提示词的注入。
56
66
 
57
- - 不替换标准、PTC、极简、创造。
58
- - 不附带角色扮演预设。
59
- - 不用 CLI 改桌面端 profile。headless 不会启动使用 agent preset 的会话。
67
+ ## 升级、clone 开发与卸载
60
68
 
61
- ## 兼容
69
+ 上面的固定版本 npm 命令是普通安装入口。发布完成后,Release 还会提供 `dsh-custom-mode-2.2.6.tgz`、仅供预设导入的归档和 `SHA256SUMS`。安装下载的归档前先核对散列;仅供预设导入的归档没有设置页。
62
70
 
63
- 声明范围:`>=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`
71
+ clone 后可用 `./install.sh --profile web` 保留源码链接的开发方式,需要 Node.js、npm、Bash 和宿主 CLI。它在改动 profile 前,通过离线 `npm pack --ignore-scripts` 准备新包,并归档**实际已安装的旧包**。已有提示词、元数据与组成保持原样,安装自检通过后才刷新受管代码。替换失败时,用本地旧归档重新安装并恢复已刷新的代码;本次新建文件在回滚时留有恢复副本,路径会打印。回滚未验证则报错,并在打印的事务目录保留旧包和日志。旧安装损坏、无法归档时,脚本在替换前中止。这个脚本不能代替桌面应用的插件管理器。
64
72
 
65
- 2026-10-17 核对 npm:`latest` 是 `0.2.0-rc.2`,`alpha` 是 `0.2.1-alpha.5`,`next` 是 `0.2.1-rc.2`。上游尚未发布去掉预发布后缀的正式稳定版。
73
+ `./uninstall.sh` 移除插件但保留助手数据。`--purge` 明确删除受管助手目录及提示词,使用前请自行备份。
66
74
 
67
- | dsh | 通道 | 2.2.5 核对 |
68
- | --- | --- | --- |
69
- | `0.2.0-rc.2` | npm `latest` | 精确 npm 宿主、打包加载、API 生命周期、隔离 Linux Web profile 设置页与选择器 |
70
- | `0.2.1-alpha.5` | npm `alpha` | 同一套隔离检查,读取该宿主自己的预设声明 |
71
- | `0.2.1-rc.2` | npm `next` | 同一套隔离检查,读取该宿主自己的预设声明 |
72
- | `0.1.7-rc.2` | 先前声明支持的版本 | 保留在 CI 回归矩阵里 |
75
+ ## 兼容与版本
73
76
 
74
- 发布必须等待单元、启动和浏览器工作流通过。已有精确版本的宿主安装时,可以这样复现:
77
+ 声明的宿主范围:`>=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`
75
78
 
76
- ```sh
77
- node tools/compat-check.mjs --host-install /path/to/host --out /tmp/custom-mode-check
78
- node tools/runtime-check.mjs --host-install /path/to/host --out /tmp/custom-mode-check --browser /path/to/chrome
79
- ```
79
+ 插件版本走独立的稳定 `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)。
80
80
 
81
- 两项工具只使用新生成的 home 和普通 mock 提示词。运行检查覆盖全部五档基础模式、重复保存、卸载、重装、用户数据保留与重复安装,不需要调用模型。Windows/Electron 桌面端和真实模型或 GPU 执行尚未验证。详见[准确覆盖范围](docs/CURRENT_COMPATIBILITY.zh.md)。
81
+ 发布必须通过兼容性与浏览器工作流。单元、真实提示词组件、打包安装和浏览器检查分别覆盖不同边界。Windows/Electron、真实模型回复、GPU 执行都必须单独留证,不能从页面截图或单元计数推断。headless 不启动使用 agent preset 的会话。
82
82
 
83
83
  ## 插件市场
84
84
 
85
- `dshmarket`、DSH Plugin Hub(`dsh-plugin`)和 DSH Plugin Store(`dshmarketplace-plugin`)使用不同目录,但安装的是同一个 profile bundle。根 npm 清单提供 `dsh.bundle.patch`、浏览器导出、公开仓库和一致的兼容范围,不需要额外的运行时适配器或构建脚本。
86
-
87
- 运行 `node tools/market-check.mjs --out /tmp/custom-mode-markets.json` 检查它们的公开发现数据。目录不可访问、条目未审核或数据过期都会明确报告。工具不执行目录提供的 shell 命令,也不提交收录。[市场接口与限制](docs/MARKETPLACES.zh.md)。
85
+ 各目录的发现与审核规则不同。2026-10-09 的上架结果属于历史记录,不能据此保证今天每个市场都能搜索到。上面的固定版本安装仍是直接入口。[市场接口与限制](docs/MARKETPLACES.zh.md) 说明只读适配器;它不执行目录提供的 shell 命令,也不提交收录。
88
86
 
89
87
  ## 文档
90
88
 
91
- - [docs/TROUBLESHOOTING.zh.md](docs/TROUBLESHOOTING.zh.md) — 装到旧版、基础模式不可用、升级停在半截
92
- - [docs/ARCHITECTURE.zh.md](docs/ARCHITECTURE.zh.md) — 提示词在哪被读,依赖宿主的哪些调用
93
- - [docs/PUBLISHING.zh.md](docs/PUBLISHING.zh.md) — tag 与发布
94
- - [CONTRIBUTING.zh.md](CONTRIBUTING.zh.md) — 给人看
95
- - [AGENTS.md](AGENTS.md) — 给 agent 看
89
+ - [故障排查](docs/TROUBLESHOOTING.zh.md) — 当前自救在前,历史故障在后。
90
+ - [架构](docs/ARCHITECTURE.zh.md) — 提示词读取与宿主耦合点。
91
+ - [发布](docs/PUBLISHING.zh.md) — 既有发布门禁与读回证据。
92
+ - [贡献指南](CONTRIBUTING.zh.md) — 隔离开发与检查。
93
+ - [AGENTS.md](AGENTS.md) — 仓库代理约定。
96
94
 
97
95
  ## 许可证
98
96
 
package/assistants.mjs CHANGED
@@ -32,7 +32,7 @@ import { dshHome, PRESET_DIR } from './paths.mjs'
32
32
  // ReferenceError 被 try/catch 吞掉,回滚循环 continue 掉每一条 —— 用户看到的"已回滚"从未发生,
33
33
  // 磁盘上留下改了一半的 order。少一个导入就能让一整段安全网变成死代码(外部评审复现)。
34
34
  import { readPresetMeta, writePresetMeta, presetMetaPath } from './meta.mjs'
35
- import { writeAtomic } from './atomic.mjs'
35
+ import { restoreBackups, writeAtomic } from './atomic.mjs'
36
36
  import { unresolvableRows } from './composition.mjs'
37
37
  import { seedPreset, seedPresetWithLog, dropShippedDescription } from './seed.mjs'
38
38
 
@@ -100,8 +100,13 @@ export function markSeeded(root) {
100
100
  /** Whether `dir` is a directory this feature owns (see the module comment). */
101
101
  export function isManagedDir(dir) {
102
102
  if (typeof dir !== 'string' || dir === '') return false
103
+ // A crashed save can move prompt.md away. Recover before requiring that very file as proof.
104
+ // Keep a known reader-owned directory visible on an unresolved recovery; readState explains it and
105
+ // seedPreset refuses to fill missing user data with template content.
106
+ const readerPresent = existsSync(join(dir, 'prompt-reader.mjs'))
107
+ if (readerPresent && restoreBackups(dir).ok !== true) return true
103
108
  if (!existsSync(join(dir, 'prompt.md'))) return false
104
- if (existsSync(join(dir, 'prompt-reader.mjs'))) return true
109
+ if (readerPresent) return true
105
110
  // The reader is gone but the composition still injects it: still ours, and the
106
111
  // repair pass below can put the module back.
107
112
  try {
@@ -333,6 +338,7 @@ export function seedOnActivation({ root, templateDir, composition, log = console
333
338
  // 之后描述仍是旧的)。放在这个循环里,每次激活都会检查,且 `dropShippedDescription` 只认**逐字**
334
339
  // 等于我们自己写过的那些串,用户自己写的描述一个字都不会动。
335
340
  for (const dir of existing) {
341
+ if (restoreBackups(dir).ok !== true) continue
336
342
  try {
337
343
  if (dropShippedDescription(dir).migrated === true) {
338
344
  info(`custom-mode: 已清空 ${dir} 的描述 —— 它还是我们早年播种的那条中英拼接,壳无法按界面语言本地化产品数据`)
package/atomic.mjs CHANGED
@@ -14,8 +14,8 @@
14
14
  * random temporary name does not help with *that* collision, only with temp-vs-temp ones.
15
15
  */
16
16
  import { randomBytes } from 'node:crypto'
17
- import { existsSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
18
- import { join } from 'node:path'
17
+ import { existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
18
+ import { basename, dirname, join, resolve } from 'node:path'
19
19
 
20
20
  /** Synchronous backoff: these write paths are synchronous, so waiting is simpler than async plumbing. */
21
21
  function sleepSync(ms) {
@@ -63,7 +63,12 @@ export function renameWithRetry(from, to, options = {}) {
63
63
  */
64
64
  export function stageAtomic(file, text) {
65
65
  const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
66
- writeFileSync(temporary, text, 'utf8')
66
+ try {
67
+ writeFileSync(temporary, text, 'utf8')
68
+ } catch (error) {
69
+ try { rmSync(temporary, { force: true }) } catch { /* retain the original failure */ }
70
+ throw error
71
+ }
67
72
  return { file, temporary }
68
73
  }
69
74
 
@@ -108,6 +113,14 @@ export function discardStaged(staged) {
108
113
  * @returns {void}
109
114
  */
110
115
  export function writeAtomicPair(entries) {
116
+ if (entries.length === 0) return
117
+ const directory = dirname(resolve(entries[0][0]))
118
+ const names = entries.map(([file]) => basename(file))
119
+ if (new Set(names).size !== names.length || entries.some(([file]) => dirname(resolve(file)) !== directory || !TRANSACTION_FILES.includes(basename(file)))) {
120
+ throw new Error('Atomic preset transactions require distinct preset files in one directory')
121
+ }
122
+ const recovery = restoreBackups(directory)
123
+ if (recovery.ok !== true) throw new Error(recovery.detail)
111
124
  const stamp = `${String(process.pid)}-${randomBytes(4).toString('hex')}`
112
125
 
113
126
  // ── phase 0: refuse to touch a destination that is a directory. ──
@@ -133,54 +146,41 @@ export function writeAtomicPair(entries) {
133
146
  throw error
134
147
  }
135
148
 
136
- // ── phase 2: move the current targets aside. `null` = the target did not exist. ──
137
- const backups = []
138
- const installed = []
149
+ // A prepared journal is on disk BEFORE any original moves. Recovery rolls the WHOLE transaction
150
+ // back, including files already installed; a committed journal keeps the whole new generation.
151
+ const journal = join(directory, `${TRANSACTION_PREFIX}${stamp}.json`)
152
+ const transaction = {
153
+ version: 1,
154
+ stamp,
155
+ ownerPid: process.pid,
156
+ phase: 'prepared',
157
+ entries: staged.map((one) => ({
158
+ name: basename(one.file),
159
+ temporary: basename(one.temporary),
160
+ backup: `${basename(one.file)}.bak-${stamp}`,
161
+ existed: existsSync(one.file),
162
+ })),
163
+ }
164
+ ACTIVE_TRANSACTIONS.add(directory)
139
165
  try {
140
- for (const one of staged) {
141
- if (existsSync(one.file)) {
142
- const backup = `${one.file}.bak-${stamp}`
143
- renameWithRetry(one.file, backup)
144
- backups.push([one.file, backup])
145
- } else {
146
- backups.push([one.file, null])
147
- }
148
- }
149
- // ── phase 3: install. ──
150
- for (const one of staged) {
151
- commitStaged(one)
152
- installed.push(one)
166
+ writeAtomic(journal, JSON.stringify(transaction) + '\n')
167
+ for (const one of transaction.entries) {
168
+ if (one.existed) renameWithRetry(join(directory, one.name), join(directory, one.backup))
153
169
  }
170
+ for (const one of staged) commitStaged(one)
171
+ writeAtomic(journal, JSON.stringify({ ...transaction, phase: 'committed' }) + '\n')
154
172
  } catch (error) {
155
- // Undo in reverse: remove what we installed, then put the backups back.
156
- for (const one of installed) {
157
- try {
158
- rmSync(one.file, { force: true })
159
- } catch {
160
- /* best effort — the restore below is what matters */
161
- }
162
- }
163
- for (const [file, backup] of backups) {
164
- if (backup === null) continue
165
- try {
166
- renameWithRetry(backup, file)
167
- } catch {
168
- /* best effort; the thrown error still tells the caller nothing is consistent */
169
- }
170
- }
173
+ // If writing the journal itself failed no target moved. Otherwise leave recovery evidence until
174
+ // rollback has completed; a failed restore must not be hidden as "nothing changed".
175
+ const outcome = existsSync(journal) ? recoverTransaction(directory, journal, transaction) : { ok: true }
171
176
  for (const one of staged) discardStaged(one)
177
+ ACTIVE_TRANSACTIONS.delete(directory)
178
+ if (outcome.ok !== true) throw new Error(String(error.message ?? error) + '; recovery required: ' + outcome.detail, { cause: error })
172
179
  throw error
173
180
  }
174
-
175
- // ── success: drop the backups ──
176
- for (const [, backup] of backups) {
177
- if (backup === null) continue
178
- try {
179
- rmSync(backup, { force: true })
180
- } catch {
181
- /* a leftover .bak-… is inert; failing the whole write over it would be worse */
182
- }
183
- }
181
+ // Cleanup failure is recoverable from the committed journal and cannot undo a successful write.
182
+ recoverTransaction(directory, journal, { ...transaction, phase: 'committed' })
183
+ ACTIVE_TRANSACTIONS.delete(directory)
184
184
  }
185
185
 
186
186
  /**
@@ -204,24 +204,124 @@ export function writeAtomicPair(entries) {
204
204
  * @param {string} directory - one assistant directory.
205
205
  * @returns {void}
206
206
  */
207
+ const TRANSACTION_FILES = ['prompt.md', 'agent.cordis.yml', 'preset.yml']
208
+ const TRANSACTION_PREFIX = '.custom-mode-transaction-'
209
+ const ACTIVE_TRANSACTIONS = new Set()
210
+
211
+ function pidFromStamp(stamp) {
212
+ const pid = Number(stamp.split('-')[0])
213
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : null
214
+ }
215
+
216
+ /** A different live or uninspectable process might still be writing. Only ESRCH proves death. */
217
+ function ownerMayBeActive(pid) {
218
+ // This process runs transactions synchronously and ACTIVE_TRANSACTIONS covers its live writes.
219
+ if (pid === process.pid) return false
220
+ try {
221
+ process.kill(pid, 0)
222
+ return true
223
+ } catch (error) {
224
+ return error.code !== 'ESRCH'
225
+ }
226
+ }
227
+
228
+ function activeOwnerResult(pid) {
229
+ return { ok: false, code: 'recoveryRequired', detail: `Preset transaction owner ${pid} is active or cannot be inspected; retry after it finishes` }
230
+ }
231
+
232
+ function validTransaction(transaction, journalName) {
233
+ if (transaction?.version !== 1 || !/^\d+-[a-f0-9]{8}$/.test(transaction.stamp ?? '')) return false
234
+ if (journalName !== `${TRANSACTION_PREFIX}${transaction.stamp}.json`) return false
235
+ const stampedPid = pidFromStamp(transaction.stamp)
236
+ const ownerPid = transaction.ownerPid ?? stampedPid
237
+ if (stampedPid === null || !Number.isSafeInteger(ownerPid) || ownerPid <= 0) return false
238
+ if (transaction.legacy !== true && ownerPid !== stampedPid) return false
239
+ if (!['prepared', 'committed'].includes(transaction.phase) || !Array.isArray(transaction.entries) || transaction.entries.length === 0) return false
240
+ const names = new Set()
241
+ for (const one of transaction.entries) {
242
+ if (one === null || typeof one !== 'object') return false
243
+ if (!TRANSACTION_FILES.includes(one.name) || names.has(one.name) || typeof one.existed !== 'boolean') return false
244
+ if (one.backup !== `${one.name}.bak-${transaction.stamp}`) return false
245
+ if (transaction.legacy === true ? one.temporary !== null : !new RegExp(`^${one.name.replaceAll('.', '\\.')}\\.tmp-\\d+-[a-f0-9]{8}$`).test(one.temporary ?? '')) return false
246
+ names.add(one.name)
247
+ }
248
+ return true
249
+ }
250
+
251
+ function recoverTransaction(directory, journal, transaction) {
252
+ try {
253
+ if (transaction.phase === 'prepared') {
254
+ for (const one of transaction.entries) {
255
+ const file = join(directory, one.name)
256
+ const backup = join(directory, one.backup)
257
+ if (one.existed) {
258
+ if (existsSync(backup)) renameWithRetry(backup, file)
259
+ else if (!existsSync(file)) throw new Error(`Missing original and backup for ${one.name}`)
260
+ } else {
261
+ rmSync(file, { force: true })
262
+ }
263
+ }
264
+ }
265
+ for (const one of transaction.entries) {
266
+ rmSync(join(directory, one.backup), { force: true })
267
+ if (one.temporary !== null) rmSync(join(directory, one.temporary), { force: true })
268
+ }
269
+ rmSync(journal, { force: true })
270
+ return { ok: true }
271
+ } catch (error) {
272
+ return { ok: false, code: 'recoveryRequired', detail: String(error.message ?? error) }
273
+ }
274
+ }
275
+
276
+ /** Recover before ownership discovery: prompt.md itself can be temporarily absent after a crash. */
207
277
  export function restoreBackups(directory) {
208
- const names = ['prompt.md', 'agent.cordis.yml', 'preset.yml']
278
+ if (ACTIVE_TRANSACTIONS.has(resolve(directory))) return { ok: true, active: true }
209
279
  let entries
210
280
  try {
211
281
  entries = readdirSync(directory)
212
- } catch {
213
- return
282
+ } catch (error) {
283
+ return error.code === 'ENOENT' ? { ok: true } : { ok: false, code: 'recoveryRequired', detail: String(error.message ?? error) }
214
284
  }
215
- for (const name of names) {
216
- const file = join(directory, name)
217
- if (existsSync(file)) continue
218
- const backups = entries.filter((entry) => entry.startsWith(name + '.bak-'))
219
- if (backups.length !== 1) continue
285
+ const journals = entries.filter((entry) => entry.startsWith(TRANSACTION_PREFIX) && entry.endsWith('.json'))
286
+ // Shared homes across live processes are not a supported transaction boundary. Never guess which of
287
+ // multiple interrupted generations owns a target, and never seed a replacement over their evidence.
288
+ if (journals.length > 1) return { ok: false, code: 'recoveryRequired', detail: 'Multiple preset transactions require manual recovery' }
289
+ for (const name of journals) {
290
+ let transaction
220
291
  try {
221
- renameSync(join(directory, backups[0]), file)
222
- } catch {
223
- /* 恢复失败就留着备份,下一次读取再试 */
292
+ transaction = JSON.parse(readFileSync(join(directory, name), 'utf8'))
293
+ } catch (error) {
294
+ return { ok: false, code: 'recoveryRequired', detail: 'Unreadable preset transaction: ' + String(error.message ?? error) }
224
295
  }
296
+ if (!validTransaction(transaction, name)) return { ok: false, code: 'recoveryRequired', detail: 'Invalid preset transaction journal' }
297
+ const ownerPid = transaction.ownerPid ?? pidFromStamp(transaction.stamp)
298
+ if (ownerMayBeActive(ownerPid)) return activeOwnerResult(ownerPid)
299
+ const outcome = recoverTransaction(directory, join(directory, name), transaction)
300
+ if (outcome.ok !== true) return outcome
301
+ }
302
+ // Upgrade recovery for v2.2.5 transactions that predate the journal. Restore one whole backup group
303
+ // only when something is missing. Complete targets plus leftover backups denote a completed write.
304
+ try { entries = readdirSync(directory) } catch (error) {
305
+ return { ok: false, code: 'recoveryRequired', detail: String(error.message ?? error) }
306
+ }
307
+ const legacy = entries.map((entry) => ({ entry, match: /^(prompt\.md|agent\.cordis\.yml|preset\.yml)\.bak-(\d+-[a-f0-9]{8})$/.exec(entry) })).filter((one) => one.match !== null)
308
+ if (!legacy.some((one) => !existsSync(join(directory, one.match[1])))) return { ok: true }
309
+ if (new Set(legacy.map((one) => one.match[2])).size !== 1) return { ok: false, code: 'recoveryRequired', detail: 'Ambiguous legacy preset backups require manual recovery' }
310
+ const legacyOwner = pidFromStamp(legacy[0].match[2])
311
+ if (legacyOwner === null) return { ok: false, code: 'recoveryRequired', detail: 'Invalid legacy preset transaction owner' }
312
+ if (ownerMayBeActive(legacyOwner)) return activeOwnerResult(legacyOwner)
313
+ try {
314
+ // Recovery itself can be interrupted. Journal the whole legacy group BEFORE restoring even the
315
+ // first missing target; otherwise that first rename can hide the need to restore the rest.
316
+ const stamp = legacy[0].match[2]
317
+ const journal = join(directory, `${TRANSACTION_PREFIX}${stamp}.json`)
318
+ const transaction = { version: 1, stamp, ownerPid: process.pid, phase: 'prepared', legacy: true, entries: legacy.map((one) => ({
319
+ name: one.match[1], backup: one.entry, temporary: null, existed: true,
320
+ })) }
321
+ writeAtomic(journal, JSON.stringify(transaction) + '\n')
322
+ return recoverTransaction(directory, journal, transaction)
323
+ } catch (error) {
324
+ return { ok: false, code: 'recoveryRequired', detail: String(error.message ?? error) }
225
325
  }
226
326
  }
227
327