dsh-custom-mode 1.9.6 → 1.9.8

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.md CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  ![dsh-custom-mode — a settings page for dsh agent modes](https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/header.png)
4
4
 
5
- [![npm](https://img.shields.io/npm/v/dsh-custom-mode?label=npm&style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/dsh-custom-mode) [![CI](https://img.shields.io/github/actions/workflow/status/BOWLUNA/dsh-custom-mode/test.yml?label=CI&style=flat-square&logo=githubactions&logoColor=white)](https://github.com/BOWLUNA/dsh-custom-mode/actions/workflows/test.yml) [![license](https://img.shields.io/npm/l/dsh-custom-mode?label=license&style=flat-square&logo=opensourceinitiative&logoColor=white)](LICENSE)
5
+ [![npm](https://img.shields.io/npm/v/dsh-custom-mode?label=npm&style=flat-square&logo=npm&logoColor=white&labelColor=1f2430)](https://www.npmjs.com/package/dsh-custom-mode) [![CI](https://img.shields.io/github/actions/workflow/status/BOWLUNA/dsh-custom-mode/test.yml?label=CI&style=flat-square&logo=githubactions&logoColor=white&labelColor=1f2430)](https://github.com/BOWLUNA/dsh-custom-mode/actions/workflows/test.yml) [![license](https://img.shields.io/badge/license-MIT-97ca00?style=flat-square&logo=opensourceinitiative&logoColor=white&labelColor=1f2430)](LICENSE) [![dsh](https://img.shields.io/badge/dsh-%E2%89%A50.1.5--rc.2-4d6bfe?style=flat-square&logo=deepseek&logoColor=white&labelColor=1f2430)](https://github.com/BOWLUNA/dsh-custom-mode#readme)
6
6
 
7
- <!-- community badge row: required in every new repository (see the storefront template in the operator SOP/DSH Plugins folder) -->
7
+ <!-- badge rows: same two-part structure (dark label + coloured value) in both rows; every new repo copies this block -->
8
+ [![bilibili](https://img.shields.io/badge/bilibili-videos-%2300A1D6?style=flat-square&logo=bilibili&logoColor=white&labelColor=1f2430)](https://b23.tv/qJ4Ev0W) [![Douyin](https://img.shields.io/badge/Douyin-shorts-%23FE2C55?style=flat-square&logo=tiktok&logoColor=white&labelColor=1f2430)](https://v.douyin.com/VWh0M03Fa4Y/) [![RedNote](https://img.shields.io/badge/RedNote-notes-%23FF2442?style=flat-square&logo=xiaohongshu&logoColor=white&labelColor=1f2430)](https://xhslink.cn/o/A7QtXmePBBF) [![Discord](https://img.shields.io/badge/Discord-chat-%235865F2?style=flat-square&logo=discord&logoColor=white&labelColor=1f2430)](https://discord.gg/pz97SfAfSy) [![GitHub](https://img.shields.io/github/discussions/BOWLUNA/dsh-custom-mode?label=GitHub&style=flat-square&logo=github&logoColor=white&labelColor=1f2430)](https://github.com/BOWLUNA/dsh-custom-mode/discussions)
8
9
 
9
- [![bilibili](https://img.shields.io/badge/bilibili-%2300A1D6?style=flat-square&logo=bilibili&logoColor=white)](https://b23.tv/qJ4Ev0W) [![Douyin](https://img.shields.io/badge/Douyin-%23FE2C55?style=flat-square&logo=tiktok&logoColor=white)](https://v.douyin.com/VWh0M03Fa4Y/) [![RedNote](https://img.shields.io/badge/RedNote-%23FF2442?style=flat-square&logo=xiaohongshu&logoColor=white)](https://xhslink.cn/o/A7QtXmePBBF) [![Discord](https://img.shields.io/badge/Discord-%235865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/pz97SfAfSy) [![Discussions](https://img.shields.io/github/discussions/BOWLUNA/dsh-custom-mode?label=Discussions&style=flat-square&logo=github&logoColor=white)](https://github.com/BOWLUNA/dsh-custom-mode/discussions)
10
+ <!-- community badge row: required in every new repository (see the storefront template in the operator SOP/DSH Plugins folder) -->
10
11
 
11
12
  The **settings-page half** of [dsh-custom-mode](https://github.com/BOWLUNA/dsh-custom-mode) — a Web UI for
12
13
  [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) agent modes: choose a mode's base
@@ -15,7 +16,8 @@ agent loop re-reads before every model step — **a save applies on the next ste
15
16
  session**. Several modes ("assistants") live side by side, each with its own prompt.
16
17
 
17
18
  Searched for as: custom mode · custom prompt · system-prompt editor · multi-mode / several assistants ·
18
- 自定义模式 · 自定义提示词 · 多助手/多模式.
19
+ multi-agent · roleplay (RP) / chat personas ·
20
+ 自定义模式 · 自定义提示词 · 多助手/多模式 · 角色扮演/聊天人格(RP).
19
21
 
20
22
  ![Assistant manager](https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/05-assistant-manager.png)
21
23
 
package/assistants.mjs CHANGED
@@ -28,7 +28,10 @@
28
28
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
29
29
  import { dirname, join } from 'node:path'
30
30
  import { dshHome, PRESET_DIR } from './paths.mjs'
31
- import { readPresetMeta, writePresetMeta } from './meta.mjs'
31
+ // `presetMetaPath` 曾被漏掉:reorderAssistant 用它做"快照 → 失败回滚",于是两处调用都抛
32
+ // ReferenceError 被 try/catch 吞掉,回滚循环 continue 掉每一条 —— 用户看到的"已回滚"从未发生,
33
+ // 磁盘上留下改了一半的 order。少一个导入就能让一整段安全网变成死代码(外部评审复现)。
34
+ import { readPresetMeta, writePresetMeta, presetMetaPath } from './meta.mjs'
32
35
  import { writeAtomic } from './atomic.mjs'
33
36
  import { unresolvableRows } from './composition.mjs'
34
37
  import { seedPreset, seedPresetWithLog } from './seed.mjs'
@@ -256,20 +259,22 @@ export function allocateId(name, taken) {
256
259
  * @returns {{ok: true, id: string, dir: string} | {ok: false, error: string}}
257
260
  */
258
261
  export function createAssistantDir({ root, id, composition, templateDir }) {
262
+ // 每个用户可见的结果都带 `code`(AGENTS.md 第 13 条):没有它,页面只能显示下面这串中文,
263
+ // 英文界面就在这一刻掉回中文。前三条还会被设置页原样显示给用户。
259
264
  if (!PRESET_ID.test(String(id ?? ''))) {
260
- return { ok: false, error: `助手标识不合法:${String(id)}(只能是 a-z0-9 与连字符)` }
265
+ return { ok: false, code: 'badAssistantId', params: { id: String(id) }, error: `助手标识不合法:${String(id)}(只能是 a-z0-9 与连字符)` }
261
266
  }
262
267
  const dir = join(root, id)
263
- if (existsSync(dir)) return { ok: false, error: `目录已存在:${dir}` }
268
+ if (existsSync(dir)) return { ok: false, code: 'dirExists', params: { path: dir }, error: `目录已存在:${dir}` }
264
269
 
265
270
  const seeded = seedPreset(dir, templateDir)
266
271
  if (seeded.errors.length > 0) {
267
- return { ok: false, error: '复制模式模板失败:' + seeded.errors.join(';') }
272
+ return { ok: false, code: 'seedFailed', params: { detail: seeded.errors.join(';') }, error: '复制模式模板失败:' + seeded.errors.join(';') }
268
273
  }
269
274
  try {
270
275
  writeFileSync(join(dir, COMPOSITION_FILE), composition, 'utf8')
271
276
  } catch (error) {
272
- return { ok: false, error: '写入组成文件失败:' + describe(error) }
277
+ return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入组成文件失败:' + describe(error) }
273
278
  }
274
279
  return { ok: true, id, dir }
275
280
  }
package/client.js CHANGED
@@ -138,6 +138,13 @@ try {
138
138
  "api.promptWriteFailed": "写入提示词失败:{detail}",
139
139
  "api.renderFailed": "生成组成文件失败:{detail}",
140
140
  "api.selfCheckFailed": "生成结果自检失败,已放弃写入:{detail}",
141
+ "api.internalError": "宿主内部错误:{detail}",
142
+ "api.bodyTooLarge": "请求体过大(上限 {max} 字节)",
143
+ "api.repairNotNeeded": "这个助手在本机没有无法解析的行,无需修复。",
144
+ "api.repaired": "已按本机这条 dsh 线关闭 {count} 个无法解析的行({ids})。现在这个模式能重新出现在选择器里。",
145
+ "api.badAssistantId": "助手标识不合法:{id}(只能用 a-z、0-9 与连字符)",
146
+ "api.seedFailed": "复制模式模板失败:{detail}",
147
+ "api.metaWriteFailed": "写入 preset.yml 失败:{detail}",
141
148
  "api.promptReadFailed": "读取提示词失败:{detail}",
142
149
  "api.noRemoveApi": "当前 DSH 版本没有 agentPresets.remove(),无法删除。",
143
150
  "api.deleteFailed": "删除失败:{detail}",
@@ -163,7 +170,7 @@ try {
163
170
  "msg.exported": "已导出为文件。",
164
171
  "msg.exportFailed": "导出失败",
165
172
  "delete.title": "永久删除这个助手?",
166
- "delete.description": "这会删除磁盘上的模式目录,连同它的系统提示词一起消失,无法撤销。正在使用它的会话不受影响;新建会话时它不再出现在选择器里。",
173
+ "delete.description": "这会删除磁盘上的模式目录,连同它的系统提示词一起消失,无法撤销。正在使用它的会话不受影响;新建会话时它不再出现在选择器里。({id})",
167
174
  "delete.acknowledge": "我明白这个助手的提示词会被永久删除",
168
175
  "delete.confirm": "永久删除",
169
176
  "delete.close": "关闭",
@@ -193,6 +200,7 @@ try {
193
200
  "msg.readFailed": "读取失败",
194
201
  "msg.saveFailed": "保存失败",
195
202
  "msg.saved": "已保存。新建会话即生效,当前会话保持原配置。",
203
+ "status.detail": "{message}:{detail}",
196
204
  "row.persona.label": "身份(系统提示词)",
197
205
  "row.persona.note": "提示词注入点;关掉后本模式用回部署默认身份",
198
206
  "row.custom-prompt-tool.label": "custom_prompt 工具",
@@ -338,6 +346,13 @@ try {
338
346
  "api.promptWriteFailed": "Writing the prompt failed: {detail}",
339
347
  "api.renderFailed": "Rendering the composition failed: {detail}",
340
348
  "api.selfCheckFailed": "The generated composition failed its self-check, so nothing was written: {detail}",
349
+ "api.internalError": "Unexpected host error: {detail}",
350
+ "api.bodyTooLarge": "Request body too large (limit {max} bytes)",
351
+ "api.repairNotNeeded": "Nothing to repair — no row of this assistant is unresolvable on this machine.",
352
+ "api.repaired": "Disabled {count} row(s) this dsh line cannot resolve ({ids}). The mode can appear in the pickers again.",
353
+ "api.badAssistantId": "Invalid assistant id: {id} (a-z, 0-9 and hyphens only)",
354
+ "api.seedFailed": "Copying the mode template failed: {detail}",
355
+ "api.metaWriteFailed": "Writing preset.yml failed: {detail}",
341
356
  "api.promptReadFailed": "Reading the prompt failed: {detail}",
342
357
  "api.noRemoveApi": "This DSH build has no agentPresets.remove(), so deleting is unavailable.",
343
358
  "api.deleteFailed": "Delete failed: {detail}",
@@ -363,7 +378,7 @@ try {
363
378
  "msg.exported": "Exported to a file.",
364
379
  "msg.exportFailed": "Export failed",
365
380
  "delete.title": "Delete this assistant permanently?",
366
- "delete.description": "This removes the mode directory from disk, system prompt included, and cannot be undone. Sessions already using it keep running; new sessions no longer offer it.",
381
+ "delete.description": "This removes the mode directory from disk, system prompt included, and cannot be undone. Sessions already using it keep running; new sessions no longer offer it. ({id})",
367
382
  "delete.acknowledge": "I understand this assistant's prompt will be deleted permanently",
368
383
  "delete.confirm": "Delete permanently",
369
384
  "delete.close": "Close",
@@ -393,6 +408,7 @@ try {
393
408
  "msg.readFailed": "Load failed",
394
409
  "msg.saveFailed": "Save failed",
395
410
  "msg.saved": "Saved. A new session picks it up; the current one keeps its configuration.",
411
+ "status.detail": "{message}: {detail}",
396
412
  "row.persona.label": "Identity (system prompt)",
397
413
  "row.persona.note": "Where the prompt is injected; turning it off falls back to the deployment identity",
398
414
  "row.custom-prompt-tool.label": "custom_prompt tool",
@@ -1024,6 +1040,19 @@ try {
1024
1040
  const result = await postJson(ROUTES.repair, { id: selected })
1025
1041
  if (result !== null && result.ok === true) {
1026
1042
  setStatus(apiText(result, "msg.repaired"))
1043
+ // ★ 把修复折进**草稿**(issue #8)。
1044
+ //
1045
+ // 修复在磁盘上是生效的,但草稿里那几行仍然是"启用"。而 `reload()` 对脏草稿是
1046
+ // **保留**的(`open()` 的 `!sameDraft(...)` 保护),所以草稿不会自己更新;
1047
+ // 于是下一次保存会按草稿重渲染,把刚修好的行原样打开 —— 模式再次从所有选择器里消失。
1048
+ // 实测(2026-09-21):修复后磁盘上 ghost-row 已关闭,保存一次就又被打开了。
1049
+ const repairedIds = Array.isArray(result.params?.repairedIds) ? result.params.repairedIds : []
1050
+ if (repairedIds.length > 0 && draft !== null) {
1051
+ // 草稿里的 `overrides` 是"显式**启用**"语义,所以关闭 = false。
1052
+ const nextOverrides = { ...draft.overrides }
1053
+ for (const rowId of repairedIds) nextOverrides[rowId] = false
1054
+ update({ overrides: nextOverrides }, { force: true })
1055
+ }
1027
1056
  await reload()
1028
1057
  } else {
1029
1058
  setFailed(true)
@@ -1169,7 +1198,7 @@ try {
1169
1198
  } catch (error) {
1170
1199
  if (alive) {
1171
1200
  setFailed(true)
1172
- setStatus(t("msg.readFailed") + ":" + describeError(error))
1201
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.readFailed"), detail: describeError(error) }))
1173
1202
  }
1174
1203
  } finally {
1175
1204
  if (alive) setBusy(false)
@@ -1181,7 +1210,20 @@ try {
1181
1210
  }, [])
1182
1211
 
1183
1212
  /** Change one field of the selected assistant's draft. */
1184
- const update = (patch) => {
1213
+ const update = (patch, options) => {
1214
+ // ★ 忙碌期间**不接受**对草稿的修改(issue #6)。
1215
+ //
1216
+ // 为什么必须在这里拦:保存成功后(下面 `save()` 里)会用服务器归一化的结果
1217
+ // `value: normalized` **无条件**覆盖草稿。于是往返期间敲进去的字、翻过的行开关都会
1218
+ // 凭空消失,而页面还显示 "Saved" —— 用户丢的是手写的系统提示词。
1219
+ //
1220
+ // 拦在这一层而不是逐个控件加 `disabled`,是因为它一处覆盖**所有**草稿字段
1221
+ // (name / description / prompt / overrides),漏掉任何一个都会重新打开那条丢失路径。
1222
+ // 视觉上控件仍可点,但点了不会改状态,所以不会出现"开关翻过去又弹回来"这种骗人的反馈。
1223
+ //
1224
+ // `{ force: true }` 是给**内部**调用用的(例如「按本线修复」把结果折进草稿):
1225
+ // 那不是用户输入,不受这条守护约束。
1226
+ if (busy === true && options?.force !== true) return
1185
1227
  setEntries((previous) => {
1186
1228
  const current = previous[selected]
1187
1229
  if (current === undefined) return previous
@@ -1201,7 +1243,7 @@ try {
1201
1243
  await open(id)
1202
1244
  } catch (error) {
1203
1245
  setFailed(true)
1204
- setStatus(t("msg.readFailed") + ":" + describeError(error))
1246
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.readFailed"), detail: describeError(error) }))
1205
1247
  } finally {
1206
1248
  setBusy(false)
1207
1249
  }
@@ -1233,7 +1275,7 @@ try {
1233
1275
  }
1234
1276
  } catch (error) {
1235
1277
  setFailed(true)
1236
- setStatus(t("msg.createFailed") + ":" + describeError(error))
1278
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.createFailed"), detail: describeError(error) }))
1237
1279
  } finally {
1238
1280
  setBusy(false)
1239
1281
  }
@@ -1261,7 +1303,7 @@ try {
1261
1303
  }
1262
1304
  } catch (error) {
1263
1305
  setFailed(true)
1264
- setStatus(t("msg.reorderFailed") + ":" + describe(error))
1306
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.reorderFailed"), detail: describe(error) }))
1265
1307
  } finally {
1266
1308
  setBusy(false)
1267
1309
  }
@@ -1294,7 +1336,7 @@ try {
1294
1336
  }
1295
1337
  update({ prompt: payload.text })
1296
1338
  } catch (error) {
1297
- setStatus(t("msg.loadFailed") + ":" + describeError(error))
1339
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.loadFailed"), detail: describeError(error) }))
1298
1340
  }
1299
1341
  }
1300
1342
 
@@ -1313,7 +1355,7 @@ try {
1313
1355
  setStatus(t("msg.exported"))
1314
1356
  } catch (error) {
1315
1357
  setFailed(true)
1316
- setStatus(t("msg.exportFailed") + ":" + describe(error))
1358
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.exportFailed"), detail: describe(error) }))
1317
1359
  }
1318
1360
  }
1319
1361
 
@@ -1375,7 +1417,7 @@ try {
1375
1417
  }
1376
1418
  } catch (error) {
1377
1419
  setFailed(true)
1378
- setStatus(t("msg.deleteFailed") + ":" + describeError(error))
1420
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.deleteFailed"), detail: describeError(error) }))
1379
1421
  } finally {
1380
1422
  setBusy(false)
1381
1423
  }
@@ -1438,7 +1480,7 @@ try {
1438
1480
  }
1439
1481
  } catch (error) {
1440
1482
  setFailed(true)
1441
- setStatus(t("msg.saveFailed") + ":" + describeError(error))
1483
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.saveFailed"), detail: describeError(error) }))
1442
1484
  } finally {
1443
1485
  setBusy(false)
1444
1486
  }
@@ -1743,6 +1785,11 @@ try {
1743
1785
  className: "cpfe-editor",
1744
1786
  value: draft.prompt,
1745
1787
  spellCheck: false,
1788
+ // 忙碌期间只读(issue #6):`update()` 那一层已经会丢弃改动,这里再给一个
1789
+ // **看得见**的信号 —— 否则用户会以为自己在编辑,而字根本没进去。
1790
+ // 用 readOnly 而不是 disabled:仍然可以选中/复制,只是改不了。
1791
+ readOnly: busy === true,
1792
+ "aria-busy": busy === true,
1746
1793
  "aria-label": t("prompt.heading"),
1747
1794
  onChange: (event) => update({ prompt: event.target.value }),
1748
1795
  }),
@@ -1864,7 +1911,7 @@ try {
1864
1911
  ? react.createElement(A.RiskConfirmation, {
1865
1912
  open: deleteOpen,
1866
1913
  title: t("delete.title"),
1867
- description: t("delete.description") + "(" + draft.id + ")",
1914
+ description: fillPlaceholders(t("delete.description"), { id: draft.id }),
1868
1915
  acknowledgeLabel: t("delete.acknowledge"),
1869
1916
  cancelLabel: t("btn.cancel"),
1870
1917
  closeLabel: t("delete.close"),
package/composition.mjs CHANGED
@@ -593,8 +593,15 @@ export function disableRowsInPlace(text, ids) {
593
593
  if (wanted.length === 0) return text
594
594
  // `applyLevel` 把 Map 的值直接交给 `setDisabled(...)`,所以值是**关闭**布尔(true = 关闭)。
595
595
  const off = new Map(wanted.map((id) => [id, true]))
596
- const top = applyLevel(text, true, off, false, false)
597
- return applyLevel(top, false, off, false, false)
596
+ // **只跑顶级这一遍。** 它已经通过 applyLevel 的 `isGroup` 分支递归进每个分组,
597
+ // 所以原先跟在后面的那次扁平扫描(`applyLevel(top, false, …)`)纯属重复 —— 而且有害:
598
+ // 它以「下一个 4 空格 `- id:`」界定片段,于是**分组的最后一个子行会吞掉后续的顶层内容**
599
+ // (包括下一个分组的 `name:` 头),`ownKeyIndent` 随即算出缩进 2,把 `disabled` 写进了
600
+ // **下一个分组**。实测(2026-09-21,4 个模式 × 每个行 id 全扫):连带损害 7 例
601
+ // (关 plan-mode 会连带关掉整个 compaction 分组;关 tool-result-pruner 会连带关掉整个
602
+ // delegation 分组)、行数 Δ=+2 的结构异常 6 例、以及若干"目标行根本没被改到"的空操作。
603
+ // 删掉这一遍之后:连带 0 例、结构异常 0 例,正确生效 81 → 87。
604
+ return applyLevel(text, true, off, false, false)
598
605
  }
599
606
 
600
607
  export function unresolvableRows(text) {
@@ -613,15 +620,25 @@ export function unresolvableRows(text) {
613
620
  const row = /^( {0,4})- id: (.+?)\s*$/.exec(lines[index])
614
621
  if (row === null) continue
615
622
  const indent = row[1].length
623
+ // 这一行**自己的键**所在的列(`- id:` 再进两格)。只有这一列上的 `name:` / `disabled:`
624
+ // 才属于它 —— 否则会读到子树里的键。
625
+ const keyIndent = indent + 2
616
626
  let name
617
627
  let disabled = false
618
628
  for (let next = index + 1; next < lines.length; next += 1) {
619
629
  const line = lines[next]
620
- if (line.trim() !== '' && line.search(/\S/) <= indent) break
621
- if (/^\s+name: ['"]?(.+?)['"]?\s*$/.test(line)) name = /^\s+name: ['"]?(.+?)['"]?\s*$/.exec(line)[1]
630
+ if (line.trim() === '') continue
631
+ const column = line.search(/\S/)
632
+ if (column <= indent) break
633
+ // **跳过比自己深的行**(子行的键、子行的键的子行……)。
634
+ // 原先这里没有这一层过滤,于是顶层分组会一路走完整个子树,`name` 被**最后一个子行**覆盖:
635
+ // 一个健康的分组因此被报成"解析不了"(外部实测:`grp` 被报成坏行,name 取自 child-last),
636
+ // 后果是页面点名它、启动日志喊它、而"按本线修复"会**关掉整个分组**。
637
+ if (column !== keyIndent) continue
638
+ if (/^name: ['"]?(.+?)['"]?\s*$/.test(line.slice(keyIndent))) name = /^name: ['"]?(.+?)['"]?\s*$/.exec(line.slice(keyIndent))[1]
622
639
  // 平台条件行(`disabled: !!js …`)必须**求值**判定:只看有没有字面 `true` 会把"本平台已启用"的行
623
640
  // 误判成关闭,也会把"本平台本来就关闭"的行(如 Windows 专用的 pwsh)误报成"无法解析"。
624
- const flag = /^\s+disabled:\s*(.+?)\s*$/.exec(line)
641
+ const flag = /^disabled:\s*(.+?)\s*$/.exec(line.slice(keyIndent))
625
642
  if (flag !== null) {
626
643
  // 字面量直接读,`!!js` 一类交给求值器(它对字面量不做布尔化)。
627
644
  const raw = flag[1].replace(/^['"]|['"]$/g, '')
package/index.mjs CHANGED
@@ -196,8 +196,12 @@ export function readPrompt(directory) {
196
196
 
197
197
  /** The isolation message for an id the roster does not describe as a managed assistant. */
198
198
  function unknownAssistant(id) {
199
+ // 带 `code`:两个标签页同时开着、在 B 里删掉这个助手、回到 A 点保存时走的就是这条 ——
200
+ // 没有 code 时英文界面会显示下面这串中文,而词典里的 `api.unknownAssistant` 反而永远用不上。
199
201
  return {
200
202
  ok: false,
203
+ code: 'unknownAssistant',
204
+ params: { id: String(id) },
201
205
  error:
202
206
  '找不到助手「' +
203
207
  String(id) +
@@ -277,7 +281,19 @@ export function readState(rows, id, options = {}) {
277
281
  const meta = readPresetMeta(directory)
278
282
  // 页面要打开时顺便对账:磁盘上的文本若与日志末条不同,说明它在设置页之外被改过
279
283
  // (会话内的 custom_prompt 工具、手工编辑、别处同步)—— 补记一条 external,于是"被改过"看得见。
280
- if (prompt.ok === true) recordExternalChange(directory, prompt.text)
284
+ //
285
+ // **必须包起来**:这是一次"写透"(recordPrompt → writeAtomic),而日志路径本身可能是坏的
286
+ // (被换成目录、被 chmod 000、一次 sudo 跑 dsh 留下的 root 属主)。1.9.7 给 readEntries 加的
287
+ // 守卫只覆盖了**读**那一半,于是同一个损坏场景会在这里重新把整个 GET state 打成 500 ——
288
+ // 页面打不开这个助手,用户既看不到也改不了自己的提示词(外部实测:EISDIR on rename)。
289
+ // 留痕失败降级为"这一版没记上",与 saveState 里已经做过的处置一致。
290
+ if (prompt.ok === true) {
291
+ try {
292
+ recordExternalChange(directory, prompt.text)
293
+ } catch {
294
+ /* 审计留痕失败不影响读取 */
295
+ }
296
+ }
281
297
  return {
282
298
  ok: true,
283
299
  id,
@@ -414,13 +430,22 @@ export function saveState(rows, input) {
414
430
  [compositionFile(directory), composition],
415
431
  [promptFile(directory), prompt],
416
432
  ])
417
- // 改动留痕:三个改动路径(设置页 / 会话内工具 / 手工编辑)里,只有设置页是"当场知道"的。
418
- // 另外两条由 readState 对比补记(见 journal.mjs 的单写者说明)。
419
- recordPrompt(directory, prompt, HISTORY_SOURCE.settings)
420
433
  } catch (error) {
421
434
  return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
422
435
  }
423
436
 
437
+ // 改动留痕:三个改动路径(设置页 / 会话内工具 / 手工编辑)里,只有设置页是"当场知道"的。
438
+ // 另外两条由 readState 对比补记(见 journal.mjs 的单写者说明)。
439
+ //
440
+ // **在写入 try 之外**:这是一条审计记录,它失败不该让一次已经落盘的保存报 writeFailed
441
+ // ——外部评审复现过:日志文件被 chmod 000 时,prompt.md 已经写成功而页面显示"保存失败",
442
+ // 页面又不会重新读取,于是它永远停在旧草稿上。留痕失败只降级为"这一版没记上"。
443
+ try {
444
+ recordPrompt(directory, prompt, HISTORY_SOURCE.settings)
445
+ } catch {
446
+ /* 审计记录失败不影响保存本身 */
447
+ }
448
+
424
449
  // A name the user cleared is left alone rather than written as an empty scalar:
425
450
  // an empty `preset.yml` name is what makes a mode render as its bare id.
426
451
  if (name !== '') {
@@ -494,7 +519,14 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
494
519
  // 显示名(`rows` 里的 name)优先于内部 id —— 与删除一致。
495
520
  const fromRow = rows.find((item) => item !== null && typeof item === 'object' && item.id === from)
496
521
  if (typeof fromRow?.name === 'string' && fromRow.name.trim() !== '') fromDisplayName = fromRow.name
497
- const text = readFileSync(fromComposition, 'utf8')
522
+ // 与 readState 同一条纪律:**坏的源文件要给类型化错误,不能把异常抛到路由层**
523
+ // (抛上去就变成 500 internalError,页面只能显示 Node 的原始错误串)。
524
+ let text
525
+ try {
526
+ text = readFileSync(fromComposition, 'utf8')
527
+ } catch (error) {
528
+ return { ok: false, code: 'compositionMissing', params: { path: fromComposition, detail: describe(error) }, error: '读不到源助手的组成文件:' + describe(error) }
529
+ }
498
530
  const sourceMode = modeOf(text)
499
531
  const sourcePrompt = readPrompt(fromDir)
500
532
  source = {
@@ -511,7 +543,10 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
511
543
  ? renderComposition('standard', new Map(), { modeName: name, assistantId: id })
512
544
  : renderComposition(source.mode, source.overrides, { modeName: name, assistantId: id })
513
545
  } catch (error) {
514
- return { ok: false, error: '生成组成文件失败:' + describe(error) }
546
+ // 与保存路径的 `renderFailed` 同一套:**每个**用户可见的结果都要带 `code`,否则页面无从本地化
547
+ // —— client.js 的 apiText 在没有 code 时直接退回下面这串中文,英文界面会在这一刻掉回中文
548
+ // (AGENTS.md 第 13 条)。这条曾经是宿主半唯一漏掉 code 的普通返回。
549
+ return { ok: false, code: 'renderFailed', params: { detail: describe(error) }, error: '生成组成文件失败:' + describe(error) }
515
550
  }
516
551
 
517
552
  const created = createAssistantDir({ root, id, composition, templateDir })
@@ -584,7 +619,10 @@ export function repairComposition(rows, input) {
584
619
  ok: true,
585
620
  id,
586
621
  code: 'repaired',
587
- params: { count: bad.length, ids },
622
+ // `ids` 是给人看的一串;`repairedIds` 是给**页面**用的数组 —— 页面必须把这几行写进它自己的
623
+ // 草稿,否则下一次保存会按草稿重渲染,把刚才的修复原样撤销(issue #8 实测:
624
+ // 修复写进了磁盘,但草稿里那几行仍是启用的,于是保存后 ghost 行又回来了)。
625
+ params: { count: bad.length, ids, repairedIds: bad.map((row) => row.id) },
588
626
  note: '已按本机这条 dsh 线关闭 ' + String(bad.length) + ' 个无法解析的行(' + ids + ')。现在这个模式能重新出现在选择器里。',
589
627
  }
590
628
  }
@@ -819,7 +857,9 @@ export function apply(ctx) {
819
857
  const result = await serializedWrite('delete:' + targetId, async () => deleteAssistant(await roster(), parsed, scope.agentPresets))
820
858
  return json(result, result.ok === true ? 200 : 400)
821
859
  } catch (error) {
822
- return json({ ok: false, error: describe(error) }, 500)
860
+ // 兜底也要带 code:这是**意料之外**的异常,页面拿到的是 Node 的原始错误串,方向反着也一样糟
861
+ // ——中文界面里会蹦出一句英文。带 code 后页面用自己的词典渲染,原始串只作为 `detail` 参数。
862
+ return json({ ok: false, code: 'internalError', params: { detail: describe(error) }, error: describe(error) }, 500)
823
863
  }
824
864
  }
825
865
 
package/journal.mjs CHANGED
@@ -55,8 +55,18 @@ export function historyFile(directory) {
55
55
  export function readEntries(directory) {
56
56
  const file = historyFile(directory)
57
57
  if (!existsSync(file)) return []
58
+ let raw = ''
59
+ try {
60
+ raw = readFileSync(file, 'utf8')
61
+ } catch {
62
+ // **坏的是文件本身**(不是某一行):被 chmod 000(一次 sudo 跑 dsh 就会把 root 属主留在
63
+ // 日志上)、被改成目录、或读到一半的 EIO。此前这里裸读,于是这一个文件能同时做到两件坏事 ——
64
+ // 让 GET state(页面打开某个助手的入口)永远 500,以及让一次**已经写成功**的保存报 writeFailed。
65
+ // 模块自己的承诺是"坏一行不该赔上全部版本",那么坏一个文件更不该赔上这个助手。
66
+ return []
67
+ }
58
68
  const entries = []
59
- for (const line of readFileSync(file, 'utf8').split('\n')) {
69
+ for (const line of raw.split('\n')) {
60
70
  if (line.trim() === '') continue
61
71
  try {
62
72
  const parsed = JSON.parse(line)
@@ -82,13 +92,16 @@ export function readEntries(directory) {
82
92
  * @param {string} directory - one assistant's preset directory.
83
93
  * @param {string} text - the prompt content **after** the change.
84
94
  * @param {string} by - {@link HISTORY_SOURCE}.
85
- * @returns {{ recorded: boolean, at: string, n: number }} whether a line was appended.
95
+ * @returns {{ recorded: boolean, at: string, n: number }} whether a line was appended. `n` is the
96
+ * revision this text already corresponds to when `recorded` is false, and `0` when the input could
97
+ * not be recorded at all — never `undefined`, so a caller can feed it straight back to
98
+ * {@link readVersion} without a guard that nobody would remember to write.
86
99
  */
87
100
  export function recordPrompt(directory, text, by = HISTORY_SOURCE.settings) {
88
- if (typeof text !== 'string') return { recorded: false, at: '' }
101
+ if (typeof text !== 'string') return { recorded: false, at: '', n: 0 }
89
102
  const entries = readEntries(directory)
90
103
  const newest = entries[entries.length - 1]
91
- if (newest !== undefined && newest.text === text) return { recorded: false, at: newest.at }
104
+ if (newest !== undefined && newest.text === text) return { recorded: false, at: newest.at, n: newest.n }
92
105
 
93
106
  const at = new Date().toISOString()
94
107
  const n = entries.length === 0 ? 1 : entries[entries.length - 1].n + 1
package/locales.mjs CHANGED
@@ -97,6 +97,13 @@ export const zh = {
97
97
  'api.promptWriteFailed': '写入提示词失败:{detail}',
98
98
  'api.renderFailed': '生成组成文件失败:{detail}',
99
99
  'api.selfCheckFailed': '生成结果自检失败,已放弃写入:{detail}',
100
+ 'api.internalError': '宿主内部错误:{detail}',
101
+ 'api.bodyTooLarge': '请求体过大(上限 {max} 字节)',
102
+ 'api.repairNotNeeded': '这个助手在本机没有无法解析的行,无需修复。',
103
+ 'api.repaired': '已按本机这条 dsh 线关闭 {count} 个无法解析的行({ids})。现在这个模式能重新出现在选择器里。',
104
+ 'api.badAssistantId': '助手标识不合法:{id}(只能用 a-z、0-9 与连字符)',
105
+ 'api.seedFailed': '复制模式模板失败:{detail}',
106
+ 'api.metaWriteFailed': '写入 preset.yml 失败:{detail}',
100
107
  'api.promptReadFailed': '读取提示词失败:{detail}',
101
108
  'api.noRemoveApi': '当前 DSH 版本没有 agentPresets.remove(),无法删除。',
102
109
  'api.deleteFailed': '删除失败:{detail}',
@@ -122,7 +129,7 @@ export const zh = {
122
129
  'msg.exported': '已导出为文件。',
123
130
  'msg.exportFailed': '导出失败',
124
131
  'delete.title': '永久删除这个助手?',
125
- 'delete.description': '这会删除磁盘上的模式目录,连同它的系统提示词一起消失,无法撤销。正在使用它的会话不受影响;新建会话时它不再出现在选择器里。',
132
+ 'delete.description': '这会删除磁盘上的模式目录,连同它的系统提示词一起消失,无法撤销。正在使用它的会话不受影响;新建会话时它不再出现在选择器里。({id})',
126
133
  'delete.acknowledge': '我明白这个助手的提示词会被永久删除',
127
134
  'delete.confirm': '永久删除',
128
135
  'delete.close': '关闭',
@@ -161,6 +168,9 @@ export const zh = {
161
168
  'msg.readFailed': '读取失败',
162
169
  'msg.saveFailed': '保存失败',
163
170
  'msg.saved': '已保存。新建会话即生效,当前会话保持原配置。',
171
+ // 把「一句已翻译的话 + 一个底层细节」拼起来时,中间那个标点也必须**按语言**走 ——
172
+ // 硬编码全角冒号会让英文界面在出错那一刻出现中文标点(issue #7)。
173
+ 'status.detail': '{message}:{detail}',
164
174
 
165
175
  // ── row labels ────────────────────────────────────────────────────────────
166
176
  'row.persona.label': '身份(系统提示词)',
@@ -313,6 +323,13 @@ export const en = {
313
323
  'api.promptWriteFailed': 'Writing the prompt failed: {detail}',
314
324
  'api.renderFailed': 'Rendering the composition failed: {detail}',
315
325
  'api.selfCheckFailed': 'The generated composition failed its self-check, so nothing was written: {detail}',
326
+ 'api.internalError': 'Unexpected host error: {detail}',
327
+ 'api.bodyTooLarge': 'Request body too large (limit {max} bytes)',
328
+ 'api.repairNotNeeded': 'Nothing to repair — no row of this assistant is unresolvable on this machine.',
329
+ 'api.repaired': 'Disabled {count} row(s) this dsh line cannot resolve ({ids}). The mode can appear in the pickers again.',
330
+ 'api.badAssistantId': 'Invalid assistant id: {id} (a-z, 0-9 and hyphens only)',
331
+ 'api.seedFailed': 'Copying the mode template failed: {detail}',
332
+ 'api.metaWriteFailed': 'Writing preset.yml failed: {detail}',
316
333
  'api.promptReadFailed': 'Reading the prompt failed: {detail}',
317
334
  'api.noRemoveApi': 'This DSH build has no agentPresets.remove(), so deleting is unavailable.',
318
335
  'api.deleteFailed': 'Delete failed: {detail}',
@@ -338,7 +355,7 @@ export const en = {
338
355
  'msg.exported': 'Exported to a file.',
339
356
  'msg.exportFailed': 'Export failed',
340
357
  'delete.title': 'Delete this assistant permanently?',
341
- 'delete.description': 'This removes the mode directory from disk, system prompt included, and cannot be undone. Sessions already using it keep running; new sessions no longer offer it.',
358
+ 'delete.description': 'This removes the mode directory from disk, system prompt included, and cannot be undone. Sessions already using it keep running; new sessions no longer offer it. ({id})',
342
359
  'delete.acknowledge': 'I understand this assistant\'s prompt will be deleted permanently',
343
360
  'delete.confirm': 'Delete permanently',
344
361
  'delete.close': 'Close',
@@ -380,6 +397,7 @@ export const en = {
380
397
  'msg.readFailed': 'Load failed',
381
398
  'msg.saveFailed': 'Save failed',
382
399
  'msg.saved': 'Saved. A new session picks it up; the current one keeps its configuration.',
400
+ 'status.detail': '{message}: {detail}',
383
401
 
384
402
  // ── row labels ────────────────────────────────────────────────────────────
385
403
  'row.persona.label': 'Identity (system prompt)',
package/meta.mjs CHANGED
@@ -86,7 +86,12 @@ export function readPresetMeta(directory = PRESET_DIR) {
86
86
  /** Quote and flatten a value into a single-line YAML scalar. */
87
87
  function yamlScalar(value) {
88
88
  const flat = String(value).replace(/\r?\n/g, ' ').trim()
89
- return '"' + flat.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
89
+ // `JSON.stringify` 恰好就是 YAML 双引号标量需要的转义:除了 `"` 与 `\`,它还会把控制字符写成
90
+ // `\u0007` 这类转义序列。此前手工只转前两个,于是名字里一个 BEL / VT / NUL 就会写出平台
91
+ // js-yaml 解析不了的 preset.yml —— 这个模式在**所有选择器**里退化成裸目录 id,description 与
92
+ // order 一起丢,而本插件自己的页面还显示着那个名字(外部评审实测)。
93
+ // 对普通名字(含中文)输出与旧实现逐字节相同,所以这次替换不会动任何既有文件。
94
+ return JSON.stringify(flat)
90
95
  }
91
96
 
92
97
  /**
@@ -104,7 +109,7 @@ function yamlScalar(value) {
104
109
  */
105
110
  export function writePresetMeta(name, description, directory = PRESET_DIR, options = {}) {
106
111
  const cleanName = typeof name === 'string' ? name.replace(/\r?\n/g, ' ').trim() : ''
107
- if (cleanName === '') return { ok: false, error: '模式名称不能为空。' }
112
+ if (cleanName === '') return { ok: false, code: 'nameRequired', error: '模式名称不能为空。' }
108
113
  const lines = ['name: ' + yamlScalar(cleanName)]
109
114
  if (typeof description === 'string' && description.trim() !== '') {
110
115
  lines.push('description: ' + yamlScalar(description))
@@ -118,7 +123,11 @@ export function writePresetMeta(name, description, directory = PRESET_DIR, optio
118
123
  // 实测审阅指出这里原先用的是裸 writeFileSync,与设置页的纪律不一致;现在共用同一份实现。
119
124
  writeAtomic(presetMetaPath(directory), lines.join('\n') + '\n')
120
125
  } catch (error) {
121
- return { ok: false, error: '写入 preset.yml 失败:' + String((error && error.message) || error) }
126
+ // 带 `code`:这条是保存路径上最后一个没有 code 的用户可见失败,英文界面此前会在这里显示中文。
127
+ // 它发生得尤其难受 —— 组成文件与提示词**已经写成功**了,只有 preset.yml 没写成,所以页面
128
+ // 说"失败"而磁盘上是新提示词 + 旧名字(外部评审复现)。
129
+ const detail = String((error && error.message) || error)
130
+ return { ok: false, code: 'metaWriteFailed', params: { detail }, error: '写入 preset.yml 失败:' + detail }
122
131
  }
123
132
  return { ok: true, name: cleanName }
124
133
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "1.9.6",
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), choose its base mode, switch plugins row by row, and keep several assistants side by side.",
3
+ "version": "1.9.8",
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",
7
7
  "main": "./index.mjs",
@@ -33,7 +33,10 @@
33
33
  "custom-prompt",
34
34
  "prompt-editor",
35
35
  "prompt-engineering",
36
- "persona"
36
+ "persona",
37
+ "multi-agent",
38
+ "roleplay",
39
+ "chat"
37
40
  ],
38
41
  "dsh": {
39
42
  "bundle": {
@@ -27,7 +27,7 @@
27
27
 
28
28
  import { dirname, join } from 'node:path'
29
29
  import { randomBytes } from 'node:crypto'
30
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
30
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
31
31
  import { fileURLToPath } from 'node:url'
32
32
 
33
33
  /** Absolute path of the prompt file this preset reads. */
@@ -216,6 +216,17 @@ function makeDefinition(modeName) {
216
216
  }
217
217
  }
218
218
 
219
+ // **显式白名单,不再靠"落到最后就当写入"。** 这段代码到这儿只剩整体替换,此前它对任何
220
+ // 不认识的 action(`replace` / `Write` / `overwrite` / 空串 / `append ` 末尾多一个空格)
221
+ // 都静默照做,而审批闸门只认识 write/append —— 一个词的拼写差异就能把用户的提示词整体
222
+ // 换掉且不弹审批(外部评审实测)。不认识的动词现在一律拒绝并说明,模型据此改对再调。
223
+ if (action !== 'write') {
224
+ return (
225
+ '写入被拒绝:未知的 action「' + action + '」。只接受 read / write / append' +
226
+ '(未指定 action 按 read 处理);append 必须精确拼写,末尾多一个空格不算。'
227
+ )
228
+ }
229
+
219
230
  if (typeof args.text !== 'string' || args.text.trim() === '') {
220
231
  return '写入被拒绝:action 为 "write" 时必须提供非空的 text。'
221
232
  }
@@ -263,9 +274,17 @@ function registerApprovalGate(ctx) {
263
274
  () => ctx.on('tools/pre-execute', (exec, next) => {
264
275
  if (exec === null || typeof exec !== 'object' || exec.name !== TOOL_NAME) return next()
265
276
  const args = exec.arguments
266
- const action = args !== null && typeof args === 'object' ? args.action : undefined
267
- // 拦所有会改文件的动作:write 与 append 都在改系统提示词;未指定 action 时按 read 处理,不拦。
268
- if (action !== 'write' && action !== 'append') return next()
277
+ // **只放行真正的读取。** 未指定 action 与 execute 一样按 read 处理;其余一律发问。
278
+ //
279
+ // 此前这里只拦 `write` / `append` 两个字面量,而 execute 把"既不是 read 也不是 append"的
280
+ // **任何** action 都当成整体替换 —— 于是 `replace` / `Write` / 空串 / 末尾多一个空格都能
281
+ // 绕过审批面板直接改掉用户的提示词(外部评审实测复现)。闸门要盖住"一切可能改文件的调用",
282
+ // 不能只认识它恰好想到的那两个词。execute 那边同时加了显式白名单,两层各自独立。
283
+ const raw = args !== null && typeof args === 'object' ? args.action : undefined
284
+ // 只有"没提供 action"才等同 read(execute 也是这样兜的);**提供了但不认识**(含空串)
285
+ // 一律发问 —— 闸门的判据是"这次调用会不会改文件",不是"这个动词我认不认识"。
286
+ const action = typeof raw === 'string' ? raw : 'read'
287
+ if (action === 'read') return next()
269
288
  const verb = action === 'append' ? '追加到' : '整体替换为'
270
289
  const text = typeof args.text === 'string' ? args.text : ''
271
290
  const firstLine = text.split('\n').find((line) => line.trim() !== '') ?? ''