dsh-custom-mode 2.2.5 → 2.2.6

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/composition.mjs CHANGED
@@ -132,9 +132,10 @@ export const ROW_META = {
132
132
  'tool-cordis': { label: 'Cordis 运行时工具', note: '可读写 harness 运行时' },
133
133
  'tool-plugin-manager': {
134
134
  label: '插件管理(安装 / 启停)',
135
- note: '模型侧可安装/启停插件;标准与 PTC 模式里出厂即关闭(只有创造模式默认开),本模式可以显式打开它',
135
+ note: '供模型安装或启停插件;出厂状态由当前宿主的基础模式声明决定',
136
136
  },
137
137
  'tool-presentation': { label: 'PTC 工具呈现' },
138
+ 'tool-executor-ptc': { label: 'PTC 程序执行器', note: '通过 run_code 执行程序并调用工具,同时向模型提供工具 SDK' },
138
139
  present: { label: '交付文件(present)' },
139
140
  'custom-prompt-tool': { label: 'custom_prompt 工具', note: '关掉后无法用对话改提示词(设置页仍可用)' },
140
141
  'persistent-shell': { label: '持久 Shell' },
@@ -241,9 +242,9 @@ export function readBaseComposition(modeId) {
241
242
  * First writer, in `BASE_MODE_IDS` order (`standard` → `ptc` → `minimal` → `cordis`). The order is not
242
243
  * cosmetic: `standard` is the mode this product's own preset is derived from, so a row declared in more
243
244
  * than one mode keeps **standard's text and standard's shipped default**. The visible consequence is
244
- * deliberate — `tool-plugin-manager` ships disabled in `standard` and enabled in `cordis`, and the union
245
- * keeps it disabled (the user turns it on per assistant if they want it). The alternative rule, "enabled
246
- * if any mode enables it", would silently arm the model's plugin manager in a mode the user never chose.
245
+ * deliberate: the union follows this host's `standard` declaration, including any condition on
246
+ * `tool-plugin-manager`. Its shipped state varies by host version; do not hard-code it as disabled.
247
+ * Enabling a row merely because another mode does so would change the selected baseline.
247
248
  *
248
249
  * ## Nothing is cached
249
250
  *
package/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Host half: the 「自定义模式」 settings page — an ASSISTANT MANAGER.
3
3
  *
4
- * It registers seven exact routes on the platform's shared `/api` channel; the browser half calls
4
+ * It registers eight exact routes on the platform's shared `/api` channel; the browser half calls
5
5
  * them with `fetch`:
6
6
  *
7
7
  * GET /api/custom-mode — every assistant this feature manages.
@@ -20,6 +20,7 @@
20
20
  * POST /api/custom-mode/reorder — { id, direction }: move one assistant up/down in the
21
21
  * picker order by writing `order` into each `preset.yml`.
22
22
  * POST /api/custom-mode/repair — { id }: turn off the rows this dsh line cannot resolve.
23
+ * POST /api/custom-mode/sync — {}: retry registry synchronization without repeating a write.
23
24
  *
24
25
  * **Why `/api` and not the raw `webServer` table**: the carrier that owns the `/api` channel applies the
25
26
  * platform's trust and authentication policy — loopback/`trustedHosts` Host check, `Sec-Fetch-Site`,
@@ -31,7 +32,7 @@
31
32
  * route answered 401; a plain form post needs no preflight, so CORS would not have helped either). The
32
33
  * registered paths are absolute *including* `/api`, which is what the platform's own packages pass.
33
34
  *
34
- * **Why exact routes rather than one prefix**: the Fetch registry matches exact paths. Seven registrations
35
+ * **Why exact routes rather than one prefix**: the Fetch registry matches exact paths. Eight registrations
35
36
  * cost nothing and each declares the methods it owns, so the method table doubles as the guarantee that a
36
37
  * prefetched `GET …/create` cannot create anything — that method is simply not registered for that path.
37
38
  *
@@ -101,6 +102,7 @@ const DELETE_PATH = ROUTE_PATH + '/delete'
101
102
  const REORDER_PATH = ROUTE_PATH + '/reorder'
102
103
  const REPAIR_PATH = ROUTE_PATH + '/repair'
103
104
  const HISTORY_PATH = ROUTE_PATH + '/history'
105
+ const SYNC_PATH = ROUTE_PATH + '/sync'
104
106
 
105
107
  /** Which verbs each endpoint answers. `undefined` for a path means 404. */
106
108
  const METHODS = {
@@ -111,6 +113,7 @@ const METHODS = {
111
113
  [DELETE_PATH]: ['POST'],
112
114
  [REORDER_PATH]: ['POST'],
113
115
  [REPAIR_PATH]: ['POST'],
116
+ [SYNC_PATH]: ['POST'],
114
117
  }
115
118
 
116
119
  /** 只告警一次:避免每个请求都刷同一行日志。 */
@@ -154,6 +157,7 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
154
157
  * Mirrors `checkPromptText` in the preset's `prompt-tool.mjs`; the duplication is
155
158
  * deliberate so neither side depends on the other's install location.
156
159
  */
160
+ // cwd is supplied by the old loop, or by prompt-reader's scoped workingDirectory compatibility provider.
157
161
  const KNOWN_VARIABLES = ['model', 'cwd', 'provider']
158
162
 
159
163
  /**
@@ -372,7 +376,8 @@ export function brokenRowIds(message) {
372
376
  export function readState(rows, id, options = {}) {
373
377
  const directory = assistantDir(rows, id)
374
378
  if (directory === undefined) return unknownAssistant(id)
375
- restoreBackups(directory)
379
+ const recovery = restoreBackups(directory)
380
+ if (recovery.ok !== true) return { ok: false, code: 'recoveryRequired', params: { path: directory, detail: recovery.detail }, error: '助手文件需要恢复:' + recovery.detail }
376
381
  const composition = compositionFile(directory)
377
382
  if (!existsSync(composition)) return { ok: false, code: 'compositionMissing', params: { path: composition }, error: '找不到组成文件:' + composition }
378
383
  const text = readFileSync(composition, 'utf8')
@@ -1299,7 +1304,7 @@ export function apply(ctx) {
1299
1304
  console.error('custom-mode: 取回出厂组成时出错(已忽略): ' + describe(error))
1300
1305
  }
1301
1306
  }
1302
- if (declarative === null) return
1307
+ if (declarative === null) return { ok: true, failures: [] }
1303
1308
  try {
1304
1309
  const rows = await rawRoster()
1305
1310
  const targets = []
@@ -1343,8 +1348,10 @@ export function apply(ctx) {
1343
1348
  if (failed.length > 0) {
1344
1349
  console.error(`custom-mode: ${failed.length} 个助手的 preset 未能挂载 —— 见上面的具体原因。`)
1345
1350
  }
1351
+ return { ok: failed.length === 0, failures: failed }
1346
1352
  } catch (error) {
1347
- console.error('custom-mode: 同步 preset 到注册表失败(已忽略): ' + describe(error))
1353
+ console.error('custom-mode: 同步 preset 到注册表失败: ' + describe(error))
1354
+ return { ok: false, code: 'syncFailed', failures: [{ id: '', error: describe(error) }] }
1348
1355
  }
1349
1356
  }
1350
1357
 
@@ -1357,18 +1364,14 @@ export function apply(ctx) {
1357
1364
  */
1358
1365
  let startupSync = null
1359
1366
  const roster = async () => {
1360
- if (startupSync !== null) {
1361
- const pending = startupSync
1362
- startupSync = null
1363
- await pending
1364
- }
1367
+ if (startupSync !== null) await startupSync
1365
1368
  return rawRoster()
1366
1369
  }
1367
1370
 
1368
- /** 写操作之后调用:重新对齐注册表。失败已在内部记录,不抛出。 */
1369
- const resyncPresets = async () => {
1370
- startupSync = null
1371
- await doSync()
1371
+ /** Called inside the write queue; readers also await the current synchronization. */
1372
+ const resyncPresets = () => {
1373
+ startupSync = doSync()
1374
+ return startupSync
1372
1375
  }
1373
1376
 
1374
1377
  // 启动即同步:新线不是"写盘即生效",第一次进来必须先注册。
@@ -1391,6 +1394,11 @@ export function apply(ctx) {
1391
1394
  const json = (value, status = 200) =>
1392
1395
  Response.json(value, { status, headers: { 'cache-control': 'no-store' } })
1393
1396
 
1397
+ const syncFailureParams = (outcome) => ({
1398
+ ids: outcome.failures.map((failure) => failure.id).filter(Boolean).join(', '),
1399
+ detail: outcome.failures.map((failure) => failure.error ?? failure.code ?? 'unknown').join('; '),
1400
+ })
1401
+
1394
1402
  const handle = async (request, pathname) => {
1395
1403
  try {
1396
1404
  const url = new URL(request.url)
@@ -1436,26 +1444,32 @@ export function apply(ctx) {
1436
1444
  return json({ ok: false, code: 'badJson', error: '请求体不是合法 JSON' }, 400)
1437
1445
  }
1438
1446
  await ensureShipped()
1439
- const targetId = parsed !== null && typeof parsed === 'object' && typeof parsed.id === 'string' ? parsed.id : ''
1440
- // 一把锁覆盖同一棵预设树。分钥匙会让「删除」和「保存」交错,把刚删的目录再写成半成品。
1441
- if (pathname === STATE_PATH) {
1442
- const result = await serializedWrite('preset-write', async () => saveState(await roster(), parsed))
1443
- return json(result, result.ok === true ? 200 : 400)
1444
- }
1445
- if (pathname === CREATE_PATH) {
1446
- const result = await serializedWrite('preset-write', async () => createAssistant(await roster(), parsed))
1447
- return json(result, result.ok === true ? 200 : 400)
1448
- }
1449
- if (pathname === REPAIR_PATH) {
1450
- const result = await serializedWrite('preset-write', async () => repairComposition(await roster(), parsed))
1451
- return json(result, result.ok === true ? 200 : 400)
1452
- }
1453
- if (pathname === REORDER_PATH) {
1454
- const result = await serializedWrite('preset-write', async () => reorderAssistant(await roster(), parsed))
1455
- return json(result, result.ok === true ? 200 : 400)
1456
- }
1457
- const result = await serializedWrite('preset-write', async () => deleteAssistant(await roster(), parsed, presetRemover()))
1458
- return json(result, result.ok === true ? 200 : 400)
1447
+ // One queue owns the entire mutation, INCLUDING its registry projection. Releasing this lock
1448
+ // before sync allowed a later save to race an older registration and leave disk B / runtime A.
1449
+ return await serializedWrite('preset-write', async () => {
1450
+ const rows = await roster()
1451
+ if (pathname === SYNC_PATH) {
1452
+ const synced = await resyncPresets()
1453
+ if (synced.ok === true) return json({ ok: true, code: 'synchronized', synchronized: true })
1454
+ return json({ ok: false, code: 'syncFailed', persisted: false, synchronized: false, params: syncFailureParams(synced) }, 503)
1455
+ }
1456
+ const result = pathname === STATE_PATH ? saveState(rows, parsed)
1457
+ : pathname === CREATE_PATH ? createAssistant(rows, parsed)
1458
+ : pathname === REPAIR_PATH ? repairComposition(rows, parsed)
1459
+ : pathname === REORDER_PATH ? reorderAssistant(rows, parsed)
1460
+ : await deleteAssistant(rows, parsed, presetRemover())
1461
+ if (result.ok !== true) return json(result, 400)
1462
+ const synced = await resyncPresets()
1463
+ if (synced.ok !== true) {
1464
+ return json({
1465
+ ...result, ok: false, code: 'savedButSyncFailed', operationCode: result.code,
1466
+ persisted: true, synchronized: false, params: { ...result.params, ...syncFailureParams(synced) },
1467
+ error: '文件已写入,但预设同步失败;请只重试生效,不要重复原操作。',
1468
+ note: '文件已写入,但预设同步失败;请只重试生效,不要重复原操作。',
1469
+ }, 503)
1470
+ }
1471
+ return json({ ...result, persisted: true, synchronized: true })
1472
+ })
1459
1473
  } catch (error) {
1460
1474
  // 兜底也要带 code:这是**意料之外**的异常,页面拿到的是 Node 的原始错误串,方向反着也一样糟
1461
1475
  // ——中文界面里会蹦出一句英文。带 code 后页面用自己的词典渲染,原始串只作为 `detail` 参数。
@@ -1472,14 +1486,7 @@ export function apply(ctx) {
1472
1486
  path: API_PREFIX + pathname,
1473
1487
  methods: [...methods],
1474
1488
  requestBody: 'buffered',
1475
- fetch: async (request) => {
1476
- const response = await handle(request, pathname)
1477
- // 写成功之后,把注册表同步到磁盘的新状态。
1478
- // 旧线上 `declarative` 是 null,这里恒为空操作;新线上没有它,用户在设置页
1479
- // 新建/改名/删除的助手就不会出现在选择器里 —— 是本插件最容易被漏掉的一步。
1480
- if (declarative !== null && request.method !== 'GET' && response.ok) await resyncPresets()
1481
- return response
1482
- },
1489
+ fetch: (request) => handle(request, pathname),
1483
1490
  }),
1484
1491
  'custom-mode.route' + pathname,
1485
1492
  )
package/locales.mjs CHANGED
@@ -16,17 +16,34 @@
16
16
 
17
17
  /** Simplified Chinese dictionary — the key source of truth. */
18
18
  export const zh = {
19
+ 'create.nextStep': '创建后可在上方继续编辑并保存;使用时,在新会话的模式菜单选择它。',
20
+ 'btn.retryRead': '重试读取',
21
+ 'btn.retrySync': '重试生效',
22
+ 'api.savedButSyncFailed': '文件已保存,但这些助手尚未生效:{ids}。请点「重试生效」,无需重复原操作。',
23
+ 'api.syncFailed': '这些助手仍未生效:{ids}。请重试;仍失败时查看宿主日志。',
24
+ 'api.synchronized': '配置已生效。新会话请从模式菜单选择助手。',
25
+ 'api.recoveryRequired': '助手文件需要恢复:{path}。请保留目录中的备份,并查看宿主日志中的恢复说明。',
26
+ 'msg.completedReadFailed': '{result} 页面刷新失败。请点「重试读取」,无需重复原操作。',
27
+ 'msg.synchronized': '配置已生效。',
28
+ 'msg.syncFailed': '重新应用配置失败,请重试。',
29
+ 'msg.loaded': '已载入到编辑器。尚未保存。',
30
+ 'status.reading': '正在读取…',
31
+ 'status.saving': '正在保存…',
32
+ 'status.creating': '正在创建助手…',
33
+ 'status.deleting': '正在删除助手…',
34
+ 'status.reordering': '正在保存顺序…',
35
+ 'status.repairing': '正在修复…',
36
+ 'status.importing': '正在读取导入文件…',
37
+ 'status.synchronizing': '正在应用配置…',
19
38
  nav: '自定义模式',
20
39
 
21
40
  // ── 助手管理 ──────────────────────────────────────────────────────────────
22
41
  'assistant.heading': '助手',
23
- 'assistant.hint': '每个助手单独保存系统提示词、基础模式和插件开关。',
24
- 'posture.hint': '这一支只在点「新增助手」时写入。',
42
+ 'assistant.hint': '选择要编辑的助手。每位助手独立保存系统提示词、基础模式和插件开关。',
43
+ 'posture.hint': '选择起步姿态并命名,再点「新增助手」。',
25
44
  'tree.posture': '起步',
26
- 'tree.base': '工具底',
27
- 'tree.baseHold': '新建时固定用官方标准模式。',
28
- 'tree.baseSkip': '这一支点不了。建好后到「当前助手」里改。',
29
- 'tree.baseLocked': '只有官方标准模式是亮的。其余四档是暗的,新建时点不了。',
45
+ 'tree.base': '初始基础模式',
46
+ 'tree.baseLocked': '新建助手从官方标准模式起步。建好后,可在「当前助手」调整基础模式。',
30
47
  'posture.develop.label': '开发',
31
48
  'posture.develop.note': '官方标准模式,工具按出厂。',
32
49
  'posture.write.label': '写作',
@@ -34,7 +51,7 @@ export const zh = {
34
51
  'posture.chat.label': '聊天',
35
52
  'posture.chat.note': '官方标准模式。文件和终端关闭。',
36
53
  'editor.heading': '当前助手',
37
- 'editor.hint': '这一支改上面选中的助手。基础模式五档都可以点。',
54
+ 'editor.hint': '正在编辑「{name}」。这里的选择只决定编辑对象;新会话请从模式菜单选择助手。',
38
55
  'assistant.empty': '还没有助手,用下面的输入框新建一个。',
39
56
  'assistant.loadingList': '正在读取助手列表…',
40
57
  'assistant.newPlaceholder': '新助手的名字(例如:写作助手)',
@@ -57,7 +74,7 @@ export const zh = {
57
74
  'btn.resetHint': '把编辑器换回此助手新建时保存的提示词。只改草稿,保存后才写入。',
58
75
  'btn.delete': '删除',
59
76
  'btn.cancel': '取消',
60
- 'msg.created': '已创建。刷新后,新会话的模式菜单里会出现它。',
77
+ 'msg.created': '已创建。可继续编辑;使用时请在新会话的模式菜单选择它。',
61
78
  'msg.duplicated': '已复制。两份配置彼此独立。',
62
79
  'msg.deleted': '已删除。',
63
80
  'msg.createFailed': '创建失败',
@@ -97,7 +114,7 @@ export const zh = {
97
114
  'api.saved': '已保存({name},{mode})。下一步组装时会重读已保存的提示词。',
98
115
  'api.conflict': '磁盘上的提示词已经变了,这次没有写入。先重新读取,再决定要不要覆盖。',
99
116
  'api.savedWithExclusiveRows': '已保存({name},{mode})。已关闭不能同时启用的行:{rows}。',
100
- 'api.created': '已创建「{name}」。刷新后,新会话的模式菜单里会出现它。',
117
+ 'api.created': '已创建「{name}」。可继续编辑;使用时请在新会话的模式菜单选择它。',
101
118
  'api.savedPrompt': '已保存系统提示词({name})。下一步组装时会重读这个文件。',
102
119
  'api.duplicated': '已从「{from}」复制。两份互不影响。',
103
120
  'api.deleted': '已删除「{name}」。正在使用它的会话不受影响。',
@@ -186,7 +203,7 @@ export const zh = {
186
203
  'tag.followPlatform': '随平台',
187
204
 
188
205
  'btn.save': '保存',
189
- 'btn.saving': '处理中…',
206
+ 'btn.saving': '保存中…',
190
207
  'btn.reload': '重新读取',
191
208
  'btn.reloadDiscard': '放弃修改并重新读取',
192
209
  'btn.reloadConfirm': '放弃未保存的修改并重新读取?',
@@ -250,6 +267,8 @@ export const zh = {
250
267
  'row.tool-plugin-manager.label': '插件管理(安装 / 启停)',
251
268
  'row.tool-plugin-manager.note': '供模型安装或开关插件。',
252
269
  'row.tool-presentation.label': 'PTC 工具呈现',
270
+ 'row.tool-executor-ptc.label': 'PTC 程序执行器',
271
+ 'row.tool-executor-ptc.note': '通过 run_code 执行程序并调用工具,同时向模型提供工具 SDK。',
253
272
  'row.present.label': '交付文件(present)',
254
273
  'row.command-goal.label': '目标命令',
255
274
  'row.tool-goal.label': '目标',
@@ -283,17 +302,34 @@ export const zh = {
283
302
 
284
303
  /** English dictionary; the parity test keeps its key set equal to {@link zh}. */
285
304
  export const en = {
305
+ 'create.nextStep': 'After creating, edit and save above. To use the assistant, choose it in the mode menu for a new session.',
306
+ 'btn.retryRead': 'Retry refresh',
307
+ 'btn.retrySync': 'Retry activation',
308
+ 'api.savedButSyncFailed': 'Files are saved, but these assistants are not active yet: {ids}. Select Retry activation; you do not need to repeat the original action.',
309
+ 'api.syncFailed': 'These assistants are still not active: {ids}. Retry, then check the host log if the problem continues.',
310
+ 'api.synchronized': 'Configuration is active. Choose an assistant in the mode menu for a new session.',
311
+ 'api.recoveryRequired': 'Assistant files need recovery: {path}. Keep the backups in that directory and check the host log for recovery details.',
312
+ 'msg.completedReadFailed': '{result} The page could not refresh. Select Retry refresh; do not repeat the original action.',
313
+ 'msg.synchronized': 'Configuration is active.',
314
+ 'msg.syncFailed': 'Could not apply the configuration. Please retry.',
315
+ 'msg.loaded': 'Loaded into the editor. Not saved yet.',
316
+ 'status.reading': 'Reading…',
317
+ 'status.saving': 'Saving…',
318
+ 'status.creating': 'Creating assistant…',
319
+ 'status.deleting': 'Deleting assistant…',
320
+ 'status.reordering': 'Saving order…',
321
+ 'status.repairing': 'Repairing…',
322
+ 'status.importing': 'Reading import file…',
323
+ 'status.synchronizing': 'Applying configuration…',
286
324
  nav: 'Custom mode',
287
325
 
288
326
  // ── assistant management ──────────────────────────────────────────────────
289
327
  'assistant.heading': 'Assistants',
290
- 'assistant.hint': 'Each assistant keeps its own system prompt, base mode and plugin switches.',
291
- 'posture.hint': 'This branch is written only when you click New assistant.',
328
+ 'assistant.hint': 'Choose an assistant to edit. Each keeps its own system prompt, base mode and plugin switches.',
329
+ 'posture.hint': 'Choose a starting posture and a name, then select New assistant.',
292
330
  'tree.posture': 'Starter',
293
- 'tree.base': 'Tool base',
294
- 'tree.baseHold': 'A new assistant always starts on the official Standard mode.',
295
- 'tree.baseSkip': 'Cannot be chosen on this branch. Change it later under This assistant.',
296
- 'tree.baseLocked': 'Only the official Standard mode is lit. The other four are dim and cannot be chosen when creating.',
331
+ 'tree.base': 'Starting base mode',
332
+ 'tree.baseLocked': 'New assistants start on the official Standard mode. You can change the base mode under This assistant after creating one.',
297
333
  'posture.develop.label': 'Coding',
298
334
  'posture.develop.note': 'Official Standard mode, tools as shipped.',
299
335
  'posture.write.label': 'Writing',
@@ -301,7 +337,7 @@ export const en = {
301
337
  'posture.chat.label': 'Chat',
302
338
  'posture.chat.note': 'Official Standard mode. Files and terminal off.',
303
339
  'editor.heading': 'This assistant',
304
- 'editor.hint': 'This branch edits the assistant selected above. All five base modes can be chosen.',
340
+ 'editor.hint': 'Editing {name}. This selection controls what you edit here; choose an assistant in the mode menu when starting a new session.',
305
341
  'assistant.empty': 'No assistants yet — create one with the field below.',
306
342
  'assistant.loadingList': 'Loading assistants…',
307
343
  'assistant.newPlaceholder': 'Name of the new assistant (e.g. Writing assistant)',
@@ -322,7 +358,7 @@ export const en = {
322
358
  'btn.resetHint': 'Restores the prompt saved when this assistant was created. The editor changes. Save writes it.',
323
359
  'btn.delete': 'Delete',
324
360
  'btn.cancel': 'Cancel',
325
- 'msg.created': 'Created. Refresh once, then it appears in the mode menu for a new session.',
361
+ 'msg.created': 'Created. Continue editing here, then choose it in the mode menu for a new session.',
326
362
  'msg.duplicated': 'Duplicated. The two are independent from here on.',
327
363
  'msg.deleted': 'Deleted.',
328
364
  'msg.createFailed': 'Could not create',
@@ -362,7 +398,7 @@ export const en = {
362
398
  'api.saved': 'Saved ({name}, {mode}). The next model step rereads the saved prompt.',
363
399
  'api.conflict': 'The prompt on disk has changed, so this save was not written. Reload, then decide whether to overwrite.',
364
400
  'api.savedWithExclusiveRows': 'Saved ({name}, {mode}). Turned off rows that cannot be on together: {rows}.',
365
- 'api.created': 'Created {name}. Refresh once, then it appears in the mode menu for a new session.',
401
+ 'api.created': 'Created {name}. Continue editing here, then choose it in the mode menu for a new session.',
366
402
  'api.savedPrompt': 'System prompt saved ({name}). The next model step rereads this file.',
367
403
  'api.duplicated': 'Copied from {from}. The two are independent from now on.',
368
404
  'api.deleted': 'Deleted {name}. Sessions already using it are unchanged.',
@@ -451,7 +487,7 @@ export const en = {
451
487
  'tag.followPlatform': 'Platform',
452
488
 
453
489
  'btn.save': 'Save',
454
- 'btn.saving': 'Working…',
490
+ 'btn.saving': 'Saving…',
455
491
  'btn.reload': 'Reload',
456
492
  'btn.reloadDiscard': 'Discard edits and reload',
457
493
  'btn.reloadConfirm': 'Discard unsaved edits and reload?',
@@ -513,6 +549,8 @@ export const en = {
513
549
  'row.tool-plugin-manager.label': 'Plugin manager (install / toggle)',
514
550
  'row.tool-plugin-manager.note': 'Lets the model install or toggle plugins.',
515
551
  'row.tool-presentation.label': 'PTC tool presentation',
552
+ 'row.tool-executor-ptc.label': 'PTC program executor',
553
+ 'row.tool-executor-ptc.note': 'Runs programs and calls tools through run_code, and provides the tool SDK to the model.',
516
554
  'row.present.label': 'Deliverables (present)',
517
555
  'row.command-goal.label': 'Goal command',
518
556
  'row.tool-goal.label': 'Goals',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "2.2.5",
3
+ "version": "2.2.6",
4
4
  "description": "Edit a DeepSeek Harness (dsh) custom mode: system prompt, base mode, and plugin switches.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -80,7 +80,7 @@
80
80
  "market-check": "node tools/market-check.mjs"
81
81
  },
82
82
  "peerDependencies": {
83
- "@deepseek-ai/dsh": ">=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0 || >=0.2.0-0 <0.3.0-0 || >=0.2.1-alpha.1 <0.3.0-0"
83
+ "@deepseek-ai/dsh": ">=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0 || >=0.2.0-0 <0.3.0-0 || >=0.2.1-alpha.1 <0.3.0-0 || >=0.2.2-alpha.2 <0.3.0-0"
84
84
  },
85
85
  "peerDependenciesMeta": {
86
86
  "@deepseek-ai/dsh": {
@@ -89,7 +89,7 @@
89
89
  },
90
90
  "engines": {
91
91
  "node": ">=20",
92
- "dsh": ">=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0 || >=0.2.0-0 <0.3.0-0 || >=0.2.1-alpha.1 <0.3.0-0"
92
+ "dsh": ">=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0 || >=0.2.0-0 <0.3.0-0 || >=0.2.1-alpha.1 <0.3.0-0 || >=0.2.2-alpha.2 <0.3.0-0"
93
93
  },
94
94
  "publishConfig": {
95
95
  "access": "public",
@@ -86,11 +86,44 @@ export const name = 'custom-prompt-file'
86
86
  /** The prompt registry is a hard dependency: without it there is no identity. */
87
87
  export const inject = ['systemPrompt']
88
88
 
89
+ /** Remove only our old standalone directory sentence; never rewrite the user's file. */
90
+ function withoutLegacyDirectoryLine(text) {
91
+ return text.replace(/^Your working directory is \{\{cwd\}\}\.(?:\r?\n|$)/gm, '')
92
+ }
93
+
89
94
  export function apply(ctx, config = {}) {
90
95
  const path = config.path ?? fileURLToPath(new URL('./prompt.md', import.meta.url))
91
96
  const suffix = config.suffix ?? DEFAULT_SUFFIX
92
97
  const fallback = config.fallback ?? DEFAULT_FALLBACK
93
98
  const file = new PromptFile(path, fallback)
99
+ let ownsWorkingDirectory = false
100
+
101
+ // New hosts moved cwd out of the loop's variables and into this service's required runtime context.
102
+ // Keep older hosts on their own cwd variable. This optional child does not hold the persona pending.
103
+ // A scoped compatibility variable also preserves custom {{cwd}} references without string-substituting
104
+ // paths before interpolation (a directory containing braces must not itself become a template).
105
+ if (typeof ctx.inject === 'function') {
106
+ ctx.inject(['systemPrompt', 'workingDirectory'], (scope) => {
107
+ const directory = scope.workingDirectory
108
+ if (typeof directory?.get !== 'function') return
109
+ const currentDirectory = (context) => {
110
+ const session = context.agent?.session
111
+ if (session === undefined) return undefined
112
+ return directory.get(session)
113
+ }
114
+ scope.effect(() => scope.systemPrompt.variable('cwd', currentDirectory), 'custom-prompt.cwd-compatibility')
115
+ // The host can recover a missing directory during this waterfall, after variable providers were
116
+ // snapshotted. Refresh only our compatibility value after downstream assembly has settled.
117
+ scope.effect(() => scope.on('system-prompt/assemble', async (_assembly, context, next) => {
118
+ const presentation = await next()
119
+ presentation.variables.cwd = currentDirectory(context)
120
+ return presentation
121
+ }), 'custom-prompt.cwd-after-assembly')
122
+ ownsWorkingDirectory = true
123
+ scope.effect(() => () => { ownsWorkingDirectory = false }, 'custom-prompt.cwd-lifecycle')
124
+ })
125
+ }
126
+ const compatibleText = (text) => ownsWorkingDirectory ? withoutLegacyDirectoryLine(text) : text
94
127
 
95
128
  ctx.effect(
96
129
  () =>
@@ -98,7 +131,7 @@ export function apply(ctx, config = {}) {
98
131
  name: PERSONA_PREFIX_SECTION,
99
132
  order: PERSONA_PREFIX_ORDER,
100
133
  // A provider, not a string: evaluated at every assembly.
101
- text: () => file.read(),
134
+ text: () => compatibleText(file.read()),
102
135
  complete: config.complete === true,
103
136
  }),
104
137
  'custom-prompt.prefix',
@@ -112,7 +145,7 @@ export function apply(ctx, config = {}) {
112
145
  ctx.systemPrompt.section({
113
146
  name: PERSONA_SUFFIX_SECTION,
114
147
  order: PERSONA_SUFFIX_ORDER,
115
- text: suffix,
148
+ text: () => compatibleText(suffix),
116
149
  }),
117
150
  'custom-prompt.suffix',
118
151
  )
@@ -61,6 +61,7 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
61
61
  * step; the duplication is deliberate so neither side depends on the other's
62
62
  * install location.
63
63
  */
64
+ // cwd is supplied by the old loop, or by prompt-reader's scoped workingDirectory compatibility provider.
64
65
  const KNOWN_VARIABLES = ['model', 'cwd', 'provider']
65
66
 
66
67
  /**
package/preset/prompt.md CHANGED
@@ -2,5 +2,3 @@ You are a coding agent powered by the {{model}} model.
2
2
 
3
3
  You work directly in the user's repository. Read the code before changing it, prefer
4
4
  the project's existing patterns over new machinery, and keep changes reviewable.
5
-
6
- Your working directory is {{cwd}}.
@@ -102,16 +102,18 @@ export function toPluginRows(rows, presetDir) {
102
102
  export function createDeclarativeBackend({ scope, log = console.log, warn = console.error }) {
103
103
  /** presetId → { dispose, dir } —— 当前挂在注册表里的东西,注销的唯一凭据。 */
104
104
  const mounted = new Map()
105
+ let mutations = Promise.resolve()
106
+ const serialize = (work) => {
107
+ const next = mutations.then(work, work)
108
+ mutations = next.then(() => undefined, () => undefined)
109
+ return next
110
+ }
105
111
 
106
112
  async function unmount(id) {
107
113
  const entry = mounted.get(id)
108
114
  if (entry === undefined) return
115
+ await entry.dispose()
109
116
  mounted.delete(id)
110
- try {
111
- await entry.dispose()
112
- } catch (error) {
113
- warn(`custom-mode: 注销 preset "${id}" 时出错(已忽略): ${describe(error)}`)
114
- }
115
117
  }
116
118
 
117
119
  /** Build the definition for one assistant directory, or null when it has no composition. */
@@ -146,8 +148,8 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
146
148
  await unmount(assistant.id)
147
149
  return { ok: false, error: 'no composition' }
148
150
  }
149
- await unmount(assistant.id)
150
151
  try {
152
+ await unmount(assistant.id)
151
153
  const dispose = await scope.agentPresets.register(definition)
152
154
  mounted.set(assistant.id, { dispose, dir: assistant.dir })
153
155
  return { ok: true }
@@ -257,11 +259,11 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
257
259
  id: BACKEND_ID,
258
260
  /** 新线不把 preset 落到磁盘,注册表就是唯一真相。 */
259
261
  writesPresetFiles: false,
260
- sync,
261
- mountOne,
262
- unmount,
263
- remove,
264
- disposeAll,
262
+ sync: (assistants) => serialize(() => sync(assistants)),
263
+ mountOne: (assistant) => serialize(() => mountOne(assistant)),
264
+ unmount: (id) => serialize(() => unmount(id)),
265
+ remove: (id, dir) => serialize(() => remove(id, dir)),
266
+ disposeAll: () => serialize(disposeAll),
265
267
  fetchBaseCompositions,
266
268
  mountedIds: () => [...mounted.keys()],
267
269
  }
package/seed.mjs CHANGED
@@ -24,7 +24,7 @@
24
24
  import { existsSync, mkdirSync, readFileSync } from 'node:fs'
25
25
  import { renderComposition } from './composition.mjs'
26
26
  import { readPresetMeta, writePresetMeta } from './meta.mjs'
27
- import { writeAtomic } from './atomic.mjs'
27
+ import { restoreBackups, writeAtomic } from './atomic.mjs'
28
28
  import { dirname, join } from 'node:path'
29
29
  import { fileURLToPath } from 'node:url'
30
30
 
@@ -116,6 +116,10 @@ export function seedPreset(presetDir, sourceDir = packagedPresetDir(), options =
116
116
  if (typeof presetDir !== 'string' || presetDir === '') {
117
117
  return { created, kept, errors: ['没有可用的 preset 目录路径'] }
118
118
  }
119
+ const recovery = restoreBackups(presetDir)
120
+ if (recovery.ok !== true) {
121
+ return { created, refreshed, kept, errors: ['Preset recovery required: ' + recovery.detail] }
122
+ }
119
123
  if (!existsSync(sourceDir)) {
120
124
  return { created, kept, errors: [`包内预设目录不存在: ${sourceDir}`] }
121
125
  }