dsh-custom-mode 1.9.6 → 1.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  ![dsh-custom-mode — a settings page for dsh agent modes](https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/header.png)
4
4
 
5
- [![npm](https://img.shields.io/npm/v/dsh-custom-mode?label=npm&style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/dsh-custom-mode) [![CI](https://img.shields.io/github/actions/workflow/status/BOWLUNA/dsh-custom-mode/test.yml?label=CI&style=flat-square&logo=githubactions&logoColor=white)](https://github.com/BOWLUNA/dsh-custom-mode/actions/workflows/test.yml) [![license](https://img.shields.io/npm/l/dsh-custom-mode?label=license&style=flat-square&logo=opensourceinitiative&logoColor=white)](LICENSE)
5
+ [![npm](https://img.shields.io/npm/v/dsh-custom-mode?label=npm&style=flat-square&logo=npm&logoColor=white&labelColor=1f2430)](https://www.npmjs.com/package/dsh-custom-mode) [![CI](https://img.shields.io/github/actions/workflow/status/BOWLUNA/dsh-custom-mode/test.yml?label=CI&style=flat-square&logo=githubactions&logoColor=white&labelColor=1f2430)](https://github.com/BOWLUNA/dsh-custom-mode/actions/workflows/test.yml) [![license](https://img.shields.io/badge/license-MIT-97ca00?style=flat-square&logo=opensourceinitiative&logoColor=white&labelColor=1f2430)](LICENSE) [![dsh](https://img.shields.io/badge/dsh-%E2%89%A50.1.5--rc.2-4d6bfe?style=flat-square&logo=deepseek&logoColor=white&labelColor=1f2430)](https://github.com/BOWLUNA/dsh-custom-mode#readme)
6
6
 
7
- <!-- community badge row: required in every new repository (see the storefront template in the operator SOP/DSH Plugins folder) -->
7
+ <!-- badge rows: same two-part structure (dark label + coloured value) in both rows; every new repo copies this block -->
8
+ [![bilibili](https://img.shields.io/badge/bilibili-videos-%2300A1D6?style=flat-square&logo=bilibili&logoColor=white&labelColor=1f2430)](https://b23.tv/qJ4Ev0W) [![Douyin](https://img.shields.io/badge/Douyin-shorts-%23FE2C55?style=flat-square&logo=tiktok&logoColor=white&labelColor=1f2430)](https://v.douyin.com/VWh0M03Fa4Y/) [![RedNote](https://img.shields.io/badge/RedNote-notes-%23FF2442?style=flat-square&logo=xiaohongshu&logoColor=white&labelColor=1f2430)](https://xhslink.cn/o/A7QtXmePBBF) [![Discord](https://img.shields.io/badge/Discord-chat-%235865F2?style=flat-square&logo=discord&logoColor=white&labelColor=1f2430)](https://discord.gg/pz97SfAfSy) [![GitHub](https://img.shields.io/github/discussions/BOWLUNA/dsh-custom-mode?label=GitHub&style=flat-square&logo=github&logoColor=white&labelColor=1f2430)](https://github.com/BOWLUNA/dsh-custom-mode/discussions)
8
9
 
9
- [![bilibili](https://img.shields.io/badge/bilibili-%2300A1D6?style=flat-square&logo=bilibili&logoColor=white)](https://b23.tv/qJ4Ev0W) [![Douyin](https://img.shields.io/badge/Douyin-%23FE2C55?style=flat-square&logo=tiktok&logoColor=white)](https://v.douyin.com/VWh0M03Fa4Y/) [![RedNote](https://img.shields.io/badge/RedNote-%23FF2442?style=flat-square&logo=xiaohongshu&logoColor=white)](https://xhslink.cn/o/A7QtXmePBBF) [![Discord](https://img.shields.io/badge/Discord-%235865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/pz97SfAfSy) [![Discussions](https://img.shields.io/github/discussions/BOWLUNA/dsh-custom-mode?label=Discussions&style=flat-square&logo=github&logoColor=white)](https://github.com/BOWLUNA/dsh-custom-mode/discussions)
10
+ <!-- community badge row: required in every new repository (see the storefront template in the operator SOP/DSH Plugins folder) -->
10
11
 
11
12
  The **settings-page half** of [dsh-custom-mode](https://github.com/BOWLUNA/dsh-custom-mode) — a Web UI for
12
13
  [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) agent modes: choose a mode's base
@@ -15,7 +16,8 @@ agent loop re-reads before every model step — **a save applies on the next ste
15
16
  session**. Several modes ("assistants") live side by side, each with its own prompt.
16
17
 
17
18
  Searched for as: custom mode · custom prompt · system-prompt editor · multi-mode / several assistants ·
18
- 自定义模式 · 自定义提示词 · 多助手/多模式.
19
+ multi-agent · roleplay (RP) / chat personas ·
20
+ 自定义模式 · 自定义提示词 · 多助手/多模式 · 角色扮演/聊天人格(RP).
19
21
 
20
22
  ![Assistant manager](https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/05-assistant-manager.png)
21
23
 
package/assistants.mjs CHANGED
@@ -28,7 +28,10 @@
28
28
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
29
29
  import { dirname, join } from 'node:path'
30
30
  import { dshHome, PRESET_DIR } from './paths.mjs'
31
- import { readPresetMeta, writePresetMeta } from './meta.mjs'
31
+ // `presetMetaPath` 曾被漏掉:reorderAssistant 用它做"快照 → 失败回滚",于是两处调用都抛
32
+ // ReferenceError 被 try/catch 吞掉,回滚循环 continue 掉每一条 —— 用户看到的"已回滚"从未发生,
33
+ // 磁盘上留下改了一半的 order。少一个导入就能让一整段安全网变成死代码(外部评审复现)。
34
+ import { readPresetMeta, writePresetMeta, presetMetaPath } from './meta.mjs'
32
35
  import { writeAtomic } from './atomic.mjs'
33
36
  import { unresolvableRows } from './composition.mjs'
34
37
  import { seedPreset, seedPresetWithLog } from './seed.mjs'
@@ -256,20 +259,22 @@ export function allocateId(name, taken) {
256
259
  * @returns {{ok: true, id: string, dir: string} | {ok: false, error: string}}
257
260
  */
258
261
  export function createAssistantDir({ root, id, composition, templateDir }) {
262
+ // 每个用户可见的结果都带 `code`(AGENTS.md 第 13 条):没有它,页面只能显示下面这串中文,
263
+ // 英文界面就在这一刻掉回中文。前三条还会被设置页原样显示给用户。
259
264
  if (!PRESET_ID.test(String(id ?? ''))) {
260
- return { ok: false, error: `助手标识不合法:${String(id)}(只能是 a-z0-9 与连字符)` }
265
+ return { ok: false, code: 'badAssistantId', params: { id: String(id) }, error: `助手标识不合法:${String(id)}(只能是 a-z0-9 与连字符)` }
261
266
  }
262
267
  const dir = join(root, id)
263
- if (existsSync(dir)) return { ok: false, error: `目录已存在:${dir}` }
268
+ if (existsSync(dir)) return { ok: false, code: 'dirExists', params: { path: dir }, error: `目录已存在:${dir}` }
264
269
 
265
270
  const seeded = seedPreset(dir, templateDir)
266
271
  if (seeded.errors.length > 0) {
267
- return { ok: false, error: '复制模式模板失败:' + seeded.errors.join(';') }
272
+ return { ok: false, code: 'seedFailed', params: { detail: seeded.errors.join(';') }, error: '复制模式模板失败:' + seeded.errors.join(';') }
268
273
  }
269
274
  try {
270
275
  writeFileSync(join(dir, COMPOSITION_FILE), composition, 'utf8')
271
276
  } catch (error) {
272
- return { ok: false, error: '写入组成文件失败:' + describe(error) }
277
+ return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入组成文件失败:' + describe(error) }
273
278
  }
274
279
  return { ok: true, id, dir }
275
280
  }
package/client.js CHANGED
@@ -138,6 +138,13 @@ try {
138
138
  "api.promptWriteFailed": "写入提示词失败:{detail}",
139
139
  "api.renderFailed": "生成组成文件失败:{detail}",
140
140
  "api.selfCheckFailed": "生成结果自检失败,已放弃写入:{detail}",
141
+ "api.internalError": "宿主内部错误:{detail}",
142
+ "api.bodyTooLarge": "请求体过大(上限 {max} 字节)",
143
+ "api.repairNotNeeded": "这个助手在本机没有无法解析的行,无需修复。",
144
+ "api.repaired": "已按本机这条 dsh 线关闭 {count} 个无法解析的行({ids})。现在这个模式能重新出现在选择器里。",
145
+ "api.badAssistantId": "助手标识不合法:{id}(只能用 a-z、0-9 与连字符)",
146
+ "api.seedFailed": "复制模式模板失败:{detail}",
147
+ "api.metaWriteFailed": "写入 preset.yml 失败:{detail}",
141
148
  "api.promptReadFailed": "读取提示词失败:{detail}",
142
149
  "api.noRemoveApi": "当前 DSH 版本没有 agentPresets.remove(),无法删除。",
143
150
  "api.deleteFailed": "删除失败:{detail}",
@@ -338,6 +345,13 @@ try {
338
345
  "api.promptWriteFailed": "Writing the prompt failed: {detail}",
339
346
  "api.renderFailed": "Rendering the composition failed: {detail}",
340
347
  "api.selfCheckFailed": "The generated composition failed its self-check, so nothing was written: {detail}",
348
+ "api.internalError": "Unexpected host error: {detail}",
349
+ "api.bodyTooLarge": "Request body too large (limit {max} bytes)",
350
+ "api.repairNotNeeded": "Nothing to repair — no row of this assistant is unresolvable on this machine.",
351
+ "api.repaired": "Disabled {count} row(s) this dsh line cannot resolve ({ids}). The mode can appear in the pickers again.",
352
+ "api.badAssistantId": "Invalid assistant id: {id} (a-z, 0-9 and hyphens only)",
353
+ "api.seedFailed": "Copying the mode template failed: {detail}",
354
+ "api.metaWriteFailed": "Writing preset.yml failed: {detail}",
341
355
  "api.promptReadFailed": "Reading the prompt failed: {detail}",
342
356
  "api.noRemoveApi": "This DSH build has no agentPresets.remove(), so deleting is unavailable.",
343
357
  "api.deleteFailed": "Delete failed: {detail}",
package/index.mjs CHANGED
@@ -196,8 +196,12 @@ export function readPrompt(directory) {
196
196
 
197
197
  /** The isolation message for an id the roster does not describe as a managed assistant. */
198
198
  function unknownAssistant(id) {
199
+ // 带 `code`:两个标签页同时开着、在 B 里删掉这个助手、回到 A 点保存时走的就是这条 ——
200
+ // 没有 code 时英文界面会显示下面这串中文,而词典里的 `api.unknownAssistant` 反而永远用不上。
199
201
  return {
200
202
  ok: false,
203
+ code: 'unknownAssistant',
204
+ params: { id: String(id) },
201
205
  error:
202
206
  '找不到助手「' +
203
207
  String(id) +
@@ -414,13 +418,22 @@ export function saveState(rows, input) {
414
418
  [compositionFile(directory), composition],
415
419
  [promptFile(directory), prompt],
416
420
  ])
417
- // 改动留痕:三个改动路径(设置页 / 会话内工具 / 手工编辑)里,只有设置页是"当场知道"的。
418
- // 另外两条由 readState 对比补记(见 journal.mjs 的单写者说明)。
419
- recordPrompt(directory, prompt, HISTORY_SOURCE.settings)
420
421
  } catch (error) {
421
422
  return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
422
423
  }
423
424
 
425
+ // 改动留痕:三个改动路径(设置页 / 会话内工具 / 手工编辑)里,只有设置页是"当场知道"的。
426
+ // 另外两条由 readState 对比补记(见 journal.mjs 的单写者说明)。
427
+ //
428
+ // **在写入 try 之外**:这是一条审计记录,它失败不该让一次已经落盘的保存报 writeFailed
429
+ // ——外部评审复现过:日志文件被 chmod 000 时,prompt.md 已经写成功而页面显示"保存失败",
430
+ // 页面又不会重新读取,于是它永远停在旧草稿上。留痕失败只降级为"这一版没记上"。
431
+ try {
432
+ recordPrompt(directory, prompt, HISTORY_SOURCE.settings)
433
+ } catch {
434
+ /* 审计记录失败不影响保存本身 */
435
+ }
436
+
424
437
  // A name the user cleared is left alone rather than written as an empty scalar:
425
438
  // an empty `preset.yml` name is what makes a mode render as its bare id.
426
439
  if (name !== '') {
@@ -511,7 +524,10 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
511
524
  ? renderComposition('standard', new Map(), { modeName: name, assistantId: id })
512
525
  : renderComposition(source.mode, source.overrides, { modeName: name, assistantId: id })
513
526
  } catch (error) {
514
- return { ok: false, error: '生成组成文件失败:' + describe(error) }
527
+ // 与保存路径的 `renderFailed` 同一套:**每个**用户可见的结果都要带 `code`,否则页面无从本地化
528
+ // —— client.js 的 apiText 在没有 code 时直接退回下面这串中文,英文界面会在这一刻掉回中文
529
+ // (AGENTS.md 第 13 条)。这条曾经是宿主半唯一漏掉 code 的普通返回。
530
+ return { ok: false, code: 'renderFailed', params: { detail: describe(error) }, error: '生成组成文件失败:' + describe(error) }
515
531
  }
516
532
 
517
533
  const created = createAssistantDir({ root, id, composition, templateDir })
@@ -819,7 +835,9 @@ export function apply(ctx) {
819
835
  const result = await serializedWrite('delete:' + targetId, async () => deleteAssistant(await roster(), parsed, scope.agentPresets))
820
836
  return json(result, result.ok === true ? 200 : 400)
821
837
  } catch (error) {
822
- return json({ ok: false, error: describe(error) }, 500)
838
+ // 兜底也要带 code:这是**意料之外**的异常,页面拿到的是 Node 的原始错误串,方向反着也一样糟
839
+ // ——中文界面里会蹦出一句英文。带 code 后页面用自己的词典渲染,原始串只作为 `detail` 参数。
840
+ return json({ ok: false, code: 'internalError', params: { detail: describe(error) }, error: describe(error) }, 500)
823
841
  }
824
842
  }
825
843
 
package/journal.mjs CHANGED
@@ -55,8 +55,18 @@ export function historyFile(directory) {
55
55
  export function readEntries(directory) {
56
56
  const file = historyFile(directory)
57
57
  if (!existsSync(file)) return []
58
+ let raw = ''
59
+ try {
60
+ raw = readFileSync(file, 'utf8')
61
+ } catch {
62
+ // **坏的是文件本身**(不是某一行):被 chmod 000(一次 sudo 跑 dsh 就会把 root 属主留在
63
+ // 日志上)、被改成目录、或读到一半的 EIO。此前这里裸读,于是这一个文件能同时做到两件坏事 ——
64
+ // 让 GET state(页面打开某个助手的入口)永远 500,以及让一次**已经写成功**的保存报 writeFailed。
65
+ // 模块自己的承诺是"坏一行不该赔上全部版本",那么坏一个文件更不该赔上这个助手。
66
+ return []
67
+ }
58
68
  const entries = []
59
- for (const line of readFileSync(file, 'utf8').split('\n')) {
69
+ for (const line of raw.split('\n')) {
60
70
  if (line.trim() === '') continue
61
71
  try {
62
72
  const parsed = JSON.parse(line)
@@ -82,13 +92,16 @@ export function readEntries(directory) {
82
92
  * @param {string} directory - one assistant's preset directory.
83
93
  * @param {string} text - the prompt content **after** the change.
84
94
  * @param {string} by - {@link HISTORY_SOURCE}.
85
- * @returns {{ recorded: boolean, at: string, n: number }} whether a line was appended.
95
+ * @returns {{ recorded: boolean, at: string, n: number }} whether a line was appended. `n` is the
96
+ * revision this text already corresponds to when `recorded` is false, and `0` when the input could
97
+ * not be recorded at all — never `undefined`, so a caller can feed it straight back to
98
+ * {@link readVersion} without a guard that nobody would remember to write.
86
99
  */
87
100
  export function recordPrompt(directory, text, by = HISTORY_SOURCE.settings) {
88
- if (typeof text !== 'string') return { recorded: false, at: '' }
101
+ if (typeof text !== 'string') return { recorded: false, at: '', n: 0 }
89
102
  const entries = readEntries(directory)
90
103
  const newest = entries[entries.length - 1]
91
- if (newest !== undefined && newest.text === text) return { recorded: false, at: newest.at }
104
+ if (newest !== undefined && newest.text === text) return { recorded: false, at: newest.at, n: newest.n }
92
105
 
93
106
  const at = new Date().toISOString()
94
107
  const n = entries.length === 0 ? 1 : entries[entries.length - 1].n + 1
package/locales.mjs CHANGED
@@ -97,6 +97,13 @@ export const zh = {
97
97
  'api.promptWriteFailed': '写入提示词失败:{detail}',
98
98
  'api.renderFailed': '生成组成文件失败:{detail}',
99
99
  'api.selfCheckFailed': '生成结果自检失败,已放弃写入:{detail}',
100
+ 'api.internalError': '宿主内部错误:{detail}',
101
+ 'api.bodyTooLarge': '请求体过大(上限 {max} 字节)',
102
+ 'api.repairNotNeeded': '这个助手在本机没有无法解析的行,无需修复。',
103
+ 'api.repaired': '已按本机这条 dsh 线关闭 {count} 个无法解析的行({ids})。现在这个模式能重新出现在选择器里。',
104
+ 'api.badAssistantId': '助手标识不合法:{id}(只能用 a-z、0-9 与连字符)',
105
+ 'api.seedFailed': '复制模式模板失败:{detail}',
106
+ 'api.metaWriteFailed': '写入 preset.yml 失败:{detail}',
100
107
  'api.promptReadFailed': '读取提示词失败:{detail}',
101
108
  'api.noRemoveApi': '当前 DSH 版本没有 agentPresets.remove(),无法删除。',
102
109
  'api.deleteFailed': '删除失败:{detail}',
@@ -313,6 +320,13 @@ export const en = {
313
320
  'api.promptWriteFailed': 'Writing the prompt failed: {detail}',
314
321
  'api.renderFailed': 'Rendering the composition failed: {detail}',
315
322
  'api.selfCheckFailed': 'The generated composition failed its self-check, so nothing was written: {detail}',
323
+ 'api.internalError': 'Unexpected host error: {detail}',
324
+ 'api.bodyTooLarge': 'Request body too large (limit {max} bytes)',
325
+ 'api.repairNotNeeded': 'Nothing to repair — no row of this assistant is unresolvable on this machine.',
326
+ 'api.repaired': 'Disabled {count} row(s) this dsh line cannot resolve ({ids}). The mode can appear in the pickers again.',
327
+ 'api.badAssistantId': 'Invalid assistant id: {id} (a-z, 0-9 and hyphens only)',
328
+ 'api.seedFailed': 'Copying the mode template failed: {detail}',
329
+ 'api.metaWriteFailed': 'Writing preset.yml failed: {detail}',
316
330
  'api.promptReadFailed': 'Reading the prompt failed: {detail}',
317
331
  'api.noRemoveApi': 'This DSH build has no agentPresets.remove(), so deleting is unavailable.',
318
332
  'api.deleteFailed': 'Delete failed: {detail}',
package/meta.mjs CHANGED
@@ -86,7 +86,12 @@ export function readPresetMeta(directory = PRESET_DIR) {
86
86
  /** Quote and flatten a value into a single-line YAML scalar. */
87
87
  function yamlScalar(value) {
88
88
  const flat = String(value).replace(/\r?\n/g, ' ').trim()
89
- return '"' + flat.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
89
+ // `JSON.stringify` 恰好就是 YAML 双引号标量需要的转义:除了 `"` 与 `\`,它还会把控制字符写成
90
+ // `\u0007` 这类转义序列。此前手工只转前两个,于是名字里一个 BEL / VT / NUL 就会写出平台
91
+ // js-yaml 解析不了的 preset.yml —— 这个模式在**所有选择器**里退化成裸目录 id,description 与
92
+ // order 一起丢,而本插件自己的页面还显示着那个名字(外部评审实测)。
93
+ // 对普通名字(含中文)输出与旧实现逐字节相同,所以这次替换不会动任何既有文件。
94
+ return JSON.stringify(flat)
90
95
  }
91
96
 
92
97
  /**
@@ -104,7 +109,7 @@ function yamlScalar(value) {
104
109
  */
105
110
  export function writePresetMeta(name, description, directory = PRESET_DIR, options = {}) {
106
111
  const cleanName = typeof name === 'string' ? name.replace(/\r?\n/g, ' ').trim() : ''
107
- if (cleanName === '') return { ok: false, error: '模式名称不能为空。' }
112
+ if (cleanName === '') return { ok: false, code: 'nameRequired', error: '模式名称不能为空。' }
108
113
  const lines = ['name: ' + yamlScalar(cleanName)]
109
114
  if (typeof description === 'string' && description.trim() !== '') {
110
115
  lines.push('description: ' + yamlScalar(description))
@@ -118,7 +123,11 @@ export function writePresetMeta(name, description, directory = PRESET_DIR, optio
118
123
  // 实测审阅指出这里原先用的是裸 writeFileSync,与设置页的纪律不一致;现在共用同一份实现。
119
124
  writeAtomic(presetMetaPath(directory), lines.join('\n') + '\n')
120
125
  } catch (error) {
121
- return { ok: false, error: '写入 preset.yml 失败:' + String((error && error.message) || error) }
126
+ // 带 `code`:这条是保存路径上最后一个没有 code 的用户可见失败,英文界面此前会在这里显示中文。
127
+ // 它发生得尤其难受 —— 组成文件与提示词**已经写成功**了,只有 preset.yml 没写成,所以页面
128
+ // 说"失败"而磁盘上是新提示词 + 旧名字(外部评审复现)。
129
+ const detail = String((error && error.message) || error)
130
+ return { ok: false, code: 'metaWriteFailed', params: { detail }, error: '写入 preset.yml 失败:' + detail }
122
131
  }
123
132
  return { ok: true, name: cleanName }
124
133
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "1.9.6",
4
- "description": "Custom modes and custom prompts for DeepSeek Harness (dsh): edit a mode's system prompt on the settings page (it takes effect on the next model step), choose its base mode, switch plugins row by row, and keep several assistants side by side.",
3
+ "version": "1.9.7",
4
+ "description": "Custom modes and custom prompts for DeepSeek Harness (dsh): edit a mode's system prompt on the settings page (it takes effect on the next model step), pick its base mode, switch plugin rows one by one, and keep several assistants side by side — coding, chat or roleplay (RP) personas (multi-mode / multi-agent).",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./index.mjs",
@@ -33,7 +33,10 @@
33
33
  "custom-prompt",
34
34
  "prompt-editor",
35
35
  "prompt-engineering",
36
- "persona"
36
+ "persona",
37
+ "multi-agent",
38
+ "roleplay",
39
+ "chat"
37
40
  ],
38
41
  "dsh": {
39
42
  "bundle": {
@@ -27,7 +27,7 @@
27
27
 
28
28
  import { dirname, join } from 'node:path'
29
29
  import { randomBytes } from 'node:crypto'
30
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
30
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
31
31
  import { fileURLToPath } from 'node:url'
32
32
 
33
33
  /** Absolute path of the prompt file this preset reads. */
@@ -216,6 +216,17 @@ function makeDefinition(modeName) {
216
216
  }
217
217
  }
218
218
 
219
+ // **显式白名单,不再靠"落到最后就当写入"。** 这段代码到这儿只剩整体替换,此前它对任何
220
+ // 不认识的 action(`replace` / `Write` / `overwrite` / 空串 / `append ` 末尾多一个空格)
221
+ // 都静默照做,而审批闸门只认识 write/append —— 一个词的拼写差异就能把用户的提示词整体
222
+ // 换掉且不弹审批(外部评审实测)。不认识的动词现在一律拒绝并说明,模型据此改对再调。
223
+ if (action !== 'write') {
224
+ return (
225
+ '写入被拒绝:未知的 action「' + action + '」。只接受 read / write / append' +
226
+ '(未指定 action 按 read 处理);append 必须精确拼写,末尾多一个空格不算。'
227
+ )
228
+ }
229
+
219
230
  if (typeof args.text !== 'string' || args.text.trim() === '') {
220
231
  return '写入被拒绝:action 为 "write" 时必须提供非空的 text。'
221
232
  }
@@ -263,9 +274,17 @@ function registerApprovalGate(ctx) {
263
274
  () => ctx.on('tools/pre-execute', (exec, next) => {
264
275
  if (exec === null || typeof exec !== 'object' || exec.name !== TOOL_NAME) return next()
265
276
  const args = exec.arguments
266
- const action = args !== null && typeof args === 'object' ? args.action : undefined
267
- // 拦所有会改文件的动作:write 与 append 都在改系统提示词;未指定 action 时按 read 处理,不拦。
268
- if (action !== 'write' && action !== 'append') return next()
277
+ // **只放行真正的读取。** 未指定 action 与 execute 一样按 read 处理;其余一律发问。
278
+ //
279
+ // 此前这里只拦 `write` / `append` 两个字面量,而 execute 把"既不是 read 也不是 append"的
280
+ // **任何** action 都当成整体替换 —— 于是 `replace` / `Write` / 空串 / 末尾多一个空格都能
281
+ // 绕过审批面板直接改掉用户的提示词(外部评审实测复现)。闸门要盖住"一切可能改文件的调用",
282
+ // 不能只认识它恰好想到的那两个词。execute 那边同时加了显式白名单,两层各自独立。
283
+ const raw = args !== null && typeof args === 'object' ? args.action : undefined
284
+ // 只有"没提供 action"才等同 read(execute 也是这样兜的);**提供了但不认识**(含空串)
285
+ // 一律发问 —— 闸门的判据是"这次调用会不会改文件",不是"这个动词我认不认识"。
286
+ const action = typeof raw === 'string' ? raw : 'read'
287
+ if (action === 'read') return next()
269
288
  const verb = action === 'append' ? '追加到' : '整体替换为'
270
289
  const text = typeof args.text === 'string' ? args.text : ''
271
290
  const firstLine = text.split('\n').find((line) => line.trim() !== '') ?? ''