triflux 3.3.0-dev.1 → 3.3.0-dev.3

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.
@@ -0,0 +1,900 @@
1
+ #!/usr/bin/env node
2
+ // hub/workers/delegator-mcp.mjs — triflux 위임용 MCP stdio 서버
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { dirname, isAbsolute, resolve } from 'node:path';
8
+ import process from 'node:process';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
13
+ import * as z from 'zod';
14
+
15
+ import { CodexMcpWorker } from './codex-mcp.mjs';
16
+ import { GeminiWorker } from './gemini-worker.mjs';
17
+
18
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
19
+ const SERVER_INFO = { name: 'triflux-delegator', version: '1.0.0' };
20
+ const DEFAULT_CONTEXT_BYTES = 32 * 1024;
21
+ const DEFAULT_ROUTE_TIMEOUT_SEC = 120;
22
+ const DIRECT_PROGRESS_START = 5;
23
+ const DIRECT_PROGRESS_RUNNING = 60;
24
+ const DIRECT_PROGRESS_DONE = 100;
25
+
26
+ const AGENT_TIMEOUT_SEC = Object.freeze({
27
+ executor: 1080,
28
+ 'build-fixer': 540,
29
+ debugger: 900,
30
+ 'deep-executor': 3600,
31
+ architect: 3600,
32
+ planner: 3600,
33
+ critic: 3600,
34
+ analyst: 3600,
35
+ 'code-reviewer': 1800,
36
+ 'security-reviewer': 1800,
37
+ 'quality-reviewer': 1800,
38
+ scientist: 1440,
39
+ 'scientist-deep': 3600,
40
+ 'document-specialist': 1440,
41
+ designer: 900,
42
+ writer: 900,
43
+ explore: 300,
44
+ verifier: 1200,
45
+ 'test-engineer': 300,
46
+ 'qa-tester': 300,
47
+ spark: 180,
48
+ });
49
+
50
+ const CODEX_PROFILE_BY_AGENT = Object.freeze({
51
+ executor: 'high',
52
+ 'build-fixer': 'fast',
53
+ debugger: 'high',
54
+ 'deep-executor': 'xhigh',
55
+ architect: 'xhigh',
56
+ planner: 'xhigh',
57
+ critic: 'xhigh',
58
+ analyst: 'xhigh',
59
+ 'code-reviewer': 'thorough',
60
+ 'security-reviewer': 'thorough',
61
+ 'quality-reviewer': 'thorough',
62
+ scientist: 'high',
63
+ 'scientist-deep': 'thorough',
64
+ 'document-specialist': 'high',
65
+ verifier: 'thorough',
66
+ spark: 'spark_fast',
67
+ });
68
+
69
+ const GEMINI_MODEL_BY_AGENT = Object.freeze({
70
+ 'build-fixer': 'gemini-3-flash-preview',
71
+ writer: 'gemini-3-flash-preview',
72
+ spark: 'gemini-3-flash-preview',
73
+ });
74
+
75
+ const REVIEW_INSTRUCTION_BY_AGENT = Object.freeze({
76
+ 'code-reviewer': '코드 리뷰 모드로 동작하라. 버그, 리스크, 회귀, 테스트 누락을 우선 식별하라.',
77
+ 'security-reviewer': '보안 리뷰 모드로 동작하라. 취약점, 권한 경계, 비밀정보 노출 가능성을 우선 식별하라.',
78
+ 'quality-reviewer': '품질 리뷰 모드로 동작하라. 로직 결함, 유지보수성 저하, 테스트 누락을 우선 식별하라.',
79
+ });
80
+
81
+ const IMPLEMENT_AGENT_SET = new Set(['executor', 'build-fixer', 'debugger', 'deep-executor']);
82
+ const ANALYZE_AGENT_SET = new Set(['architect', 'planner', 'critic', 'analyst', 'scientist', 'scientist-deep', 'document-specialist']);
83
+ const REVIEW_AGENT_SET = new Set(['code-reviewer', 'security-reviewer', 'quality-reviewer', 'verifier']);
84
+ const DOCS_AGENT_SET = new Set(['designer', 'writer']);
85
+ const SEARCH_TOOL_ORDER = ['brave-search', 'tavily', 'exa'];
86
+
87
+ function cloneEnv(env = process.env) {
88
+ return Object.fromEntries(
89
+ Object.entries(env).filter(([, value]) => typeof value === 'string'),
90
+ );
91
+ }
92
+
93
+ function parseJsonArray(raw, fallback = []) {
94
+ if (!raw) return [...fallback];
95
+ try {
96
+ const parsed = JSON.parse(raw);
97
+ return Array.isArray(parsed)
98
+ ? parsed.map((item) => String(item ?? '')).filter(Boolean)
99
+ : [...fallback];
100
+ } catch {
101
+ return [...fallback];
102
+ }
103
+ }
104
+
105
+ function resolveCandidatePath(candidate, cwd = process.cwd()) {
106
+ if (!candidate) return null;
107
+ const normalized = isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
108
+ return existsSync(normalized) ? normalized : null;
109
+ }
110
+
111
+ function resolveRouteScript(explicitPath, cwd = process.cwd()) {
112
+ const candidates = [
113
+ explicitPath,
114
+ process.env.TFX_DELEGATOR_ROUTE_SCRIPT,
115
+ process.env.TFX_ROUTE_SCRIPT,
116
+ resolve(SCRIPT_DIR, '..', '..', 'scripts', 'tfx-route.sh'),
117
+ resolve(cwd, 'scripts', 'tfx-route.sh'),
118
+ ];
119
+
120
+ for (const candidate of candidates) {
121
+ const resolved = resolveCandidatePath(candidate, cwd);
122
+ if (resolved) return resolved;
123
+ }
124
+
125
+ return null;
126
+ }
127
+
128
+ function resolveMcpProfile(agentType, requested = 'auto') {
129
+ if (requested && requested !== 'auto') return requested;
130
+ if (IMPLEMENT_AGENT_SET.has(agentType)) return 'implement';
131
+ if (ANALYZE_AGENT_SET.has(agentType)) return 'analyze';
132
+ if (REVIEW_AGENT_SET.has(agentType)) return 'review';
133
+ if (DOCS_AGENT_SET.has(agentType)) return 'docs';
134
+ return 'minimal';
135
+ }
136
+
137
+ function resolveSearchToolOrder(searchTool, workerIndex) {
138
+ const available = [...SEARCH_TOOL_ORDER];
139
+ if (searchTool && available.includes(searchTool)) {
140
+ return [searchTool, ...available.filter((tool) => tool !== searchTool)];
141
+ }
142
+
143
+ if (Number.isInteger(workerIndex) && workerIndex > 0 && available.length > 1) {
144
+ const offset = (workerIndex - 1) % available.length;
145
+ return available.slice(offset).concat(available.slice(0, offset));
146
+ }
147
+
148
+ return available;
149
+ }
150
+
151
+ function buildPromptHint(profile, args) {
152
+ const orderedTools = resolveSearchToolOrder(args.searchTool, args.workerIndex);
153
+ switch (profile) {
154
+ case 'implement':
155
+ return [
156
+ 'context7으로 라이브러리 문서를 조회하세요.',
157
+ `웹 검색은 ${orderedTools[0]}를 사용하세요.`,
158
+ '검색 도구 실패 시 402, 429, 432, 433, quota 에러에서 재시도하지 말고 다음 도구로 전환하세요.',
159
+ ].join(' ');
160
+ case 'analyze':
161
+ return [
162
+ 'context7으로 관련 문서를 조회하세요.',
163
+ `웹 검색 우선순위: ${orderedTools.join(', ')}. 402, 429, 432, 433, quota 에러 시 즉시 다음 도구로 전환.`,
164
+ '모든 검색 실패 시 playwright로 직접 방문 (최대 3 URL).',
165
+ '검색 깊이를 제한하고 결과를 빠르게 요약하세요.',
166
+ ].join(' ');
167
+ case 'review':
168
+ return 'sequential-thinking으로 체계적으로 분석하세요.';
169
+ case 'docs':
170
+ return [
171
+ 'context7으로 공식 문서를 참조하세요.',
172
+ `추가 검색은 ${orderedTools[0]}를 사용하세요.`,
173
+ '검색 결과의 출처 URL을 함께 제시하세요.',
174
+ ].join(' ');
175
+ default:
176
+ return '';
177
+ }
178
+ }
179
+
180
+ function resolveGeminiMcpServers(profile) {
181
+ switch (profile) {
182
+ case 'implement':
183
+ return ['context7', 'brave-search'];
184
+ case 'analyze':
185
+ return ['context7', 'brave-search', 'exa', 'tavily'];
186
+ case 'review':
187
+ return ['sequential-thinking'];
188
+ case 'docs':
189
+ return ['context7', 'brave-search'];
190
+ default:
191
+ return [];
192
+ }
193
+ }
194
+
195
+ function resolveCodexProfile(agentType) {
196
+ return CODEX_PROFILE_BY_AGENT[agentType] || 'high';
197
+ }
198
+
199
+ function resolveGeminiModel(agentType) {
200
+ return GEMINI_MODEL_BY_AGENT[agentType] || 'gemini-3.1-pro-preview';
201
+ }
202
+
203
+ function resolveTimeoutMs(agentType, timeoutMs) {
204
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
205
+ return Math.trunc(timeoutMs);
206
+ }
207
+
208
+ const timeoutSec = AGENT_TIMEOUT_SEC[agentType] || DEFAULT_ROUTE_TIMEOUT_SEC;
209
+ return timeoutSec * 1000;
210
+ }
211
+
212
+ function resolveTimeoutSec(agentType, timeoutMs) {
213
+ const resolved = resolveTimeoutMs(agentType, timeoutMs);
214
+ return Math.max(1, Math.ceil(resolved / 1000));
215
+ }
216
+
217
+ function loadContextFromFile(contextFile) {
218
+ if (!contextFile) return '';
219
+ const resolved = resolveCandidatePath(contextFile);
220
+ if (!resolved) return '';
221
+ try {
222
+ return readFileSync(resolved, 'utf8').slice(0, DEFAULT_CONTEXT_BYTES);
223
+ } catch {
224
+ return '';
225
+ }
226
+ }
227
+
228
+ function withContext(prompt, contextFile) {
229
+ const context = loadContextFromFile(contextFile);
230
+ if (!context) return prompt;
231
+ return `${prompt}\n\n<prior_context>\n${context}\n</prior_context>`;
232
+ }
233
+
234
+ function withPromptHint(prompt, args) {
235
+ const profile = resolveMcpProfile(args.agentType, args.mcpProfile);
236
+ const hint = buildPromptHint(profile, args);
237
+ if (!hint) return withContext(prompt, args.contextFile);
238
+ return `${withContext(prompt, args.contextFile)}. ${hint}`;
239
+ }
240
+
241
+ function joinInstructions(...values) {
242
+ return values
243
+ .filter((value) => typeof value === 'string' && value.trim())
244
+ .join('\n')
245
+ .trim();
246
+ }
247
+
248
+ function parseRouteType(stderr = '') {
249
+ const match = stderr.match(/type=([a-z-]+)/);
250
+ if (!match) return null;
251
+ if (match[1] === 'codex') return 'codex';
252
+ if (match[1] === 'gemini') return 'gemini';
253
+ return match[1];
254
+ }
255
+
256
+ function summarizePayload(payload) {
257
+ if (typeof payload.output === 'string' && payload.output.trim()) return payload.output.trim();
258
+ if (payload.mode === 'async' && payload.jobId) return `비동기 위임이 시작되었습니다. jobId=${payload.jobId}`;
259
+ if (payload.jobId) return `jobId=${payload.jobId} 상태=${payload.status}`;
260
+ if (payload.status) return `상태=${payload.status}`;
261
+ return payload.ok ? '완료되었습니다.' : '실패했습니다.';
262
+ }
263
+
264
+ function createToolResponse(payload, { isError = false } = {}) {
265
+ return {
266
+ content: [{ type: 'text', text: summarizePayload(payload) }],
267
+ structuredContent: payload,
268
+ isError,
269
+ };
270
+ }
271
+
272
+ function createErrorPayload(message, extras = {}) {
273
+ return {
274
+ ok: false,
275
+ status: 'failed',
276
+ error: message,
277
+ ...extras,
278
+ };
279
+ }
280
+
281
+ const DelegateInputSchema = z.object({
282
+ prompt: z.string().min(1).describe('위임할 프롬프트'),
283
+ provider: z.enum(['auto', 'codex', 'gemini']).default('auto').describe('사용할 provider'),
284
+ mode: z.enum(['sync', 'async']).default('sync').describe('동기 또는 비동기 실행'),
285
+ agentType: z.string().default('executor').describe('tfx-route 역할명 또는 direct 실행 역할'),
286
+ cwd: z.string().optional().describe('작업 디렉터리'),
287
+ timeoutMs: z.number().int().positive().optional().describe('요청 타임아웃(ms)'),
288
+ sessionKey: z.string().optional().describe('Codex warm session 재사용 키'),
289
+ resetSession: z.boolean().optional().describe('기존 Codex 세션 초기화 여부'),
290
+ mcpProfile: z.enum(['auto', 'implement', 'analyze', 'review', 'docs', 'minimal', 'none']).default('auto'),
291
+ contextFile: z.string().optional().describe('tfx-route prior_context 파일 경로'),
292
+ searchTool: z.enum(['brave-search', 'tavily', 'exa']).optional().describe('검색 우선 도구'),
293
+ workerIndex: z.number().int().positive().optional().describe('병렬 워커 인덱스'),
294
+ model: z.string().optional().describe('직접 실행 시 모델 오버라이드'),
295
+ developerInstructions: z.string().optional().describe('직접 실행 시 추가 개발자 지침'),
296
+ compactPrompt: z.string().optional().describe('Codex compact prompt'),
297
+ threadId: z.string().optional().describe('Codex 직접 실행 시 기존 threadId'),
298
+ codexTransport: z.enum(['auto', 'mcp', 'exec']).optional().describe('route 경로용 Codex transport'),
299
+ noClaudeNative: z.boolean().optional().describe('route 경로용 TFX_NO_CLAUDE_NATIVE'),
300
+ teamName: z.string().optional().describe('TFX_TEAM_NAME'),
301
+ teamTaskId: z.string().optional().describe('TFX_TEAM_TASK_ID'),
302
+ teamAgentName: z.string().optional().describe('TFX_TEAM_AGENT_NAME'),
303
+ teamLeadName: z.string().optional().describe('TFX_TEAM_LEAD_NAME'),
304
+ hubUrl: z.string().optional().describe('TFX_HUB_URL'),
305
+ });
306
+
307
+ const DelegateStatusInputSchema = z.object({
308
+ jobId: z.string().min(1).describe('조회할 비동기 job ID'),
309
+ });
310
+
311
+ const DelegateOutputSchema = z.object({
312
+ ok: z.boolean(),
313
+ jobId: z.string().optional(),
314
+ mode: z.enum(['sync', 'async']).optional(),
315
+ status: z.enum(['running', 'completed', 'failed']).optional(),
316
+ error: z.string().optional(),
317
+ providerRequested: z.string().optional(),
318
+ providerResolved: z.string().nullable().optional(),
319
+ agentType: z.string().optional(),
320
+ transport: z.string().optional(),
321
+ createdAt: z.string().optional(),
322
+ startedAt: z.string().optional(),
323
+ updatedAt: z.string().optional(),
324
+ completedAt: z.string().nullable().optional(),
325
+ exitCode: z.number().nullable().optional(),
326
+ output: z.string().optional(),
327
+ stderr: z.string().optional(),
328
+ threadId: z.string().nullable().optional(),
329
+ sessionKey: z.string().nullable().optional(),
330
+ });
331
+
332
+ function isTeamRouteRequested(args) {
333
+ return Boolean(
334
+ args.teamName
335
+ || args.teamTaskId
336
+ || args.teamAgentName
337
+ || args.teamLeadName
338
+ || args.hubUrl
339
+ );
340
+ }
341
+
342
+ function pickRouteMode(provider) {
343
+ return provider === 'auto' ? 'auto' : provider;
344
+ }
345
+
346
+ async function emitProgress(extra, progress, total, message) {
347
+ if (extra?._meta?.progressToken === undefined) return;
348
+ await extra.sendNotification({
349
+ method: 'notifications/progress',
350
+ params: {
351
+ progressToken: extra._meta.progressToken,
352
+ progress,
353
+ total,
354
+ message,
355
+ },
356
+ });
357
+ }
358
+
359
+ export class DelegatorMcpWorker {
360
+ type = 'delegator';
361
+
362
+ constructor(options = {}) {
363
+ this.cwd = options.cwd || process.cwd();
364
+ this.env = cloneEnv({ ...cloneEnv(process.env), ...cloneEnv(options.env) });
365
+ this.routeScript = resolveRouteScript(options.routeScript, this.cwd);
366
+ this.bashCommand = options.bashCommand
367
+ || this.env.TFX_DELEGATOR_BASH_COMMAND
368
+ || this.env.BASH_BIN
369
+ || 'bash';
370
+
371
+ this.codexWorker = new CodexMcpWorker({
372
+ command: options.codexCommand
373
+ || this.env.TFX_DELEGATOR_CODEX_COMMAND
374
+ || this.env.CODEX_BIN
375
+ || 'codex',
376
+ args: Array.isArray(options.codexArgs) && options.codexArgs.length
377
+ ? options.codexArgs
378
+ : parseJsonArray(this.env.TFX_DELEGATOR_CODEX_ARGS_JSON, []),
379
+ cwd: this.cwd,
380
+ env: this.env,
381
+ clientInfo: { name: SERVER_INFO.name, version: SERVER_INFO.version },
382
+ });
383
+
384
+ this.geminiCommand = options.geminiCommand || this.env.GEMINI_BIN || 'gemini';
385
+ this.geminiCommandArgs = Array.isArray(options.geminiArgs) && options.geminiArgs.length
386
+ ? [...options.geminiArgs]
387
+ : parseJsonArray(this.env.GEMINI_BIN_ARGS_JSON, []);
388
+
389
+ this.server = null;
390
+ this.transport = null;
391
+ this.jobs = new Map();
392
+ this.routeChildren = new Set();
393
+ this.ready = false;
394
+ }
395
+
396
+ isReady() {
397
+ return this.ready;
398
+ }
399
+
400
+ async start() {
401
+ if (this.server) {
402
+ this.ready = true;
403
+ return;
404
+ }
405
+
406
+ const server = new McpServer(SERVER_INFO, {
407
+ capabilities: { logging: {} },
408
+ });
409
+
410
+ server.registerTool('triflux-delegate', {
411
+ description: '새 위임을 실행합니다. codex/gemini direct 경로와 tfx-route 기반 auto 라우팅을 모두 지원합니다.',
412
+ inputSchema: DelegateInputSchema,
413
+ outputSchema: DelegateOutputSchema,
414
+ }, async (args, extra) => {
415
+ const payload = await this.delegate(args, extra);
416
+ return createToolResponse(payload, { isError: payload.ok === false && payload.mode !== 'async' });
417
+ });
418
+
419
+ server.registerTool('triflux-delegate-status', {
420
+ description: '비동기 위임 job 상태를 조회합니다.',
421
+ inputSchema: DelegateStatusInputSchema,
422
+ outputSchema: DelegateOutputSchema,
423
+ }, async ({ jobId }, extra) => {
424
+ const payload = await this.getJobStatus(jobId, extra);
425
+ return createToolResponse(payload, { isError: payload.ok === false });
426
+ });
427
+
428
+ this.server = server;
429
+ this.ready = true;
430
+ }
431
+
432
+ async serveStdio() {
433
+ await this.start();
434
+ if (this.transport) return;
435
+ const transport = new StdioServerTransport();
436
+ await this.server.connect(transport);
437
+ this.transport = transport;
438
+ }
439
+
440
+ async stop() {
441
+ this.ready = false;
442
+
443
+ for (const child of this.routeChildren) {
444
+ try { child.kill(); } catch {}
445
+ }
446
+ this.routeChildren.clear();
447
+
448
+ await this.codexWorker.stop().catch(() => {});
449
+
450
+ for (const job of this.jobs.values()) {
451
+ if (job.worker) {
452
+ await job.worker.stop().catch(() => {});
453
+ job.worker = null;
454
+ }
455
+ }
456
+
457
+ if (this.server) {
458
+ await this.server.close().catch(() => {});
459
+ }
460
+
461
+ this.server = null;
462
+ this.transport = null;
463
+ }
464
+
465
+ async run(prompt, options = {}) {
466
+ return this._executeDirect({ prompt, ...options });
467
+ }
468
+
469
+ async execute(prompt, options = {}) {
470
+ const result = await this._executeDirect({ prompt, ...options });
471
+ return {
472
+ output: result.output || result.error || '',
473
+ exitCode: result.exitCode ?? (result.ok ? 0 : 1),
474
+ threadId: result.threadId || null,
475
+ sessionKey: result.sessionKey || null,
476
+ raw: result,
477
+ };
478
+ }
479
+
480
+ async delegate(args, extra) {
481
+ if (args.mode === 'async') {
482
+ return this._startAsyncJob(args, extra);
483
+ }
484
+ return this._executeDirect(args, extra);
485
+ }
486
+
487
+ async getJobStatus(jobId, extra) {
488
+ const job = this.jobs.get(jobId);
489
+ if (!job) {
490
+ return createErrorPayload(`알 수 없는 jobId: ${jobId}`, { jobId });
491
+ }
492
+
493
+ const payload = this._serializeJob(job);
494
+ if (job.status === 'running') {
495
+ await emitProgress(extra, 25, 100, `job ${jobId} 실행 중`);
496
+ } else if (job.status === 'completed') {
497
+ await emitProgress(extra, DIRECT_PROGRESS_DONE, 100, `job ${jobId} 완료`);
498
+ } else if (job.status === 'failed') {
499
+ await emitProgress(extra, DIRECT_PROGRESS_DONE, 100, `job ${jobId} 실패`);
500
+ }
501
+ return payload;
502
+ }
503
+
504
+ _createGeminiWorker() {
505
+ return new GeminiWorker({
506
+ command: this.geminiCommand,
507
+ commandArgs: this.geminiCommandArgs,
508
+ cwd: this.cwd,
509
+ env: this.env,
510
+ });
511
+ }
512
+
513
+ _buildDirectPrompt(args) {
514
+ return withContext(String(args.prompt ?? ''), args.contextFile);
515
+ }
516
+
517
+ _buildDirectPromptWithHint(args) {
518
+ return withPromptHint(String(args.prompt ?? ''), {
519
+ agentType: args.agentType || 'executor',
520
+ mcpProfile: args.mcpProfile || 'auto',
521
+ searchTool: args.searchTool,
522
+ workerIndex: Number.isInteger(args.workerIndex) ? args.workerIndex : undefined,
523
+ contextFile: args.contextFile,
524
+ });
525
+ }
526
+
527
+ _buildPromptHintInstruction(args) {
528
+ return buildPromptHint(resolveMcpProfile(args.agentType, args.mcpProfile), {
529
+ agentType: args.agentType || 'executor',
530
+ mcpProfile: args.mcpProfile || 'auto',
531
+ searchTool: args.searchTool,
532
+ workerIndex: Number.isInteger(args.workerIndex) ? args.workerIndex : undefined,
533
+ });
534
+ }
535
+
536
+ _shouldUseRoute(args) {
537
+ return args.provider === 'auto' || isTeamRouteRequested(args);
538
+ }
539
+
540
+ async _executeDirect(args, extra = null) {
541
+ await emitProgress(extra, DIRECT_PROGRESS_START, 100, '위임 실행을 시작합니다.');
542
+
543
+ const runViaRoute = this._shouldUseRoute(args);
544
+
545
+ try {
546
+ const result = runViaRoute
547
+ ? await this._executeRoute(args, extra)
548
+ : await this._executeWorker(args, extra);
549
+
550
+ await emitProgress(extra, DIRECT_PROGRESS_DONE, 100, '위임이 완료되었습니다.');
551
+ return result;
552
+ } catch (error) {
553
+ const message = error instanceof Error ? error.message : String(error);
554
+ return createErrorPayload(message, {
555
+ mode: 'sync',
556
+ providerRequested: args.provider,
557
+ agentType: args.agentType,
558
+ transport: runViaRoute ? 'route-script' : `${args.provider}-worker`,
559
+ });
560
+ }
561
+ }
562
+
563
+ async _executeWorker(args, extra) {
564
+ await emitProgress(extra, DIRECT_PROGRESS_RUNNING, 100, '직접 워커 경로로 실행 중입니다.');
565
+
566
+ if (args.provider === 'codex') {
567
+ const result = await this.codexWorker.execute(this._buildDirectPrompt(args), {
568
+ cwd: args.cwd || this.cwd,
569
+ timeoutMs: resolveTimeoutMs(args.agentType, args.timeoutMs),
570
+ sessionKey: args.sessionKey,
571
+ threadId: args.threadId,
572
+ resetSession: args.resetSession,
573
+ profile: resolveCodexProfile(args.agentType),
574
+ sandbox: 'danger-full-access',
575
+ approvalPolicy: 'never',
576
+ developerInstructions: joinInstructions(
577
+ REVIEW_INSTRUCTION_BY_AGENT[args.agentType],
578
+ this._buildPromptHintInstruction(args),
579
+ args.developerInstructions,
580
+ ),
581
+ compactPrompt: args.compactPrompt,
582
+ model: args.model,
583
+ });
584
+
585
+ return {
586
+ ok: result.exitCode === 0,
587
+ mode: 'sync',
588
+ status: result.exitCode === 0 ? 'completed' : 'failed',
589
+ providerRequested: 'codex',
590
+ providerResolved: 'codex',
591
+ agentType: args.agentType,
592
+ transport: 'codex-mcp',
593
+ exitCode: result.exitCode,
594
+ output: result.output,
595
+ sessionKey: result.sessionKey,
596
+ threadId: result.threadId,
597
+ };
598
+ }
599
+
600
+ if (args.provider === 'gemini') {
601
+ const worker = this._createGeminiWorker();
602
+ try {
603
+ const result = await worker.execute(this._buildDirectPromptWithHint(args), {
604
+ cwd: args.cwd || this.cwd,
605
+ timeoutMs: resolveTimeoutMs(args.agentType, args.timeoutMs),
606
+ model: args.model || resolveGeminiModel(args.agentType),
607
+ approvalMode: 'yolo',
608
+ allowedMcpServerNames: resolveGeminiMcpServers(
609
+ resolveMcpProfile(args.agentType, args.mcpProfile),
610
+ ),
611
+ });
612
+
613
+ return {
614
+ ok: result.exitCode === 0,
615
+ mode: 'sync',
616
+ status: result.exitCode === 0 ? 'completed' : 'failed',
617
+ providerRequested: 'gemini',
618
+ providerResolved: 'gemini',
619
+ agentType: args.agentType,
620
+ transport: 'gemini-worker',
621
+ exitCode: result.exitCode,
622
+ output: result.output,
623
+ sessionKey: result.sessionKey,
624
+ };
625
+ } finally {
626
+ await worker.stop().catch(() => {});
627
+ }
628
+ }
629
+
630
+ return createErrorPayload(`지원하지 않는 direct provider: ${args.provider}`, {
631
+ mode: 'sync',
632
+ providerRequested: args.provider,
633
+ agentType: args.agentType,
634
+ transport: 'direct-worker',
635
+ });
636
+ }
637
+
638
+ async _executeRoute(args, extra) {
639
+ if (!this.routeScript) {
640
+ return createErrorPayload('tfx-route.sh 경로를 찾지 못했습니다.', {
641
+ mode: 'sync',
642
+ providerRequested: args.provider,
643
+ agentType: args.agentType,
644
+ transport: 'route-script',
645
+ });
646
+ }
647
+
648
+ await emitProgress(extra, DIRECT_PROGRESS_RUNNING, 100, 'tfx-route.sh 경로로 실행 중입니다.');
649
+ const result = await this._spawnRoute(args);
650
+ return {
651
+ ok: result.exitCode === 0,
652
+ mode: 'sync',
653
+ status: result.exitCode === 0 ? 'completed' : 'failed',
654
+ providerRequested: args.provider,
655
+ providerResolved: parseRouteType(result.stderr) || args.provider,
656
+ agentType: args.agentType,
657
+ transport: 'route-script',
658
+ exitCode: result.exitCode,
659
+ output: result.stdout.trim() || result.stderr.trim(),
660
+ stderr: result.stderr.trim(),
661
+ };
662
+ }
663
+
664
+ async _startAsyncJob(args, extra) {
665
+ const jobId = randomUUID();
666
+ const startedAt = new Date().toISOString();
667
+ const payload = {
668
+ ok: true,
669
+ jobId,
670
+ mode: 'async',
671
+ status: 'running',
672
+ providerRequested: args.provider,
673
+ providerResolved: null,
674
+ agentType: args.agentType,
675
+ transport: this._shouldUseRoute(args) ? 'route-script' : `${args.provider}-worker`,
676
+ createdAt: startedAt,
677
+ startedAt,
678
+ };
679
+
680
+ const job = {
681
+ ...payload,
682
+ updatedAt: startedAt,
683
+ completedAt: null,
684
+ output: '',
685
+ stderr: '',
686
+ exitCode: null,
687
+ threadId: null,
688
+ sessionKey: args.sessionKey || null,
689
+ worker: null,
690
+ child: null,
691
+ };
692
+ this.jobs.set(jobId, job);
693
+
694
+ await emitProgress(extra, DIRECT_PROGRESS_START, 100, `비동기 job ${jobId}를 시작합니다.`);
695
+
696
+ void (async () => {
697
+ try {
698
+ const result = this._shouldUseRoute(args)
699
+ ? await this._spawnRoute(args, job)
700
+ : await this._runAsyncWorker(args, job);
701
+
702
+ job.ok = result.ok;
703
+ job.status = result.ok ? 'completed' : 'failed';
704
+ job.providerResolved = result.providerResolved || job.providerRequested;
705
+ job.output = result.output || '';
706
+ job.stderr = result.stderr || '';
707
+ job.exitCode = result.exitCode ?? (result.ok ? 0 : 1);
708
+ job.threadId = result.threadId || null;
709
+ job.sessionKey = result.sessionKey || job.sessionKey || null;
710
+ job.completedAt = new Date().toISOString();
711
+ job.updatedAt = job.completedAt;
712
+ } catch (error) {
713
+ job.ok = false;
714
+ job.status = 'failed';
715
+ job.output = '';
716
+ job.stderr = error instanceof Error ? error.message : String(error);
717
+ job.exitCode = 1;
718
+ job.completedAt = new Date().toISOString();
719
+ job.updatedAt = job.completedAt;
720
+ } finally {
721
+ if (job.worker) {
722
+ await job.worker.stop().catch(() => {});
723
+ job.worker = null;
724
+ }
725
+ job.child = null;
726
+ }
727
+ })();
728
+
729
+ return payload;
730
+ }
731
+
732
+ async _runAsyncWorker(args, job) {
733
+ if (args.provider === 'codex') {
734
+ const result = await this.codexWorker.execute(this._buildDirectPrompt(args), {
735
+ cwd: args.cwd || this.cwd,
736
+ timeoutMs: resolveTimeoutMs(args.agentType, args.timeoutMs),
737
+ sessionKey: args.sessionKey,
738
+ threadId: args.threadId,
739
+ resetSession: args.resetSession,
740
+ profile: resolveCodexProfile(args.agentType),
741
+ sandbox: 'danger-full-access',
742
+ approvalPolicy: 'never',
743
+ developerInstructions: joinInstructions(
744
+ REVIEW_INSTRUCTION_BY_AGENT[args.agentType],
745
+ this._buildPromptHintInstruction(args),
746
+ args.developerInstructions,
747
+ ),
748
+ compactPrompt: args.compactPrompt,
749
+ model: args.model,
750
+ });
751
+
752
+ return {
753
+ ok: result.exitCode === 0,
754
+ providerResolved: 'codex',
755
+ output: result.output,
756
+ exitCode: result.exitCode,
757
+ threadId: result.threadId,
758
+ sessionKey: result.sessionKey,
759
+ };
760
+ }
761
+
762
+ if (args.provider === 'gemini') {
763
+ const worker = this._createGeminiWorker();
764
+ job.worker = worker;
765
+ const result = await worker.execute(this._buildDirectPromptWithHint(args), {
766
+ cwd: args.cwd || this.cwd,
767
+ timeoutMs: resolveTimeoutMs(args.agentType, args.timeoutMs),
768
+ model: args.model || resolveGeminiModel(args.agentType),
769
+ approvalMode: 'yolo',
770
+ allowedMcpServerNames: resolveGeminiMcpServers(
771
+ resolveMcpProfile(args.agentType, args.mcpProfile),
772
+ ),
773
+ });
774
+
775
+ return {
776
+ ok: result.exitCode === 0,
777
+ providerResolved: 'gemini',
778
+ output: result.output,
779
+ exitCode: result.exitCode,
780
+ sessionKey: result.sessionKey,
781
+ };
782
+ }
783
+
784
+ throw new Error(`지원하지 않는 async provider: ${args.provider}`);
785
+ }
786
+
787
+ _buildRouteEnv(args) {
788
+ const env = cloneEnv(this.env);
789
+ env.TFX_CLI_MODE = pickRouteMode(args.provider);
790
+
791
+ if (args.codexTransport) {
792
+ env.TFX_CODEX_TRANSPORT = args.codexTransport;
793
+ }
794
+ if (args.noClaudeNative === true) {
795
+ env.TFX_NO_CLAUDE_NATIVE = '1';
796
+ }
797
+ if (args.searchTool) {
798
+ env.TFX_SEARCH_TOOL = args.searchTool;
799
+ }
800
+ if (Number.isInteger(args.workerIndex) && args.workerIndex > 0) {
801
+ env.TFX_WORKER_INDEX = String(args.workerIndex);
802
+ }
803
+ if (args.teamName) env.TFX_TEAM_NAME = args.teamName;
804
+ if (args.teamTaskId) env.TFX_TEAM_TASK_ID = args.teamTaskId;
805
+ if (args.teamAgentName) env.TFX_TEAM_AGENT_NAME = args.teamAgentName;
806
+ if (args.teamLeadName) env.TFX_TEAM_LEAD_NAME = args.teamLeadName;
807
+ if (args.hubUrl) env.TFX_HUB_URL = args.hubUrl;
808
+
809
+ return env;
810
+ }
811
+
812
+ async _spawnRoute(args, job = null) {
813
+ const prompt = withContext(String(args.prompt ?? ''), args.contextFile);
814
+ const childArgs = [
815
+ this.routeScript,
816
+ args.agentType || 'executor',
817
+ prompt,
818
+ args.mcpProfile || 'auto',
819
+ String(resolveTimeoutSec(args.agentType, args.timeoutMs)),
820
+ ];
821
+
822
+ const child = spawn(this.bashCommand, childArgs, {
823
+ cwd: args.cwd || this.cwd,
824
+ env: this._buildRouteEnv(args),
825
+ stdio: ['ignore', 'pipe', 'pipe'],
826
+ windowsHide: true,
827
+ });
828
+
829
+ if (job) {
830
+ job.child = child;
831
+ }
832
+
833
+ this.routeChildren.add(child);
834
+
835
+ return await new Promise((resolvePromise, rejectPromise) => {
836
+ const stdoutChunks = [];
837
+ const stderrChunks = [];
838
+
839
+ child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
840
+ child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
841
+ child.once('error', (error) => {
842
+ this.routeChildren.delete(child);
843
+ rejectPromise(error);
844
+ });
845
+ child.once('close', (code) => {
846
+ this.routeChildren.delete(child);
847
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
848
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
849
+ resolvePromise({
850
+ ok: code === 0,
851
+ providerResolved: parseRouteType(stderr) || args.provider,
852
+ output: stdout.trim() || stderr.trim(),
853
+ stdout,
854
+ stderr,
855
+ exitCode: code ?? 1,
856
+ });
857
+ });
858
+ });
859
+ }
860
+
861
+ _serializeJob(job) {
862
+ return {
863
+ ok: job.ok,
864
+ jobId: job.jobId,
865
+ mode: 'async',
866
+ status: job.status,
867
+ providerRequested: job.providerRequested,
868
+ providerResolved: job.providerResolved,
869
+ agentType: job.agentType,
870
+ transport: job.transport,
871
+ createdAt: job.createdAt,
872
+ startedAt: job.startedAt,
873
+ updatedAt: job.updatedAt,
874
+ completedAt: job.completedAt,
875
+ exitCode: job.exitCode,
876
+ output: job.output,
877
+ stderr: job.stderr,
878
+ threadId: job.threadId,
879
+ sessionKey: job.sessionKey,
880
+ };
881
+ }
882
+ }
883
+
884
+ export function createDelegatorMcpWorker(options = {}) {
885
+ return new DelegatorMcpWorker(options);
886
+ }
887
+
888
+ export async function runDelegatorMcpCli() {
889
+ const worker = createDelegatorMcpWorker();
890
+ try {
891
+ await worker.serveStdio();
892
+ } catch (error) {
893
+ console.error(`[delegator-mcp] ${error instanceof Error ? error.message : String(error)}`);
894
+ process.exitCode = 1;
895
+ }
896
+ }
897
+
898
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
899
+ await runDelegatorMcpCli();
900
+ }