@triflux/remote 10.1.0 → 10.26.0

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 (162) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +61 -0
  3. package/hub/server.mjs +2179 -709
  4. package/hub/store-adapter.mjs +59 -26
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +78 -17
  9. package/hub/team/build-worker-prompt.mjs +73 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +1130 -0
  12. package/hub/team/claude-native-bridge.mjs +1011 -0
  13. package/hub/team/claude-session-projection.mjs +74 -0
  14. package/hub/team/cli/commands/attach.mjs +57 -37
  15. package/hub/team/cli/commands/control.mjs +29 -8
  16. package/hub/team/cli/commands/debug.mjs +95 -74
  17. package/hub/team/cli/commands/focus.mjs +80 -53
  18. package/hub/team/cli/commands/interrupt.mjs +18 -6
  19. package/hub/team/cli/commands/kill.mjs +16 -5
  20. package/hub/team/cli/commands/list.mjs +31 -24
  21. package/hub/team/cli/commands/send.mjs +19 -6
  22. package/hub/team/cli/commands/start/index.mjs +151 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +97 -13
  24. package/hub/team/cli/commands/start/start-headless.mjs +155 -37
  25. package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
  26. package/hub/team/cli/commands/start/start-mux.mjs +122 -73
  27. package/hub/team/cli/commands/start/start-wt.mjs +86 -69
  28. package/hub/team/cli/commands/status.mjs +43 -14
  29. package/hub/team/cli/commands/stop.mjs +11 -4
  30. package/hub/team/cli/commands/task.mjs +8 -3
  31. package/hub/team/cli/commands/tasks.mjs +13 -13
  32. package/hub/team/cli/help.mjs +1 -1
  33. package/hub/team/cli/index.mjs +2 -2
  34. package/hub/team/cli/manifest.mjs +38 -8
  35. package/hub/team/cli/render.mjs +52 -30
  36. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  37. package/hub/team/cli/services/hub-client.mjs +44 -16
  38. package/hub/team/cli/services/member-selector.mjs +37 -30
  39. package/hub/team/cli/services/native-control.mjs +144 -116
  40. package/hub/team/cli/services/runtime-mode.mjs +61 -4
  41. package/hub/team/cli/services/state-store.mjs +66 -9
  42. package/hub/team/cli/services/task-model.mjs +40 -30
  43. package/hub/team/codex-review.mjs +521 -0
  44. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  45. package/hub/team/conductor-registry.mjs +121 -0
  46. package/hub/team/conductor.mjs +1379 -671
  47. package/hub/team/consensus-meta.mjs +176 -0
  48. package/hub/team/dashboard-anchor.mjs +4 -5
  49. package/hub/team/dashboard-layout.mjs +3 -1
  50. package/hub/team/dashboard-open.mjs +20 -118
  51. package/hub/team/dashboard.mjs +76 -28
  52. package/hub/team/event-log.mjs +18 -10
  53. package/hub/team/execution-mode.mjs +256 -0
  54. package/hub/team/extract-completion-payload.mjs +110 -0
  55. package/hub/team/git-preflight.mjs +286 -0
  56. package/hub/team/handoff.mjs +49 -18
  57. package/hub/team/headless-bridge-session.mjs +17 -0
  58. package/hub/team/headless.mjs +2213 -1156
  59. package/hub/team/health-probe.mjs +232 -69
  60. package/hub/team/interactive-native-launcher.mjs +106 -0
  61. package/hub/team/interactive-native-worker.mjs +80 -0
  62. package/hub/team/interactive-tui-transport.mjs +465 -0
  63. package/hub/team/launcher-template.mjs +49 -17
  64. package/hub/team/lead-control.mjs +91 -4
  65. package/hub/team/mcp-selector.mjs +145 -0
  66. package/hub/team/native-supervisor.mjs +90 -26
  67. package/hub/team/native.mjs +120 -42
  68. package/hub/team/nativeProxy.mjs +184 -113
  69. package/hub/team/notify.mjs +147 -78
  70. package/hub/team/orchestrator.mjs +173 -161
  71. package/hub/team/pane.mjs +43 -20
  72. package/hub/team/process-cleanup.mjs +69 -25
  73. package/hub/team/psmux.mjs +1022 -281
  74. package/hub/team/recovery-store.mjs +149 -0
  75. package/hub/team/remote-probe.mjs +68 -52
  76. package/hub/team/remote-session.mjs +131 -59
  77. package/hub/team/remote-watcher.mjs +61 -33
  78. package/hub/team/retry-state-machine.mjs +336 -0
  79. package/hub/team/routing.mjs +51 -25
  80. package/hub/team/runtime-strategy.mjs +200 -0
  81. package/hub/team/schemas/worker-completion.json +39 -0
  82. package/hub/team/sentinel-capture.mjs +127 -0
  83. package/hub/team/session-sync.mjs +153 -6
  84. package/hub/team/session.mjs +574 -611
  85. package/hub/team/shared.mjs +13 -13
  86. package/hub/team/staleState.mjs +72 -30
  87. package/hub/team/swarm-cli.mjs +390 -0
  88. package/hub/team/swarm-hypervisor.mjs +1544 -193
  89. package/hub/team/swarm-intent.mjs +216 -0
  90. package/hub/team/swarm-locks.mjs +140 -25
  91. package/hub/team/swarm-planner.mjs +121 -21
  92. package/hub/team/swarm-preflight.mjs +319 -0
  93. package/hub/team/swarm-reconciler.mjs +128 -23
  94. package/hub/team/synapse-cli.mjs +458 -0
  95. package/hub/team/synapse-http.mjs +60 -0
  96. package/hub/team/synapse-registry.mjs +329 -0
  97. package/hub/team/terminal-opener.mjs +216 -0
  98. package/hub/team/tui-core.mjs +292 -0
  99. package/hub/team/tui-lite.mjs +237 -148
  100. package/hub/team/tui-remote-adapter.mjs +18 -34
  101. package/hub/team/tui-synapse.mjs +223 -0
  102. package/hub/team/tui-viewer.mjs +108 -48
  103. package/hub/team/tui-widgets.mjs +262 -0
  104. package/hub/team/tui.mjs +931 -439
  105. package/hub/team/worker-completion-validator.mjs +84 -0
  106. package/hub/team/worker-sandbox.mjs +97 -0
  107. package/hub/team/worker-signal.mjs +263 -0
  108. package/hub/team/worker-signal.types.d.ts +52 -0
  109. package/hub/team/worktree-lifecycle.mjs +517 -63
  110. package/hub/team/wt-manager.mjs +297 -26
  111. package/hub/tools.mjs +477 -252
  112. package/hub/tray.mjs +411 -368
  113. package/hub/workers/antigravity-route-worker.mjs +169 -0
  114. package/hub/workers/claude-worker.mjs +162 -118
  115. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  116. package/hub/workers/codex-mcp.mjs +724 -507
  117. package/hub/workers/delegator-mcp.mjs +570 -499
  118. package/hub/workers/factory.mjs +103 -21
  119. package/hub/workers/gemini-worker.mjs +126 -284
  120. package/hub/workers/interface.mjs +24 -2
  121. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  122. package/hub/workers/worker-utils.mjs +23 -17
  123. package/package.json +4 -3
  124. package/scripts/lib/agent-json.mjs +27 -0
  125. package/scripts/lib/async.mjs +174 -0
  126. package/scripts/lib/claudemd-scanner.mjs +223 -0
  127. package/scripts/lib/cli-agy.mjs +62 -0
  128. package/scripts/lib/cli-claude.mjs +78 -0
  129. package/scripts/lib/cli-codex.mjs +199 -0
  130. package/scripts/lib/cli-gemini.mjs +37 -0
  131. package/scripts/lib/codex-recovery.sh +50 -0
  132. package/scripts/lib/context.mjs +67 -0
  133. package/scripts/lib/cross-review-utils.mjs +59 -0
  134. package/scripts/lib/doctor-env-checks.mjs +121 -0
  135. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  136. package/scripts/lib/env-probe.mjs +269 -0
  137. package/scripts/lib/env.mjs +14 -0
  138. package/scripts/lib/gemini-profiles.mjs +119 -0
  139. package/scripts/lib/handoff.mjs +187 -0
  140. package/scripts/lib/hook-utils.mjs +16 -0
  141. package/scripts/lib/hub.mjs +48 -0
  142. package/scripts/lib/keyword-rules.mjs +195 -0
  143. package/scripts/lib/logger.mjs +105 -0
  144. package/scripts/lib/mcp-filter.mjs +883 -0
  145. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  146. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  147. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  148. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  149. package/scripts/lib/mcp-health.mjs +773 -0
  150. package/scripts/lib/mcp-manifest.mjs +89 -0
  151. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  152. package/scripts/lib/pid.mjs +63 -0
  153. package/scripts/lib/process-utils.mjs +26 -0
  154. package/scripts/lib/psmux-info.mjs +224 -0
  155. package/scripts/lib/quota.mjs +47 -0
  156. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  157. package/scripts/lib/skill-state.mjs +220 -0
  158. package/scripts/lib/skill-template.mjs +375 -0
  159. package/scripts/lib/team.mjs +75 -0
  160. package/scripts/lib/timeout.mjs +66 -0
  161. package/scripts/lib/tmp.mjs +26 -0
  162. package/scripts/lib/toml.mjs +113 -0
@@ -1,507 +1,724 @@
1
- // hub/workers/codex-mcp.mjs — Codex MCP 서버 래퍼
2
- import process from 'node:process';
3
- import { fileURLToPath } from 'node:url';
4
-
5
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
6
- import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
7
-
8
- import {
9
- CODEX_MCP_EXECUTION_EXIT_CODE,
10
- CODEX_MCP_TRANSPORT_EXIT_CODE,
11
- } from '@triflux/core/hub/cli-adapter-base.mjs';
12
- import { withRetry } from './worker-utils.mjs';
13
-
14
- const REQUIRED_TOOLS = ['codex', 'codex-reply'];
15
-
16
- export { CODEX_MCP_EXECUTION_EXIT_CODE, CODEX_MCP_TRANSPORT_EXIT_CODE };
17
- export const DEFAULT_CODEX_MCP_TIMEOUT_MS = 10 * 60 * 1000;
18
- export const DEFAULT_CODEX_MCP_BOOTSTRAP_TIMEOUT_MS = 10 * 1000;
19
-
20
- /**
21
- * Codex MCP transport/bootstrap 계층 오류
22
- */
23
- export class CodexMcpTransportError extends Error {
24
- /**
25
- * @param {string} message
26
- * @param {object} [options]
27
- * @param {unknown} [options.cause]
28
- * @param {string} [options.stderr]
29
- */
30
- constructor(message, options = {}) {
31
- super(message, { cause: options.cause });
32
- this.name = 'CodexMcpTransportError';
33
- this.stderr = options.stderr || '';
34
- }
35
- }
36
-
37
- function cloneEnv(env = process.env) {
38
- return Object.fromEntries(
39
- Object.entries(env).filter(([, value]) => typeof value === 'string'),
40
- );
41
- }
42
-
43
- function collectTextContent(content = []) {
44
- return content
45
- .filter((item) => item?.type === 'text' && typeof item.text === 'string')
46
- .map((item) => item.text)
47
- .join('\n')
48
- .trim();
49
- }
50
-
51
- function normalizeStructuredContent(structuredContent, fallbackText = '') {
52
- if (!structuredContent || typeof structuredContent !== 'object') {
53
- return { threadId: null, content: fallbackText };
54
- }
55
-
56
- const threadId = typeof structuredContent.threadId === 'string'
57
- ? structuredContent.threadId
58
- : null;
59
- const content = typeof structuredContent.content === 'string'
60
- ? structuredContent.content
61
- : fallbackText;
62
-
63
- return { threadId, content };
64
- }
65
-
66
- function buildCodexArguments(prompt, opts = {}) {
67
- const args = { prompt };
68
-
69
- if (typeof opts.cwd === 'string' && opts.cwd) args.cwd = opts.cwd;
70
- if (typeof opts.model === 'string' && opts.model) args.model = opts.model;
71
- if (typeof opts.profile === 'string' && opts.profile) args.profile = opts.profile;
72
- if (typeof opts.approvalPolicy === 'string' && opts.approvalPolicy) {
73
- args['approval-policy'] = opts.approvalPolicy;
74
- }
75
- if (typeof opts.sandbox === 'string' && opts.sandbox) args.sandbox = opts.sandbox;
76
- if (opts.config && typeof opts.config === 'object') args.config = opts.config;
77
- if (typeof opts.baseInstructions === 'string' && opts.baseInstructions) {
78
- args['base-instructions'] = opts.baseInstructions;
79
- }
80
- if (typeof opts.developerInstructions === 'string' && opts.developerInstructions) {
81
- args['developer-instructions'] = opts.developerInstructions;
82
- }
83
- if (typeof opts.compactPrompt === 'string' && opts.compactPrompt) {
84
- args['compact-prompt'] = opts.compactPrompt;
85
- }
86
-
87
- return args;
88
- }
89
-
90
- function pickToolName(threadId) {
91
- return threadId ? 'codex-reply' : 'codex';
92
- }
93
-
94
- function withTimeout(promise, timeoutMs, message) {
95
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
96
- return promise;
97
- }
98
-
99
- let timer;
100
- const timeoutPromise = new Promise((_, reject) => {
101
- timer = setTimeout(() => {
102
- reject(new Error(message));
103
- }, timeoutMs);
104
- timer.unref?.();
105
- });
106
-
107
- return Promise.race([promise, timeoutPromise]).finally(() => {
108
- clearTimeout(timer);
109
- });
110
- }
111
-
112
- function normalizeRetryOptions(retryOptions) {
113
- if (!retryOptions || typeof retryOptions !== 'object') {
114
- return Object.freeze({});
115
- }
116
- return Object.freeze({ ...retryOptions });
117
- }
118
-
119
- function isCodexRetryable(error) {
120
- return error instanceof CodexMcpTransportError
121
- || error?.code === 'ETIMEDOUT'
122
- || error?.cause?.code === 'ETIMEDOUT';
123
- }
124
-
125
- function detectWorkerCategory(error, fallbackCategory = 'transient') {
126
- const combined = `${error?.message || ''}\n${error?.stderr || ''}`.toLowerCase();
127
-
128
- if (error?.code === 'INVALID_INPUT') return 'input';
129
- if (/(unauthorized|forbidden|auth|login|token|credential|apikey|api key)/i.test(combined)) {
130
- return 'auth';
131
- }
132
- if (/(config|unknown option|invalid option|missing|필수 mcp 도구 누락)/i.test(combined)) {
133
- return 'config';
134
- }
135
-
136
- return fallbackCategory;
137
- }
138
-
139
- function buildCodexErrorInfo(error, attempts) {
140
- const retryable = isCodexRetryable(error);
141
- const code = error instanceof CodexMcpTransportError
142
- ? 'CODEX_TRANSPORT_ERROR'
143
- : (error?.code || 'CODEX_EXECUTION_ERROR');
144
- const category = detectWorkerCategory(error, retryable ? 'transient' : 'config');
145
-
146
- let recovery = 'Review the Codex worker error output and retry after correcting the issue.';
147
- if (code === 'INVALID_INPUT') {
148
- recovery = 'Provide a non-empty prompt before invoking the Codex worker.';
149
- } else if (retryable) {
150
- recovery = 'Retry after reconnecting the Codex MCP transport.';
151
- } else if (category === 'auth') {
152
- recovery = 'Refresh the Codex authentication state and retry.';
153
- } else if (category === 'config') {
154
- recovery = 'Check the Codex MCP configuration and available tools.';
155
- }
156
-
157
- return Object.freeze({
158
- code,
159
- retryable,
160
- attempts,
161
- category,
162
- recovery,
163
- });
164
- }
165
-
166
- /**
167
- * Codex MCP 워커
168
- */
169
- export class CodexMcpWorker {
170
- type = 'codex';
171
-
172
- /**
173
- * @param {object} [options]
174
- * @param {string} [options.command]
175
- * @param {string[]} [options.args]
176
- * @param {string} [options.cwd]
177
- * @param {Record<string, string>} [options.env]
178
- * @param {{ name: string, version: string }} [options.clientInfo]
179
- * @param {number} [options.bootstrapTimeoutMs]
180
- */
181
- constructor(options = {}) {
182
- this.command = options.command || process.env.CODEX_BIN || 'codex';
183
- this.args = Array.isArray(options.args) && options.args.length
184
- ? [...options.args]
185
- : ['mcp-server'];
186
- this.cwd = options.cwd || process.cwd();
187
- this.env = cloneEnv({ ...cloneEnv(process.env), ...cloneEnv(options.env) });
188
- this.clientInfo = options.clientInfo || { name: 'triflux-codex-mcp', version: '1.0.0' };
189
- this.bootstrapTimeoutMs = Number.isFinite(options.bootstrapTimeoutMs)
190
- ? options.bootstrapTimeoutMs
191
- : DEFAULT_CODEX_MCP_BOOTSTRAP_TIMEOUT_MS;
192
- this.retryOptions = normalizeRetryOptions(options.retryOptions);
193
-
194
- this.client = null;
195
- this.transport = null;
196
- this.ready = false;
197
- this.availableTools = new Set();
198
- this.threadIds = new Map();
199
- this.serverStderr = '';
200
- }
201
-
202
- isReady() {
203
- return this.ready;
204
- }
205
-
206
- getThreadId(sessionKey) {
207
- return this.threadIds.get(sessionKey) || null;
208
- }
209
-
210
- setThreadId(sessionKey, threadId) {
211
- if (!sessionKey || !threadId) return;
212
- this.threadIds.set(sessionKey, threadId);
213
- }
214
-
215
- clearThread(sessionKey) {
216
- if (!sessionKey) return;
217
- this.threadIds.delete(sessionKey);
218
- }
219
-
220
- async start() {
221
- if (this.ready && this.client && this.transport) return;
222
-
223
- await this.stop();
224
-
225
- const transport = new StdioClientTransport({
226
- command: this.command,
227
- args: this.args,
228
- cwd: this.cwd,
229
- env: this.env,
230
- stderr: 'pipe',
231
- });
232
- const client = new Client(this.clientInfo, { capabilities: {} });
233
-
234
- this.serverStderr = '';
235
- transport.stderr?.on('data', (chunk) => {
236
- this.serverStderr += String(chunk);
237
- if (this.serverStderr.length > 16000) {
238
- this.serverStderr = this.serverStderr.slice(-16000);
239
- }
240
- });
241
-
242
- try {
243
- await withTimeout((async () => {
244
- await client.connect(transport);
245
- const tools = await client.listTools(undefined, { timeout: this.bootstrapTimeoutMs });
246
- this.availableTools = new Set(tools.tools.map((tool) => tool.name));
247
-
248
- for (const requiredTool of REQUIRED_TOOLS) {
249
- if (!this.availableTools.has(requiredTool)) {
250
- throw new Error(`필수 MCP 도구 누락: ${requiredTool}`);
251
- }
252
- }
253
- })(), this.bootstrapTimeoutMs, `Codex MCP bootstrap timeout (${this.bootstrapTimeoutMs}ms)`);
254
- } catch (error) {
255
- await client.close().catch(() => {});
256
- transport.stderr?.destroy?.();
257
- throw new CodexMcpTransportError(
258
- `Codex MCP 연결 실패: ${error instanceof Error ? error.message : String(error)}`,
259
- { cause: error, stderr: this.serverStderr.trim() },
260
- );
261
- }
262
-
263
- this.client = client;
264
- this.transport = transport;
265
- this.ready = true;
266
- }
267
-
268
- async stop() {
269
- this.ready = false;
270
- this.availableTools.clear();
271
-
272
- const client = this.client;
273
- const transport = this.transport;
274
- this.transport = null;
275
- this.client = null;
276
-
277
- if (client) {
278
- await client.close().catch(() => {});
279
- } else if (transport) {
280
- await transport.close().catch(() => {});
281
- }
282
-
283
- transport?.stderr?.destroy?.();
284
- }
285
-
286
- /**
287
- * @param {string} prompt
288
- * @param {import('./interface.mjs').WorkerExecuteOptions} [opts]
289
- * @returns {Promise<import('./interface.mjs').WorkerResult>}
290
- */
291
- async execute(prompt, opts = {}) {
292
- if (typeof prompt !== 'string' || !prompt.trim()) {
293
- return {
294
- output: 'prompt는 비어 있을 수 없습니다.',
295
- exitCode: CODEX_MCP_EXECUTION_EXIT_CODE,
296
- threadId: null,
297
- sessionKey: opts.sessionKey || null,
298
- error: buildCodexErrorInfo({ code: 'INVALID_INPUT', message: 'prompt는 비어 있을 수 없습니다.' }, 0),
299
- raw: null,
300
- };
301
- }
302
-
303
- const sessionKey = typeof opts.sessionKey === 'string' && opts.sessionKey
304
- ? opts.sessionKey
305
- : null;
306
-
307
- if (opts.resetSession && sessionKey) {
308
- this.clearThread(sessionKey);
309
- }
310
-
311
- const threadId = typeof opts.threadId === 'string' && opts.threadId
312
- ? opts.threadId
313
- : (sessionKey ? this.getThreadId(sessionKey) : null);
314
- const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_CODEX_MCP_TIMEOUT_MS;
315
- let attempts = 0;
316
- let activeThreadId = threadId;
317
-
318
- try {
319
- const { rawResult, normalized } = await withRetry(async () => {
320
- attempts += 1;
321
- if (attempts === 1) {
322
- await this.start();
323
- } else {
324
- await this.stop();
325
- await this.start();
326
- }
327
-
328
- const toolName = pickToolName(activeThreadId);
329
- const toolArguments = toolName === 'codex-reply'
330
- ? { prompt, threadId: activeThreadId }
331
- : buildCodexArguments(prompt, opts);
332
-
333
- const nextRawResult = await this.client.callTool(
334
- { name: toolName, arguments: toolArguments },
335
- undefined,
336
- { timeout: timeoutMs },
337
- );
338
-
339
- const textContent = collectTextContent(nextRawResult.content);
340
- const nextNormalized = normalizeStructuredContent(nextRawResult.structuredContent, textContent);
341
- activeThreadId = nextNormalized.threadId || activeThreadId;
342
-
343
- return { rawResult: nextRawResult, normalized: nextNormalized };
344
- }, {
345
- ...this.retryOptions,
346
- shouldRetry: (error) => isCodexRetryable(error),
347
- });
348
-
349
- if (sessionKey && normalized.threadId) {
350
- this.setThreadId(sessionKey, normalized.threadId);
351
- }
352
-
353
- if (rawResult.isError) {
354
- return {
355
- output: normalized.content,
356
- exitCode: CODEX_MCP_EXECUTION_EXIT_CODE,
357
- threadId: normalized.threadId,
358
- sessionKey,
359
- error: buildCodexErrorInfo(
360
- { code: 'CODEX_TOOL_ERROR', message: normalized.content },
361
- attempts,
362
- ),
363
- raw: rawResult,
364
- };
365
- }
366
-
367
- return {
368
- output: normalized.content,
369
- exitCode: 0,
370
- threadId: normalized.threadId,
371
- sessionKey,
372
- raw: rawResult,
373
- };
374
- } catch (error) {
375
- await this.stop().catch(() => {});
376
- return {
377
- output: error instanceof Error ? error.message : String(error),
378
- exitCode: CODEX_MCP_EXECUTION_EXIT_CODE,
379
- threadId: activeThreadId,
380
- sessionKey,
381
- error: buildCodexErrorInfo(error, attempts || 1),
382
- raw: null,
383
- };
384
- }
385
- }
386
- }
387
-
388
- export function createCodexMcpWorker(options = {}) {
389
- return new CodexMcpWorker(options);
390
- }
391
-
392
- function parseCliArgs(argv) {
393
- const options = {
394
- command: process.env.CODEX_BIN || 'codex',
395
- cwd: process.cwd(),
396
- timeoutMs: DEFAULT_CODEX_MCP_TIMEOUT_MS,
397
- };
398
-
399
- for (let i = 0; i < argv.length; i += 1) {
400
- const token = argv[i];
401
- const next = () => {
402
- const value = argv[i + 1];
403
- if (value === undefined) {
404
- throw new Error(`${token} 값이 필요합니다.`);
405
- }
406
- i += 1;
407
- return value;
408
- };
409
-
410
- switch (token) {
411
- case '--prompt':
412
- options.prompt = next();
413
- break;
414
- case '--thread-id':
415
- options.threadId = next();
416
- break;
417
- case '--session-key':
418
- options.sessionKey = next();
419
- break;
420
- case '--cwd':
421
- options.cwd = next();
422
- break;
423
- case '--profile':
424
- options.profile = next();
425
- break;
426
- case '--model':
427
- options.model = next();
428
- break;
429
- case '--approval-policy':
430
- options.approvalPolicy = next();
431
- break;
432
- case '--sandbox':
433
- options.sandbox = next();
434
- break;
435
- case '--base-instructions':
436
- options.baseInstructions = next();
437
- break;
438
- case '--developer-instructions':
439
- options.developerInstructions = next();
440
- break;
441
- case '--compact-prompt':
442
- options.compactPrompt = next();
443
- break;
444
- case '--timeout-ms':
445
- options.timeoutMs = Number.parseInt(next(), 10);
446
- break;
447
- case '--config-json':
448
- options.config = JSON.parse(next());
449
- break;
450
- case '--codex-command':
451
- options.command = next();
452
- break;
453
- case '--reset-session':
454
- options.resetSession = true;
455
- break;
456
- default:
457
- throw new Error(`알 수 없는 옵션: ${token}`);
458
- }
459
- }
460
-
461
- if (typeof options.prompt !== 'string' || !options.prompt) {
462
- throw new Error('--prompt는 필수입니다.');
463
- }
464
-
465
- return options;
466
- }
467
-
468
- export async function runCodexMcpCli(argv = process.argv.slice(2)) {
469
- let options;
470
- try {
471
- options = parseCliArgs(argv);
472
- } catch (error) {
473
- console.error(`[codex-mcp] ${error instanceof Error ? error.message : String(error)}`);
474
- process.exitCode = 64;
475
- return;
476
- }
477
-
478
- const worker = new CodexMcpWorker({
479
- command: options.command,
480
- cwd: options.cwd,
481
- });
482
-
483
- try {
484
- const result = await worker.execute(options.prompt, options);
485
- if (result.output) {
486
- process.stdout.write(result.output);
487
- if (!result.output.endsWith('\n')) {
488
- process.stdout.write('\n');
489
- }
490
- }
491
- process.exitCode = result.exitCode;
492
- } catch (error) {
493
- const lines = [error instanceof Error ? error.message : String(error)];
494
- if (error instanceof CodexMcpTransportError && error.stderr) {
495
- lines.push(error.stderr);
496
- }
497
- console.error(`[codex-mcp] ${lines.join('\n')}`);
498
- process.exitCode = CODEX_MCP_TRANSPORT_EXIT_CODE;
499
- } finally {
500
- await worker.stop();
501
- }
502
- }
503
-
504
- if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
505
- await runCodexMcpCli();
506
- process.exit(process.exitCode ?? 0);
507
- }
1
+ // hub/workers/codex-mcp.mjs — Codex MCP 서버 래퍼
2
+ import process from "node:process";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
+
8
+ import {
9
+ CODEX_MCP_EXECUTION_EXIT_CODE,
10
+ CODEX_MCP_TRANSPORT_EXIT_CODE,
11
+ } from "@triflux/core/hub/cli-adapter-base.mjs";
12
+ import { withRetry } from "./worker-utils.mjs";
13
+
14
+ const REQUIRED_TOOLS = ["codex", "codex-reply"];
15
+
16
+ export { CODEX_MCP_EXECUTION_EXIT_CODE, CODEX_MCP_TRANSPORT_EXIT_CODE };
17
+ export const DEFAULT_CODEX_MCP_TIMEOUT_MS = 10 * 60 * 1000;
18
+ export const DEFAULT_CODEX_MCP_BOOTSTRAP_TIMEOUT_MS = 120 * 1000;
19
+ export const DEFAULT_CODEX_MCP_SHUTDOWN_TIMEOUT_MS = 2_000;
20
+
21
+ /**
22
+ * Codex MCP transport/bootstrap 계층 오류
23
+ */
24
+ export class CodexMcpTransportError extends Error {
25
+ /**
26
+ * @param {string} message
27
+ * @param {object} [options]
28
+ * @param {unknown} [options.cause]
29
+ * @param {string} [options.stderr]
30
+ */
31
+ constructor(message, options = {}) {
32
+ super(message, { cause: options.cause });
33
+ this.name = "CodexMcpTransportError";
34
+ this.stderr = options.stderr || "";
35
+ }
36
+ }
37
+
38
+ function cloneEnv(env = process.env) {
39
+ return Object.fromEntries(
40
+ Object.entries(env).filter(([, value]) => typeof value === "string"),
41
+ );
42
+ }
43
+
44
+ async function closeWithin(label, closeFn, timeoutMs) {
45
+ let timer = null;
46
+ try {
47
+ const timeout = new Promise((resolve) => {
48
+ timer = setTimeout(() => {
49
+ resolve({ timedOut: true });
50
+ }, timeoutMs);
51
+ });
52
+ const closed = Promise.resolve()
53
+ .then(closeFn)
54
+ .then(() => ({ timedOut: false }));
55
+ const result = await Promise.race([closed, timeout]);
56
+ if (result.timedOut) {
57
+ console.error(
58
+ `[codex-mcp] WARNING: ${label} did not close within ${timeoutMs}ms; continuing shutdown.`,
59
+ );
60
+ }
61
+ return result;
62
+ } catch (error) {
63
+ console.error(
64
+ `[codex-mcp] WARNING: ${label} close failed: ${error instanceof Error ? error.message : String(error)}`,
65
+ );
66
+ return { timedOut: false };
67
+ } finally {
68
+ if (timer) clearTimeout(timer);
69
+ }
70
+ }
71
+
72
+ function collectTextContent(content = []) {
73
+ return content
74
+ .filter((item) => item?.type === "text" && typeof item.text === "string")
75
+ .map((item) => item.text)
76
+ .join("\n")
77
+ .trim();
78
+ }
79
+
80
+ function normalizeStructuredContent(structuredContent, fallbackText = "") {
81
+ if (!structuredContent || typeof structuredContent !== "object") {
82
+ return { threadId: null, content: fallbackText };
83
+ }
84
+
85
+ const threadId =
86
+ typeof structuredContent.threadId === "string"
87
+ ? structuredContent.threadId
88
+ : null;
89
+ const content =
90
+ typeof structuredContent.content === "string"
91
+ ? structuredContent.content
92
+ : fallbackText;
93
+
94
+ return { threadId, content };
95
+ }
96
+
97
+ function buildCodexArguments(prompt, opts = {}) {
98
+ const args = { prompt };
99
+
100
+ if (typeof opts.cwd === "string" && opts.cwd) args.cwd = opts.cwd;
101
+ if (typeof opts.model === "string" && opts.model) args.model = opts.model;
102
+ if (typeof opts.profile === "string" && opts.profile)
103
+ args.profile = opts.profile;
104
+ if (typeof opts.approvalPolicy === "string" && opts.approvalPolicy) {
105
+ args["approval-policy"] = opts.approvalPolicy;
106
+ }
107
+ if (typeof opts.sandbox === "string" && opts.sandbox)
108
+ args.sandbox = opts.sandbox;
109
+ if (opts.config && typeof opts.config === "object") args.config = opts.config;
110
+ if (typeof opts.baseInstructions === "string" && opts.baseInstructions) {
111
+ args["base-instructions"] = opts.baseInstructions;
112
+ }
113
+ if (
114
+ typeof opts.developerInstructions === "string" &&
115
+ opts.developerInstructions
116
+ ) {
117
+ args["developer-instructions"] = opts.developerInstructions;
118
+ }
119
+ if (typeof opts.compactPrompt === "string" && opts.compactPrompt) {
120
+ args["compact-prompt"] = opts.compactPrompt;
121
+ }
122
+
123
+ return args;
124
+ }
125
+
126
+ function pickToolName(threadId) {
127
+ return threadId ? "codex-reply" : "codex";
128
+ }
129
+
130
+ function withTimeout(promise, timeoutMs, message) {
131
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
132
+ return promise;
133
+ }
134
+
135
+ let timer;
136
+ const timeoutPromise = new Promise((_, reject) => {
137
+ timer = setTimeout(() => {
138
+ reject(new Error(message));
139
+ }, timeoutMs);
140
+ timer.unref?.();
141
+ });
142
+
143
+ return Promise.race([promise, timeoutPromise]).finally(() => {
144
+ clearTimeout(timer);
145
+ });
146
+ }
147
+
148
+ // Known noise-only output patterns from codex MCP that indicate transport failure
149
+ // despite exitCode=0. Each entry is matched after trim() against the full output
150
+ // string. Length cap is applied separately to avoid masking real responses that
151
+ // happen to contain a noise substring as a header line.
152
+ const NOISE_ONLY_OUTPUT_PATTERNS = Object.freeze([
153
+ /^MCP issues detected\.\s*Run \/mcp list for status\.?$/i,
154
+ ]);
155
+ const NOISE_ONLY_MAX_BYTES = 256;
156
+
157
+ function isNoiseOnlyOutput(text) {
158
+ if (typeof text !== "string") return false;
159
+ const trimmed = text.trim();
160
+ if (!trimmed || trimmed.length > NOISE_ONLY_MAX_BYTES) return false;
161
+ return NOISE_ONLY_OUTPUT_PATTERNS.some((pattern) => pattern.test(trimmed));
162
+ }
163
+
164
+ function watchBootstrapClose(transport) {
165
+ let active = true;
166
+ const promise = new Promise((_, reject) => {
167
+ const priorClose = transport.onclose;
168
+ transport.onclose = () => {
169
+ priorClose?.();
170
+ if (active) {
171
+ reject(new Error("Codex MCP transport closed during bootstrap"));
172
+ }
173
+ };
174
+
175
+ const priorError = transport.onerror;
176
+ transport.onerror = (error) => {
177
+ priorError?.(error);
178
+ if (active) {
179
+ reject(error instanceof Error ? error : new Error(String(error)));
180
+ }
181
+ };
182
+ });
183
+
184
+ return {
185
+ promise,
186
+ stop() {
187
+ active = false;
188
+ },
189
+ };
190
+ }
191
+
192
+ function watchBootstrapProcessExit(transport) {
193
+ let active = true;
194
+ let timer = null;
195
+ let child = null;
196
+
197
+ const promise = new Promise((_, reject) => {
198
+ const rejectClosed = (code, signal) => {
199
+ if (!active) return;
200
+ const suffix = signal ? `signal ${signal}` : `exit ${code}`;
201
+ reject(
202
+ new Error(`Codex MCP transport closed during bootstrap (${suffix})`),
203
+ );
204
+ };
205
+
206
+ timer = setInterval(() => {
207
+ child = transport._process;
208
+ if (!child) return;
209
+ clearInterval(timer);
210
+ timer = null;
211
+ if (child.exitCode !== null || child.signalCode !== null) {
212
+ rejectClosed(child.exitCode, child.signalCode);
213
+ return;
214
+ }
215
+ child.once("close", rejectClosed);
216
+ }, 10);
217
+ });
218
+
219
+ return {
220
+ promise,
221
+ stop() {
222
+ active = false;
223
+ if (timer) clearInterval(timer);
224
+ child?.off?.("close", () => {});
225
+ },
226
+ };
227
+ }
228
+
229
+ function normalizeRetryOptions(retryOptions) {
230
+ if (!retryOptions || typeof retryOptions !== "object") {
231
+ return Object.freeze({});
232
+ }
233
+ return Object.freeze({ ...retryOptions });
234
+ }
235
+
236
+ function isCodexRetryable(error) {
237
+ return (
238
+ error instanceof CodexMcpTransportError ||
239
+ error?.code === "ETIMEDOUT" ||
240
+ error?.cause?.code === "ETIMEDOUT"
241
+ );
242
+ }
243
+
244
+ function detectWorkerCategory(error, fallbackCategory = "transient") {
245
+ const combined =
246
+ `${error?.message || ""}\n${error?.stderr || ""}`.toLowerCase();
247
+
248
+ if (error?.code === "INVALID_INPUT") return "input";
249
+ if (
250
+ /(unauthorized|forbidden|auth|login|token|credential|apikey|api key)/i.test(
251
+ combined,
252
+ )
253
+ ) {
254
+ return "auth";
255
+ }
256
+ if (
257
+ /(config|unknown option|invalid option|missing|필수 mcp 도구 누락)/i.test(
258
+ combined,
259
+ )
260
+ ) {
261
+ return "config";
262
+ }
263
+
264
+ return fallbackCategory;
265
+ }
266
+
267
+ function buildCodexErrorInfo(error, attempts) {
268
+ const retryable = isCodexRetryable(error);
269
+ const code =
270
+ error instanceof CodexMcpTransportError
271
+ ? "CODEX_TRANSPORT_ERROR"
272
+ : error?.code || "CODEX_EXECUTION_ERROR";
273
+ const category = detectWorkerCategory(
274
+ error,
275
+ retryable ? "transient" : "config",
276
+ );
277
+
278
+ let recovery =
279
+ "Review the Codex worker error output and retry after correcting the issue.";
280
+ if (code === "INVALID_INPUT") {
281
+ recovery = "Provide a non-empty prompt before invoking the Codex worker.";
282
+ } else if (retryable) {
283
+ recovery = "Retry after reconnecting the Codex MCP transport.";
284
+ } else if (category === "auth") {
285
+ recovery = "Refresh the Codex authentication state and retry.";
286
+ } else if (category === "config") {
287
+ recovery = "Check the Codex MCP configuration and available tools.";
288
+ }
289
+
290
+ return Object.freeze({
291
+ code,
292
+ retryable,
293
+ attempts,
294
+ category,
295
+ recovery,
296
+ });
297
+ }
298
+
299
+ /**
300
+ * Codex MCP 워커
301
+ */
302
+ export class CodexMcpWorker {
303
+ type = "codex";
304
+
305
+ /**
306
+ * @param {object} [options]
307
+ * @param {string} [options.command]
308
+ * @param {string[]} [options.args]
309
+ * @param {string} [options.cwd]
310
+ * @param {Record<string, string>} [options.env]
311
+ * @param {{ name: string, version: string }} [options.clientInfo]
312
+ * @param {number} [options.bootstrapTimeoutMs]
313
+ */
314
+ constructor(options = {}) {
315
+ this.command = options.command || process.env.CODEX_BIN || "codex";
316
+ this.args =
317
+ Array.isArray(options.args) && options.args.length
318
+ ? [...options.args]
319
+ : ["mcp-server"];
320
+ this.cwd = options.cwd || process.cwd();
321
+ this.env = cloneEnv({ ...cloneEnv(process.env), ...cloneEnv(options.env) });
322
+ this.clientInfo = options.clientInfo || {
323
+ name: "triflux-codex-mcp",
324
+ version: "1.0.0",
325
+ };
326
+ this.bootstrapTimeoutMs = Number.isFinite(options.bootstrapTimeoutMs)
327
+ ? options.bootstrapTimeoutMs
328
+ : DEFAULT_CODEX_MCP_BOOTSTRAP_TIMEOUT_MS;
329
+ this.retryOptions = normalizeRetryOptions(options.retryOptions);
330
+
331
+ this.client = null;
332
+ this.transport = null;
333
+ this.ready = false;
334
+ this.availableTools = new Set();
335
+ this.threadIds = new Map();
336
+ this.serverStderr = "";
337
+ }
338
+
339
+ isReady() {
340
+ return this.ready;
341
+ }
342
+
343
+ getThreadId(sessionKey) {
344
+ return this.threadIds.get(sessionKey) || null;
345
+ }
346
+
347
+ setThreadId(sessionKey, threadId) {
348
+ if (!sessionKey || !threadId) return;
349
+ this.threadIds.set(sessionKey, threadId);
350
+ }
351
+
352
+ clearThread(sessionKey) {
353
+ if (!sessionKey) return;
354
+ this.threadIds.delete(sessionKey);
355
+ }
356
+
357
+ async start() {
358
+ if (this.ready && this.client && this.transport) return;
359
+
360
+ await this.stop();
361
+
362
+ const transport = new StdioClientTransport({
363
+ command: this.command,
364
+ args: this.args,
365
+ cwd: this.cwd,
366
+ env: this.env,
367
+ stderr: "pipe",
368
+ });
369
+ const client = new Client(this.clientInfo, { capabilities: {} });
370
+
371
+ this.serverStderr = "";
372
+ transport.stderr?.on("data", (chunk) => {
373
+ this.serverStderr += String(chunk);
374
+ if (this.serverStderr.length > 16000) {
375
+ this.serverStderr = this.serverStderr.slice(-16000);
376
+ }
377
+ });
378
+
379
+ try {
380
+ const bootstrapClose = watchBootstrapClose(transport);
381
+ const bootstrapProcessExit = watchBootstrapProcessExit(transport);
382
+ await withTimeout(
383
+ Promise.race([
384
+ (async () => {
385
+ await client.connect(transport);
386
+ const tools = await client.listTools(undefined, {
387
+ timeout: this.bootstrapTimeoutMs,
388
+ });
389
+ this.availableTools = new Set(tools.tools.map((tool) => tool.name));
390
+
391
+ for (const requiredTool of REQUIRED_TOOLS) {
392
+ if (!this.availableTools.has(requiredTool)) {
393
+ throw new Error(`필수 MCP 도구 누락: ${requiredTool}`);
394
+ }
395
+ }
396
+ })(),
397
+ bootstrapClose.promise,
398
+ bootstrapProcessExit.promise,
399
+ ]).finally(() => {
400
+ bootstrapClose.stop();
401
+ bootstrapProcessExit.stop();
402
+ }),
403
+ this.bootstrapTimeoutMs,
404
+ `Codex MCP bootstrap timeout (${this.bootstrapTimeoutMs}ms)`,
405
+ );
406
+ } catch (error) {
407
+ await client.close().catch(() => {});
408
+ transport.stderr?.destroy?.();
409
+ throw new CodexMcpTransportError(
410
+ `Codex MCP 연결 실패: ${error instanceof Error ? error.message : String(error)}`,
411
+ { cause: error, stderr: this.serverStderr.trim() },
412
+ );
413
+ }
414
+
415
+ this.client = client;
416
+ this.transport = transport;
417
+ this.ready = true;
418
+ }
419
+
420
+ async stop(options = {}) {
421
+ this.ready = false;
422
+ this.availableTools.clear();
423
+
424
+ const client = this.client;
425
+ const transport = this.transport;
426
+ this.transport = null;
427
+ this.client = null;
428
+
429
+ const shutdownTimeoutMs = Number.isFinite(options.shutdownTimeoutMs)
430
+ ? options.shutdownTimeoutMs
431
+ : DEFAULT_CODEX_MCP_SHUTDOWN_TIMEOUT_MS;
432
+
433
+ let clientCloseTimedOut = false;
434
+ if (client) {
435
+ const result = await closeWithin(
436
+ "client.close",
437
+ () => client.close(),
438
+ shutdownTimeoutMs,
439
+ );
440
+ clientCloseTimedOut = Boolean(result.timedOut);
441
+ }
442
+
443
+ if (transport && (!client || clientCloseTimedOut)) {
444
+ await closeWithin(
445
+ "transport.close",
446
+ () => transport.close(),
447
+ shutdownTimeoutMs,
448
+ );
449
+ }
450
+
451
+ transport?.stderr?.destroy?.();
452
+ }
453
+
454
+ /**
455
+ * @param {string} prompt
456
+ * @param {import('./interface.mjs').WorkerExecuteOptions} [opts]
457
+ * @returns {Promise<import('./interface.mjs').WorkerResult>}
458
+ */
459
+ async execute(prompt, opts = {}) {
460
+ if (typeof prompt !== "string" || !prompt.trim()) {
461
+ return {
462
+ output: "prompt는 비어 있을 수 없습니다.",
463
+ exitCode: CODEX_MCP_EXECUTION_EXIT_CODE,
464
+ threadId: null,
465
+ sessionKey: opts.sessionKey || null,
466
+ error: buildCodexErrorInfo(
467
+ { code: "INVALID_INPUT", message: "prompt는 비어 있을 수 없습니다." },
468
+ 0,
469
+ ),
470
+ raw: null,
471
+ };
472
+ }
473
+
474
+ const sessionKey =
475
+ typeof opts.sessionKey === "string" && opts.sessionKey
476
+ ? opts.sessionKey
477
+ : null;
478
+
479
+ if (opts.resetSession && sessionKey) {
480
+ this.clearThread(sessionKey);
481
+ }
482
+
483
+ const threadId =
484
+ typeof opts.threadId === "string" && opts.threadId
485
+ ? opts.threadId
486
+ : sessionKey
487
+ ? this.getThreadId(sessionKey)
488
+ : null;
489
+ const timeoutMs = Number.isFinite(opts.timeoutMs)
490
+ ? opts.timeoutMs
491
+ : DEFAULT_CODEX_MCP_TIMEOUT_MS;
492
+ let attempts = 0;
493
+ let activeThreadId = threadId;
494
+
495
+ try {
496
+ const { rawResult, normalized } = await withRetry(
497
+ async () => {
498
+ attempts += 1;
499
+ if (attempts === 1) {
500
+ await this.start();
501
+ } else {
502
+ await this.stop();
503
+ await this.start();
504
+ }
505
+
506
+ const toolName = pickToolName(activeThreadId);
507
+ const toolArguments =
508
+ toolName === "codex-reply"
509
+ ? { prompt, threadId: activeThreadId }
510
+ : buildCodexArguments(prompt, opts);
511
+
512
+ const nextRawResult = await this.client.callTool(
513
+ { name: toolName, arguments: toolArguments },
514
+ undefined,
515
+ { timeout: timeoutMs },
516
+ );
517
+
518
+ const textContent = collectTextContent(nextRawResult.content);
519
+ const nextNormalized = normalizeStructuredContent(
520
+ nextRawResult.structuredContent,
521
+ textContent,
522
+ );
523
+ activeThreadId = nextNormalized.threadId || activeThreadId;
524
+
525
+ return { rawResult: nextRawResult, normalized: nextNormalized };
526
+ },
527
+ {
528
+ ...this.retryOptions,
529
+ shouldRetry: (error) => isCodexRetryable(error),
530
+ },
531
+ );
532
+
533
+ if (sessionKey && normalized.threadId) {
534
+ this.setThreadId(sessionKey, normalized.threadId);
535
+ }
536
+
537
+ if (rawResult.isError) {
538
+ return {
539
+ output: normalized.content,
540
+ exitCode: CODEX_MCP_EXECUTION_EXIT_CODE,
541
+ threadId: normalized.threadId,
542
+ sessionKey,
543
+ error: buildCodexErrorInfo(
544
+ { code: "CODEX_TOOL_ERROR", message: normalized.content },
545
+ attempts,
546
+ ),
547
+ raw: rawResult,
548
+ };
549
+ }
550
+
551
+ return {
552
+ output: normalized.content,
553
+ exitCode: 0,
554
+ threadId: normalized.threadId,
555
+ sessionKey,
556
+ raw: rawResult,
557
+ };
558
+ } catch (error) {
559
+ await this.stop().catch(() => {});
560
+ return {
561
+ output: error instanceof Error ? error.message : String(error),
562
+ exitCode: CODEX_MCP_EXECUTION_EXIT_CODE,
563
+ threadId: activeThreadId,
564
+ sessionKey,
565
+ error: buildCodexErrorInfo(error, attempts || 1),
566
+ raw: null,
567
+ };
568
+ }
569
+ }
570
+ }
571
+
572
+ export function createCodexMcpWorker(options = {}) {
573
+ return new CodexMcpWorker(options);
574
+ }
575
+
576
+ function parseCliArgs(argv) {
577
+ const options = {
578
+ command: process.env.CODEX_BIN || "codex",
579
+ cwd: process.cwd(),
580
+ timeoutMs: DEFAULT_CODEX_MCP_TIMEOUT_MS,
581
+ };
582
+
583
+ for (let i = 0; i < argv.length; i += 1) {
584
+ const token = argv[i];
585
+ const next = () => {
586
+ const value = argv[i + 1];
587
+ if (value === undefined) {
588
+ throw new Error(`${token} 값이 필요합니다.`);
589
+ }
590
+ i += 1;
591
+ return value;
592
+ };
593
+
594
+ switch (token) {
595
+ case "--prompt":
596
+ options.prompt = next();
597
+ break;
598
+ case "--thread-id":
599
+ options.threadId = next();
600
+ break;
601
+ case "--session-key":
602
+ options.sessionKey = next();
603
+ break;
604
+ case "--cwd":
605
+ options.cwd = next();
606
+ break;
607
+ case "--profile":
608
+ options.profile = next();
609
+ break;
610
+ case "--model":
611
+ options.model = next();
612
+ break;
613
+ case "--approval-policy":
614
+ options.approvalPolicy = next();
615
+ break;
616
+ case "--sandbox":
617
+ options.sandbox = next();
618
+ break;
619
+ case "--base-instructions":
620
+ options.baseInstructions = next();
621
+ break;
622
+ case "--developer-instructions":
623
+ options.developerInstructions = next();
624
+ break;
625
+ case "--compact-prompt":
626
+ options.compactPrompt = next();
627
+ break;
628
+ case "--timeout-ms":
629
+ options.timeoutMs = Number.parseInt(next(), 10);
630
+ break;
631
+ case "--config-json":
632
+ options.config = JSON.parse(next());
633
+ break;
634
+ case "--codex-command":
635
+ options.command = next();
636
+ break;
637
+ case "--reset-session":
638
+ options.resetSession = true;
639
+ break;
640
+ default:
641
+ throw new Error(`알 수 없는 옵션: ${token}`);
642
+ }
643
+ }
644
+
645
+ if (typeof options.prompt !== "string" || !options.prompt) {
646
+ throw new Error("--prompt는 필수입니다.");
647
+ }
648
+
649
+ return options;
650
+ }
651
+
652
+ export async function runCodexMcpCli(argv = process.argv.slice(2)) {
653
+ let options;
654
+ try {
655
+ options = parseCliArgs(argv);
656
+ } catch (error) {
657
+ console.error(
658
+ `[codex-mcp] ${error instanceof Error ? error.message : String(error)}`,
659
+ );
660
+ process.exitCode = 64;
661
+ return;
662
+ }
663
+
664
+ const worker = new CodexMcpWorker({
665
+ command: options.command,
666
+ cwd: options.cwd,
667
+ retryOptions: { maxAttempts: 1 },
668
+ });
669
+
670
+ try {
671
+ const result = await worker.execute(options.prompt, options);
672
+ const hasOutput =
673
+ typeof result.output === "string" && result.output.trim().length > 0;
674
+ const noiseOnly = hasOutput && isNoiseOnlyOutput(result.output);
675
+ if (hasOutput) {
676
+ process.stdout.write(result.output);
677
+ if (!result.output.endsWith("\n")) {
678
+ process.stdout.write("\n");
679
+ }
680
+ }
681
+ // Silent-success regression guard (codex 0.124.0+):
682
+ // codex MCP can return exitCode=0 with empty/whitespace `output` when the
683
+ // result-extraction path fails to read the assistant content from the MCP
684
+ // response. Without this guard, tfx-route.sh sees STDOUT_LOG=0 bytes and
685
+ // treats it as success — caller gets nothing back.
686
+ // Promote to a transport failure so the wrapper falls back to `codex exec`.
687
+ //
688
+ // Noise-only guard (codex 0.125.0+, P2 swarm signature):
689
+ // codex MCP can also return exitCode=0 with a short noise-only message like
690
+ // "MCP issues detected. Run /mcp list for status." (~48 bytes) instead of
691
+ // an actual response payload. trim().length > 0 passes the empty-output
692
+ // check, so the previous guard misses this case. Pattern-match known noise
693
+ // strings under a small length cap and promote to transport failure too.
694
+ if (result.error?.code === "CODEX_TRANSPORT_ERROR") {
695
+ process.exitCode = CODEX_MCP_TRANSPORT_EXIT_CODE;
696
+ } else if (!hasOutput && result.exitCode === 0) {
697
+ console.error(
698
+ "[codex-mcp] WARNING: empty output with exit 0 — treating as transport failure (codex 0.124.0 silent-flush regression). Wrapper will fall back to exec path.",
699
+ );
700
+ process.exitCode = CODEX_MCP_TRANSPORT_EXIT_CODE;
701
+ } else if (noiseOnly && result.exitCode === 0) {
702
+ console.error(
703
+ "[codex-mcp] WARNING: noise-only output (codex MCP error string without payload) with exit 0 — treating as transport failure. Wrapper will fall back to exec path.",
704
+ );
705
+ process.exitCode = CODEX_MCP_TRANSPORT_EXIT_CODE;
706
+ } else {
707
+ process.exitCode = result.exitCode;
708
+ }
709
+ } catch (error) {
710
+ const lines = [error instanceof Error ? error.message : String(error)];
711
+ if (error instanceof CodexMcpTransportError && error.stderr) {
712
+ lines.push(error.stderr);
713
+ }
714
+ console.error(`[codex-mcp] ${lines.join("\n")}`);
715
+ process.exitCode = CODEX_MCP_TRANSPORT_EXIT_CODE;
716
+ } finally {
717
+ await worker.stop();
718
+ }
719
+ }
720
+
721
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
722
+ await runCodexMcpCli();
723
+ process.exit(process.exitCode ?? 0);
724
+ }