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.
@@ -26,20 +26,17 @@
26
26
  import { existsSync, readFileSync, renameSync, rmSync } from 'node:fs'
27
27
  import { basename, dirname, join } from 'node:path'
28
28
  import { pathToFileURL } from 'node:url'
29
- import { parseComposition } from './parse-composition.mjs'
29
+ import { parseComposition, evaluateDisabledPredicate, profileContextPredicate } from './parse-composition.mjs'
30
30
 
31
31
  export const BACKEND_ID = 'declarative'
32
32
 
33
- /** Evaluate a `!!js` predicate. Only ever applied to text this feature itself wrote. */
33
+ /** Preserve the host-native predicate node so the loader evaluates it against its real Entry.ctx. */
34
34
  function resolveDisabled(value) {
35
35
  if (value !== null && typeof value === 'object' && typeof value.__js === 'string') {
36
- try {
37
- // eslint-disable-next-line no-new-func
38
- return Boolean(new Function(`"use strict"; return (${value.__js});`)())
39
- } catch {
40
- // 表达式求值不了时按"关掉"处理:宁可少一行能力,也不要让整条 preset 挂载失败。
41
- return true
42
- }
36
+ // An unknown ctx service can return undefined rather than throw, which would
37
+ // make the native loader enable it. Keep unsupported conditions truly off.
38
+ if (profileContextPredicate(value.__js) === null && evaluateDisabledPredicate(value.__js) === undefined) return true
39
+ return { __jsExpr: value.__js }
43
40
  }
44
41
  return value === true
45
42
  }
@@ -103,6 +100,8 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
103
100
  /** presetId → { dispose, dir } —— 当前挂在注册表里的东西,注销的唯一凭据。 */
104
101
  const mounted = new Map()
105
102
  let mutations = Promise.resolve()
103
+ let stopped = false
104
+ const stoppedResult = () => ({ ok: false, code: 'backendStopped', error: 'backend stopped' })
106
105
  const serialize = (work) => {
107
106
  const next = mutations.then(work, work)
108
107
  mutations = next.then(() => undefined, () => undefined)
@@ -135,6 +134,7 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
135
134
  * registry refuses a duplicate id outright.
136
135
  */
137
136
  async function mountOne(assistant) {
137
+ if (stopped) return stoppedResult()
138
138
  let definition
139
139
  try {
140
140
  definition = buildDefinition(assistant)
@@ -142,26 +142,40 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
142
142
  // 解析失败是**用户可见**的问题(手改坏了 composition),要显式说,不能静默跳过。
143
143
  warn(`custom-mode: 预设 "${assistant.id}" 的 composition 解析失败 —— ${describe(error)}。该助手不会出现在选择器里。`)
144
144
  await unmount(assistant.id)
145
- return { ok: false, error: describe(error) }
145
+ return { ok: false, code: 'compositionParseFailed', error: describe(error) }
146
146
  }
147
147
  if (definition === null) {
148
148
  await unmount(assistant.id)
149
- return { ok: false, error: 'no composition' }
149
+ return { ok: false, code: 'noComposition', error: 'no composition' }
150
150
  }
151
+ let registering = false
151
152
  try {
152
153
  await unmount(assistant.id)
154
+ if (stopped) return stoppedResult()
155
+ registering = true
153
156
  const dispose = await scope.agentPresets.register(definition)
157
+ registering = false
154
158
  mounted.set(assistant.id, { dispose, dir: assistant.dir })
159
+ // register() can finish after the scope starts stopping. Keep its disposer until
160
+ // it succeeds, and release only this generation's own registration.
161
+ if (stopped) {
162
+ await unmount(assistant.id)
163
+ return stoppedResult()
164
+ }
155
165
  return { ok: true }
156
166
  } catch (error) {
157
167
  // register 会抛(Duplicate / id 空 / 已存在):说清是哪个助手、哪条错误。
158
168
  warn(`custom-mode: 注册预设 "${assistant.id}" 失败 —— ${describe(error)}`)
159
- return { ok: false, error: describe(error) }
169
+ const code = registering
170
+ ? describe(error) === 'Duplicate agent preset: ' + assistant.id ? 'registrationConflict' : 'registrationFailed'
171
+ : 'registrationCleanupFailed'
172
+ return { ok: false, code, error: describe(error) }
160
173
  }
161
174
  }
162
175
 
163
176
  /** Bring the registry in line with the given assistant list: mount all, drop the rest. */
164
177
  async function sync(assistants) {
178
+ if (stopped) return assistants.map(({ id }) => ({ id, ...stoppedResult() }))
165
179
  const results = []
166
180
  for (const assistant of assistants) results.push({ id: assistant.id, ...(await mountOne(assistant)) })
167
181
  const wanted = new Set(assistants.map((a) => a.id))
@@ -175,7 +189,11 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
175
189
  }
176
190
 
177
191
  async function disposeAll() {
178
- for (const id of [...mounted.keys()]) await unmount(id)
192
+ const errors = []
193
+ for (const id of [...mounted.keys()]) {
194
+ try { await unmount(id) } catch (error) { errors.push(error) }
195
+ }
196
+ if (errors.length > 0) throw new AggregateError(errors, 'preset registration cleanup failed')
179
197
  }
180
198
 
181
199
  /**
@@ -262,8 +280,13 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
262
280
  sync: (assistants) => serialize(() => sync(assistants)),
263
281
  mountOne: (assistant) => serialize(() => mountOne(assistant)),
264
282
  unmount: (id) => serialize(() => unmount(id)),
265
- remove: (id, dir) => serialize(() => remove(id, dir)),
266
- disposeAll: () => serialize(disposeAll),
283
+ remove: (id, dir) => serialize(() => stopped ? stoppedResult() : remove(id, dir)),
284
+ disposeAll: () => {
285
+ // Close admission immediately, before awaiting queued/in-flight mutations. A
286
+ // startup sync still reading host metadata must not resurrect this backend.
287
+ stopped = true
288
+ return serialize(disposeAll)
289
+ },
267
290
  fetchBaseCompositions,
268
291
  mountedIds: () => [...mounted.keys()],
269
292
  }
@@ -39,6 +39,56 @@ export class CompositionParseError extends Error {
39
39
  const isBlank = (line) => line.trim() === '' || line.trim().startsWith('#')
40
40
  const indentOf = (line) => line.length - line.trimStart().length
41
41
 
42
+ /** Decode the YAML scalar around a tagged JS source; never execute the expression. */
43
+ export function decodeJsExpression(raw, lineNo = 0) {
44
+ const source = String(raw).trim()
45
+ if (source === '') throw new CompositionParseError('!!js 后面没有表达式', lineNo)
46
+ if (source.startsWith("'")) {
47
+ if (!/^'(?:[^']|'')*'$/.test(source)) throw new CompositionParseError('!!js 单引号标量无效', lineNo)
48
+ return source.slice(1, -1).replace(/''/g, "'")
49
+ }
50
+ if (source.startsWith('"')) {
51
+ try {
52
+ const decoded = JSON.parse(source)
53
+ if (typeof decoded !== 'string') throw new Error('Expected a string')
54
+ return decoded
55
+ } catch {
56
+ throw new CompositionParseError('!!js 双引号标量无效', lineNo)
57
+ }
58
+ }
59
+ return source
60
+ }
61
+
62
+ /** The only context-service predicate currently understood by the editor. */
63
+ export function profileContextPredicate(source) {
64
+ const match = /^(!{0,2})\s*ctx\s*\.\s*get\s*\(\s*(['"])profileContext\2\s*\)$/.exec(source)
65
+ return match === null ? null : match[1].length
66
+ }
67
+
68
+ /**
69
+ * Preserve existing non-context evaluation semantics for installed composition
70
+ * code, while unknown context references stay unsupported rather than guessed.
71
+ */
72
+ export function evaluateDisabledPredicate(source, capabilities = {}) {
73
+ const negations = profileContextPredicate(source)
74
+ if (negations !== null) {
75
+ if (typeof capabilities?.profileContext !== 'boolean') return undefined
76
+ return negations === 1 ? !capabilities.profileContext : capabilities.profileContext
77
+ }
78
+ const code = source
79
+ .replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, '')
80
+ .replace(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g, '')
81
+ if (/\bctx\b/.test(code)) return undefined
82
+ try {
83
+ // Composition files are installed executable code. No real host context is
84
+ // supplied, and the ctx parameter shadows any unrelated global of that name.
85
+ const evaluate = new Function('process', 'ctx', `return (${source});`)
86
+ return Boolean(evaluate(process, undefined))
87
+ } catch {
88
+ return undefined
89
+ }
90
+ }
91
+
42
92
  /**
43
93
  * Parse a composition document into plain JS.
44
94
  *
@@ -156,9 +206,7 @@ export function parseScalar(raw, lineNo = 0) {
156
206
  const value = raw.trim()
157
207
  if (value === '') return null
158
208
  if (value.startsWith('!!js')) {
159
- const source = value.slice(4).trim()
160
- if (source === '') throw new CompositionParseError('!!js 后面没有表达式', lineNo)
161
- return { __js: source }
209
+ return { __js: decodeJsExpression(value.slice(4), lineNo) }
162
210
  }
163
211
  if (value === 'true') return true
164
212
  if (value === 'false') return false