lv24-opencode-consilium 0.0.0-stage → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +511 -3
  3. package/bin/cli.js +19 -0
  4. package/bin/mcp.js +24 -0
  5. package/consilium.config.example.json +84 -0
  6. package/dist/agents.d.ts +79 -0
  7. package/dist/agents.d.ts.map +1 -0
  8. package/dist/agents.js +1051 -0
  9. package/dist/agents.js.map +1 -0
  10. package/dist/artifacts.d.ts +75 -0
  11. package/dist/artifacts.d.ts.map +1 -0
  12. package/dist/artifacts.js +464 -0
  13. package/dist/artifacts.js.map +1 -0
  14. package/dist/cli.d.ts +12 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +1392 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/compat.d.ts +160 -0
  19. package/dist/compat.d.ts.map +1 -0
  20. package/dist/compat.js +419 -0
  21. package/dist/compat.js.map +1 -0
  22. package/dist/config.d.ts +112 -0
  23. package/dist/config.d.ts.map +1 -0
  24. package/dist/config.js +728 -0
  25. package/dist/config.js.map +1 -0
  26. package/dist/consensus.d.ts +73 -0
  27. package/dist/consensus.d.ts.map +1 -0
  28. package/dist/consensus.js +273 -0
  29. package/dist/consensus.js.map +1 -0
  30. package/dist/defaults.d.ts +34 -0
  31. package/dist/defaults.d.ts.map +1 -0
  32. package/dist/defaults.js +102 -0
  33. package/dist/defaults.js.map +1 -0
  34. package/dist/doctor.d.ts +27 -0
  35. package/dist/doctor.d.ts.map +1 -0
  36. package/dist/doctor.js +798 -0
  37. package/dist/doctor.js.map +1 -0
  38. package/dist/fallback.d.ts +50 -0
  39. package/dist/fallback.d.ts.map +1 -0
  40. package/dist/fallback.js +134 -0
  41. package/dist/fallback.js.map +1 -0
  42. package/dist/git.d.ts +28 -0
  43. package/dist/git.d.ts.map +1 -0
  44. package/dist/git.js +71 -0
  45. package/dist/git.js.map +1 -0
  46. package/dist/index.d.ts +100 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +653 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/mcp.d.ts +20 -0
  51. package/dist/mcp.d.ts.map +1 -0
  52. package/dist/mcp.js +1215 -0
  53. package/dist/mcp.js.map +1 -0
  54. package/dist/models.d.ts +97 -0
  55. package/dist/models.d.ts.map +1 -0
  56. package/dist/models.js +873 -0
  57. package/dist/models.js.map +1 -0
  58. package/dist/plugin.d.ts +59 -0
  59. package/dist/plugin.d.ts.map +1 -0
  60. package/dist/plugin.js +223 -0
  61. package/dist/plugin.js.map +1 -0
  62. package/dist/presets.d.ts +27 -0
  63. package/dist/presets.d.ts.map +1 -0
  64. package/dist/presets.js +86 -0
  65. package/dist/presets.js.map +1 -0
  66. package/dist/replay.d.ts +37 -0
  67. package/dist/replay.d.ts.map +1 -0
  68. package/dist/replay.js +103 -0
  69. package/dist/replay.js.map +1 -0
  70. package/dist/report.d.ts +28 -0
  71. package/dist/report.d.ts.map +1 -0
  72. package/dist/report.js +300 -0
  73. package/dist/report.js.map +1 -0
  74. package/dist/smart-experts.d.ts +44 -0
  75. package/dist/smart-experts.d.ts.map +1 -0
  76. package/dist/smart-experts.js +242 -0
  77. package/dist/smart-experts.js.map +1 -0
  78. package/dist/stream.d.ts +50 -0
  79. package/dist/stream.d.ts.map +1 -0
  80. package/dist/stream.js +219 -0
  81. package/dist/stream.js.map +1 -0
  82. package/dist/tool-run.d.ts +49 -0
  83. package/dist/tool-run.d.ts.map +1 -0
  84. package/dist/tool-run.js +143 -0
  85. package/dist/tool-run.js.map +1 -0
  86. package/dist/types.d.ts +352 -0
  87. package/dist/types.d.ts.map +1 -0
  88. package/dist/types.js +6 -0
  89. package/dist/types.js.map +1 -0
  90. package/dist/variants.d.ts +30 -0
  91. package/dist/variants.d.ts.map +1 -0
  92. package/dist/variants.js +111 -0
  93. package/dist/variants.js.map +1 -0
  94. package/dist/verify.d.ts +33 -0
  95. package/dist/verify.d.ts.map +1 -0
  96. package/dist/verify.js +80 -0
  97. package/dist/verify.js.map +1 -0
  98. package/dist/watch.d.ts +73 -0
  99. package/dist/watch.d.ts.map +1 -0
  100. package/dist/watch.js +350 -0
  101. package/dist/watch.js.map +1 -0
  102. package/docs/README.ru.md +508 -0
  103. package/docs/README.zh.md +196 -0
  104. package/docs/session-and-plugin.md +642 -0
  105. package/package.json +88 -6
  106. package/schema/config.schema.json +100 -0
package/dist/agents.js ADDED
@@ -0,0 +1,1051 @@
1
+ /**
2
+ * @fileoverview Управление агентами OpenCode
3
+ * @module lv24-opencode-consilium
4
+ */
5
+ import { spawn } from 'child_process';
6
+ import { StringDecoder } from 'string_decoder';
7
+ import { DEFAULT_EXPERT_PROMPTS } from './defaults.js';
8
+ import { createEventDecoder } from './stream.js';
9
+ import { resolveOpenCodeAgent } from './config.js';
10
+ import { analyzeConsensus, buildConsensusPromptSection } from './consensus.js';
11
+ import { buildFallbackChain, shouldFallback } from './fallback.js';
12
+ export { parseJsonOutput } from './stream.js';
13
+ /** Ограничение захвата stdout/stderr (1 МиБ) */
14
+ const MAX_CAPTURE_LENGTH = 1024 * 1024;
15
+ const DEFAULT_TIMEOUT = 300000;
16
+ const DEFAULT_CHAIR_TIMEOUT = 600000;
17
+ const DEFAULT_RETRY_DELAY = 1000;
18
+ const DEFAULT_MAX_CONCURRENCY = 3;
19
+ const DEFAULT_MAX_EXPERT_TEXT = 3000;
20
+ const AVAILABILITY_TIMEOUT = 10000;
21
+ const AGENT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@\/-]*$/;
22
+ const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@\/#+-]*$/;
23
+ const SESSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@\/_-]*$/;
24
+ const MAX_RETRIES = 10;
25
+ const MAX_CONCURRENCY = 8;
26
+ const controllerStates = new WeakMap();
27
+ const killTimers = new WeakMap();
28
+ /** Создаёт независимый контроллер запуска */
29
+ export function createRunController() {
30
+ const state = {
31
+ cancelled: false,
32
+ children: new Set()
33
+ };
34
+ const controller = {
35
+ cancel() {
36
+ state.cancelled = true;
37
+ for (const child of [...state.children]) {
38
+ killProcessTree(child);
39
+ }
40
+ state.children.clear();
41
+ },
42
+ reset() {
43
+ state.cancelled = false;
44
+ },
45
+ isCancelled() {
46
+ return state.cancelled;
47
+ }
48
+ };
49
+ controllerStates.set(controller, state);
50
+ return controller;
51
+ }
52
+ function getControllerState(controller) {
53
+ let state = controllerStates.get(controller);
54
+ if (!state) {
55
+ state = { cancelled: false, children: new Set() };
56
+ controllerStates.set(controller, state);
57
+ }
58
+ return state;
59
+ }
60
+ const defaultController = createRunController();
61
+ /** Отменяет запуск по умолчанию и убивает его процессы */
62
+ export function cancel() {
63
+ defaultController.cancel();
64
+ }
65
+ /** Сбрасывает флаг отмены запуска по умолчанию */
66
+ export function resetCancel() {
67
+ defaultController.reset();
68
+ }
69
+ /** Проверяет, отменён ли запуск по умолчанию */
70
+ export function isCancelled() {
71
+ return defaultController.isCancelled();
72
+ }
73
+ /** Убивает процесс и его дерево */
74
+ function killProcessTree(child) {
75
+ const pid = child.pid;
76
+ if (process.platform === 'win32') {
77
+ if (pid === undefined) {
78
+ try {
79
+ child.kill();
80
+ }
81
+ catch {
82
+ // Игнорируем
83
+ }
84
+ return;
85
+ }
86
+ try {
87
+ const killer = spawn('taskkill', ['/pid', String(pid), '/T', '/F'], {
88
+ stdio: 'ignore',
89
+ windowsHide: true,
90
+ detached: true
91
+ });
92
+ killer.on('error', () => {
93
+ // taskkill недоступен — пробуем убить хотя бы сам процесс
94
+ try {
95
+ child.kill();
96
+ }
97
+ catch {
98
+ // Игнорируем
99
+ }
100
+ });
101
+ killer.on('close', code => {
102
+ if (code !== 0) {
103
+ try {
104
+ child.kill();
105
+ }
106
+ catch {
107
+ // Игнорируем
108
+ }
109
+ }
110
+ });
111
+ killer.unref();
112
+ }
113
+ catch {
114
+ try {
115
+ child.kill();
116
+ }
117
+ catch {
118
+ // Игнорируем
119
+ }
120
+ }
121
+ return;
122
+ }
123
+ const signalGroup = (signal) => {
124
+ try {
125
+ if (pid !== undefined) {
126
+ process.kill(-pid, signal);
127
+ return;
128
+ }
129
+ }
130
+ catch {
131
+ // Группа недоступна — убиваем сам процесс
132
+ }
133
+ try {
134
+ child.kill(signal);
135
+ }
136
+ catch {
137
+ // Игнорируем
138
+ }
139
+ };
140
+ signalGroup('SIGTERM');
141
+ if (killTimers.has(child))
142
+ return;
143
+ const killerTimer = setTimeout(() => {
144
+ killTimers.delete(child);
145
+ signalGroup('SIGKILL');
146
+ }, 2000);
147
+ killerTimer.unref();
148
+ killTimers.set(child, killerTimer);
149
+ }
150
+ function toFiniteNumber(value) {
151
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
152
+ }
153
+ function positiveInteger(value) {
154
+ const numeric = toFiniteNumber(value);
155
+ if (numeric === undefined || numeric <= 0 || !Number.isInteger(numeric)) {
156
+ return undefined;
157
+ }
158
+ return numeric;
159
+ }
160
+ function nonNegativeInteger(value) {
161
+ const numeric = toFiniteNumber(value);
162
+ if (numeric === undefined || numeric < 0 || !Number.isInteger(numeric)) {
163
+ return undefined;
164
+ }
165
+ return numeric;
166
+ }
167
+ /** Нормализует таймаут: конечное положительное число (мс) либо fallback */
168
+ export function sanitizeTimeout(value, fallback) {
169
+ const toTimeout = (candidate) => {
170
+ const numeric = toFiniteNumber(candidate);
171
+ if (numeric === undefined || numeric <= 0)
172
+ return undefined;
173
+ return Math.max(1, Math.round(numeric));
174
+ };
175
+ return toTimeout(value) ?? toTimeout(fallback) ?? DEFAULT_TIMEOUT;
176
+ }
177
+ /**
178
+ * Строит идентификатор сессии OpenCode для конкретного агента.
179
+ * Эксперты не должны делить одну сессию: к контексту добавляется имя агента.
180
+ */
181
+ export function buildAgentSession(base, agentName) {
182
+ const trimmed = base?.trim();
183
+ if (trimmed === undefined || trimmed.length === 0)
184
+ return undefined;
185
+ // OpenCode требует, чтобы session id начинался с "ses".
186
+ const prefixed = trimmed.startsWith('ses') ? trimmed : `ses_${trimmed}`;
187
+ const suffix = `-${agentName.trim().length > 0 ? agentName.trim() : 'default'}`;
188
+ const cleaned = `${prefixed}${suffix}`
189
+ .replace(/[^A-Za-z0-9._:-]+/g, '-')
190
+ .replace(/-+/g, '-')
191
+ .replace(/^[-_.]+|[-_.]+$/g, '');
192
+ // Обрезаем базу, а не суффикс: иначе разные агенты получат одинаковый id.
193
+ const maxBase = Math.max(8, 128 - suffix.length);
194
+ return cleaned.length <= 128
195
+ ? cleaned
196
+ : `${cleaned.slice(0, Math.min(maxBase, cleaned.length - suffix.length))}${suffix}`;
197
+ }
198
+ /**
199
+ * Сессия председателя: базовый id используется как есть, чтобы продолжать
200
+ * существующую сессию OpenCode, а не создавать новую.
201
+ */
202
+ export function buildChairSession(base) {
203
+ const trimmed = base?.trim();
204
+ if (trimmed === undefined || trimmed.length === 0)
205
+ return undefined;
206
+ const prefixed = trimmed.startsWith('ses') ? trimmed : `ses_${trimmed}`;
207
+ const cleaned = prefixed
208
+ .replace(/[^A-Za-z0-9._:-]+/g, '-')
209
+ .replace(/-+/g, '-')
210
+ .replace(/^[-_.]+|[-_.]+$/g, '');
211
+ return cleaned.length > 0 ? cleaned.slice(0, 128) : undefined;
212
+ }
213
+ function sanitizeRetries(value, fallback) {
214
+ const retries = nonNegativeInteger(value) ?? nonNegativeInteger(fallback) ?? 0;
215
+ return Math.min(MAX_RETRIES, retries);
216
+ }
217
+ function calculateRetryDelay(attempt, baseDelay, exponentialBackoff, jitter) {
218
+ const base = exponentialBackoff ? baseDelay * Math.pow(2, attempt) : baseDelay;
219
+ if (!jitter)
220
+ return base;
221
+ // Полный джиттер 50–100%: параллельные эксперты не упираются в лимит одновременно.
222
+ return Math.max(0, Math.round(base * (0.5 + Math.random() * 0.5)));
223
+ }
224
+ /** Максимальная задержка rate limit, которую имеет смысл ждать внутри запуска */
225
+ const RATE_LIMIT_RETRY_WINDOW_MS = 30_000;
226
+ /** Проверяет, имеет ли смысл повторять попытку */
227
+ function isRetryable(code, retryAfterMs) {
228
+ if (code === undefined)
229
+ return true;
230
+ if (code === 'RATE_LIMITED') {
231
+ // Короткий лимит (секунды) пережидаем; длинный — сразу отдаём ошибку.
232
+ return (retryAfterMs !== undefined &&
233
+ retryAfterMs > 0 &&
234
+ retryAfterMs <= RATE_LIMIT_RETRY_WINDOW_MS);
235
+ }
236
+ return (code !== 'CANCELLED' &&
237
+ code !== 'MODEL_UNAVAILABLE' &&
238
+ code !== 'PARSE_ERROR' &&
239
+ code !== 'AGENT_NOT_FOUND' &&
240
+ code !== 'COST_LIMIT');
241
+ }
242
+ function errorToMessage(error) {
243
+ return error instanceof Error ? error.message : String(error);
244
+ }
245
+ function logWarn(config, message) {
246
+ try {
247
+ config.hooks?.onLog?.('warn', message);
248
+ }
249
+ catch {
250
+ // Хук логов не должен ломать запуск
251
+ }
252
+ }
253
+ async function invokeHook(config, hook, name) {
254
+ try {
255
+ await hook();
256
+ }
257
+ catch (error) {
258
+ logWarn(config, `Ошибка хука ${name}: ${errorToMessage(error)}`);
259
+ }
260
+ }
261
+ async function invokeErrorHook(config, isChair, result) {
262
+ const hook = isChair ? config.hooks?.onChairError : config.hooks?.onExpertError;
263
+ if (!hook)
264
+ return;
265
+ try {
266
+ await hook(result);
267
+ }
268
+ catch (error) {
269
+ logWarn(config, `Ошибка хука ${isChair ? 'onChairError' : 'onExpertError'}: ${errorToMessage(error)}`);
270
+ }
271
+ }
272
+ /**
273
+ * Формирует аргументы `opencode run`.
274
+ * Невалидные agent/model отбрасываются.
275
+ */
276
+ export function buildRunArgs(opts) {
277
+ const args = ['run'];
278
+ if (opts.config?.standalone !== false) {
279
+ args.push('--standalone');
280
+ }
281
+ args.push('--format', 'json');
282
+ if (opts.config?.autoApprove) {
283
+ args.push('--auto');
284
+ }
285
+ const agent = opts.agent?.trim();
286
+ if (agent !== undefined && agent.length > 0 && AGENT_NAME_PATTERN.test(agent)) {
287
+ args.push(`--agent=${agent}`);
288
+ }
289
+ const model = opts.model?.trim();
290
+ if (model !== undefined && model.length > 0 && MODEL_PATTERN.test(model)) {
291
+ args.push(`--model=${model}`);
292
+ }
293
+ const session = opts.session?.trim();
294
+ if (session !== undefined && session.length > 0 && SESSION_PATTERN.test(session)) {
295
+ args.push(`--session=${session}`);
296
+ }
297
+ return args;
298
+ }
299
+ function buildExpertPrompt(expert, task) {
300
+ if (typeof expert.prompt === 'function') {
301
+ return expert.prompt(task);
302
+ }
303
+ if (typeof expert.prompt === 'string' && expert.prompt.trim().length > 0) {
304
+ return expert.prompt.replace(/\$\{task\}/g, task);
305
+ }
306
+ const fallback = Object.prototype.hasOwnProperty.call(DEFAULT_EXPERT_PROMPTS, expert.name)
307
+ ? DEFAULT_EXPERT_PROMPTS[expert.name]
308
+ : DEFAULT_EXPERT_PROMPTS.default;
309
+ if (typeof fallback === 'function') {
310
+ return fallback(task);
311
+ }
312
+ if (typeof fallback === 'string') {
313
+ return fallback.replace(/\$\{task\}/g, task);
314
+ }
315
+ return task;
316
+ }
317
+ function buildChairPrompt(task, expertResults, maxExpertTextLength, consensus = false) {
318
+ const successful = expertResults.filter(result => result.success);
319
+ let prompt = `Синтезируй мнения экспертов и составь итоговый план. Не вызывай инструменты — ответь только текстом.\n\n`;
320
+ prompt += `## Задача\n${task}\n\n`;
321
+ prompt += `## Мнения экспертов\n`;
322
+ for (const result of successful) {
323
+ const cleanText = result.text.substring(0, maxExpertTextLength);
324
+ prompt += `### ${result.agent.toUpperCase()}\n${cleanText}\n\n`;
325
+ }
326
+ const failedCount = expertResults.length - successful.length;
327
+ if (failedCount > 0) {
328
+ prompt += `<!-- Не ответили экспертов: ${failedCount} -->\n\n`;
329
+ }
330
+ prompt += `## Требования к ответу\n`;
331
+ prompt += `1. **Резюме** (1-2 предложения)\n`;
332
+ prompt += `2. **Топ-3 приоритетных действия** с обоснованием\n`;
333
+ prompt += `3. **Главный риск** и митигация\n`;
334
+ prompt += `4. **Оценка трудозатрат** (если применимо)`;
335
+ if (consensus === true) {
336
+ const report = analyzeConsensus(expertResults.map(result => ({
337
+ agent: result.agent,
338
+ text: result.text,
339
+ model: result.model,
340
+ success: result.success
341
+ })));
342
+ prompt += `\n\n${buildConsensusPromptSection(report)}`;
343
+ }
344
+ return prompt;
345
+ }
346
+ function quoteCmdArg(arg) {
347
+ return `"${arg.replace(/"/g, '\\"')}"`;
348
+ }
349
+ /** Готовит команду запуска (на Windows — через cmd.exe для .cmd-шимов) */
350
+ function buildSpawnSpec(opencodePath, args) {
351
+ if (process.platform !== 'win32') {
352
+ return { command: opencodePath, args };
353
+ }
354
+ const comspec = process.env.ComSpec || 'cmd.exe';
355
+ const safePath = opencodePath.replace(/"/g, '');
356
+ const commandLine = `"${safePath}" ` + args.map(quoteCmdArg).join(' ');
357
+ return {
358
+ command: comspec,
359
+ args: ['/d', '/s', '/c', `"${commandLine}"`],
360
+ windowsVerbatimArguments: true
361
+ };
362
+ }
363
+ function resolveOpenCodePath(config) {
364
+ const configured = config?.opencodePath?.trim();
365
+ return configured !== undefined && configured.length > 0 ? configured : 'opencode';
366
+ }
367
+ function resolveCwd(config) {
368
+ const configured = config?.cwd?.trim();
369
+ return configured !== undefined && configured.length > 0 ? configured : process.cwd();
370
+ }
371
+ function appendCapped(current, chunk) {
372
+ if (current.length >= MAX_CAPTURE_LENGTH)
373
+ return current;
374
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
375
+ return (current + text).slice(0, MAX_CAPTURE_LENGTH);
376
+ }
377
+ function tailText(value, maxLength) {
378
+ const trimmed = value.trim();
379
+ if (trimmed.length <= maxLength)
380
+ return trimmed;
381
+ return trimmed.slice(trimmed.length - maxLength);
382
+ }
383
+ function formatExitCode(code, signal) {
384
+ if (code !== null)
385
+ return `exit code ${code}`;
386
+ if (signal)
387
+ return `процесс завершён сигналом ${signal}`;
388
+ return 'exit code неизвестен';
389
+ }
390
+ function classifyError(message) {
391
+ if (/Agent not found|agent[-_. ]not[-_. ]found/i.test(message))
392
+ return 'AGENT_NOT_FOUND';
393
+ if (isModelUnavailableMessage(message))
394
+ return 'MODEL_UNAVAILABLE';
395
+ return 'EXIT_ERROR';
396
+ }
397
+ /** Типы ошибок OpenCode, означающие исчерпание квоты или rate limit */
398
+ const RATE_LIMIT_PATTERN = /(free[-_.]?limit|rate[-_.]?limit|quota|too[-_.]?many[-_.]?requests|resource[-_.]?exhausted)/i;
399
+ /** Проверяет, что ошибка потока — rate limit или исчерпание квоты */
400
+ function isRateLimitError(detail) {
401
+ if (detail.status === 429)
402
+ return true;
403
+ return RATE_LIMIT_PATTERN.test(`${detail.type ?? ''} ${detail.code ?? ''}`);
404
+ }
405
+ /** Проверяет текст ошибки на недоступную модель */
406
+ function isModelUnavailableMessage(message) {
407
+ return /Model unavailable|no-route|model[-_. ]not[-_. ]found|not found in catalog/i.test(message);
408
+ }
409
+ /** Определяет код ошибки по структурированным событиям потока */
410
+ function classifyStreamErrors(parsed) {
411
+ for (const detail of parsed.errorDetails) {
412
+ if (isRateLimitError(detail))
413
+ return 'RATE_LIMITED';
414
+ if (isModelUnavailableMessage(detail.message))
415
+ return 'MODEL_UNAVAILABLE';
416
+ }
417
+ return 'EXIT_ERROR';
418
+ }
419
+ /** Форматирует длительность ожидания для сообщения пользователю */
420
+ function formatRetryAfter(ms) {
421
+ if (ms >= 3_600_000)
422
+ return `${Math.round(ms / 3_600_000)} ч`;
423
+ if (ms >= 60_000)
424
+ return `${Math.round(ms / 60_000)} мин`;
425
+ return `${Math.max(1, Math.round(ms / 1000))} с`;
426
+ }
427
+ /** Добавляет к сообщению подсказку о времени повтора */
428
+ function withRetryHint(message, detail) {
429
+ if (detail?.retryAfterMs === undefined || detail.retryAfterMs <= 0)
430
+ return message;
431
+ return `${message} (повтор возможен через ~${formatRetryAfter(detail.retryAfterMs)})`;
432
+ }
433
+ /** Запускает opencode, пишет промпт в stdin и собирает вывод */
434
+ function executeOpenCode(options) {
435
+ return new Promise(resolve => {
436
+ if (options.controller?.isCancelled()) {
437
+ resolve({
438
+ code: null,
439
+ signal: null,
440
+ stdout: '',
441
+ stderr: '',
442
+ timedOut: false,
443
+ cancelled: true
444
+ });
445
+ return;
446
+ }
447
+ let child;
448
+ try {
449
+ const spec = buildSpawnSpec(options.opencodePath, options.args);
450
+ child = spawn(spec.command, spec.args, {
451
+ cwd: options.cwd,
452
+ stdio: ['pipe', 'pipe', 'pipe'],
453
+ windowsHide: true,
454
+ detached: process.platform !== 'win32',
455
+ windowsVerbatimArguments: spec.windowsVerbatimArguments
456
+ });
457
+ }
458
+ catch (error) {
459
+ resolve({
460
+ code: null,
461
+ signal: null,
462
+ stdout: '',
463
+ stderr: '',
464
+ spawnError: error instanceof Error ? error : new Error(String(error)),
465
+ timedOut: false,
466
+ cancelled: false
467
+ });
468
+ return;
469
+ }
470
+ if (!child) {
471
+ resolve({
472
+ code: null,
473
+ signal: null,
474
+ stdout: '',
475
+ stderr: '',
476
+ spawnError: new Error('Не удалось запустить opencode'),
477
+ timedOut: false,
478
+ cancelled: false
479
+ });
480
+ return;
481
+ }
482
+ const proc = child;
483
+ const state = options.controller ? getControllerState(options.controller) : undefined;
484
+ state?.children.add(proc);
485
+ let stdout = '';
486
+ let stderr = '';
487
+ let settled = false;
488
+ let timedOut = false;
489
+ let timer;
490
+ const cleanup = () => {
491
+ if (state)
492
+ state.children.delete(proc);
493
+ if (timer !== undefined) {
494
+ clearTimeout(timer);
495
+ timer = undefined;
496
+ }
497
+ const killTimer = killTimers.get(proc);
498
+ if (killTimer !== undefined) {
499
+ clearTimeout(killTimer);
500
+ killTimers.delete(proc);
501
+ }
502
+ };
503
+ const settle = (outcome) => {
504
+ if (settled)
505
+ return;
506
+ settled = true;
507
+ cleanup();
508
+ resolve(outcome);
509
+ };
510
+ if (options.timeoutMs > 0) {
511
+ timer = setTimeout(() => {
512
+ timedOut = true;
513
+ killProcessTree(proc);
514
+ settle({
515
+ code: null,
516
+ signal: null,
517
+ stdout,
518
+ stderr,
519
+ timedOut: true,
520
+ cancelled: false
521
+ });
522
+ }, options.timeoutMs);
523
+ }
524
+ const stdoutDecoder = new StringDecoder('utf8');
525
+ const stderrDecoder = new StringDecoder('utf8');
526
+ proc.stdout?.on('data', (chunk) => {
527
+ const text = typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk);
528
+ stdout = appendCapped(stdout, text);
529
+ });
530
+ proc.stderr?.on('data', (chunk) => {
531
+ const text = typeof chunk === 'string' ? chunk : stderrDecoder.write(chunk);
532
+ stderr = appendCapped(stderr, text);
533
+ });
534
+ const stdin = proc.stdin;
535
+ if (stdin) {
536
+ stdin.on('error', () => {
537
+ // Обрыв stdin (EPIPE): дочерний процесс уже не получит промпт
538
+ if (!settled) {
539
+ killProcessTree(proc);
540
+ }
541
+ });
542
+ try {
543
+ if (options.input !== undefined) {
544
+ stdin.write(options.input, 'utf8');
545
+ }
546
+ stdin.end();
547
+ }
548
+ catch (error) {
549
+ killProcessTree(proc);
550
+ try {
551
+ stdin.end();
552
+ }
553
+ catch {
554
+ // stdin уже закрыт
555
+ }
556
+ settle({
557
+ code: null,
558
+ signal: null,
559
+ stdout,
560
+ stderr,
561
+ spawnError: error instanceof Error ? error : new Error(String(error)),
562
+ timedOut: false,
563
+ cancelled: false
564
+ });
565
+ return;
566
+ }
567
+ }
568
+ proc.on('error', (error) => {
569
+ settle({
570
+ code: null,
571
+ signal: null,
572
+ stdout,
573
+ stderr,
574
+ spawnError: error,
575
+ timedOut: false,
576
+ cancelled: false
577
+ });
578
+ });
579
+ proc.on('close', (code, signal) => {
580
+ stdout = appendCapped(stdout, stdoutDecoder.end());
581
+ stderr = appendCapped(stderr, stderrDecoder.end());
582
+ settle({
583
+ code,
584
+ signal,
585
+ stdout,
586
+ stderr,
587
+ timedOut,
588
+ cancelled: false
589
+ });
590
+ });
591
+ });
592
+ }
593
+ /** Один запуск процесса opencode для агента */
594
+ async function runAgentProcess(options) {
595
+ const extraArgs = options.config.opencodeArgs ?? [];
596
+ const args = buildRunArgs({
597
+ agent: options.agentArg,
598
+ model: options.model,
599
+ session: options.session,
600
+ config: options.config
601
+ }).concat(extraArgs);
602
+ const outcome = await executeOpenCode({
603
+ opencodePath: resolveOpenCodePath(options.config),
604
+ args,
605
+ cwd: resolveCwd(options.config),
606
+ timeoutMs: options.timeoutMs,
607
+ input: options.prompt,
608
+ controller: options.controller
609
+ });
610
+ const decoder = createEventDecoder();
611
+ decoder.push(outcome.stdout);
612
+ const parsed = decoder.end();
613
+ const text = parsed.text;
614
+ const stderrTail = tailText(outcome.stderr, 500);
615
+ const streamCode = classifyStreamErrors(parsed);
616
+ const rateLimit = parsed.errorDetails.find(isRateLimitError);
617
+ const base = {
618
+ agent: options.agentName,
619
+ text,
620
+ success: false,
621
+ attempt: options.attempt,
622
+ exitCode: outcome.code,
623
+ usage: parsed.usage
624
+ };
625
+ if (options.model !== undefined) {
626
+ base.model = options.model;
627
+ }
628
+ if (rateLimit?.retryAfterMs !== undefined) {
629
+ base.retryAfterMs = rateLimit.retryAfterMs;
630
+ }
631
+ if (parsed.sessionID !== undefined) {
632
+ base.sessionId = parsed.sessionID;
633
+ }
634
+ if (outcome.cancelled) {
635
+ return {
636
+ ...base,
637
+ errorCode: 'CANCELLED',
638
+ errorMessage: 'Запуск отменён',
639
+ exitCode: null
640
+ };
641
+ }
642
+ if (outcome.spawnError) {
643
+ return {
644
+ ...base,
645
+ errorCode: 'SPAWN_ERROR',
646
+ errorMessage: outcome.spawnError.message,
647
+ exitCode: null
648
+ };
649
+ }
650
+ if (outcome.timedOut) {
651
+ return {
652
+ ...base,
653
+ errorCode: 'TIMEOUT',
654
+ errorMessage: `Таймаут ${options.timeoutMs} мс`,
655
+ exitCode: null
656
+ };
657
+ }
658
+ if (outcome.code === 0 && text.trim().length > 0) {
659
+ return { ...base, success: true };
660
+ }
661
+ if (options.controller.isCancelled()) {
662
+ return {
663
+ ...base,
664
+ errorCode: 'CANCELLED',
665
+ errorMessage: 'Запуск отменён'
666
+ };
667
+ }
668
+ if (outcome.code !== 0) {
669
+ const message = parsed.errors.join('; ') ||
670
+ stderrTail ||
671
+ formatExitCode(outcome.code, outcome.signal);
672
+ return {
673
+ ...base,
674
+ errorCode: streamCode !== 'EXIT_ERROR' ? streamCode : classifyError(message),
675
+ errorMessage: withRetryHint(message, rateLimit)
676
+ };
677
+ }
678
+ const message = parsed.errors.join('; ') ||
679
+ stderrTail ||
680
+ `Пустой ответ агента${parsed.eventCount > 0 ? ` (событий: ${parsed.eventCount})` : ''}`;
681
+ return {
682
+ ...base,
683
+ errorCode: streamCode !== 'EXIT_ERROR' ? streamCode : 'EMPTY_RESPONSE',
684
+ errorMessage: withRetryHint(message, rateLimit)
685
+ };
686
+ }
687
+ /** Суммирует usage двух вызовов (токены и стоимость) */
688
+ function sumUsagePair(a, b) {
689
+ if (a === undefined && b === undefined)
690
+ return undefined;
691
+ const usage = {};
692
+ const fields = [
693
+ 'inputTokens',
694
+ 'outputTokens',
695
+ 'reasoningTokens',
696
+ 'cacheReadTokens',
697
+ 'cacheWriteTokens'
698
+ ];
699
+ for (const field of fields) {
700
+ const total = (a?.[field] ?? 0) + (b?.[field] ?? 0);
701
+ if (total > 0)
702
+ usage[field] = total;
703
+ }
704
+ usage.cost = (a?.cost ?? 0) + (b?.cost ?? 0);
705
+ return usage;
706
+ }
707
+ /** Суммирует usage двух попыток (токены и стоимость) */
708
+ function mergeAttemptUsage(previous, next) {
709
+ const usage = sumUsagePair(previous.usage, next.usage);
710
+ return usage === undefined ? next : { ...next, usage };
711
+ }
712
+ /** Одна попытка вызова агента: fallback по моделям и при отсутствии агента */
713
+ async function callAgentOnce(options) {
714
+ const agentName = options.agentName.trim();
715
+ if (agentName.length > 0 && !AGENT_NAME_PATTERN.test(agentName)) {
716
+ return {
717
+ agent: options.agentName,
718
+ text: '',
719
+ success: false,
720
+ errorCode: 'PARSE_ERROR',
721
+ errorMessage: `Недопустимое имя агента: "${options.agentName}"`,
722
+ attempt: options.attempt,
723
+ exitCode: null
724
+ };
725
+ }
726
+ const requestedModel = options.model?.trim();
727
+ let model = requestedModel !== undefined && requestedModel.length > 0
728
+ ? requestedModel
729
+ : undefined;
730
+ // OpenCode V2 не применяет модель агента из конфигурации при `run --agent`.
731
+ // Заодно проверяем существование агента: для неизвестного имени сразу идём
732
+ // default-агентом, иначе OpenCode создаст сессию с несуществующим агентом.
733
+ let agentExists = agentName.length === 0;
734
+ if (agentName.length > 0) {
735
+ try {
736
+ const resolved = resolveOpenCodeAgent(agentName, {
737
+ opencodeConfigPath: options.config.opencodeConfigPath,
738
+ cwd: options.config.cwd
739
+ });
740
+ agentExists = resolved.exists;
741
+ if (model === undefined &&
742
+ resolved.model !== undefined &&
743
+ MODEL_PATTERN.test(resolved.model)) {
744
+ model = resolved.model;
745
+ }
746
+ }
747
+ catch {
748
+ // Конфигурация OpenCode недоступна: пусть модель и агента выбирает сам OpenCode
749
+ agentExists = true;
750
+ }
751
+ }
752
+ const agentArg = agentExists && agentName.length > 0 ? agentName : undefined;
753
+ const sessionSuffix = agentName.length === 0 ? 'default' : agentExists ? agentName : `default-${agentName}`;
754
+ const session = options.isChair === true
755
+ ? buildChairSession(options.sessionBase)
756
+ : buildAgentSession(options.sessionBase, sessionSuffix);
757
+ if (model !== undefined && !MODEL_PATTERN.test(model)) {
758
+ return {
759
+ agent: options.agentName,
760
+ text: '',
761
+ success: false,
762
+ errorCode: 'PARSE_ERROR',
763
+ errorMessage: `Недопустимая модель: "${model}"`,
764
+ attempt: options.attempt,
765
+ exitCode: null
766
+ };
767
+ }
768
+ let effectiveAgentArg = agentArg;
769
+ let result = await runAgentProcess({
770
+ agentName: options.agentName,
771
+ agentArg: effectiveAgentArg,
772
+ model,
773
+ session,
774
+ prompt: options.prompt,
775
+ timeoutMs: options.timeoutMs,
776
+ attempt: options.attempt,
777
+ controller: options.controller,
778
+ config: options.config
779
+ });
780
+ if (result.errorCode === 'AGENT_NOT_FOUND') {
781
+ // Агент не найден: повторяем без --agent и с default-сессией.
782
+ // Старую сессию не переиспользуем — OpenCode запомнил в ней несуществующего агента.
783
+ const fallback = await runAgentProcess({
784
+ agentName: options.agentName,
785
+ agentArg: undefined,
786
+ model,
787
+ session: buildAgentSession(options.sessionBase, agentName.length > 0 ? `default-${agentName}` : 'default'),
788
+ prompt: options.prompt,
789
+ timeoutMs: options.timeoutMs,
790
+ attempt: options.attempt,
791
+ controller: options.controller,
792
+ config: options.config
793
+ });
794
+ if (model !== undefined)
795
+ fallback.model = model;
796
+ result = mergeAttemptUsage(result, fallback);
797
+ effectiveAgentArg = undefined;
798
+ }
799
+ // Запасные модели: включаются только для RATE_LIMITED/MODEL_UNAVAILABLE/EMPTY_RESPONSE.
800
+ const chain = buildFallbackChain(model, options.fallbackModels ?? [], { max: 2 });
801
+ for (const candidate of chain) {
802
+ if (result.success || !shouldFallback(result.errorCode))
803
+ break;
804
+ if (options.controller.isCancelled())
805
+ break;
806
+ const attempt = await runAgentProcess({
807
+ agentName: options.agentName,
808
+ agentArg: effectiveAgentArg,
809
+ model: candidate,
810
+ session,
811
+ prompt: options.prompt,
812
+ timeoutMs: options.timeoutMs,
813
+ attempt: options.attempt,
814
+ controller: options.controller,
815
+ config: options.config
816
+ });
817
+ attempt.model = candidate;
818
+ result = mergeAttemptUsage(result, attempt);
819
+ if (attempt.success)
820
+ break;
821
+ }
822
+ return result;
823
+ }
824
+ function finalizeResult(result, startedAt, totalCost, hasCost, mergedUsage) {
825
+ const usage = { ...(mergedUsage ?? result.usage ?? {}) };
826
+ if (hasCost) {
827
+ usage.cost = totalCost;
828
+ }
829
+ return {
830
+ ...result,
831
+ duration: Date.now() - startedAt,
832
+ usage
833
+ };
834
+ }
835
+ function cancelledResult(agentName, attempt, model) {
836
+ const result = {
837
+ agent: agentName,
838
+ text: '',
839
+ success: false,
840
+ errorCode: 'CANCELLED',
841
+ errorMessage: 'Запуск отменён',
842
+ attempt,
843
+ exitCode: null
844
+ };
845
+ if (model !== undefined) {
846
+ result.model = model;
847
+ }
848
+ return result;
849
+ }
850
+ /** Ожидание с проверкой отмены каждые 100 мс; false — отменено */
851
+ async function delayWithCancel(delayMs, controller) {
852
+ let remaining = delayMs;
853
+ while (remaining > 0) {
854
+ if (controller.isCancelled())
855
+ return false;
856
+ const chunk = Math.min(100, remaining);
857
+ await new Promise(resolve => setTimeout(resolve, chunk));
858
+ remaining -= chunk;
859
+ }
860
+ return !controller.isCancelled();
861
+ }
862
+ /**
863
+ * Вызывает одного агента с повторами и контролем отмены
864
+ */
865
+ export async function callAgent(agentName, prompt, config, options = {}) {
866
+ const controller = options.controller ?? defaultController;
867
+ const isChair = options.isChair === true;
868
+ const sessionBase = options.session ?? config.session;
869
+ const fallbackModels = options.fallbackModels ?? config.fallbackModels ?? [];
870
+ const defaultTimeout = isChair
871
+ ? sanitizeTimeout(config.timeouts?.chair, DEFAULT_CHAIR_TIMEOUT)
872
+ : sanitizeTimeout(config.timeouts?.expert, DEFAULT_TIMEOUT);
873
+ const timeoutMs = sanitizeTimeout(options.timeout, defaultTimeout);
874
+ const defaultRetries = isChair
875
+ ? nonNegativeInteger(config.retry?.chairRetries) ?? 1
876
+ : nonNegativeInteger(config.retry?.expertRetries) ?? 1;
877
+ const retries = sanitizeRetries(options.retries, defaultRetries);
878
+ const delayMs = nonNegativeInteger(config.retry?.delay) ?? DEFAULT_RETRY_DELAY;
879
+ const exponential = config.retry?.exponentialBackoff === true;
880
+ const maxAttempts = retries + 1;
881
+ const startedAt = Date.now();
882
+ let totalCost = 0;
883
+ let hasCost = false;
884
+ let lastResult;
885
+ let mergedUsage;
886
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
887
+ if (controller.isCancelled()) {
888
+ return finalizeResult(cancelledResult(agentName, attempt, options.model), startedAt, totalCost, hasCost, mergedUsage);
889
+ }
890
+ if (attempt > 0) {
891
+ const rateLimitDelay = lastResult?.errorCode === 'RATE_LIMITED' ? lastResult.retryAfterMs : undefined;
892
+ const delay = rateLimitDelay !== undefined
893
+ ? Math.min(rateLimitDelay, RATE_LIMIT_RETRY_WINDOW_MS)
894
+ : calculateRetryDelay(attempt - 1, delayMs, exponential, config.retry?.jitter === true);
895
+ const ready = await delayWithCancel(delay, controller);
896
+ if (!ready) {
897
+ return finalizeResult(cancelledResult(agentName, attempt, options.model), startedAt, totalCost, hasCost, mergedUsage);
898
+ }
899
+ }
900
+ const result = await callAgentOnce({
901
+ agentName,
902
+ prompt,
903
+ model: options.model,
904
+ sessionBase,
905
+ isChair,
906
+ fallbackModels,
907
+ timeoutMs,
908
+ attempt,
909
+ controller,
910
+ config
911
+ });
912
+ const cost = result.usage?.cost;
913
+ if (typeof cost === 'number' && Number.isFinite(cost)) {
914
+ totalCost += cost;
915
+ hasCost = true;
916
+ }
917
+ lastResult = result;
918
+ mergedUsage = sumUsagePair(mergedUsage, result.usage);
919
+ if (result.success) {
920
+ return finalizeResult(result, startedAt, totalCost, hasCost, mergedUsage);
921
+ }
922
+ await invokeErrorHook(config, isChair, result);
923
+ if (!isRetryable(result.errorCode, result.retryAfterMs)) {
924
+ return finalizeResult(result, startedAt, totalCost, hasCost, mergedUsage);
925
+ }
926
+ }
927
+ return finalizeResult(lastResult ?? {
928
+ agent: agentName,
929
+ text: '',
930
+ success: false,
931
+ errorCode: 'EXIT_ERROR',
932
+ errorMessage: 'Все попытки исчерпаны'
933
+ }, startedAt, totalCost, hasCost, mergedUsage);
934
+ }
935
+ /**
936
+ * Параллельно вызывает экспертов с ограничением конкурентности
937
+ */
938
+ export async function callExpertsParallel(experts, task, config, options = {}) {
939
+ const enabled = experts.filter(expert => expert.enabled !== false);
940
+ const results = new Array(enabled.length);
941
+ const concurrency = Math.min(MAX_CONCURRENCY, positiveInteger(config.retry?.maxConcurrency) ?? DEFAULT_MAX_CONCURRENCY);
942
+ const controller = options.controller;
943
+ const runExpert = async (expert) => {
944
+ try {
945
+ const prompt = buildExpertPrompt(expert, task);
946
+ await invokeHook(config, () => config.hooks?.onExpertStart?.(expert.name, task), 'onExpertStart');
947
+ const result = await callAgent(expert.name, prompt, config, {
948
+ timeout: expert.timeout,
949
+ retries: expert.retries,
950
+ isChair: false,
951
+ model: expert.model,
952
+ controller
953
+ });
954
+ await invokeHook(config, () => config.hooks?.onExpertEnd?.(result), 'onExpertEnd');
955
+ return result;
956
+ }
957
+ catch (error) {
958
+ const result = {
959
+ agent: expert.name,
960
+ text: '',
961
+ success: false,
962
+ errorCode: 'SPAWN_ERROR',
963
+ errorMessage: errorToMessage(error)
964
+ };
965
+ await invokeHook(config, () => config.hooks?.onExpertError?.(result), 'onExpertError');
966
+ return result;
967
+ }
968
+ };
969
+ let nextIndex = 0;
970
+ const worker = async () => {
971
+ for (;;) {
972
+ const index = nextIndex;
973
+ nextIndex += 1;
974
+ if (index >= enabled.length)
975
+ return;
976
+ results[index] = await runExpert(enabled[index]);
977
+ }
978
+ };
979
+ const workerCount = Math.min(concurrency, enabled.length);
980
+ const workers = [];
981
+ for (let i = 0; i < workerCount; i++) {
982
+ workers.push(worker());
983
+ }
984
+ await Promise.all(workers);
985
+ return results;
986
+ }
987
+ /**
988
+ * Вызывает председателя по результатам экспертов
989
+ */
990
+ export async function callChair(expertResults, task, config, options = {}) {
991
+ const successful = expertResults.filter(result => result.success);
992
+ if (successful.length === 0) {
993
+ throw new Error('Ни один эксперт не дал ответа — председатель не может синтезировать результат');
994
+ }
995
+ const maxExpertTextLength = positiveInteger(config.chair?.maxExpertTextLength) ?? DEFAULT_MAX_EXPERT_TEXT;
996
+ let prompt;
997
+ const template = config.chair?.promptTemplate;
998
+ if (template) {
999
+ prompt = template(task, expertResults);
1000
+ }
1001
+ else {
1002
+ prompt = buildChairPrompt(task, expertResults, maxExpertTextLength, config.consensus === true);
1003
+ }
1004
+ await invokeHook(config, () => config.hooks?.onChairStart?.(task, expertResults), 'onChairStart');
1005
+ const result = await callAgent(config.chair.agent, prompt, config, {
1006
+ timeout: config.chair?.timeout,
1007
+ retries: config.retry?.chairRetries,
1008
+ isChair: true,
1009
+ model: config.chair?.model,
1010
+ controller: options.controller
1011
+ });
1012
+ await invokeHook(config, () => config.hooks?.onChairEnd?.(result), 'onChairEnd');
1013
+ return result;
1014
+ }
1015
+ /**
1016
+ * Проверяет доступность OpenCode CLI (таймаут 10 с)
1017
+ */
1018
+ export async function checkOpenCodeAvailable(config) {
1019
+ const outcome = await executeOpenCode({
1020
+ opencodePath: resolveOpenCodePath(config),
1021
+ args: ['--version'],
1022
+ cwd: resolveCwd(config),
1023
+ timeoutMs: AVAILABILITY_TIMEOUT
1024
+ });
1025
+ return outcome.spawnError === undefined && !outcome.timedOut && outcome.code === 0;
1026
+ }
1027
+ /**
1028
+ * Возвращает версию OpenCode CLI или null
1029
+ */
1030
+ export async function getOpenCodeVersion(config) {
1031
+ const outcome = await executeOpenCode({
1032
+ opencodePath: resolveOpenCodePath(config),
1033
+ args: ['--version'],
1034
+ cwd: resolveCwd(config),
1035
+ timeoutMs: AVAILABILITY_TIMEOUT
1036
+ });
1037
+ if (outcome.spawnError !== undefined || outcome.timedOut || outcome.code !== 0) {
1038
+ return null;
1039
+ }
1040
+ const version = outcome.stdout.trim() || outcome.stderr.trim();
1041
+ return version.length > 0 ? version : null;
1042
+ }
1043
+ /**
1044
+ * Получает список доступных агентов из конфигурации (обратная совместимость)
1045
+ */
1046
+ export async function getAvailableAgents() {
1047
+ const { loadConfig } = await import('./config.js');
1048
+ const { config } = loadConfig();
1049
+ return config.experts.map(expert => expert.name);
1050
+ }
1051
+ //# sourceMappingURL=agents.js.map