dsh-custom-mode 1.9.0 → 1.9.2

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
@@ -101,6 +101,7 @@ try {
101
101
  "assistant.short": "每个助手是一个独立模式:自己的系统提示词、基础模式与插件开关。",
102
102
  "name.short": "改名只影响显示,内部标识与已有会话不受影响。",
103
103
  "mode.short": "底子决定「行集合」与工具能力;persona 行始终由本模式替换。",
104
+ "mode.pendingRows": "底子已改为「{mode}」:保存后,下面的行列表会按新底子重算。",
104
105
  "rows.short": "逐行控制挂载哪些插件;没拨过的行保持官方默认。",
105
106
  "prompt.short": "这段文本就是本模式的系统提示词,保存后下一步生效。",
106
107
  "aria.expandHint": "展开完整说明",
@@ -108,12 +109,19 @@ try {
108
109
  "aria.expand": "展开详情",
109
110
  "aria.collapse": "收起详情",
110
111
  "detail.id": "行 id",
112
+ "detail.shipped": "出厂状态",
113
+ "detail.shippedOn": "启用",
114
+ "detail.shippedOff": "关闭",
111
115
  "detail.note": "说明",
112
116
  "detail.state": "开关状态",
113
117
  "detail.explicitOn": "已手动启用",
114
118
  "detail.explicitOff": "已手动停用",
115
119
  "detail.untouched": "未改动(跟随官方默认)",
116
120
  "detail.platform": "平台条件",
121
+ "btn.repair": "按本线修复",
122
+ "msg.repaired": "已修复",
123
+ "meta.version": "插件版本",
124
+ "meta.versionHint": "安装时不钉版本号会受 pnpm 发布冷却期影响(默认 24 小时),可能装到较旧的版本。要换版本请按 README 的钉版本命令重装,然后重启 DSH。",
117
125
  "api.saved": "已保存({name},基础模式 {mode})。新建会话即生效,当前会话保持原配置。",
118
126
  "api.created": "已创建「{name}」。现在可以为它写系统提示词。",
119
127
  "api.duplicated": "已复制自「{from}」。两份从此各改各的。",
@@ -135,6 +143,8 @@ try {
135
143
  "api.deleteFailed": "删除失败:{detail}",
136
144
  "api.versionMissing": "找不到这个版本(历史可能已被上限裁剪)。",
137
145
  "api.badJson": "请求体不是合法 JSON",
146
+ "warn.approvalGateMissing": "审批闸门未启用:本机这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词不会弹审批。见「详情」。",
147
+ "warn.unresolvableRows": "有行在本机这条 DSH 线上无法解析:平台会把整个模式判为 broken,并从新会话的选择器里**静默丢弃**。点右侧的「按本线修复」即可(只关掉那几行,其它选择不动)。",
138
148
  "warn.approvalGateMissing.label": "审批闸门未启用",
139
149
  "warn.approvalGateMissing.hint": "这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词**不会**弹审批。设置页不受影响;要恢复保护请升级 DSH,或把「custom_prompt 工具」那一行关掉。",
140
150
  "warn.personaOffWithPrompt": "「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。",
@@ -210,15 +220,15 @@ try {
210
220
  "row.tool-subagent-control.label": "子代理控制",
211
221
  "row.tool-subagent-list-agents.label": "列出子代理",
212
222
  "row.tool-subagent-codex.label": "Codex 子代理",
213
- "row.tool-subagent-codex.note": "默认关闭:需要先安装对应 Bundle",
223
+ "row.tool-subagent-codex.note": "需要先安装对应 Bundle 才能用;出厂状态见「详情」",
214
224
  "row.tool-subagent-claude-code.label": "Claude Code 子代理",
215
- "row.tool-subagent-claude-code.note": "默认关闭:需要先安装对应 Bundle",
225
+ "row.tool-subagent-claude-code.note": "需要先安装对应 Bundle 才能用;出厂状态见「详情」",
216
226
  "row.workflow-ptc.label": "工作流引擎",
217
227
  "row.workflow-worker-thread.label": "工作流 Worker 线程",
218
228
  "row.workflow-worker-thread.note": "把工作流跑在独立的 worker 线程里",
219
229
  "row.tool-workflow.label": "工作流工具",
220
230
  "row.tool-ralph.label": "Ralph 工作流",
221
- "row.tool-ralph.note": "默认关闭",
231
+ "row.tool-ralph.note": "Ralph 工作流工具;出厂状态见「详情」",
222
232
  "row.tool-web.label": "网页检索与抓取",
223
233
  "row.tool-skill.label": "技能工具",
224
234
  "row.skill-filesystem.label": "技能发现",
@@ -291,6 +301,7 @@ try {
291
301
  "assistant.short": "Each assistant is its own mode: its own system prompt, base mode and plugin switches.",
292
302
  "name.short": "Renaming only changes what is displayed — not the internal id or existing sessions.",
293
303
  "mode.short": "The base decides the row set and tool abilities; the persona row is always replaced by this mode.",
304
+ "mode.pendingRows": "Base changed to 「{mode}」: the row list below is recomputed from the new base when you save.",
294
305
  "rows.short": "Control which plugins this mode mounts, row by row; untouched rows keep the shipped default.",
295
306
  "prompt.short": "Saving this text makes it the system prompt of this mode, and it takes effect on the next step.",
296
307
  "aria.expandHint": "Show the full explanation",
@@ -298,12 +309,19 @@ try {
298
309
  "aria.expand": "Show details",
299
310
  "aria.collapse": "Hide details",
300
311
  "detail.id": "Row id",
312
+ "detail.shipped": "Shipped",
313
+ "detail.shippedOn": "enabled",
314
+ "detail.shippedOff": "disabled",
301
315
  "detail.note": "Note",
302
316
  "detail.state": "Switch",
303
317
  "detail.explicitOn": "Set to on by you",
304
318
  "detail.explicitOff": "Set to off by you",
305
319
  "detail.untouched": "Untouched (follows the shipped default)",
306
320
  "detail.platform": "Platform condition",
321
+ "btn.repair": "Fix for this line",
322
+ "msg.repaired": "Repaired",
323
+ "meta.version": "Plugin version",
324
+ "meta.versionHint": "Installing without a pinned version is subject to pnpm’s release cooldown (24 h by default) and can land on an older release. To change version, reinstall with the pinned command from the README and restart DSH.",
307
325
  "api.saved": "Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.",
308
326
  "api.created": "Created 「{name}」. You can write its system prompt now.",
309
327
  "api.duplicated": "Copied from 「{from}」. The two are independent from now on.",
@@ -328,6 +346,8 @@ try {
328
346
  "warn.personaOffWithPrompt": "The \"Identity (system prompt)\" row is off, so prompt.md is never injected — the prompt you wrote has no effect. Turn the row on, or clear the prompt.",
329
347
  "warn.toolOff": "The \"custom_prompt tool\" row is off: the agent cannot change the prompt from inside a session, only this page can.",
330
348
  "warn.noDescription": "No description: the new-session mode picker will show it as \"no description yet\".",
349
+ "warn.approvalGateMissing": "The approval gate is off: this DSH build has no tools/pre-execute event, so in-session prompt rewrites do not ask for approval. See the details.",
350
+ "warn.unresolvableRows": "Some rows cannot be resolved on this DSH line: the platform marks the whole mode broken and **silently drops it** from the new-session picker. Click 「Fix for this line」 — it only turns those rows off and leaves your other choices alone.",
331
351
  "warn.approvalGateMissing.label": "Approval gate is off",
332
352
  "warn.approvalGateMissing.hint": "This DSH build has no tools/pre-execute event, so in-session prompt rewrites do NOT ask for approval. The settings page is unaffected; upgrade DSH or turn the 「custom_prompt tool」 row off to restore the gate.",
333
353
  "warn.noName": "No name: the mode picker will show the directory id (e.g. custom).",
@@ -400,15 +420,15 @@ try {
400
420
  "row.tool-subagent-control.label": "Subagent control",
401
421
  "row.tool-subagent-list-agents.label": "List subagents",
402
422
  "row.tool-subagent-codex.label": "Codex subagent",
403
- "row.tool-subagent-codex.note": "Off by default: install the matching Bundle first",
423
+ "row.tool-subagent-codex.note": "Needs its Bundle installed first; the shipped state is in the details",
404
424
  "row.tool-subagent-claude-code.label": "Claude Code subagent",
405
- "row.tool-subagent-claude-code.note": "Off by default: install the matching Bundle first",
425
+ "row.tool-subagent-claude-code.note": "Needs its Bundle installed first; the shipped state is in the details",
406
426
  "row.workflow-ptc.label": "Workflow engine",
407
427
  "row.workflow-worker-thread.label": "Workflow worker thread",
408
428
  "row.workflow-worker-thread.note": "Runs workflows on a separate worker thread",
409
429
  "row.tool-workflow.label": "Workflow tool",
410
430
  "row.tool-ralph.label": "Ralph workflow",
411
- "row.tool-ralph.note": "Off by default",
431
+ "row.tool-ralph.note": "The Ralph workflow tool; the shipped state is in the details",
412
432
  "row.tool-web.label": "Web search and fetch",
413
433
  "row.tool-skill.label": "Skill tool",
414
434
  "row.skill-filesystem.label": "Skill discovery",
@@ -600,6 +620,7 @@ try {
600
620
  ".cpfe-hint-detail{flex:1 0 100%;margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}",
601
621
  // 描述:多行、自适应高度(没有多行输入组件,所以用 textarea + 同一批语义变量)
602
622
  ".cpfe-desc{box-sizing:border-box;min-height:56px;max-height:200px;resize:vertical;padding:8px 12px;border-radius:10px;border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;margin-bottom:8px}",
623
+ ".cpfe-base-pending{color:var(--dsw-alias-state-warn-primary)}",
603
624
  ".cpfe-note{display:block;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}",
604
625
  ".cpfe-mono{display:block;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}",
605
626
  ".cpfe-pills{display:flex;flex-wrap:wrap;gap:6px;align-items:center}",
@@ -635,6 +656,8 @@ try {
635
656
  ".cpfe-err{color:var(--dsw-alias-state-error-primary)}",
636
657
  ".cpfe-dirty{color:var(--dsw-alias-state-warn-primary)}",
637
658
  ".cpfe-danger{color:var(--dsw-alias-state-error-primary)}",
659
+ ".cpfe-meta-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;min-width:0}",
660
+ ".cpfe-version{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);white-space:nowrap}",
638
661
  ".cpfe-path{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}",
639
662
  // ── fallback-path controls (unused when the shell provides the atoms) ──
640
663
  ".cpfe-btn{appearance:none;cursor:pointer;padding:0 14px;height:32px;border-radius:8px;border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px}",
@@ -674,6 +697,7 @@ try {
674
697
  create: ROUTE + "/create",
675
698
  delete: ROUTE + "/delete",
676
699
  reorder: ROUTE + "/reorder",
700
+ repair: ROUTE + "/repair",
677
701
  }
678
702
 
679
703
  /** Every assistant this feature manages. */
@@ -882,6 +906,18 @@ try {
882
906
  react.createElement("span", { className: "cpfe-detail-key" }, t("detail.note")),
883
907
  react.createElement("span", { className: "cpfe-detail-value" }, note),
884
908
  ),
909
+ react.createElement(
910
+ "div",
911
+ { className: "cpfe-detail-line" },
912
+ react.createElement("span", { className: "cpfe-detail-key" }, t("detail.shipped")),
913
+ // 出厂状态来自**本机实际文件**(row.disabled),不是写死的文案:同一个行在两条 dsh 线上
914
+ // 的出厂状态可能不同(实测:Ralph 在稳定线出厂是启用的,在预览线是关闭的)。
915
+ react.createElement(
916
+ "span",
917
+ { className: "cpfe-detail-value" },
918
+ row.disabled ? t("detail.shippedOff") : t("detail.shippedOn"),
919
+ ),
920
+ ),
885
921
  react.createElement(
886
922
  "div",
887
923
  { className: "cpfe-detail-line" },
@@ -979,6 +1015,28 @@ try {
979
1015
  * differs per user, so nothing is expanded by default and nothing is persisted.
980
1016
  */
981
1017
  const [expandedRows, setExpandedRows] = react.useState({})
1018
+ /** 本机这条线上有无法解析的行时,一键把它们关掉(服务端复用保存同一条排版手术)。 */
1019
+ const repairRowsNow = async () => {
1020
+ if (draft === null) return
1021
+ setBusy(true)
1022
+ setFailed(false)
1023
+ try {
1024
+ const result = await postJson(ROUTES.repair, { id: selected })
1025
+ if (result !== null && result.ok === true) {
1026
+ setStatus(apiText(result, "msg.repaired"))
1027
+ await reload()
1028
+ } else {
1029
+ setFailed(true)
1030
+ setStatus(apiText(result, "msg.saveFailed"))
1031
+ }
1032
+ } catch {
1033
+ setFailed(true)
1034
+ setStatus(t("msg.saveFailed"))
1035
+ } finally {
1036
+ setBusy(false)
1037
+ }
1038
+ }
1039
+
982
1040
  const toggleRowExpanded = (id) =>
983
1041
  setExpandedRows((previous) => {
984
1042
  const next = { ...previous }
@@ -995,6 +1053,11 @@ try {
995
1053
  /** 描述框:随内容长高,避免双语描述被单行截断。draft 在未选中助手时是 null,依赖项要先取出来。 */
996
1054
  const descriptionRef = react.useRef(null)
997
1055
  const draftDescription = draft === null ? "" : draft.description
1056
+ // 已保存的底子:用来提示"改了但还没保存"—— 行列表是按它渲染的。
1057
+ const savedMode =
1058
+ entry === undefined || entry === null || entry.saved === null || entry.saved === undefined
1059
+ ? null
1060
+ : entry.saved.mode
998
1061
  react.useEffect(() => {
999
1062
  const el = descriptionRef.current
1000
1063
  if (el === null || el === undefined) return
@@ -1574,6 +1637,14 @@ try {
1574
1637
  { className: "cpfe-note" },
1575
1638
  payload.modes.reduce((note, mode) => (mode.id === draft.mode ? t("base." + mode.id + ".note", mode.note) : note), ""),
1576
1639
  ),
1640
+ // 底子改过但还没保存时明说一句:行列表是按**已保存**的组成渲染的,审阅把它记成了"点了没反应"。
1641
+ draft.mode !== savedMode
1642
+ ? react.createElement(
1643
+ "p",
1644
+ { className: "cpfe-note cpfe-base-pending" },
1645
+ fillPlaceholders(t("mode.pendingRows"), { mode: t("base." + draft.mode + ".label", draft.mode) }),
1646
+ )
1647
+ : null,
1577
1648
  ),
1578
1649
  react.createElement(
1579
1650
  "section",
@@ -1729,7 +1800,19 @@ try {
1729
1800
  "div",
1730
1801
  { className: "cpfe-warns" },
1731
1802
  ...draft.warnings.map((code) =>
1732
- react.createElement("p", { key: code, className: "cpfe-warn" }, "⚠ " + t("warn." + code)),
1803
+ react.createElement(
1804
+ "div",
1805
+ { key: code, className: "cpfe-warn-row" },
1806
+ react.createElement("p", { className: "cpfe-warn" }, "⚠ " + t("warn." + code)),
1807
+ // 本线无法解析的行可以一键修(只关掉那几行;用户创建它的那个版本可能早于播种改为派生的版本)。
1808
+ code === "unresolvableRows"
1809
+ ? react.createElement(
1810
+ A.Button,
1811
+ { disabled: busy, onClick: repairRowsNow },
1812
+ t("btn.repair"),
1813
+ )
1814
+ : null,
1815
+ ),
1733
1816
  ),
1734
1817
  ),
1735
1818
  ...editorSections,
@@ -1760,7 +1843,20 @@ try {
1760
1843
  dirty ? t("btn.reloadDiscard") : t("btn.reload"),
1761
1844
  ),
1762
1845
  react.createElement("span", { className: statusClass }, shown),
1763
- react.createElement("span", { className: "cpfe-path" }, editorReady ? payload.compositionPath : ""),
1846
+ react.createElement(
1847
+ "span",
1848
+ { className: "cpfe-meta-line" },
1849
+ // 让用户能自己判断装到的是哪一版:pnpm 的发布冷却期会让"不钉版本"的安装落到旧版
1850
+ // (实测:干净机器上按名安装装到 1.0.1,而 latest 是 1.9.x)。
1851
+ react.createElement(
1852
+ "span",
1853
+ { className: "cpfe-version", title: t("meta.versionHint") },
1854
+ // payload 在"还没读到任何助手"时是 null —— 页脚仍然会渲染,所以必须判空
1855
+ // (同一条错误这一轮被浏览器验收抓到过三次,单元测试一次都看不到)。
1856
+ payload === null ? "" : t("meta.version") + " v" + String(payload.version ?? "?"),
1857
+ ),
1858
+ react.createElement("span", { className: "cpfe-path" }, editorReady ? payload.compositionPath : ""),
1859
+ ),
1764
1860
  ),
1765
1861
  // The confirmation is a portal: rendering it here keeps every piece of this page's
1766
1862
  // state in one component.
package/composition.mjs CHANGED
@@ -387,7 +387,7 @@ export function collectRows(text) {
387
387
  /**
388
388
  * Rewrite one level of rows, applying disabled overrides by id.
389
389
  *
390
- * `overrides` maps a row id to an explicit DISABLED state (already normalised). A row absent from the map is
390
+ * `overrides` maps a row id to its explicit **enabled** state (`true` = on, `false` = off). A row absent is
391
391
  * left byte-for-byte as shipped — that is how an untouched `!!js` platform
392
392
  * condition and the rows that ship disabled survive a regeneration.
393
393
  *
@@ -559,6 +559,81 @@ function yamlScalar(value) {
559
559
  }
560
560
 
561
561
  /** Read the base mode recorded in a generated composition, defaulting to standard. */
562
+ /**
563
+ * Enabled rows whose plugin package cannot be resolved in **this** installation.
564
+ *
565
+ * Why this exists (measured): a preset with an enabled row whose package this dsh line does not ship is marked
566
+ * broken by the platform and **silently dropped from every picker**, while the settings page keeps working — the
567
+ * P0 an external review found. Fresh installs are safe because seeding derives the composition from the installed
568
+ * line, but an assistant created by an **older** version keeps its old file forever (seeding never overwrites user
569
+ * data), so this is how the page can tell the user instead of leaving them with an invisible mode.
570
+ *
571
+ * Relative modules (`./prompt-reader.mjs`, ours) and `cordis:` pseudo-packages (the runtime's) are skipped.
572
+ *
573
+ * @param {string} text - a composition.
574
+ * @returns {Array<{id: string, name: string}>} enabled rows that cannot resolve.
575
+ */
576
+ /**
577
+ * Turn the named rows **off in place**, leaving every other byte of the file alone.
578
+ *
579
+ * Used by the "fix for this line" action: the composition a user has may contain rows this dsh line cannot
580
+ * resolve (typically written by an older version of this plugin), and the platform then drops the whole preset
581
+ * from every picker. Re-rendering the file from the base would also fix it — but it would silently discard any
582
+ * row the user (or a future version) added outside the base. This edits only the offending rows.
583
+ *
584
+ * @param {string} text - the composition.
585
+ * @param {Iterable<string>} ids - row ids to disable.
586
+ * @returns {string} the rewritten composition.
587
+ */
588
+ export function disableRowsInPlace(text, ids) {
589
+ const wanted = [...new Set(ids)]
590
+ if (wanted.length === 0) return text
591
+ // `applyLevel` 把 Map 的值直接交给 `setDisabled(...)`,所以值是**关闭**布尔(true = 关闭)。
592
+ const off = new Map(wanted.map((id) => [id, true]))
593
+ const top = applyLevel(text, true, off, false)
594
+ return applyLevel(top, false, off, false)
595
+ }
596
+
597
+ export function unresolvableRows(text) {
598
+ let root
599
+ try {
600
+ root = join(shippedPresetsDir(), '..', '..', '..')
601
+ } catch {
602
+ return []
603
+ }
604
+ // **判断不了就不要报警**:如果这个根下根本没有 node_modules(例如测试用的是一个临时出厂目录),
605
+ // 那么"查不到某个包"只说明我们不知道,不说明那行坏了。误报的代价是用户被引导去关掉本来正常的行。
606
+ if (existsSync(join(root, '@deepseek-ai')) === false) return []
607
+ const out = []
608
+ const lines = text.split('\n')
609
+ for (let index = 0; index < lines.length; index += 1) {
610
+ const row = /^( {0,4})- id: (.+?)\s*$/.exec(lines[index])
611
+ if (row === null) continue
612
+ const indent = row[1].length
613
+ let name
614
+ let disabled = false
615
+ for (let next = index + 1; next < lines.length; next += 1) {
616
+ const line = lines[next]
617
+ if (line.trim() !== '' && line.search(/\S/) <= indent) break
618
+ if (/^\s+name: ['"]?(.+?)['"]?\s*$/.test(line)) name = /^\s+name: ['"]?(.+?)['"]?\s*$/.exec(line)[1]
619
+ // 平台条件行(`disabled: !!js …`)必须**求值**判定:只看有没有字面 `true` 会把"本平台已启用"的行
620
+ // 误判成关闭,也会把"本平台本来就关闭"的行(如 Windows 专用的 pwsh)误报成"无法解析"。
621
+ const flag = /^\s+disabled:\s*(.+?)\s*$/.exec(line)
622
+ if (flag !== null) {
623
+ // 字面量直接读,`!!js` 一类交给求值器(它对字面量不做布尔化)。
624
+ const raw = flag[1].replace(/^['"]|['"]$/g, '')
625
+ disabled = raw === 'true' ? true : raw === 'false' ? false : evalDisabledExpression(flag[1]) === true
626
+ }
627
+ }
628
+ if (name === undefined || disabled) continue
629
+ if (name.startsWith('.') || name.startsWith('cordis:')) continue
630
+ const parts = name.split('/')
631
+ const pkg = name.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
632
+ if (existsSync(join(root, pkg)) === false) out.push({ id: row[2], name })
633
+ }
634
+ return out
635
+ }
636
+
562
637
  export function modeOf(text) {
563
638
  const match = /^# 基础模式: (\S+)\s*$/m.exec(text)
564
639
  const id = match === null ? undefined : match[1]
package/index.mjs CHANGED
@@ -44,11 +44,13 @@ import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR } from './paths.m
44
44
  import {
45
45
  BASE_MODES,
46
46
  collectRows,
47
- renderComposition,
47
+ disableRowsInPlace,
48
48
  modeOf,
49
49
  overridesOf,
50
50
  readBaseComposition,
51
+ renderComposition,
51
52
  setShippedPresetsDir,
53
+ unresolvableRows,
52
54
  } from './composition.mjs'
53
55
  import { readPresetMeta, writePresetMeta, presetMetaPath, PRESET_META_PATH } from './meta.mjs'
54
56
  import { listHistory, readVersion, recordExternalChange, recordPrompt, HISTORY_SOURCE } from './journal.mjs'
@@ -72,6 +74,7 @@ const STATE_PATH = ROUTE_PATH + '/state'
72
74
  const CREATE_PATH = ROUTE_PATH + '/create'
73
75
  const DELETE_PATH = ROUTE_PATH + '/delete'
74
76
  const REORDER_PATH = ROUTE_PATH + '/reorder'
77
+ const REPAIR_PATH = ROUTE_PATH + '/repair'
75
78
  const HISTORY_PATH = ROUTE_PATH + '/history'
76
79
 
77
80
  /** Which verbs each endpoint answers. `undefined` for a path means 404. */
@@ -82,6 +85,7 @@ const METHODS = {
82
85
  [CREATE_PATH]: ['POST'],
83
86
  [DELETE_PATH]: ['POST'],
84
87
  [REORDER_PATH]: ['POST'],
88
+ [REPAIR_PATH]: ['POST'],
85
89
  }
86
90
 
87
91
  /** 只告警一次:避免每个请求都刷同一行日志。 */
@@ -90,6 +94,21 @@ let warnedRosterShape = false
90
94
  /** 应用层请求体上限;平台的 buffered cap 是第一道,这个是我们自己的兜底(见 handler 里的注释)。 */
91
95
  const MAX_BODY_BYTES = 4 * 1024 * 1024
92
96
 
97
+ /**
98
+ * 本插件的版本。
99
+ *
100
+ * 页面把它显示出来,是因为**用户很难自己判断装到的是哪一版**:pnpm 的发布冷却期(默认 24 小时)会让
101
+ * 不钉版本的安装落到"超过 24 小时的最新版",实测在一台干净机器上 `dsh plugin add dsh-custom-mode`
102
+ * 装到的是 1.0.1 而不是最新的 1.9.x。看见版本号,用户才知道要不要按 README 的钉版本命令重装。
103
+ */
104
+ const PLUGIN_VERSION = (() => {
105
+ try {
106
+ return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version
107
+ } catch {
108
+ return 'unknown'
109
+ }
110
+ })()
111
+
93
112
  /** Longest display name / description the page accepts, so one paste cannot bloat every picker. */
94
113
  const MAX_NAME = 80
95
114
  const MAX_DESCRIPTION = 400
@@ -278,12 +297,17 @@ export function readState(rows, id, options = {}) {
278
297
  factoryPrompt: typeof options.factoryPrompt === 'string' ? options.factoryPrompt : null,
279
298
  // 改动历史(只有元数据,正文按需取:见 GET /custom-mode/history)。
280
299
  history: listHistory(directory),
300
+ // 本插件版本 + 本机装的那条 dsh 线上无法解析的行(页面据此显示版本与"按本线修复")。
301
+ version: PLUGIN_VERSION,
302
+ unresolvable: unresolvableRows(text),
281
303
  // 「配置了却不生效」的告警码(文案在页面侧按语言渲染)。
282
304
  warnings: [
283
305
  ...configWarnings(text, prompt.ok === true ? prompt.text : '', meta),
284
306
  // 审批闸门缺失:由预置侧在注册失败时留下标记文件(宿主半看不到那个事件是否真的有人监听)。
285
307
  // 诚实地把它变成页面上的告警,而不是只留在 console.error 里。
286
308
  ...(existsSync(join(directory, 'approval-gate-missing')) ? ['approvalGateMissing'] : []),
309
+ // 本线无法解析的启用行 = 平台会把整个预设判为 broken 并从选择器里丢掉(曾经的 P0)。
310
+ ...(unresolvableRows(text).length > 0 ? ['unresolvableRows'] : []),
287
311
  ],
288
312
  }
289
313
  }
@@ -525,6 +549,42 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
525
549
  * @param {object} input - `{ id }`.
526
550
  * @param {{remove: (id: string) => Promise<void>}} agentPresets - the roster service.
527
551
  */
552
+ /**
553
+ * 把"本机这条 dsh 线上无法解析的启用行"关掉,让预设重新健康。
554
+ *
555
+ * 为什么需要它:老版本创建(或老版本播种)的组成文件会一直留着 —— 播种只补缺失文件、从不覆盖用户数据。
556
+ * 如果那份文件里有一行启用了本线不提供的插件,平台会把整个预设判为 broken 并从所有选择器里**静默丢弃**
557
+ * (设置页照常能开,所以用户完全不知道)。这里复用与保存同一条排版手术:读出现有 overrides,把那几行显式
558
+ * 关闭后重新渲染。用户的其它选择一字不动。
559
+ */
560
+ export function repairComposition(rows, input) {
561
+ const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
562
+ const directory = assistantDir(rows, id)
563
+ if (directory === undefined) return unknownAssistant(id)
564
+ const file = compositionFile(directory)
565
+ if (!existsSync(file)) return { ok: false, code: 'compositionMissing', params: { path: file }, error: '找不到组成文件:' + file }
566
+ const text = readFileSync(file, 'utf8')
567
+ const bad = unresolvableRows(text)
568
+ if (bad.length === 0) {
569
+ return { ok: true, id, code: 'repairNotNeeded', note: '这个助手在本机没有无法解析的行,无需修复。' }
570
+ }
571
+ // **就地**关闭那几行,不重渲染:重渲染会顺手丢掉 base 之外的自有行,而用户的数据不该被这样动。
572
+ const rendered = disableRowsInPlace(text, bad.map((row) => row.id))
573
+ try {
574
+ writeAtomic(file, rendered)
575
+ } catch (error) {
576
+ return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
577
+ }
578
+ const ids = bad.map((row) => row.id).join('、')
579
+ return {
580
+ ok: true,
581
+ id,
582
+ code: 'repaired',
583
+ params: { count: bad.length, ids },
584
+ note: '已按本机这条 dsh 线关闭 ' + String(bad.length) + ' 个无法解析的行(' + ids + ')。现在这个模式能重新出现在选择器里。',
585
+ }
586
+ }
587
+
528
588
  export async function deleteAssistant(rows, input, agentPresets) {
529
589
  const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
530
590
  if (assistantDir(rows, id) === undefined) return unknownAssistant(id)
@@ -717,13 +777,19 @@ export function apply(ctx) {
717
777
  // an unbounded amount; the platform's cap remains the primary guard.
718
778
  const declared = Number(request.headers.get('content-length') ?? '')
719
779
  if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
720
- return json({ ok: false, error: `请求体过大(上限 ${String(MAX_BODY_BYTES)} 字节)` }, 413)
780
+ return json({ ok: false, code: 'bodyTooLarge', params: { max: MAX_BODY_BYTES }, error: `请求体过大(上限 ${String(MAX_BODY_BYTES)} 字节)` }, 413)
721
781
  }
722
782
 
723
783
  // Everything below writes, so the body is read and parsed exactly once.
724
784
  let parsed
725
785
  try {
726
- parsed = await request.json()
786
+ // 只信 `content-length` 会被 chunked(或不带该头)的请求绕过 —— 所以读完再按**实际字节**判一次。
787
+ // 平台自己的 buffered cap 仍是第一道;这一道保证"我们绝不 buffer 一个无上限的请求体"。
788
+ const raw = await request.text()
789
+ if (Buffer.byteLength(raw, 'utf8') > MAX_BODY_BYTES) {
790
+ return json({ ok: false, code: 'bodyTooLarge', params: { max: MAX_BODY_BYTES }, error: `请求体过大(上限 ${String(MAX_BODY_BYTES)} 字节)` }, 413)
791
+ }
792
+ parsed = JSON.parse(raw)
727
793
  } catch {
728
794
  return json({ ok: false, code: 'badJson', error: '请求体不是合法 JSON' }, 400)
729
795
  }
@@ -738,6 +804,10 @@ export function apply(ctx) {
738
804
  const result = await serializedWrite('tree', async () => createAssistant(await roster(), parsed))
739
805
  return json(result, result.ok === true ? 200 : 400)
740
806
  }
807
+ if (pathname === REPAIR_PATH) {
808
+ const result = await serializedWrite('repair:' + targetId, async () => repairComposition(await roster(), parsed))
809
+ return json(result, result.ok === true ? 200 : 400)
810
+ }
741
811
  if (pathname === REORDER_PATH) {
742
812
  const result = await serializedWrite('tree', async () => reorderAssistant(await roster(), parsed))
743
813
  return json(result, result.ok === true ? 200 : 400)
package/journal.mjs CHANGED
@@ -25,27 +25,9 @@
25
25
  */
26
26
  import { randomBytes } from 'node:crypto'
27
27
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
28
+ import { writeAtomic } from './atomic.mjs'
28
29
  import { join } from 'node:path'
29
30
 
30
- /**
31
- * `rename` 带重试(与宿主半同一实现,见 editor/index.mjs 的说明)。
32
- *
33
- * 这两个文件刻意各留一份:preset 侧的模块是独立分发的,不能 import 宿主半 —— 与 `checkPromptText`
34
- * 同样的取舍。journal 只在宿主半写,所以这里只需与宿主半保持一致的重试策略。
35
- */
36
- function renameWithRetry(from, to, attempts = 5) {
37
- for (let attempt = 1; ; attempt += 1) {
38
- try {
39
- renameSync(from, to)
40
- return
41
- } catch (error) {
42
- const code = error !== null && typeof error === 'object' ? error.code : undefined
43
- const retryable = code === 'EPERM' || code === 'EBUSY' || code === 'EACCES'
44
- if (retryable !== true || attempt >= attempts) throw error
45
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * attempt)
46
- }
47
- }
48
- }
49
31
 
50
32
  /** 每个助手目录下的日志文件名。 */
51
33
  export const HISTORY_NAME = 'prompt-history.jsonl'
@@ -121,18 +103,9 @@ export function recordPrompt(directory, text, by = HISTORY_SOURCE.settings) {
121
103
  const file = historyFile(directory)
122
104
  // 与宿主半同一条纪律:随机临时名(消除 tmp-vs-tmp 碰撞)+ 重试(Windows 上目标被并发 rename
123
105
  // 持有时会短暂 EPERM)+ 失败清理。
124
- const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
125
- writeFileSync(temporary, lines.join('\n') + '\n', 'utf8')
126
- try {
127
- renameWithRetry(temporary, file)
128
- } catch (error) {
129
- try {
130
- rmSync(temporary, { force: true })
131
- } catch {
132
- /* 不掩盖原始错误 */
133
- }
134
- throw error
135
- }
106
+ // 与宿主半共用同一份实现(editor/atomic.mjs):这里原先自己留了一份重试与清理,两份会各自漂移
107
+ // —— 外部审阅点名了这一点。预设侧(prompt-tool.mjs)仍保留自己的副本,因为那个文件独立分发、不能 import 宿主半。
108
+ writeAtomic(file, lines.join('\n') + '\n')
136
109
  return { recorded: true, at, n }
137
110
  }
138
111
 
package/locales.mjs CHANGED
@@ -60,6 +60,7 @@ export const zh = {
60
60
  'assistant.short': '每个助手是一个独立模式:自己的系统提示词、基础模式与插件开关。',
61
61
  'name.short': '改名只影响显示,内部标识与已有会话不受影响。',
62
62
  'mode.short': '底子决定「行集合」与工具能力;persona 行始终由本模式替换。',
63
+ 'mode.pendingRows': '底子已改为「{mode}」:保存后,下面的行列表会按新底子重算。',
63
64
  'rows.short': '逐行控制挂载哪些插件;没拨过的行保持官方默认。',
64
65
  'prompt.short': '这段文本就是本模式的系统提示词,保存后下一步生效。',
65
66
  'aria.expandHint': '展开完整说明',
@@ -67,12 +68,19 @@ export const zh = {
67
68
  'aria.expand': '展开详情',
68
69
  'aria.collapse': '收起详情',
69
70
  'detail.id': '行 id',
71
+ 'detail.shipped': '出厂状态',
72
+ 'detail.shippedOn': '启用',
73
+ 'detail.shippedOff': '关闭',
70
74
  'detail.note': '说明',
71
75
  'detail.state': '开关状态',
72
76
  'detail.explicitOn': '已手动启用',
73
77
  'detail.explicitOff': '已手动停用',
74
78
  'detail.untouched': '未改动(跟随官方默认)',
75
79
  'detail.platform': '平台条件',
80
+ 'btn.repair': '按本线修复',
81
+ 'msg.repaired': '已修复',
82
+ 'meta.version': '插件版本',
83
+ 'meta.versionHint': '安装时不钉版本号会受 pnpm 发布冷却期影响(默认 24 小时),可能装到较旧的版本。要换版本请按 README 的钉版本命令重装,然后重启 DSH。',
76
84
  'api.saved': '已保存({name},基础模式 {mode})。新建会话即生效,当前会话保持原配置。',
77
85
  'api.created': '已创建「{name}」。现在可以为它写系统提示词。',
78
86
  'api.duplicated': '已复制自「{from}」。两份从此各改各的。',
@@ -94,6 +102,8 @@ export const zh = {
94
102
  'api.deleteFailed': '删除失败:{detail}',
95
103
  'api.versionMissing': '找不到这个版本(历史可能已被上限裁剪)。',
96
104
  'api.badJson': '请求体不是合法 JSON',
105
+ 'warn.approvalGateMissing': '审批闸门未启用:本机这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词不会弹审批。见「详情」。',
106
+ 'warn.unresolvableRows': '有行在本机这条 DSH 线上无法解析:平台会把整个模式判为 broken,并从新会话的选择器里**静默丢弃**。点右侧的「按本线修复」即可(只关掉那几行,其它选择不动)。',
97
107
  'warn.approvalGateMissing.label': '审批闸门未启用',
98
108
  'warn.approvalGateMissing.hint': '这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词**不会**弹审批。设置页不受影响;要恢复保护请升级 DSH,或把「custom_prompt 工具」那一行关掉。',
99
109
  'warn.personaOffWithPrompt': '「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。',
@@ -180,15 +190,15 @@ export const zh = {
180
190
  'row.tool-subagent-control.label': '子代理控制',
181
191
  'row.tool-subagent-list-agents.label': '列出子代理',
182
192
  'row.tool-subagent-codex.label': 'Codex 子代理',
183
- 'row.tool-subagent-codex.note': '默认关闭:需要先安装对应 Bundle',
193
+ 'row.tool-subagent-codex.note': '需要先安装对应 Bundle 才能用;出厂状态见「详情」',
184
194
  'row.tool-subagent-claude-code.label': 'Claude Code 子代理',
185
- 'row.tool-subagent-claude-code.note': '默认关闭:需要先安装对应 Bundle',
195
+ 'row.tool-subagent-claude-code.note': '需要先安装对应 Bundle 才能用;出厂状态见「详情」',
186
196
  'row.workflow-ptc.label': '工作流引擎',
187
197
  'row.workflow-worker-thread.label': '工作流 Worker 线程',
188
198
  'row.workflow-worker-thread.note': '把工作流跑在独立的 worker 线程里',
189
199
  'row.tool-workflow.label': '工作流工具',
190
200
  'row.tool-ralph.label': 'Ralph 工作流',
191
- 'row.tool-ralph.note': '默认关闭',
201
+ 'row.tool-ralph.note': 'Ralph 工作流工具;出厂状态见「详情」',
192
202
  'row.tool-web.label': '网页检索与抓取',
193
203
  'row.tool-skill.label': '技能工具',
194
204
  'row.skill-filesystem.label': '技能发现',
@@ -266,6 +276,7 @@ export const en = {
266
276
  'assistant.short': 'Each assistant is its own mode: its own system prompt, base mode and plugin switches.',
267
277
  'name.short': 'Renaming only changes what is displayed — not the internal id or existing sessions.',
268
278
  'mode.short': 'The base decides the row set and tool abilities; the persona row is always replaced by this mode.',
279
+ 'mode.pendingRows': 'Base changed to 「{mode}」: the row list below is recomputed from the new base when you save.',
269
280
  'rows.short': 'Control which plugins this mode mounts, row by row; untouched rows keep the shipped default.',
270
281
  'prompt.short': 'Saving this text makes it the system prompt of this mode, and it takes effect on the next step.',
271
282
  'aria.expandHint': 'Show the full explanation',
@@ -273,12 +284,19 @@ export const en = {
273
284
  'aria.expand': 'Show details',
274
285
  'aria.collapse': 'Hide details',
275
286
  'detail.id': 'Row id',
287
+ 'detail.shipped': 'Shipped',
288
+ 'detail.shippedOn': 'enabled',
289
+ 'detail.shippedOff': 'disabled',
276
290
  'detail.note': 'Note',
277
291
  'detail.state': 'Switch',
278
292
  'detail.explicitOn': 'Set to on by you',
279
293
  'detail.explicitOff': 'Set to off by you',
280
294
  'detail.untouched': 'Untouched (follows the shipped default)',
281
295
  'detail.platform': 'Platform condition',
296
+ 'btn.repair': 'Fix for this line',
297
+ 'msg.repaired': 'Repaired',
298
+ 'meta.version': 'Plugin version',
299
+ 'meta.versionHint': 'Installing without a pinned version is subject to pnpm’s release cooldown (24 h by default) and can land on an older release. To change version, reinstall with the pinned command from the README and restart DSH.',
282
300
  'api.saved': 'Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.',
283
301
  'api.created': 'Created 「{name}」. You can write its system prompt now.',
284
302
  'api.duplicated': 'Copied from 「{from}」. The two are independent from now on.',
@@ -303,6 +321,8 @@ export const en = {
303
321
  'warn.personaOffWithPrompt': 'The "Identity (system prompt)" row is off, so prompt.md is never injected — the prompt you wrote has no effect. Turn the row on, or clear the prompt.',
304
322
  'warn.toolOff': 'The "custom_prompt tool" row is off: the agent cannot change the prompt from inside a session, only this page can.',
305
323
  'warn.noDescription': 'No description: the new-session mode picker will show it as "no description yet".',
324
+ 'warn.approvalGateMissing': 'The approval gate is off: this DSH build has no tools/pre-execute event, so in-session prompt rewrites do not ask for approval. See the details.',
325
+ 'warn.unresolvableRows': 'Some rows cannot be resolved on this DSH line: the platform marks the whole mode broken and **silently drops it** from the new-session picker. Click 「Fix for this line」 — it only turns those rows off and leaves your other choices alone.',
306
326
  'warn.approvalGateMissing.label': 'Approval gate is off',
307
327
  'warn.approvalGateMissing.hint': 'This DSH build has no tools/pre-execute event, so in-session prompt rewrites do NOT ask for approval. The settings page is unaffected; upgrade DSH or turn the 「custom_prompt tool」 row off to restore the gate.',
308
328
  'warn.noName': 'No name: the mode picker will show the directory id (e.g. custom).',
@@ -389,15 +409,15 @@ export const en = {
389
409
  'row.tool-subagent-control.label': 'Subagent control',
390
410
  'row.tool-subagent-list-agents.label': 'List subagents',
391
411
  'row.tool-subagent-codex.label': 'Codex subagent',
392
- 'row.tool-subagent-codex.note': 'Off by default: install the matching Bundle first',
412
+ 'row.tool-subagent-codex.note': 'Needs its Bundle installed first; the shipped state is in the details',
393
413
  'row.tool-subagent-claude-code.label': 'Claude Code subagent',
394
- 'row.tool-subagent-claude-code.note': 'Off by default: install the matching Bundle first',
414
+ 'row.tool-subagent-claude-code.note': 'Needs its Bundle installed first; the shipped state is in the details',
395
415
  'row.workflow-ptc.label': 'Workflow engine',
396
416
  'row.workflow-worker-thread.label': 'Workflow worker thread',
397
417
  'row.workflow-worker-thread.note': 'Runs workflows on a separate worker thread',
398
418
  'row.tool-workflow.label': 'Workflow tool',
399
419
  'row.tool-ralph.label': 'Ralph workflow',
400
- 'row.tool-ralph.note': 'Off by default',
420
+ 'row.tool-ralph.note': 'The Ralph workflow tool; the shipped state is in the details',
401
421
  'row.tool-web.label': 'Web search and fetch',
402
422
  'row.tool-skill.label': 'Skill tool',
403
423
  'row.skill-filesystem.label': 'Skill discovery',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
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), choose its base mode, switch plugins row by row, and keep several assistants side by side.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/preset/preset.yml CHANGED
@@ -1,2 +1,2 @@
1
1
  name: 自定义模式
2
- description: 完整编码能力,系统提示词来自 prompt.md,可在设置页随时修改、下一步即生效。 / Full coding ability; the system prompt lives in prompt.md and can be edited in the settings page — it takes effect on the next step.
2
+ description: 在设置页编辑本模式的系统提示词 / Edit this mode's system prompt in Settings.