dsh-custom-mode 1.9.0 → 1.9.3

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/assistants.mjs CHANGED
@@ -30,6 +30,7 @@ import { dirname, join } from 'node:path'
30
30
  import { dshHome, PRESET_DIR } from './paths.mjs'
31
31
  import { readPresetMeta, writePresetMeta } from './meta.mjs'
32
32
  import { writeAtomic } from './atomic.mjs'
33
+ import { unresolvableRows } from './composition.mjs'
33
34
  import { seedPreset, seedPresetWithLog } from './seed.mjs'
34
35
 
35
36
  /**
@@ -303,6 +304,25 @@ export function seedOnActivation({ root, templateDir, composition, log = console
303
304
  for (const error of result.errors) log(`custom-mode: 补全 ${dir} 失败 —— ${error}`)
304
305
  }
305
306
 
307
+ // 启动告警:组成文件里有本机这条线解析不到的行时,平台会把整个预设判为 broken 并从选择器里**静默丢弃**。
308
+ // 以前这件事只在设置页可见 —— 用户从不打开设置页就完全无感知(外部评审实测)。这里至少在宿主日志里喊一声。
309
+ for (const dir of [...existing, join(root, LEGACY_ID)]) {
310
+ try {
311
+ const file = join(dir, 'agent.cordis.yml')
312
+ if (existsSync(file) === false) continue
313
+ const bad = unresolvableRows(readFileSync(file, 'utf8'))
314
+ if (bad.length > 0) {
315
+ log(
316
+ `custom-mode: ${dir} 的组成文件里有 ${String(bad.length)} 行在这条 dsh 线上无法解析` +
317
+ `(${bad.map((row) => row.id).join(', ')})—— 平台会把整个模式从新建会话的选择器里丢弃。` +
318
+ '打开 设置 → 自定义模式 并点「按本线修复」。',
319
+ )
320
+ }
321
+ } catch {
322
+ /* 启动告警失败不能影响激活 */
323
+ }
324
+ }
325
+
306
326
  if (isSeeded(root)) return { created: false, repaired, adopted: false }
307
327
  if (existing.length > 0) {
308
328
  markSeeded(root)
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": "技能发现",
@@ -283,14 +293,15 @@ try {
283
293
  "msg.imported": "Imported into the editor (not saved yet) — review it, then click Save.",
284
294
  "msg.importFailed": "Import failed",
285
295
  "api.badDirection": "Unknown reorder direction: {direction}",
286
- "api.unknownAssistant": "No assistant {id}」: this page only manages the assistants it created (a directory with prompt.md whose composition injects the identity through prompt-reader.mjs).",
287
- "api.alreadyFirst": "{name} is already first.",
288
- "api.alreadyLast": "{name} is already last.",
296
+ "api.unknownAssistant": "No assistant {id}: this page only manages the assistants it created (a directory with prompt.md whose composition injects the identity through prompt-reader.mjs).",
297
+ "api.alreadyFirst": "{name} is already first.",
298
+ "api.alreadyLast": "{name} is already last.",
289
299
  "api.badVariableName": "{variable} is not a valid variable reference: names may use lower-case letters, digits and underscores, and must start with a letter. For a literal brace, use a single opening brace or an unclosed double brace.",
290
300
  "api.unknownVariable": "{variable} is not a registered variable — rendering would fail every request in this mode. Available: {known}.",
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,16 +309,23 @@ 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
- "api.created": "Created {name}」. You can write its system prompt now.",
309
- "api.duplicated": "Copied from {from}」. The two are independent from now on.",
310
- "api.deleted": "Deleted {name}」. Sessions already using it keep running; it no longer appears for new sessions.",
326
+ "api.created": "Created {name}. You can write its system prompt now.",
327
+ "api.duplicated": "Copied from {from}. The two are independent from now on.",
328
+ "api.deleted": "Deleted {name}. Sessions already using it keep running; it no longer appears for new sessions.",
311
329
  "api.reordered": "Order saved: the new-session mode picker follows it.",
312
330
  "api.nameRequired": "Give the new assistant a name first.",
313
331
  "api.nameTooLong": "That name is too long (limit {max} characters).",
@@ -328,8 +346,10 @@ 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
- "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.",
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).",
334
354
  "history.label": "Change history",
335
355
  "history.pick": "Pick a version to load…",
@@ -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
  *
@@ -397,14 +397,17 @@ export function collectRows(text) {
397
397
  * @param {boolean} nested - whether this call rewrites a group's contents.
398
398
  * @returns {string} the rewritten level.
399
399
  */
400
- function applyLevel(text, topLevel, overrides, nested) {
400
+ function applyLevel(text, topLevel, overrides, nested, replacePersona = true) {
401
401
  const { lead, segments } = splitSegments(text, topLevel)
402
402
  if (segments.length === 0) return text
403
403
  const rendered = segments.map((segment) => {
404
404
  // The persona row is always replaced by this feature's own reader row: the
405
405
  // shipped one is a static-string persona whose text cannot be edited, so
406
406
  // keeping it would silently disable the editable prompt.
407
- if (!nested && segment.id === 'persona') {
407
+ // `replacePersona` 只有**重新渲染**时才为真:那时整段 persona 换成我们的读取器行是对的。
408
+ // 但"按本线修复"是**就地**手术,它必须连 persona 段里的注释都原样保留 ——
409
+ // 之前这里无条件替换,导致每次修复都会丢注释 / 或多复制一行身份注释(外部评审实测)。
410
+ if (!nested && segment.id === 'persona' && replacePersona === true) {
408
411
  return setDisabled(PERSONA_ROW, overrides.get('persona'))
409
412
  }
410
413
  let body = segment.text
@@ -559,6 +562,81 @@ function yamlScalar(value) {
559
562
  }
560
563
 
561
564
  /** Read the base mode recorded in a generated composition, defaulting to standard. */
565
+ /**
566
+ * Enabled rows whose plugin package cannot be resolved in **this** installation.
567
+ *
568
+ * Why this exists (measured): a preset with an enabled row whose package this dsh line does not ship is marked
569
+ * broken by the platform and **silently dropped from every picker**, while the settings page keeps working — the
570
+ * P0 an external review found. Fresh installs are safe because seeding derives the composition from the installed
571
+ * line, but an assistant created by an **older** version keeps its old file forever (seeding never overwrites user
572
+ * data), so this is how the page can tell the user instead of leaving them with an invisible mode.
573
+ *
574
+ * Relative modules (`./prompt-reader.mjs`, ours) and `cordis:` pseudo-packages (the runtime's) are skipped.
575
+ *
576
+ * @param {string} text - a composition.
577
+ * @returns {Array<{id: string, name: string}>} enabled rows that cannot resolve.
578
+ */
579
+ /**
580
+ * Turn the named rows **off in place**, leaving every other byte of the file alone.
581
+ *
582
+ * Used by the "fix for this line" action: the composition a user has may contain rows this dsh line cannot
583
+ * resolve (typically written by an older version of this plugin), and the platform then drops the whole preset
584
+ * from every picker. Re-rendering the file from the base would also fix it — but it would silently discard any
585
+ * row the user (or a future version) added outside the base. This edits only the offending rows.
586
+ *
587
+ * @param {string} text - the composition.
588
+ * @param {Iterable<string>} ids - row ids to disable.
589
+ * @returns {string} the rewritten composition.
590
+ */
591
+ export function disableRowsInPlace(text, ids) {
592
+ const wanted = [...new Set(ids)]
593
+ if (wanted.length === 0) return text
594
+ // `applyLevel` 把 Map 的值直接交给 `setDisabled(...)`,所以值是**关闭**布尔(true = 关闭)。
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)
598
+ }
599
+
600
+ export function unresolvableRows(text) {
601
+ let root
602
+ try {
603
+ root = join(shippedPresetsDir(), '..', '..', '..')
604
+ } catch {
605
+ return []
606
+ }
607
+ // **判断不了就不要报警**:如果这个根下根本没有 node_modules(例如测试用的是一个临时出厂目录),
608
+ // 那么"查不到某个包"只说明我们不知道,不说明那行坏了。误报的代价是用户被引导去关掉本来正常的行。
609
+ if (existsSync(join(root, '@deepseek-ai')) === false) return []
610
+ const out = []
611
+ const lines = text.split('\n')
612
+ for (let index = 0; index < lines.length; index += 1) {
613
+ const row = /^( {0,4})- id: (.+?)\s*$/.exec(lines[index])
614
+ if (row === null) continue
615
+ const indent = row[1].length
616
+ let name
617
+ let disabled = false
618
+ for (let next = index + 1; next < lines.length; next += 1) {
619
+ 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]
622
+ // 平台条件行(`disabled: !!js …`)必须**求值**判定:只看有没有字面 `true` 会把"本平台已启用"的行
623
+ // 误判成关闭,也会把"本平台本来就关闭"的行(如 Windows 专用的 pwsh)误报成"无法解析"。
624
+ const flag = /^\s+disabled:\s*(.+?)\s*$/.exec(line)
625
+ if (flag !== null) {
626
+ // 字面量直接读,`!!js` 一类交给求值器(它对字面量不做布尔化)。
627
+ const raw = flag[1].replace(/^['"]|['"]$/g, '')
628
+ disabled = raw === 'true' ? true : raw === 'false' ? false : evalDisabledExpression(flag[1]) === true
629
+ }
630
+ }
631
+ if (name === undefined || disabled) continue
632
+ if (name.startsWith('.') || name.startsWith('cordis:')) continue
633
+ const parts = name.split('/')
634
+ const pkg = name.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
635
+ if (existsSync(join(root, pkg)) === false) out.push({ id: row[2], name })
636
+ }
637
+ return out
638
+ }
639
+
562
640
  export function modeOf(text) {
563
641
  const match = /^# 基础模式: (\S+)\s*$/m.exec(text)
564
642
  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
@@ -142,7 +161,7 @@ export function checkPromptText(text) {
142
161
  return {
143
162
  ok: false,
144
163
  code: 'unknownVariable',
145
- params: { variable: '{{' + variable + '}}', known: KNOWN_VARIABLES.map((item) => '{{' + item + '}}').join('') },
164
+ params: { variable: '{{' + variable + '}}', known: KNOWN_VARIABLES.map((item) => '{{' + item + '}}').join(', ') },
146
165
  error:
147
166
  '保存被拒绝:{{' +
148
167
  variable +
@@ -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
  }
@@ -348,8 +372,12 @@ export function saveState(rows, input) {
348
372
  if (name.length > MAX_NAME) {
349
373
  return { ok: false, code: 'nameTooLong', params: { max: MAX_NAME }, error: '保存被拒绝:模式名称过长(上限 ' + String(MAX_NAME) + ' 个字符)。' }
350
374
  }
375
+ // 请求里**没带** description 时保留原值:API 调用方只改提示词,不该顺手把描述清空(外部评审实测)。
376
+ const meta = readPresetMeta(directory)
351
377
  const rawDescription =
352
- input !== null && typeof input === 'object' && typeof input.description === 'string' ? input.description : ''
378
+ input !== null && typeof input === 'object' && typeof input.description === 'string'
379
+ ? input.description
380
+ : (typeof meta.description === 'string' ? meta.description : '')
353
381
  const description = rawDescription.replace(/\r?\n/g, ' ').trim()
354
382
  if (description.length > MAX_DESCRIPTION) {
355
383
  return { ok: false, code: 'descriptionTooLong', params: { max: MAX_DESCRIPTION }, error: '保存被拒绝:模式描述过长(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
@@ -525,6 +553,42 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
525
553
  * @param {object} input - `{ id }`.
526
554
  * @param {{remove: (id: string) => Promise<void>}} agentPresets - the roster service.
527
555
  */
556
+ /**
557
+ * 把"本机这条 dsh 线上无法解析的启用行"关掉,让预设重新健康。
558
+ *
559
+ * 为什么需要它:老版本创建(或老版本播种)的组成文件会一直留着 —— 播种只补缺失文件、从不覆盖用户数据。
560
+ * 如果那份文件里有一行启用了本线不提供的插件,平台会把整个预设判为 broken 并从所有选择器里**静默丢弃**
561
+ * (设置页照常能开,所以用户完全不知道)。这里复用与保存同一条排版手术:读出现有 overrides,把那几行显式
562
+ * 关闭后重新渲染。用户的其它选择一字不动。
563
+ */
564
+ export function repairComposition(rows, input) {
565
+ const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
566
+ const directory = assistantDir(rows, id)
567
+ if (directory === undefined) return unknownAssistant(id)
568
+ const file = compositionFile(directory)
569
+ if (!existsSync(file)) return { ok: false, code: 'compositionMissing', params: { path: file }, error: '找不到组成文件:' + file }
570
+ const text = readFileSync(file, 'utf8')
571
+ const bad = unresolvableRows(text)
572
+ if (bad.length === 0) {
573
+ return { ok: true, id, code: 'repairNotNeeded', note: '这个助手在本机没有无法解析的行,无需修复。' }
574
+ }
575
+ // **就地**关闭那几行,不重渲染:重渲染会顺手丢掉 base 之外的自有行,而用户的数据不该被这样动。
576
+ const rendered = disableRowsInPlace(text, bad.map((row) => row.id))
577
+ try {
578
+ writeAtomic(file, rendered)
579
+ } catch (error) {
580
+ return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
581
+ }
582
+ const ids = bad.map((row) => row.id).join('、')
583
+ return {
584
+ ok: true,
585
+ id,
586
+ code: 'repaired',
587
+ params: { count: bad.length, ids },
588
+ note: '已按本机这条 dsh 线关闭 ' + String(bad.length) + ' 个无法解析的行(' + ids + ')。现在这个模式能重新出现在选择器里。',
589
+ }
590
+ }
591
+
528
592
  export async function deleteAssistant(rows, input, agentPresets) {
529
593
  const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
530
594
  if (assistantDir(rows, id) === undefined) return unknownAssistant(id)
@@ -717,13 +781,19 @@ export function apply(ctx) {
717
781
  // an unbounded amount; the platform's cap remains the primary guard.
718
782
  const declared = Number(request.headers.get('content-length') ?? '')
719
783
  if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
720
- return json({ ok: false, error: `请求体过大(上限 ${String(MAX_BODY_BYTES)} 字节)` }, 413)
784
+ return json({ ok: false, code: 'bodyTooLarge', params: { max: MAX_BODY_BYTES }, error: `请求体过大(上限 ${String(MAX_BODY_BYTES)} 字节)` }, 413)
721
785
  }
722
786
 
723
787
  // Everything below writes, so the body is read and parsed exactly once.
724
788
  let parsed
725
789
  try {
726
- parsed = await request.json()
790
+ // 只信 `content-length` 会被 chunked(或不带该头)的请求绕过 —— 所以读完再按**实际字节**判一次。
791
+ // 平台自己的 buffered cap 仍是第一道;这一道保证"我们绝不 buffer 一个无上限的请求体"。
792
+ const raw = await request.text()
793
+ if (Buffer.byteLength(raw, 'utf8') > MAX_BODY_BYTES) {
794
+ return json({ ok: false, code: 'bodyTooLarge', params: { max: MAX_BODY_BYTES }, error: `请求体过大(上限 ${String(MAX_BODY_BYTES)} 字节)` }, 413)
795
+ }
796
+ parsed = JSON.parse(raw)
727
797
  } catch {
728
798
  return json({ ok: false, code: 'badJson', error: '请求体不是合法 JSON' }, 400)
729
799
  }
@@ -738,6 +808,10 @@ export function apply(ctx) {
738
808
  const result = await serializedWrite('tree', async () => createAssistant(await roster(), parsed))
739
809
  return json(result, result.ok === true ? 200 : 400)
740
810
  }
811
+ if (pathname === REPAIR_PATH) {
812
+ const result = await serializedWrite('repair:' + targetId, async () => repairComposition(await roster(), parsed))
813
+ return json(result, result.ok === true ? 200 : 400)
814
+ }
741
815
  if (pathname === REORDER_PATH) {
742
816
  const result = await serializedWrite('tree', async () => reorderAssistant(await roster(), parsed))
743
817
  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': '技能发现',
@@ -258,14 +268,15 @@ export const en = {
258
268
  'msg.imported': 'Imported into the editor (not saved yet) — review it, then click Save.',
259
269
  'msg.importFailed': 'Import failed',
260
270
  'api.badDirection': 'Unknown reorder direction: {direction}',
261
- 'api.unknownAssistant': 'No assistant {id}」: this page only manages the assistants it created (a directory with prompt.md whose composition injects the identity through prompt-reader.mjs).',
262
- 'api.alreadyFirst': '{name} is already first.',
263
- 'api.alreadyLast': '{name} is already last.',
271
+ 'api.unknownAssistant': 'No assistant {id}: this page only manages the assistants it created (a directory with prompt.md whose composition injects the identity through prompt-reader.mjs).',
272
+ 'api.alreadyFirst': '{name} is already first.',
273
+ 'api.alreadyLast': '{name} is already last.',
264
274
  'api.badVariableName': '{variable} is not a valid variable reference: names may use lower-case letters, digits and underscores, and must start with a letter. For a literal brace, use a single opening brace or an unclosed double brace.',
265
275
  'api.unknownVariable': '{variable} is not a registered variable — rendering would fail every request in this mode. Available: {known}.',
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,16 +284,23 @@ 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
- 'api.created': 'Created {name}」. You can write its system prompt now.',
284
- 'api.duplicated': 'Copied from {from}」. The two are independent from now on.',
285
- 'api.deleted': 'Deleted {name}」. Sessions already using it keep running; it no longer appears for new sessions.',
301
+ 'api.created': 'Created {name}. You can write its system prompt now.',
302
+ 'api.duplicated': 'Copied from {from}. The two are independent from now on.',
303
+ 'api.deleted': 'Deleted {name}. Sessions already using it keep running; it no longer appears for new sessions.',
286
304
  'api.reordered': 'Order saved: the new-session mode picker follows it.',
287
305
  'api.nameRequired': 'Give the new assistant a name first.',
288
306
  'api.nameTooLong': 'That name is too long (limit {max} characters).',
@@ -303,8 +321,10 @@ 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
- '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.',
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).',
309
329
  'history.label': 'Change history',
310
330
  'history.pick': 'Pick a version to load…',
@@ -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.3",
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",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  "publishConfig": {
67
67
  "access": "public",
68
- "tag": "alpha"
68
+ "tag": "latest"
69
69
  },
70
70
  "peerDependenciesMeta": {
71
71
  "@deepseek-ai/dsh": {
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.
package/seed.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  * 3. **Idempotent.** A second activation writes nothing and changes nothing.
22
22
  */
23
23
 
24
- import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
24
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
25
25
  import { renderComposition } from './composition.mjs'
26
26
  import { writeAtomic } from './atomic.mjs'
27
27
  import { dirname, join } from 'node:path'
@@ -91,8 +91,19 @@ export function starterComposition(options = {}) {
91
91
  * @param {{composition?: string|null}} [options] - a rendered composition to write instead of copying
92
92
  * `agent.cordis.yml`; see {@link starterComposition}.
93
93
  */
94
+ /**
95
+ * 我们自己的**代码**模块(相对于"用户数据")。
96
+ *
97
+ * 这两个文件是随助手目录一起分发的**代码**,不是用户内容:老版本创建的助手会一直留着旧副本,
98
+ * 于是 1.0.x / 1.1.x 首装的用户即使把插件升到最新,会话内改写提示词的**审批闸门仍然是缺的** ——
99
+ * 外部评审实测到了这一点(P1 安全)。所以它们在升级时**刷新**。
100
+ * `prompt.md` / `preset.yml` / `agent.cordis.yml` 是用户数据(提示词、开关、名字),保持"只补不缺"。
101
+ */
102
+ const REFRESHABLE_MODULES = ['prompt-reader.mjs', 'prompt-tool.mjs']
103
+
94
104
  export function seedPreset(presetDir, sourceDir = packagedPresetDir(), options = {}) {
95
105
  const created = []
106
+ const refreshed = []
96
107
  const kept = []
97
108
  const errors = []
98
109
 
@@ -111,7 +122,22 @@ export function seedPreset(presetDir, sourceDir = packagedPresetDir(), options =
111
122
 
112
123
  for (const name of PRESET_FILES) {
113
124
  const target = join(presetDir, name)
125
+ const source = join(sourceDir, name)
114
126
  if (existsSync(target)) {
127
+ // 代码模块:内容与包内不一致就升级(用原子写,带 Windows 重试)。
128
+ if (REFRESHABLE_MODULES.includes(name) && existsSync(source)) {
129
+ try {
130
+ const shipped = readFileSync(source, 'utf8')
131
+ if (readFileSync(target, 'utf8') !== shipped) {
132
+ writeAtomic(target, shipped)
133
+ refreshed.push(name)
134
+ continue
135
+ }
136
+ } catch (error) {
137
+ errors.push(`刷新 ${name} 失败: ${describe(error)}`)
138
+ continue
139
+ }
140
+ }
115
141
  kept.push(name)
116
142
  continue
117
143
  }
@@ -126,19 +152,19 @@ export function seedPreset(presetDir, sourceDir = packagedPresetDir(), options =
126
152
  continue
127
153
  }
128
154
  }
129
- const source = join(sourceDir, name)
130
155
  if (!existsSync(source)) {
131
156
  errors.push(`包内缺少 ${name}`)
132
157
  continue
133
158
  }
134
159
  try {
135
- copyFileSync(source, target)
160
+ // 原子写(内部带 Windows 重试):原先裸 copyFileSync 在两个实例共抢一个 DSH_HOME 首启时会 EBUSY。
161
+ writeAtomic(target, readFileSync(source, 'utf8'))
136
162
  created.push(name)
137
163
  } catch (error) {
138
164
  errors.push(`写入 ${name} 失败: ${describe(error)}`)
139
165
  }
140
166
  }
141
- return { created, kept, errors }
167
+ return { created, refreshed, kept, errors }
142
168
  }
143
169
 
144
170
  /**
@@ -163,6 +189,9 @@ export function seedPresetWithLog(presetDir, log = console.error, info = console
163
189
  log(`custom-mode: 播种 preset 时出现意外错误(已忽略): ${describe(error)}`)
164
190
  return { created: [], kept: [], errors: [describe(error)] }
165
191
  }
192
+ if (result.refreshed !== undefined && result.refreshed.length > 0) {
193
+ info(`custom-mode: 已刷新 ${presetDir} 里的代码模块(${result.refreshed.join(', ')})`)
194
+ }
166
195
  if (result.created.length > 0) {
167
196
  info(`custom-mode: 已播种 preset 到 ${presetDir}(新建 ${result.created.length} 个文件: ${result.created.join(', ')})`)
168
197
  }