dsh-custom-mode 2.2.6 → 2.3.1

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
@@ -24,6 +24,18 @@
24
24
 
25
25
  import { readFileSync, existsSync, readdirSync } from 'node:fs'
26
26
  import { join } from 'node:path'
27
+ import { AsyncLocalStorage } from 'node:async_hooks'
28
+ import { decodeJsExpression, evaluateDisabledPredicate } from './preset-backend/parse-composition.mjs'
29
+
30
+ // Capabilities belong to the request that observed them, never to a module-global
31
+ // host context. Store only known booleans, not service objects or arbitrary ctx access.
32
+ const predicateCapabilities = new AsyncLocalStorage()
33
+ export function withPredicateCapabilities(snapshot, work) {
34
+ const capabilities = typeof snapshot?.profileContext === 'boolean'
35
+ ? Object.freeze({ profileContext: snapshot.profileContext })
36
+ : Object.freeze({})
37
+ return predicateCapabilities.run(capabilities, work)
38
+ }
27
39
 
28
40
  // 出厂组成「从哪来」是两条 dsh 线唯一还没收进后端的一处:旧线是文件,0.1.7 是
29
41
  // agentPresets.readDocument()。解析链整个搬到了 base-composition.mjs(含它的理由与实测),
@@ -460,7 +472,7 @@ function setDisabled(segmentText, disabled) {
460
472
  const shipped = lines[index].trim().replace(/^disabled:\s*/, '')
461
473
  if (disabled) {
462
474
  lines[index] = `${' '.repeat(indent)}disabled: true${carriage}`
463
- } else if (shipped.startsWith('!!js') && evalDisabledExpression(shipped) !== true) {
475
+ } else if (shipped.startsWith('!!js') && evalDisabledExpression(shipped) === false) {
464
476
  // 显式打开,但出厂那行是平台表达式、且它在本机求值就是"开":这不是覆盖,而是**撤销覆盖** ——
465
477
  // 保持出厂表达式原样,这一行就回到"跟随平台"。(原先这里删掉整行,结果是文件既不是出厂原样、
466
478
  // 也不是显式覆盖,页面却显示"未拨过" —— 文件与显示同时失真。)
@@ -496,9 +508,8 @@ function setDisabled(segmentText, disabled) {
496
508
  function evalDisabledExpression(expression) {
497
509
  if (typeof expression !== 'string' || !expression.startsWith('!!js')) return undefined
498
510
  try {
499
- // eslint-disable-next-line no-new-func -- evaluating the composition's own predicate is the point
500
- const fn = new Function('process', `return (${expression.slice(4).trim()});`)
501
- return fn(process) === true
511
+ const source = decodeJsExpression(expression.slice(4))
512
+ return evaluateDisabledPredicate(source, predicateCapabilities.getStore())
502
513
  } catch {
503
514
  // 求值失败与「表达式不存在」都是 undefined。调用方若已经确认这是 `!!js`,
504
515
  // 必须把 undefined 当成关闭:挂载侧同样按关闭处理,两边不能一个开一个关。
@@ -1020,13 +1031,19 @@ export function overridesOf(text, modeId) {
1020
1031
  // 某个字面量一致 —— 只看求值结果,会把"表达式被换成字面量"误判成"没动过"。
1021
1032
  const sameForm = baseRow !== undefined
1022
1033
  && baseRow.disabled === row.disabled
1023
- && baseRow.disabledExpression === row.disabledExpression
1034
+ && predicateForm(baseRow.disabledExpression) === predicateForm(row.disabledExpression)
1024
1035
  if (sameForm) continue
1025
1036
  overrides[id] = row.disabled !== true
1026
1037
  }
1027
1038
  return overrides
1028
1039
  }
1029
1040
 
1041
+ /** YAML quoting is transport syntax, while literal versus expression remains significant. */
1042
+ function predicateForm(expression) {
1043
+ if (typeof expression !== 'string') return expression
1044
+ try { return decodeJsExpression(expression.slice(4)) } catch { return expression }
1045
+ }
1046
+
1030
1047
  /**
1031
1048
  * 按允许名单生成显式开关。
1032
1049
  *
package/index.mjs CHANGED
@@ -55,7 +55,8 @@ import {
55
55
  effectiveRosterRows,
56
56
  } from './preset-backend/index.mjs'
57
57
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
58
- import { dirname, join } from 'node:path'
58
+ import { createHash } from 'node:crypto'
59
+ import { basename, dirname, join } from 'node:path'
59
60
  import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR, dshHome } from './paths.mjs'
60
61
  import { ensureUnsetDefaultPreset } from './defaults.mjs'
61
62
  import {
@@ -76,6 +77,7 @@ import {
76
77
  setBaseCompositions,
77
78
  setShippedPresetsDir,
78
79
  unresolvableRows,
80
+ withPredicateCapabilities,
79
81
  } from './composition.mjs'
80
82
  import { presetMetaText, readPresetMeta, writePresetMeta, presetMetaPath, PRESET_META_PATH } from './meta.mjs'
81
83
  import { listHistory, readVersion, recordExternalChange, recordPrompt, HISTORY_SOURCE } from './journal.mjs'
@@ -373,11 +375,42 @@ export function brokenRowIds(message) {
373
375
  return [...new Set(ids)]
374
376
  }
375
377
 
378
+ /**
379
+ * An opaque content revision of the complete editable state. Length framing and
380
+ * an explicit missing marker distinguish absent files from empty ones. History
381
+ * is deliberately excluded: losing an audit write must not disable conflict checks.
382
+ */
383
+ export function stateRevision(directory) {
384
+ return revisionWithEntries(directory)
385
+ }
386
+
387
+ function revisionWithEntries(directory, entries = []) {
388
+ const replacements = new Map(entries.map(([file, text]) => [basename(file), Buffer.from(text, 'utf8')]))
389
+ const hash = createHash('sha256')
390
+ for (const name of ['prompt.md', 'preset.yml', 'agent.cordis.yml']) {
391
+ hash.update(name + '\0')
392
+ let bytes
393
+ try {
394
+ bytes = replacements.has(name) ? replacements.get(name) : readFileSync(join(directory, name))
395
+ } catch (error) {
396
+ if (error.code !== 'ENOENT') throw error
397
+ hash.update('missing\0')
398
+ continue
399
+ }
400
+ hash.update('present:' + String(bytes.length) + '\0')
401
+ hash.update(bytes)
402
+ }
403
+ return hash.digest('hex')
404
+ }
405
+
376
406
  export function readState(rows, id, options = {}) {
377
407
  const directory = assistantDir(rows, id)
378
408
  if (directory === undefined) return unknownAssistant(id)
379
409
  const recovery = restoreBackups(directory)
380
410
  if (recovery.ok !== true) return { ok: false, code: 'recoveryRequired', params: { path: directory, detail: recovery.detail }, error: '助手文件需要恢复:' + recovery.detail }
411
+ // Take the token before reading the displayed fields. A later external edit
412
+ // can then cause a conservative conflict, never bless an older displayed value.
413
+ const revision = stateRevision(directory)
381
414
  const composition = compositionFile(directory)
382
415
  if (!existsSync(composition)) return { ok: false, code: 'compositionMissing', params: { path: composition }, error: '找不到组成文件:' + composition }
383
416
  const text = readFileSync(composition, 'utf8')
@@ -423,6 +456,7 @@ export function readState(rows, id, options = {}) {
423
456
  return {
424
457
  ok: true,
425
458
  id,
459
+ revision,
426
460
  mode,
427
461
  modes: BASE_MODES,
428
462
  rows: rowTree,
@@ -468,7 +502,7 @@ export function readState(rows, id, options = {}) {
468
502
  }
469
503
 
470
504
  /**
471
- * The list payload: every managed assistant, plus the root they live in.
505
+ * The list payload: managed assistants, their root, and the running plugin version.
472
506
  *
473
507
  * @param {Array<object>} rows - the current roster.
474
508
  */
@@ -478,7 +512,7 @@ export function readList(rows) {
478
512
  const list = assistantsFromRoster(rows).map((item) =>
479
513
  typeof item.broken === 'string' ? { ...item, brokenRows: brokenRowIds(item.broken) } : item,
480
514
  )
481
- return { ok: true, assistants: list, postures: publicPostures(), root: userPresetRoot(rows) }
515
+ return { ok: true, assistants: list, postures: publicPostures(), root: userPresetRoot(rows), version: PLUGIN_VERSION }
482
516
  }
483
517
 
484
518
  /**
@@ -503,33 +537,75 @@ const writeChains = new Map()
503
537
  /**
504
538
  * 打开页面之后、按下保存之前,`prompt.md` 可能已被会话工具或手工改过。
505
539
  *
506
- * 先把磁盘上的那一版记进历史。页面带了 JSON 数字 `baseN`,且那个版本不是磁盘上这一版时,
507
- * 拒绝写入:磁盘保持外部文本。不带 `baseN` 的旧客户端仍覆盖,但原文已经在历史里。
508
- * 数字字符串不算 `baseN`。留痕自己失败时不挡住这次保存。
540
+ * 新页面用 baseRevision 比较三个编辑文件的原始内容。旧页面的 JSON 数字
541
+ * baseN 仍比较提示词历史;没有 token 的旧 API 保留覆盖语义。
542
+ * 冲突判定与审计写入隔离:历史不可写时也不能放行过期草稿。
509
543
  *
510
544
  * @param {string} directory
511
545
  * @param {string} prompt - the text this save wants to write.
512
546
  * @param {unknown} baseN
547
+ * @param {unknown} baseRevision - opaque revision returned when the draft was loaded.
513
548
  * @returns {object|null} a `conflict` result, or null when the write may proceed.
514
549
  */
515
- function refuseStalePrompt(directory, prompt, baseN) {
516
- try {
517
- const diskPrompt = readPrompt(directory)
518
- if (diskPrompt.ok !== true || diskPrompt.text === prompt) return null
519
- recordExternalChange(directory, diskPrompt.text)
520
- if (typeof baseN === 'number' && readVersion(directory, baseN) !== diskPrompt.text) {
521
- return {
522
- ok: false,
523
- code: 'conflict',
524
- params: { baseN },
525
- error: '磁盘上的提示词已经变了,这次保存没有写入。',
526
- note: '磁盘上的提示词已经变了,这次保存没有写入。',
550
+ function refuseStaleState(directory, prompt, baseN, baseRevision) {
551
+ const diskPrompt = readPrompt(directory)
552
+ let stale = false
553
+ if (typeof baseRevision === 'string') {
554
+ try {
555
+ stale = stateRevision(directory) !== baseRevision
556
+ } catch {
557
+ // An unreadable editable file cannot prove the loaded baseline is current.
558
+ stale = true
559
+ }
560
+ } else if (typeof baseN === 'number') {
561
+ stale = diskPrompt.ok !== true || (diskPrompt.text !== prompt && readVersion(directory, baseN) !== diskPrompt.text)
562
+ }
563
+ if (diskPrompt.ok === true && diskPrompt.text !== prompt) {
564
+ try {
565
+ recordExternalChange(directory, diskPrompt.text)
566
+ } catch {
567
+ /* 审计留痕失败不改变上面已经完成的冲突判定 */
568
+ }
569
+ }
570
+ return stale ? {
571
+ ok: false,
572
+ code: 'conflict',
573
+ params: typeof baseRevision === 'string' ? {} : { baseN },
574
+ error: '磁盘上的助手文件已经变了或无法核对,这次保存没有写入。',
575
+ note: '磁盘上的助手文件已经变了或无法核对,这次保存没有写入。',
576
+ } : null
577
+ }
578
+
579
+ /** Reordering writes every assistant's metadata, so validate all held baselines first. */
580
+ function reorderState(rows, input) {
581
+ const baselines = input?.baseRevisions
582
+ if (baselines !== null && typeof baselines === 'object') {
583
+ for (const [id, revision] of Object.entries(baselines)) {
584
+ const directory = assistantDir(rows, id)
585
+ if (directory === undefined || typeof revision !== 'string') {
586
+ return { ok: false, code: 'conflict', params: {}, error: '助手列表或文件已经变了,这次排序没有写入。' }
527
587
  }
588
+ const conflict = refuseStaleState(directory, readPrompt(directory).text ?? '', undefined, revision)
589
+ if (conflict !== null) return conflict
528
590
  }
529
- } catch {
530
- /* 审计留痕失败不挡住这次保存 */
531
591
  }
532
- return null
592
+ const ids = new Map(assistantsFromRoster(rows).map(({ id }) => [assistantDir(rows, id), id]))
593
+ const revisions = {}
594
+ const result = reorderAssistant(rows, input, (name, description, directory, options) => {
595
+ const rendered = presetMetaText(name, description, directory, options)
596
+ if (rendered.ok !== true) return rendered
597
+ try {
598
+ const entries = [[presetMetaPath(directory), rendered.text]]
599
+ const revision = revisionWithEntries(directory, entries)
600
+ writeAtomic(presetMetaPath(directory), rendered.text)
601
+ revisions[ids.get(directory)] = revision
602
+ return { ok: true, name: rendered.name }
603
+ } catch (error) {
604
+ return { ok: false, code: 'metaWriteFailed', params: { detail: describe(error) }, error: '写入 preset.yml 失败:' + describe(error) }
605
+ }
606
+ })
607
+ // Failed reorders can roll back earlier writes; none of their tokens may be adopted.
608
+ return result.ok === true ? { ...result, revisions } : result
533
609
  }
534
610
 
535
611
  function serializedWrite(key, work) {
@@ -549,13 +625,15 @@ function serializedWrite(key, work) {
549
625
  * 的行集合也就不变,这是所有选项里破坏性最小的一个。
550
626
  *
551
627
  * @param {string} directory - the assistant's preset directory.
552
- * @param {{id: string, mode: string, prompt: string, name: string, description: string, displayName?: string, baseN?: number}} input
628
+ * @param {{id: string, mode: string, prompt: string, name: string, description: string, displayName?: string, baseN?: number, baseRevision?: string}} input
553
629
  * - the validated save; `name` decides whether `preset.yml` is written, `displayName` is what the response
554
630
  * reports (the disk name when the request omitted one — issue #9). `baseN` is the same stale-save
555
631
  * token as {@link saveState}: a JSON number only.
556
632
  * @returns {object} the shape `saveState` returns, with `code: 'savedPromptOnly'`.
557
633
  */
558
- export function savePromptOnly(directory, { id, mode, prompt, name, description, displayName, baseN }) {
634
+ export function savePromptOnly(directory, { id, mode, prompt, name, description, displayName, baseN, baseRevision }) {
635
+ const recovery = restoreBackups(directory)
636
+ if (recovery.ok !== true) return { ok: false, code: 'recoveryRequired', params: { path: directory, detail: recovery.detail }, error: '助手文件需要恢复:' + recovery.detail }
559
637
  // 与正常保存同一条纪律:提示词与 preset.yml 一起换名,失败则两个都不动。
560
638
  let metaText = null
561
639
  if (name !== '') {
@@ -563,13 +641,18 @@ export function savePromptOnly(directory, { id, mode, prompt, name, description,
563
641
  if (rendered.ok !== true) return rendered
564
642
  metaText = rendered.text
565
643
  }
566
- const conflict = refuseStalePrompt(directory, prompt, baseN)
644
+ const conflict = refuseStaleState(directory, prompt, baseN, baseRevision)
567
645
  if (conflict !== null) return conflict
646
+ let revision
568
647
  try {
569
- writeAtomicPair([
648
+ const entries = [
570
649
  [promptFile(directory), prompt],
571
650
  ...metaText === null ? [] : [[presetMetaPath(directory), metaText]],
572
- ])
651
+ ]
652
+ // Return our exact submitted generation, without a post-commit read that
653
+ // could fail or accidentally adopt a different writer's subsequent change.
654
+ revision = revisionWithEntries(directory, entries)
655
+ writeAtomicPair(entries)
573
656
  } catch (error) {
574
657
  return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
575
658
  }
@@ -583,6 +666,7 @@ export function savePromptOnly(directory, { id, mode, prompt, name, description,
583
666
  id,
584
667
  mode,
585
668
  code: 'savedPromptOnly',
669
+ revision,
586
670
  // 响应里的名字用**磁盘上的那个**(issue #9):请求省略 name 时不该把 params.name 报成裸目录 id。
587
671
  params: { name: (displayName === undefined || displayName === '' ? name : displayName) === '' ? id : (displayName === undefined || displayName === '' ? name : displayName), mode },
588
672
  note: '已保存系统提示词(本机取不到基础模式的出厂组成,插件开关与基础模式未改动)。下一步组装时会重读这个文件。',
@@ -669,6 +753,7 @@ export function saveState(rows, input) {
669
753
  description,
670
754
  displayName: effectiveName,
671
755
  baseN: input !== null && typeof input === 'object' ? input.baseN : undefined,
756
+ baseRevision: input !== null && typeof input === 'object' ? input.baseRevision : undefined,
672
757
  })
673
758
  }
674
759
  return {
@@ -701,14 +786,17 @@ export function saveState(rows, input) {
701
786
  if (rendered.ok !== true) return rendered
702
787
  metaText = rendered.text
703
788
  }
704
- const conflict = refuseStalePrompt(directory, prompt, input !== null && typeof input === 'object' ? input.baseN : undefined)
789
+ const conflict = refuseStaleState(directory, prompt, input?.baseN, input?.baseRevision)
705
790
  if (conflict !== null) return conflict
791
+ let revision
706
792
  try {
707
- writeAtomicPair([
793
+ const entries = [
708
794
  [compositionFile(directory), composition],
709
795
  [promptFile(directory), prompt],
710
796
  ...metaText === null ? [] : [[presetMetaPath(directory), metaText]],
711
- ])
797
+ ]
798
+ revision = revisionWithEntries(directory, entries)
799
+ writeAtomicPair(entries)
712
800
  } catch (error) {
713
801
  return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
714
802
  }
@@ -732,6 +820,7 @@ export function saveState(rows, input) {
732
820
  id,
733
821
  mode,
734
822
  code: 'savedWithExclusiveRows',
823
+ revision,
735
824
  // `rows` 用行 id:页面自己按 `row.<id>.label` 渲染,英文界面不会因为这条掉回中文。
736
825
  params: { name: displayName, mode, rows: autoOff.join(', ') },
737
826
  note:
@@ -744,6 +833,7 @@ export function saveState(rows, input) {
744
833
  id,
745
834
  mode,
746
835
  code: 'saved',
836
+ revision,
747
837
  params: { name: displayName, mode },
748
838
  note: '已保存(' + displayName + ',基础模式 ' + mode + ')。下一步组装时会重读已保存的提示词。',
749
839
  }
@@ -976,6 +1066,8 @@ export function repairComposition(rows, input) {
976
1066
  const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
977
1067
  const directory = assistantDir(rows, id)
978
1068
  if (directory === undefined) return unknownAssistant(id)
1069
+ const conflict = refuseStaleState(directory, readPrompt(directory).text ?? '', undefined, input?.baseRevision)
1070
+ if (conflict !== null) return conflict
979
1071
  const file = compositionFile(directory)
980
1072
  if (!existsSync(file)) return { ok: false, code: 'compositionMissing', params: { path: file }, error: '找不到组成文件:' + file }
981
1073
  const text = readFileSync(file, 'utf8')
@@ -1006,7 +1098,9 @@ export function repairComposition(rows, input) {
1006
1098
  }
1007
1099
  // **就地**关闭那几行,不重渲染:重渲染会顺手丢掉 base 之外的自有行,而用户的数据不该被这样动。
1008
1100
  const rendered = disableRowsInPlace(text, bad.map((row) => row.id))
1101
+ let revision
1009
1102
  try {
1103
+ revision = revisionWithEntries(directory, [[file, rendered]])
1010
1104
  writeAtomic(file, rendered)
1011
1105
  } catch (error) {
1012
1106
  return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
@@ -1016,6 +1110,7 @@ export function repairComposition(rows, input) {
1016
1110
  ok: true,
1017
1111
  id,
1018
1112
  code: 'repaired',
1113
+ revision,
1019
1114
  // `ids` 是给人看的一串;`repairedIds` 是给**页面**用的数组 —— 页面必须把这几行写进它自己的
1020
1115
  // 草稿,否则下一次保存会按草稿重渲染,把刚才的修复原样撤销(issue #8 实测:
1021
1116
  // 修复写进了磁盘,但草稿里那几行仍是启用的,于是保存后 ghost 行又回来了)。
@@ -1222,6 +1317,9 @@ export function apply(ctx) {
1222
1317
  backendVerdict.id === BACKEND_DECLARATIVE
1223
1318
  ? createDeclarativeBackend({ scope, log: console.log, warn: console.error })
1224
1319
  : null
1320
+ if (declarative !== null) {
1321
+ scope.effect(() => () => declarative.disposeAll(), 'custom-mode.presets')
1322
+ }
1225
1323
 
1226
1324
  /**
1227
1325
  * 删除入口,按后端选。新线上服务**没有** `remove()`(唯一手段是 `register()` 的 disposer),
@@ -1363,6 +1461,26 @@ export function apply(ctx) {
1363
1461
  * 写操作之后的重同步由 `resyncPresets` 负责,不该让每个请求都等一遍。
1364
1462
  */
1365
1463
  let startupSync = null
1464
+ let latestSync = { ok: true, failures: [] }
1465
+ const synchronize = async () => {
1466
+ const outcome = await doSync()
1467
+ latestSync = outcome
1468
+ return outcome
1469
+ }
1470
+ // Public synchronization evidence contains only bounded preset identifiers and
1471
+ // typed codes. Raw registration errors stay in host logs.
1472
+ const registrationSync = (assistantId) => {
1473
+ const failures = assistantId === undefined ? latestSync.failures
1474
+ : latestSync.failures.filter(({ id }) => typeof id !== 'string' || id === '' || id === assistantId)
1475
+ if (latestSync.ok === true || failures.length === 0) return { ok: true, code: 'synchronized', ids: [] }
1476
+ return {
1477
+ ok: false,
1478
+ code: 'presetRegistrationSyncFailed',
1479
+ ids: [...new Set(failures.map(({ id }) => id)
1480
+ .filter((id) => typeof id === 'string' && /^[A-Za-z0-9_.-]{1,128}$/.test(id)))].slice(0, 32),
1481
+ restartSuggested: failures.some(({ code }) => code === 'registrationConflict' || code === 'registrationCleanupFailed'),
1482
+ }
1483
+ }
1366
1484
  const roster = async () => {
1367
1485
  if (startupSync !== null) await startupSync
1368
1486
  return rawRoster()
@@ -1370,12 +1488,12 @@ export function apply(ctx) {
1370
1488
 
1371
1489
  /** Called inside the write queue; readers also await the current synchronization. */
1372
1490
  const resyncPresets = () => {
1373
- startupSync = doSync()
1491
+ startupSync = synchronize()
1374
1492
  return startupSync
1375
1493
  }
1376
1494
 
1377
1495
  // 启动即同步:新线不是"写盘即生效",第一次进来必须先注册。
1378
- startupSync = doSync()
1496
+ startupSync = synchronize()
1379
1497
 
1380
1498
  /**
1381
1499
  * One Fetch-shaped handler for the whole surface.
@@ -1399,16 +1517,33 @@ export function apply(ctx) {
1399
1517
  detail: outcome.failures.map((failure) => failure.error ?? failure.code ?? 'unknown').join('; '),
1400
1518
  })
1401
1519
 
1402
- const handle = async (request, pathname) => {
1520
+ const capabilitySnapshot = () => {
1521
+ try {
1522
+ if (typeof scope.get === 'function') return { profileContext: Boolean(scope.get('profileContext')) }
1523
+ } catch {
1524
+ /* Optional host capability is unknown; keep predicate warning/fail-safe. */
1525
+ }
1526
+ return {}
1527
+ }
1528
+ const handle = (request, pathname) => withPredicateCapabilities(capabilitySnapshot(), async () => {
1403
1529
  try {
1404
1530
  const url = new URL(request.url)
1405
1531
  if (request.method === 'GET' && pathname === ROUTE_PATH) {
1406
1532
  await ensureShipped()
1407
- return json(readList(await roster()))
1533
+ const rows = await roster()
1534
+ return json({ ...readList(rows), registrationSync: registrationSync() })
1408
1535
  }
1409
1536
  if (request.method === 'GET' && pathname === STATE_PATH) {
1410
1537
  await ensureShipped()
1411
- return json(readState(await roster(), url.searchParams.get('id') ?? '', { factoryPrompt: packagedPrompt() }))
1538
+ const state = readState(await roster(), url.searchParams.get('id') ?? '', { factoryPrompt: packagedPrompt() })
1539
+ const synchronization = registrationSync(state.id)
1540
+ return json({
1541
+ ...state,
1542
+ registrationSync: synchronization,
1543
+ ...(state.ok === true && synchronization.ok === false
1544
+ ? { warnings: [...state.warnings, synchronization.restartSuggested ? 'presetRegistrationRestartSuggested' : 'presetRegistrationSyncFailed'] }
1545
+ : {}),
1546
+ })
1412
1547
  }
1413
1548
 
1414
1549
  if (request.method === 'GET' && pathname === HISTORY_PATH) {
@@ -1456,7 +1591,7 @@ export function apply(ctx) {
1456
1591
  const result = pathname === STATE_PATH ? saveState(rows, parsed)
1457
1592
  : pathname === CREATE_PATH ? createAssistant(rows, parsed)
1458
1593
  : pathname === REPAIR_PATH ? repairComposition(rows, parsed)
1459
- : pathname === REORDER_PATH ? reorderAssistant(rows, parsed)
1594
+ : pathname === REORDER_PATH ? reorderState(rows, parsed)
1460
1595
  : await deleteAssistant(rows, parsed, presetRemover())
1461
1596
  if (result.ok !== true) return json(result, 400)
1462
1597
  const synced = await resyncPresets()
@@ -1475,7 +1610,7 @@ export function apply(ctx) {
1475
1610
  // ——中文界面里会蹦出一句英文。带 code 后页面用自己的词典渲染,原始串只作为 `detail` 参数。
1476
1611
  return json({ ok: false, code: 'internalError', params: { detail: describe(error) }, error: describe(error) }, 500)
1477
1612
  }
1478
- }
1613
+ })
1479
1614
 
1480
1615
  // One registration per exact path — the platform's Fetch registry matches exact paths, not prefixes —
1481
1616
  // each declaring the methods it owns. The method table doubles as the guarantee that a prefetched
package/locales.mjs CHANGED
@@ -16,11 +16,49 @@
16
16
 
17
17
  /** Simplified Chinese dictionary — the key source of truth. */
18
18
  export const zh = {
19
+ 'update.heading': '插件更新',
20
+ 'update.orphanHint': '以下助手已不在当前列表,但本页仍保留其未保存草稿,因此暂不能离页。',
21
+ 'update.orphanName': '{name}(ID:{id})',
22
+ 'update.orphanView': '查看并保留草稿',
23
+ 'update.orphanHide': '收起草稿',
24
+ 'update.orphanCopy': '下方包含完整提示词、名称、描述、模式和覆盖项,可选中复制到本地文件保留。放弃仅移除本页内存草稿,不修改磁盘文件。',
25
+ 'update.orphanDiscard': '放弃此草稿…',
26
+ 'update.orphanConfirm': '确认放弃“{name}”(ID:{id})的未保存草稿?请先复制保留需要的内容。放弃后无法从本页恢复;磁盘文件不会改动。',
27
+ 'update.orphanAcknowledge': '我已保留需要的内容,确认放弃后无法从本页恢复。',
28
+ 'update.orphanChanged': '草稿或助手列表已变化,未放弃任何内容。请重新查看并确认。',
29
+ 'update.orphanDiscarded': '已放弃所确认的本页草稿;磁盘文件未改动。',
30
+ 'update.running': '运行中的插件版本:{version}',
31
+ 'update.unknown': '未知',
32
+ 'update.check': '检查更新',
33
+ 'update.checking': '正在检查…',
34
+ 'update.inPlugins': '在插件管理中更新',
35
+ 'update.details': '打开插件详情',
36
+ 'update.guide': '更新指引',
37
+ 'update.idle': '仅在点击检查时查询宿主配置的包仓库;不会自动安装。',
38
+ 'update.unavailable': '当前连接未提供原生版本检查能力。可打开插件详情或查看更新指引。',
39
+ 'update.permission': '此操作缺少宿主授权,或权限服务暂不可用。',
40
+ 'update.updatePermission': '可以查看版本,但当前连接没有更新权限。',
41
+ 'update.available': '可用更新:{version}。安装由官方插件管理处理。',
42
+ 'update.current': '此次检查未返回更高版本。',
43
+ 'update.filtered': '发现较新版本,但没有符合官方默认条件的稳定兼容版本。',
44
+ 'update.partial': '结果未覆盖全部新版本,暂未找到可推荐的更新。',
45
+ 'update.omitted': '另有 {count} 个较新版本未列出。',
46
+ 'update.disk': '磁盘安装版本:{version};它可能尚未在运行中加载。',
47
+ 'update.managed': '此安装无法使用原生包仓库更新,请通过原来源或插件详情管理;不会自动切换来源。',
48
+ 'update.notFound': '宿主插件列表中没有找到此插件,无法确认更新。',
49
+ 'update.network': '宿主查询包仓库失败,请稍后重试;尚不能确认更新状态。',
50
+ 'update.refused': '宿主未提供可用的版本列表,尚不能确认更新状态。',
51
+ 'update.failed': '未能完成更新检查,请重试或打开插件详情查看。',
52
+ 'update.finishOperation': '请等待当前操作完成后再离开此页。',
53
+ 'update.finishRecovery': '请先完成重试读取或重试生效,再离开此页。',
54
+ 'update.saveDrafts': '请先保存或主动放弃所有助手草稿及未提交的新建输入,再离开此页。',
55
+ 'update.navigationFailed': '未能打开插件详情;草稿已保留,可稍后重试。',
19
56
  'create.nextStep': '创建后可在上方继续编辑并保存;使用时,在新会话的模式菜单选择它。',
20
57
  'btn.retryRead': '重试读取',
21
58
  'btn.retrySync': '重试生效',
22
59
  'api.savedButSyncFailed': '文件已保存,但这些助手尚未生效:{ids}。请点「重试生效」,无需重复原操作。',
23
60
  'api.syncFailed': '这些助手仍未生效:{ids}。请重试;仍失败时查看宿主日志。',
61
+ 'api.presetRegistrationSyncFailed': '尚未确认已保存的助手配置在宿主注册表中生效,请处理已有告警或查看宿主日志。',
24
62
  'api.synchronized': '配置已生效。新会话请从模式菜单选择助手。',
25
63
  'api.recoveryRequired': '助手文件需要恢复:{path}。请保留目录中的备份,并查看宿主日志中的恢复说明。',
26
64
  'msg.completedReadFailed': '{result} 页面刷新失败。请点「重试读取」,无需重复原操作。',
@@ -63,6 +101,9 @@ export const zh = {
63
101
  'assistant.brokenHint': '此模式在本机无法启动,不会出现在新会话菜单里。点「按本线修复」只关闭那些行。',
64
102
  'assistant.switchHint': '切换助手保留各自的未保存草稿;列表中的「未保存」标记即为此。',
65
103
  'btn.create': '新增助手',
104
+ 'btn.newAssistant': '新建助手…',
105
+ 'assistant.duplicateSavedConfirm': '复制使用磁盘上已保存的配置,不包含当前未保存的修改。继续复制已保存版本?当前草稿会保留。',
106
+ 'prompt.replaceConfirm': '替换当前提示词草稿?尚未保存的提示词会被替换,无法从保存历史中恢复。请先复制保留需要的内容。',
66
107
  'create.heading': '新建助手',
67
108
  'btn.creating': '创建中…',
68
109
  'btn.duplicate': '复制',
@@ -112,7 +153,7 @@ export const zh = {
112
153
  'meta.version': '插件版本',
113
154
  'meta.versionHint': '未钉版本时,可能装到较旧的发布。要换版本,按 README 钉版本重装,然后重启。',
114
155
  'api.saved': '已保存({name},{mode})。下一步组装时会重读已保存的提示词。',
115
- 'api.conflict': '磁盘上的提示词已经变了,这次没有写入。先重新读取,再决定要不要覆盖。',
156
+ 'api.conflict': '磁盘上的助手配置已经变了,这次没有写入。请先导出或复制保留草稿,再重新读取并合并修改。',
116
157
  'api.savedWithExclusiveRows': '已保存({name},{mode})。已关闭不能同时启用的行:{rows}。',
117
158
  'api.created': '已创建「{name}」。可继续编辑;使用时请在新会话的模式菜单选择它。',
118
159
  'api.savedPrompt': '已保存系统提示词({name})。下一步组装时会重读这个文件。',
@@ -150,6 +191,8 @@ export const zh = {
150
191
  'warn.approvalGateMissing': '当前版本没有审批事件。会话内改写提示词不会请求确认。',
151
192
  'warn.unresolvableRows': '有的行在本机无法启动,此模式不会出现在新会话菜单里。点「按本线修复」只关闭那些行。',
152
193
  'warn.presetBroken': '此模式无法启动,不会出现在新会话菜单里。点「按本线修复」。原因见下一行。',
194
+ 'warn.presetRegistrationSyncFailed': '尚未确认这位助手已保存的配置在宿主注册表中生效。请先处理已有告警或查看宿主日志;无需重复保存。',
195
+ 'warn.presetRegistrationRestartSuggested': '尚未确认这位助手已保存的配置在宿主注册表中生效。如刚更新或恢复插件,建议重启 DeepSeek Harness 后重新检查;无需重复保存。',
153
196
  'warn.badPromptVariable': '提示词里有不可用的变量。改掉后再保存,否则此模式的请求会失败。',
154
197
  'warn.duplicateRowIds': '组成文件里有重复的行 id。开关只作用于第一处。',
155
198
  'warn.predicateFailed': '有一行的平台条件无法计算,已按关闭处理。',
@@ -302,11 +345,49 @@ export const zh = {
302
345
 
303
346
  /** English dictionary; the parity test keeps its key set equal to {@link zh}. */
304
347
  export const en = {
348
+ 'update.heading': 'Plugin updates',
349
+ 'update.orphanHint': 'These assistants left the current list, but this page still holds their unsaved drafts. Resolve them before leaving.',
350
+ 'update.orphanName': '{name} (ID: {id})',
351
+ 'update.orphanView': 'View and keep draft',
352
+ 'update.orphanHide': 'Hide draft',
353
+ 'update.orphanCopy': 'The text below includes the full prompt, name, description, mode and overrides. Select and copy it to a local file to keep it. Discarding only removes this page\'s in-memory draft; files on disk are unchanged.',
354
+ 'update.orphanDiscard': 'Discard this draft…',
355
+ 'update.orphanConfirm': 'Discard the unsaved draft for "{name}" (ID: {id})? First copy any content you need to keep. This page cannot recover it after discarding; files on disk will remain unchanged.',
356
+ 'update.orphanAcknowledge': 'I have kept the content I need and understand this page cannot recover the discarded draft.',
357
+ 'update.orphanChanged': 'The draft or assistant list changed. Nothing was discarded. Review it again before confirming.',
358
+ 'update.orphanDiscarded': 'The confirmed draft was discarded from this page; files on disk are unchanged.',
359
+ 'update.running': 'Running plugin version: {version}',
360
+ 'update.unknown': 'unknown',
361
+ 'update.check': 'Check for updates',
362
+ 'update.checking': 'Checking…',
363
+ 'update.inPlugins': 'Update in Plugins',
364
+ 'update.details': 'Open plugin details',
365
+ 'update.guide': 'Update guide',
366
+ 'update.idle': 'Only a click checks the registries configured by the host; installation is never automatic.',
367
+ 'update.unavailable': 'This connection does not offer native version checks. Open plugin details or the update guide.',
368
+ 'update.permission': 'Host permission is missing for this action, or the access service is unavailable.',
369
+ 'update.updatePermission': 'You can view versions, but this connection does not have update permission.',
370
+ 'update.available': 'Update available: {version}. Official plugin management handles installation.',
371
+ 'update.current': 'This check returned no newer releases.',
372
+ 'update.filtered': 'Newer releases exist, but none meet the official default conditions for a stable compatible version.',
373
+ 'update.partial': 'The result omits some newer releases; no recommended update was found.',
374
+ 'update.omitted': '{count} additional newer releases were omitted.',
375
+ 'update.disk': 'Installed on disk: {version}; this version may not be loaded in the running host yet.',
376
+ 'update.managed': 'Manage this installation through its original source or plugin details; it cannot use this native registry update path. The source will not be changed automatically.',
377
+ 'update.notFound': 'The host did not list this plugin, so its update status could not be determined.',
378
+ 'update.network': 'The host could not query the registry. Try again later; update status is unknown.',
379
+ 'update.refused': 'The host did not provide a usable version list; update status is unknown.',
380
+ 'update.failed': 'The update check could not finish. Retry or open plugin details.',
381
+ 'update.finishOperation': 'Wait for the current operation to finish before leaving this page.',
382
+ 'update.finishRecovery': 'Complete Retry refresh or Retry activation before leaving this page.',
383
+ 'update.saveDrafts': 'Save or explicitly discard all assistant drafts and unsubmitted new-assistant input before leaving this page.',
384
+ 'update.navigationFailed': 'Plugin details could not open. Your drafts are preserved; try again later.',
305
385
  'create.nextStep': 'After creating, edit and save above. To use the assistant, choose it in the mode menu for a new session.',
306
386
  'btn.retryRead': 'Retry refresh',
307
387
  'btn.retrySync': 'Retry activation',
308
388
  '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
389
  'api.syncFailed': 'These assistants are still not active: {ids}. Retry, then check the host log if the problem continues.',
390
+ 'api.presetRegistrationSyncFailed': 'The saved assistant configuration has not been confirmed in the host registry. Address existing warnings or check the host log.',
310
391
  'api.synchronized': 'Configuration is active. Choose an assistant in the mode menu for a new session.',
311
392
  'api.recoveryRequired': 'Assistant files need recovery: {path}. Keep the backups in that directory and check the host log for recovery details.',
312
393
  'msg.completedReadFailed': '{result} The page could not refresh. Select Retry refresh; do not repeat the original action.',
@@ -347,6 +428,9 @@ export const en = {
347
428
  'assistant.brokenHint': 'This mode cannot start here, so it will not appear for a new session. Fix for this line turns off only those rows.',
348
429
  'assistant.switchHint': 'Switching assistants keeps each draft; the "Unsaved" marker in the list is exactly that.',
349
430
  'btn.create': 'New assistant',
431
+ 'btn.newAssistant': 'New assistant…',
432
+ 'assistant.duplicateSavedConfirm': 'Copy the saved configuration from disk? Unsaved changes are not included, and your current draft will be kept.',
433
+ 'prompt.replaceConfirm': 'Replace the current prompt draft? Unsaved prompt text will be replaced and cannot be recovered from saved history. Copy any content you need to keep first.',
350
434
  'create.heading': 'New assistant',
351
435
  'btn.creating': 'Creating…',
352
436
  'btn.duplicate': 'Duplicate',
@@ -396,7 +480,7 @@ export const en = {
396
480
  'meta.version': 'Plugin version',
397
481
  'meta.versionHint': 'Without a pinned version, an older release may be installed. To change it, reinstall with the version pin from the README, then restart.',
398
482
  'api.saved': 'Saved ({name}, {mode}). The next model step rereads the saved prompt.',
399
- 'api.conflict': 'The prompt on disk has changed, so this save was not written. Reload, then decide whether to overwrite.',
483
+ 'api.conflict': 'The assistant configuration on disk has changed. Nothing was written. Export or copy your draft, then reload and merge your changes.',
400
484
  'api.savedWithExclusiveRows': 'Saved ({name}, {mode}). Turned off rows that cannot be on together: {rows}.',
401
485
  'api.created': 'Created {name}. Continue editing here, then choose it in the mode menu for a new session.',
402
486
  'api.savedPrompt': 'System prompt saved ({name}). The next model step rereads this file.',
@@ -437,6 +521,8 @@ export const en = {
437
521
  'warn.approvalGateMissing': 'This version has no approval event. In-session prompt edits are not confirmed.',
438
522
  'warn.unresolvableRows': 'Some rows cannot start here, so this mode is absent from the new-session menu. Fix for this line turns off only those rows.',
439
523
  'warn.presetBroken': 'This mode cannot start, so it will not appear for a new session. Use Fix for this line. The reason is on the next line.',
524
+ 'warn.presetRegistrationSyncFailed': 'This assistant\'s saved configuration has not been confirmed in the host registry. Address existing warnings or check the host log; you do not need to save again.',
525
+ 'warn.presetRegistrationRestartSuggested': 'This assistant\'s saved configuration has not been confirmed in the host registry. If the plugin was just updated or restored, consider restarting DeepSeek Harness, then check again; you do not need to save again.',
440
526
  'warn.badPromptVariable': 'The prompt uses a variable that is not available. Fix it before saving.',
441
527
  'warn.duplicateRowIds': 'A row id is repeated. A switch applies only to the first one.',
442
528
  'warn.predicateFailed': 'A platform condition could not be evaluated. That row is treated as off.',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "2.2.6",
4
- "description": "Edit a DeepSeek Harness (dsh) custom mode: system prompt, base mode, and plugin switches.",
3
+ "version": "2.3.1",
4
+ "description": "Customize DeepSeek Harness assistants: edit system prompts, base modes, and plugin switches.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "author": "bowluna <ai@bowluna.com>",