dsh-custom-mode 1.6.1 → 1.7.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/README.md CHANGED
@@ -33,7 +33,7 @@ The mode is **web-profile only**: `agent-presets`, the service that mounts prese
33
33
 
34
34
  ## Requirements
35
35
 
36
- - **dsh `>=0.1.6-alpha.1`** — declared in `engines.dsh` and as an *optional* peer range. This package never
36
+ - **dsh `>=0.1.5-rc.2 <0.2.0-0`**(latest stable `0.1.5-rc.2` and latest preview `0.1.6-alpha.2`)** — declared in `engines.dsh` and as an *optional* peer range. This package never
37
37
  imports `@deepseek-ai/dsh`; it only reads the host services dsh injects.
38
38
  - **No build step, no dependencies**: the source is published as-is and uses only Node's standard library.
39
39
  - The package version is its own line (`1.0.0`, `1.0.1`, …). Which dsh it supports is declared in
package/assistants.mjs CHANGED
@@ -29,6 +29,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
29
29
  import { dirname, join } from 'node:path'
30
30
  import { dshHome, PRESET_DIR } from './paths.mjs'
31
31
  import { readPresetMeta, writePresetMeta } from './meta.mjs'
32
+ import { writeAtomic } from './atomic.mjs'
32
33
  import { seedPreset, seedPresetWithLog } from './seed.mjs'
33
34
 
34
35
  /**
@@ -289,12 +290,12 @@ export function createAssistantDir({ root, id, composition, templateDir }) {
289
290
  * @param {{root: string, templateDir?: string, log?: Function, info?: Function}} input
290
291
  * @returns {{created: boolean, repaired: number, adopted: boolean}}
291
292
  */
292
- export function seedOnActivation({ root, templateDir, log = console.error, info = console.log }) {
293
+ export function seedOnActivation({ root, templateDir, composition, log = console.error, info = console.log }) {
293
294
  const existing = scanManagedDirs(root)
294
295
  let repaired = 0
295
296
  for (const dir of existing) {
296
297
  // Fill-only, exactly like the original single-preset behaviour.
297
- const result = seedPreset(dir, templateDir)
298
+ const result = seedPreset(dir, templateDir, { composition })
298
299
  if (result.created.length > 0) {
299
300
  repaired += 1
300
301
  info(`custom-mode: 已补全 ${dir} 缺失的模板文件(${result.created.join(', ')})`)
@@ -308,7 +309,7 @@ export function seedOnActivation({ root, templateDir, log = console.error, info
308
309
  return { created: false, repaired, adopted: true }
309
310
  }
310
311
 
311
- const created = seedPresetWithLog(join(root, LEGACY_ID), log, info, templateDir)
312
+ const created = seedPresetWithLog(join(root, LEGACY_ID), log, info, templateDir, { composition })
312
313
  markSeeded(root)
313
314
  return { created: created.created.length > 0, repaired, adopted: false }
314
315
  }
@@ -347,11 +348,39 @@ export function reorderAssistant(rows, input, write = writePresetMeta) {
347
348
  const [moved] = next.splice(index, 1)
348
349
  next.splice(target, 0, moved)
349
350
 
351
+ // N 个 preset.yml 无法一次事务化:先记下原值,写失败就回滚已经写过的那些 —— 否则用户会得到一个
352
+ // 只排了一半的顺序(审阅点名)。回滚失败只报告,不掩盖原始错误。
353
+ const snapshot = []
354
+ const written = []
355
+ for (const item of next) {
356
+ const directory = assistantDir(rows, item.id)
357
+ if (directory === undefined) continue
358
+ try {
359
+ snapshot.push({ item, text: readFileSync(presetMetaPath(directory), 'utf8') })
360
+ } catch {
361
+ snapshot.push({ item, text: null })
362
+ }
363
+ }
350
364
  for (const [position, item] of next.entries()) {
351
365
  const directory = assistantDir(rows, item.id)
352
366
  if (directory === undefined) continue
353
367
  const result = write(item.name, item.description, directory, { order: position + 1 })
354
- if (result.ok !== true) return result
368
+ if (result.ok !== true) {
369
+ const failed = assistantDir(rows, item.id)
370
+ for (const done of written) {
371
+ if (done.text === null) continue
372
+ try {
373
+ writeAtomic(presetMetaPath(done.directory), done.text)
374
+ } catch {
375
+ /* 回滚失败不掩盖原始错误 */
376
+ }
377
+ }
378
+ return {
379
+ ...result,
380
+ error: result.error + `(已回滚 ${String(written.length)} 个已写入的顺序;失败的目录:${String(failed)})`,
381
+ }
382
+ }
383
+ written.push({ directory, text: snapshot.find((entry) => entry.item.id === item.id)?.text ?? null })
355
384
  }
356
385
  return {
357
386
  ok: true,
package/atomic.mjs ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Atomic file writes — one implementation for every writer in the host half.
3
+ *
4
+ * **Why this is a module and not a helper inside `index.mjs`**: the settings page, the preset meta writer and
5
+ * the seeder all write user-visible files, and "we are careful here but not there" is how a project gets a
6
+ * half-written `preset.yml` (whose failure mode is the whole mode disappearing from every picker). A review
7
+ * found exactly that split: `index.mjs` had the retrying atomic write while `meta.mjs` used a bare
8
+ * `writeFileSync` under a comment promising the opposite. Sharing the function makes the discipline structural.
9
+ *
10
+ * The two things it buys:
11
+ * - **temporary name + rename**, so a reader never observes a half-written file (the prompt reader caches by
12
+ * mtime+size, and `preset.yml` is parsed by the platform);
13
+ * - **retry on Windows**, where two concurrent renames onto the same target fail with `EPERM`/`EBUSY`; a
14
+ * random temporary name does not help with *that* collision, only with temp-vs-temp ones.
15
+ */
16
+ import { randomBytes } from 'node:crypto'
17
+ import { renameSync, rmSync, writeFileSync } from 'node:fs'
18
+
19
+ /** Synchronous backoff: these write paths are synchronous, so waiting is simpler than async plumbing. */
20
+ function sleepSync(ms) {
21
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
22
+ }
23
+
24
+ /**
25
+ * `rename` with retries.
26
+ *
27
+ * @param {string} from - the temporary file that already holds the content.
28
+ * @param {string} to - the destination.
29
+ * @param {{rename?: Function, sleep?: Function, attempts?: number}} [options] - injectable for tests, which is
30
+ * how the Windows-only retry behaviour is pinned on Linux.
31
+ * @returns {void}
32
+ */
33
+ export function renameWithRetry(from, to, options = {}) {
34
+ const rename = typeof options.rename === 'function' ? options.rename : renameSync
35
+ const sleep = typeof options.sleep === 'function' ? options.sleep : sleepSync
36
+ const attempts = Number.isInteger(options.attempts) ? options.attempts : 8
37
+ for (let attempt = 1; ; attempt += 1) {
38
+ try {
39
+ rename(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
+ // 8 attempts with a growing backoff (20, 40, … 160 ms) — measured: a review still saw 1 failure in 10
46
+ // concurrent saves with the previous 5×20 ms, so the window is wider than that on Windows.
47
+ sleep(20 * attempt)
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Stage content into a temporary file next to its destination, without touching the destination yet.
54
+ *
55
+ * Used when several files must change together (the settings page writes the composition and the prompt):
56
+ * staging both first means a failure while *preparing* one leaves the other untouched, which is the window a
57
+ * review pointed at ("`saveState` is not transactional; the second write failing leaves a mixed state").
58
+ *
59
+ * @param {string} file - destination path.
60
+ * @param {string} text - content to write into the temporary.
61
+ * @returns {{file: string, temporary: string}} handle for {@link commitStaged} / {@link discardStaged}.
62
+ */
63
+ export function stageAtomic(file, text) {
64
+ const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
65
+ writeFileSync(temporary, text, 'utf8')
66
+ return { file, temporary }
67
+ }
68
+
69
+ /** Rename a staged temporary onto its destination (with the Windows retry). */
70
+ export function commitStaged(staged) {
71
+ renameWithRetry(staged.temporary, staged.file)
72
+ }
73
+
74
+ /** Remove a staged temporary without touching the destination. */
75
+ export function discardStaged(staged) {
76
+ try {
77
+ rmSync(staged.temporary, { force: true })
78
+ } catch {
79
+ /* best effort */
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Write several files together: stage them all, then commit them all.
85
+ *
86
+ * Not a true transaction — a rename can still fail between two commits — but it shrinks the mixed-state window
87
+ * to a single rename, and any failure discards the staged temporaries so no `.tmp-…` is left behind.
88
+ *
89
+ * @param {Array<[string, string]>} entries - `[file, text]` pairs.
90
+ * @returns {void}
91
+ */
92
+ export function writeAtomicPair(entries) {
93
+ const staged = entries.map(([file, text]) => stageAtomic(file, text))
94
+ try {
95
+ for (const one of staged) commitStaged(one)
96
+ } catch (error) {
97
+ for (const one of staged) discardStaged(one)
98
+ throw error
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Write a file so that readers see either the old content or the new one, never a mix.
104
+ *
105
+ * The temporary name is unique per call (pid + random suffix). On failure the temporary file is removed —
106
+ * an orphan `.tmp-…` inside an assistant directory is not just litter: the roster scans that directory.
107
+ *
108
+ * @param {string} file - destination path.
109
+ * @param {string} text - content to write.
110
+ * @returns {void}
111
+ */
112
+ export function writeAtomic(file, text) {
113
+ const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
114
+ writeFileSync(temporary, text, 'utf8')
115
+ try {
116
+ renameWithRetry(temporary, file)
117
+ } catch (error) {
118
+ try {
119
+ rmSync(temporary, { force: true })
120
+ } catch {
121
+ /* cleaning up must not mask the original error */
122
+ }
123
+ throw error
124
+ }
125
+ }
package/client.js CHANGED
@@ -96,6 +96,8 @@ try {
96
96
  "api.unknownAssistant": "找不到助手「{id}」:这一页只管理本工具创建的助手(目录里有 prompt.md,且组成文件用 prompt-reader.mjs 注入身份)。",
97
97
  "api.alreadyFirst": "「{name}」已经在最前面。",
98
98
  "api.alreadyLast": "「{name}」已经在最后面。",
99
+ "api.badVariableName": "变量引用的写法不合法:{variable} 里的名字只能用小写字母、数字、下划线,且以字母开头。要写字面量花括号,请用单个左花括号,或不闭合的双左花括号。",
100
+ "api.unknownVariable": "{variable} 不是已注册的变量,渲染会报错并让本模式每个请求都失败。可用:{known}。",
99
101
  "api.saved": "已保存({name},基础模式 {mode})。新建会话即生效,当前会话保持原配置。",
100
102
  "api.created": "已创建「{name}」。现在可以为它写系统提示词。",
101
103
  "api.duplicated": "已复制自「{from}」。两份从此各改各的。",
@@ -117,6 +119,8 @@ try {
117
119
  "api.deleteFailed": "删除失败:{detail}",
118
120
  "api.versionMissing": "找不到这个版本(历史可能已被上限裁剪)。",
119
121
  "api.badJson": "请求体不是合法 JSON",
122
+ "warn.approvalGateMissing.label": "审批闸门未启用",
123
+ "warn.approvalGateMissing.hint": "这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词**不会**弹审批。设置页不受影响;要恢复保护请升级 DSH,或把「custom_prompt 工具」那一行关掉。",
120
124
  "warn.personaOffWithPrompt": "「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。",
121
125
  "warn.toolOff": "「custom_prompt 工具」这一行是关的:会话里无法让 agent 改提示词,只能在本页改。",
122
126
  "warn.noDescription": "没有描述:新建会话的模式选择器里会显示成「暂无描述」。",
@@ -143,7 +147,7 @@ try {
143
147
  "name.placeholder": "自定义模式",
144
148
  "name.descriptionPlaceholder": "模式描述(显示在模式选择器里,可留空)",
145
149
  "mode.heading": "基础模式",
146
- "mode.hint": "选一个官方模式作为底子,下面再按行微调。注意:底子只决定**行集合**与工具能力 —— 本模式的 persona 行始终替换掉底子那一行(提示词由你编辑,complete: false),底子的提示词语义不会被继承。改完保存后,新建会话即生效,不需要重启。",
150
+ "mode.hint": "选一个官方模式作为底子,下面再按行微调。注意:底子只决定「行集合」与工具能力 —— 本模式的 persona 行始终替换掉底子那一行(提示词由你编辑,complete: false),底子的提示词语义不会被继承。改完保存后,新建会话即生效,不需要重启。",
147
151
  "rows.heading": "插件开关",
148
152
  "rows.hint": "逐行控制这个模式挂载哪些插件,和官方插件列表一样按行铺开。没拨过的行保持官方默认(含平台判断);你手动拨了就以你的为准。",
149
153
  "prompt.heading": "系统提示词",
@@ -266,6 +270,8 @@ try {
266
270
  "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).",
267
271
  "api.alreadyFirst": "「{name}」 is already first.",
268
272
  "api.alreadyLast": "「{name}」 is already last.",
273
+ "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.",
274
+ "api.unknownVariable": "{variable} is not a registered variable — rendering would fail every request in this mode. Available: {known}.",
269
275
  "api.saved": "Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.",
270
276
  "api.created": "Created 「{name}」. You can write its system prompt now.",
271
277
  "api.duplicated": "Copied from 「{from}」. The two are independent from now on.",
@@ -290,6 +296,8 @@ try {
290
296
  "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.",
291
297
  "warn.toolOff": "The \"custom_prompt tool\" row is off: the agent cannot change the prompt from inside a session, only this page can.",
292
298
  "warn.noDescription": "No description: the new-session mode picker will show it as \"no description yet\".",
299
+ "warn.approvalGateMissing.label": "Approval gate is off",
300
+ "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.",
293
301
  "warn.noName": "No name: the mode picker will show the directory id (e.g. custom).",
294
302
  "history.label": "Change history",
295
303
  "history.pick": "Pick a version to load…",
package/index.mjs CHANGED
@@ -37,8 +37,8 @@
37
37
  * rows the user changed by diffing against the same shipped base mode.
38
38
  */
39
39
 
40
- import { randomBytes } from 'node:crypto'
41
- import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
40
+ import { writeAtomic, writeAtomicPair } from './atomic.mjs'
41
+ import { existsSync, readFileSync, 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 {
@@ -52,7 +52,7 @@ import {
52
52
  } from './composition.mjs'
53
53
  import { readPresetMeta, writePresetMeta, presetMetaPath, PRESET_META_PATH } from './meta.mjs'
54
54
  import { listHistory, readVersion, recordExternalChange, recordPrompt, HISTORY_SOURCE } from './journal.mjs'
55
- import { packagedPresetDir } from './seed.mjs'
55
+ import { packagedPresetDir, starterComposition } from './seed.mjs'
56
56
  import {
57
57
  allocateId,
58
58
  assistantDir,
@@ -61,6 +61,7 @@ import {
61
61
  createAssistantDir,
62
62
  reorderAssistant,
63
63
  seedOnActivation,
64
+ LEGACY_ID,
64
65
  userPresetRoot,
65
66
  } from './assistants.mjs'
66
67
 
@@ -128,6 +129,8 @@ export function checkPromptText(text) {
128
129
  if (!VARIABLE_NAME.test(variable)) {
129
130
  return {
130
131
  ok: false,
132
+ code: 'badVariableName',
133
+ params: { variable: '{{' + variable + '}}' },
131
134
  error:
132
135
  '保存被拒绝:{{' +
133
136
  variable +
@@ -138,6 +141,8 @@ export function checkPromptText(text) {
138
141
  if (!KNOWN_VARIABLES.includes(variable)) {
139
142
  return {
140
143
  ok: false,
144
+ code: 'unknownVariable',
145
+ params: { variable: '{{' + variable + '}}', known: KNOWN_VARIABLES.map((item) => '{{' + item + '}}').join('、') },
141
146
  error:
142
147
  '保存被拒绝:{{' +
143
148
  variable +
@@ -274,7 +279,12 @@ export function readState(rows, id, options = {}) {
274
279
  // 改动历史(只有元数据,正文按需取:见 GET /custom-mode/history)。
275
280
  history: listHistory(directory),
276
281
  // 「配置了却不生效」的告警码(文案在页面侧按语言渲染)。
277
- warnings: configWarnings(text, prompt.ok === true ? prompt.text : '', meta),
282
+ warnings: [
283
+ ...configWarnings(text, prompt.ok === true ? prompt.text : '', meta),
284
+ // 审批闸门缺失:由预置侧在注册失败时留下标记文件(宿主半看不到那个事件是否真的有人监听)。
285
+ // 诚实地把它变成页面上的告警,而不是只留在 console.error 里。
286
+ ...(existsSync(join(directory, 'approval-gate-missing')) ? ['approvalGateMissing'] : []),
287
+ ],
278
288
  }
279
289
  }
280
290
 
@@ -297,6 +307,23 @@ export function readList(rows) {
297
307
  * @param {Array<object>} rows - the current roster.
298
308
  * @param {object} input - `{ id, mode, overrides, prompt, name, description }`.
299
309
  */
310
+ /**
311
+ * 进程内按助手串行化写入。
312
+ *
313
+ * 实测(外部审阅,Windows):即使有 rename 重试,同一助手 10 个并发保存里仍有 1 次 EPERM —— 因为两个
314
+ * 处理器在**同一时刻**准备并改名同一对文件。重试覆盖的是跨进程窗口;这里消掉的是本进程内的竞争,也就是
315
+ * 我们能真正保证的那一半。(两个实例共用同一个 DSH_HOME 的跨进程竞争,插件无法串行化,那里靠重试。)
316
+ */
317
+ const writeChains = new Map()
318
+
319
+ function serializedWrite(key, work) {
320
+ const previous = writeChains.get(key) ?? Promise.resolve()
321
+ const next = previous.then(work, work)
322
+ // 链本身必须永远处于 fulfilled 状态,否则一次失败会卡死后续所有写入。
323
+ writeChains.set(key, next.then(() => undefined, () => undefined))
324
+ return next
325
+ }
326
+
300
327
  export function saveState(rows, input) {
301
328
  const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
302
329
  const directory = assistantDir(rows, id)
@@ -311,7 +338,10 @@ export function saveState(rows, input) {
311
338
  return { ok: false, code: 'promptEmpty', error: '保存被拒绝:系统提示词为空。留空不会清空身份,读取器会沿用上一版。' }
312
339
  }
313
340
  const verdict = checkPromptText(prompt)
314
- if (verdict.ok !== true) return { ok: false, error: verdict.error }
341
+ if (verdict.ok !== true) {
342
+ // 带上 code/params:英文界面由页面自己的词典渲染;中文串保留给直接调 HTTP API 的调用方。
343
+ return { ok: false, code: verdict.code, params: verdict.params, error: verdict.error }
344
+ }
315
345
 
316
346
  const rawName = input !== null && typeof input === 'object' && typeof input.name === 'string' ? input.name : ''
317
347
  const name = rawName.replace(/\r?\n/g, ' ').trim()
@@ -350,8 +380,12 @@ export function saveState(rows, input) {
350
380
  }
351
381
 
352
382
  try {
353
- writeAtomic(compositionFile(directory), composition)
354
- writeAtomic(promptFile(directory), prompt)
383
+ // 组成文件与提示词必须一起更新:先都写进临时文件再一起换名,失败时不会留下"新组成 + 旧提示词"
384
+ // 这种混合状态(审阅指出原先两次独立原子写之间存在这个窗口)。
385
+ writeAtomicPair([
386
+ [compositionFile(directory), composition],
387
+ [promptFile(directory), prompt],
388
+ ])
355
389
  // 改动留痕:三个改动路径(设置页 / 会话内工具 / 手工编辑)里,只有设置页是"当场知道"的。
356
390
  // 另外两条由 readState 对比补记(见 journal.mjs 的单写者说明)。
357
391
  recordPrompt(directory, prompt, HISTORY_SOURCE.settings)
@@ -422,11 +456,16 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
422
456
  // file embeds the new assistant's own name and id.
423
457
  let source = null
424
458
  const from = input !== null && typeof input === 'object' && typeof input.from === 'string' ? input.from : ''
459
+ // 显示名在分支里计算,但返回语句在分支外 —— 所以声明在外层。
460
+ let fromDisplayName = from
425
461
  if (from !== '') {
426
462
  const fromDir = assistantDir(rows, from)
427
463
  if (fromDir === undefined) return unknownAssistant(from)
428
464
  const fromComposition = compositionFile(fromDir)
429
465
  if (!existsSync(fromComposition)) return { ok: false, code: 'compositionMissing', params: { path: fromComposition }, error: '找不到组成文件:' + fromComposition }
466
+ // 显示名(`rows` 里的 name)优先于内部 id —— 与删除一致。
467
+ const fromRow = rows.find((item) => item !== null && typeof item === 'object' && item.id === from)
468
+ if (typeof fromRow?.name === 'string' && fromRow.name.trim() !== '') fromDisplayName = fromRow.name
430
469
  const text = readFileSync(fromComposition, 'utf8')
431
470
  const sourceMode = modeOf(text)
432
471
  const sourcePrompt = readPrompt(fromDir)
@@ -467,7 +506,7 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
467
506
  name,
468
507
  code: source === null ? 'created' : 'duplicated',
469
508
  // D5:文案里用**显示名**而不是内部目录 id。
470
- params: source === null ? { name } : { name, from: source === null ? '' : from },
509
+ params: source === null ? { name } : { name, from: fromDisplayName },
471
510
  note: source === null
472
511
  ? '已创建「' + name + '」。它的系统提示词现在是模板默认文本;写好后新建会话即可选择它。'
473
512
  : '已复制出「' + name + '」:提示词、基础模式与插件开关都来自「' + from + '」,之后各改各的,互不影响。',
@@ -492,12 +531,17 @@ export async function deleteAssistant(rows, input, agentPresets) {
492
531
  if (typeof agentPresets?.remove !== 'function') {
493
532
  return { ok: false, code: 'noRemoveApi', error: '当前 DSH 版本没有 agentPresets.remove(),无法删除。' }
494
533
  }
534
+ // 文案用**显示名**,不用内部目录 id(复制/删除的状态行曾把 id 暴露给用户,审阅点名)。
535
+ const displayName = (() => {
536
+ const row = Array.isArray(rows) ? rows.find((item) => item !== null && typeof item === 'object' && item.id === id) : undefined
537
+ return typeof row?.name === 'string' && row.name.trim() !== '' ? row.name : id
538
+ })()
495
539
  try {
496
540
  await agentPresets.remove(id)
497
541
  } catch (error) {
498
542
  return { ok: false, code: 'deleteFailed', params: { detail: describe(error) }, error: '删除失败:' + describe(error) }
499
543
  }
500
- return { ok: true, id, code: 'deleted', params: { name: id }, note: '已删除「' + id + '」。正在使用它的会话不受影响;新建会话时不再出现。' }
544
+ return { ok: true, id, code: 'deleted', params: { name: displayName }, note: '已删除「' + displayName + '」。正在使用它的会话不受影响;新建会话时不再出现。' }
501
545
  }
502
546
 
503
547
  /**
@@ -559,7 +603,14 @@ export function apply(ctx) {
559
603
  // generated composition) and creates the legacy assistant only on a first run — see
560
604
  // the assistant registry for why a deleted assistant must not come back.
561
605
  try {
562
- seedOnActivation({ root: dirname(PRESET_DIR), templateDir: packagedPresetDir() })
606
+ seedOnActivation({
607
+ root: dirname(PRESET_DIR),
608
+ templateDir: packagedPresetDir(),
609
+ // 组成文件**不照搬包内模板**,而是按本机装的那条 dsh 线派生:模板是某一条线渲染出来的,
610
+ // 另一条线可能根本没有它的某些行(实测:预览线的 workflow-ptc 在稳定线上不存在,
611
+ // 平台会把整个预设判为 broken 并从所有选择器里静默丢弃)。
612
+ composition: starterComposition({ assistantId: LEGACY_ID }),
613
+ })
563
614
  } catch (error) {
564
615
  console.error('custom-mode: 初始化 preset 目录时出现意外错误(已忽略): ' + describe(error))
565
616
  }
@@ -677,19 +728,21 @@ export function apply(ctx) {
677
728
  return json({ ok: false, code: 'badJson', error: '请求体不是合法 JSON' }, 400)
678
729
  }
679
730
  await ensureShipped()
731
+ const targetId = parsed !== null && typeof parsed === 'object' && typeof parsed.id === 'string' ? parsed.id : ''
680
732
  if (pathname === STATE_PATH) {
681
- const result = saveState(await roster(), parsed)
733
+ const result = await serializedWrite('save:' + targetId, async () => saveState(await roster(), parsed))
682
734
  return json(result, result.ok === true ? 200 : 400)
683
735
  }
684
736
  if (pathname === CREATE_PATH) {
685
- const result = createAssistant(await roster(), parsed)
737
+ // 新建与排序改的是整棵树(根目录 + 每个助手的 preset.yml),所以用同一把"树锁"。
738
+ const result = await serializedWrite('tree', async () => createAssistant(await roster(), parsed))
686
739
  return json(result, result.ok === true ? 200 : 400)
687
740
  }
688
741
  if (pathname === REORDER_PATH) {
689
- const result = reorderAssistant(await roster(), parsed)
742
+ const result = await serializedWrite('tree', async () => reorderAssistant(await roster(), parsed))
690
743
  return json(result, result.ok === true ? 200 : 400)
691
744
  }
692
- const result = await deleteAssistant(await roster(), parsed, scope.agentPresets)
745
+ const result = await serializedWrite('delete:' + targetId, async () => deleteAssistant(await roster(), parsed, scope.agentPresets))
693
746
  return json(result, result.ok === true ? 200 : 400)
694
747
  } catch (error) {
695
748
  return json({ ok: false, error: describe(error) }, 500)
@@ -720,58 +773,9 @@ export function apply(ctx) {
720
773
  * 要么看到新内容,不会读到写了一半的文件。原来连续两次 writeFileSync 在极端时序下可能被读成撕裂的
721
774
  * prompt,而这个文件正是"用户的提示词"。
722
775
  */
723
- function writeAtomic(file, text) {
724
- // 临时名必须**每个请求唯一**:只带 pid 时,同一进程内两个并发保存会争同一个临时名。
725
- // 但随机后缀只消除 tmp-vs-tmp 竞争 —— Windows 上**两个 rename 指向同一目标**仍会以
726
- // EPERM/EBUSY 失败(实测:10 并发保存 2 例 400),所以还要短退避重试。
727
- const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
728
- writeFileSync(temporary, text, 'utf8')
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
- }
774
- }
776
+ // 原子写与重试只有一份实现(见 atomic.mjs 的说明);这里再导出 renameWithRetry,
777
+ // 让既有的路由测试继续能从 index.mjs 取到它。
778
+ export { renameWithRetry } from './atomic.mjs'
775
779
 
776
780
  /** `error` as a readable string, without assuming it is an Error. */
777
781
  function describe(error) {
package/locales.mjs CHANGED
@@ -46,7 +46,6 @@ export const zh = {
46
46
  'msg.createFailed': '创建失败',
47
47
  'msg.deleteFailed': '删除失败',
48
48
  'msg.nameRequired': '请先给新助手起个名字。',
49
- 'msg.unsaved': '未保存',
50
49
  'msg.readOnlyHint': '这一页只管理本工具创建的助手;手写的 preset 不在这里,也不会被改写。',
51
50
  'msg.reordered': '顺序已保存:新建会话时的模式选择器按这个顺序排列。',
52
51
  'msg.reorderFailed': '调整顺序失败',
@@ -56,6 +55,8 @@ export const zh = {
56
55
  'api.unknownAssistant': '找不到助手「{id}」:这一页只管理本工具创建的助手(目录里有 prompt.md,且组成文件用 prompt-reader.mjs 注入身份)。',
57
56
  'api.alreadyFirst': '「{name}」已经在最前面。',
58
57
  'api.alreadyLast': '「{name}」已经在最后面。',
58
+ 'api.badVariableName': '变量引用的写法不合法:{variable} 里的名字只能用小写字母、数字、下划线,且以字母开头。要写字面量花括号,请用单个左花括号,或不闭合的双左花括号。',
59
+ 'api.unknownVariable': '{variable} 不是已注册的变量,渲染会报错并让本模式每个请求都失败。可用:{known}。',
59
60
  'api.saved': '已保存({name},基础模式 {mode})。新建会话即生效,当前会话保持原配置。',
60
61
  'api.created': '已创建「{name}」。现在可以为它写系统提示词。',
61
62
  'api.duplicated': '已复制自「{from}」。两份从此各改各的。',
@@ -77,6 +78,8 @@ export const zh = {
77
78
  'api.deleteFailed': '删除失败:{detail}',
78
79
  'api.versionMissing': '找不到这个版本(历史可能已被上限裁剪)。',
79
80
  'api.badJson': '请求体不是合法 JSON',
81
+ 'warn.approvalGateMissing.label': '审批闸门未启用',
82
+ 'warn.approvalGateMissing.hint': '这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词**不会**弹审批。设置页不受影响;要恢复保护请升级 DSH,或把「custom_prompt 工具」那一行关掉。',
80
83
  'warn.personaOffWithPrompt': '「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。',
81
84
  'warn.toolOff': '「custom_prompt 工具」这一行是关的:会话里无法让 agent 改提示词,只能在本页改。',
82
85
  'warn.noDescription': '没有描述:新建会话的模式选择器里会显示成「暂无描述」。',
@@ -105,7 +108,7 @@ export const zh = {
105
108
  'name.descriptionPlaceholder': '模式描述(显示在模式选择器里,可留空)',
106
109
 
107
110
  'mode.heading': '基础模式',
108
- 'mode.hint': '选一个官方模式作为底子,下面再按行微调。注意:底子只决定**行集合**与工具能力 —— 本模式的 persona 行始终替换掉底子那一行(提示词由你编辑,complete: false),底子的提示词语义不会被继承。改完保存后,新建会话即生效,不需要重启。',
111
+ 'mode.hint': '选一个官方模式作为底子,下面再按行微调。注意:底子只决定「行集合」与工具能力 —— 本模式的 persona 行始终替换掉底子那一行(提示词由你编辑,complete: false),底子的提示词语义不会被继承。改完保存后,新建会话即生效,不需要重启。',
109
112
 
110
113
  'rows.heading': '插件开关',
111
114
  'rows.hint': '逐行控制这个模式挂载哪些插件,和官方插件列表一样按行铺开。没拨过的行保持官方默认(含平台判断);你手动拨了就以你的为准。',
@@ -233,7 +236,6 @@ export const en = {
233
236
  'msg.createFailed': 'Could not create',
234
237
  'msg.deleteFailed': 'Could not delete',
235
238
  'msg.nameRequired': 'Give the new assistant a name first.',
236
- 'msg.unsaved': 'Unsaved',
237
239
  'msg.readOnlyHint': 'This page manages only the assistants this tool created; a hand-written preset is not listed here and is never rewritten.',
238
240
  'msg.reordered': 'Order saved: the mode picker for new sessions follows it.',
239
241
  'msg.reorderFailed': 'Could not reorder',
@@ -243,6 +245,8 @@ export const en = {
243
245
  '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).',
244
246
  'api.alreadyFirst': '「{name}」 is already first.',
245
247
  'api.alreadyLast': '「{name}」 is already last.',
248
+ '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.',
249
+ 'api.unknownVariable': '{variable} is not a registered variable — rendering would fail every request in this mode. Available: {known}.',
246
250
  'api.saved': 'Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.',
247
251
  'api.created': 'Created 「{name}」. You can write its system prompt now.',
248
252
  'api.duplicated': 'Copied from 「{from}」. The two are independent from now on.',
@@ -267,6 +271,8 @@ export const en = {
267
271
  '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.',
268
272
  'warn.toolOff': 'The "custom_prompt tool" row is off: the agent cannot change the prompt from inside a session, only this page can.',
269
273
  'warn.noDescription': 'No description: the new-session mode picker will show it as "no description yet".',
274
+ 'warn.approvalGateMissing.label': 'Approval gate is off',
275
+ '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.',
270
276
  'warn.noName': 'No name: the mode picker will show the directory id (e.g. custom).',
271
277
  'history.label': 'Change history',
272
278
  'history.pick': 'Pick a version to load…',
package/meta.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { readFileSync, writeFileSync } from 'node:fs'
20
+ import { writeAtomic } from './atomic.mjs'
20
21
  import { join } from 'node:path'
21
22
  import { PRESET_DIR } from './paths.mjs'
22
23
 
@@ -113,7 +114,9 @@ export function writePresetMeta(name, description, directory = PRESET_DIR, optio
113
114
  typeof requested === 'number' && Number.isFinite(requested) ? Math.trunc(requested) : readPresetMeta(directory).order
114
115
  if (order !== undefined) lines.push('order: ' + String(order))
115
116
  try {
116
- writeFileSync(presetMetaPath(directory), lines.join('\n') + '\n', 'utf8')
117
+ // **非原子写的最坏后果在这里**:preset.yml 写坏 = 这个模式从所有选择器里消失(见本文件头注释)。
118
+ // 实测审阅指出这里原先用的是裸 writeFileSync,与设置页的纪律不一致;现在共用同一份实现。
119
+ writeAtomic(presetMetaPath(directory), lines.join('\n') + '\n')
117
120
  } catch (error) {
118
121
  return { ok: false, error: '写入 preset.yml 失败:' + String((error && error.message) || error) }
119
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "1.6.1",
3
+ "version": "1.7.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",
@@ -47,6 +47,7 @@
47
47
  "index.mjs",
48
48
  "assistants.mjs",
49
49
  "seed.mjs",
50
+ "atomic.mjs",
50
51
  "client.js",
51
52
  "composition.mjs",
52
53
  "meta.mjs",
package/preset/preset.yml CHANGED
@@ -1,2 +1,2 @@
1
1
  name: 自定义模式
2
- description: 完整编码能力,系统提示词来自 prompt.md,可在设置页随时修改、下一步即生效。
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.
@@ -25,9 +25,9 @@
25
25
  * needs no isolate realm.
26
26
  */
27
27
 
28
+ import { dirname, join } from 'node:path'
28
29
  import { randomBytes } from 'node:crypto'
29
30
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
30
- import { dirname } from 'node:path'
31
31
  import { fileURLToPath } from 'node:url'
32
32
 
33
33
  /** Absolute path of the prompt file this preset reads. */
@@ -271,10 +271,14 @@ function registerApprovalGate(ctx) {
271
271
  const firstLine = text.split('\n').find((line) => line.trim() !== '') ?? ''
272
272
  return {
273
273
  kind: 'ask',
274
+ // **双语**:这是安全决策界面 —— 用哪种界面语言的用户都必须读懂自己要批准什么。
275
+ // (页面其它文案走词典,但审批面板由平台渲染,插件侧拿不到当前界面语言。)
274
276
  reason:
275
277
  '把「' + resolveModeName(undefined) + '」的系统提示词' + verb + ' ' + String(text.length) + ' 字符' +
276
278
  (firstLine === '' ? '' : ':' + firstLine.trim().slice(0, 60)) +
277
- '(写入 ' + PROMPT_PATH + ')',
279
+ '(写入 ' + PROMPT_PATH + ')' +
280
+ " / Change this mode's system prompt: " + (action === 'append' ? 'append ' : 'replace with ') +
281
+ String(text.length) + ' characters (writes ' + PROMPT_PATH + ')',
278
282
  }
279
283
  }),
280
284
  'custom-prompt.approval-gate',
@@ -282,6 +286,15 @@ function registerApprovalGate(ctx) {
282
286
  return true
283
287
  }
284
288
 
289
+ /**
290
+ * Marker file the host half reads to surface a MISSING approval gate in the settings page.
291
+ *
292
+ * The gate lives on the preset side, which is loaded per session, so the host half cannot see whether it
293
+ * registered. A review called the previous behaviour ("only console.error") a silent gap; this turns it into a
294
+ * visible warning. The marker is removed as soon as registration succeeds.
295
+ */
296
+ export const GATE_MARKER = 'approval-gate-missing'
297
+
285
298
  /** The tool registry is a hard dependency; without it there is no tool. */
286
299
  export const inject = ['tools']
287
300
 
@@ -291,7 +304,14 @@ export function apply(ctx, config = {}) {
291
304
 
292
305
  // 审批闸门。宿主若不支持 `tools/pre-execute`(比本插件声明的下限还老的构建),这里会**明确**
293
306
  // 说一声再继续 —— 降级是有的,但不许静默。
294
- if (registerApprovalGate(ctx) !== true) {
307
+ const gateReady = registerApprovalGate(ctx) === true
308
+ try {
309
+ if (gateReady) rmSync(join(dirname(PROMPT_PATH), GATE_MARKER), { force: true })
310
+ else writeFileSync(join(dirname(PROMPT_PATH), GATE_MARKER), 'this host has no tools/pre-execute event\n', 'utf8')
311
+ } catch {
312
+ /* 标记写不进去不能影响会话;下面的 console.error 仍然是兜底 */
313
+ }
314
+ if (gateReady !== true) {
295
315
  console.error(
296
316
  'custom-mode: 这个宿主没有 tools/pre-execute 事件,会话内改写系统提示词的审批闸门**未启用**' +
297
317
  '(设置页不受影响)。请升级 DSH,或把「custom_prompt 工具」这一行关掉。',
@@ -305,8 +325,35 @@ export function apply(ctx, config = {}) {
305
325
  * 临时名带 pid 与随机后缀:同一进程内的并发写必须各用各的临时名,否则 Windows 上两个
306
326
  * rename 指向同一目标会以 EPERM 失败。
307
327
  */
328
+ /**
329
+ * `rename` 带重试 —— 与设置页同一条纪律(原先这里只有裸 rename,注释却自称"同一条纪律",审阅点名)。
330
+ * Windows 上并发改名到同一目标会短暂 EPERM/EBUSY;退避几毫秒即可跨过。
331
+ */
332
+ function renameWithRetry(from, to, attempts = 8) {
333
+ for (let attempt = 1; ; attempt += 1) {
334
+ try {
335
+ renameSync(from, to)
336
+ return
337
+ } catch (error) {
338
+ const code = error !== null && typeof error === 'object' ? error.code : undefined
339
+ const retryable = code === 'EPERM' || code === 'EBUSY' || code === 'EACCES'
340
+ if (retryable !== true || attempt >= attempts) throw error
341
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * attempt)
342
+ }
343
+ }
344
+ }
345
+
308
346
  function writeAtomic(file, text) {
309
347
  const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
310
348
  writeFileSync(temporary, text, 'utf8')
311
- renameSync(temporary, file)
349
+ try {
350
+ renameWithRetry(temporary, file)
351
+ } catch (error) {
352
+ try {
353
+ rmSync(temporary, { force: true })
354
+ } catch {
355
+ /* 不掩盖原始错误 */
356
+ }
357
+ throw error
358
+ }
312
359
  }
package/seed.mjs CHANGED
@@ -22,6 +22,8 @@
22
22
  */
23
23
 
24
24
  import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
25
+ import { renderComposition } from './composition.mjs'
26
+ import { writeAtomic } from './atomic.mjs'
25
27
  import { dirname, join } from 'node:path'
26
28
  import { fileURLToPath } from 'node:url'
27
29
 
@@ -51,7 +53,45 @@ function describe(error) {
51
53
  * @param {string} [sourceDir] - the packaged copy to copy from.
52
54
  * @returns {{created: string[], kept: string[], errors: string[]}} what happened, per file.
53
55
  */
54
- export function seedPreset(presetDir, sourceDir = packagedPresetDir()) {
56
+ /**
57
+ * The starter composition for a newly seeded assistant, **derived from the composition the installed dsh line
58
+ * actually ships**.
59
+ *
60
+ * **Why not copy the packaged file** (measured, and it was a real defect): the packaged `agent.cordis.yml` was
61
+ * rendered from one dsh line, and the other line may not ship some of its rows. Concretely, the preview line's
62
+ * standard composition enables `workflow-ptc` (`@deepseek-ai/dsh-workflow-ptc`), which the stable line
63
+ * (`0.1.5-rc.2`) does not install at all — the platform's health check then marks the whole preset broken and
64
+ * **silently drops the mode from every picker**, while the settings page keeps working (it never needs the
65
+ * preset to be resolvable). Deriving the rows from what is installed makes that impossible by construction,
66
+ * on any future line as well.
67
+ *
68
+ * @param {{mode?: string, assistantId?: string, modeName?: string}} [options]
69
+ * @returns {string|null} rendered composition, or `null` when the installed composition cannot be read (the
70
+ * caller then keeps the packaged fallback file).
71
+ */
72
+ export function starterComposition(options = {}) {
73
+ const mode = typeof options.mode === 'string' && options.mode !== '' ? options.mode : 'standard'
74
+ try {
75
+ return renderComposition(mode, new Map(), {
76
+ assistantId: typeof options.assistantId === 'string' ? options.assistantId : '',
77
+ modeName: typeof options.modeName === 'string' ? options.modeName : '',
78
+ })
79
+ } catch (error) {
80
+ console.error(
81
+ 'custom-mode: 无法从本机安装的出厂组成派生播种文件(' + describe(error) + '),改用包内模板。' +
82
+ '如果这条 dsh 线与该模板的差异行不匹配,模式可能被判为 broken 而不出现在选择器里。',
83
+ )
84
+ return null
85
+ }
86
+ }
87
+
88
+ /**
89
+ * @param {string} presetDir - where the assistant lives.
90
+ * @param {string} sourceDir - packaged template directory (tests inject their own).
91
+ * @param {{composition?: string|null}} [options] - a rendered composition to write instead of copying
92
+ * `agent.cordis.yml`; see {@link starterComposition}.
93
+ */
94
+ export function seedPreset(presetDir, sourceDir = packagedPresetDir(), options = {}) {
55
95
  const created = []
56
96
  const kept = []
57
97
  const errors = []
@@ -75,6 +115,17 @@ export function seedPreset(presetDir, sourceDir = packagedPresetDir()) {
75
115
  kept.push(name)
76
116
  continue
77
117
  }
118
+ // 组成文件优先用"按本机那条线派生"的内容;只有派生失败(null)时才退回包内模板。
119
+ if (name === 'agent.cordis.yml' && typeof options.composition === 'string' && options.composition !== '') {
120
+ try {
121
+ writeAtomic(target, options.composition)
122
+ created.push(name)
123
+ continue
124
+ } catch (error) {
125
+ errors.push(`写入 ${name} 失败: ${describe(error)}`)
126
+ continue
127
+ }
128
+ }
78
129
  const source = join(sourceDir, name)
79
130
  if (!existsSync(source)) {
80
131
  errors.push(`包内缺少 ${name}`)
@@ -102,10 +153,10 @@ export function seedPreset(presetDir, sourceDir = packagedPresetDir()) {
102
153
  * @param {string} [sourceDir] - packaged template to copy from (defaults to `preset/` beside this module).
103
154
  * @returns {{created: string[], kept: string[], errors: string[]}}
104
155
  */
105
- export function seedPresetWithLog(presetDir, log = console.error, info = console.log, sourceDir = packagedPresetDir()) {
156
+ export function seedPresetWithLog(presetDir, log = console.error, info = console.log, sourceDir = packagedPresetDir(), options = {}) {
106
157
  let result
107
158
  try {
108
- result = seedPreset(presetDir, sourceDir)
159
+ result = seedPreset(presetDir, sourceDir, options)
109
160
  } catch (error) {
110
161
  // seedPreset is written not to throw; this is a last-resort guard so that a bug here
111
162
  // can never stop the host from booting.