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 +2 -2
- package/README.md +43 -45
- package/README.zh.md +43 -45
- package/assistants.mjs +8 -2
- package/atomic.mjs +156 -56
- package/client.js +269 -211
- package/composition.mjs +5 -4
- package/index.mjs +49 -42
- package/locales.mjs +58 -20
- package/package.json +3 -3
- package/preset/prompt-reader.mjs +35 -2
- package/preset/prompt-tool.mjs +1 -0
- package/preset/prompt.md +0 -2
- package/preset-backend/declarative.mjs +13 -11
- package/seed.mjs +5 -1
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:
|
|
5
|
-
README.zh.md:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
45
|
+
### Verify the whole path
|
|
37
46
|
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
|
59
|
+
| Starting settings | Initial behavior |
|
|
50
60
|
| --- | --- |
|
|
51
|
-
|
|
|
52
|
-
|
|
|
53
|
-
| Chat | Official Standard mode
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- [
|
|
92
|
-
- [
|
|
93
|
-
- [
|
|
94
|
-
- [
|
|
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
|
-
|
|
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
|
|
33
|
+
dsh --version
|
|
34
|
+
dsh plugin --profile web add dsh-custom-mode@2.2.6
|
|
28
35
|
```
|
|
29
36
|
|
|
30
|
-
|
|
37
|
+
用原有启动方式重启为该 Web profile 提供服务的进程。本地 CLI 的既有启动形式是 `dsh web --port 3081 --no-open`;选择空闲端口,打开该进程打印的地址。只重启这个实例。安装 bundle 不会刷新已经运行的宿主。
|
|
38
|
+
|
|
39
|
+
### 桌面端与 Windows
|
|
31
40
|
|
|
32
|
-
|
|
41
|
+
在桌面应用的 **Plugins(插件)** 页安装 `dsh-custom-mode@2.2.6`,然后重启应用。本项目脚本明确拒绝 `--profile desktop`,不要手改它的 profile 文件。Windows/Electron 与 Web 分别验收,详见兼容记录。
|
|
33
42
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
2. 改系统提示词。保存按钮在编辑器下面。
|
|
42
|
-
3. 典型用法:选写作姿态新增助手,再改提示词;或调整当前助手的基础模式和工具开关后保存。
|
|
43
|
-
4. `settings.yaml` 里还没有 `agent-presets.default` 时,新会话用这个助手。已经写过的值保持原样。`agent-presets` 使用行内对象、标量或别名时也原样保留,不向里面插入 YAML。
|
|
53
|
+
## 各项选择分别做什么
|
|
44
54
|
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
+
clone 后可用 `./install.sh --profile web` 保留源码链接的开发方式,需要 Node.js、npm、Bash 和宿主 CLI。它在改动 profile 前,通过离线 `npm pack --ignore-scripts` 准备新包,并归档**实际已安装的旧包**。已有提示词、元数据与组成保持原样,安装自检通过后才刷新受管代码。替换失败时,用本地旧归档重新安装并恢复已刷新的代码;本次新建文件在回滚时留有恢复副本,路径会打印。回滚未验证则报错,并在打印的事务目录保留旧包和日志。旧安装损坏、无法归档时,脚本在替换前中止。这个脚本不能代替桌面应用的插件管理器。
|
|
64
72
|
|
|
65
|
-
|
|
73
|
+
`./uninstall.sh` 移除插件但保留助手数据。`--purge` 明确删除受管助手目录及提示词,使用前请自行备份。
|
|
66
74
|
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
+
发布必须通过兼容性与浏览器工作流。单元、真实提示词组件、打包安装和浏览器检查分别覆盖不同边界。Windows/Electron、真实模型回复、GPU 执行都必须单独留证,不能从页面截图或单元计数推断。headless 不启动使用 agent preset 的会话。
|
|
82
82
|
|
|
83
83
|
## 插件市场
|
|
84
84
|
|
|
85
|
-
|
|
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
|
-
- [
|
|
92
|
-
- [
|
|
93
|
-
- [
|
|
94
|
-
- [
|
|
95
|
-
- [AGENTS.md](AGENTS.md) —
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
137
|
-
|
|
138
|
-
const
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|