dsh-custom-mode 2.2.5 → 2.3.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.
@@ -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,20 @@ 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
+ let stopped = false
107
+ const stoppedResult = () => ({ ok: false, code: 'backendStopped', error: 'backend stopped' })
108
+ const serialize = (work) => {
109
+ const next = mutations.then(work, work)
110
+ mutations = next.then(() => undefined, () => undefined)
111
+ return next
112
+ }
105
113
 
106
114
  async function unmount(id) {
107
115
  const entry = mounted.get(id)
108
116
  if (entry === undefined) return
117
+ await entry.dispose()
109
118
  mounted.delete(id)
110
- try {
111
- await entry.dispose()
112
- } catch (error) {
113
- warn(`custom-mode: 注销 preset "${id}" 时出错(已忽略): ${describe(error)}`)
114
- }
115
119
  }
116
120
 
117
121
  /** Build the definition for one assistant directory, or null when it has no composition. */
@@ -133,6 +137,7 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
133
137
  * registry refuses a duplicate id outright.
134
138
  */
135
139
  async function mountOne(assistant) {
140
+ if (stopped) return stoppedResult()
136
141
  let definition
137
142
  try {
138
143
  definition = buildDefinition(assistant)
@@ -140,26 +145,40 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
140
145
  // 解析失败是**用户可见**的问题(手改坏了 composition),要显式说,不能静默跳过。
141
146
  warn(`custom-mode: 预设 "${assistant.id}" 的 composition 解析失败 —— ${describe(error)}。该助手不会出现在选择器里。`)
142
147
  await unmount(assistant.id)
143
- return { ok: false, error: describe(error) }
148
+ return { ok: false, code: 'compositionParseFailed', error: describe(error) }
144
149
  }
145
150
  if (definition === null) {
146
151
  await unmount(assistant.id)
147
- return { ok: false, error: 'no composition' }
152
+ return { ok: false, code: 'noComposition', error: 'no composition' }
148
153
  }
149
- await unmount(assistant.id)
154
+ let registering = false
150
155
  try {
156
+ await unmount(assistant.id)
157
+ if (stopped) return stoppedResult()
158
+ registering = true
151
159
  const dispose = await scope.agentPresets.register(definition)
160
+ registering = false
152
161
  mounted.set(assistant.id, { dispose, dir: assistant.dir })
162
+ // register() can finish after the scope starts stopping. Keep its disposer until
163
+ // it succeeds, and release only this generation's own registration.
164
+ if (stopped) {
165
+ await unmount(assistant.id)
166
+ return stoppedResult()
167
+ }
153
168
  return { ok: true }
154
169
  } catch (error) {
155
170
  // register 会抛(Duplicate / id 空 / 已存在):说清是哪个助手、哪条错误。
156
171
  warn(`custom-mode: 注册预设 "${assistant.id}" 失败 —— ${describe(error)}`)
157
- return { ok: false, error: describe(error) }
172
+ const code = registering
173
+ ? describe(error) === 'Duplicate agent preset: ' + assistant.id ? 'registrationConflict' : 'registrationFailed'
174
+ : 'registrationCleanupFailed'
175
+ return { ok: false, code, error: describe(error) }
158
176
  }
159
177
  }
160
178
 
161
179
  /** Bring the registry in line with the given assistant list: mount all, drop the rest. */
162
180
  async function sync(assistants) {
181
+ if (stopped) return assistants.map(({ id }) => ({ id, ...stoppedResult() }))
163
182
  const results = []
164
183
  for (const assistant of assistants) results.push({ id: assistant.id, ...(await mountOne(assistant)) })
165
184
  const wanted = new Set(assistants.map((a) => a.id))
@@ -173,7 +192,11 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
173
192
  }
174
193
 
175
194
  async function disposeAll() {
176
- for (const id of [...mounted.keys()]) await unmount(id)
195
+ const errors = []
196
+ for (const id of [...mounted.keys()]) {
197
+ try { await unmount(id) } catch (error) { errors.push(error) }
198
+ }
199
+ if (errors.length > 0) throw new AggregateError(errors, 'preset registration cleanup failed')
177
200
  }
178
201
 
179
202
  /**
@@ -257,11 +280,16 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
257
280
  id: BACKEND_ID,
258
281
  /** 新线不把 preset 落到磁盘,注册表就是唯一真相。 */
259
282
  writesPresetFiles: false,
260
- sync,
261
- mountOne,
262
- unmount,
263
- remove,
264
- disposeAll,
283
+ sync: (assistants) => serialize(() => sync(assistants)),
284
+ mountOne: (assistant) => serialize(() => mountOne(assistant)),
285
+ unmount: (id) => serialize(() => unmount(id)),
286
+ remove: (id, dir) => serialize(() => stopped ? stoppedResult() : remove(id, dir)),
287
+ disposeAll: () => {
288
+ // Close admission immediately, before awaiting queued/in-flight mutations. A
289
+ // startup sync still reading host metadata must not resurrect this backend.
290
+ stopped = true
291
+ return serialize(disposeAll)
292
+ },
265
293
  fetchBaseCompositions,
266
294
  mountedIds: () => [...mounted.keys()],
267
295
  }
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
  }