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.
- package/README.i18n.yaml +2 -2
- package/README.md +76 -57
- package/README.zh.md +76 -57
- package/assistants.mjs +8 -2
- package/atomic.mjs +156 -56
- package/client.js +653 -227
- package/composition.mjs +5 -4
- package/index.mjs +86 -47
- package/locales.mjs +138 -20
- package/package.json +4 -4
- package/preset/prompt-reader.mjs +35 -2
- package/preset/prompt-tool.mjs +1 -0
- package/preset/prompt.md +0 -2
- package/preset-backend/declarative.mjs +43 -15
- package/seed.mjs +5 -1
package/atomic.mjs
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* random temporary name does not help with *that* collision, only with temp-vs-temp ones.
|
|
15
15
|
*/
|
|
16
16
|
import { randomBytes } from 'node:crypto'
|
|
17
|
-
import { existsSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
18
|
-
import { join } from 'node:path'
|
|
17
|
+
import { existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
19
19
|
|
|
20
20
|
/** Synchronous backoff: these write paths are synchronous, so waiting is simpler than async plumbing. */
|
|
21
21
|
function sleepSync(ms) {
|
|
@@ -63,7 +63,12 @@ export function renameWithRetry(from, to, options = {}) {
|
|
|
63
63
|
*/
|
|
64
64
|
export function stageAtomic(file, text) {
|
|
65
65
|
const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
|
|
66
|
-
|
|
66
|
+
try {
|
|
67
|
+
writeFileSync(temporary, text, 'utf8')
|
|
68
|
+
} catch (error) {
|
|
69
|
+
try { rmSync(temporary, { force: true }) } catch { /* retain the original failure */ }
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
67
72
|
return { file, temporary }
|
|
68
73
|
}
|
|
69
74
|
|
|
@@ -108,6 +113,14 @@ export function discardStaged(staged) {
|
|
|
108
113
|
* @returns {void}
|
|
109
114
|
*/
|
|
110
115
|
export function writeAtomicPair(entries) {
|
|
116
|
+
if (entries.length === 0) return
|
|
117
|
+
const directory = dirname(resolve(entries[0][0]))
|
|
118
|
+
const names = entries.map(([file]) => basename(file))
|
|
119
|
+
if (new Set(names).size !== names.length || entries.some(([file]) => dirname(resolve(file)) !== directory || !TRANSACTION_FILES.includes(basename(file)))) {
|
|
120
|
+
throw new Error('Atomic preset transactions require distinct preset files in one directory')
|
|
121
|
+
}
|
|
122
|
+
const recovery = restoreBackups(directory)
|
|
123
|
+
if (recovery.ok !== true) throw new Error(recovery.detail)
|
|
111
124
|
const stamp = `${String(process.pid)}-${randomBytes(4).toString('hex')}`
|
|
112
125
|
|
|
113
126
|
// ── phase 0: refuse to touch a destination that is a directory. ──
|
|
@@ -133,54 +146,41 @@ export function writeAtomicPair(entries) {
|
|
|
133
146
|
throw error
|
|
134
147
|
}
|
|
135
148
|
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
const
|
|
149
|
+
// A prepared journal is on disk BEFORE any original moves. Recovery rolls the WHOLE transaction
|
|
150
|
+
// back, including files already installed; a committed journal keeps the whole new generation.
|
|
151
|
+
const journal = join(directory, `${TRANSACTION_PREFIX}${stamp}.json`)
|
|
152
|
+
const transaction = {
|
|
153
|
+
version: 1,
|
|
154
|
+
stamp,
|
|
155
|
+
ownerPid: process.pid,
|
|
156
|
+
phase: 'prepared',
|
|
157
|
+
entries: staged.map((one) => ({
|
|
158
|
+
name: basename(one.file),
|
|
159
|
+
temporary: basename(one.temporary),
|
|
160
|
+
backup: `${basename(one.file)}.bak-${stamp}`,
|
|
161
|
+
existed: existsSync(one.file),
|
|
162
|
+
})),
|
|
163
|
+
}
|
|
164
|
+
ACTIVE_TRANSACTIONS.add(directory)
|
|
139
165
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
renameWithRetry(one.file, backup)
|
|
144
|
-
backups.push([one.file, backup])
|
|
145
|
-
} else {
|
|
146
|
-
backups.push([one.file, null])
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
// ── phase 3: install. ──
|
|
150
|
-
for (const one of staged) {
|
|
151
|
-
commitStaged(one)
|
|
152
|
-
installed.push(one)
|
|
166
|
+
writeAtomic(journal, JSON.stringify(transaction) + '\n')
|
|
167
|
+
for (const one of transaction.entries) {
|
|
168
|
+
if (one.existed) renameWithRetry(join(directory, one.name), join(directory, one.backup))
|
|
153
169
|
}
|
|
170
|
+
for (const one of staged) commitStaged(one)
|
|
171
|
+
writeAtomic(journal, JSON.stringify({ ...transaction, phase: 'committed' }) + '\n')
|
|
154
172
|
} catch (error) {
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
rmSync(one.file, { force: true })
|
|
159
|
-
} catch {
|
|
160
|
-
/* best effort — the restore below is what matters */
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
for (const [file, backup] of backups) {
|
|
164
|
-
if (backup === null) continue
|
|
165
|
-
try {
|
|
166
|
-
renameWithRetry(backup, file)
|
|
167
|
-
} catch {
|
|
168
|
-
/* best effort; the thrown error still tells the caller nothing is consistent */
|
|
169
|
-
}
|
|
170
|
-
}
|
|
173
|
+
// If writing the journal itself failed no target moved. Otherwise leave recovery evidence until
|
|
174
|
+
// rollback has completed; a failed restore must not be hidden as "nothing changed".
|
|
175
|
+
const outcome = existsSync(journal) ? recoverTransaction(directory, journal, transaction) : { ok: true }
|
|
171
176
|
for (const one of staged) discardStaged(one)
|
|
177
|
+
ACTIVE_TRANSACTIONS.delete(directory)
|
|
178
|
+
if (outcome.ok !== true) throw new Error(String(error.message ?? error) + '; recovery required: ' + outcome.detail, { cause: error })
|
|
172
179
|
throw error
|
|
173
180
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (backup === null) continue
|
|
178
|
-
try {
|
|
179
|
-
rmSync(backup, { force: true })
|
|
180
|
-
} catch {
|
|
181
|
-
/* a leftover .bak-… is inert; failing the whole write over it would be worse */
|
|
182
|
-
}
|
|
183
|
-
}
|
|
181
|
+
// Cleanup failure is recoverable from the committed journal and cannot undo a successful write.
|
|
182
|
+
recoverTransaction(directory, journal, { ...transaction, phase: 'committed' })
|
|
183
|
+
ACTIVE_TRANSACTIONS.delete(directory)
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
/**
|
|
@@ -204,24 +204,124 @@ export function writeAtomicPair(entries) {
|
|
|
204
204
|
* @param {string} directory - one assistant directory.
|
|
205
205
|
* @returns {void}
|
|
206
206
|
*/
|
|
207
|
+
const TRANSACTION_FILES = ['prompt.md', 'agent.cordis.yml', 'preset.yml']
|
|
208
|
+
const TRANSACTION_PREFIX = '.custom-mode-transaction-'
|
|
209
|
+
const ACTIVE_TRANSACTIONS = new Set()
|
|
210
|
+
|
|
211
|
+
function pidFromStamp(stamp) {
|
|
212
|
+
const pid = Number(stamp.split('-')[0])
|
|
213
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A different live or uninspectable process might still be writing. Only ESRCH proves death. */
|
|
217
|
+
function ownerMayBeActive(pid) {
|
|
218
|
+
// This process runs transactions synchronously and ACTIVE_TRANSACTIONS covers its live writes.
|
|
219
|
+
if (pid === process.pid) return false
|
|
220
|
+
try {
|
|
221
|
+
process.kill(pid, 0)
|
|
222
|
+
return true
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return error.code !== 'ESRCH'
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function activeOwnerResult(pid) {
|
|
229
|
+
return { ok: false, code: 'recoveryRequired', detail: `Preset transaction owner ${pid} is active or cannot be inspected; retry after it finishes` }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function validTransaction(transaction, journalName) {
|
|
233
|
+
if (transaction?.version !== 1 || !/^\d+-[a-f0-9]{8}$/.test(transaction.stamp ?? '')) return false
|
|
234
|
+
if (journalName !== `${TRANSACTION_PREFIX}${transaction.stamp}.json`) return false
|
|
235
|
+
const stampedPid = pidFromStamp(transaction.stamp)
|
|
236
|
+
const ownerPid = transaction.ownerPid ?? stampedPid
|
|
237
|
+
if (stampedPid === null || !Number.isSafeInteger(ownerPid) || ownerPid <= 0) return false
|
|
238
|
+
if (transaction.legacy !== true && ownerPid !== stampedPid) return false
|
|
239
|
+
if (!['prepared', 'committed'].includes(transaction.phase) || !Array.isArray(transaction.entries) || transaction.entries.length === 0) return false
|
|
240
|
+
const names = new Set()
|
|
241
|
+
for (const one of transaction.entries) {
|
|
242
|
+
if (one === null || typeof one !== 'object') return false
|
|
243
|
+
if (!TRANSACTION_FILES.includes(one.name) || names.has(one.name) || typeof one.existed !== 'boolean') return false
|
|
244
|
+
if (one.backup !== `${one.name}.bak-${transaction.stamp}`) return false
|
|
245
|
+
if (transaction.legacy === true ? one.temporary !== null : !new RegExp(`^${one.name.replaceAll('.', '\\.')}\\.tmp-\\d+-[a-f0-9]{8}$`).test(one.temporary ?? '')) return false
|
|
246
|
+
names.add(one.name)
|
|
247
|
+
}
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function recoverTransaction(directory, journal, transaction) {
|
|
252
|
+
try {
|
|
253
|
+
if (transaction.phase === 'prepared') {
|
|
254
|
+
for (const one of transaction.entries) {
|
|
255
|
+
const file = join(directory, one.name)
|
|
256
|
+
const backup = join(directory, one.backup)
|
|
257
|
+
if (one.existed) {
|
|
258
|
+
if (existsSync(backup)) renameWithRetry(backup, file)
|
|
259
|
+
else if (!existsSync(file)) throw new Error(`Missing original and backup for ${one.name}`)
|
|
260
|
+
} else {
|
|
261
|
+
rmSync(file, { force: true })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (const one of transaction.entries) {
|
|
266
|
+
rmSync(join(directory, one.backup), { force: true })
|
|
267
|
+
if (one.temporary !== null) rmSync(join(directory, one.temporary), { force: true })
|
|
268
|
+
}
|
|
269
|
+
rmSync(journal, { force: true })
|
|
270
|
+
return { ok: true }
|
|
271
|
+
} catch (error) {
|
|
272
|
+
return { ok: false, code: 'recoveryRequired', detail: String(error.message ?? error) }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Recover before ownership discovery: prompt.md itself can be temporarily absent after a crash. */
|
|
207
277
|
export function restoreBackups(directory) {
|
|
208
|
-
|
|
278
|
+
if (ACTIVE_TRANSACTIONS.has(resolve(directory))) return { ok: true, active: true }
|
|
209
279
|
let entries
|
|
210
280
|
try {
|
|
211
281
|
entries = readdirSync(directory)
|
|
212
|
-
} catch {
|
|
213
|
-
return
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return error.code === 'ENOENT' ? { ok: true } : { ok: false, code: 'recoveryRequired', detail: String(error.message ?? error) }
|
|
214
284
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
285
|
+
const journals = entries.filter((entry) => entry.startsWith(TRANSACTION_PREFIX) && entry.endsWith('.json'))
|
|
286
|
+
// Shared homes across live processes are not a supported transaction boundary. Never guess which of
|
|
287
|
+
// multiple interrupted generations owns a target, and never seed a replacement over their evidence.
|
|
288
|
+
if (journals.length > 1) return { ok: false, code: 'recoveryRequired', detail: 'Multiple preset transactions require manual recovery' }
|
|
289
|
+
for (const name of journals) {
|
|
290
|
+
let transaction
|
|
220
291
|
try {
|
|
221
|
-
|
|
222
|
-
} catch {
|
|
223
|
-
|
|
292
|
+
transaction = JSON.parse(readFileSync(join(directory, name), 'utf8'))
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return { ok: false, code: 'recoveryRequired', detail: 'Unreadable preset transaction: ' + String(error.message ?? error) }
|
|
224
295
|
}
|
|
296
|
+
if (!validTransaction(transaction, name)) return { ok: false, code: 'recoveryRequired', detail: 'Invalid preset transaction journal' }
|
|
297
|
+
const ownerPid = transaction.ownerPid ?? pidFromStamp(transaction.stamp)
|
|
298
|
+
if (ownerMayBeActive(ownerPid)) return activeOwnerResult(ownerPid)
|
|
299
|
+
const outcome = recoverTransaction(directory, join(directory, name), transaction)
|
|
300
|
+
if (outcome.ok !== true) return outcome
|
|
301
|
+
}
|
|
302
|
+
// Upgrade recovery for v2.2.5 transactions that predate the journal. Restore one whole backup group
|
|
303
|
+
// only when something is missing. Complete targets plus leftover backups denote a completed write.
|
|
304
|
+
try { entries = readdirSync(directory) } catch (error) {
|
|
305
|
+
return { ok: false, code: 'recoveryRequired', detail: String(error.message ?? error) }
|
|
306
|
+
}
|
|
307
|
+
const legacy = entries.map((entry) => ({ entry, match: /^(prompt\.md|agent\.cordis\.yml|preset\.yml)\.bak-(\d+-[a-f0-9]{8})$/.exec(entry) })).filter((one) => one.match !== null)
|
|
308
|
+
if (!legacy.some((one) => !existsSync(join(directory, one.match[1])))) return { ok: true }
|
|
309
|
+
if (new Set(legacy.map((one) => one.match[2])).size !== 1) return { ok: false, code: 'recoveryRequired', detail: 'Ambiguous legacy preset backups require manual recovery' }
|
|
310
|
+
const legacyOwner = pidFromStamp(legacy[0].match[2])
|
|
311
|
+
if (legacyOwner === null) return { ok: false, code: 'recoveryRequired', detail: 'Invalid legacy preset transaction owner' }
|
|
312
|
+
if (ownerMayBeActive(legacyOwner)) return activeOwnerResult(legacyOwner)
|
|
313
|
+
try {
|
|
314
|
+
// Recovery itself can be interrupted. Journal the whole legacy group BEFORE restoring even the
|
|
315
|
+
// first missing target; otherwise that first rename can hide the need to restore the rest.
|
|
316
|
+
const stamp = legacy[0].match[2]
|
|
317
|
+
const journal = join(directory, `${TRANSACTION_PREFIX}${stamp}.json`)
|
|
318
|
+
const transaction = { version: 1, stamp, ownerPid: process.pid, phase: 'prepared', legacy: true, entries: legacy.map((one) => ({
|
|
319
|
+
name: one.match[1], backup: one.entry, temporary: null, existed: true,
|
|
320
|
+
})) }
|
|
321
|
+
writeAtomic(journal, JSON.stringify(transaction) + '\n')
|
|
322
|
+
return recoverTransaction(directory, journal, transaction)
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return { ok: false, code: 'recoveryRequired', detail: String(error.message ?? error) }
|
|
225
325
|
}
|
|
226
326
|
}
|
|
227
327
|
|