@goodandready/dsh-moa 0.2.29 → 0.2.31

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'
@@ -237,13 +238,28 @@ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, opt
237
238
  }
238
239
 
239
240
  const label = options.checkpointLabel || `moa-pre-promotion: candidate-${candidateTarget}`
240
- 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
+
241
257
  const endpoint = options.timeMachineUrl || `http://127.0.0.1:${port}/dsh-time-machine/create`
242
258
 
243
259
  const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
244
260
  const res = await fetch(endpoint, {
245
261
  method: 'POST',
246
- headers: { 'Content-Type': 'application/json', 'x-dsh-trusted': '1' },
262
+ headers: { 'Content-Type': 'application/json' },
247
263
  body: JSON.stringify({ label, cwd: baseDir }),
248
264
  signal: AbortSignal.timeout(options.timeoutMs ?? 1500),
249
265
  })
@@ -275,6 +291,11 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
275
291
  }
276
292
 
277
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
+ }
278
299
 
279
300
  const promoted = []
280
301
  for (const rel of entries) {
package/lib/history.js CHANGED
@@ -10,8 +10,38 @@ import path from 'node:path'
10
10
  import os from 'node:os'
11
11
  import crypto from 'node:crypto'
12
12
 
13
- const DEFAULT_HISTORY_DIR = path.join(os.homedir(), '.dsh')
14
- 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()
15
45
 
16
46
  /** Max history file size before rotation (10 MB) */
17
47
  export const MAX_HISTORY_BYTES = 10 * 1024 * 1024
@@ -23,18 +53,18 @@ export function invalidateHistoryCache() {
23
53
  _historyCache = null
24
54
  }
25
55
 
26
- function getHistoryFilePath(customDir) {
56
+ export function getHistoryFilePath(customDir) {
27
57
  if (customDir) {
28
58
  return path.join(customDir, '.moa-history.jsonl')
29
59
  }
30
- return DEFAULT_HISTORY_FILE
60
+ return getDefaultHistoryFile()
31
61
  }
32
62
 
33
63
  /**
34
64
  * Checks if the history file exceeds the size threshold and rotates it.
35
65
  * Moves current file to `${filePath}.1`, removing any older `.1` backup.
36
66
  */
37
- export function rotateHistoryFileIfNeeded(filePath = DEFAULT_HISTORY_FILE, maxBytes = MAX_HISTORY_BYTES) {
67
+ export function rotateHistoryFileIfNeeded(filePath = getDefaultHistoryFile(), maxBytes = MAX_HISTORY_BYTES) {
38
68
  try {
39
69
  if (!fs.existsSync(filePath)) return false
40
70
  const stat = fs.statSync(filePath)
@@ -96,7 +126,7 @@ function normalizeRunRecord(record) {
96
126
  * Appends a completed MoA run record to the history file synchronously (preserves sync API).
97
127
  * Performs automatic size-based rotation.
98
128
  */
99
- export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
129
+ export function recordMoaRun(record, filePath = getDefaultHistoryFile()) {
100
130
  try {
101
131
  const dir = path.dirname(filePath)
102
132
  if (!fs.existsSync(dir)) {
@@ -119,7 +149,7 @@ export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
119
149
  /**
120
150
  * Asynchronously appends a completed MoA run record without blocking the event loop.
121
151
  */
122
- export async function recordMoaRunAsync(record, filePath = DEFAULT_HISTORY_FILE) {
152
+ export async function recordMoaRunAsync(record, filePath = getDefaultHistoryFile()) {
123
153
  try {
124
154
  const dir = path.dirname(filePath)
125
155
  await fs.promises.mkdir(dir, { recursive: true }).catch(() => {})
@@ -183,7 +213,7 @@ export function readTailLinesSync(filePath, countNeeded = 50) {
183
213
  /**
184
214
  * Reads history runs with pagination (latest first).
185
215
  */
186
- export function getMoaHistory(limit = 20, offset = 0, filePath = DEFAULT_HISTORY_FILE) {
216
+ export function getMoaHistory(limit = 20, offset = 0, filePath = getDefaultHistoryFile()) {
187
217
  try {
188
218
  if (!fs.existsSync(filePath)) {
189
219
  return { total: 0, runs: [] }
@@ -242,7 +272,7 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = DEFAULT_HISTORY
242
272
  * Calculates win-rate leaderboard and model performance statistics across history.
243
273
  * Employs in-memory caching keyed by file mtime and size for O(1) repeated queries.
244
274
  */
245
- export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE, options = {}) {
275
+ export function getMoaLeaderboard(filePath = getDefaultHistoryFile(), options = {}) {
246
276
  try {
247
277
  if (!fs.existsSync(filePath)) {
248
278
  return { totalRuns: 0, models: [] }
@@ -344,7 +374,7 @@ export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE, options = {})
344
374
  /**
345
375
  * Retrieves a single history run by ID.
346
376
  */
347
- export function getMoaRunById(runId, filePath = DEFAULT_HISTORY_FILE) {
377
+ export function getMoaRunById(runId, filePath = getDefaultHistoryFile()) {
348
378
  try {
349
379
  if (!fs.existsSync(filePath)) return null
350
380
  const raw = fs.readFileSync(filePath, 'utf8')
@@ -364,7 +394,7 @@ export function getMoaRunById(runId, filePath = DEFAULT_HISTORY_FILE) {
364
394
  /**
365
395
  * Exports history data in JSON or CSV format with optional preset filtering.
366
396
  */
367
- export function exportMoaHistory(filePath = DEFAULT_HISTORY_FILE, options = {}) {
397
+ export function exportMoaHistory(filePath = getDefaultHistoryFile(), options = {}) {
368
398
  try {
369
399
  if (!fs.existsSync(filePath)) {
370
400
  return options?.format === 'csv' ? 'id,timestamp,preset,winnerModel,totalTokens,totalCostUsd,durationMs\n' : '[]'
package/lib/index.js CHANGED
@@ -51,8 +51,8 @@ export const PriceRow = z.object({
51
51
  })
52
52
 
53
53
  export const ModelSlotSchema = z.object({
54
- provider: z.string().default('opencode-go'),
55
- model: z.string().default('deepseek-v4-flash'),
54
+ provider: z.string().default(''),
55
+ model: z.string().default(''),
56
56
  role_persona: z.string().default(''),
57
57
  temperature: z.number().default(-1),
58
58
  })
@@ -66,7 +66,7 @@ export const PresetSchema = z.object({
66
66
  quorum_enabled: z.boolean().default(false),
67
67
  grace_period_sec: z.number().default(10),
68
68
  reference_models: z.array(ModelSlotSchema).default([]),
69
- aggregator: ModelSlotSchema.default({ provider: 'codex', model: 'gpt-5.6-sol' }),
69
+ aggregator: ModelSlotSchema.default({ provider: '', model: '' }),
70
70
  aggregator_fallbacks: z.array(ModelSlotSchema).default([]),
71
71
  reference_temperature: z.number().default(0.6),
72
72
  aggregator_temperature: z.number().default(0.4),
@@ -85,7 +85,7 @@ export const PresetSchema = z.object({
85
85
  judge_voting_strategy: z.string().default('majority'),
86
86
  composite_merge_enabled: z.boolean().default(false),
87
87
  smart_routing_enabled: z.boolean().default(false),
88
- smart_routing_model: ModelSlotSchema.default({ provider: 'opencode-go', model: 'jev' }),
88
+ smart_routing_model: ModelSlotSchema.default({ provider: '', model: '' }),
89
89
  budget_guard_enabled: z.boolean().default(false),
90
90
  max_budget_usd: z.number().default(0),
91
91
  budget_action: z.string().default('trim'),
@@ -100,7 +100,7 @@ export const Config = z.object({
100
100
  enabled: z.boolean().default(true).volatile(),
101
101
  default_preset: z.string().default('default').volatile(),
102
102
  smart_routing_enabled: z.boolean().default(false).volatile(),
103
- smart_routing_model: ModelSlotSchema.default({ provider: 'opencode-go', model: 'jev' }).volatile(),
103
+ smart_routing_model: ModelSlotSchema.default({ provider: '', model: '' }).volatile(),
104
104
  prices: z.dict(PriceRow).default({}).volatile(),
105
105
  presets: z.array(PresetSchema).default([
106
106
  {
@@ -108,10 +108,10 @@ export const Config = z.object({
108
108
  enabled: true,
109
109
  ask_clarifying_questions: true,
110
110
  reference_models: [
111
- { provider: 'opencode-go', model: 'deepseek-v4-flash' },
112
- { provider: 'grok', model: 'grok-build-0.1' },
111
+ { provider: '', model: '', role_persona: 'proposer' },
112
+ { provider: '', model: '', role_persona: 'challenger' },
113
113
  ],
114
- aggregator: { provider: 'codex', model: 'gpt-5.6-sol' },
114
+ aggregator: { provider: '', model: '' },
115
115
  reference_temperature: 0.6,
116
116
  aggregator_temperature: 0.4,
117
117
  max_tokens: 4096,
@@ -122,9 +122,9 @@ export const Config = z.object({
122
122
  enabled: true,
123
123
  ask_clarifying_questions: false,
124
124
  reference_models: [
125
- { provider: 'opencode-go', model: 'deepseek-v4-flash' },
125
+ { provider: '', model: '', role_persona: 'fast' },
126
126
  ],
127
- aggregator: { provider: 'opencode-go', model: 'deepseek-v4-flash' },
127
+ aggregator: { provider: '', model: '' },
128
128
  reference_temperature: 0.6,
129
129
  aggregator_temperature: 0.2,
130
130
  max_tokens: 4096,
@@ -135,10 +135,10 @@ export const Config = z.object({
135
135
  enabled: true,
136
136
  ask_clarifying_questions: true,
137
137
  reference_models: [
138
- { provider: 'grok', model: 'grok-build-0.1' },
139
- { provider: 'commandcode', model: 'deepseek/deepseek-v4-flash' },
138
+ { provider: '', model: '', role_persona: 'architect' },
139
+ { provider: '', model: '', role_persona: 'critic' },
140
140
  ],
141
- aggregator: { provider: 'codex', model: 'gpt-5.6-sol' },
141
+ aggregator: { provider: '', model: '' },
142
142
  reference_temperature: 0.6,
143
143
  aggregator_temperature: 0.2,
144
144
  max_tokens: 8192,
@@ -377,8 +377,8 @@ export function apply(ctx, config) {
377
377
  }
378
378
  }
379
379
  const targetPreset = (cfg.presets || []).find((p) => p.name === parsed.presetName) || cfg.presets?.[0]
380
- const aggProvider = targetPreset?.aggregator?.provider || 'codex'
381
- const aggModel = targetPreset?.aggregator?.model || 'gpt-5.6-sol'
380
+ const aggProvider = targetPreset?.aggregator?.provider || ''
381
+ const aggModel = targetPreset?.aggregator?.model || ''
382
382
  const sessCwd = event.cwd || event.session?.cwd || (event.session?.path ? path.dirname(event.session.path) : undefined) || process.cwd()
383
383
 
384
384
  const turnContext = {
@@ -390,6 +390,8 @@ export function apply(ctx, config) {
390
390
  callLlm,
391
391
  prices: cfg.prices || {},
392
392
  liveCanvas,
393
+ webServerPort: ctx.webServer?.port,
394
+ timeMachineEngine: ctx.get?.('timeMachineEngine') || ctx.timeMachineEngine,
393
395
  }
394
396
 
395
397
  if (event.signal) {
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
@@ -4,7 +4,7 @@ import crypto from 'node:crypto'
4
4
  import { bestEffort } from './best-effort.js'
5
5
  import { extractFileBlocks, collectProjectContext, formatProjectContext, isRefinementTask, writeCandidateWorkspace, promoteCandidateWorkspace, cleanMoaWorkspaces, verifyFileSyntax } from './file-workspace.js'
6
6
  import { estimateTokenCost, summarizeMoAUsage } from './pricing.js'
7
- import { recordMoaRunAsync, candidatesForHistory } from './history.js'
7
+ import { recordMoaRunAsync, candidatesForHistory, isTestEnvironment } from './history.js'
8
8
  import { createPromotedPreview } from './live-canvas.js'
9
9
  import { slotLabel, cleanAdvisoryMessages, isBroadPromptRequiringQuestions, buildQuestionSynthesisPrompt, buildCuratorSynthesisPrompt, buildSynthesisPrompt, buildPeerCritiquePrompt, ROLE_PERSONA_PROMPTS, SYSTEM_ROLE_PROPOSER, ANTIPATTERNS_RUBRIC } from './moa-prompts.js'
10
10
  import { parseWinnerIndex, parseRecommendedAssembler, parseMoACommand, stripOrSummarizeCode, formatMoAResponse } from './moa-parser.js'
@@ -41,15 +41,29 @@ export async function runMoAPipeline({
41
41
  testGateEnabled = null,
42
42
  testCommand = null,
43
43
  execFn = null,
44
+ webServerPort = null,
45
+ timeMachineEngine = null,
46
+ timeMachineUrl = null,
47
+ force = isTestEnvironment() ? true : false,
48
+ checkpoint = true,
49
+ checkpointFn = null,
44
50
  }) {
51
+ const promoteOpts = {
52
+ webServerPort,
53
+ timeMachineEngine,
54
+ timeMachineUrl,
55
+ force: Boolean(force),
56
+ checkpoint,
57
+ checkpointFn,
58
+ }
45
59
  if (signal?.aborted) return { error: new Error('Turn aborted') }
46
60
  const startTime = Date.now()
47
61
 
48
62
  // 1. Resolve configurations
49
63
  const referenceModels = Array.isArray(preset?.reference_models) && preset.reference_models.length > 0
50
- ? preset.reference_models : [{ provider: 'opencode-go', model: 'deepseek-v4-flash' }]
64
+ ? preset.reference_models : [{ provider: '', model: '' }]
51
65
  const primaryJudge = preset?.aggregator?.provider && preset?.aggregator?.model
52
- ? preset.aggregator : { provider: 'codex', model: 'gpt-5.6-sol' }
66
+ ? preset.aggregator : { provider: '', model: '' }
53
67
 
54
68
  const fallbackJudges = Array.isArray(preset?.aggregator_fallbacks) ? preset.aggregator_fallbacks : []
55
69
  const judgesChain = [primaryJudge, ...fallbackJudges]
@@ -283,7 +297,7 @@ export async function runMoAPipeline({
283
297
  const single = successfulRefs[0]
284
298
  let promotedFiles = []
285
299
  if (single.files?.length > 0) {
286
- promotedFiles = await promoteCandidateWorkspace(cwd, single.index)
300
+ promotedFiles = await promoteCandidateWorkspace(cwd, single.index, promoteOpts)
287
301
  } else {
288
302
  await cleanMoaWorkspaces(cwd)
289
303
  }
@@ -492,12 +506,12 @@ export async function runMoAPipeline({
492
506
  const keepWorkspaces = allowCandidateOverride
493
507
  if (synthesizedFiles.length > 0) {
494
508
  await writeCandidateWorkspace(cwd, 'curator-synthesis', synthesizedFiles)
495
- promotedFiles = await promoteCandidateWorkspace(cwd, 'curator-synthesis', { keepMoa: keepWorkspaces })
509
+ promotedFiles = await promoteCandidateWorkspace(cwd, 'curator-synthesis', { ...promoteOpts, keepMoa: keepWorkspaces })
496
510
  } else if (referenceOutputs[winningIndex - 1]?.files?.length > 0) {
497
- promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex, { keepMoa: keepWorkspaces })
511
+ promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex, { ...promoteOpts, keepMoa: keepWorkspaces })
498
512
  } else if (successfulRefs[0]?.files?.length > 0) {
499
513
  const fallbackIdx = successfulRefs[0].index
500
- promotedFiles = await promoteCandidateWorkspace(cwd, fallbackIdx, { keepMoa: keepWorkspaces })
514
+ promotedFiles = await promoteCandidateWorkspace(cwd, fallbackIdx, { ...promoteOpts, keepMoa: keepWorkspaces })
501
515
  } else if (!keepWorkspaces) {
502
516
  await cleanMoaWorkspaces(cwd)
503
517
  }
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 }))
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
@@ -319,7 +319,12 @@ ctx.effect(() => {
319
319
  return
320
320
  }
321
321
  const cwd = body?.cwd || process.cwd()
322
- 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
+ })
323
328
  writeJson(res, 200, { ok: true, candidateIndex, promotedFiles, checkpoint: promotedFiles?.checkpoint || null })
324
329
  } catch (err) {
325
330
  writeJson(res, 500, { ok: false, error: err?.message || String(err) })
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.29",
3
+ "version": "0.2.31",
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",