triflux 7.1.4 → 7.2.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 (73) hide show
  1. package/.claude-plugin/marketplace.json +31 -31
  2. package/.claude-plugin/plugin.json +22 -23
  3. package/bin/triflux.mjs +18 -5
  4. package/hooks/keyword-rules.json +393 -361
  5. package/hub/bridge.mjs +799 -786
  6. package/hub/delegator/contracts.mjs +37 -38
  7. package/hub/delegator/schema/delegator-tools.schema.json +250 -250
  8. package/hub/delegator/service.mjs +307 -302
  9. package/hub/intent.mjs +108 -11
  10. package/hub/lib/process-utils.mjs +20 -0
  11. package/hub/pipe.mjs +589 -589
  12. package/hub/pipeline/gates/confidence.mjs +1 -1
  13. package/hub/pipeline/gates/selfcheck.mjs +2 -4
  14. package/hub/pipeline/state.mjs +191 -187
  15. package/hub/pipeline/transitions.mjs +124 -120
  16. package/hub/public/dashboard.html +355 -349
  17. package/hub/quality/deslop.mjs +5 -3
  18. package/hub/reflexion.mjs +5 -1
  19. package/hub/research.mjs +6 -1
  20. package/hub/router.mjs +791 -782
  21. package/hub/server.mjs +893 -822
  22. package/hub/store.mjs +807 -778
  23. package/hub/team/agent-map.json +10 -0
  24. package/hub/team/ansi.mjs +3 -4
  25. package/hub/team/cli/commands/control.mjs +43 -43
  26. package/hub/team/cli/commands/interrupt.mjs +36 -36
  27. package/hub/team/cli/commands/kill.mjs +3 -3
  28. package/hub/team/cli/commands/send.mjs +37 -37
  29. package/hub/team/cli/commands/start/index.mjs +18 -8
  30. package/hub/team/cli/commands/start/parse-args.mjs +3 -1
  31. package/hub/team/cli/commands/start/start-headless.mjs +4 -1
  32. package/hub/team/cli/commands/status.mjs +87 -87
  33. package/hub/team/cli/commands/stop.mjs +1 -1
  34. package/hub/team/cli/commands/task.mjs +1 -1
  35. package/hub/team/cli/index.mjs +41 -39
  36. package/hub/team/cli/manifest.mjs +29 -28
  37. package/hub/team/cli/services/hub-client.mjs +37 -0
  38. package/hub/team/cli/services/state-store.mjs +26 -12
  39. package/hub/team/dashboard.mjs +11 -4
  40. package/hub/team/handoff.mjs +12 -0
  41. package/hub/team/headless.mjs +202 -200
  42. package/hub/team/native-supervisor.mjs +386 -346
  43. package/hub/team/nativeProxy.mjs +680 -692
  44. package/hub/team/staleState.mjs +361 -369
  45. package/hub/team/tui-viewer.mjs +27 -3
  46. package/hub/team/tui.mjs +1 -0
  47. package/hub/token-mode.mjs +114 -24
  48. package/hub/workers/delegator-mcp.mjs +1059 -1057
  49. package/hud/colors.mjs +88 -0
  50. package/hud/constants.mjs +78 -0
  51. package/hud/hud-qos-status.mjs +206 -1872
  52. package/hud/providers/claude.mjs +309 -0
  53. package/hud/providers/codex.mjs +151 -0
  54. package/hud/providers/gemini.mjs +320 -0
  55. package/hud/renderers.mjs +424 -0
  56. package/hud/terminal.mjs +140 -0
  57. package/hud/utils.mjs +271 -0
  58. package/package.json +1 -2
  59. package/scripts/__tests__/keyword-detector.test.mjs +234 -234
  60. package/scripts/headless-guard-fast.sh +21 -0
  61. package/scripts/headless-guard.mjs +26 -6
  62. package/scripts/lib/keyword-rules.mjs +166 -168
  63. package/scripts/setup.mjs +720 -690
  64. package/scripts/tfx-route-post.mjs +424 -424
  65. package/scripts/tfx-route.sh +1663 -1650
  66. package/scripts/tmp-cleanup.mjs +74 -0
  67. package/skills/tfx-auto/SKILL.md +279 -278
  68. package/skills/tfx-auto-codex/SKILL.md +98 -77
  69. package/skills/tfx-codex/SKILL.md +65 -65
  70. package/skills/tfx-gemini/SKILL.md +83 -82
  71. package/skills/tfx-hub/SKILL.md +205 -136
  72. package/skills/tfx-multi/SKILL.md +11 -5
  73. package/.mcp.json +0 -8
@@ -1,1057 +1,1059 @@
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, pathToFileURL } 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
-
20
- // mcp-filter.mjs 동적 해석 — 프로젝트(hub/workers/)와 배포(scripts/hub/workers/) 양쪽 대응
21
- const MCP_FILTER_CANDIDATES = [
22
- resolve(SCRIPT_DIR, '../../scripts/lib/mcp-filter.mjs'), // 프로젝트 원본
23
- resolve(SCRIPT_DIR, '../../lib/mcp-filter.mjs'), // 배포 (~/.claude/scripts/)
24
- ];
25
- const mcpFilterPath = MCP_FILTER_CANDIDATES.find((p) => existsSync(p));
26
- if (!mcpFilterPath) {
27
- throw new Error(`mcp-filter.mjs not found. candidates: ${MCP_FILTER_CANDIDATES.join(', ')}`);
28
- }
29
- const {
30
- buildPromptHint,
31
- getCodexMcpConfig,
32
- getGeminiAllowedServers,
33
- resolveMcpProfile,
34
- SUPPORTED_MCP_PROFILES,
35
- } = await import(pathToFileURL(mcpFilterPath).href);
36
- const SERVER_INFO = { name: 'triflux-delegator', version: '1.0.0' };
37
- const DEFAULT_CONTEXT_BYTES = 32 * 1024;
38
- const DEFAULT_ROUTE_TIMEOUT_SEC = 120;
39
- const DIRECT_PROGRESS_START = 5;
40
- const DIRECT_PROGRESS_RUNNING = 60;
41
- const DIRECT_PROGRESS_DONE = 100;
42
-
43
- const AGENT_TIMEOUT_SEC = Object.freeze({
44
- executor: 1080,
45
- 'build-fixer': 540,
46
- debugger: 900,
47
- 'deep-executor': 3600,
48
- architect: 3600,
49
- planner: 3600,
50
- critic: 3600,
51
- analyst: 3600,
52
- 'code-reviewer': 1800,
53
- 'security-reviewer': 1800,
54
- 'quality-reviewer': 1800,
55
- scientist: 1440,
56
- 'scientist-deep': 3600,
57
- 'document-specialist': 1440,
58
- designer: 900,
59
- writer: 900,
60
- explore: 300,
61
- verifier: 1200,
62
- 'test-engineer': 300,
63
- 'qa-tester': 300,
64
- spark: 180,
65
- });
66
-
67
- const CODEX_PROFILE_BY_AGENT = Object.freeze({
68
- executor: 'high',
69
- 'build-fixer': 'fast',
70
- debugger: 'high',
71
- 'deep-executor': 'xhigh',
72
- architect: 'xhigh',
73
- planner: 'xhigh',
74
- critic: 'xhigh',
75
- analyst: 'xhigh',
76
- 'code-reviewer': 'thorough',
77
- 'security-reviewer': 'thorough',
78
- 'quality-reviewer': 'thorough',
79
- scientist: 'high',
80
- 'scientist-deep': 'thorough',
81
- 'document-specialist': 'high',
82
- verifier: 'thorough',
83
- designer: 'high', // Gemini primary, codex fallback용
84
- writer: 'high', // Gemini primary, codex fallback용
85
- spark: 'spark_fast',
86
- });
87
-
88
- const GEMINI_MODEL_BY_AGENT = Object.freeze({
89
- 'build-fixer': 'gemini-3-flash-preview',
90
- writer: 'gemini-3-flash-preview',
91
- spark: 'gemini-3-flash-preview',
92
- });
93
-
94
- const REVIEW_INSTRUCTION_BY_AGENT = Object.freeze({
95
- 'code-reviewer': '코드 리뷰 모드로 동작하라. 버그, 리스크, 회귀, 테스트 누락을 우선 식별하라.',
96
- 'security-reviewer': '보안 리뷰 모드로 동작하라. 취약점, 권한 경계, 비밀정보 노출 가능성을 우선 식별하라.',
97
- 'quality-reviewer': '품질 리뷰 모드로 동작하라. 로직 결함, 유지보수성 저하, 테스트 누락을 우선 식별하라.',
98
- });
99
-
100
- function cloneEnv(env = process.env) {
101
- return Object.fromEntries(
102
- Object.entries(env).filter(([, value]) => typeof value === 'string'),
103
- );
104
- }
105
-
106
- function parseJsonArray(raw, fallback = []) {
107
- if (!raw) return [...fallback];
108
- try {
109
- const parsed = JSON.parse(raw);
110
- return Array.isArray(parsed)
111
- ? parsed.map((item) => String(item ?? '')).filter(Boolean)
112
- : [...fallback];
113
- } catch {
114
- return [...fallback];
115
- }
116
- }
117
-
118
- function resolveCandidatePath(candidate, cwd = process.cwd()) {
119
- if (!candidate) return null;
120
- const normalized = isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
121
- return existsSync(normalized) ? normalized : null;
122
- }
123
-
124
- function resolveRouteScript(explicitPath, cwd = process.cwd()) {
125
- const candidates = [
126
- explicitPath,
127
- process.env.TFX_DELEGATOR_ROUTE_SCRIPT,
128
- process.env.TFX_ROUTE_SCRIPT,
129
- resolve(SCRIPT_DIR, '..', '..', 'scripts', 'tfx-route.sh'),
130
- resolve(cwd, 'scripts', 'tfx-route.sh'),
131
- ];
132
-
133
- for (const candidate of candidates) {
134
- const resolved = resolveCandidatePath(candidate, cwd);
135
- if (resolved) return resolved;
136
- }
137
-
138
- return null;
139
- }
140
-
141
- function resolveCodexProfile(agentType) {
142
- return CODEX_PROFILE_BY_AGENT[agentType] || 'high';
143
- }
144
-
145
- function resolveGeminiModel(agentType) {
146
- return GEMINI_MODEL_BY_AGENT[agentType] || 'gemini-3.1-pro-preview';
147
- }
148
-
149
- function resolveTimeoutMs(agentType, timeoutMs) {
150
- if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
151
- return Math.trunc(timeoutMs);
152
- }
153
-
154
- const timeoutSec = AGENT_TIMEOUT_SEC[agentType] || DEFAULT_ROUTE_TIMEOUT_SEC;
155
- return timeoutSec * 1000;
156
- }
157
-
158
- function resolveTimeoutSec(agentType, timeoutMs) {
159
- const resolved = resolveTimeoutMs(agentType, timeoutMs);
160
- return Math.max(1, Math.ceil(resolved / 1000));
161
- }
162
-
163
- function loadContextFromFile(contextFile) {
164
- if (!contextFile) return '';
165
- const resolved = resolveCandidatePath(contextFile);
166
- if (!resolved) return '';
167
- try {
168
- return readFileSync(resolved, 'utf8').slice(0, DEFAULT_CONTEXT_BYTES);
169
- } catch {
170
- return '';
171
- }
172
- }
173
-
174
- function withContext(prompt, contextFile) {
175
- const context = loadContextFromFile(contextFile);
176
- if (!context) return prompt;
177
- return `${prompt}\n\n<prior_context>\n${context}\n</prior_context>`;
178
- }
179
-
180
- function withPromptHint(prompt, args) {
181
- const promptWithContext = withContext(prompt, args.contextFile);
182
- const hint = buildPromptHint({
183
- agentType: args.agentType,
184
- requestedProfile: args.mcpProfile,
185
- searchTool: args.searchTool,
186
- workerIndex: Number.isInteger(args.workerIndex) ? args.workerIndex : undefined,
187
- taskText: promptWithContext,
188
- });
189
- if (!hint) return promptWithContext;
190
- return `${promptWithContext}. ${hint}`;
191
- }
192
-
193
- function joinInstructions(...values) {
194
- return values
195
- .filter((value) => typeof value === 'string' && value.trim())
196
- .join('\n')
197
- .trim();
198
- }
199
-
200
- function parseRouteType(stderr = '') {
201
- const match = stderr.match(/type=([a-z-]+)/);
202
- if (!match) return null;
203
- if (match[1] === 'codex') return 'codex';
204
- if (match[1] === 'gemini') return 'gemini';
205
- return match[1];
206
- }
207
-
208
- function summarizePayload(payload) {
209
- if (typeof payload.output === 'string' && payload.output.trim()) return payload.output.trim();
210
- if (payload.mode === 'async' && payload.jobId) return `비동기 위임이 시작되었습니다. jobId=${payload.jobId}`;
211
- if (payload.jobId) return `jobId=${payload.jobId} 상태=${payload.status}`;
212
- if (payload.status) return `상태=${payload.status}`;
213
- return payload.ok ? '완료되었습니다.' : '실패했습니다.';
214
- }
215
-
216
- function createToolResponse(payload, { isError = false } = {}) {
217
- return {
218
- content: [{ type: 'text', text: summarizePayload(payload) }],
219
- structuredContent: payload,
220
- isError,
221
- };
222
- }
223
-
224
- function createErrorPayload(message, extras = {}) {
225
- return {
226
- ok: false,
227
- status: 'failed',
228
- error: message,
229
- ...extras,
230
- };
231
- }
232
-
233
- const DelegateInputSchema = z.object({
234
- prompt: z.string().min(1).describe('위임할 프롬프트'),
235
- provider: z.enum(['auto', 'codex', 'gemini']).default('auto').describe('사용할 provider'),
236
- mode: z.enum(['sync', 'async']).default('sync').describe('동기 또는 비동기 실행'),
237
- agentType: z.string().default('executor').describe('tfx-route 역할명 또는 direct 실행 역할'),
238
- cwd: z.string().optional().describe('작업 디렉터리'),
239
- timeoutMs: z.number().int().positive().optional().describe('요청 타임아웃(ms)'),
240
- sessionKey: z.string().optional().describe('Codex warm session 재사용 키'),
241
- resetSession: z.boolean().optional().describe('기존 Codex 세션 초기화 여부'),
242
- mcpProfile: z.enum(SUPPORTED_MCP_PROFILES).default('auto'),
243
- contextFile: z.string().optional().describe('tfx-route prior_context 파일 경로'),
244
- searchTool: z.enum(['brave-search', 'tavily', 'exa']).optional().describe('검색 우선 도구'),
245
- workerIndex: z.number().int().positive().optional().describe('병렬 워커 인덱스'),
246
- model: z.string().optional().describe('직접 실행 시 모델 오버라이드'),
247
- developerInstructions: z.string().optional().describe('직접 실행 시 추가 개발자 지침'),
248
- compactPrompt: z.string().optional().describe('Codex compact prompt'),
249
- threadId: z.string().optional().describe('Codex 직접 실행 시 기존 threadId'),
250
- codexTransport: z.enum(['auto', 'mcp', 'exec']).optional().describe('route 경로용 Codex transport'),
251
- noClaudeNative: z.boolean().optional().describe('route 경로용 TFX_NO_CLAUDE_NATIVE'),
252
- teamName: z.string().optional().describe('TFX_TEAM_NAME'),
253
- teamTaskId: z.string().optional().describe('TFX_TEAM_TASK_ID'),
254
- teamAgentName: z.string().optional().describe('TFX_TEAM_AGENT_NAME'),
255
- teamLeadName: z.string().optional().describe('TFX_TEAM_LEAD_NAME'),
256
- hubUrl: z.string().optional().describe('TFX_HUB_URL'),
257
- });
258
-
259
- const DelegateStatusInputSchema = z.object({
260
- jobId: z.string().min(1).describe('조회할 비동기 job ID'),
261
- });
262
-
263
- const DelegateReplyInputSchema = z.object({
264
- job_id: z.string().min(1).describe('후속 응답을 보낼 기존 delegate job ID'),
265
- reply: z.string().min(1).describe('후속 사용자 응답'),
266
- done: z.boolean().default(false).describe('true이면 응답 처리 후 대화를 종료'),
267
- });
268
-
269
- const DelegateOutputSchema = z.object({
270
- ok: z.boolean(),
271
- jobId: z.string().optional(),
272
- job_id: z.string().optional(),
273
- mode: z.enum(['sync', 'async']).optional(),
274
- status: z.enum(['running', 'completed', 'failed']).optional(),
275
- error: z.string().optional(),
276
- providerRequested: z.string().optional(),
277
- providerResolved: z.string().nullable().optional(),
278
- agentType: z.string().optional(),
279
- transport: z.string().optional(),
280
- createdAt: z.string().optional(),
281
- startedAt: z.string().optional(),
282
- updatedAt: z.string().optional(),
283
- completedAt: z.string().nullable().optional(),
284
- exitCode: z.number().nullable().optional(),
285
- output: z.string().optional(),
286
- stderr: z.string().optional(),
287
- threadId: z.string().nullable().optional(),
288
- sessionKey: z.string().nullable().optional(),
289
- conversationOpen: z.boolean().optional(),
290
- });
291
-
292
- function isTeamRouteRequested(args) {
293
- return Boolean(
294
- args.teamName
295
- || args.teamTaskId
296
- || args.teamAgentName
297
- || args.teamLeadName
298
- || args.hubUrl
299
- );
300
- }
301
-
302
- function pickRouteMode(provider) {
303
- return provider === 'auto' ? 'auto' : provider;
304
- }
305
-
306
- function sanitizeDelegateArgs(args = {}) {
307
- return {
308
- provider: args.provider || 'auto',
309
- agentType: args.agentType || 'executor',
310
- cwd: args.cwd || null,
311
- timeoutMs: Number.isFinite(Number(args.timeoutMs)) ? Math.trunc(Number(args.timeoutMs)) : null,
312
- sessionKey: args.sessionKey || null,
313
- resetSession: Boolean(args.resetSession),
314
- mcpProfile: args.mcpProfile || 'auto',
315
- contextFile: args.contextFile || null,
316
- searchTool: args.searchTool || null,
317
- workerIndex: Number.isInteger(args.workerIndex) ? args.workerIndex : null,
318
- model: args.model || null,
319
- developerInstructions: args.developerInstructions || null,
320
- compactPrompt: args.compactPrompt || null,
321
- threadId: args.threadId || null,
322
- codexTransport: args.codexTransport || null,
323
- noClaudeNative: args.noClaudeNative === true,
324
- teamName: args.teamName || null,
325
- teamTaskId: args.teamTaskId || null,
326
- teamAgentName: args.teamAgentName || null,
327
- teamLeadName: args.teamLeadName || null,
328
- hubUrl: args.hubUrl || null,
329
- };
330
- }
331
-
332
- function formatConversationTranscript(turns = []) {
333
- return turns.map((turn, index) => {
334
- const parts = [
335
- `Turn ${index + 1} user:\n${turn.user}`,
336
- ];
337
- if (typeof turn.assistant === 'string' && turn.assistant.trim()) {
338
- parts.push(`Turn ${index + 1} assistant:\n${turn.assistant}`);
339
- }
340
- return parts.join('\n\n');
341
- }).join('\n\n');
342
- }
343
-
344
- async function emitProgress(extra, progress, total, message) {
345
- if (extra?._meta?.progressToken === undefined) return;
346
- await extra.sendNotification({
347
- method: 'notifications/progress',
348
- params: {
349
- progressToken: extra._meta.progressToken,
350
- progress,
351
- total,
352
- message,
353
- },
354
- });
355
- }
356
-
357
- export class DelegatorMcpWorker {
358
- type = 'delegator';
359
-
360
- constructor(options = {}) {
361
- this.cwd = options.cwd || process.cwd();
362
- this.env = cloneEnv({ ...cloneEnv(process.env), ...cloneEnv(options.env) });
363
- this.routeScript = resolveRouteScript(options.routeScript, this.cwd);
364
- this.bashCommand = options.bashCommand
365
- || this.env.TFX_DELEGATOR_BASH_COMMAND
366
- || this.env.BASH_BIN
367
- || 'bash';
368
-
369
- this.codexWorker = new CodexMcpWorker({
370
- command: options.codexCommand
371
- || this.env.TFX_DELEGATOR_CODEX_COMMAND
372
- || this.env.CODEX_BIN
373
- || 'codex',
374
- args: Array.isArray(options.codexArgs) && options.codexArgs.length
375
- ? options.codexArgs
376
- : parseJsonArray(this.env.TFX_DELEGATOR_CODEX_ARGS_JSON, []),
377
- cwd: this.cwd,
378
- env: this.env,
379
- clientInfo: { name: SERVER_INFO.name, version: SERVER_INFO.version },
380
- });
381
-
382
- this.geminiCommand = options.geminiCommand || this.env.GEMINI_BIN || 'gemini';
383
- this.geminiCommandArgs = Array.isArray(options.geminiArgs) && options.geminiArgs.length
384
- ? [...options.geminiArgs]
385
- : parseJsonArray(this.env.GEMINI_BIN_ARGS_JSON, []);
386
-
387
- this.server = null;
388
- this.transport = null;
389
- this.jobs = new Map();
390
- this.geminiConversations = new Map();
391
- this.routeChildren = new Set();
392
- this.ready = false;
393
- }
394
-
395
- isReady() {
396
- return this.ready;
397
- }
398
-
399
- async start() {
400
- if (this.server) {
401
- this.ready = true;
402
- return;
403
- }
404
-
405
- const server = new McpServer(SERVER_INFO, {
406
- capabilities: { logging: {} },
407
- });
408
-
409
- server.registerTool('triflux-delegate', {
410
- description: '새 위임을 실행합니다. codex/gemini direct 경로와 tfx-route 기반 auto 라우팅을 모두 지원합니다.',
411
- inputSchema: DelegateInputSchema,
412
- outputSchema: DelegateOutputSchema,
413
- }, async (args, extra) => {
414
- const payload = await this.delegate(args, extra);
415
- return createToolResponse(payload, { isError: payload.ok === false && payload.mode !== 'async' });
416
- });
417
-
418
- server.registerTool('triflux-delegate-status', {
419
- description: '비동기 위임 job 상태를 조회합니다.',
420
- inputSchema: DelegateStatusInputSchema,
421
- outputSchema: DelegateOutputSchema,
422
- }, async ({ jobId }, extra) => {
423
- const payload = await this.getJobStatus(jobId, extra);
424
- return createToolResponse(payload, { isError: payload.ok === false });
425
- });
426
-
427
- server.registerTool('triflux-delegate-reply', {
428
- description: '기존 delegate job에 후속 응답을 보내고, Gemini direct job이면 multi-turn 대화를 이어갑니다.',
429
- inputSchema: DelegateReplyInputSchema,
430
- outputSchema: DelegateOutputSchema,
431
- }, async (args, extra) => {
432
- const payload = await this.reply(args, extra);
433
- return createToolResponse(payload, { isError: payload.ok === false });
434
- });
435
-
436
- this.server = server;
437
- this.ready = true;
438
- }
439
-
440
- async serveStdio() {
441
- await this.start();
442
- if (this.transport) return;
443
- const transport = new StdioServerTransport();
444
- await this.server.connect(transport);
445
- this.transport = transport;
446
- }
447
-
448
- async stop() {
449
- this.ready = false;
450
-
451
- for (const child of this.routeChildren) {
452
- try { child.kill(); } catch {}
453
- }
454
- this.routeChildren.clear();
455
-
456
- await this.codexWorker.stop().catch(() => {});
457
-
458
- for (const job of this.jobs.values()) {
459
- if (job.worker) {
460
- await job.worker.stop().catch(() => {});
461
- job.worker = null;
462
- }
463
- }
464
- this.geminiConversations.clear();
465
-
466
- if (this.server) {
467
- await this.server.close().catch(() => {});
468
- }
469
-
470
- this.server = null;
471
- this.transport = null;
472
- }
473
-
474
- async run(prompt, options = {}) {
475
- return this._executeDirect({ prompt, ...options });
476
- }
477
-
478
- async execute(prompt, options = {}) {
479
- const result = await this._executeDirect({ prompt, ...options });
480
- return {
481
- output: result.output || result.error || '',
482
- exitCode: result.exitCode ?? (result.ok ? 0 : 1),
483
- threadId: result.threadId || null,
484
- sessionKey: result.sessionKey || null,
485
- raw: result,
486
- };
487
- }
488
-
489
- async delegate(args, extra) {
490
- if (args.mode === 'async') {
491
- return this._startAsyncJob(args, extra);
492
- }
493
- return this._runSyncJob(args, extra);
494
- }
495
-
496
- async getJobStatus(jobId, extra) {
497
- const job = this.jobs.get(jobId);
498
- if (!job) {
499
- return createErrorPayload(`알 없는 jobId: ${jobId}`, { jobId });
500
- }
501
-
502
- const payload = this._serializeJob(job);
503
- if (job.status === 'running') {
504
- await emitProgress(extra, 25, 100, `job ${jobId} 실행 중`);
505
- } else if (job.status === 'completed') {
506
- await emitProgress(extra, DIRECT_PROGRESS_DONE, 100, `job ${jobId} 완료`);
507
- } else if (job.status === 'failed') {
508
- await emitProgress(extra, DIRECT_PROGRESS_DONE, 100, `job ${jobId} 실패`);
509
- }
510
- return payload;
511
- }
512
-
513
- async reply({ job_id, reply, done = false }, extra) {
514
- const job = this.jobs.get(job_id);
515
- if (!job) {
516
- return createErrorPayload(`알 없는 jobId: ${job_id}`, { jobId: job_id, job_id });
517
- }
518
- if (job.status === 'running') {
519
- return createErrorPayload(`job ${job_id}가 아직 실행 중입니다.`, { jobId: job_id, job_id });
520
- }
521
- if (job.providerRequested !== 'gemini' || job.transport !== 'gemini-worker') {
522
- return createErrorPayload('delegate-reply는 현재 direct Gemini job에만 지원됩니다.', {
523
- jobId: job_id,
524
- job_id,
525
- });
526
- }
527
-
528
- const conversation = this.geminiConversations.get(job_id);
529
- if (!conversation) {
530
- return createErrorPayload(`Gemini 대화 컨텍스트가 없습니다: ${job_id}`, { jobId: job_id, job_id });
531
- }
532
- if (conversation.closed) {
533
- return createErrorPayload(`이미 종료된 대화입니다: ${job_id}`, { jobId: job_id, job_id });
534
- }
535
-
536
- await emitProgress(extra, DIRECT_PROGRESS_START, 100, `job ${job_id} 후속 응답을 시작합니다.`);
537
- job.status = 'running';
538
- job.updatedAt = new Date().toISOString();
539
-
540
- const worker = this._createGeminiWorker();
541
- job.worker = worker;
542
- const prompt = this._buildGeminiReplyPrompt(conversation, reply);
543
-
544
- try {
545
- const result = await worker.execute(prompt, {
546
- cwd: job.requestArgs.cwd || this.cwd,
547
- timeoutMs: resolveTimeoutMs(job.agentType, job.requestArgs.timeoutMs),
548
- model: job.requestArgs.model || resolveGeminiModel(job.agentType),
549
- approvalMode: 'yolo',
550
- allowedMcpServerNames: getGeminiAllowedServers(this._getMcpPolicyOptions(job.requestArgs)),
551
- });
552
-
553
- conversation.turns.push({
554
- user: reply,
555
- assistant: result.output,
556
- });
557
- conversation.updatedAt = new Date().toISOString();
558
- conversation.closed = Boolean(done);
559
- if (done) {
560
- this.geminiConversations.delete(job_id);
561
- }
562
-
563
- this._applyJobResult(job, {
564
- ok: result.exitCode === 0,
565
- status: result.exitCode === 0 ? 'completed' : 'failed',
566
- providerRequested: 'gemini',
567
- providerResolved: 'gemini',
568
- agentType: job.agentType,
569
- transport: 'gemini-worker',
570
- exitCode: result.exitCode,
571
- output: result.output,
572
- sessionKey: result.sessionKey || job.sessionKey || null,
573
- });
574
- await emitProgress(extra, DIRECT_PROGRESS_DONE, 100, `job ${job_id} 후속 응답이 완료되었습니다.`);
575
- return this._serializeJob(job);
576
- } catch (error) {
577
- const message = error instanceof Error ? error.message : String(error);
578
- this._applyJobResult(job, createErrorPayload(message, {
579
- mode: job.mode,
580
- providerRequested: 'gemini',
581
- providerResolved: 'gemini',
582
- agentType: job.agentType,
583
- transport: 'gemini-worker',
584
- }));
585
- return this._serializeJob(job);
586
- } finally {
587
- await worker.stop().catch(() => {});
588
- job.worker = null;
589
- }
590
- }
591
-
592
- _createGeminiWorker() {
593
- return new GeminiWorker({
594
- command: this.geminiCommand,
595
- commandArgs: this.geminiCommandArgs,
596
- cwd: this.cwd,
597
- env: this.env,
598
- });
599
- }
600
-
601
- _buildDirectPrompt(args) {
602
- return withContext(String(args.prompt ?? ''), args.contextFile);
603
- }
604
-
605
- _buildDirectPromptWithHint(args) {
606
- return withPromptHint(String(args.prompt ?? ''), {
607
- agentType: args.agentType || 'executor',
608
- mcpProfile: args.mcpProfile || 'auto',
609
- searchTool: args.searchTool,
610
- workerIndex: Number.isInteger(args.workerIndex) ? args.workerIndex : undefined,
611
- contextFile: args.contextFile,
612
- });
613
- }
614
-
615
- _buildGeminiReplyPrompt(conversation, reply) {
616
- const transcript = formatConversationTranscript(conversation.turns);
617
- return [
618
- 'Continue the conversation using the prior transcript below.',
619
- '',
620
- '<conversation_history>',
621
- transcript,
622
- '</conversation_history>',
623
- '',
624
- '<latest_user_reply>',
625
- reply,
626
- '</latest_user_reply>',
627
- ].join('\n');
628
- }
629
-
630
- _getMcpPolicyOptions(args) {
631
- return {
632
- agentType: args.agentType || 'executor',
633
- requestedProfile: args.mcpProfile || 'auto',
634
- searchTool: args.searchTool,
635
- workerIndex: Number.isInteger(args.workerIndex) ? args.workerIndex : undefined,
636
- taskText: withContext(String(args.prompt ?? ''), args.contextFile),
637
- };
638
- }
639
-
640
- _buildPromptHintInstruction(args) {
641
- return buildPromptHint(this._getMcpPolicyOptions(args));
642
- }
643
-
644
- _shouldUseRoute(args) {
645
- return args.provider === 'auto' || isTeamRouteRequested(args);
646
- }
647
-
648
- async _executeDirect(args, extra = null) {
649
- await emitProgress(extra, DIRECT_PROGRESS_START, 100, '위임 실행을 시작합니다.');
650
-
651
- const runViaRoute = this._shouldUseRoute(args);
652
-
653
- try {
654
- const result = runViaRoute
655
- ? await this._executeRoute(args, extra)
656
- : await this._executeWorker(args, extra);
657
-
658
- await emitProgress(extra, DIRECT_PROGRESS_DONE, 100, '위임이 완료되었습니다.');
659
- return result;
660
- } catch (error) {
661
- const message = error instanceof Error ? error.message : String(error);
662
- return createErrorPayload(message, {
663
- mode: 'sync',
664
- providerRequested: args.provider,
665
- agentType: args.agentType,
666
- transport: runViaRoute ? 'route-script' : `${args.provider}-worker`,
667
- });
668
- }
669
- }
670
-
671
- async _executeWorker(args, extra) {
672
- await emitProgress(extra, DIRECT_PROGRESS_RUNNING, 100, '직접 워커 경로로 실행 중입니다.');
673
-
674
- if (args.provider === 'codex') {
675
- const result = await this.codexWorker.execute(this._buildDirectPrompt(args), {
676
- cwd: args.cwd || this.cwd,
677
- timeoutMs: resolveTimeoutMs(args.agentType, args.timeoutMs),
678
- sessionKey: args.sessionKey,
679
- threadId: args.threadId,
680
- resetSession: args.resetSession,
681
- profile: resolveCodexProfile(args.agentType),
682
- sandbox: 'danger-full-access',
683
- approvalPolicy: 'never',
684
- developerInstructions: joinInstructions(
685
- REVIEW_INSTRUCTION_BY_AGENT[args.agentType],
686
- this._buildPromptHintInstruction(args),
687
- args.developerInstructions,
688
- ),
689
- config: getCodexMcpConfig(this._getMcpPolicyOptions(args)),
690
- compactPrompt: args.compactPrompt,
691
- model: args.model,
692
- });
693
-
694
- return {
695
- ok: result.exitCode === 0,
696
- mode: 'sync',
697
- status: result.exitCode === 0 ? 'completed' : 'failed',
698
- providerRequested: 'codex',
699
- providerResolved: 'codex',
700
- agentType: args.agentType,
701
- transport: 'codex-mcp',
702
- exitCode: result.exitCode,
703
- output: result.output,
704
- sessionKey: result.sessionKey,
705
- threadId: result.threadId,
706
- };
707
- }
708
-
709
- if (args.provider === 'gemini') {
710
- const worker = this._createGeminiWorker();
711
- const prompt = this._buildDirectPromptWithHint(args);
712
- try {
713
- const result = await worker.execute(prompt, {
714
- cwd: args.cwd || this.cwd,
715
- timeoutMs: resolveTimeoutMs(args.agentType, args.timeoutMs),
716
- model: args.model || resolveGeminiModel(args.agentType),
717
- approvalMode: 'yolo',
718
- allowedMcpServerNames: getGeminiAllowedServers(this._getMcpPolicyOptions(args)),
719
- });
720
-
721
- return {
722
- ok: result.exitCode === 0,
723
- mode: 'sync',
724
- status: result.exitCode === 0 ? 'completed' : 'failed',
725
- providerRequested: 'gemini',
726
- providerResolved: 'gemini',
727
- agentType: args.agentType,
728
- transport: 'gemini-worker',
729
- exitCode: result.exitCode,
730
- output: result.output,
731
- sessionKey: result.sessionKey,
732
- _geminiPrompt: prompt,
733
- };
734
- } finally {
735
- await worker.stop().catch(() => {});
736
- }
737
- }
738
-
739
- return createErrorPayload(`지원하지 않는 direct provider: ${args.provider}`, {
740
- mode: 'sync',
741
- providerRequested: args.provider,
742
- agentType: args.agentType,
743
- transport: 'direct-worker',
744
- });
745
- }
746
-
747
- async _executeRoute(args, extra) {
748
- if (!this.routeScript) {
749
- return createErrorPayload('tfx-route.sh 경로를 찾지 못했습니다.', {
750
- mode: 'sync',
751
- providerRequested: args.provider,
752
- agentType: args.agentType,
753
- transport: 'route-script',
754
- });
755
- }
756
-
757
- await emitProgress(extra, DIRECT_PROGRESS_RUNNING, 100, 'tfx-route.sh 경로로 실행 중입니다.');
758
- const result = await this._spawnRoute(args);
759
- return {
760
- ok: result.exitCode === 0,
761
- mode: 'sync',
762
- status: result.exitCode === 0 ? 'completed' : 'failed',
763
- providerRequested: args.provider,
764
- providerResolved: parseRouteType(result.stderr) || args.provider,
765
- agentType: args.agentType,
766
- transport: 'route-script',
767
- exitCode: result.exitCode,
768
- output: result.stdout.trim() || result.stderr.trim(),
769
- stderr: result.stderr.trim(),
770
- };
771
- }
772
-
773
- async _startAsyncJob(args, extra) {
774
- const job = this._createJob(args, 'async');
775
- this.jobs.set(job.jobId, job);
776
-
777
- await emitProgress(extra, DIRECT_PROGRESS_START, 100, `비동기 job ${job.jobId}를 시작합니다.`);
778
-
779
- void (async () => {
780
- try {
781
- const result = this._shouldUseRoute(args)
782
- ? await this._spawnRoute(args, job)
783
- : await this._runAsyncWorker(args, job);
784
- this._applyJobResult(job, result);
785
- } catch (error) {
786
- this._applyJobResult(job, createErrorPayload(
787
- error instanceof Error ? error.message : String(error),
788
- {
789
- mode: 'async',
790
- providerRequested: args.provider,
791
- agentType: args.agentType,
792
- transport: this._shouldUseRoute(args) ? 'route-script' : `${args.provider}-worker`,
793
- },
794
- ));
795
- } finally {
796
- if (job.worker) {
797
- await job.worker.stop().catch(() => {});
798
- job.worker = null;
799
- }
800
- job.child = null;
801
- }
802
- })();
803
-
804
- return this._serializeJob(job);
805
- }
806
-
807
- async _runAsyncWorker(args, job) {
808
- if (args.provider === 'codex') {
809
- const result = await this.codexWorker.execute(this._buildDirectPrompt(args), {
810
- cwd: args.cwd || this.cwd,
811
- timeoutMs: resolveTimeoutMs(args.agentType, args.timeoutMs),
812
- sessionKey: args.sessionKey,
813
- threadId: args.threadId,
814
- resetSession: args.resetSession,
815
- profile: resolveCodexProfile(args.agentType),
816
- sandbox: 'danger-full-access',
817
- approvalPolicy: 'never',
818
- developerInstructions: joinInstructions(
819
- REVIEW_INSTRUCTION_BY_AGENT[args.agentType],
820
- this._buildPromptHintInstruction(args),
821
- args.developerInstructions,
822
- ),
823
- config: getCodexMcpConfig(this._getMcpPolicyOptions(args)),
824
- compactPrompt: args.compactPrompt,
825
- model: args.model,
826
- });
827
-
828
- return {
829
- ok: result.exitCode === 0,
830
- providerResolved: 'codex',
831
- output: result.output,
832
- exitCode: result.exitCode,
833
- threadId: result.threadId,
834
- sessionKey: result.sessionKey,
835
- };
836
- }
837
-
838
- if (args.provider === 'gemini') {
839
- const worker = this._createGeminiWorker();
840
- job.worker = worker;
841
- const prompt = this._buildDirectPromptWithHint(args);
842
- const result = await worker.execute(prompt, {
843
- cwd: args.cwd || this.cwd,
844
- timeoutMs: resolveTimeoutMs(args.agentType, args.timeoutMs),
845
- model: args.model || resolveGeminiModel(args.agentType),
846
- approvalMode: 'yolo',
847
- allowedMcpServerNames: getGeminiAllowedServers(this._getMcpPolicyOptions(args)),
848
- });
849
-
850
- return {
851
- ok: result.exitCode === 0,
852
- providerResolved: 'gemini',
853
- output: result.output,
854
- exitCode: result.exitCode,
855
- sessionKey: result.sessionKey,
856
- _geminiPrompt: prompt,
857
- };
858
- }
859
-
860
- throw new Error(`지원하지 않는 async provider: ${args.provider}`);
861
- }
862
-
863
- _buildRouteEnv(args) {
864
- const env = cloneEnv(this.env);
865
- env.TFX_CLI_MODE = pickRouteMode(args.provider);
866
-
867
- if (args.codexTransport) {
868
- env.TFX_CODEX_TRANSPORT = args.codexTransport;
869
- }
870
- if (args.noClaudeNative === true) {
871
- env.TFX_NO_CLAUDE_NATIVE = '1';
872
- }
873
- if (args.searchTool) {
874
- env.TFX_SEARCH_TOOL = args.searchTool;
875
- }
876
- if (Number.isInteger(args.workerIndex) && args.workerIndex > 0) {
877
- env.TFX_WORKER_INDEX = String(args.workerIndex);
878
- }
879
- if (args.teamName) env.TFX_TEAM_NAME = args.teamName;
880
- if (args.teamTaskId) env.TFX_TEAM_TASK_ID = args.teamTaskId;
881
- if (args.teamAgentName) env.TFX_TEAM_AGENT_NAME = args.teamAgentName;
882
- if (args.teamLeadName) env.TFX_TEAM_LEAD_NAME = args.teamLeadName;
883
- if (args.hubUrl) env.TFX_HUB_URL = args.hubUrl;
884
-
885
- return env;
886
- }
887
-
888
- async _spawnRoute(args, job = null) {
889
- const prompt = withContext(String(args.prompt ?? ''), args.contextFile);
890
- const childArgs = [
891
- this.routeScript,
892
- args.agentType || 'executor',
893
- prompt,
894
- args.mcpProfile || 'auto',
895
- String(resolveTimeoutSec(args.agentType, args.timeoutMs)),
896
- ];
897
-
898
- const child = spawn(this.bashCommand, childArgs, {
899
- cwd: args.cwd || this.cwd,
900
- env: this._buildRouteEnv(args),
901
- stdio: ['ignore', 'pipe', 'pipe'],
902
- windowsHide: true,
903
- });
904
-
905
- if (job) {
906
- job.child = child;
907
- }
908
-
909
- this.routeChildren.add(child);
910
-
911
- return await new Promise((resolvePromise, rejectPromise) => {
912
- const stdoutChunks = [];
913
- const stderrChunks = [];
914
-
915
- child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
916
- child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
917
- child.once('error', (error) => {
918
- this.routeChildren.delete(child);
919
- rejectPromise(error);
920
- });
921
- child.once('close', (code) => {
922
- this.routeChildren.delete(child);
923
- const stdout = Buffer.concat(stdoutChunks).toString('utf8');
924
- const stderr = Buffer.concat(stderrChunks).toString('utf8');
925
- resolvePromise({
926
- ok: code === 0,
927
- providerResolved: parseRouteType(stderr) || args.provider,
928
- output: stdout.trim() || stderr.trim(),
929
- stdout,
930
- stderr,
931
- exitCode: code ?? 1,
932
- });
933
- });
934
- });
935
- }
936
-
937
- _serializeJob(job) {
938
- return {
939
- ok: job.ok,
940
- jobId: job.jobId,
941
- job_id: job.jobId,
942
- mode: job.mode || 'async',
943
- status: job.status,
944
- providerRequested: job.providerRequested,
945
- providerResolved: job.providerResolved,
946
- agentType: job.agentType,
947
- transport: job.transport,
948
- createdAt: job.createdAt,
949
- startedAt: job.startedAt,
950
- updatedAt: job.updatedAt,
951
- completedAt: job.completedAt,
952
- exitCode: job.exitCode,
953
- output: job.output,
954
- stderr: job.stderr,
955
- threadId: job.threadId,
956
- sessionKey: job.sessionKey,
957
- conversationOpen: this.geminiConversations.has(job.jobId),
958
- };
959
- }
960
-
961
- _createJob(args, mode) {
962
- const jobId = randomUUID();
963
- const now = new Date().toISOString();
964
- return {
965
- ok: true,
966
- jobId,
967
- mode,
968
- status: 'running',
969
- providerRequested: args.provider,
970
- providerResolved: null,
971
- agentType: args.agentType,
972
- transport: this._shouldUseRoute(args) ? 'route-script' : `${args.provider}-worker`,
973
- createdAt: now,
974
- startedAt: now,
975
- updatedAt: now,
976
- completedAt: null,
977
- output: '',
978
- stderr: '',
979
- exitCode: null,
980
- threadId: null,
981
- sessionKey: args.sessionKey || null,
982
- worker: null,
983
- child: null,
984
- requestArgs: sanitizeDelegateArgs(args),
985
- };
986
- }
987
-
988
- _applyJobResult(job, result = {}) {
989
- job.ok = result.ok !== false;
990
- job.status = job.ok ? 'completed' : 'failed';
991
- job.providerResolved = result.providerResolved || job.providerRequested;
992
- job.transport = result.transport || job.transport;
993
- job.output = result.output || '';
994
- job.stderr = result.stderr || result.error || '';
995
- job.exitCode = result.exitCode ?? (job.ok ? 0 : 1);
996
- job.threadId = result.threadId || job.threadId || null;
997
- job.sessionKey = result.sessionKey || job.sessionKey || null;
998
- job.completedAt = new Date().toISOString();
999
- job.updatedAt = job.completedAt;
1000
-
1001
- if (job.providerRequested === 'gemini'
1002
- && job.transport === 'gemini-worker'
1003
- && typeof result._geminiPrompt === 'string') {
1004
- this._storeGeminiConversation(job, result._geminiPrompt, result.output || '');
1005
- }
1006
- }
1007
-
1008
- _storeGeminiConversation(job, userPrompt, assistantReply) {
1009
- const existing = this.geminiConversations.get(job.jobId);
1010
- if (existing) {
1011
- if (typeof assistantReply === 'string') {
1012
- const lastTurn = existing.turns.at(-1);
1013
- if (lastTurn && lastTurn.assistant !== assistantReply) {
1014
- lastTurn.assistant = assistantReply;
1015
- }
1016
- }
1017
- existing.updatedAt = new Date().toISOString();
1018
- return;
1019
- }
1020
-
1021
- this.geminiConversations.set(job.jobId, {
1022
- jobId: job.jobId,
1023
- closed: false,
1024
- updatedAt: new Date().toISOString(),
1025
- turns: [{
1026
- user: userPrompt,
1027
- assistant: assistantReply,
1028
- }],
1029
- });
1030
- }
1031
-
1032
- async _runSyncJob(args, extra) {
1033
- const job = this._createJob(args, 'sync');
1034
- this.jobs.set(job.jobId, job);
1035
- const result = await this._executeDirect(args, extra);
1036
- this._applyJobResult(job, result);
1037
- return this._serializeJob(job);
1038
- }
1039
- }
1040
-
1041
- export function createDelegatorMcpWorker(options = {}) {
1042
- return new DelegatorMcpWorker(options);
1043
- }
1044
-
1045
- export async function runDelegatorMcpCli() {
1046
- const worker = createDelegatorMcpWorker();
1047
- try {
1048
- await worker.serveStdio();
1049
- } catch (error) {
1050
- console.error(`[delegator-mcp] ${error instanceof Error ? error.message : String(error)}`);
1051
- process.exitCode = 1;
1052
- }
1053
- }
1054
-
1055
- if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
1056
- await runDelegatorMcpCli();
1057
- }
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, pathToFileURL } 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
+
20
+ // mcp-filter.mjs 동적 해석 — 프로젝트(hub/workers/)와 배포(scripts/hub/workers/) 양쪽 대응
21
+ const MCP_FILTER_CANDIDATES = [
22
+ resolve(SCRIPT_DIR, '../../scripts/lib/mcp-filter.mjs'), // 프로젝트 원본
23
+ resolve(SCRIPT_DIR, '../../lib/mcp-filter.mjs'), // 배포 (~/.claude/scripts/)
24
+ ];
25
+ const mcpFilterPath = MCP_FILTER_CANDIDATES.find((p) => existsSync(p));
26
+ if (!mcpFilterPath) {
27
+ throw new Error(`mcp-filter.mjs not found. candidates: ${MCP_FILTER_CANDIDATES.join(', ')}`);
28
+ }
29
+ const {
30
+ buildPromptHint,
31
+ getCodexMcpConfig,
32
+ getGeminiAllowedServers,
33
+ resolveMcpProfile,
34
+ SUPPORTED_MCP_PROFILES,
35
+ } = await import(pathToFileURL(mcpFilterPath).href);
36
+ const SERVER_INFO = { name: 'triflux-delegator', version: '1.0.0' };
37
+ const DEFAULT_CONTEXT_BYTES = 32 * 1024;
38
+ const DEFAULT_ROUTE_TIMEOUT_SEC = 120;
39
+ const DIRECT_PROGRESS_START = 5;
40
+ const DIRECT_PROGRESS_RUNNING = 60;
41
+ const DIRECT_PROGRESS_DONE = 100;
42
+
43
+ const AGENT_TIMEOUT_SEC = Object.freeze({
44
+ executor: 1080,
45
+ 'build-fixer': 540,
46
+ debugger: 900,
47
+ 'deep-executor': 3600,
48
+ architect: 3600,
49
+ planner: 3600,
50
+ critic: 3600,
51
+ analyst: 3600,
52
+ 'code-reviewer': 1800,
53
+ 'security-reviewer': 1800,
54
+ 'quality-reviewer': 1800,
55
+ scientist: 1440,
56
+ 'scientist-deep': 3600,
57
+ 'document-specialist': 1440,
58
+ designer: 900,
59
+ writer: 900,
60
+ explore: 300,
61
+ verifier: 1200,
62
+ 'test-engineer': 300,
63
+ 'qa-tester': 300,
64
+ spark: 180,
65
+ });
66
+
67
+ const CODEX_PROFILE_BY_AGENT = Object.freeze({
68
+ executor: 'high',
69
+ 'build-fixer': 'fast',
70
+ debugger: 'high',
71
+ 'deep-executor': 'xhigh',
72
+ architect: 'xhigh',
73
+ planner: 'xhigh',
74
+ critic: 'xhigh',
75
+ analyst: 'xhigh',
76
+ 'code-reviewer': 'thorough',
77
+ 'security-reviewer': 'thorough',
78
+ 'quality-reviewer': 'thorough',
79
+ scientist: 'high',
80
+ 'scientist-deep': 'thorough',
81
+ 'document-specialist': 'high',
82
+ verifier: 'thorough',
83
+ designer: 'high', // Gemini primary, codex fallback용
84
+ writer: 'high', // Gemini primary, codex fallback용
85
+ spark: 'spark_fast',
86
+ });
87
+
88
+ const GEMINI_MODEL_BY_AGENT = Object.freeze({
89
+ 'build-fixer': 'gemini-3-flash-preview',
90
+ writer: 'gemini-3-flash-preview',
91
+ spark: 'gemini-3-flash-preview',
92
+ });
93
+
94
+ const REVIEW_INSTRUCTION_BY_AGENT = Object.freeze({
95
+ 'code-reviewer': '코드 리뷰 모드로 동작하라. 버그, 리스크, 회귀, 테스트 누락을 우선 식별하라.',
96
+ 'security-reviewer': '보안 리뷰 모드로 동작하라. 취약점, 권한 경계, 비밀정보 노출 가능성을 우선 식별하라.',
97
+ 'quality-reviewer': '품질 리뷰 모드로 동작하라. 로직 결함, 유지보수성 저하, 테스트 누락을 우선 식별하라.',
98
+ });
99
+
100
+ function cloneEnv(env = process.env) {
101
+ return Object.fromEntries(
102
+ Object.entries(env).filter(([, value]) => typeof value === 'string'),
103
+ );
104
+ }
105
+
106
+ function parseJsonArray(raw, fallback = []) {
107
+ if (!raw) return [...fallback];
108
+ try {
109
+ const parsed = JSON.parse(raw);
110
+ return Array.isArray(parsed)
111
+ ? parsed.map((item) => String(item ?? '')).filter(Boolean)
112
+ : [...fallback];
113
+ } catch {
114
+ return [...fallback];
115
+ }
116
+ }
117
+
118
+ function resolveCandidatePath(candidate, cwd = process.cwd()) {
119
+ if (!candidate) return null;
120
+ const normalized = isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
121
+ return existsSync(normalized) ? normalized : null;
122
+ }
123
+
124
+ function resolveRouteScript(explicitPath, cwd = process.cwd()) {
125
+ const candidates = [
126
+ explicitPath,
127
+ process.env.TFX_DELEGATOR_ROUTE_SCRIPT,
128
+ process.env.TFX_ROUTE_SCRIPT,
129
+ resolve(SCRIPT_DIR, '..', '..', 'scripts', 'tfx-route.sh'),
130
+ resolve(cwd, 'scripts', 'tfx-route.sh'),
131
+ ];
132
+
133
+ for (const candidate of candidates) {
134
+ const resolved = resolveCandidatePath(candidate, cwd);
135
+ if (resolved) return resolved;
136
+ }
137
+
138
+ return null;
139
+ }
140
+
141
+ function resolveCodexProfile(agentType) {
142
+ return CODEX_PROFILE_BY_AGENT[agentType] || 'high';
143
+ }
144
+
145
+ function resolveGeminiModel(agentType) {
146
+ return GEMINI_MODEL_BY_AGENT[agentType] || 'gemini-3.1-pro-preview';
147
+ }
148
+
149
+ function resolveTimeoutMs(agentType, timeoutMs) {
150
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
151
+ return Math.trunc(timeoutMs);
152
+ }
153
+
154
+ const timeoutSec = AGENT_TIMEOUT_SEC[agentType] || DEFAULT_ROUTE_TIMEOUT_SEC;
155
+ return timeoutSec * 1000;
156
+ }
157
+
158
+ function resolveTimeoutSec(agentType, timeoutMs) {
159
+ const resolved = resolveTimeoutMs(agentType, timeoutMs);
160
+ return Math.max(1, Math.ceil(resolved / 1000));
161
+ }
162
+
163
+ function loadContextFromFile(contextFile) {
164
+ if (!contextFile) return '';
165
+ const resolved = resolveCandidatePath(contextFile);
166
+ if (!resolved) return '';
167
+ try {
168
+ return readFileSync(resolved, 'utf8').slice(0, DEFAULT_CONTEXT_BYTES);
169
+ } catch {
170
+ return '';
171
+ }
172
+ }
173
+
174
+ function withContext(prompt, contextFile) {
175
+ const context = loadContextFromFile(contextFile);
176
+ if (!context) return prompt;
177
+ return `${prompt}\n\n<prior_context>\n${context}\n</prior_context>`;
178
+ }
179
+
180
+ function withPromptHint(prompt, args) {
181
+ const promptWithContext = withContext(prompt, args.contextFile);
182
+ const hint = buildPromptHint({
183
+ agentType: args.agentType,
184
+ requestedProfile: args.mcpProfile,
185
+ searchTool: args.searchTool,
186
+ workerIndex: Number.isInteger(args.workerIndex) ? args.workerIndex : undefined,
187
+ taskText: promptWithContext,
188
+ });
189
+ if (!hint) return promptWithContext;
190
+ return `${promptWithContext}. ${hint}`;
191
+ }
192
+
193
+ function joinInstructions(...values) {
194
+ return values
195
+ .filter((value) => typeof value === 'string' && value.trim())
196
+ .join('\n')
197
+ .trim();
198
+ }
199
+
200
+ function parseRouteType(stderr = '') {
201
+ const match = stderr.match(/type=([a-z-]+)/);
202
+ if (!match) return null;
203
+ if (match[1] === 'codex') return 'codex';
204
+ if (match[1] === 'gemini') return 'gemini';
205
+ return match[1];
206
+ }
207
+
208
+ function summarizePayload(payload) {
209
+ if (typeof payload.output === 'string' && payload.output.trim()) return payload.output.trim();
210
+ if (payload.mode === 'async' && payload.job_id) return `비동기 위임이 시작되었습니다. jobId=${payload.job_id}`;
211
+ if (payload.job_id) return `jobId=${payload.job_id} 상태=${payload.status}`;
212
+ if (payload.status) return `상태=${payload.status}`;
213
+ return payload.ok ? '완료되었습니다.' : '실패했습니다.';
214
+ }
215
+
216
+ function createToolResponse(payload, { isError = false } = {}) {
217
+ return {
218
+ content: [{ type: 'text', text: summarizePayload(payload) }],
219
+ structuredContent: payload,
220
+ isError,
221
+ };
222
+ }
223
+
224
+ function createErrorPayload(message, extras = {}) {
225
+ return {
226
+ ok: false,
227
+ status: 'failed',
228
+ error: message,
229
+ ...extras,
230
+ };
231
+ }
232
+
233
+ const DelegateInputSchema = z.object({
234
+ prompt: z.string().min(1).describe('위임할 프롬프트'),
235
+ provider: z.enum(['auto', 'codex', 'gemini']).default('auto').describe('사용할 provider'),
236
+ mode: z.enum(['sync', 'async']).default('sync').describe('동기 또는 비동기 실행'),
237
+ agentType: z.string().default('executor').describe('tfx-route 역할명 또는 direct 실행 역할'),
238
+ cwd: z.string().optional().describe('작업 디렉터리'),
239
+ timeoutMs: z.number().int().positive().optional().describe('요청 타임아웃(ms)'),
240
+ sessionKey: z.string().optional().describe('Codex warm session 재사용 키'),
241
+ resetSession: z.boolean().optional().describe('기존 Codex 세션 초기화 여부'),
242
+ mcpProfile: z.enum(SUPPORTED_MCP_PROFILES).default('auto'),
243
+ contextFile: z.string().optional().describe('tfx-route prior_context 파일 경로'),
244
+ availableServers: z.array(z.string()).optional().describe('Codex에 등록된 MCP 서버 이름 목록 (config override 대상)'),
245
+ searchTool: z.enum(['brave-search', 'tavily', 'exa']).optional().describe('검색 우선 도구'),
246
+ workerIndex: z.number().int().positive().optional().describe('병렬 워커 인덱스'),
247
+ model: z.string().optional().describe('직접 실행 시 모델 오버라이드'),
248
+ developerInstructions: z.string().optional().describe('직접 실행 시 추가 개발자 지침'),
249
+ compactPrompt: z.string().optional().describe('Codex compact prompt'),
250
+ threadId: z.string().optional().describe('Codex 직접 실행 시 기존 threadId'),
251
+ codexTransport: z.enum(['auto', 'mcp', 'exec']).optional().describe('route 경로용 Codex transport'),
252
+ noClaudeNative: z.boolean().optional().describe('route 경로용 TFX_NO_CLAUDE_NATIVE'),
253
+ teamName: z.string().optional().describe('TFX_TEAM_NAME'),
254
+ teamTaskId: z.string().optional().describe('TFX_TEAM_TASK_ID'),
255
+ teamAgentName: z.string().optional().describe('TFX_TEAM_AGENT_NAME'),
256
+ teamLeadName: z.string().optional().describe('TFX_TEAM_LEAD_NAME'),
257
+ hubUrl: z.string().optional().describe('TFX_HUB_URL'),
258
+ });
259
+
260
+ const DelegateStatusInputSchema = z.object({
261
+ jobId: z.string().min(1).describe('조회할 비동기 job ID'),
262
+ });
263
+
264
+ const DelegateReplyInputSchema = z.object({
265
+ job_id: z.string().min(1).describe('후속 응답을 보낼 기존 delegate job ID'),
266
+ reply: z.string().min(1).describe('후속 사용자 응답'),
267
+ done: z.boolean().default(false).describe('true이면 응답 처리 후 대화를 종료'),
268
+ });
269
+
270
+ const DelegateOutputSchema = z.object({
271
+ ok: z.boolean(),
272
+ jobId: z.string().optional(),
273
+ job_id: z.string().optional(),
274
+ mode: z.enum(['sync', 'async']).optional(),
275
+ status: z.enum(['running', 'completed', 'failed']).optional(),
276
+ error: z.string().optional(),
277
+ providerRequested: z.string().optional(),
278
+ providerResolved: z.string().nullable().optional(),
279
+ agentType: z.string().optional(),
280
+ transport: z.string().optional(),
281
+ createdAt: z.string().optional(),
282
+ startedAt: z.string().optional(),
283
+ updatedAt: z.string().optional(),
284
+ completedAt: z.string().nullable().optional(),
285
+ exitCode: z.number().nullable().optional(),
286
+ output: z.string().optional(),
287
+ stderr: z.string().optional(),
288
+ threadId: z.string().nullable().optional(),
289
+ sessionKey: z.string().nullable().optional(),
290
+ conversationOpen: z.boolean().optional(),
291
+ });
292
+
293
+ function isTeamRouteRequested(args) {
294
+ return Boolean(
295
+ args.teamName
296
+ || args.teamTaskId
297
+ || args.teamAgentName
298
+ || args.teamLeadName
299
+ || args.hubUrl
300
+ );
301
+ }
302
+
303
+ function pickRouteMode(provider) {
304
+ return provider === 'auto' ? 'auto' : provider;
305
+ }
306
+
307
+ function sanitizeDelegateArgs(args = {}) {
308
+ return {
309
+ provider: args.provider || 'auto',
310
+ agentType: args.agentType || 'executor',
311
+ cwd: args.cwd || null,
312
+ timeoutMs: Number.isFinite(Number(args.timeoutMs)) ? Math.trunc(Number(args.timeoutMs)) : null,
313
+ sessionKey: args.sessionKey || null,
314
+ resetSession: Boolean(args.resetSession),
315
+ mcpProfile: args.mcpProfile || 'auto',
316
+ contextFile: args.contextFile || null,
317
+ availableServers: Array.isArray(args.availableServers) ? args.availableServers : null,
318
+ searchTool: args.searchTool || null,
319
+ workerIndex: Number.isInteger(args.workerIndex) ? args.workerIndex : null,
320
+ model: args.model || null,
321
+ developerInstructions: args.developerInstructions || null,
322
+ compactPrompt: args.compactPrompt || null,
323
+ threadId: args.threadId || null,
324
+ codexTransport: args.codexTransport || null,
325
+ noClaudeNative: args.noClaudeNative === true,
326
+ teamName: args.teamName || null,
327
+ teamTaskId: args.teamTaskId || null,
328
+ teamAgentName: args.teamAgentName || null,
329
+ teamLeadName: args.teamLeadName || null,
330
+ hubUrl: args.hubUrl || null,
331
+ };
332
+ }
333
+
334
+ function formatConversationTranscript(turns = []) {
335
+ return turns.map((turn, index) => {
336
+ const parts = [
337
+ `Turn ${index + 1} user:\n${turn.user}`,
338
+ ];
339
+ if (typeof turn.assistant === 'string' && turn.assistant.trim()) {
340
+ parts.push(`Turn ${index + 1} assistant:\n${turn.assistant}`);
341
+ }
342
+ return parts.join('\n\n');
343
+ }).join('\n\n');
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.geminiConversations = new Map();
393
+ this.routeChildren = new Set();
394
+ this.ready = false;
395
+ }
396
+
397
+ isReady() {
398
+ return this.ready;
399
+ }
400
+
401
+ async start() {
402
+ if (this.server) {
403
+ this.ready = true;
404
+ return;
405
+ }
406
+
407
+ const server = new McpServer(SERVER_INFO, {
408
+ capabilities: { logging: {} },
409
+ });
410
+
411
+ server.registerTool('triflux-delegate', {
412
+ description: '새 위임을 실행합니다. codex/gemini direct 경로와 tfx-route 기반 auto 라우팅을 모두 지원합니다.',
413
+ inputSchema: DelegateInputSchema,
414
+ outputSchema: DelegateOutputSchema,
415
+ }, async (args, extra) => {
416
+ const payload = await this.delegate(args, extra);
417
+ return createToolResponse(payload, { isError: payload.ok === false && payload.mode !== 'async' });
418
+ });
419
+
420
+ server.registerTool('triflux-delegate-status', {
421
+ description: '비동기 위임 job 상태를 조회합니다.',
422
+ inputSchema: DelegateStatusInputSchema,
423
+ outputSchema: DelegateOutputSchema,
424
+ }, async ({ jobId }, extra) => {
425
+ const payload = await this.getJobStatus(jobId, extra);
426
+ return createToolResponse(payload, { isError: payload.ok === false });
427
+ });
428
+
429
+ server.registerTool('triflux-delegate-reply', {
430
+ description: '기존 delegate job에 후속 응답을 보내고, Gemini direct job이면 multi-turn 대화를 이어갑니다.',
431
+ inputSchema: DelegateReplyInputSchema,
432
+ outputSchema: DelegateOutputSchema,
433
+ }, async (args, extra) => {
434
+ const payload = await this.reply(args, extra);
435
+ return createToolResponse(payload, { isError: payload.ok === false });
436
+ });
437
+
438
+ this.server = server;
439
+ this.ready = true;
440
+ }
441
+
442
+ async serveStdio() {
443
+ await this.start();
444
+ if (this.transport) return;
445
+ const transport = new StdioServerTransport();
446
+ await this.server.connect(transport);
447
+ this.transport = transport;
448
+ }
449
+
450
+ async stop() {
451
+ this.ready = false;
452
+
453
+ for (const child of this.routeChildren) {
454
+ try { child.kill(); } catch {}
455
+ }
456
+ this.routeChildren.clear();
457
+
458
+ await this.codexWorker.stop().catch(() => {});
459
+
460
+ for (const job of this.jobs.values()) {
461
+ if (job.worker) {
462
+ await job.worker.stop().catch(() => {});
463
+ job.worker = null;
464
+ }
465
+ }
466
+ this.geminiConversations.clear();
467
+
468
+ if (this.server) {
469
+ await this.server.close().catch(() => {});
470
+ }
471
+
472
+ this.server = null;
473
+ this.transport = null;
474
+ }
475
+
476
+ async run(prompt, options = {}) {
477
+ return this._executeDirect({ prompt, ...options });
478
+ }
479
+
480
+ async execute(prompt, options = {}) {
481
+ const result = await this._executeDirect({ prompt, ...options });
482
+ return {
483
+ output: result.output || result.error || '',
484
+ exitCode: result.exitCode ?? (result.ok ? 0 : 1),
485
+ threadId: result.threadId || null,
486
+ sessionKey: result.sessionKey || null,
487
+ raw: result,
488
+ };
489
+ }
490
+
491
+ async delegate(args, extra) {
492
+ if (args.mode === 'async') {
493
+ return this._startAsyncJob(args, extra);
494
+ }
495
+ return this._runSyncJob(args, extra);
496
+ }
497
+
498
+ async getJobStatus(jobId, extra) {
499
+ const job = this.jobs.get(jobId);
500
+ if (!job) {
501
+ return createErrorPayload(`알 수 없는 jobId: ${jobId}`, { jobId });
502
+ }
503
+
504
+ const payload = this._serializeJob(job);
505
+ if (job.status === 'running') {
506
+ await emitProgress(extra, 25, 100, `job ${jobId} 실행 중`);
507
+ } else if (job.status === 'completed') {
508
+ await emitProgress(extra, DIRECT_PROGRESS_DONE, 100, `job ${jobId} 완료`);
509
+ } else if (job.status === 'failed') {
510
+ await emitProgress(extra, DIRECT_PROGRESS_DONE, 100, `job ${jobId} 실패`);
511
+ }
512
+ return payload;
513
+ }
514
+
515
+ async reply({ job_id, reply, done = false }, extra) {
516
+ const job = this.jobs.get(job_id);
517
+ if (!job) {
518
+ return createErrorPayload(`알 없는 jobId: ${job_id}`, { jobId: job_id, job_id });
519
+ }
520
+ if (job.status === 'running') {
521
+ return createErrorPayload(`job ${job_id}가 아직 실행 중입니다.`, { jobId: job_id, job_id });
522
+ }
523
+ if (job.providerRequested !== 'gemini' || job.transport !== 'gemini-worker') {
524
+ return createErrorPayload('delegate-reply는 현재 direct Gemini job에만 지원됩니다.', {
525
+ jobId: job_id,
526
+ job_id,
527
+ });
528
+ }
529
+
530
+ const conversation = this.geminiConversations.get(job_id);
531
+ if (!conversation) {
532
+ return createErrorPayload(`Gemini 대화 컨텍스트가 없습니다: ${job_id}`, { jobId: job_id, job_id });
533
+ }
534
+ if (conversation.closed) {
535
+ return createErrorPayload(`이미 종료된 대화입니다: ${job_id}`, { jobId: job_id, job_id });
536
+ }
537
+
538
+ await emitProgress(extra, DIRECT_PROGRESS_START, 100, `job ${job_id} 후속 응답을 시작합니다.`);
539
+ job.status = 'running';
540
+ job.updatedAt = new Date().toISOString();
541
+
542
+ const worker = this._createGeminiWorker();
543
+ job.worker = worker;
544
+ const prompt = this._buildGeminiReplyPrompt(conversation, reply);
545
+
546
+ try {
547
+ const result = await worker.execute(prompt, {
548
+ cwd: job.requestArgs.cwd || this.cwd,
549
+ timeoutMs: resolveTimeoutMs(job.agentType, job.requestArgs.timeoutMs),
550
+ model: job.requestArgs.model || resolveGeminiModel(job.agentType),
551
+ approvalMode: 'yolo',
552
+ allowedMcpServerNames: getGeminiAllowedServers(this._getMcpPolicyOptions(job.requestArgs)),
553
+ });
554
+
555
+ conversation.turns.push({
556
+ user: reply,
557
+ assistant: result.output,
558
+ });
559
+ conversation.updatedAt = new Date().toISOString();
560
+ conversation.closed = Boolean(done);
561
+ if (done) {
562
+ this.geminiConversations.delete(job_id);
563
+ }
564
+
565
+ this._applyJobResult(job, {
566
+ ok: result.exitCode === 0,
567
+ status: result.exitCode === 0 ? 'completed' : 'failed',
568
+ providerRequested: 'gemini',
569
+ providerResolved: 'gemini',
570
+ agentType: job.agentType,
571
+ transport: 'gemini-worker',
572
+ exitCode: result.exitCode,
573
+ output: result.output,
574
+ sessionKey: result.sessionKey || job.sessionKey || null,
575
+ });
576
+ await emitProgress(extra, DIRECT_PROGRESS_DONE, 100, `job ${job_id} 후속 응답이 완료되었습니다.`);
577
+ return this._serializeJob(job);
578
+ } catch (error) {
579
+ const message = error instanceof Error ? error.message : String(error);
580
+ this._applyJobResult(job, createErrorPayload(message, {
581
+ mode: job.mode,
582
+ providerRequested: 'gemini',
583
+ providerResolved: 'gemini',
584
+ agentType: job.agentType,
585
+ transport: 'gemini-worker',
586
+ }));
587
+ return this._serializeJob(job);
588
+ } finally {
589
+ await worker.stop().catch(() => {});
590
+ job.worker = null;
591
+ }
592
+ }
593
+
594
+ _createGeminiWorker() {
595
+ return new GeminiWorker({
596
+ command: this.geminiCommand,
597
+ commandArgs: this.geminiCommandArgs,
598
+ cwd: this.cwd,
599
+ env: this.env,
600
+ });
601
+ }
602
+
603
+ _buildDirectPrompt(args) {
604
+ return withContext(String(args.prompt ?? ''), args.contextFile);
605
+ }
606
+
607
+ _buildDirectPromptWithHint(args) {
608
+ return withPromptHint(String(args.prompt ?? ''), {
609
+ agentType: args.agentType || 'executor',
610
+ mcpProfile: args.mcpProfile || 'auto',
611
+ searchTool: args.searchTool,
612
+ workerIndex: Number.isInteger(args.workerIndex) ? args.workerIndex : undefined,
613
+ contextFile: args.contextFile,
614
+ });
615
+ }
616
+
617
+ _buildGeminiReplyPrompt(conversation, reply) {
618
+ const transcript = formatConversationTranscript(conversation.turns);
619
+ return [
620
+ 'Continue the conversation using the prior transcript below.',
621
+ '',
622
+ '<conversation_history>',
623
+ transcript,
624
+ '</conversation_history>',
625
+ '',
626
+ '<latest_user_reply>',
627
+ reply,
628
+ '</latest_user_reply>',
629
+ ].join('\n');
630
+ }
631
+
632
+ _getMcpPolicyOptions(args) {
633
+ return {
634
+ agentType: args.agentType || 'executor',
635
+ requestedProfile: args.mcpProfile || 'auto',
636
+ searchTool: args.searchTool,
637
+ workerIndex: Number.isInteger(args.workerIndex) ? args.workerIndex : undefined,
638
+ taskText: withContext(String(args.prompt ?? ''), args.contextFile),
639
+ availableServers: Array.isArray(args.availableServers) ? args.availableServers : undefined,
640
+ };
641
+ }
642
+
643
+ _buildPromptHintInstruction(args) {
644
+ return buildPromptHint(this._getMcpPolicyOptions(args));
645
+ }
646
+
647
+ _shouldUseRoute(args) {
648
+ return args.provider === 'auto' || isTeamRouteRequested(args);
649
+ }
650
+
651
+ async _executeDirect(args, extra = null) {
652
+ await emitProgress(extra, DIRECT_PROGRESS_START, 100, '위임 실행을 시작합니다.');
653
+
654
+ const runViaRoute = this._shouldUseRoute(args);
655
+
656
+ try {
657
+ const result = runViaRoute
658
+ ? await this._executeRoute(args, extra)
659
+ : await this._executeWorker(args, extra);
660
+
661
+ await emitProgress(extra, DIRECT_PROGRESS_DONE, 100, '위임이 완료되었습니다.');
662
+ return result;
663
+ } catch (error) {
664
+ const message = error instanceof Error ? error.message : String(error);
665
+ return createErrorPayload(message, {
666
+ mode: 'sync',
667
+ providerRequested: args.provider,
668
+ agentType: args.agentType,
669
+ transport: runViaRoute ? 'route-script' : `${args.provider}-worker`,
670
+ });
671
+ }
672
+ }
673
+
674
+ async _executeWorker(args, extra) {
675
+ await emitProgress(extra, DIRECT_PROGRESS_RUNNING, 100, '직접 워커 경로로 실행 중입니다.');
676
+
677
+ if (args.provider === 'codex') {
678
+ const result = await this.codexWorker.execute(this._buildDirectPrompt(args), {
679
+ cwd: args.cwd || this.cwd,
680
+ timeoutMs: resolveTimeoutMs(args.agentType, args.timeoutMs),
681
+ sessionKey: args.sessionKey,
682
+ threadId: args.threadId,
683
+ resetSession: args.resetSession,
684
+ profile: resolveCodexProfile(args.agentType),
685
+ sandbox: 'danger-full-access',
686
+ approvalPolicy: 'never',
687
+ developerInstructions: joinInstructions(
688
+ REVIEW_INSTRUCTION_BY_AGENT[args.agentType],
689
+ this._buildPromptHintInstruction(args),
690
+ args.developerInstructions,
691
+ ),
692
+ config: getCodexMcpConfig(this._getMcpPolicyOptions(args)),
693
+ compactPrompt: args.compactPrompt,
694
+ model: args.model,
695
+ });
696
+
697
+ return {
698
+ ok: result.exitCode === 0,
699
+ mode: 'sync',
700
+ status: result.exitCode === 0 ? 'completed' : 'failed',
701
+ providerRequested: 'codex',
702
+ providerResolved: 'codex',
703
+ agentType: args.agentType,
704
+ transport: 'codex-mcp',
705
+ exitCode: result.exitCode,
706
+ output: result.output,
707
+ sessionKey: result.sessionKey,
708
+ threadId: result.threadId,
709
+ };
710
+ }
711
+
712
+ if (args.provider === 'gemini') {
713
+ const worker = this._createGeminiWorker();
714
+ const prompt = this._buildDirectPromptWithHint(args);
715
+ try {
716
+ const result = await worker.execute(prompt, {
717
+ cwd: args.cwd || this.cwd,
718
+ timeoutMs: resolveTimeoutMs(args.agentType, args.timeoutMs),
719
+ model: args.model || resolveGeminiModel(args.agentType),
720
+ approvalMode: 'yolo',
721
+ allowedMcpServerNames: getGeminiAllowedServers(this._getMcpPolicyOptions(args)),
722
+ });
723
+
724
+ return {
725
+ ok: result.exitCode === 0,
726
+ mode: 'sync',
727
+ status: result.exitCode === 0 ? 'completed' : 'failed',
728
+ providerRequested: 'gemini',
729
+ providerResolved: 'gemini',
730
+ agentType: args.agentType,
731
+ transport: 'gemini-worker',
732
+ exitCode: result.exitCode,
733
+ output: result.output,
734
+ sessionKey: result.sessionKey,
735
+ _geminiPrompt: prompt,
736
+ };
737
+ } finally {
738
+ await worker.stop().catch(() => {});
739
+ }
740
+ }
741
+
742
+ return createErrorPayload(`지원하지 않는 direct provider: ${args.provider}`, {
743
+ mode: 'sync',
744
+ providerRequested: args.provider,
745
+ agentType: args.agentType,
746
+ transport: 'direct-worker',
747
+ });
748
+ }
749
+
750
+ async _executeRoute(args, extra) {
751
+ if (!this.routeScript) {
752
+ return createErrorPayload('tfx-route.sh 경로를 찾지 못했습니다.', {
753
+ mode: 'sync',
754
+ providerRequested: args.provider,
755
+ agentType: args.agentType,
756
+ transport: 'route-script',
757
+ });
758
+ }
759
+
760
+ await emitProgress(extra, DIRECT_PROGRESS_RUNNING, 100, 'tfx-route.sh 경로로 실행 중입니다.');
761
+ const result = await this._spawnRoute(args);
762
+ return {
763
+ ok: result.exitCode === 0,
764
+ mode: 'sync',
765
+ status: result.exitCode === 0 ? 'completed' : 'failed',
766
+ providerRequested: args.provider,
767
+ providerResolved: parseRouteType(result.stderr) || args.provider,
768
+ agentType: args.agentType,
769
+ transport: 'route-script',
770
+ exitCode: result.exitCode,
771
+ output: result.stdout.trim() || result.stderr.trim(),
772
+ stderr: result.stderr.trim(),
773
+ };
774
+ }
775
+
776
+ async _startAsyncJob(args, extra) {
777
+ const job = this._createJob(args, 'async');
778
+ this.jobs.set(job.jobId, job);
779
+
780
+ await emitProgress(extra, DIRECT_PROGRESS_START, 100, `비동기 job ${job.jobId}를 시작합니다.`);
781
+
782
+ void (async () => {
783
+ try {
784
+ const result = this._shouldUseRoute(args)
785
+ ? await this._spawnRoute(args, job)
786
+ : await this._runAsyncWorker(args, job);
787
+ this._applyJobResult(job, result);
788
+ } catch (error) {
789
+ this._applyJobResult(job, createErrorPayload(
790
+ error instanceof Error ? error.message : String(error),
791
+ {
792
+ mode: 'async',
793
+ providerRequested: args.provider,
794
+ agentType: args.agentType,
795
+ transport: this._shouldUseRoute(args) ? 'route-script' : `${args.provider}-worker`,
796
+ },
797
+ ));
798
+ } finally {
799
+ if (job.worker) {
800
+ await job.worker.stop().catch(() => {});
801
+ job.worker = null;
802
+ }
803
+ job.child = null;
804
+ }
805
+ })();
806
+
807
+ return this._serializeJob(job);
808
+ }
809
+
810
+ async _runAsyncWorker(args, job) {
811
+ if (args.provider === 'codex') {
812
+ const result = await this.codexWorker.execute(this._buildDirectPrompt(args), {
813
+ cwd: args.cwd || this.cwd,
814
+ timeoutMs: resolveTimeoutMs(args.agentType, args.timeoutMs),
815
+ sessionKey: args.sessionKey,
816
+ threadId: args.threadId,
817
+ resetSession: args.resetSession,
818
+ profile: resolveCodexProfile(args.agentType),
819
+ sandbox: 'danger-full-access',
820
+ approvalPolicy: 'never',
821
+ developerInstructions: joinInstructions(
822
+ REVIEW_INSTRUCTION_BY_AGENT[args.agentType],
823
+ this._buildPromptHintInstruction(args),
824
+ args.developerInstructions,
825
+ ),
826
+ config: getCodexMcpConfig(this._getMcpPolicyOptions(args)),
827
+ compactPrompt: args.compactPrompt,
828
+ model: args.model,
829
+ });
830
+
831
+ return {
832
+ ok: result.exitCode === 0,
833
+ providerResolved: 'codex',
834
+ output: result.output,
835
+ exitCode: result.exitCode,
836
+ threadId: result.threadId,
837
+ sessionKey: result.sessionKey,
838
+ };
839
+ }
840
+
841
+ if (args.provider === 'gemini') {
842
+ const worker = this._createGeminiWorker();
843
+ job.worker = worker;
844
+ const prompt = this._buildDirectPromptWithHint(args);
845
+ const result = await worker.execute(prompt, {
846
+ cwd: args.cwd || this.cwd,
847
+ timeoutMs: resolveTimeoutMs(args.agentType, args.timeoutMs),
848
+ model: args.model || resolveGeminiModel(args.agentType),
849
+ approvalMode: 'yolo',
850
+ allowedMcpServerNames: getGeminiAllowedServers(this._getMcpPolicyOptions(args)),
851
+ });
852
+
853
+ return {
854
+ ok: result.exitCode === 0,
855
+ providerResolved: 'gemini',
856
+ output: result.output,
857
+ exitCode: result.exitCode,
858
+ sessionKey: result.sessionKey,
859
+ _geminiPrompt: prompt,
860
+ };
861
+ }
862
+
863
+ throw new Error(`지원하지 않는 async provider: ${args.provider}`);
864
+ }
865
+
866
+ _buildRouteEnv(args) {
867
+ const env = cloneEnv(this.env);
868
+ env.TFX_CLI_MODE = pickRouteMode(args.provider);
869
+
870
+ if (args.codexTransport) {
871
+ env.TFX_CODEX_TRANSPORT = args.codexTransport;
872
+ }
873
+ if (args.noClaudeNative === true) {
874
+ env.TFX_NO_CLAUDE_NATIVE = '1';
875
+ }
876
+ if (args.searchTool) {
877
+ env.TFX_SEARCH_TOOL = args.searchTool;
878
+ }
879
+ if (Number.isInteger(args.workerIndex) && args.workerIndex > 0) {
880
+ env.TFX_WORKER_INDEX = String(args.workerIndex);
881
+ }
882
+ if (args.teamName) env.TFX_TEAM_NAME = args.teamName;
883
+ if (args.teamTaskId) env.TFX_TEAM_TASK_ID = args.teamTaskId;
884
+ if (args.teamAgentName) env.TFX_TEAM_AGENT_NAME = args.teamAgentName;
885
+ if (args.teamLeadName) env.TFX_TEAM_LEAD_NAME = args.teamLeadName;
886
+ if (args.hubUrl) env.TFX_HUB_URL = args.hubUrl;
887
+
888
+ return env;
889
+ }
890
+
891
+ async _spawnRoute(args, job = null) {
892
+ const prompt = withContext(String(args.prompt ?? ''), args.contextFile);
893
+ const childArgs = [
894
+ this.routeScript,
895
+ args.agentType || 'executor',
896
+ prompt,
897
+ args.mcpProfile || 'auto',
898
+ String(resolveTimeoutSec(args.agentType, args.timeoutMs)),
899
+ ];
900
+
901
+ const child = spawn(this.bashCommand, childArgs, {
902
+ cwd: args.cwd || this.cwd,
903
+ env: this._buildRouteEnv(args),
904
+ stdio: ['ignore', 'pipe', 'pipe'],
905
+ windowsHide: true,
906
+ });
907
+
908
+ if (job) {
909
+ job.child = child;
910
+ }
911
+
912
+ this.routeChildren.add(child);
913
+
914
+ return await new Promise((resolvePromise, rejectPromise) => {
915
+ const stdoutChunks = [];
916
+ const stderrChunks = [];
917
+
918
+ child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
919
+ child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
920
+ child.once('error', (error) => {
921
+ this.routeChildren.delete(child);
922
+ rejectPromise(error);
923
+ });
924
+ child.once('close', (code) => {
925
+ this.routeChildren.delete(child);
926
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
927
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
928
+ resolvePromise({
929
+ ok: code === 0,
930
+ providerResolved: parseRouteType(stderr) || args.provider,
931
+ output: stdout.trim() || stderr.trim(),
932
+ stdout,
933
+ stderr,
934
+ exitCode: code ?? 1,
935
+ });
936
+ });
937
+ });
938
+ }
939
+
940
+ _serializeJob(job) {
941
+ return {
942
+ ok: job.ok,
943
+ job_id: job.jobId,
944
+ mode: job.mode || 'async',
945
+ status: job.status,
946
+ provider_requested: job.providerRequested,
947
+ provider_resolved: job.providerResolved,
948
+ agent_type: job.agentType,
949
+ transport: job.transport,
950
+ created_at: job.createdAt,
951
+ started_at: job.startedAt,
952
+ updated_at: job.updatedAt,
953
+ completed_at: job.completedAt,
954
+ output: job.output,
955
+ stderr: job.stderr,
956
+ error: '',
957
+ thread_id: job.threadId,
958
+ session_key: job.sessionKey,
959
+ conversation_open: this.geminiConversations.has(job.jobId),
960
+ };
961
+ }
962
+
963
+ _createJob(args, mode) {
964
+ const jobId = randomUUID();
965
+ const now = new Date().toISOString();
966
+ return {
967
+ ok: true,
968
+ jobId,
969
+ mode,
970
+ status: 'running',
971
+ providerRequested: args.provider,
972
+ providerResolved: null,
973
+ agentType: args.agentType,
974
+ transport: this._shouldUseRoute(args) ? 'route-script' : `${args.provider}-worker`,
975
+ createdAt: now,
976
+ startedAt: now,
977
+ updatedAt: now,
978
+ completedAt: null,
979
+ output: '',
980
+ stderr: '',
981
+ exitCode: null,
982
+ threadId: null,
983
+ sessionKey: args.sessionKey || null,
984
+ worker: null,
985
+ child: null,
986
+ requestArgs: sanitizeDelegateArgs(args),
987
+ };
988
+ }
989
+
990
+ _applyJobResult(job, result = {}) {
991
+ job.ok = result.ok !== false;
992
+ job.status = job.ok ? 'completed' : 'failed';
993
+ job.providerResolved = result.providerResolved || job.providerRequested;
994
+ job.transport = result.transport || job.transport;
995
+ job.output = result.output || '';
996
+ job.stderr = result.stderr || result.error || '';
997
+ job.exitCode = result.exitCode ?? (job.ok ? 0 : 1);
998
+ job.threadId = result.threadId || job.threadId || null;
999
+ job.sessionKey = result.sessionKey || job.sessionKey || null;
1000
+ job.completedAt = new Date().toISOString();
1001
+ job.updatedAt = job.completedAt;
1002
+
1003
+ if (job.providerRequested === 'gemini'
1004
+ && job.transport === 'gemini-worker'
1005
+ && typeof result._geminiPrompt === 'string') {
1006
+ this._storeGeminiConversation(job, result._geminiPrompt, result.output || '');
1007
+ }
1008
+ }
1009
+
1010
+ _storeGeminiConversation(job, userPrompt, assistantReply) {
1011
+ const existing = this.geminiConversations.get(job.jobId);
1012
+ if (existing) {
1013
+ if (typeof assistantReply === 'string') {
1014
+ const lastTurn = existing.turns.at(-1);
1015
+ if (lastTurn && lastTurn.assistant !== assistantReply) {
1016
+ lastTurn.assistant = assistantReply;
1017
+ }
1018
+ }
1019
+ existing.updatedAt = new Date().toISOString();
1020
+ return;
1021
+ }
1022
+
1023
+ this.geminiConversations.set(job.jobId, {
1024
+ jobId: job.jobId,
1025
+ closed: false,
1026
+ updatedAt: new Date().toISOString(),
1027
+ turns: [{
1028
+ user: userPrompt,
1029
+ assistant: assistantReply,
1030
+ }],
1031
+ });
1032
+ }
1033
+
1034
+ async _runSyncJob(args, extra) {
1035
+ const job = this._createJob(args, 'sync');
1036
+ this.jobs.set(job.jobId, job);
1037
+ const result = await this._executeDirect(args, extra);
1038
+ this._applyJobResult(job, result);
1039
+ return this._serializeJob(job);
1040
+ }
1041
+ }
1042
+
1043
+ export function createDelegatorMcpWorker(options = {}) {
1044
+ return new DelegatorMcpWorker(options);
1045
+ }
1046
+
1047
+ export async function runDelegatorMcpCli() {
1048
+ const worker = createDelegatorMcpWorker();
1049
+ try {
1050
+ await worker.serveStdio();
1051
+ } catch (error) {
1052
+ console.error(`[delegator-mcp] ${error instanceof Error ? error.message : String(error)}`);
1053
+ process.exitCode = 1;
1054
+ }
1055
+ }
1056
+
1057
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
1058
+ await runDelegatorMcpCli();
1059
+ }