dsh-custom-mode 1.9.7 → 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/client.js CHANGED
@@ -170,7 +170,7 @@ try {
170
170
  "msg.exported": "已导出为文件。",
171
171
  "msg.exportFailed": "导出失败",
172
172
  "delete.title": "永久删除这个助手?",
173
- "delete.description": "这会删除磁盘上的模式目录,连同它的系统提示词一起消失,无法撤销。正在使用它的会话不受影响;新建会话时它不再出现在选择器里。",
173
+ "delete.description": "这会删除磁盘上的模式目录,连同它的系统提示词一起消失,无法撤销。正在使用它的会话不受影响;新建会话时它不再出现在选择器里。({id})",
174
174
  "delete.acknowledge": "我明白这个助手的提示词会被永久删除",
175
175
  "delete.confirm": "永久删除",
176
176
  "delete.close": "关闭",
@@ -200,6 +200,7 @@ try {
200
200
  "msg.readFailed": "读取失败",
201
201
  "msg.saveFailed": "保存失败",
202
202
  "msg.saved": "已保存。新建会话即生效,当前会话保持原配置。",
203
+ "status.detail": "{message}:{detail}",
203
204
  "row.persona.label": "身份(系统提示词)",
204
205
  "row.persona.note": "提示词注入点;关掉后本模式用回部署默认身份",
205
206
  "row.custom-prompt-tool.label": "custom_prompt 工具",
@@ -377,7 +378,7 @@ try {
377
378
  "msg.exported": "Exported to a file.",
378
379
  "msg.exportFailed": "Export failed",
379
380
  "delete.title": "Delete this assistant permanently?",
380
- "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})",
381
382
  "delete.acknowledge": "I understand this assistant's prompt will be deleted permanently",
382
383
  "delete.confirm": "Delete permanently",
383
384
  "delete.close": "Close",
@@ -407,6 +408,7 @@ try {
407
408
  "msg.readFailed": "Load failed",
408
409
  "msg.saveFailed": "Save failed",
409
410
  "msg.saved": "Saved. A new session picks it up; the current one keeps its configuration.",
411
+ "status.detail": "{message}: {detail}",
410
412
  "row.persona.label": "Identity (system prompt)",
411
413
  "row.persona.note": "Where the prompt is injected; turning it off falls back to the deployment identity",
412
414
  "row.custom-prompt-tool.label": "custom_prompt tool",
@@ -1038,6 +1040,19 @@ try {
1038
1040
  const result = await postJson(ROUTES.repair, { id: selected })
1039
1041
  if (result !== null && result.ok === true) {
1040
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
+ }
1041
1056
  await reload()
1042
1057
  } else {
1043
1058
  setFailed(true)
@@ -1183,7 +1198,7 @@ try {
1183
1198
  } catch (error) {
1184
1199
  if (alive) {
1185
1200
  setFailed(true)
1186
- setStatus(t("msg.readFailed") + ":" + describeError(error))
1201
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.readFailed"), detail: describeError(error) }))
1187
1202
  }
1188
1203
  } finally {
1189
1204
  if (alive) setBusy(false)
@@ -1195,7 +1210,20 @@ try {
1195
1210
  }, [])
1196
1211
 
1197
1212
  /** Change one field of the selected assistant's draft. */
1198
- 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
1199
1227
  setEntries((previous) => {
1200
1228
  const current = previous[selected]
1201
1229
  if (current === undefined) return previous
@@ -1215,7 +1243,7 @@ try {
1215
1243
  await open(id)
1216
1244
  } catch (error) {
1217
1245
  setFailed(true)
1218
- setStatus(t("msg.readFailed") + ":" + describeError(error))
1246
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.readFailed"), detail: describeError(error) }))
1219
1247
  } finally {
1220
1248
  setBusy(false)
1221
1249
  }
@@ -1247,7 +1275,7 @@ try {
1247
1275
  }
1248
1276
  } catch (error) {
1249
1277
  setFailed(true)
1250
- setStatus(t("msg.createFailed") + ":" + describeError(error))
1278
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.createFailed"), detail: describeError(error) }))
1251
1279
  } finally {
1252
1280
  setBusy(false)
1253
1281
  }
@@ -1275,7 +1303,7 @@ try {
1275
1303
  }
1276
1304
  } catch (error) {
1277
1305
  setFailed(true)
1278
- setStatus(t("msg.reorderFailed") + ":" + describe(error))
1306
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.reorderFailed"), detail: describe(error) }))
1279
1307
  } finally {
1280
1308
  setBusy(false)
1281
1309
  }
@@ -1308,7 +1336,7 @@ try {
1308
1336
  }
1309
1337
  update({ prompt: payload.text })
1310
1338
  } catch (error) {
1311
- setStatus(t("msg.loadFailed") + ":" + describeError(error))
1339
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.loadFailed"), detail: describeError(error) }))
1312
1340
  }
1313
1341
  }
1314
1342
 
@@ -1327,7 +1355,7 @@ try {
1327
1355
  setStatus(t("msg.exported"))
1328
1356
  } catch (error) {
1329
1357
  setFailed(true)
1330
- setStatus(t("msg.exportFailed") + ":" + describe(error))
1358
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.exportFailed"), detail: describe(error) }))
1331
1359
  }
1332
1360
  }
1333
1361
 
@@ -1389,7 +1417,7 @@ try {
1389
1417
  }
1390
1418
  } catch (error) {
1391
1419
  setFailed(true)
1392
- setStatus(t("msg.deleteFailed") + ":" + describeError(error))
1420
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.deleteFailed"), detail: describeError(error) }))
1393
1421
  } finally {
1394
1422
  setBusy(false)
1395
1423
  }
@@ -1452,7 +1480,7 @@ try {
1452
1480
  }
1453
1481
  } catch (error) {
1454
1482
  setFailed(true)
1455
- setStatus(t("msg.saveFailed") + ":" + describeError(error))
1483
+ setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.saveFailed"), detail: describeError(error) }))
1456
1484
  } finally {
1457
1485
  setBusy(false)
1458
1486
  }
@@ -1757,6 +1785,11 @@ try {
1757
1785
  className: "cpfe-editor",
1758
1786
  value: draft.prompt,
1759
1787
  spellCheck: false,
1788
+ // 忙碌期间只读(issue #6):`update()` 那一层已经会丢弃改动,这里再给一个
1789
+ // **看得见**的信号 —— 否则用户会以为自己在编辑,而字根本没进去。
1790
+ // 用 readOnly 而不是 disabled:仍然可以选中/复制,只是改不了。
1791
+ readOnly: busy === true,
1792
+ "aria-busy": busy === true,
1760
1793
  "aria-label": t("prompt.heading"),
1761
1794
  onChange: (event) => update({ prompt: event.target.value }),
1762
1795
  }),
@@ -1878,7 +1911,7 @@ try {
1878
1911
  ? react.createElement(A.RiskConfirmation, {
1879
1912
  open: deleteOpen,
1880
1913
  title: t("delete.title"),
1881
- description: t("delete.description") + "(" + draft.id + ")",
1914
+ description: fillPlaceholders(t("delete.description"), { id: draft.id }),
1882
1915
  acknowledgeLabel: t("delete.acknowledge"),
1883
1916
  cancelLabel: t("btn.cancel"),
1884
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
@@ -281,7 +281,19 @@ export function readState(rows, id, options = {}) {
281
281
  const meta = readPresetMeta(directory)
282
282
  // 页面要打开时顺便对账:磁盘上的文本若与日志末条不同,说明它在设置页之外被改过
283
283
  // (会话内的 custom_prompt 工具、手工编辑、别处同步)—— 补记一条 external,于是"被改过"看得见。
284
- 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
+ }
285
297
  return {
286
298
  ok: true,
287
299
  id,
@@ -507,7 +519,14 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
507
519
  // 显示名(`rows` 里的 name)优先于内部 id —— 与删除一致。
508
520
  const fromRow = rows.find((item) => item !== null && typeof item === 'object' && item.id === from)
509
521
  if (typeof fromRow?.name === 'string' && fromRow.name.trim() !== '') fromDisplayName = fromRow.name
510
- 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
+ }
511
530
  const sourceMode = modeOf(text)
512
531
  const sourcePrompt = readPrompt(fromDir)
513
532
  source = {
@@ -600,7 +619,10 @@ export function repairComposition(rows, input) {
600
619
  ok: true,
601
620
  id,
602
621
  code: 'repaired',
603
- 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) },
604
626
  note: '已按本机这条 dsh 线关闭 ' + String(bad.length) + ' 个无法解析的行(' + ids + ')。现在这个模式能重新出现在选择器里。',
605
627
  }
606
628
  }
package/locales.mjs CHANGED
@@ -129,7 +129,7 @@ export const zh = {
129
129
  'msg.exported': '已导出为文件。',
130
130
  'msg.exportFailed': '导出失败',
131
131
  'delete.title': '永久删除这个助手?',
132
- 'delete.description': '这会删除磁盘上的模式目录,连同它的系统提示词一起消失,无法撤销。正在使用它的会话不受影响;新建会话时它不再出现在选择器里。',
132
+ 'delete.description': '这会删除磁盘上的模式目录,连同它的系统提示词一起消失,无法撤销。正在使用它的会话不受影响;新建会话时它不再出现在选择器里。({id})',
133
133
  'delete.acknowledge': '我明白这个助手的提示词会被永久删除',
134
134
  'delete.confirm': '永久删除',
135
135
  'delete.close': '关闭',
@@ -168,6 +168,9 @@ export const zh = {
168
168
  'msg.readFailed': '读取失败',
169
169
  'msg.saveFailed': '保存失败',
170
170
  'msg.saved': '已保存。新建会话即生效,当前会话保持原配置。',
171
+ // 把「一句已翻译的话 + 一个底层细节」拼起来时,中间那个标点也必须**按语言**走 ——
172
+ // 硬编码全角冒号会让英文界面在出错那一刻出现中文标点(issue #7)。
173
+ 'status.detail': '{message}:{detail}',
171
174
 
172
175
  // ── row labels ────────────────────────────────────────────────────────────
173
176
  'row.persona.label': '身份(系统提示词)',
@@ -352,7 +355,7 @@ export const en = {
352
355
  'msg.exported': 'Exported to a file.',
353
356
  'msg.exportFailed': 'Export failed',
354
357
  'delete.title': 'Delete this assistant permanently?',
355
- '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})',
356
359
  'delete.acknowledge': 'I understand this assistant\'s prompt will be deleted permanently',
357
360
  'delete.confirm': 'Delete permanently',
358
361
  'delete.close': 'Close',
@@ -394,6 +397,7 @@ export const en = {
394
397
  'msg.readFailed': 'Load failed',
395
398
  'msg.saveFailed': 'Save failed',
396
399
  'msg.saved': 'Saved. A new session picks it up; the current one keeps its configuration.',
400
+ 'status.detail': '{message}: {detail}',
397
401
 
398
402
  // ── row labels ────────────────────────────────────────────────────────────
399
403
  'row.persona.label': 'Identity (system prompt)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "1.9.7",
3
+ "version": "1.9.8",
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",