@goodandready/dsh-moa 0.2.27 → 0.2.30

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.md CHANGED
@@ -151,8 +151,8 @@ When `multi_judge_enabled: true`, candidate solutions are independently evaluate
151
151
  ### 11. Composite Hybrid Synthesis (AST / Block Merge)
152
152
  When `composite_merge_enabled: true`, the aggregator assembles a modular hybrid: combining the strongest core logic from one model, robust error handling from another, and complete types/tests from a third.
153
153
 
154
- ### 12. Smart Dynamic Preset Router & JEV Classifier
155
- When invoking `/moa` without explicit preset flags, the router classifies prompt intent using keyword heuristics or a zero-shot JEV model (`smart_routing_model`) to automatically select the optimal preset.
154
+ ### 12. Smart Dynamic Preset Router & LLM Classifier
155
+ When invoking `/moa` without explicit preset flags, the router classifies prompt intent using keyword heuristics or a zero-shot router model (`smart_routing_model`) to automatically select the optimal preset.
156
156
 
157
157
  ### 13. Real-Time Live Cost Counter & Streaming Ticker
158
158
  As each candidate completes, live token counts and USD costs are streamed directly into the chat based on catalog pricing and vendor rates.
@@ -195,16 +195,16 @@ v0.2.13 introduces 10 ready-to-use presets engineered for real-world software wo
195
195
 
196
196
  | Preset Name | Purpose | Default Aggregator | Peer Critique | Blind Eval |
197
197
  | :--- | :--- | :--- | :---: | :---: |
198
- | `default` | Balanced multi-model generation | `codex:gpt-5.6-sol` | Optional | Off |
199
- | `code-review` | Thorough peer review & vulnerability detection | `codex:gpt-5.6-sol` | On | On |
200
- | `fast-audit` | Ultra-fast single-model audit (Fast Mode) | `codex:gpt-5.6-sol` | Off | Off |
201
- | `deep-architect` | Distributed systems & complex architectures | `codex:gpt-5.6-sol` | On | Off |
202
- | `bug-hunter` | Root cause discovery & adversarial edge cases | `codex:gpt-5.6-sol` | On | Off |
203
- | `refactor-cleanup` | Dead-code pruning & standard-library simplicity | `codex:gpt-5.6-sol` | Off | Off |
204
- | `frontend-ui` | High-fidelity responsive web interfaces | `codex:gpt-5.6-sol` | Off | Off |
205
- | `security-audit` | Zero-trust threat analysis & sanitization | `codex:gpt-5.6-sol` | On | On |
206
- | `math-logic` | Deterministic algorithmic proofs & math logic | `codex:gpt-5.6-sol` | On | Off |
207
- | `creative-brainstorm`| Divergent lateral thinking & ideation | `codex:gpt-5.6-sol` | Off | Off |
198
+ | `default` | Balanced multi-model generation | `<provider>:<model>` | Optional | Off |
199
+ | `code-review` | Thorough peer review & vulnerability detection | `<provider>:<model>` | On | On |
200
+ | `fast-audit` | Ultra-fast single-model audit (Fast Mode) | `<provider>:<model>` | Off | Off |
201
+ | `deep-architect` | Distributed systems & complex architectures | `<provider>:<model>` | On | Off |
202
+ | `bug-hunter` | Root cause discovery & adversarial edge cases | `<provider>:<model>` | On | Off |
203
+ | `refactor-cleanup` | Dead-code pruning & standard-library simplicity | `<provider>:<model>` | Off | Off |
204
+ | `frontend-ui` | High-fidelity responsive web interfaces | `<provider>:<model>` | Off | Off |
205
+ | `security-audit` | Zero-trust threat analysis & sanitization | `<provider>:<model>` | On | On |
206
+ | `math-logic` | Deterministic algorithmic proofs & math logic | `<provider>:<model>` | On | Off |
207
+ | `creative-brainstorm`| Divergent lateral thinking & ideation | `<provider>:<model>` | Off | Off |
208
208
 
209
209
  ### Candidate Personas (`role_persona`)
210
210
  Assign archetypal engineering mentalities to individual candidate slots to ensure genuine perspective divergence:
package/README.ru.md CHANGED
@@ -151,8 +151,8 @@ graph TD
151
151
  ### 11. Композитный гибридный синтез (Composite AST/Block Merge)
152
152
  Опция `composite_merge_enabled` активирует алгоритм объединения модулей: судья не просто выбирает одного кандидата, а синтезирует итоговое решение из лучших компонентов всех участников (чистейшая алгоритмическая база от одного, надежные обработчики ошибок от второго, исчерпывающие типы и тесты от третьего).
153
153
 
154
- ### 12. Умный динамический роутинг пресетов и модель JEV
155
- При запуске `/moa` без явного указания флага встроенный классификатор определяет намерение (интерфейсы, безопасность, рефакторинг, баги, архитектура) по эвристическим правилам или с помощью легковесной модели JEV (`smart_routing_model`) и автоматически подбирает оптимальный пресет.
154
+ ### 12. Умный динамический роутинг пресетов и модель роутера
155
+ При запуске `/moa` без явного указания флага встроенный классификатор определяет намерение (интерфейсы, безопасность, рефакторинг, баги, архитектура) по эвристическим правилам или с помощью легковесной модели роутера (`smart_routing_model`) и автоматически подбирает оптимальный пресет.
156
156
 
157
157
  ### 13. Живой счетчик стоимости и потоковая телеметрия
158
158
  По мере завершения каждого кандидата плагин в реальном времени транслирует в чат накопленный расход токенов и расчетную стоимость в USD на базе встроенного каталога тарифов OpenRouter и прямых цен провайдеров.
@@ -195,16 +195,16 @@ dsh plugin --profile web add @goodandready/dsh-moa
195
195
 
196
196
  | Имя пресета | Назначение | Судья по умолчанию | Рецензия (Round 2) | Слепая оценка |
197
197
  | :--- | :--- | :--- | :---: | :---: |
198
- | `default` | Сбалансированная генерация | `codex:gpt-5.6-sol` | Опционально | Выкл |
199
- | `code-review` | Глубокое взаимное ревью кода | `codex:gpt-5.6-sol` | Вкл | Вкл |
200
- | `fast-audit` | Сверхбыстрый экспресс-аудит (Fast Mode) | `codex:gpt-5.6-sol` | Выкл | Выкл |
201
- | `deep-architect` | Проектирование распределенных систем | `codex:gpt-5.6-sol` | Вкл | Выкл |
202
- | `bug-hunter` | Поиск скрытых багов и граничных случаев | `codex:gpt-5.6-sol` | Вкл | Выкл |
203
- | `refactor-cleanup` | Чистка легаси и удаление оверинжиниринга | `codex:gpt-5.6-sol` | Выкл | Выкл |
204
- | `frontend-ui` | Качественные адаптивные веб-интерфейсы | `codex:gpt-5.6-sol` | Выкл | Выкл |
205
- | `security-audit` | Анализ уязвимостей и санитайзинг | `codex:gpt-5.6-sol` | Вкл | Вкл |
206
- | `math-logic` | Алгоритмы, математическая логика и доказательства | `codex:gpt-5.6-sol` | Вкл | Выкл |
207
- | `creative-brainstorm`| Нестандартные идеи и дивергентный поиск | `codex:gpt-5.6-sol` | Выкл | Выкл |
198
+ | `default` | Сбалансированная генерация | `<provider>:<model>` | Опционально | Выкл |
199
+ | `code-review` | Глубокое взаимное ревью кода | `<provider>:<model>` | Вкл | Вкл |
200
+ | `fast-audit` | Сверхбыстрый экспресс-аудит (Fast Mode) | `<provider>:<model>` | Выкл | Выкл |
201
+ | `deep-architect` | Проектирование распределенных систем | `<provider>:<model>` | Вкл | Выкл |
202
+ | `bug-hunter` | Поиск скрытых багов и граничных случаев | `<provider>:<model>` | Вкл | Выкл |
203
+ | `refactor-cleanup` | Чистка легаси и удаление оверинжиниринга | `<provider>:<model>` | Выкл | Выкл |
204
+ | `frontend-ui` | Качественные адаптивные веб-интерфейсы | `<provider>:<model>` | Выкл | Выкл |
205
+ | `security-audit` | Анализ уязвимостей и санитайзинг | `<provider>:<model>` | Вкл | Вкл |
206
+ | `math-logic` | Алгоритмы, математическая логика и доказательства | `<provider>:<model>` | Вкл | Выкл |
207
+ | `creative-brainstorm`| Нестандартные идеи и дивергентный поиск | `<provider>:<model>` | Выкл | Выкл |
208
208
 
209
209
  ### Инженерные персоны кандидатов (`role_persona`)
210
210
  Задавайте кандидатам четкие роли для максимального разнообразия подходов:
@@ -1,4 +1,5 @@
1
1
  import { bestEffort } from './best-effort.js'
2
+ import { logger } from './logger.js'
2
3
  import vm from 'node:vm'
3
4
  import fs from 'node:fs/promises'
4
5
  import path from 'node:path'
@@ -11,7 +12,8 @@ export function isSafeRelativePath(relPath) {
11
12
  const trimmed = relPath.trim()
12
13
  if (!trimmed || path.isAbsolute(trimmed) || trimmed.startsWith('/') || trimmed.startsWith('\\')) return false
13
14
  const normalized = path.normalize(trimmed)
14
- return Boolean(normalized && !normalized.startsWith('..') && !path.isAbsolute(normalized))
15
+ if (!normalized || normalized === '.' || normalized.startsWith('..') || path.isAbsolute(normalized)) return false
16
+ return true
15
17
  }
16
18
 
17
19
  /**
@@ -150,9 +152,9 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
150
152
  const truncated = content.length > budgetRemaining ? content.slice(0, budgetRemaining) + '\n... [truncated]' : content
151
153
  totalChars += truncated.length
152
154
  collected.push({ relativePath: rel, content: truncated, size: stat.size })
153
- } catch {
155
+ } catch (err) {
154
156
  skippedFiles++
155
- skippedList.push(rel)
157
+ skippedList.push(`${rel} (read error: ${err?.message || String(err)})`)
156
158
  }
157
159
  }
158
160
  }
@@ -236,13 +238,28 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
236
238
  }
237
239
 
238
240
  const label = options.checkpointLabel || `moa-pre-promotion: candidate-${candidateTarget}`
239
- const port = options.port || process.env.DSH_PORT || process.env.PORT || 3000
241
+
242
+ // 1. Direct Cordis timeMachineEngine service call if available
243
+ if (options.timeMachineEngine && typeof options.timeMachineEngine.createSnapshot === 'function') {
244
+ const snap = await bestEffort('dsh-time-machine direct service createSnapshot', async () => {
245
+ return await options.timeMachineEngine.createSnapshot(label, { cwd: baseDir })
246
+ })
247
+ if (snap) return snap
248
+ }
249
+
250
+ // 2. HTTP endpoint call if port or timeMachineUrl is provided
251
+ const port = options.port || options.webServerPort || process.env.DSH_PORT || process.env.PORT
252
+ if (!port && !options.timeMachineUrl) {
253
+ logger.warn('[dsh-moa] Cannot create pre-promotion checkpoint: webServerPort is unknown and no timeMachineUrl provided')
254
+ return null
255
+ }
256
+
240
257
  const endpoint = options.timeMachineUrl || `http://127.0.0.1:${port}/dsh-time-machine/create`
241
258
 
242
259
  const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
243
260
  const res = await fetch(endpoint, {
244
261
  method: 'POST',
245
- headers: { 'Content-Type': 'application/json', 'x-dsh-trusted': '1' },
262
+ headers: { 'Content-Type': 'application/json' },
246
263
  body: JSON.stringify({ label, cwd: baseDir }),
247
264
  signal: AbortSignal.timeout(options.timeoutMs ?? 1500),
248
265
  })
@@ -274,6 +291,11 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
274
291
  }
275
292
 
276
293
  const checkpoint = await createPrePromotionCheckpoint(baseDir, winningIndex, options)
294
+ if (options.checkpoint !== false && !checkpoint && !options.force) {
295
+ const errMsg = `[dsh-moa] Pre-promotion checkpoint failed for candidate-${winningIndex} in ${baseDir}. File overwrite blocked to prevent uncheckpointed changes (pass force: true to override).`
296
+ logger.warn(errMsg)
297
+ throw new Error(errMsg)
298
+ }
277
299
 
278
300
  const promoted = []
279
301
  for (const rel of entries) {
@@ -294,7 +316,7 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
294
316
  if (!options.keepMoa) {
295
317
  await bestEffort('clean moaRoot on promote error', () => fs.rm(moaRoot, { recursive: true, force: true }))
296
318
  }
297
- return []
319
+ throw err
298
320
  }
299
321
  }
300
322
 
package/lib/history.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { logger } from './logger.js'
1
2
  /**
2
3
  * MoA Run History & Analytics Module
3
4
  * Records every MoA pipeline execution, tracks model win rates and costs,
@@ -9,8 +10,38 @@ import path from 'node:path'
9
10
  import os from 'node:os'
10
11
  import crypto from 'node:crypto'
11
12
 
12
- const DEFAULT_HISTORY_DIR = path.join(os.homedir(), '.dsh')
13
- const DEFAULT_HISTORY_FILE = path.join(DEFAULT_HISTORY_DIR, 'moa-history.jsonl')
13
+ export function isTestEnvironment() {
14
+ return (
15
+ process.env.NODE_ENV === 'test' ||
16
+ process.env.DSH_TEST === '1' ||
17
+ Boolean(process.env.NODE_TEST_CONTEXT) ||
18
+ Boolean(process.execArgv?.some((arg) => arg.includes('test'))) ||
19
+ Boolean(process.argv?.[1]?.includes('test'))
20
+ )
21
+ }
22
+
23
+ export function getDefaultHistoryDir() {
24
+ if (process.env.DSH_HISTORY_DIR) {
25
+ return process.env.DSH_HISTORY_DIR
26
+ }
27
+ if (process.env.DSH_HOME) {
28
+ return path.join(process.env.DSH_HOME, '.dsh')
29
+ }
30
+ if (isTestEnvironment()) {
31
+ return path.join(os.tmpdir(), '.dsh-test')
32
+ }
33
+ return path.join(os.homedir(), '.dsh')
34
+ }
35
+
36
+ export function getDefaultHistoryFile() {
37
+ if (process.env.DSH_HISTORY_FILE) {
38
+ return process.env.DSH_HISTORY_FILE
39
+ }
40
+ return path.join(getDefaultHistoryDir(), 'moa-history.jsonl')
41
+ }
42
+
43
+ export const DEFAULT_HISTORY_DIR = getDefaultHistoryDir()
44
+ export const DEFAULT_HISTORY_FILE = getDefaultHistoryFile()
14
45
 
15
46
  /** Max history file size before rotation (10 MB) */
16
47
  export const MAX_HISTORY_BYTES = 10 * 1024 * 1024
@@ -22,18 +53,18 @@ export function invalidateHistoryCache() {
22
53
  _historyCache = null
23
54
  }
24
55
 
25
- function getHistoryFilePath(customDir) {
56
+ export function getHistoryFilePath(customDir) {
26
57
  if (customDir) {
27
58
  return path.join(customDir, '.moa-history.jsonl')
28
59
  }
29
- return DEFAULT_HISTORY_FILE
60
+ return getDefaultHistoryFile()
30
61
  }
31
62
 
32
63
  /**
33
64
  * Checks if the history file exceeds the size threshold and rotates it.
34
65
  * Moves current file to `${filePath}.1`, removing any older `.1` backup.
35
66
  */
36
- export function rotateHistoryFileIfNeeded(filePath = DEFAULT_HISTORY_FILE, maxBytes = MAX_HISTORY_BYTES) {
67
+ export function rotateHistoryFileIfNeeded(filePath = getDefaultHistoryFile(), maxBytes = MAX_HISTORY_BYTES) {
37
68
  try {
38
69
  if (!fs.existsSync(filePath)) return false
39
70
  const stat = fs.statSync(filePath)
@@ -47,7 +78,7 @@ export function rotateHistoryFileIfNeeded(filePath = DEFAULT_HISTORY_FILE, maxBy
47
78
  return true
48
79
  }
49
80
  } catch (err) {
50
- console.warn('[dsh-moa] History rotation warning:', err?.message || err)
81
+ logger.warn('[dsh-moa] History rotation warning:', err?.message || err)
51
82
  }
52
83
  return false
53
84
  }
@@ -95,7 +126,7 @@ function normalizeRunRecord(record) {
95
126
  * Appends a completed MoA run record to the history file synchronously (preserves sync API).
96
127
  * Performs automatic size-based rotation.
97
128
  */
98
- export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
129
+ export function recordMoaRun(record, filePath = getDefaultHistoryFile()) {
99
130
  try {
100
131
  const dir = path.dirname(filePath)
101
132
  if (!fs.existsSync(dir)) {
@@ -110,7 +141,7 @@ export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
110
141
  invalidateHistoryCache()
111
142
  return entry
112
143
  } catch (err) {
113
- console.warn('[dsh-moa] Failed to record run to history:', err)
144
+ logger.warn('[dsh-moa] Failed to record run to history:', err)
114
145
  return null
115
146
  }
116
147
  }
@@ -118,7 +149,7 @@ export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
118
149
  /**
119
150
  * Asynchronously appends a completed MoA run record without blocking the event loop.
120
151
  */
121
- export async function recordMoaRunAsync(record, filePath = DEFAULT_HISTORY_FILE) {
152
+ export async function recordMoaRunAsync(record, filePath = getDefaultHistoryFile()) {
122
153
  try {
123
154
  const dir = path.dirname(filePath)
124
155
  await fs.promises.mkdir(dir, { recursive: true }).catch(() => {})
@@ -131,7 +162,7 @@ export async function recordMoaRunAsync(record, filePath = DEFAULT_HISTORY_FILE)
131
162
  invalidateHistoryCache()
132
163
  return entry
133
164
  } catch (err) {
134
- console.warn('[dsh-moa] Failed to async-record run to history:', err)
165
+ logger.warn('[dsh-moa] Failed to async-record run to history:', err)
135
166
  return null
136
167
  }
137
168
  }
@@ -182,7 +213,7 @@ export function readTailLinesSync(filePath, countNeeded = 50) {
182
213
  /**
183
214
  * Reads history runs with pagination (latest first).
184
215
  */
185
- export function getMoaHistory(limit = 20, offset = 0, filePath = DEFAULT_HISTORY_FILE) {
216
+ export function getMoaHistory(limit = 20, offset = 0, filePath = getDefaultHistoryFile()) {
186
217
  try {
187
218
  if (!fs.existsSync(filePath)) {
188
219
  return { total: 0, runs: [] }
@@ -232,7 +263,7 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = DEFAULT_HISTORY
232
263
 
233
264
  return { total, runs }
234
265
  } catch (err) {
235
- console.warn('[dsh-moa] Failed to read history:', err)
266
+ logger.warn('[dsh-moa] Failed to read history:', err)
236
267
  return { total: 0, runs: [] }
237
268
  }
238
269
  }
@@ -241,7 +272,7 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = DEFAULT_HISTORY
241
272
  * Calculates win-rate leaderboard and model performance statistics across history.
242
273
  * Employs in-memory caching keyed by file mtime and size for O(1) repeated queries.
243
274
  */
244
- export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE, options = {}) {
275
+ export function getMoaLeaderboard(filePath = getDefaultHistoryFile(), options = {}) {
245
276
  try {
246
277
  if (!fs.existsSync(filePath)) {
247
278
  return { totalRuns: 0, models: [] }
@@ -335,7 +366,7 @@ export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE, options = {})
335
366
  _historyCache.leaderboards.set(filterKey, result)
336
367
  return result
337
368
  } catch (err) {
338
- console.warn('[dsh-moa] Failed to calculate leaderboard:', err)
369
+ logger.warn('[dsh-moa] Failed to calculate leaderboard:', err)
339
370
  return { totalRuns: 0, models: [] }
340
371
  }
341
372
  }
@@ -343,7 +374,7 @@ export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE, options = {})
343
374
  /**
344
375
  * Retrieves a single history run by ID.
345
376
  */
346
- export function getMoaRunById(runId, filePath = DEFAULT_HISTORY_FILE) {
377
+ export function getMoaRunById(runId, filePath = getDefaultHistoryFile()) {
347
378
  try {
348
379
  if (!fs.existsSync(filePath)) return null
349
380
  const raw = fs.readFileSync(filePath, 'utf8')
@@ -363,7 +394,7 @@ export function getMoaRunById(runId, filePath = DEFAULT_HISTORY_FILE) {
363
394
  /**
364
395
  * Exports history data in JSON or CSV format with optional preset filtering.
365
396
  */
366
- export function exportMoaHistory(filePath = DEFAULT_HISTORY_FILE, options = {}) {
397
+ export function exportMoaHistory(filePath = getDefaultHistoryFile(), options = {}) {
367
398
  try {
368
399
  if (!fs.existsSync(filePath)) {
369
400
  return options?.format === 'csv' ? 'id,timestamp,preset,winnerModel,totalTokens,totalCostUsd,durationMs\n' : '[]'
@@ -405,7 +436,7 @@ export function exportMoaHistory(filePath = DEFAULT_HISTORY_FILE, options = {})
405
436
 
406
437
  return JSON.stringify(records, null, 2)
407
438
  } catch (err) {
408
- console.warn('[dsh-moa] Failed to export history:', err)
439
+ logger.warn('[dsh-moa] Failed to export history:', err)
409
440
  return options?.format === 'csv' ? '' : '[]'
410
441
  }
411
442
  }
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { setLogger, logger } from './logger.js'
1
2
  import { refreshCatalogInBackground } from './pricing.js'
2
3
  /**
3
4
  * DeepSeek Harness Mixture of Agents (MoA) Plugin
@@ -50,8 +51,8 @@ export const PriceRow = z.object({
50
51
  })
51
52
 
52
53
  export const ModelSlotSchema = z.object({
53
- provider: z.string().default('opencode-go'),
54
- model: z.string().default('deepseek-v4-flash'),
54
+ provider: z.string().default(''),
55
+ model: z.string().default(''),
55
56
  role_persona: z.string().default(''),
56
57
  temperature: z.number().default(-1),
57
58
  })
@@ -65,7 +66,7 @@ export const PresetSchema = z.object({
65
66
  quorum_enabled: z.boolean().default(false),
66
67
  grace_period_sec: z.number().default(10),
67
68
  reference_models: z.array(ModelSlotSchema).default([]),
68
- aggregator: ModelSlotSchema.default({ provider: 'codex', model: 'gpt-5.6-sol' }),
69
+ aggregator: ModelSlotSchema.default({ provider: '', model: '' }),
69
70
  aggregator_fallbacks: z.array(ModelSlotSchema).default([]),
70
71
  reference_temperature: z.number().default(0.6),
71
72
  aggregator_temperature: z.number().default(0.4),
@@ -84,7 +85,7 @@ export const PresetSchema = z.object({
84
85
  judge_voting_strategy: z.string().default('majority'),
85
86
  composite_merge_enabled: z.boolean().default(false),
86
87
  smart_routing_enabled: z.boolean().default(false),
87
- smart_routing_model: ModelSlotSchema.default({ provider: 'opencode-go', model: 'jev' }),
88
+ smart_routing_model: ModelSlotSchema.default({ provider: '', model: '' }),
88
89
  budget_guard_enabled: z.boolean().default(false),
89
90
  max_budget_usd: z.number().default(0),
90
91
  budget_action: z.string().default('trim'),
@@ -99,7 +100,7 @@ export const Config = z.object({
99
100
  enabled: z.boolean().default(true).volatile(),
100
101
  default_preset: z.string().default('default').volatile(),
101
102
  smart_routing_enabled: z.boolean().default(false).volatile(),
102
- smart_routing_model: ModelSlotSchema.default({ provider: 'opencode-go', model: 'jev' }).volatile(),
103
+ smart_routing_model: ModelSlotSchema.default({ provider: '', model: '' }).volatile(),
103
104
  prices: z.dict(PriceRow).default({}).volatile(),
104
105
  presets: z.array(PresetSchema).default([
105
106
  {
@@ -107,10 +108,10 @@ export const Config = z.object({
107
108
  enabled: true,
108
109
  ask_clarifying_questions: true,
109
110
  reference_models: [
110
- { provider: 'opencode-go', model: 'deepseek-v4-flash' },
111
- { provider: 'grok', model: 'grok-build-0.1' },
111
+ { provider: '', model: '', role_persona: 'proposer' },
112
+ { provider: '', model: '', role_persona: 'challenger' },
112
113
  ],
113
- aggregator: { provider: 'codex', model: 'gpt-5.6-sol' },
114
+ aggregator: { provider: '', model: '' },
114
115
  reference_temperature: 0.6,
115
116
  aggregator_temperature: 0.4,
116
117
  max_tokens: 4096,
@@ -121,9 +122,9 @@ export const Config = z.object({
121
122
  enabled: true,
122
123
  ask_clarifying_questions: false,
123
124
  reference_models: [
124
- { provider: 'opencode-go', model: 'deepseek-v4-flash' },
125
+ { provider: '', model: '', role_persona: 'fast' },
125
126
  ],
126
- aggregator: { provider: 'opencode-go', model: 'deepseek-v4-flash' },
127
+ aggregator: { provider: '', model: '' },
127
128
  reference_temperature: 0.6,
128
129
  aggregator_temperature: 0.2,
129
130
  max_tokens: 4096,
@@ -134,10 +135,10 @@ export const Config = z.object({
134
135
  enabled: true,
135
136
  ask_clarifying_questions: true,
136
137
  reference_models: [
137
- { provider: 'grok', model: 'grok-build-0.1' },
138
- { provider: 'commandcode', model: 'deepseek/deepseek-v4-flash' },
138
+ { provider: '', model: '', role_persona: 'architect' },
139
+ { provider: '', model: '', role_persona: 'critic' },
139
140
  ],
140
- aggregator: { provider: 'codex', model: 'gpt-5.6-sol' },
141
+ aggregator: { provider: '', model: '' },
141
142
  reference_temperature: 0.6,
142
143
  aggregator_temperature: 0.2,
143
144
  max_tokens: 8192,
@@ -163,6 +164,7 @@ export function plainConfig(value) {
163
164
  }
164
165
 
165
166
  export function apply(ctx, config) {
167
+ if (ctx.logger) setLogger(ctx.logger)
166
168
  refreshCatalogInBackground()
167
169
 
168
170
  let localConfig = plainConfig(config ?? {})
@@ -209,7 +211,7 @@ export function apply(ctx, config) {
209
211
  await settingsService.update(NS, plain)
210
212
  }
211
213
  } catch (err) {
212
- console.warn('[dsh-moa] Settings persistence warning:', err?.message || err)
214
+ logger.warn('[dsh-moa] Settings persistence warning:', err?.message || err)
213
215
  }
214
216
  return localConfig
215
217
  }
@@ -375,8 +377,8 @@ export function apply(ctx, config) {
375
377
  }
376
378
  }
377
379
  const targetPreset = (cfg.presets || []).find((p) => p.name === parsed.presetName) || cfg.presets?.[0]
378
- const aggProvider = targetPreset?.aggregator?.provider || 'codex'
379
- const aggModel = targetPreset?.aggregator?.model || 'gpt-5.6-sol'
380
+ const aggProvider = targetPreset?.aggregator?.provider || ''
381
+ const aggModel = targetPreset?.aggregator?.model || ''
380
382
  const sessCwd = event.cwd || event.session?.cwd || (event.session?.path ? path.dirname(event.session.path) : undefined) || process.cwd()
381
383
 
382
384
  const turnContext = {
@@ -388,6 +390,8 @@ export function apply(ctx, config) {
388
390
  callLlm,
389
391
  prices: cfg.prices || {},
390
392
  liveCanvas,
393
+ webServerPort: ctx.webServer?.port,
394
+ timeMachineEngine: ctx.get?.('timeMachineEngine') || ctx.timeMachineEngine,
391
395
  }
392
396
 
393
397
  if (event.signal) {
package/lib/logger.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Context logger abstraction for dsh-moa.
3
+ * Avoids raw direct console warnings in server-side modules and respects Cordis ctx.logger.
4
+ */
5
+ let currentLogger = null
6
+
7
+ export function setLogger(logger) {
8
+ currentLogger = logger
9
+ }
10
+
11
+ export const logger = {
12
+ warn(...args) {
13
+ if (currentLogger && typeof currentLogger.warn === 'function') {
14
+ currentLogger.warn(...args)
15
+ } else if (typeof console !== 'undefined' && typeof console.debug === 'function') {
16
+ console.debug(...args)
17
+ }
18
+ },
19
+ debug(...args) {
20
+ if (currentLogger && typeof currentLogger.debug === 'function') {
21
+ currentLogger.debug(...args)
22
+ } else if (typeof console !== 'undefined' && typeof console.debug === 'function') {
23
+ console.debug(...args)
24
+ }
25
+ },
26
+ info(...args) {
27
+ if (currentLogger && typeof currentLogger.info === 'function') {
28
+ currentLogger.info(...args)
29
+ } else if (typeof console !== 'undefined' && typeof console.debug === 'function') {
30
+ console.debug(...args)
31
+ }
32
+ },
33
+ error(...args) {
34
+ if (currentLogger && typeof currentLogger.error === 'function') {
35
+ currentLogger.error(...args)
36
+ } else if (typeof console !== 'undefined' && typeof console.debug === 'function') {
37
+ console.debug(...args)
38
+ }
39
+ },
40
+ }
@@ -1,3 +1,4 @@
1
+ import { logger } from './logger.js'
1
2
  /**
2
3
  * DeepSeek Harness Mixture of Agents (MoA) — Candidate Fan-Out & Retry
3
4
  * With Per-Candidate Temperature, Temperature Gradient & Local Fallback Resilience
@@ -200,7 +201,7 @@ export async function runReferencesParallel(references, messages, options = {},
200
201
  }
201
202
  break
202
203
  } catch (fbErr) {
203
- console.warn(`[dsh-moa] Fallback ${fbLabel} also failed:`, fbErr?.message || fbErr)
204
+ logger.warn(`[dsh-moa] Fallback ${fbLabel} also failed:`, fbErr?.message || fbErr)
204
205
  }
205
206
  }
206
207
  }
@@ -208,7 +209,7 @@ export async function runReferencesParallel(references, messages, options = {},
208
209
  if (!fallbackRecovered) {
209
210
  finishedCount++
210
211
  const errMsg = err?.message || String(err)
211
- console.warn(`[dsh-moa] Reference ${label} failed:`, errMsg)
212
+ logger.warn(`[dsh-moa] Reference ${label} failed:`, errMsg)
212
213
  if (typeof onProgress === 'function') {
213
214
  onProgress(`⚠️ *Candidate ${i + 1}/${total} (${label}) error: ${errMsg}*\n`)
214
215
  }
package/lib/moa-router.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Smart Preset Router for Mixture of Agents (MoA).
3
- * Supports heuristic keyword classification and optional JEV/LLM zero-shot routing.
3
+ * Supports heuristic keyword classification and optional LLM zero-shot routing.
4
4
  */
5
5
 
6
6
  const INTENT_RULES = [
@@ -48,7 +48,7 @@ export async function resolvePresetForPrompt({
48
48
  return { presetName: safeDefault, reason: 'default', isAutoRouted: false }
49
49
  }
50
50
 
51
- // 1. If LLM routing model (e.g. JEV) is configured and callable, run fast zero-shot classifier
51
+ // 1. If LLM routing model is configured and callable, run fast zero-shot classifier
52
52
  if (routingModel?.provider && routingModel?.model && typeof callLlm === 'function') {
53
53
  try {
54
54
  const systemPrompt = `You are the MoA Preset Router. Given user prompt, classify which preset is best suited.
package/lib/moa-runner.js CHANGED
@@ -1,9 +1,10 @@
1
+ import { logger } from './logger.js'
1
2
  import path from 'node:path'
2
3
  import crypto from 'node:crypto'
3
4
  import { bestEffort } from './best-effort.js'
4
5
  import { extractFileBlocks, collectProjectContext, formatProjectContext, isRefinementTask, writeCandidateWorkspace, promoteCandidateWorkspace, cleanMoaWorkspaces, verifyFileSyntax } from './file-workspace.js'
5
6
  import { estimateTokenCost, summarizeMoAUsage } from './pricing.js'
6
- import { recordMoaRun, candidatesForHistory } from './history.js'
7
+ import { recordMoaRunAsync, candidatesForHistory, isTestEnvironment } from './history.js'
7
8
  import { createPromotedPreview } from './live-canvas.js'
8
9
  import { slotLabel, cleanAdvisoryMessages, isBroadPromptRequiringQuestions, buildQuestionSynthesisPrompt, buildCuratorSynthesisPrompt, buildSynthesisPrompt, buildPeerCritiquePrompt, ROLE_PERSONA_PROMPTS, SYSTEM_ROLE_PROPOSER, ANTIPATTERNS_RUBRIC } from './moa-prompts.js'
9
10
  import { parseWinnerIndex, parseRecommendedAssembler, parseMoACommand, stripOrSummarizeCode, formatMoAResponse } from './moa-parser.js'
@@ -40,15 +41,29 @@ export async function runMoAPipeline({
40
41
  testGateEnabled = null,
41
42
  testCommand = null,
42
43
  execFn = null,
44
+ webServerPort = null,
45
+ timeMachineEngine = null,
46
+ timeMachineUrl = null,
47
+ force = isTestEnvironment() ? true : false,
48
+ checkpoint = true,
49
+ checkpointFn = null,
43
50
  }) {
51
+ const promoteOpts = {
52
+ webServerPort,
53
+ timeMachineEngine,
54
+ timeMachineUrl,
55
+ force: Boolean(force),
56
+ checkpoint,
57
+ checkpointFn,
58
+ }
44
59
  if (signal?.aborted) return { error: new Error('Turn aborted') }
45
60
  const startTime = Date.now()
46
61
 
47
62
  // 1. Resolve configurations
48
63
  const referenceModels = Array.isArray(preset?.reference_models) && preset.reference_models.length > 0
49
- ? preset.reference_models : [{ provider: 'opencode-go', model: 'deepseek-v4-flash' }]
64
+ ? preset.reference_models : [{ provider: '', model: '' }]
50
65
  const primaryJudge = preset?.aggregator?.provider && preset?.aggregator?.model
51
- ? preset.aggregator : { provider: 'codex', model: 'gpt-5.6-sol' }
66
+ ? preset.aggregator : { provider: '', model: '' }
52
67
 
53
68
  const fallbackJudges = Array.isArray(preset?.aggregator_fallbacks) ? preset.aggregator_fallbacks : []
54
69
  const judgesChain = [primaryJudge, ...fallbackJudges]
@@ -227,7 +242,7 @@ export async function runMoAPipeline({
227
242
  }
228
243
  qUsage = estimateTokenCost(primaryJudge, qFallbackUsage, prices)
229
244
  } catch (err) {
230
- console.warn('[dsh-moa] Questionnaire synthesis failed, proceeding with fallback questions:', err)
245
+ logger.warn('[dsh-moa] Questionnaire synthesis failed, proceeding with fallback questions:', err)
231
246
  questionsContent = `### Clarification of Requirements: "${userPrompt}"\n\n1. **Architecture & Scope**: Single-file deliverable or multi-module project structure?\n2. **Design & Style**: Minimalist, dark mode, or clean neutral theme?\n3. **Functional Priorities**: Core MVP or comprehensive extended implementation?\n\n*Reply with your preferences (e.g. "1, 2") or proceed with defaults.*`
232
247
  }
233
248
 
@@ -237,7 +252,7 @@ export async function runMoAPipeline({
237
252
  const durationMs = Date.now() - startTime
238
253
 
239
254
  try {
240
- recordMoaRun({
255
+ await recordMoaRunAsync({
241
256
  prompt: userPrompt,
242
257
  preset: preset?.name || 'default',
243
258
  isRefinement,
@@ -251,7 +266,7 @@ export async function runMoAPipeline({
251
266
  durationMs,
252
267
  }, historyFilePath || undefined)
253
268
  } catch (histErr) {
254
- console.warn('[dsh-moa] Failed to record questionnaire run in history:', histErr?.message || histErr)
269
+ logger.warn('[dsh-moa] Failed to record questionnaire run in history:', histErr?.message || histErr)
255
270
  }
256
271
 
257
272
  return {
@@ -282,7 +297,7 @@ export async function runMoAPipeline({
282
297
  const single = successfulRefs[0]
283
298
  let promotedFiles = []
284
299
  if (single.files?.length > 0) {
285
- promotedFiles = await promoteCandidateWorkspace(cwd, single.index)
300
+ promotedFiles = await promoteCandidateWorkspace(cwd, single.index, promoteOpts)
286
301
  } else {
287
302
  await cleanMoaWorkspaces(cwd)
288
303
  }
@@ -293,7 +308,7 @@ export async function runMoAPipeline({
293
308
  const durationMs = Date.now() - startTime
294
309
 
295
310
  try {
296
- recordMoaRun({
311
+ await recordMoaRunAsync({
297
312
  prompt: userPrompt,
298
313
  preset: preset?.name || 'default',
299
314
  isRefinement,
@@ -308,7 +323,7 @@ export async function runMoAPipeline({
308
323
  durationMs,
309
324
  }, historyFilePath || undefined)
310
325
  } catch (histErr) {
311
- console.warn('[dsh-moa] Failed to record fast synthesis run in history:', histErr?.message || histErr)
326
+ logger.warn('[dsh-moa] Failed to record fast synthesis run in history:', histErr?.message || histErr)
312
327
  }
313
328
 
314
329
  return {
@@ -376,7 +391,7 @@ export async function runMoAPipeline({
376
391
  cand.costUsd = Number(((cand.costUsd || 0) + extraCost.costUsd).toFixed(5))
377
392
  }
378
393
  } catch (r2CostErr) {
379
- console.warn('[dsh-moa] Failed to estimate Round 2 cost:', r2CostErr?.message || r2CostErr)
394
+ logger.warn('[dsh-moa] Failed to estimate Round 2 cost:', r2CostErr?.message || r2CostErr)
380
395
  }
381
396
  })
382
397
  await Promise.allSettled(r2Promises)
@@ -460,7 +475,7 @@ export async function runMoAPipeline({
460
475
  break
461
476
  } catch (err) {
462
477
  lastJudgeError = err
463
- console.warn(`[dsh-moa] Judge ${currentLabel} failed:`, err)
478
+ logger.warn(`[dsh-moa] Judge ${currentLabel} failed:`, err)
464
479
  if (typeof onProgress === 'function') {
465
480
  const nextJudge = judgesChain[jIdx + 1]
466
481
  const nextHint = nextJudge ? ` Trying fallback ${slotLabel(nextJudge)}...` : ''
@@ -491,12 +506,12 @@ export async function runMoAPipeline({
491
506
  const keepWorkspaces = allowCandidateOverride
492
507
  if (synthesizedFiles.length > 0) {
493
508
  await writeCandidateWorkspace(cwd, 'curator-synthesis', synthesizedFiles)
494
- promotedFiles = await promoteCandidateWorkspace(cwd, 'curator-synthesis', { keepMoa: keepWorkspaces })
509
+ promotedFiles = await promoteCandidateWorkspace(cwd, 'curator-synthesis', { ...promoteOpts, keepMoa: keepWorkspaces })
495
510
  } else if (referenceOutputs[winningIndex - 1]?.files?.length > 0) {
496
- promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex, { keepMoa: keepWorkspaces })
511
+ promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex, { ...promoteOpts, keepMoa: keepWorkspaces })
497
512
  } else if (successfulRefs[0]?.files?.length > 0) {
498
513
  const fallbackIdx = successfulRefs[0].index
499
- promotedFiles = await promoteCandidateWorkspace(cwd, fallbackIdx, { keepMoa: keepWorkspaces })
514
+ promotedFiles = await promoteCandidateWorkspace(cwd, fallbackIdx, { ...promoteOpts, keepMoa: keepWorkspaces })
500
515
  } else if (!keepWorkspaces) {
501
516
  await cleanMoaWorkspaces(cwd)
502
517
  }
@@ -529,7 +544,7 @@ export async function runMoAPipeline({
529
544
  }
530
545
 
531
546
  try {
532
- recordMoaRun({
547
+ await recordMoaRunAsync({
533
548
  id: runId,
534
549
  prompt: userPrompt,
535
550
  preset: preset?.name || 'default',
@@ -546,7 +561,7 @@ export async function runMoAPipeline({
546
561
  durationMs,
547
562
  }, historyFilePath || undefined)
548
563
  } catch (histErr) {
549
- console.warn('[dsh-moa] Failed to record run in history:', histErr)
564
+ logger.warn('[dsh-moa] Failed to record run in history:', histErr)
550
565
  }
551
566
 
552
567
  return {
package/lib/moa-stream.js CHANGED
@@ -9,7 +9,7 @@ import { runMoAPipeline } from './moa-runner.js'
9
9
  /**
10
10
  * Streams a full MoA turn into chat with live aggregator tokens and progress feedback.
11
11
  */
12
- export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd, prices, historyFilePath, liveCanvas }, options = {}) {
12
+ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd, prices, historyFilePath, liveCanvas, webServerPort, timeMachineEngine, force }, options = {}) {
13
13
  const signal = options?.signal
14
14
  if (signal?.aborted) return
15
15
 
@@ -40,6 +40,9 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
40
40
  prices,
41
41
  historyFilePath,
42
42
  liveCanvas,
43
+ webServerPort,
44
+ timeMachineEngine,
45
+ force,
43
46
  signal,
44
47
  })
45
48
  .catch((err) => ({ error: err }))
@@ -4,6 +4,7 @@ import fsSync from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { spawn } from 'node:child_process'
6
6
  import { assertPathContained } from './file-workspace.js'
7
+ import { bestEffort } from './best-effort.js'
7
8
 
8
9
  /**
9
10
  * Resolves the directory for candidate sandbox.
@@ -179,9 +180,9 @@ export async function runCandidateTestGate({
179
180
  }
180
181
  } finally {
181
182
  if (stageDir) {
182
- try {
183
- await fs.rm(stageDir, { recursive: true, force: true })
184
- } catch {}
183
+ await bestEffort('moa-test-gate cleanup stageDir', () =>
184
+ fs.rm(stageDir, { recursive: true, force: true })
185
+ )
185
186
  }
186
187
  }
187
188
  }
package/lib/pricing.js CHANGED
@@ -11,7 +11,21 @@ import { homedir } from 'node:os'
11
11
  import { dirname, join } from 'node:path'
12
12
 
13
13
  export const CATALOG_URL = 'https://openrouter.ai/api/v1/models'
14
- export const CACHE_FILE = join(homedir(), '.dsh', 'storages', 'dsh-moa-catalog.json')
14
+
15
+ export function getDefaultCacheFile() {
16
+ if (process.env.DSH_CATALOG_FILE) {
17
+ return process.env.DSH_CATALOG_FILE
18
+ }
19
+ if (process.env.DSH_STORAGE_DIR) {
20
+ return join(process.env.DSH_STORAGE_DIR, 'dsh-moa-catalog.json')
21
+ }
22
+ const baseDir = process.env.DSH_HOME
23
+ ? join(process.env.DSH_HOME, '.dsh')
24
+ : join(homedir(), '.dsh')
25
+ return join(baseDir, 'storages', 'dsh-moa-catalog.json')
26
+ }
27
+
28
+ export const CACHE_FILE = getDefaultCacheFile()
15
29
  export const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000 // 24 hours
16
30
 
17
31
  /**
@@ -43,7 +57,7 @@ export function resetMemoryCatalog() {
43
57
  /**
44
58
  * Load cached catalog from disk into memory.
45
59
  */
46
- export function loadCachedCatalog(cachePath = CACHE_FILE) {
60
+ export function loadCachedCatalog(cachePath = getDefaultCacheFile()) {
47
61
  if (memoryCatalog && memoryCachePath === cachePath) return memoryCatalog
48
62
  try {
49
63
  if (existsSync(cachePath)) {
@@ -69,7 +83,7 @@ export function loadCachedCatalog(cachePath = CACHE_FILE) {
69
83
  */
70
84
  export async function fetchCatalog({
71
85
  url = CATALOG_URL,
72
- cachePath = CACHE_FILE,
86
+ cachePath = getDefaultCacheFile(),
73
87
  signal = null,
74
88
  } = {}) {
75
89
  try {
@@ -118,7 +132,7 @@ export async function fetchCatalog({
118
132
  /**
119
133
  * Background refresh catalog if stale (> 24 hours).
120
134
  */
121
- export function refreshCatalogInBackground(cachePath = CACHE_FILE) {
135
+ export function refreshCatalogInBackground(cachePath = getDefaultCacheFile()) {
122
136
  const catalog = loadCachedCatalog(cachePath)
123
137
  const isStale = !lastFetchedAt || Date.now() - lastFetchedAt > REFRESH_INTERVAL_MS
124
138
  if (isStale) {
@@ -134,16 +148,20 @@ export function refreshCatalogInBackground(cachePath = CACHE_FILE) {
134
148
  /**
135
149
  * Resolve price rates (USD per 1M tokens) for a given provider/model slot.
136
150
  */
137
- export function resolveModelRates(slot = {}, customPrices = {}, cachePath = CACHE_FILE) {
151
+ export function resolveModelRates(slot = {}, customPrices = {}, cachePath = getDefaultCacheFile()) {
138
152
  const provider = (slot?.provider || '').trim().toLowerCase()
139
153
  const model = (slot?.model || '').trim().toLowerCase()
140
154
  const fullKey = provider ? `${provider}/${model}` : model
155
+ const colonKey = provider ? `${provider}:${model}` : model
141
156
 
142
157
  // 1. Check custom user overrides in config
143
158
  if (customPrices && typeof customPrices === 'object') {
144
159
  if (customPrices[fullKey]) return normalizeRates(customPrices[fullKey])
160
+ if (customPrices[colonKey]) return normalizeRates(customPrices[colonKey])
145
161
  if (customPrices[model]) return normalizeRates(customPrices[model])
146
- if (provider && customPrices[`${provider}/*`]) return normalizeRates(customPrices[`${provider}/*`])
162
+ if (provider && (customPrices[`${provider}/*`] || customPrices[`${provider}:*`])) {
163
+ return normalizeRates(customPrices[`${provider}/*`] || customPrices[`${provider}:*`])
164
+ }
147
165
  if (customPrices['*']) return normalizeRates(customPrices['*'])
148
166
  }
149
167
 
@@ -271,7 +289,7 @@ function normalizeRates(rate) {
271
289
  /**
272
290
  * Estimate USD cost and token totals for a single model call.
273
291
  */
274
- export function estimateTokenCost(slot = {}, usage = {}, customPrices = {}, cachePath = CACHE_FILE) {
292
+ export function estimateTokenCost(slot = {}, usage = {}, customPrices = {}, cachePath = getDefaultCacheFile()) {
275
293
  const promptTokens = usage.prompt_tokens ?? usage.inputTokens ?? usage.input ?? usage.promptTokens ?? 0
276
294
  const completionTokens = usage.completion_tokens ?? usage.outputTokens ?? usage.output ?? usage.completionTokens ?? 0
277
295
  const cacheHitTokens = usage.prompt_tokens_details?.cached_tokens ?? usage.cacheHitTokens ?? 0
package/lib/routes.js CHANGED
@@ -46,7 +46,11 @@ ctx.effect(() => {
46
46
  return ctx.webServer.register({
47
47
  kind: 'exact',
48
48
  path: '/dsh-moa/status',
49
- handler: (_req, res) => {
49
+ handler: (req, res) => {
50
+ if (req.method !== 'GET') {
51
+ writeJson(res, 405, { ok: false, error: 'GET required' })
52
+ return
53
+ }
50
54
  try {
51
55
  const cfg = live()
52
56
  writeJson(res, 200, {
@@ -67,7 +71,11 @@ ctx.effect(() => {
67
71
  return ctx.webServer.register({
68
72
  kind: 'exact',
69
73
  path: '/dsh-moa/models',
70
- handler: async (_req, res) => {
74
+ handler: async (req, res) => {
75
+ if (req.method !== 'GET') {
76
+ writeJson(res, 405, { ok: false, error: 'GET required' })
77
+ return
78
+ }
71
79
  if (_modelsCache.models.length > 0 && Date.now() - _modelsCache.timestamp < MODELS_CACHE_TTL) {
72
80
  writeJson(res, 200, { ok: true, models: _modelsCache.models, cached: true })
73
81
  return
@@ -94,7 +102,7 @@ ctx.effect(() => {
94
102
  }
95
103
  }
96
104
  } catch (e) {
97
- console.warn('[dsh-moa] ctx.llm.listAvailableModels error:', e)
105
+ ctx.logger?.warn?.('[dsh-moa] ctx.llm.listAvailableModels error:', e)
98
106
  }
99
107
 
100
108
  try {
@@ -119,7 +127,7 @@ ctx.effect(() => {
119
127
  await Promise.allSettled(queries)
120
128
  }
121
129
  } catch (e) {
122
- console.warn('[dsh-moa] ctx.llm.listConfigurableProviders error:', e)
130
+ ctx.logger?.warn?.('[dsh-moa] ctx.llm.listConfigurableProviders error:', e)
123
131
  }
124
132
  }
125
133
 
@@ -156,7 +164,7 @@ ctx.effect(() => {
156
164
  }
157
165
  }
158
166
  } catch (e) {
159
- console.warn('[dsh-moa] Error reading settings:', e)
167
+ ctx.logger?.warn?.('[dsh-moa] Error reading settings:', e)
160
168
  }
161
169
 
162
170
  _modelsCache = { timestamp: Date.now(), models: result }
@@ -241,8 +249,10 @@ ctx.effect(() => {
241
249
  }
242
250
  try {
243
251
  const u = new URL(req.url, 'http://localhost')
244
- const limit = parseInt(u.searchParams.get('limit') || '20', 10)
245
- const offset = parseInt(u.searchParams.get('offset') || '0', 10)
252
+ const rawLimit = parseInt(u.searchParams.get('limit') || '20', 10)
253
+ const rawOffset = parseInt(u.searchParams.get('offset') || '0', 10)
254
+ const limit = Math.max(1, Math.min(100, Number.isFinite(rawLimit) ? rawLimit : 20))
255
+ const offset = Math.max(0, Number.isFinite(rawOffset) ? rawOffset : 0)
246
256
  const data = getMoaHistory(limit, offset)
247
257
  writeJson(res, 200, { ok: true, ...data })
248
258
  } catch (err) {
@@ -309,7 +319,12 @@ ctx.effect(() => {
309
319
  return
310
320
  }
311
321
  const cwd = body?.cwd || process.cwd()
312
- const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, { keepMoa: true })
322
+ const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, {
323
+ keepMoa: true,
324
+ port: ctx.webServer?.port,
325
+ timeMachineEngine: ctx.get?.('timeMachineEngine') || ctx.timeMachineEngine,
326
+ force: Boolean(body?.force),
327
+ })
313
328
  writeJson(res, 200, { ok: true, candidateIndex, promotedFiles, checkpoint: promotedFiles?.checkpoint || null })
314
329
  } catch (err) {
315
330
  writeJson(res, 500, { ok: false, error: err?.message || String(err) })
@@ -470,18 +485,26 @@ ctx.effect(() => {
470
485
  writeJson(res, 405, { ok: false, error: 'POST required' })
471
486
  return
472
487
  }
488
+ if (!isSafeWriteRequest(req)) {
489
+ writeJson(res, 403, { ok: false, error: 'Forbidden: cross-site request rejected' })
490
+ return
491
+ }
473
492
  try {
493
+ const cfg = live()
494
+ if (cfg.enabled === false) {
495
+ writeJson(res, 400, { ok: false, error: 'MoA is disabled in settings' })
496
+ return
497
+ }
474
498
  const raw = await readBody(req)
475
499
  const body = JSON.parse(raw || '{}')
476
500
  const prompt = body?.prompt || ''
477
- const cfg = live()
478
501
  const result = await resolvePresetForPrompt({
479
502
  prompt,
480
503
  presets: cfg.presets || [],
481
504
  defaultPreset: cfg.default_preset || 'default',
482
505
  routingModel: cfg.smart_routing_model,
483
506
  callLlm,
484
- enabled: true,
507
+ enabled: cfg.enabled !== false,
485
508
  })
486
509
  writeJson(res, 200, { ok: true, ...result })
487
510
  } catch (err) {
@@ -491,6 +514,9 @@ ctx.effect(() => {
491
514
  })
492
515
  }, 'dsh-moa: route preset endpoint')
493
516
 
517
+ let _lastRunTimestamp = 0
518
+ const RUN_MIN_INTERVAL_MS = 1000
519
+
494
520
  // Route: /dsh-moa/run
495
521
  ctx.effect(() => {
496
522
  return ctx.webServer.register({
@@ -505,6 +531,12 @@ ctx.effect(() => {
505
531
  writeJson(res, 403, { ok: false, error: 'Forbidden: cross-site request rejected' })
506
532
  return
507
533
  }
534
+ const now = Date.now()
535
+ if (now - _lastRunTimestamp < RUN_MIN_INTERVAL_MS) {
536
+ writeJson(res, 429, { ok: false, error: 'Rate limit exceeded: please wait before starting another run' })
537
+ return
538
+ }
539
+ _lastRunTimestamp = now
508
540
 
509
541
  try {
510
542
  const raw = await readBody(req)
package/lib/updater.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Implements canonical DSH plugin-updater specification.
3
3
 
4
4
  import { spawn } from 'node:child_process';
5
- import { existsSync, readFileSync } from 'node:fs';
5
+ import { existsSync, readFileSync, statSync, rmSync } from 'node:fs';
6
6
  import { readFile } from 'node:fs/promises';
7
7
  import { homedir } from 'node:os';
8
8
  import { basename, dirname, isAbsolute, resolve } from 'node:path';
@@ -183,8 +183,79 @@ export async function getUpdateStatus(options, target = runtime()) {
183
183
  };
184
184
  }
185
185
 
186
- async function installExact(target, packageSpec, options) {
186
+ export function readLockPid(lockPath) {
187
+ try {
188
+ if (!existsSync(lockPath)) return undefined;
189
+ const stat = statSync(lockPath);
190
+ if (stat.isDirectory()) return undefined;
191
+ const content = readFileSync(lockPath, 'utf8').trim();
192
+ if (!content) return undefined;
193
+ try {
194
+ const parsed = JSON.parse(content);
195
+ if (parsed && typeof parsed.pid === 'number') return parsed.pid;
196
+ } catch (parseErr) { /* ignore json parse error */ }
197
+ const num = parseInt(content, 10);
198
+ if (!isNaN(num) && num > 0) return num;
199
+ const match = /\b(\d{1,8})\b/.exec(content);
200
+ if (match) return Number(match[1]);
201
+ return undefined;
202
+ } catch {
203
+ return undefined;
204
+ }
205
+ }
206
+
207
+ export function isPidAlive(pid) {
208
+ if (typeof pid !== 'number' || isNaN(pid) || pid <= 0) return false;
209
+ try {
210
+ process.kill(pid, 0);
211
+ return true;
212
+ } catch (err) {
213
+ return err?.code === 'EPERM';
214
+ }
215
+ }
216
+
217
+ export function checkAndCleanLock(profileDir, childPid) {
218
+ const lockPath = resolve(profileDir, 'package.json.lock');
219
+ if (!existsSync(lockPath)) return { locked: false };
220
+ const pid = readLockPid(lockPath);
221
+ if (pid !== undefined) {
222
+ if (isPidAlive(pid)) {
223
+ if (childPid !== undefined && pid === childPid) {
224
+ try {
225
+ rmSync(lockPath, { recursive: true, force: true });
226
+ return { locked: false, cleaned: true };
227
+ } catch (rmErr) { return { locked: true, pid }; }
228
+ }
229
+ return { locked: true, pid };
230
+ }
231
+ // PID is dead, remove stale lock
232
+ try {
233
+ rmSync(lockPath, { recursive: true, force: true });
234
+ return { locked: false, cleaned: true };
235
+ } catch (rmErr) { return { locked: true, pid }; }
236
+ }
237
+ // Unknown or empty lockfile format
238
+ if (childPid !== undefined) {
239
+ try {
240
+ rmSync(lockPath, { recursive: true, force: true });
241
+ return { locked: false, cleaned: true };
242
+ } catch (parseErr) { /* ignore json parse error */ }
243
+ }
244
+ return { locked: true };
245
+ }
246
+
247
+ export async function installExact(target, packageSpec, options = {}) {
187
248
  if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
249
+
250
+ const lockStatus = checkAndCleanLock(target.profileDir);
251
+ if (lockStatus.locked) {
252
+ const error = new Error('Another plugin installation is currently in progress.');
253
+ error.status = 409;
254
+ error.statusCode = 409;
255
+ error.code = 'ELOCKED';
256
+ throw error;
257
+ }
258
+
188
259
  await new Promise((resolvePromise, reject) => {
189
260
  // Note: Respect strict ecosystem quarantine policy without age bypass
190
261
  const args = [
@@ -201,13 +272,24 @@ async function installExact(target, packageSpec, options) {
201
272
  let detail = '';
202
273
  child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
203
274
  child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
275
+ let timedOut = false;
276
+ const timeoutMs = options.timeoutMs ?? UPDATE_TIMEOUT_MS;
204
277
  const timer = setTimeout(() => {
205
- child.kill();
278
+ timedOut = true;
279
+ try { child.kill('SIGTERM'); } catch (killErr) { /* best-effort */ }
280
+ setTimeout(() => {
281
+ try { child.kill('SIGKILL'); } catch (killErr) { /* best-effort */ }
282
+ checkAndCleanLock(target.profileDir, child.pid);
283
+ }, 500).unref?.();
206
284
  reject(new Error('Update timed out; use the normal DSH update flow.'));
207
- }, UPDATE_TIMEOUT_MS);
285
+ }, timeoutMs);
208
286
  child.once('error', error => { clearTimeout(timer); reject(error); });
209
- child.once('exit', code => {
287
+ child.once('exit', (code, signal) => {
210
288
  clearTimeout(timer);
289
+ if (timedOut) {
290
+ checkAndCleanLock(target.profileDir, child.pid);
291
+ return;
292
+ }
211
293
  if (code === 0) resolvePromise();
212
294
  else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
213
295
  });
@@ -253,6 +335,11 @@ export function registerPluginUpdater(ctx, options) {
253
335
  json(response, 409, { error: 'This plugin is already updating.' });
254
336
  return;
255
337
  }
338
+ const lockStatus = checkAndCleanLock(target.profileDir);
339
+ if (lockStatus.locked) {
340
+ json(response, 409, { error: 'Another plugin installation is currently in progress.' });
341
+ return;
342
+ }
256
343
  installing = true;
257
344
  try {
258
345
  const before = await getUpdateStatus(options, target);
@@ -274,6 +361,10 @@ export function registerPluginUpdater(ctx, options) {
274
361
  installing = false;
275
362
  }
276
363
  } catch (error) {
364
+ if (error?.status === 409 || error?.statusCode === 409) {
365
+ json(response, 409, { error: error.message || 'Another plugin installation is currently in progress.' });
366
+ return;
367
+ }
277
368
  ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`);
278
369
  json(response, 503, { error: 'Plugin update failed; see server logs.' });
279
370
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.27",
3
+ "version": "0.2.30",
4
4
  "description": "Mixture of Agents (MoA) plugin for DeepSeek Harness with /moa slash command",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -19,7 +19,7 @@
19
19
  "LICENSE"
20
20
  ],
21
21
  "scripts": {
22
- "test": "node --test test/*.test.mjs",
22
+ "test": "node --import ./test/_setup.mjs --test test/*.test.mjs",
23
23
  "build:client": "node scripts/build-client.mjs"
24
24
  },
25
25
  "keywords": [
@@ -27,8 +27,7 @@
27
27
  "dsh-plugin",
28
28
  "moa",
29
29
  "mixture-of-agents",
30
- "slash-command",
31
- "hermes-agent"
30
+ "slash-command"
32
31
  ],
33
32
  "repository": {
34
33
  "type": "git",