dsh-custom-mode 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assistants.mjs CHANGED
@@ -334,14 +334,14 @@ export function reorderAssistant(rows, input, write = writePresetMeta) {
334
334
  const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
335
335
  const direction = input !== null && typeof input === 'object' ? input.direction : undefined
336
336
  if (direction !== 'up' && direction !== 'down') {
337
- return { ok: false, error: '未知的排序方向:' + String(direction) }
337
+ return { ok: false, code: 'badDirection', params: { direction: String(direction) }, error: '未知的排序方向:' + String(direction) }
338
338
  }
339
339
  const list = assistantsFromRoster(rows)
340
340
  const index = list.findIndex((item) => item.id === id)
341
- if (index === -1) return { ok: false, error: '找不到助手「' + id + '」。' }
341
+ if (index === -1) return { ok: false, code: 'unknownAssistant', params: { id }, error: '找不到助手「' + id + '」。' }
342
342
  const target = direction === 'up' ? index - 1 : index + 1
343
- if (target < 0) return { ok: false, error: '「' + (list[index].name || id) + '」已经在最前面。' }
344
- if (target >= list.length) return { ok: false, error: '「' + (list[index].name || id) + '」已经在最后面。' }
343
+ if (target < 0) return { ok: false, code: 'alreadyFirst', params: { name: list[index].name || id }, error: '「' + (list[index].name || id) + '」已经在最前面。' }
344
+ if (target >= list.length) return { ok: false, code: 'alreadyLast', params: { name: list[index].name || id }, error: '「' + (list[index].name || id) + '」已经在最后面。' }
345
345
 
346
346
  const next = [...list]
347
347
  const [moved] = next.splice(index, 1)
@@ -357,6 +357,7 @@ export function reorderAssistant(rows, input, write = writePresetMeta) {
357
357
  ok: true,
358
358
  id,
359
359
  order: next.map((item) => item.id),
360
+ code: 'reordered',
360
361
  note: '顺序已保存:新建会话时的模式选择器按这个顺序排列。',
361
362
  }
362
363
  }
package/client.js CHANGED
@@ -92,6 +92,35 @@ try {
92
92
  "msg.reorderFailed": "调整顺序失败",
93
93
  "msg.imported": "已导入到编辑器(还没有保存):检查后点「保存」。",
94
94
  "msg.importFailed": "导入失败",
95
+ "api.badDirection": "未知的排序方向:{direction}",
96
+ "api.unknownAssistant": "找不到助手「{id}」:这一页只管理本工具创建的助手(目录里有 prompt.md,且组成文件用 prompt-reader.mjs 注入身份)。",
97
+ "api.alreadyFirst": "「{name}」已经在最前面。",
98
+ "api.alreadyLast": "「{name}」已经在最后面。",
99
+ "api.saved": "已保存({name},基础模式 {mode})。新建会话即生效,当前会话保持原配置。",
100
+ "api.created": "已创建「{name}」。现在可以为它写系统提示词。",
101
+ "api.duplicated": "已复制自「{from}」。两份从此各改各的。",
102
+ "api.deleted": "已删除「{name}」。正在使用它的会话不受影响;新建会话时它不再出现。",
103
+ "api.reordered": "顺序已保存:新建会话时的模式选择器按这个顺序排列。",
104
+ "api.nameRequired": "请先给新助手起个名字。",
105
+ "api.nameTooLong": "名字太长了(上限 {max} 个字符)。",
106
+ "api.descriptionTooLong": "描述太长了(上限 {max} 个字符)。",
107
+ "api.promptEmpty": "保存被拒绝:系统提示词为空。留空不会清空身份,读取器会沿用上一版。",
108
+ "api.badMode": "未知的基础模式:{mode}",
109
+ "api.dirExists": "目录已存在,请换一个名字:{path}",
110
+ "api.compositionMissing": "找不到组成文件:{path}",
111
+ "api.writeFailed": "写入失败:{detail}",
112
+ "api.promptWriteFailed": "写入提示词失败:{detail}",
113
+ "api.renderFailed": "生成组成文件失败:{detail}",
114
+ "api.selfCheckFailed": "生成结果自检失败,已放弃写入:{detail}",
115
+ "api.promptReadFailed": "读取提示词失败:{detail}",
116
+ "api.noRemoveApi": "当前 DSH 版本没有 agentPresets.remove(),无法删除。",
117
+ "api.deleteFailed": "删除失败:{detail}",
118
+ "api.versionMissing": "找不到这个版本(历史可能已被上限裁剪)。",
119
+ "api.badJson": "请求体不是合法 JSON",
120
+ "warn.personaOffWithPrompt": "「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。",
121
+ "warn.toolOff": "「custom_prompt 工具」这一行是关的:会话里无法让 agent 改提示词,只能在本页改。",
122
+ "warn.noDescription": "没有描述:新建会话的模式选择器里会显示成「暂无描述」。",
123
+ "warn.noName": "没有名字:模式选择器里会显示成目录 id(例如 custom)。",
95
124
  "history.label": "改动历史",
96
125
  "history.pick": "选择要载入的版本…",
97
126
  "history.load": "载入这一版",
@@ -231,6 +260,35 @@ try {
231
260
  "msg.reorderFailed": "Could not reorder",
232
261
  "msg.imported": "Imported into the editor (not saved yet) — review it, then click Save.",
233
262
  "msg.importFailed": "Import failed",
263
+ "api.badDirection": "Unknown reorder direction: {direction}",
264
+ "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).",
265
+ "api.alreadyFirst": "「{name}」 is already first.",
266
+ "api.alreadyLast": "「{name}」 is already last.",
267
+ "api.saved": "Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.",
268
+ "api.created": "Created 「{name}」. You can write its system prompt now.",
269
+ "api.duplicated": "Copied from 「{from}」. The two are independent from now on.",
270
+ "api.deleted": "Deleted 「{name}」. Sessions already using it keep running; it no longer appears for new sessions.",
271
+ "api.reordered": "Order saved: the new-session mode picker follows it.",
272
+ "api.nameRequired": "Give the new assistant a name first.",
273
+ "api.nameTooLong": "That name is too long (limit {max} characters).",
274
+ "api.descriptionTooLong": "That description is too long (limit {max} characters).",
275
+ "api.promptEmpty": "Save rejected: the system prompt is empty. Emptying it would not clear the identity — the reader keeps the last good text.",
276
+ "api.badMode": "Unknown base mode: {mode}",
277
+ "api.dirExists": "That directory already exists — pick another name: {path}",
278
+ "api.compositionMissing": "Composition file not found: {path}",
279
+ "api.writeFailed": "Write failed: {detail}",
280
+ "api.promptWriteFailed": "Writing the prompt failed: {detail}",
281
+ "api.renderFailed": "Rendering the composition failed: {detail}",
282
+ "api.selfCheckFailed": "The generated composition failed its self-check, so nothing was written: {detail}",
283
+ "api.promptReadFailed": "Reading the prompt failed: {detail}",
284
+ "api.noRemoveApi": "This DSH build has no agentPresets.remove(), so deleting is unavailable.",
285
+ "api.deleteFailed": "Delete failed: {detail}",
286
+ "api.versionMissing": "That version is gone (the history is capped).",
287
+ "api.badJson": "The request body is not valid JSON",
288
+ "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.",
289
+ "warn.toolOff": "The \"custom_prompt tool\" row is off: the agent cannot change the prompt from inside a session, only this page can.",
290
+ "warn.noDescription": "No description: the new-session mode picker will show it as \"no description yet\".",
291
+ "warn.noName": "No name: the mode picker will show the directory id (e.g. custom).",
234
292
  "history.label": "Change history",
235
293
  "history.pick": "Pick a version to load…",
236
294
  "history.load": "Load this version",
@@ -612,6 +670,8 @@ try {
612
670
  // 改动历史:列表来自 state(只有元数据),正文点「载入这一版」时按需取。
613
671
  history: Array.isArray(state.history) ? state.history : [],
614
672
  historyPick: "",
673
+ // 「配置了却不生效」的告警码;文案按当前语言渲染。
674
+ warnings: Array.isArray(state.warnings) ? state.warnings : [],
615
675
  }
616
676
  }
617
677
 
@@ -710,6 +770,32 @@ try {
710
770
  */
711
771
  const t = (key, fallback) => translate(key, fallback, shellT)
712
772
 
773
+ /** 把 `{name}` 这类占位符换成 params 里的值(缺失就原样留着,便于发现漏参)。 */
774
+ const fillPlaceholders = (template, params) =>
775
+ template.replace(/\{(\w+)\}/g, (match, key) =>
776
+ params !== null && typeof params === "object" && params[key] !== undefined ? String(params[key]) : match,
777
+ )
778
+
779
+ /**
780
+ * 服务端结果的**本地化渲染**。
781
+ *
782
+ * 宿主仍然回中文的 `note`/`error`(那是 HTTP API 的兼容面),但页面优先用自己的词典按 `code`
783
+ * 渲染 —— 否则英文界面会在出错那一刻掉回中文(外部审阅点名的"硬伤")。服务端若带来了页面无从
784
+ * 知道的细节(路径、底层错误),放在 `params` 里填进模板。没有 `code` 时退回宿主文案,兼容旧宿主。
785
+ */
786
+ const apiText = (result, fallbackKey) => {
787
+ const code = result !== null && typeof result === "object" && typeof result.code === "string" ? result.code : ""
788
+ if (code !== "") {
789
+ const template = translate("api." + code, "", shellT)
790
+ if (template !== "") return fillPlaceholders(template, result.params)
791
+ }
792
+ if (result !== null && typeof result === "object") {
793
+ if (typeof result.error === "string" && result.error !== "") return result.error
794
+ if (typeof result.note === "string" && result.note !== "") return result.note
795
+ }
796
+ return t(fallbackKey)
797
+ }
798
+
713
799
  const [list, setList] = react.useState(null)
714
800
  const [selected, setSelected] = react.useState("")
715
801
  /** id → { payload, saved, value }: loaded state, baseline and live draft. */
@@ -754,7 +840,7 @@ try {
754
840
  const result = await fetchState(id)
755
841
  if (result === null || result === undefined || result.ok !== true) {
756
842
  setFailed(true)
757
- setStatus((result && result.error) || t("msg.readFailed"))
843
+ setStatus(apiText(result, "msg.readFailed"))
758
844
  return
759
845
  }
760
846
  const fresh = draftOf(result)
@@ -771,7 +857,7 @@ try {
771
857
  const result = await fetchList()
772
858
  if (result === null || result === undefined || result.ok !== true) {
773
859
  setFailed(true)
774
- setStatus((result && result.error) || t("msg.readFailed"))
860
+ setStatus(apiText(result, "msg.readFailed"))
775
861
  return
776
862
  }
777
863
  const assistants = Array.isArray(result.assistants) ? result.assistants : []
@@ -885,11 +971,11 @@ try {
885
971
  if (result !== null && result !== undefined && result.ok === true) {
886
972
  setNewName("")
887
973
  setFailed(false)
888
- setStatus(result.note || (from === undefined ? t("msg.created") : t("msg.duplicated")))
974
+ setStatus(apiText(result, from === undefined ? "msg.created" : "msg.duplicated"))
889
975
  await reload(result.id)
890
976
  } else {
891
977
  setFailed(true)
892
- setStatus((result && result.error) || t("msg.createFailed"))
978
+ setStatus(apiText(result, "msg.createFailed"))
893
979
  }
894
980
  } catch (error) {
895
981
  setFailed(true)
@@ -913,11 +999,11 @@ try {
913
999
  const result = await postJson(ROUTES.reorder, { id: draft.id, direction })
914
1000
  if (result !== null && result !== undefined && result.ok === true) {
915
1001
  setFailed(false)
916
- setStatus(result.note || t("msg.reordered"))
1002
+ setStatus(apiText(result, "msg.reordered"))
917
1003
  await reload(draft.id)
918
1004
  } else {
919
1005
  setFailed(true)
920
- setStatus((result && result.error) || t("msg.reorderFailed"))
1006
+ setStatus(apiText(result, "msg.reorderFailed"))
921
1007
  }
922
1008
  } catch (error) {
923
1009
  setFailed(true)
@@ -1022,7 +1108,7 @@ try {
1022
1108
  setDeleteOpen(false)
1023
1109
  setAcknowledged(false)
1024
1110
  setFailed(false)
1025
- setStatus(result.note || t("msg.deleted"))
1111
+ setStatus(apiText(result, "msg.deleted"))
1026
1112
  setEntries((previous) => {
1027
1113
  const next = { ...previous }
1028
1114
  delete next[id]
@@ -1031,7 +1117,7 @@ try {
1031
1117
  await reload("")
1032
1118
  } else {
1033
1119
  setFailed(true)
1034
- setStatus((result && result.error) || t("msg.deleteFailed"))
1120
+ setStatus(apiText(result, "msg.deleteFailed"))
1035
1121
  }
1036
1122
  } catch (error) {
1037
1123
  setFailed(true)
@@ -1077,7 +1163,7 @@ try {
1077
1163
  })
1078
1164
  if (result !== null && result !== undefined && result.ok === true) {
1079
1165
  setFailed(false)
1080
- setStatus(result.note || t("msg.saved"))
1166
+ setStatus(apiText(result, "msg.saved"))
1081
1167
  // The server normalises what it stores (trimmed name, recomputed overrides),
1082
1168
  // so the just-saved draft is replaced by what it actually wrote. Without this
1083
1169
  // the page would show 「已保存」 and 「未保存」 at the same time.
@@ -1094,7 +1180,7 @@ try {
1094
1180
  }
1095
1181
  } else {
1096
1182
  setFailed(true)
1097
- setStatus((result && result.error) || t("msg.saveFailed"))
1183
+ setStatus(apiText(result, "msg.saveFailed"))
1098
1184
  }
1099
1185
  } catch (error) {
1100
1186
  setFailed(true)
@@ -1402,6 +1488,18 @@ try {
1402
1488
  { className: "cpfe" },
1403
1489
  assistantList,
1404
1490
  react.createElement("p", { className: "cpfe-note" }, t("assistant.switchHint")),
1491
+ // 配了却不生效的项:主动点名,而不是让用户对着"我明明写了"发呆。
1492
+ // `draft` 在没选中任何助手时是 null(列表还没加载完 / 一个都没有)—— 这里必须先守卫,
1493
+ // 否则整块设置页崩掉(实测:浏览器验收当场报 Cannot read properties of null)。
1494
+ draft === null || draft.warnings === undefined || draft.warnings.length === 0
1495
+ ? null
1496
+ : react.createElement(
1497
+ "div",
1498
+ { className: "cpfe-warns" },
1499
+ ...draft.warnings.map((code) =>
1500
+ react.createElement("p", { key: code, className: "cpfe-warn" }, "⚠ " + t("warn." + code)),
1501
+ ),
1502
+ ),
1405
1503
  ...editorSections,
1406
1504
  react.createElement(
1407
1505
  "div",
package/index.mjs CHANGED
@@ -38,7 +38,7 @@
38
38
  */
39
39
 
40
40
  import { randomBytes } from 'node:crypto'
41
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
41
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
42
42
  import { dirname, join } from 'node:path'
43
43
  import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR } from './paths.mjs'
44
44
  import {
@@ -166,7 +166,7 @@ export function readPrompt(directory) {
166
166
  try {
167
167
  return { ok: true, path, text: readFileSync(path, 'utf8') }
168
168
  } catch (error) {
169
- return { ok: false, error: '读取提示词失败:' + describe(error) }
169
+ return { ok: false, code: 'promptReadFailed', params: { detail: describe(error) }, error: '读取提示词失败:' + describe(error) }
170
170
  }
171
171
  }
172
172
 
@@ -211,11 +211,42 @@ function packagedPrompt() {
211
211
  return packagedPromptCache
212
212
  }
213
213
 
214
+ /**
215
+ * 「配置了,但不生效」的告警码。
216
+ *
217
+ * 这一条借自同生态的 whale-persona:它会主动点名"配了却没起作用"的项。这里对应四种**静默**的
218
+ * 自相矛盾 —— 每一种此前都只会让人以为"我明明写了提示词/描述,怎么没效果":
219
+ *
220
+ * - `personaOffWithPrompt`:身份行被关掉,`prompt.md` 根本不会被注入(最坑的一种);
221
+ * - `toolOff`:`custom_prompt` 工具行关掉 → 会话内改提示词不可用(设置页照旧);
222
+ * - `noDescription`:描述为空 → 新建会话的模式选择器里显示「暂无描述」;
223
+ * - `noName`:名字为空 → 选择器里显示成裸目录 id。
224
+ *
225
+ * 只返回**码**,文案由页面按语言渲染(双语文案的真值源在 locales.mjs 一处)。
226
+ *
227
+ * @param {string} text - the composition text.
228
+ * @param {string} prompt - the prompt file's content.
229
+ * @param {{name?: string, description?: string}} meta - `preset.yml`.
230
+ * @returns {string[]} warning codes, stable order.
231
+ */
232
+ export function configWarnings(text, prompt, meta) {
233
+ const warnings = []
234
+ const rows = collectRows(text)
235
+ const find = (id) => rows.find((row) => row.id === id)
236
+ if (find('persona')?.disabled === true && typeof prompt === 'string' && prompt.trim() !== '') {
237
+ warnings.push('personaOffWithPrompt')
238
+ }
239
+ if (find('custom-prompt-tool')?.disabled === true) warnings.push('toolOff')
240
+ if (typeof meta?.description !== 'string' || meta.description.trim() === '') warnings.push('noDescription')
241
+ if (typeof meta?.name !== 'string' || meta.name.trim() === '') warnings.push('noName')
242
+ return warnings
243
+ }
244
+
214
245
  export function readState(rows, id, options = {}) {
215
246
  const directory = assistantDir(rows, id)
216
247
  if (directory === undefined) return unknownAssistant(id)
217
248
  const composition = compositionFile(directory)
218
- if (!existsSync(composition)) return { ok: false, error: '找不到组成文件:' + composition }
249
+ if (!existsSync(composition)) return { ok: false, code: 'compositionMissing', params: { path: composition }, error: '找不到组成文件:' + composition }
219
250
  const text = readFileSync(composition, 'utf8')
220
251
  const mode = modeOf(text)
221
252
  const prompt = readPrompt(directory)
@@ -242,6 +273,8 @@ export function readState(rows, id, options = {}) {
242
273
  factoryPrompt: typeof options.factoryPrompt === 'string' ? options.factoryPrompt : null,
243
274
  // 改动历史(只有元数据,正文按需取:见 GET /custom-mode/history)。
244
275
  history: listHistory(directory),
276
+ // 「配置了却不生效」的告警码(文案在页面侧按语言渲染)。
277
+ warnings: configWarnings(text, prompt.ok === true ? prompt.text : '', meta),
245
278
  }
246
279
  }
247
280
 
@@ -271,11 +304,11 @@ export function saveState(rows, input) {
271
304
 
272
305
  const mode = input !== null && typeof input === 'object' && typeof input.mode === 'string' ? input.mode : ''
273
306
  if (!BASE_MODES.some((entry) => entry.id === mode)) {
274
- return { ok: false, error: '未知的基础模式:' + mode }
307
+ return { ok: false, code: 'badMode', params: { mode }, error: '未知的基础模式:' + mode }
275
308
  }
276
309
  const prompt = input !== null && typeof input === 'object' && typeof input.prompt === 'string' ? input.prompt : ''
277
310
  if (prompt.trim() === '') {
278
- return { ok: false, error: '保存被拒绝:系统提示词为空。留空不会清空身份,读取器会沿用上一版。' }
311
+ return { ok: false, code: 'promptEmpty', error: '保存被拒绝:系统提示词为空。留空不会清空身份,读取器会沿用上一版。' }
279
312
  }
280
313
  const verdict = checkPromptText(prompt)
281
314
  if (verdict.ok !== true) return { ok: false, error: verdict.error }
@@ -283,13 +316,13 @@ export function saveState(rows, input) {
283
316
  const rawName = input !== null && typeof input === 'object' && typeof input.name === 'string' ? input.name : ''
284
317
  const name = rawName.replace(/\r?\n/g, ' ').trim()
285
318
  if (name.length > MAX_NAME) {
286
- return { ok: false, error: '保存被拒绝:模式名称过长(上限 ' + String(MAX_NAME) + ' 个字符)。' }
319
+ return { ok: false, code: 'nameTooLong', params: { max: MAX_NAME }, error: '保存被拒绝:模式名称过长(上限 ' + String(MAX_NAME) + ' 个字符)。' }
287
320
  }
288
321
  const rawDescription =
289
322
  input !== null && typeof input === 'object' && typeof input.description === 'string' ? input.description : ''
290
323
  const description = rawDescription.replace(/\r?\n/g, ' ').trim()
291
324
  if (description.length > MAX_DESCRIPTION) {
292
- return { ok: false, error: '保存被拒绝:模式描述过长(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
325
+ return { ok: false, code: 'descriptionTooLong', params: { max: MAX_DESCRIPTION }, error: '保存被拒绝:模式描述过长(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
293
326
  }
294
327
 
295
328
  const overrides = new Map()
@@ -304,7 +337,7 @@ export function saveState(rows, input) {
304
337
  try {
305
338
  composition = renderComposition(mode, overrides, { modeName: name, assistantId: id })
306
339
  } catch (error) {
307
- return { ok: false, error: '生成组成文件失败:' + describe(error) }
340
+ return { ok: false, code: 'renderFailed', params: { detail: describe(error) }, error: '生成组成文件失败:' + describe(error) }
308
341
  }
309
342
 
310
343
  // Self-check our own output before publishing it: a composition that lost its
@@ -313,7 +346,7 @@ export function saveState(rows, input) {
313
346
  if (collectRows(composition).length === 0) throw new Error('生成的组成文件没有任何行')
314
347
  readBaseComposition(mode)
315
348
  } catch (error) {
316
- return { ok: false, error: '生成结果自检失败,已放弃写入:' + describe(error) }
349
+ return { ok: false, code: 'selfCheckFailed', params: { detail: describe(error) }, error: '生成结果自检失败,已放弃写入:' + describe(error) }
317
350
  }
318
351
 
319
352
  try {
@@ -323,7 +356,7 @@ export function saveState(rows, input) {
323
356
  // 另外两条由 readState 对比补记(见 journal.mjs 的单写者说明)。
324
357
  recordPrompt(directory, prompt, HISTORY_SOURCE.settings)
325
358
  } catch (error) {
326
- return { ok: false, error: '写入失败:' + describe(error) }
359
+ return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
327
360
  }
328
361
 
329
362
  // A name the user cleared is left alone rather than written as an empty scalar:
@@ -337,6 +370,8 @@ export function saveState(rows, input) {
337
370
  ok: true,
338
371
  id,
339
372
  mode,
373
+ code: 'saved',
374
+ params: { name: name === '' ? id : name, mode },
340
375
  note: '已保存(' + (name === '' ? id : name) + ',基础模式 ' + mode + ')。新建会话即生效,当前会话保持原配置。',
341
376
  }
342
377
  }
@@ -357,15 +392,15 @@ export function saveState(rows, input) {
357
392
  export function createAssistant(rows, input, templateDir = packagedPresetDir()) {
358
393
  const rawName = input !== null && typeof input === 'object' && typeof input.name === 'string' ? input.name : ''
359
394
  const name = rawName.replace(/\r?\n/g, ' ').trim()
360
- if (name === '') return { ok: false, error: '请先给新助手起个名字。' }
395
+ if (name === '') return { ok: false, code: 'nameRequired', error: '请先给新助手起个名字。' }
361
396
  if (name.length > MAX_NAME) {
362
- return { ok: false, error: '名字太长了(上限 ' + String(MAX_NAME) + ' 个字符)。' }
397
+ return { ok: false, code: 'nameTooLong', params: { max: MAX_NAME }, error: '名字太长了(上限 ' + String(MAX_NAME) + ' 个字符)。' }
363
398
  }
364
399
  const rawDescription =
365
400
  input !== null && typeof input === 'object' && typeof input.description === 'string' ? input.description : ''
366
401
  const description = rawDescription.replace(/\r?\n/g, ' ').trim()
367
402
  if (description.length > MAX_DESCRIPTION) {
368
- return { ok: false, error: '描述太长了(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
403
+ return { ok: false, code: 'descriptionTooLong', params: { max: MAX_DESCRIPTION }, error: '描述太长了(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
369
404
  }
370
405
 
371
406
  const root = userPresetRoot(rows)
@@ -377,7 +412,7 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
377
412
  // The roster can lag a directory it skipped (a hand-made one with no
378
413
  // composition). Refuse the name rather than half-own that directory.
379
414
  if (existsSync(join(root, id))) {
380
- return { ok: false, error: '目录已存在,请换一个名字:' + join(root, id) }
415
+ return { ok: false, code: 'dirExists', params: { path: join(root, id) }, error: '目录已存在,请换一个名字:' + join(root, id) }
381
416
  }
382
417
 
383
418
  // A "duplicate" carries the source's prompt, base mode and row switches into a
@@ -391,7 +426,7 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
391
426
  const fromDir = assistantDir(rows, from)
392
427
  if (fromDir === undefined) return unknownAssistant(from)
393
428
  const fromComposition = compositionFile(fromDir)
394
- if (!existsSync(fromComposition)) return { ok: false, error: '找不到组成文件:' + fromComposition }
429
+ if (!existsSync(fromComposition)) return { ok: false, code: 'compositionMissing', params: { path: fromComposition }, error: '找不到组成文件:' + fromComposition }
395
430
  const text = readFileSync(fromComposition, 'utf8')
396
431
  const sourceMode = modeOf(text)
397
432
  const sourcePrompt = readPrompt(fromDir)
@@ -420,7 +455,7 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
420
455
  try {
421
456
  writeAtomic(promptFile(created.dir), source.prompt)
422
457
  } catch (error) {
423
- return { ok: false, error: '写入提示词失败:' + describe(error) }
458
+ return { ok: false, code: 'promptWriteFailed', params: { detail: describe(error) }, error: '写入提示词失败:' + describe(error) }
424
459
  }
425
460
  }
426
461
  const metaResult = writePresetMeta(name, description === '' && source !== null ? source.description : description, created.dir)
@@ -430,6 +465,9 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
430
465
  ok: true,
431
466
  id,
432
467
  name,
468
+ code: source === null ? 'created' : 'duplicated',
469
+ // D5:文案里用**显示名**而不是内部目录 id。
470
+ params: source === null ? { name } : { name, from: source === null ? '' : from },
433
471
  note: source === null
434
472
  ? '已创建「' + name + '」。它的系统提示词现在是模板默认文本;写好后新建会话即可选择它。'
435
473
  : '已复制出「' + name + '」:提示词、基础模式与插件开关都来自「' + from + '」,之后各改各的,互不影响。',
@@ -452,14 +490,14 @@ export async function deleteAssistant(rows, input, agentPresets) {
452
490
  const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
453
491
  if (assistantDir(rows, id) === undefined) return unknownAssistant(id)
454
492
  if (typeof agentPresets?.remove !== 'function') {
455
- return { ok: false, error: '当前 DSH 版本没有 agentPresets.remove(),无法删除。' }
493
+ return { ok: false, code: 'noRemoveApi', error: '当前 DSH 版本没有 agentPresets.remove(),无法删除。' }
456
494
  }
457
495
  try {
458
496
  await agentPresets.remove(id)
459
497
  } catch (error) {
460
- return { ok: false, error: '删除失败:' + describe(error) }
498
+ return { ok: false, code: 'deleteFailed', params: { detail: describe(error) }, error: '删除失败:' + describe(error) }
461
499
  }
462
- return { ok: true, id, note: '已删除「' + id + '」。正在使用它的会话不受影响;新建会话时不再出现。' }
500
+ return { ok: true, id, code: 'deleted', params: { name: id }, note: '已删除「' + id + '」。正在使用它的会话不受影响;新建会话时不再出现。' }
463
501
  }
464
502
 
465
503
  /**
@@ -618,7 +656,7 @@ export function apply(ctx) {
618
656
  const directory = assistantDir(await roster(), id)
619
657
  if (directory === undefined) return json(unknownAssistant(id), 404)
620
658
  const text = readVersion(directory, url.searchParams.get('n') ?? '')
621
- if (text === null) return json({ ok: false, error: '找不到这个版本(历史可能已被上限裁剪)。' }, 404)
659
+ if (text === null) return json({ ok: false, code: 'versionMissing', error: '找不到这个版本(历史可能已被上限裁剪)。' }, 404)
622
660
  return json({ ok: true, id, n: url.searchParams.get('n'), text })
623
661
  }
624
662
 
@@ -636,7 +674,7 @@ export function apply(ctx) {
636
674
  try {
637
675
  parsed = await request.json()
638
676
  } catch {
639
- return json({ ok: false, error: '请求体不是合法 JSON' }, 400)
677
+ return json({ ok: false, code: 'badJson', error: '请求体不是合法 JSON' }, 400)
640
678
  }
641
679
  await ensureShipped()
642
680
  if (pathname === STATE_PATH) {
@@ -683,12 +721,56 @@ export function apply(ctx) {
683
721
  * prompt,而这个文件正是"用户的提示词"。
684
722
  */
685
723
  function writeAtomic(file, text) {
686
- // 临时名必须**每个请求唯一**:只带 pid 时,同一进程内两个并发保存会争同一个临时名,
687
- // Windows 上两个 rename 指向同一目标会以 EPERM 失败(实测:10 并发保存 2 例 400),
688
- // POSIX rename 原子覆盖、静默地后写胜出 —— 也就是说这个缺陷只在 Windows 显现。
724
+ // 临时名必须**每个请求唯一**:只带 pid 时,同一进程内两个并发保存会争同一个临时名。
725
+ // 但随机后缀只消除 tmp-vs-tmp 竞争 —— Windows 上**两个 rename 指向同一目标**仍会以
726
+ // EPERM/EBUSY 失败(实测:10 并发保存 2 400),所以还要短退避重试。
689
727
  const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
690
728
  writeFileSync(temporary, text, 'utf8')
691
- renameSync(temporary, file)
729
+ try {
730
+ renameWithRetry(temporary, file)
731
+ } catch (error) {
732
+ // 失败必须清掉临时文件:助手目录会被 agent-presets 扫描,孤儿 tmp 是脏残留(实测留下过
733
+ // `agent.cordis.yml.tmp-135052-36a5bf5c`)。
734
+ try {
735
+ rmSync(temporary, { force: true })
736
+ } catch {
737
+ /* 清理失败不再掩盖原始错误 */
738
+ }
739
+ throw error
740
+ }
741
+ }
742
+
743
+ /** 同步退避:这条写路径本身是同步的,等一小会儿比把整个调用链改成异步更合适。 */
744
+ function sleepSync(ms) {
745
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
746
+ }
747
+
748
+ /**
749
+ * `rename` 带重试。
750
+ *
751
+ * Windows 上并发 rename 到同一目标会短暂报 `EPERM`/`EBUSY`/`EACCES`(目标被另一个 rename 持有),
752
+ * 退避几毫秒就能跨过这个窗口;其它错误立刻抛出,不做无谓等待。`rename`/`sleep` 可注入,
753
+ * 于是这条重试逻辑在 Linux 上也能被测试钉住(见 test/journal.test.mjs 的对应条目)。
754
+ *
755
+ * @param {string} from - 临时文件名(已写好内容)。
756
+ * @param {string} to - 目标文件名。
757
+ * @returns {void}
758
+ */
759
+ export function renameWithRetry(from, to, options = {}) {
760
+ const rename = typeof options.rename === 'function' ? options.rename : renameSync
761
+ const sleep = typeof options.sleep === 'function' ? options.sleep : sleepSync
762
+ const attempts = Number.isInteger(options.attempts) ? options.attempts : 5
763
+ for (let attempt = 1; ; attempt += 1) {
764
+ try {
765
+ rename(from, to)
766
+ return
767
+ } catch (error) {
768
+ const code = error !== null && typeof error === 'object' ? error.code : undefined
769
+ const retryable = code === 'EPERM' || code === 'EBUSY' || code === 'EACCES'
770
+ if (retryable !== true || attempt >= attempts) throw error
771
+ sleep(20 * attempt)
772
+ }
773
+ }
692
774
  }
693
775
 
694
776
  /** `error` as a readable string, without assuming it is an Error. */
package/journal.mjs CHANGED
@@ -23,9 +23,30 @@
23
23
  * 但这里记录的是**已发生的事实**而不是待确认的提议 —— 我们的场景是"改了什么要看得见、回得去",
24
24
  * 不需要它的确认闸门。
25
25
  */
26
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
26
+ import { randomBytes } from 'node:crypto'
27
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
27
28
  import { join } from 'node:path'
28
29
 
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
+
29
50
  /** 每个助手目录下的日志文件名。 */
30
51
  export const HISTORY_NAME = 'prompt-history.jsonl'
31
52
 
@@ -89,16 +110,29 @@ export function recordPrompt(directory, text, by = HISTORY_SOURCE.settings) {
89
110
 
90
111
  const at = new Date().toISOString()
91
112
  const n = entries.length === 0 ? 1 : entries[entries.length - 1].n + 1
113
+ // `bytes` 是给人看的体积,所以按 **UTF-8 字节**计(原先用 text.length = UTF-16 码元,
114
+ // CJK 内容下显示值只有真实字节的约 1/3,而界面标注是 "B")。
92
115
  const lines = entries.slice(-(HISTORY_MAX - 1)).map((entry) =>
93
- JSON.stringify({ n: entry.n, at: entry.at, by: entry.by, bytes: entry.text.length, text: entry.text }),
116
+ JSON.stringify({ n: entry.n, at: entry.at, by: entry.by, bytes: Buffer.byteLength(entry.text, 'utf8'), text: entry.text }),
94
117
  )
95
- lines.push(JSON.stringify({ n, at, by, bytes: text.length, text }))
118
+ lines.push(JSON.stringify({ n, at, by, bytes: Buffer.byteLength(text, 'utf8'), text }))
96
119
 
97
120
  // 唯一的整文件重写:顺手把上限外的旧条目丢掉,用原子写落盘(读取器不会看到写了一半的文件)。
98
121
  const file = historyFile(directory)
99
- const temporary = `${file}.tmp-${String(process.pid)}`
122
+ // 与宿主半同一条纪律:随机临时名(消除 tmp-vs-tmp 碰撞)+ 重试(Windows 上目标被并发 rename
123
+ // 持有时会短暂 EPERM)+ 失败清理。
124
+ const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
100
125
  writeFileSync(temporary, lines.join('\n') + '\n', 'utf8')
101
- renameSync(temporary, file)
126
+ try {
127
+ renameWithRetry(temporary, file)
128
+ } catch (error) {
129
+ try {
130
+ rmSync(temporary, { force: true })
131
+ } catch {
132
+ /* 不掩盖原始错误 */
133
+ }
134
+ throw error
135
+ }
102
136
  return { recorded: true, at, n }
103
137
  }
104
138
 
@@ -119,7 +153,7 @@ export function listHistory(directory, limit = HISTORY_MAX) {
119
153
  n: entry.n,
120
154
  at: entry.at,
121
155
  by: entry.by,
122
- bytes: entry.text.length,
156
+ bytes: Buffer.byteLength(entry.text, 'utf8'),
123
157
  preview: firstLine.trim().slice(0, 80),
124
158
  })
125
159
  }
package/locales.mjs CHANGED
@@ -52,6 +52,35 @@ export const zh = {
52
52
  'msg.reorderFailed': '调整顺序失败',
53
53
  'msg.imported': '已导入到编辑器(还没有保存):检查后点「保存」。',
54
54
  'msg.importFailed': '导入失败',
55
+ 'api.badDirection': '未知的排序方向:{direction}',
56
+ 'api.unknownAssistant': '找不到助手「{id}」:这一页只管理本工具创建的助手(目录里有 prompt.md,且组成文件用 prompt-reader.mjs 注入身份)。',
57
+ 'api.alreadyFirst': '「{name}」已经在最前面。',
58
+ 'api.alreadyLast': '「{name}」已经在最后面。',
59
+ 'api.saved': '已保存({name},基础模式 {mode})。新建会话即生效,当前会话保持原配置。',
60
+ 'api.created': '已创建「{name}」。现在可以为它写系统提示词。',
61
+ 'api.duplicated': '已复制自「{from}」。两份从此各改各的。',
62
+ 'api.deleted': '已删除「{name}」。正在使用它的会话不受影响;新建会话时它不再出现。',
63
+ 'api.reordered': '顺序已保存:新建会话时的模式选择器按这个顺序排列。',
64
+ 'api.nameRequired': '请先给新助手起个名字。',
65
+ 'api.nameTooLong': '名字太长了(上限 {max} 个字符)。',
66
+ 'api.descriptionTooLong': '描述太长了(上限 {max} 个字符)。',
67
+ 'api.promptEmpty': '保存被拒绝:系统提示词为空。留空不会清空身份,读取器会沿用上一版。',
68
+ 'api.badMode': '未知的基础模式:{mode}',
69
+ 'api.dirExists': '目录已存在,请换一个名字:{path}',
70
+ 'api.compositionMissing': '找不到组成文件:{path}',
71
+ 'api.writeFailed': '写入失败:{detail}',
72
+ 'api.promptWriteFailed': '写入提示词失败:{detail}',
73
+ 'api.renderFailed': '生成组成文件失败:{detail}',
74
+ 'api.selfCheckFailed': '生成结果自检失败,已放弃写入:{detail}',
75
+ 'api.promptReadFailed': '读取提示词失败:{detail}',
76
+ 'api.noRemoveApi': '当前 DSH 版本没有 agentPresets.remove(),无法删除。',
77
+ 'api.deleteFailed': '删除失败:{detail}',
78
+ 'api.versionMissing': '找不到这个版本(历史可能已被上限裁剪)。',
79
+ 'api.badJson': '请求体不是合法 JSON',
80
+ 'warn.personaOffWithPrompt': '「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。',
81
+ 'warn.toolOff': '「custom_prompt 工具」这一行是关的:会话里无法让 agent 改提示词,只能在本页改。',
82
+ 'warn.noDescription': '没有描述:新建会话的模式选择器里会显示成「暂无描述」。',
83
+ 'warn.noName': '没有名字:模式选择器里会显示成目录 id(例如 custom)。',
55
84
  'history.label': '改动历史',
56
85
  'history.pick': '选择要载入的版本…',
57
86
  'history.load': '载入这一版',
@@ -208,6 +237,35 @@ export const en = {
208
237
  'msg.reorderFailed': 'Could not reorder',
209
238
  'msg.imported': 'Imported into the editor (not saved yet) — review it, then click Save.',
210
239
  'msg.importFailed': 'Import failed',
240
+ 'api.badDirection': 'Unknown reorder direction: {direction}',
241
+ '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).',
242
+ 'api.alreadyFirst': '「{name}」 is already first.',
243
+ 'api.alreadyLast': '「{name}」 is already last.',
244
+ 'api.saved': 'Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.',
245
+ 'api.created': 'Created 「{name}」. You can write its system prompt now.',
246
+ 'api.duplicated': 'Copied from 「{from}」. The two are independent from now on.',
247
+ 'api.deleted': 'Deleted 「{name}」. Sessions already using it keep running; it no longer appears for new sessions.',
248
+ 'api.reordered': 'Order saved: the new-session mode picker follows it.',
249
+ 'api.nameRequired': 'Give the new assistant a name first.',
250
+ 'api.nameTooLong': 'That name is too long (limit {max} characters).',
251
+ 'api.descriptionTooLong': 'That description is too long (limit {max} characters).',
252
+ 'api.promptEmpty': 'Save rejected: the system prompt is empty. Emptying it would not clear the identity — the reader keeps the last good text.',
253
+ 'api.badMode': 'Unknown base mode: {mode}',
254
+ 'api.dirExists': 'That directory already exists — pick another name: {path}',
255
+ 'api.compositionMissing': 'Composition file not found: {path}',
256
+ 'api.writeFailed': 'Write failed: {detail}',
257
+ 'api.promptWriteFailed': 'Writing the prompt failed: {detail}',
258
+ 'api.renderFailed': 'Rendering the composition failed: {detail}',
259
+ 'api.selfCheckFailed': 'The generated composition failed its self-check, so nothing was written: {detail}',
260
+ 'api.promptReadFailed': 'Reading the prompt failed: {detail}',
261
+ 'api.noRemoveApi': 'This DSH build has no agentPresets.remove(), so deleting is unavailable.',
262
+ 'api.deleteFailed': 'Delete failed: {detail}',
263
+ 'api.versionMissing': 'That version is gone (the history is capped).',
264
+ 'api.badJson': 'The request body is not valid JSON',
265
+ '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.',
266
+ 'warn.toolOff': 'The "custom_prompt tool" row is off: the agent cannot change the prompt from inside a session, only this page can.',
267
+ 'warn.noDescription': 'No description: the new-session mode picker will show it as "no description yet".',
268
+ 'warn.noName': 'No name: the mode picker will show the directory id (e.g. custom).',
211
269
  'history.label': 'Change history',
212
270
  'history.pick': 'Pick a version to load…',
213
271
  'history.load': 'Load this version',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Custom modes and custom prompts for DeepSeek Harness (dsh): edit a mode's system prompt on the settings page (it takes effect on the next model step), choose its base mode, switch plugins row by row, and keep several assistants side by side.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -60,7 +60,7 @@
60
60
  "test": "node ../test/run.mjs"
61
61
  },
62
62
  "peerDependencies": {
63
- "@deepseek-ai/dsh": ">=0.1.6-alpha.1 <0.2.0-0"
63
+ "@deepseek-ai/dsh": ">=0.1.5-rc.2 <0.2.0-0"
64
64
  },
65
65
  "publishConfig": {
66
66
  "access": "public",
@@ -72,6 +72,6 @@
72
72
  }
73
73
  },
74
74
  "engines": {
75
- "dsh": ">=0.1.6-alpha.1 <0.2.0-0"
75
+ "dsh": ">=0.1.5-rc.2 <0.2.0-0"
76
76
  }
77
77
  }
@@ -40,6 +40,7 @@ const META_PATH = fileURLToPath(new URL('./preset.yml', import.meta.url))
40
40
  const MISSING = '(prompt.md 不存在,当前模式会退回上一次成功的提示词文本)'
41
41
 
42
42
  /** Name used when neither the composition nor `preset.yml` supplies one. */
43
+ const TOOL_NAME = 'custom_prompt'
43
44
  const FALLBACK_MODE_NAME = '自定义模式'
44
45
 
45
46
  /**
@@ -201,12 +202,67 @@ function makeDefinition(modeName) {
201
202
  }
202
203
  }
203
204
 
205
+ /**
206
+ * 自我改提示词的审批闸门。
207
+ *
208
+ * `action: "write"` 会**覆盖整个系统提示词**,而调用它的可能是模型自己 —— 一次提示词注入就足以
209
+ * 让它重写自己的身份。此前这条路径没有任何门:工具直接落盘。
210
+ *
211
+ * 现在它走平台的审批缝:`tools/pre-execute` 是一个 waterfall,返回 `{kind:'ask'}` 会由审批策略决定
212
+ * 怎么处理。**实测(0.1.6-alpha.2,一次性实例)**:
213
+ *
214
+ * - 审批策略为 `ask`(权限预设 `workspace-write`):页面出现「等待审批」行,带这里的 reason,
215
+ * 按钮为「拒绝 / 允许一次」;点「允许一次」后工具真的执行,prompt.md 变成写入的内容;
216
+ * - 审批策略为 `never`(权限预设 `danger-full-access`):**不弹窗,直接判为拒绝**
217
+ * (轨迹里是 `Error: the user rejected tool "custom_prompt"`,文件未被修改)。
218
+ *
219
+ * 也就是说这条闸门**最坏情况是"改不成"而不是"悄悄改成了"** —— 与平台的默认语义一致
220
+ * (类型注释原话:missing approval support turns `ask` into denial)。
221
+ *
222
+ * 只拦 `write`:`read` 不改变任何东西,弹窗只会让人麻木。
223
+ *
224
+ * @param {object} ctx - the preset row's scope.
225
+ * @returns {boolean} whether a gate was registered.
226
+ */
227
+ function registerApprovalGate(ctx) {
228
+ if (typeof ctx.on !== 'function') return false
229
+ ctx.effect(
230
+ () => ctx.on('tools/pre-execute', (exec, next) => {
231
+ if (exec === null || typeof exec !== 'object' || exec.name !== TOOL_NAME) return next()
232
+ const args = exec.arguments
233
+ const action = args !== null && typeof args === 'object' ? args.action : undefined
234
+ // 只拦写入;未指定 action 时工具按 read 处理,同样不拦。
235
+ if (action !== 'write') return next()
236
+ const text = typeof args.text === 'string' ? args.text : ''
237
+ const firstLine = text.split('\n').find((line) => line.trim() !== '') ?? ''
238
+ return {
239
+ kind: 'ask',
240
+ reason:
241
+ '把「' + resolveModeName(undefined) + '」的系统提示词整体替换为 ' + String(text.length) + ' 字符' +
242
+ (firstLine === '' ? '' : ':' + firstLine.trim().slice(0, 60)) +
243
+ '(写入 ' + PROMPT_PATH + ')',
244
+ }
245
+ }),
246
+ 'custom-prompt.approval-gate',
247
+ )
248
+ return true
249
+ }
250
+
204
251
  /** The tool registry is a hard dependency; without it there is no tool. */
205
252
  export const inject = ['tools']
206
253
 
207
254
  export function apply(ctx, config = {}) {
208
255
  const definition = makeDefinition(resolveModeName(config))
209
256
  ctx.effect(() => ctx.tools.register(definition), 'custom-prompt.tool')
257
+
258
+ // 审批闸门。宿主若不支持 `tools/pre-execute`(比本插件声明的下限还老的构建),这里会**明确**
259
+ // 说一声再继续 —— 降级是有的,但不许静默。
260
+ if (registerApprovalGate(ctx) !== true) {
261
+ console.error(
262
+ 'custom-mode: 这个宿主没有 tools/pre-execute 事件,会话内改写系统提示词的审批闸门**未启用**' +
263
+ '(设置页不受影响)。请升级 DSH,或把「custom_prompt 工具」这一行关掉。',
264
+ )
265
+ }
210
266
  }
211
267
 
212
268
  /**