dsh-custom-mode 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -1,5 +1,5 @@
1
1
  # 双语配对一致性记录:两侧在「上次确认一致」时的 git blob 哈希。
2
2
  # 两份文档权威相同——改完任意一侧,请把另一侧也改掉,然后重新记录:
3
3
  # node tools/verify-translation-pairing.mjs --write
4
- README.md: 43c5793fdcb088e78c63fcdb7732eb308f896ff4
5
- README.zh.md: 25677b1fd67dbaccae80fa9acb7a8a9b87477978
4
+ README.md: d70fd341a9be167188f2e4d6117f93517d6221de
5
+ README.zh.md: aa63c0c8050a3832e9eab670fe77e0ba4dcabbb7
package/README.md CHANGED
@@ -56,7 +56,7 @@ One command installs everything — the settings-page plugin, and the preset it
56
56
  activation:
57
57
 
58
58
  ```sh
59
- dsh plugin --profile web add dsh-custom-mode@2.0.0 # pin the version to get this one for sure
59
+ dsh plugin --profile web add dsh-custom-mode@2.1.0 # pin the version to get this one for sure
60
60
  # A bare `add dsh-custom-mode` is subject to pnpm's release cooldown (`minimumReleaseAge`, 1 day by
61
61
  # default): for hours after a release it can silently install an OLDER version — measured: a bare
62
62
  # install 38 minutes after 1.3.0 shipped landed on 1.0.3. Check what you got with `npm ls
@@ -396,17 +396,22 @@ installing again — your data is not touched:
396
396
 
397
397
  ```sh
398
398
  # the pinned form: what you ask for is what you get
399
- dsh plugin --profile web add dsh-custom-mode@2.0.0
399
+ dsh plugin --profile web add dsh-custom-mode@2.1.0
400
400
  # then restart the DSH process that serves the web profile
401
401
  ```
402
402
 
403
- **Do not install by bare name if you want this release.** pnpm applies a release cooldown (`minimumReleaseAge`,
404
- 24 hours by default) and a bare `dsh plugin add dsh-custom-mode` resolves to *the newest version older than 24
405
- hours*. Measured on a clean machine while 1.9.0 was latest: the bare command installed **1.0.1**. Pinning the
406
- version bypasses the cooldown, which is why the command above carries `@`.
403
+ **Do not install by bare name, and do not use `@latest`.** pnpm applies a release cooldown
404
+ (`minimumReleaseAge`, 24 hours by default). A bare `dsh plugin add dsh-custom-mode` resolves to *the newest
405
+ version older than 24 hours*. Measured: while npm `latest` was 1.12.2 and six minutes old, `@latest` installed
406
+ 1.11.6. Pin the exact version.
407
407
 
408
- **How to know what you are running**: the settings page shows the installed version at the bottom (Plugin version).
409
- Compare it with `npm view dsh-custom-mode version`.
408
+ Each exact pin appends a `minimumReleaseAgeExclude` line in the profile's `pnpm-workspace.yaml`. pnpm 11.7.0
409
+ honours only the first line that matches the package name ([pnpm#12463](https://github.com/pnpm/pnpm/issues/12463)).
410
+ Keep one line, `dsh-custom-mode`, not a list of versions. This plugin does not edit that file. If install fails
411
+ with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`, merge the list to that one line and run the install again:
412
+ `node_modules` may already show the new version while `package.json` still names the old one.
413
+
414
+ **How to know the upgrade worked**: restart dsh, then read the version at the bottom of this settings page.
410
415
 
411
416
  **What an update does not touch**: `$DSH_HOME/.agent-presets/<your assistants>/` — `prompt.md`, `preset.yml` and
412
417
  your row switches are yours. Seeding only fills in *missing* files, so a prompt you wrote is never overwritten.
package/README.zh.md CHANGED
@@ -49,7 +49,7 @@ base mode / plugin switches / multi-assistant / multi-persona。
49
49
  一条命令装完——设置页插件,以及它在首次激活时自动播种的 preset:
50
50
 
51
51
  ```sh
52
- dsh plugin --profile web add dsh-custom-mode@2.0.0 # 钉版本才能确定拿到这一版
52
+ dsh plugin --profile web add dsh-custom-mode@2.1.0 # 钉版本才能确定拿到这一版
53
53
  # 不带版本号会受 pnpm 的发布冷却期影响(`minimumReleaseAge`,默认一天):发布后数小时内按名安装
54
54
  # 可能**静默装到旧版** —— 实测 1.3.0 发布 38 分钟后按名安装装到了 1.0.3。用 profile 里的
55
55
  # `npm ls dsh-custom-mode` 核对实际装到的版本,或像上面那样钉版本。
@@ -68,7 +68,7 @@ dsh `0.1.6-alpha.2` 起有插件管理页:**侧边栏 → 插件 → 添加插
68
68
 
69
69
  | 输入 | 填什么 | 说明 |
70
70
  | --- | --- | --- |
71
- | **包名** | `dsh-custom-mode` | 受 pnpm 发布冷却期影响,可能装到旧版。要这一版请钉 `@2.0.0` |
71
+ | **包名** | `dsh-custom-mode` | 受 pnpm 发布冷却期影响,可能装到旧版。要这一版请钉 `@2.1.0` |
72
72
  | **GitHub 仓库地址** | `https://github.com/BOWLUNA/dsh-custom-mode` | 指向**仓库根**即可 |
73
73
  | **本地插件目录** | `<你 clone 的路径>` | 就是仓库根 —— 仓库根**本身**就是发布包 |
74
74
 
@@ -300,15 +300,16 @@ MIT
300
300
 
301
301
  ```sh
302
302
  # 钉版本的写法:要哪版就是哪版
303
- dsh plugin --profile web add dsh-custom-mode@2.0.0
303
+ dsh plugin --profile web add dsh-custom-mode@2.1.0
304
304
  # 然后重启为该 profile 提供服务的 DSH 进程
305
305
  ```
306
306
 
307
- **想要这一版就别用不带版本号的安装。** pnpm 有发布冷却期(`minimumReleaseAge`,默认 24 小时),
308
- `dsh plugin add dsh-custom-mode` 会解析成**"超过 24 小时的最新版"**。实测:在 1.9.0 已是 latest 的干净机器上,
309
- 不带版本号的命令装到的是 **1.0.1**。钉版本可以绕过冷却期,所以上面的命令带 `@`。
307
+ **不要按裸名安装,也不要用 `@latest`。** pnpm 有发布冷却期(`minimumReleaseAge`,默认 24 小时)。
308
+ `dsh plugin add dsh-custom-mode` 会解析成**超过 24 小时的最新版**。实测:npm 上 `latest` 已是发布 6 分钟的 1.12.2 时,`@latest` 装成了 1.11.6。请钉精确版本。
310
309
 
311
- **怎么知道自己在跑哪一版**:设置页底部显示已安装版本(「插件版本」),与 `npm view dsh-custom-mode version` 对照即可。
310
+ 每次钉精确版本,pnpm 都会在 profile 的 `pnpm-workspace.yaml` 里追加一条 `minimumReleaseAgeExclude`。pnpm 11.7.0 只认第一条命中包名的记录([pnpm#12463](https://github.com/pnpm/pnpm/issues/12463))。这个包只留一条 `dsh-custom-mode`,不要留一串版本号。本插件不改这个文件。若安装报 `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`,把列表收成那一条再装一次:`node_modules` 里可能已经是新版本,而 `package.json` 仍写着旧版本。
311
+
312
+ **怎样算升级成功**:重启 dsh,再看本设置页底部的版本号。
312
313
 
313
314
  **更新不会碰的东西**:`$DSH_HOME/.agent-presets/<你的助手>/` —— `prompt.md`、`preset.yml` 与你逐行拨过的开关都属于你。
314
315
  播种只补**缺失**的文件,你写过的提示词永远不会被覆盖。
package/client.js CHANGED
@@ -127,6 +127,7 @@ try {
127
127
  "meta.version": "插件版本",
128
128
  "meta.versionHint": "未钉版本时,可能装到较旧的发布。要换版本,按 README 钉版本重装,然后重启。",
129
129
  "api.saved": "已保存({name},{mode})。新会话使用新配置,当前会话不变。",
130
+ "api.conflict": "磁盘上的提示词已经变了,这次没有写入。先重新读取,再决定要不要覆盖。",
130
131
  "api.savedWithExclusiveRows": "已保存({name},{mode})。已关闭不能同时启用的行:{rows}。",
131
132
  "api.created": "已创建「{name}」。刷新后,新会话的模式菜单里会出现它。",
132
133
  "api.savedPrompt": "已保存系统提示词({name})。下一步模型调用即生效。",
@@ -208,8 +209,8 @@ try {
208
209
  "status.enabled": "已启用",
209
210
  "status.disabled": "已停用",
210
211
  "status.changed": "已改",
211
- "tag.essential": "基础能力",
212
- "tag.followPlatform": "跟随平台",
212
+ "tag.essential": "核心",
213
+ "tag.followPlatform": "随平台",
213
214
  "btn.save": "保存",
214
215
  "btn.saving": "处理中…",
215
216
  "btn.reload": "重新读取",
@@ -360,6 +361,7 @@ try {
360
361
  "meta.version": "Plugin version",
361
362
  "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.",
362
363
  "api.saved": "Saved ({name}, {mode}). New sessions use it. The current session does not change.",
364
+ "api.conflict": "The prompt on disk has changed, so this save was not written. Reload, then decide whether to overwrite.",
363
365
  "api.savedWithExclusiveRows": "Saved ({name}, {mode}). Turned off rows that cannot be on together: {rows}.",
364
366
  "api.created": "Created {name}. Refresh once, then it appears in the mode menu for a new session.",
365
367
  "api.savedPrompt": "System prompt saved ({name}). It applies on the next model step.",
@@ -442,7 +444,7 @@ try {
442
444
  "status.disabled": "Disabled",
443
445
  "status.changed": "changed",
444
446
  "tag.essential": "Core",
445
- "tag.followPlatform": "follows platform",
447
+ "tag.followPlatform": "Platform",
446
448
  "btn.save": "Save",
447
449
  "btn.saving": "Working…",
448
450
  "btn.reload": "Reload",
@@ -731,7 +733,9 @@ try {
731
733
  * grids, the status line).
732
734
  */
733
735
  const CSS = [
734
- ".cpfe{--g:8px;display:flex;flex-direction:column;gap:24px;width:100%;max-width:900px;box-sizing:border-box;padding-bottom:16px;font:inherit;color:inherit}",
736
+ ".cpfe{--g:8px;display:flex;flex-direction:column;gap:16px;width:100%;max-width:900px;box-sizing:border-box;padding-bottom:16px;font:inherit;color:inherit}",
737
+ ".cpfe > section{padding-top:16px;border-top:1px solid var(--dsw-alias-border-l1)}",
738
+ ".cpfe > section:first-child{padding-top:0;border-top:0}",
735
739
  // 分节标题:度量抄官方设置面板里的同类文本。
736
740
  // 实测(**0.1.7-rc.2 与 0.2.0-rc.2 各量一次,结论相同**):官方 `_title`(设置行标题)
737
741
  // 与 `groupTitle`(插件页分组标题)都是 14px/22px **w400**;官方唯一的 14px w500 是插件
@@ -745,11 +749,13 @@ try {
745
749
  ".cpfe-hint-line{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
746
750
  ".cpfe-hint-detail{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
747
751
  // 描述:多行、自适应高度(没有多行输入组件,所以用 textarea + 同一批语义变量)
748
- ".cpfe-desc{box-sizing:border-box;min-height:56px;max-height:160px;resize:vertical;padding:8px 12px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;margin-bottom:8px}",
752
+ ".cpfe-desc{box-sizing:border-box;min-height:40px;max-height:120px;resize:vertical;padding:8px 12px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;margin-bottom:0}",
749
753
  ".cpfe-base-pending{color:var(--dsw-alias-state-warn-primary)}",
750
754
  ".cpfe-note{display:block;margin:8px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
751
755
  ".cpfe-mono{display:block;margin:0;font-family:var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace);font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}",
752
756
  ".cpfe-pills{display:flex;flex-wrap:wrap;gap:6px;align-items:center}",
757
+ ".cpfe-pills > *{flex:0 0 auto;white-space:nowrap}",
758
+ ".cpfe-pills button{white-space:nowrap}",
753
759
  ".cpfe-picker{display:flex;align-items:center;gap:8px;margin:2px 0 4px}",
754
760
  // 壳的菜单浮层靠一个独立的 backing 元素上色(实测在 0.1.7-rc.2 + headless 下那块是透明的,
755
761
  // 文字会"压"在下面的输入框上)。用官方 token 显式补一层底色,真实浏览器与 headless 一致。
@@ -766,7 +772,7 @@ try {
766
772
  ".cpfe-row-head{font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary)}",
767
773
  ".cpfe-row-switch{flex:0 0 auto}",
768
774
  ".cpfe-row-line{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}",
769
- ".cpfe-row-note{min-height:18px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
775
+ ".cpfe-row-note{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
770
776
  ".cpfe-row-toggle{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}",
771
777
  ".cpfe-row-toggle:hover{background:var(--dsw-alias-bg-layer-2)}",
772
778
  ".cpfe-row-open{border-color:var(--dsw-alias-border-l2)}",
@@ -785,10 +791,10 @@ try {
785
791
  ".cpfe-line:last-child{border-bottom:0}",
786
792
  ".cpfe-group{display:flex;flex-direction:column;min-width:0}",
787
793
  ".cpfe-kids{display:flex;flex-direction:column;margin-left:0;padding-left:16px}",
788
- ".cpfe-row{display:flex;gap:12px;align-items:center;box-sizing:border-box;min-height:0;padding:16px 0;border:0;border-radius:0;background:none}",
789
- ".cpfe-row-meta{display:flex;flex-direction:column;gap:4px;min-width:0;flex:1}",
790
- ".cpfe-row-badges{display:flex;align-items:center;gap:6px;flex-wrap:wrap;height:18px}",
791
- ".cpfe-editor{box-sizing:border-box;width:100%;min-height:180px;resize:vertical;padding:12px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font-family:var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace);font-size:13px;line-height:20px}",
794
+ ".cpfe-row-meta{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}",
795
+ ".cpfe-row-badges{display:flex;align-items:center;gap:4px;flex:0 1 auto;min-width:0}",
796
+ ".cpfe-row{display:flex;gap:12px;align-items:center;box-sizing:border-box;min-height:74px;padding:16px 0;border:0;border-radius:0;background:none}",
797
+ ".cpfe-editor{box-sizing:border-box;width:100%;min-height:120px;resize:vertical;padding:12px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font-family:var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace);font-size:13px;line-height:20px}",
792
798
  ".cpfe-bar{display:flex;align-items:center;gap:var(--g);flex-wrap:wrap;margin-top:8px}",
793
799
  ".cpfe-disclosure{appearance:none;display:inline-flex;align-items:center;gap:6px;margin:8px 0 0;padding:0;border:0;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px;font-weight:400;cursor:pointer}",
794
800
  ".cpfe-filter{box-sizing:border-box;width:100%;height:36px;margin:0 0 8px;padding:0 12px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px}",
@@ -1023,11 +1029,6 @@ try {
1023
1029
  react.createElement(
1024
1030
  "div",
1025
1031
  { className: "cpfe-row-badges" },
1026
- react.createElement(
1027
- A.Tag,
1028
- { tone: effective ? "success" : "neutral" },
1029
- effective ? t("status.enabled") : t("status.disabled"),
1030
- ),
1031
1032
  row.essential ? react.createElement(A.Tag, { tone: "warning" }, t("tag.essential")) : null,
1032
1033
  row.disabledExpression !== null && row.disabledExpression !== undefined
1033
1034
  ? react.createElement(A.Tag, { tone: "outline" }, t("tag.followPlatform"))
@@ -1035,9 +1036,9 @@ try {
1035
1036
  changed ? react.createElement(A.Tag, { tone: "info" }, t("status.changed")) : null,
1036
1037
  ),
1037
1038
  ),
1038
- // 折叠时只留一行说明(超出截断,悬停给全文)。**没有说明就留空**:裸露的行 id
1039
- // 是开发者信息,已经在「详情」里 —— 之前它作为副标题占了每行一行。
1040
- react.createElement("span", { className: "cpfe-row-note", title: note ?? "" }, note ?? ""),
1039
+ note
1040
+ ? react.createElement("span", { className: "cpfe-row-note", title: note }, note)
1041
+ : null,
1041
1042
  ),
1042
1043
  react.createElement(
1043
1044
  "button",
@@ -1662,6 +1663,7 @@ try {
1662
1663
  const save = async () => {
1663
1664
  setBusy(true)
1664
1665
  try {
1666
+ const newest = Array.isArray(draft.history) && draft.history.length > 0 ? draft.history[0].n : undefined
1665
1667
  const result = await postJson(ROUTES.state, {
1666
1668
  id: draft.id,
1667
1669
  mode: draft.mode,
@@ -1669,6 +1671,7 @@ try {
1669
1671
  prompt: draft.prompt,
1670
1672
  name: draft.name,
1671
1673
  description: draft.description,
1674
+ ...(typeof newest === "number" ? { baseN: newest } : {}),
1672
1675
  })
1673
1676
  if (result !== null && result !== undefined && result.ok === true) {
1674
1677
  setFailed(false)
@@ -1924,6 +1927,7 @@ try {
1924
1927
  ),
1925
1928
  )
1926
1929
  : null,
1930
+ createGroup,
1927
1931
  typeof (assistants.find((item) => item.id === selected) || {}).broken === "string"
1928
1932
  ? react.createElement("p", { className: "cpfe-note" }, t("assistant.brokenHint"))
1929
1933
  : null,
@@ -2178,11 +2182,11 @@ try {
2178
2182
  variant: "outline",
2179
2183
  size: "sm",
2180
2184
  disabled: busy || draft.historyPick === "",
2185
+ title: t("history.hint"),
2181
2186
  onClick: loadVersion,
2182
2187
  },
2183
2188
  t("history.load"),
2184
2189
  ),
2185
- react.createElement("span", { className: "cpfe-history-hint" }, t("history.hint")),
2186
2190
  ),
2187
2191
  ),
2188
2192
  ]
@@ -2322,7 +2326,6 @@ try {
2322
2326
  : null,
2323
2327
  )
2324
2328
  : null,
2325
- createGroup,
2326
2329
  // The confirmation is a portal: rendering it here keeps every piece of this page's
2327
2330
  // state in one component.
2328
2331
  renderable(A.RiskConfirmation) && editorReady
package/index.mjs CHANGED
@@ -7,8 +7,10 @@
7
7
  * GET /api/custom-mode — every assistant this feature manages.
8
8
  * GET /api/custom-mode/state?id=… — one assistant: base mode, rows, switches, prompt.
9
9
  * GET /api/custom-mode/history?id=…&n=… — one prompt revision (metadata comes with the state).
10
- * POST /api/custom-mode/state — { id, mode, overrides, prompt, name, description }:
10
+ * POST /api/custom-mode/state — { id, mode, overrides, prompt, name, description, baseN? }:
11
11
  * validate, render a fresh `agent.cordis.yml`, write both files.
12
+ * `baseN` is the newest history number the page loaded. A JSON
13
+ * number that is not the prompt now on disk refuses the write.
12
14
  * POST /api/custom-mode/create — { name, description, posture?, locale? }: seed a new
13
15
  * assistant. Omitting `posture` (or sending `develop`) is the
14
16
  * historical path: the packaged prompt and the standard base,
@@ -492,6 +494,38 @@ export function readList(rows) {
492
494
  */
493
495
  const writeChains = new Map()
494
496
 
497
+ /**
498
+ * 打开页面之后、按下保存之前,`prompt.md` 可能已被会话工具或手工改过。
499
+ *
500
+ * 先把磁盘上的那一版记进历史。页面带了 JSON 数字 `baseN`,且那个版本不是磁盘上这一版时,
501
+ * 拒绝写入:磁盘保持外部文本。不带 `baseN` 的旧客户端仍覆盖,但原文已经在历史里。
502
+ * 数字字符串不算 `baseN`。留痕自己失败时不挡住这次保存。
503
+ *
504
+ * @param {string} directory
505
+ * @param {string} prompt - the text this save wants to write.
506
+ * @param {unknown} baseN
507
+ * @returns {object|null} a `conflict` result, or null when the write may proceed.
508
+ */
509
+ function refuseStalePrompt(directory, prompt, baseN) {
510
+ try {
511
+ const diskPrompt = readPrompt(directory)
512
+ if (diskPrompt.ok !== true || diskPrompt.text === prompt) return null
513
+ recordExternalChange(directory, diskPrompt.text)
514
+ if (typeof baseN === 'number' && readVersion(directory, baseN) !== diskPrompt.text) {
515
+ return {
516
+ ok: false,
517
+ code: 'conflict',
518
+ params: { baseN },
519
+ error: '磁盘上的提示词已经变了,这次保存没有写入。',
520
+ note: '磁盘上的提示词已经变了,这次保存没有写入。',
521
+ }
522
+ }
523
+ } catch {
524
+ /* 审计留痕失败不挡住这次保存 */
525
+ }
526
+ return null
527
+ }
528
+
495
529
  function serializedWrite(key, work) {
496
530
  const previous = writeChains.get(key) ?? Promise.resolve()
497
531
  const next = previous.then(work, work)
@@ -509,12 +543,13 @@ function serializedWrite(key, work) {
509
543
  * 的行集合也就不变,这是所有选项里破坏性最小的一个。
510
544
  *
511
545
  * @param {string} directory - the assistant's preset directory.
512
- * @param {{id: string, mode: string, prompt: string, name: string, description: string, displayName?: string}} input
546
+ * @param {{id: string, mode: string, prompt: string, name: string, description: string, displayName?: string, baseN?: number}} input
513
547
  * - the validated save; `name` decides whether `preset.yml` is written, `displayName` is what the response
514
- * reports (the disk name when the request omitted one — issue #9).
548
+ * reports (the disk name when the request omitted one — issue #9). `baseN` is the same stale-save
549
+ * token as {@link saveState}: a JSON number only.
515
550
  * @returns {object} the shape `saveState` returns, with `code: 'savedPromptOnly'`.
516
551
  */
517
- export function savePromptOnly(directory, { id, mode, prompt, name, description, displayName }) {
552
+ export function savePromptOnly(directory, { id, mode, prompt, name, description, displayName, baseN }) {
518
553
  // 与正常保存同一条纪律:提示词与 preset.yml 一起换名,失败则两个都不动。
519
554
  let metaText = null
520
555
  if (name !== '') {
@@ -522,6 +557,8 @@ export function savePromptOnly(directory, { id, mode, prompt, name, description,
522
557
  if (rendered.ok !== true) return rendered
523
558
  metaText = rendered.text
524
559
  }
560
+ const conflict = refuseStalePrompt(directory, prompt, baseN)
561
+ if (conflict !== null) return conflict
525
562
  try {
526
563
  writeAtomicPair([
527
564
  [promptFile(directory), prompt],
@@ -618,7 +655,15 @@ export function saveState(rows, input) {
618
655
  // 还想改开关时明确拒绝,而不是悄悄丢掉用户的意图。
619
656
  if (isBaseCompositionUnavailable(error)) {
620
657
  if (overrides.size === 0 && existsSync(compositionFile(directory))) {
621
- return savePromptOnly(directory, { id, mode, prompt, name, description, displayName: effectiveName })
658
+ return savePromptOnly(directory, {
659
+ id,
660
+ mode,
661
+ prompt,
662
+ name,
663
+ description,
664
+ displayName: effectiveName,
665
+ baseN: input !== null && typeof input === 'object' ? input.baseN : undefined,
666
+ })
622
667
  }
623
668
  return {
624
669
  ok: false,
@@ -650,6 +695,8 @@ export function saveState(rows, input) {
650
695
  if (rendered.ok !== true) return rendered
651
696
  metaText = rendered.text
652
697
  }
698
+ const conflict = refuseStalePrompt(directory, prompt, input !== null && typeof input === 'object' ? input.baseN : undefined)
699
+ if (conflict !== null) return conflict
653
700
  try {
654
701
  writeAtomicPair([
655
702
  [compositionFile(directory), composition],
package/locales.mjs CHANGED
@@ -88,6 +88,7 @@ export const zh = {
88
88
  'meta.version': '插件版本',
89
89
  'meta.versionHint': '未钉版本时,可能装到较旧的发布。要换版本,按 README 钉版本重装,然后重启。',
90
90
  'api.saved': '已保存({name},{mode})。新会话使用新配置,当前会话不变。',
91
+ 'api.conflict': '磁盘上的提示词已经变了,这次没有写入。先重新读取,再决定要不要覆盖。',
91
92
  'api.savedWithExclusiveRows': '已保存({name},{mode})。已关闭不能同时启用的行:{rows}。',
92
93
  'api.created': '已创建「{name}」。刷新后,新会话的模式菜单里会出现它。',
93
94
  'api.savedPrompt': '已保存系统提示词({name})。下一步模型调用即生效。',
@@ -174,8 +175,8 @@ export const zh = {
174
175
  'status.enabled': '已启用',
175
176
  'status.disabled': '已停用',
176
177
  'status.changed': '已改',
177
- 'tag.essential': '基础能力',
178
- 'tag.followPlatform': '跟随平台',
178
+ 'tag.essential': '核心',
179
+ 'tag.followPlatform': '随平台',
179
180
 
180
181
  'btn.save': '保存',
181
182
  'btn.saving': '处理中…',
@@ -343,6 +344,7 @@ export const en = {
343
344
  'meta.version': 'Plugin version',
344
345
  '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.',
345
346
  'api.saved': 'Saved ({name}, {mode}). New sessions use it. The current session does not change.',
347
+ 'api.conflict': 'The prompt on disk has changed, so this save was not written. Reload, then decide whether to overwrite.',
346
348
  'api.savedWithExclusiveRows': 'Saved ({name}, {mode}). Turned off rows that cannot be on together: {rows}.',
347
349
  'api.created': 'Created {name}. Refresh once, then it appears in the mode menu for a new session.',
348
350
  'api.savedPrompt': 'System prompt saved ({name}). It applies on the next model step.',
@@ -430,7 +432,7 @@ export const en = {
430
432
  'status.disabled': 'Disabled',
431
433
  'status.changed': 'changed',
432
434
  'tag.essential': 'Core',
433
- 'tag.followPlatform': 'follows platform',
435
+ 'tag.followPlatform': 'Platform',
434
436
 
435
437
  'btn.save': 'Save',
436
438
  'btn.saving': 'Working…',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Custom modes and custom prompts for DeepSeek Harness (dsh): edit a mode's system prompt on the settings page (it takes effect on the next model step), pick its base mode, switch plugin rows one by one, and keep several assistants side by side — coding, chat or roleplay (RP) personas (multi-mode / multi-agent).",
5
5
  "license": "MIT",
6
6
  "type": "module",