triflux 3.3.0-dev.7 → 4.0.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 (91) hide show
  1. package/README.ko.md +108 -199
  2. package/README.md +108 -199
  3. package/bin/triflux.mjs +2415 -1762
  4. package/hooks/keyword-rules.json +361 -354
  5. package/hooks/pipeline-stop.mjs +5 -2
  6. package/hub/assign-callbacks.mjs +136 -136
  7. package/hub/bridge.mjs +734 -708
  8. package/hub/delegator/contracts.mjs +38 -0
  9. package/hub/delegator/index.mjs +14 -0
  10. package/hub/delegator/schema/delegator-tools.schema.json +250 -0
  11. package/hub/delegator/service.mjs +302 -0
  12. package/hub/delegator/tool-definitions.mjs +35 -0
  13. package/hub/hitl.mjs +67 -67
  14. package/hub/paths.mjs +28 -0
  15. package/hub/pipe.mjs +589 -561
  16. package/hub/pipeline/state.mjs +23 -0
  17. package/hub/public/dashboard.html +349 -0
  18. package/hub/public/tray-icon.ico +0 -0
  19. package/hub/public/tray-icon.png +0 -0
  20. package/hub/router.mjs +782 -782
  21. package/hub/schema.sql +40 -40
  22. package/hub/server.mjs +810 -637
  23. package/hub/store.mjs +706 -706
  24. package/hub/team/cli/commands/attach.mjs +37 -0
  25. package/hub/team/cli/commands/control.mjs +43 -0
  26. package/hub/team/cli/commands/debug.mjs +74 -0
  27. package/hub/team/cli/commands/focus.mjs +53 -0
  28. package/hub/team/cli/commands/interrupt.mjs +36 -0
  29. package/hub/team/cli/commands/kill.mjs +37 -0
  30. package/hub/team/cli/commands/list.mjs +24 -0
  31. package/hub/team/cli/commands/send.mjs +37 -0
  32. package/hub/team/cli/commands/start/index.mjs +87 -0
  33. package/hub/team/cli/commands/start/parse-args.mjs +32 -0
  34. package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
  35. package/hub/team/cli/commands/start/start-mux.mjs +73 -0
  36. package/hub/team/cli/commands/start/start-wt.mjs +69 -0
  37. package/hub/team/cli/commands/status.mjs +87 -0
  38. package/hub/team/cli/commands/stop.mjs +31 -0
  39. package/hub/team/cli/commands/task.mjs +30 -0
  40. package/hub/team/cli/commands/tasks.mjs +13 -0
  41. package/hub/team/{cli.mjs → cli/help.mjs} +38 -99
  42. package/hub/team/cli/index.mjs +39 -0
  43. package/hub/team/cli/manifest.mjs +28 -0
  44. package/hub/team/cli/render.mjs +30 -0
  45. package/hub/team/cli/services/attach-fallback.mjs +54 -0
  46. package/hub/team/cli/services/hub-client.mjs +171 -0
  47. package/hub/team/cli/services/member-selector.mjs +30 -0
  48. package/hub/team/cli/services/native-control.mjs +115 -0
  49. package/hub/team/cli/services/runtime-mode.mjs +60 -0
  50. package/hub/team/cli/services/state-store.mjs +34 -0
  51. package/hub/team/cli/services/task-model.mjs +30 -0
  52. package/hub/team/native-supervisor.mjs +69 -63
  53. package/hub/team/native.mjs +367 -266
  54. package/hub/team/nativeProxy.mjs +217 -173
  55. package/hub/team/pane.mjs +149 -149
  56. package/hub/team/psmux.mjs +946 -946
  57. package/hub/team/session.mjs +608 -608
  58. package/hub/team/staleState.mjs +369 -299
  59. package/hub/tools.mjs +107 -107
  60. package/hub/tray.mjs +332 -0
  61. package/hub/workers/claude-worker.mjs +446 -446
  62. package/hub/workers/codex-mcp.mjs +414 -414
  63. package/hub/workers/delegator-mcp.mjs +1045 -1045
  64. package/hub/workers/factory.mjs +21 -21
  65. package/hub/workers/gemini-worker.mjs +349 -349
  66. package/hub/workers/interface.mjs +41 -41
  67. package/package.json +61 -60
  68. package/scripts/__tests__/keyword-detector.test.mjs +234 -234
  69. package/scripts/hub-ensure.mjs +102 -101
  70. package/scripts/keyword-detector.mjs +272 -272
  71. package/scripts/keyword-rules-expander.mjs +521 -521
  72. package/scripts/lib/keyword-rules.mjs +168 -168
  73. package/scripts/lib/mcp-filter.mjs +642 -642
  74. package/scripts/lib/mcp-server-catalog.mjs +118 -118
  75. package/scripts/mcp-check.mjs +126 -126
  76. package/scripts/preflight-cache.mjs +19 -0
  77. package/scripts/run.cjs +62 -62
  78. package/scripts/setup.mjs +68 -31
  79. package/scripts/test-tfx-route-no-claude-native.mjs +57 -57
  80. package/scripts/tfx-route-worker.mjs +161 -161
  81. package/scripts/tfx-route.sh +1360 -1326
  82. package/skills/tfx-auto/SKILL.md +196 -196
  83. package/skills/tfx-auto-codex/SKILL.md +77 -77
  84. package/skills/tfx-multi/SKILL.md +378 -378
  85. package/hub/team/cli-team-common.mjs +0 -348
  86. package/hub/team/cli-team-control.mjs +0 -393
  87. package/hub/team/cli-team-start.mjs +0 -516
  88. package/hub/team/cli-team-status.mjs +0 -283
  89. package/skills/auto-verify/SKILL.md +0 -145
  90. package/skills/manage-skills/SKILL.md +0 -192
  91. package/skills/verify-implementation/SKILL.md +0 -138
package/hub/server.mjs CHANGED
@@ -1,637 +1,810 @@
1
- // hub/server.mjs — HTTP MCP + REST bridge + Named Pipe 서버 진입점
2
- import { createServer as createHttpServer } from 'node:http';
3
- import { randomUUID } from 'node:crypto';
4
- import { join } from 'node:path';
5
- import { homedir } from 'node:os';
6
- import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
7
-
8
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
10
- import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
11
-
12
- import { createStore } from './store.mjs';
13
- import { createRouter } from './router.mjs';
14
- import { createHitlManager } from './hitl.mjs';
15
- import { createPipeServer } from './pipe.mjs';
16
- import { createAssignCallbackServer } from './assign-callbacks.mjs';
17
- import { createTools } from './tools.mjs';
18
-
19
- const MAX_BODY_SIZE = 1024 * 1024;
20
- const PUBLIC_PATHS = new Set(['/', '/status', '/health', '/healthz']);
21
- const LOOPBACK_REMOTE_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
22
- const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
23
-
24
- function isInitializeRequest(body) {
25
- if (body?.method === 'initialize') return true;
26
- if (Array.isArray(body)) return body.some((message) => message.method === 'initialize');
27
- return false;
28
- }
29
-
30
- async function parseBody(req) {
31
- const chunks = [];
32
- let size = 0;
33
- for await (const chunk of req) {
34
- size += chunk.length;
35
- if (size > MAX_BODY_SIZE) {
36
- throw Object.assign(new Error('Body too large'), { statusCode: 413 });
37
- }
38
- chunks.push(chunk);
39
- }
40
- return JSON.parse(Buffer.concat(chunks).toString());
41
- }
42
-
43
- const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
44
- const PID_FILE = join(PID_DIR, 'hub.pid');
45
- const TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
46
-
47
- function isAllowedOrigin(origin) {
48
- return origin && ALLOWED_ORIGIN_RE.test(origin);
49
- }
50
-
51
- function getRequestPath(url = '/') {
52
- try {
53
- return new URL(url, 'http://127.0.0.1').pathname;
54
- } catch {
55
- return String(url).replace(/\?.*/, '') || '/';
56
- }
57
- }
58
-
59
- function isLoopbackRemoteAddress(remoteAddress) {
60
- return typeof remoteAddress === 'string' && LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress);
61
- }
62
-
63
- function extractBearerToken(req) {
64
- const authHeader = typeof req.headers.authorization === 'string' ? req.headers.authorization : '';
65
- return authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
66
- }
67
-
68
- function writeJson(res, statusCode, body, headers = {}) {
69
- res.writeHead(statusCode, {
70
- 'Content-Type': 'application/json',
71
- ...headers,
72
- });
73
- res.end(JSON.stringify(body));
74
- }
75
-
76
- function applyCorsHeaders(req, res) {
77
- const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
78
- if (origin) {
79
- res.setHeader('Vary', 'Origin');
80
- }
81
- if (!isAllowedOrigin(origin)) return false;
82
-
83
- res.setHeader('Access-Control-Allow-Origin', origin);
84
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
85
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
86
- return true;
87
- }
88
-
89
- function isAuthorizedRequest(req, path, hubToken) {
90
- if (!hubToken) {
91
- return isLoopbackRemoteAddress(req.socket.remoteAddress);
92
- }
93
- if (PUBLIC_PATHS.has(path)) return true;
94
- return extractBearerToken(req) === hubToken;
95
- }
96
-
97
- function resolveTeamStatusCode(result) {
98
- if (result?.ok) return 200;
99
- const code = result?.error?.code;
100
- if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') return 404;
101
- if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') return 409;
102
- if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') return 400;
103
- return 500;
104
- }
105
-
106
- function resolvePipelineStatusCode(result) {
107
- if (result?.ok) return 200;
108
- if (result?.error === 'pipeline_not_found') return 404;
109
- if (result?.error === 'hub_db_not_found') return 503;
110
- return 400;
111
- }
112
-
113
- /**
114
- * tfx-hub 시작
115
- * @param {object} opts
116
- * @param {number} [opts.port]
117
- * @param {string} [opts.dbPath]
118
- * @param {string} [opts.host]
119
- * @param {string|number} [opts.sessionId]
120
- */
121
- export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessionId = process.pid } = {}) {
122
- if (!dbPath) {
123
- dbPath = join(PID_DIR, 'state.db');
124
- }
125
-
126
- const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
127
- if (HUB_TOKEN) {
128
- mkdirSync(join(homedir(), '.claude'), { recursive: true });
129
- writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
130
- } else {
131
- try { unlinkSync(TOKEN_FILE); } catch {}
132
- }
133
-
134
- const store = createStore(dbPath);
135
- const router = createRouter(store);
136
- const pipe = createPipeServer({ router, store, sessionId });
137
- const assignCallbacks = createAssignCallbackServer({ store, sessionId });
138
- const hitl = createHitlManager(store, router);
139
- const tools = createTools(store, router, hitl, pipe);
140
- const transports = new Map();
141
-
142
- function createMcpForSession() {
143
- const mcp = new Server(
144
- { name: 'tfx-hub', version: '1.0.0' },
145
- { capabilities: { tools: {} } },
146
- );
147
-
148
- mcp.setRequestHandler(
149
- ListToolsRequestSchema,
150
- async () => ({
151
- tools: tools.map((tool) => ({
152
- name: tool.name,
153
- description: tool.description,
154
- inputSchema: tool.inputSchema,
155
- })),
156
- }),
157
- );
158
-
159
- mcp.setRequestHandler(
160
- CallToolRequestSchema,
161
- async (request) => {
162
- const { name, arguments: args } = request.params;
163
- const tool = tools.find((candidate) => candidate.name === name);
164
- if (!tool) {
165
- return {
166
- content: [{ type: 'text', text: JSON.stringify({ ok: false, error: { code: 'UNKNOWN_TOOL', message: `도구 없음: ${name}` } }) }],
167
- isError: true,
168
- };
169
- }
170
- return tool.handler(args || {});
171
- },
172
- );
173
-
174
- return mcp;
175
- }
176
-
177
- const httpServer = createHttpServer(async (req, res) => {
178
- const path = getRequestPath(req.url);
179
- const corsAllowed = applyCorsHeaders(req, res);
180
-
181
- if (req.method === 'OPTIONS') {
182
- const localOnlyMode = !HUB_TOKEN;
183
- const isLoopbackRequest = isLoopbackRemoteAddress(req.socket.remoteAddress);
184
- res.writeHead(corsAllowed && (!localOnlyMode || isLoopbackRequest) ? 204 : 403);
185
- return res.end();
186
- }
187
-
188
- if (!isAuthorizedRequest(req, path, HUB_TOKEN)) {
189
- if (!HUB_TOKEN) {
190
- return writeJson(res, 403, { ok: false, error: 'Forbidden: localhost only' });
191
- }
192
- return writeJson(
193
- res,
194
- 401,
195
- { ok: false, error: 'Unauthorized' },
196
- { 'WWW-Authenticate': 'Bearer realm="tfx-hub"' },
197
- );
198
- }
199
-
200
- if (path === '/' || path === '/status') {
201
- const status = router.getStatus('hub').data;
202
- return writeJson(res, 200, {
203
- ...status,
204
- sessions: transports.size,
205
- pid: process.pid,
206
- port,
207
- auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
208
- pipe_path: pipe.path,
209
- pipe: pipe.getStatus(),
210
- assign_callback_pipe_path: assignCallbacks.path,
211
- assign_callback_pipe: assignCallbacks.getStatus(),
212
- });
213
- }
214
-
215
- if (path === '/health' || path === '/healthz') {
216
- const status = router.getStatus('hub').data;
217
- const healthy = status?.hub?.state === 'healthy';
218
- return writeJson(res, healthy ? 200 : 503, { ok: healthy });
219
- }
220
-
221
- if (path.startsWith('/bridge')) {
222
- if (req.method !== 'POST' && req.method !== 'DELETE') {
223
- return writeJson(res, 405, { ok: false, error: 'Method Not Allowed' });
224
- }
225
-
226
- try {
227
- const body = req.method === 'POST' ? await parseBody(req) : {};
228
-
229
- if (path === '/bridge/register' && req.method === 'POST') {
230
- const { agent_id, cli, timeout_sec = 600, topics = [], capabilities = [], metadata = {} } = body;
231
- if (!agent_id || !cli) {
232
- return writeJson(res, 400, { ok: false, error: 'agent_id, cli 필수' });
233
- }
234
-
235
- const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
236
- const result = await pipe.executeCommand('register', {
237
- agent_id,
238
- cli,
239
- capabilities,
240
- topics,
241
- heartbeat_ttl_ms,
242
- metadata,
243
- });
244
- return writeJson(res, 200, result);
245
- }
246
-
247
- if (path === '/bridge/result' && req.method === 'POST') {
248
- const { agent_id, topic = 'task.result', payload = {}, trace_id, correlation_id } = body;
249
- if (!agent_id) {
250
- return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
251
- }
252
-
253
- const result = await pipe.executeCommand('result', {
254
- agent_id,
255
- topic,
256
- payload,
257
- trace_id,
258
- correlation_id,
259
- });
260
- return writeJson(res, 200, result);
261
- }
262
-
263
- if (path === '/bridge/control' && req.method === 'POST') {
264
- const {
265
- from_agent = 'lead',
266
- to_agent,
267
- command,
268
- reason = '',
269
- payload = {},
270
- trace_id,
271
- correlation_id,
272
- ttl_ms = 3600000,
273
- } = body;
274
-
275
- if (!to_agent || !command) {
276
- return writeJson(res, 400, { ok: false, error: 'to_agent, command 필수' });
277
- }
278
-
279
- const result = await pipe.executeCommand('control', {
280
- from_agent,
281
- to_agent,
282
- command,
283
- reason,
284
- payload,
285
- ttl_ms,
286
- trace_id,
287
- correlation_id,
288
- });
289
-
290
- return writeJson(res, 200, result);
291
- }
292
-
293
- if (path === '/bridge/assign/async' && req.method === 'POST') {
294
- const {
295
- supervisor_agent,
296
- worker_agent,
297
- task,
298
- topic = 'assign.job',
299
- payload = {},
300
- priority = 5,
301
- ttl_ms = 600000,
302
- timeout_ms = 600000,
303
- max_retries = 0,
304
- trace_id,
305
- correlation_id,
306
- } = body;
307
-
308
- if (!supervisor_agent || !worker_agent || !task) {
309
- return writeJson(res, 400, { ok: false, error: 'supervisor_agent, worker_agent, task 필수' });
310
- }
311
-
312
- const result = await pipe.executeCommand('assign', {
313
- supervisor_agent,
314
- worker_agent,
315
- task,
316
- topic,
317
- payload,
318
- priority,
319
- ttl_ms,
320
- timeout_ms,
321
- max_retries,
322
- trace_id,
323
- correlation_id,
324
- });
325
- return writeJson(res, result.ok ? 200 : 400, result);
326
- }
327
-
328
- if (path === '/bridge/assign/result' && req.method === 'POST') {
329
- const {
330
- job_id,
331
- worker_agent,
332
- status,
333
- attempt,
334
- result: assignResult,
335
- error: assignError,
336
- payload = {},
337
- metadata = {},
338
- } = body;
339
-
340
- if (!job_id || !status) {
341
- return writeJson(res, 400, { ok: false, error: 'job_id, status 필수' });
342
- }
343
-
344
- const result = await pipe.executeCommand('assign_result', {
345
- job_id,
346
- worker_agent,
347
- status,
348
- attempt,
349
- result: assignResult,
350
- error: assignError,
351
- payload,
352
- metadata,
353
- });
354
- return writeJson(res, result.ok ? 200 : 409, result);
355
- }
356
-
357
- if (path === '/bridge/assign/status' && req.method === 'POST') {
358
- const result = await pipe.executeQuery('assign_status', body);
359
- const statusCode = result.ok ? 200 : (result.error?.code === 'ASSIGN_NOT_FOUND' ? 404 : 400);
360
- return writeJson(res, statusCode, result);
361
- }
362
-
363
- if (path === '/bridge/assign/retry' && req.method === 'POST') {
364
- const { job_id, reason, requested_by } = body;
365
- if (!job_id) {
366
- return writeJson(res, 400, { ok: false, error: 'job_id 필수' });
367
- }
368
-
369
- const result = await pipe.executeCommand('assign_retry', {
370
- job_id,
371
- reason,
372
- requested_by,
373
- });
374
- const statusCode = result.ok ? 200
375
- : result.error?.code === 'ASSIGN_NOT_FOUND' ? 404
376
- : result.error?.code === 'ASSIGN_RETRY_EXHAUSTED' ? 409
377
- : 400;
378
- return writeJson(res, statusCode, result);
379
- }
380
-
381
- if (req.method === 'POST') {
382
- let teamResult = null;
383
- if (path === '/bridge/team/info' || path === '/bridge/team-info') {
384
- teamResult = await pipe.executeQuery('team_info', body);
385
- } else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
386
- teamResult = await pipe.executeQuery('team_task_list', body);
387
- } else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
388
- teamResult = await pipe.executeCommand('team_task_update', body);
389
- } else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
390
- teamResult = await pipe.executeCommand('team_send_message', body);
391
- }
392
-
393
- if (teamResult) {
394
- return writeJson(res, resolveTeamStatusCode(teamResult), teamResult);
395
- }
396
-
397
- if (path.startsWith('/bridge/team')) {
398
- return writeJson(res, 404, { ok: false, error: `Unknown team endpoint: ${path}` });
399
- }
400
-
401
- // ── 파이프라인 엔드포인트 ──
402
- if (path === '/bridge/pipeline/state' && req.method === 'POST') {
403
- const result = await pipe.executeQuery('pipeline_state', body);
404
- return writeJson(res, resolvePipelineStatusCode(result), result);
405
- }
406
-
407
- if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
408
- const result = await pipe.executeCommand('pipeline_advance', body);
409
- return writeJson(res, resolvePipelineStatusCode(result), result);
410
- }
411
-
412
- if (path === '/bridge/pipeline/init' && req.method === 'POST') {
413
- const result = await pipe.executeCommand('pipeline_init', body);
414
- return writeJson(res, resolvePipelineStatusCode(result), result);
415
- }
416
-
417
- if (path === '/bridge/pipeline/list' && req.method === 'POST') {
418
- const result = await pipe.executeQuery('pipeline_list', body);
419
- return writeJson(res, resolvePipelineStatusCode(result), result);
420
- }
421
- }
422
-
423
- if (path === '/bridge/context' && req.method === 'POST') {
424
- const { agent_id, topics, max_messages = 10, auto_ack = true } = body;
425
- if (!agent_id) {
426
- return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
427
- }
428
-
429
- const result = await pipe.executeQuery('drain', {
430
- agent_id,
431
- topics,
432
- max_messages,
433
- auto_ack,
434
- });
435
- return writeJson(res, 200, result);
436
- }
437
-
438
- if (path === '/bridge/deregister' && req.method === 'POST') {
439
- const { agent_id } = body;
440
- if (!agent_id) {
441
- return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
442
- }
443
- const result = await pipe.executeCommand('deregister', { agent_id });
444
- return writeJson(res, 200, result);
445
- }
446
-
447
- return writeJson(res, 404, { ok: false, error: 'Unknown bridge endpoint' });
448
- } catch (error) {
449
- if (!res.headersSent) {
450
- writeJson(res, 500, { ok: false, error: error.message });
451
- }
452
- return;
453
- }
454
- }
455
-
456
- if (path !== '/mcp') {
457
- res.writeHead(404);
458
- return res.end('Not Found');
459
- }
460
-
461
- try {
462
- const sessionIdHeader = req.headers['mcp-session-id'];
463
-
464
- if (req.method === 'POST') {
465
- const body = await parseBody(req);
466
-
467
- if (sessionIdHeader && transports.has(sessionIdHeader)) {
468
- const transport = transports.get(sessionIdHeader);
469
- transport._lastActivity = Date.now();
470
- await transport.handleRequest(req, res, body);
471
- } else if (!sessionIdHeader && isInitializeRequest(body)) {
472
- const transport = new StreamableHTTPServerTransport({
473
- sessionIdGenerator: () => randomUUID(),
474
- onsessioninitialized: (sid) => {
475
- transport._lastActivity = Date.now();
476
- transports.set(sid, transport);
477
- },
478
- });
479
- transport.onclose = () => {
480
- if (transport.sessionId) transports.delete(transport.sessionId);
481
- };
482
- const mcp = createMcpForSession();
483
- await mcp.connect(transport);
484
- await transport.handleRequest(req, res, body);
485
- } else {
486
- res.writeHead(400, { 'Content-Type': 'application/json' });
487
- res.end(JSON.stringify({
488
- jsonrpc: '2.0',
489
- error: { code: -32000, message: 'Bad Request: No valid session ID' },
490
- id: null,
491
- }));
492
- }
493
- } else if (req.method === 'GET') {
494
- if (sessionIdHeader && transports.has(sessionIdHeader)) {
495
- await transports.get(sessionIdHeader).handleRequest(req, res);
496
- } else {
497
- res.writeHead(400);
498
- res.end('Invalid or missing session ID');
499
- }
500
- } else if (req.method === 'DELETE') {
501
- if (sessionIdHeader && transports.has(sessionIdHeader)) {
502
- await transports.get(sessionIdHeader).handleRequest(req, res);
503
- } else {
504
- res.writeHead(400);
505
- res.end('Invalid or missing session ID');
506
- }
507
- } else {
508
- res.writeHead(405);
509
- res.end('Method Not Allowed');
510
- }
511
- } catch (error) {
512
- console.error('[tfx-hub] 요청 처리 에러:', error.message);
513
- if (!res.headersSent) {
514
- const code = error.statusCode === 413 ? 413
515
- : error instanceof SyntaxError ? 400 : 500;
516
- const message = code === 413 ? 'Body too large'
517
- : code === 400 ? 'Invalid JSON' : 'Internal server error';
518
- res.writeHead(code, { 'Content-Type': 'application/json' });
519
- res.end(JSON.stringify({
520
- jsonrpc: '2.0',
521
- error: { code: code === 500 ? -32603 : -32700, message },
522
- id: null,
523
- }));
524
- }
525
- }
526
- });
527
-
528
- router.startSweeper();
529
-
530
- const hitlTimer = setInterval(() => {
531
- try { hitl.checkTimeouts(); } catch {}
532
- }, 10000);
533
- hitlTimer.unref();
534
-
535
- const SESSION_TTL_MS = 30 * 60 * 1000;
536
- const sessionTimer = setInterval(() => {
537
- const now = Date.now();
538
- for (const [sid, transport] of transports) {
539
- if (now - (transport._lastActivity || 0) <= SESSION_TTL_MS) continue;
540
- try { transport.close(); } catch {}
541
- transports.delete(sid);
542
- }
543
- }, 60000);
544
- sessionTimer.unref();
545
-
546
- mkdirSync(PID_DIR, { recursive: true });
547
- await pipe.start();
548
- await assignCallbacks.start();
549
-
550
- return new Promise((resolve, reject) => {
551
- httpServer.listen(port, host, () => {
552
- const info = {
553
- port,
554
- host,
555
- dbPath,
556
- pid: process.pid,
557
- hubToken: HUB_TOKEN,
558
- authMode: HUB_TOKEN ? 'token-required' : 'localhost-only',
559
- url: `http://${host}:${port}/mcp`,
560
- pipe_path: pipe.path,
561
- pipePath: pipe.path,
562
- assign_callback_pipe_path: assignCallbacks.path,
563
- assignCallbackPipePath: assignCallbacks.path,
564
- };
565
-
566
- writeFileSync(PID_FILE, JSON.stringify({
567
- pid: process.pid,
568
- port,
569
- host,
570
- auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
571
- url: info.url,
572
- pipe_path: pipe.path,
573
- pipePath: pipe.path,
574
- assign_callback_pipe_path: assignCallbacks.path,
575
- started: Date.now(),
576
- }));
577
-
578
- console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} / assign-callback ${assignCallbacks.path} (PID ${process.pid})`);
579
-
580
- const stopFn = async () => {
581
- router.stopSweeper();
582
- clearInterval(hitlTimer);
583
- clearInterval(sessionTimer);
584
- for (const [, transport] of transports) {
585
- try { await transport.close(); } catch {}
586
- }
587
- transports.clear();
588
- await pipe.stop();
589
- await assignCallbacks.stop();
590
- store.close();
591
- try { unlinkSync(PID_FILE); } catch {}
592
- try { unlinkSync(TOKEN_FILE); } catch {}
593
- await new Promise((resolveClose) => httpServer.close(resolveClose));
594
- };
595
-
596
- resolve({
597
- ...info,
598
- httpServer,
599
- store,
600
- router,
601
- hitl,
602
- pipe,
603
- assignCallbacks,
604
- stop: stopFn,
605
- });
606
- });
607
- httpServer.on('error', reject);
608
- });
609
- }
610
-
611
- export function getHubInfo() {
612
- if (!existsSync(PID_FILE)) return null;
613
- try {
614
- return JSON.parse(readFileSync(PID_FILE, 'utf8'));
615
- } catch {
616
- return null;
617
- }
618
- }
619
-
620
- const selfRun = process.argv[1]?.replace(/\\/g, '/').endsWith('hub/server.mjs');
621
- if (selfRun) {
622
- const port = parseInt(process.env.TFX_HUB_PORT || '27888', 10);
623
- const dbPath = process.env.TFX_HUB_DB || undefined;
624
-
625
- startHub({ port, dbPath }).then((info) => {
626
- const shutdown = async (signal) => {
627
- console.log(`\n[tfx-hub] ${signal} 수신, 종료 중...`);
628
- await info.stop();
629
- process.exit(0);
630
- };
631
- process.on('SIGINT', () => shutdown('SIGINT'));
632
- process.on('SIGTERM', () => shutdown('SIGTERM'));
633
- }).catch((error) => {
634
- console.error('[tfx-hub] 시작 실패:', error.message);
635
- process.exit(1);
636
- });
637
- }
1
+ // hub/server.mjs — HTTP MCP + REST bridge + Named Pipe 서버 진입점
2
+ import { createServer as createHttpServer } from 'node:http';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { extname, join, resolve, sep } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
10
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
11
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
12
+
13
+ import { createStore } from './store.mjs';
14
+ import { createRouter } from './router.mjs';
15
+ import { createHitlManager } from './hitl.mjs';
16
+ import { createPipeServer } from './pipe.mjs';
17
+ import { createAssignCallbackServer } from './assign-callbacks.mjs';
18
+ import { createTools } from './tools.mjs';
19
+ import { ensurePipelineStateDbPath } from './pipeline/state.mjs';
20
+ import { DelegatorService } from './delegator/index.mjs';
21
+ import { createDelegatorMcpWorker } from './workers/delegator-mcp.mjs';
22
+
23
+ const MAX_BODY_SIZE = 1024 * 1024;
24
+ const PUBLIC_PATHS = new Set(['/', '/status', '/health', '/healthz']);
25
+ const LOOPBACK_REMOTE_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
26
+ const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
27
+ const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url));
28
+ const PUBLIC_DIR = resolve(join(PROJECT_ROOT, 'hub', 'public'));
29
+ const CACHE_DIR = join(homedir(), '.claude', 'cache');
30
+ const BATCH_EVENTS_PATH = join(CACHE_DIR, 'batch-events.jsonl');
31
+ const SV_ACCUMULATOR_PATH = join(CACHE_DIR, 'sv-accumulator.json');
32
+ const CODEX_RATE_LIMITS_CACHE_PATH = join(CACHE_DIR, 'codex-rate-limits-cache.json');
33
+ const GEMINI_QUOTA_CACHE_PATH = join(CACHE_DIR, 'gemini-quota-cache.json');
34
+ const CLAUDE_USAGE_CACHE_PATH = join(CACHE_DIR, 'claude-usage-cache.json');
35
+ const AIMD_WINDOW_MS = 30 * 60 * 1000;
36
+ const AIMD_INITIAL_BATCH_SIZE = 3;
37
+ const AIMD_MIN_BATCH_SIZE = 1;
38
+ const AIMD_MAX_BATCH_SIZE = 10;
39
+ const STATIC_CONTENT_TYPES = Object.freeze({
40
+ '.html': 'text/html',
41
+ '.css': 'text/css',
42
+ '.js': 'application/javascript',
43
+ '.png': 'image/png',
44
+ });
45
+
46
+ function isInitializeRequest(body) {
47
+ if (body?.method === 'initialize') return true;
48
+ if (Array.isArray(body)) return body.some((message) => message.method === 'initialize');
49
+ return false;
50
+ }
51
+
52
+ async function parseBody(req) {
53
+ const chunks = [];
54
+ let size = 0;
55
+ for await (const chunk of req) {
56
+ size += chunk.length;
57
+ if (size > MAX_BODY_SIZE) {
58
+ throw Object.assign(new Error('Body too large'), { statusCode: 413 });
59
+ }
60
+ chunks.push(chunk);
61
+ }
62
+ return JSON.parse(Buffer.concat(chunks).toString());
63
+ }
64
+
65
+ const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
66
+ const PID_FILE = join(PID_DIR, 'hub.pid');
67
+ const TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
68
+
69
+ function isPublicPath(path) {
70
+ return PUBLIC_PATHS.has(path)
71
+ || path === '/dashboard'
72
+ || path === '/api/qos-stats'
73
+ || path.startsWith('/public/');
74
+ }
75
+
76
+ function isAllowedOrigin(origin) {
77
+ return origin && ALLOWED_ORIGIN_RE.test(origin);
78
+ }
79
+
80
+ function getRequestPath(url = '/') {
81
+ try {
82
+ return new URL(url, 'http://127.0.0.1').pathname;
83
+ } catch {
84
+ return String(url).replace(/\?.*/, '') || '/';
85
+ }
86
+ }
87
+
88
+ function isLoopbackRemoteAddress(remoteAddress) {
89
+ return typeof remoteAddress === 'string' && LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress);
90
+ }
91
+
92
+ function extractBearerToken(req) {
93
+ const authHeader = typeof req.headers.authorization === 'string' ? req.headers.authorization : '';
94
+ return authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
95
+ }
96
+
97
+ function writeJson(res, statusCode, body, headers = {}) {
98
+ res.writeHead(statusCode, {
99
+ 'Content-Type': 'application/json',
100
+ ...headers,
101
+ });
102
+ res.end(JSON.stringify(body));
103
+ }
104
+
105
+ function applyCorsHeaders(req, res) {
106
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
107
+ if (origin) {
108
+ res.setHeader('Vary', 'Origin');
109
+ }
110
+ if (!isAllowedOrigin(origin)) return false;
111
+
112
+ res.setHeader('Access-Control-Allow-Origin', origin);
113
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
114
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
115
+ return true;
116
+ }
117
+
118
+ function isAuthorizedRequest(req, path, hubToken) {
119
+ if (!hubToken) {
120
+ return isLoopbackRemoteAddress(req.socket.remoteAddress);
121
+ }
122
+ if (isPublicPath(path)) return true;
123
+ return extractBearerToken(req) === hubToken;
124
+ }
125
+
126
+ function resolveTeamStatusCode(result) {
127
+ if (result?.ok) return 200;
128
+ const code = result?.error?.code;
129
+ if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') return 404;
130
+ if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') return 409;
131
+ if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') return 400;
132
+ return 500;
133
+ }
134
+
135
+ function resolvePipelineStatusCode(result) {
136
+ if (result?.ok) return 200;
137
+ if (result?.error === 'pipeline_not_found') return 404;
138
+ if (result?.error === 'hub_db_not_found') return 503;
139
+ return 400;
140
+ }
141
+
142
+ function safeReadJsonFile(filePath) {
143
+ try {
144
+ if (!existsSync(filePath)) return null;
145
+ return JSON.parse(readFileSync(filePath, 'utf8'));
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ function readRecentAimdEvents(now = Date.now()) {
152
+ try {
153
+ if (!existsSync(BATCH_EVENTS_PATH)) return [];
154
+ const cutoff = now - AIMD_WINDOW_MS;
155
+ return readFileSync(BATCH_EVENTS_PATH, 'utf8')
156
+ .split(/\r?\n/)
157
+ .filter(Boolean)
158
+ .map((line) => {
159
+ try {
160
+ return JSON.parse(line);
161
+ } catch {
162
+ return null;
163
+ }
164
+ })
165
+ .filter((event) => {
166
+ const timestamp = Number(event?.ts ?? event?.timestamp ?? 0);
167
+ return event && Number.isFinite(timestamp) && timestamp >= cutoff;
168
+ });
169
+ } catch {
170
+ return [];
171
+ }
172
+ }
173
+
174
+ function calculateAimdBatchSize(events) {
175
+ let batchSize = AIMD_INITIAL_BATCH_SIZE;
176
+
177
+ for (const event of events) {
178
+ const result = event?.result;
179
+ if (result === 'success' || result === 'success_with_warnings') {
180
+ batchSize = Math.min(AIMD_MAX_BATCH_SIZE, batchSize + 1);
181
+ } else if (result === 'failed' || result === 'timeout') {
182
+ batchSize = Math.max(AIMD_MIN_BATCH_SIZE, batchSize * 0.5);
183
+ }
184
+ }
185
+
186
+ return batchSize;
187
+ }
188
+
189
+ function getQosStatsPayload() {
190
+ const events = readRecentAimdEvents();
191
+ return {
192
+ aimd: {
193
+ batchSize: calculateAimdBatchSize(events),
194
+ events,
195
+ },
196
+ accumulator: safeReadJsonFile(SV_ACCUMULATOR_PATH),
197
+ codex: safeReadJsonFile(CODEX_RATE_LIMITS_CACHE_PATH),
198
+ gemini: safeReadJsonFile(GEMINI_QUOTA_CACHE_PATH),
199
+ claude: safeReadJsonFile(CLAUDE_USAGE_CACHE_PATH),
200
+ };
201
+ }
202
+
203
+ function resolvePublicFilePath(path) {
204
+ let relativePath = null;
205
+ if (path === '/dashboard') {
206
+ relativePath = 'dashboard.html';
207
+ } else if (path.startsWith('/public/')) {
208
+ relativePath = path.slice('/public/'.length);
209
+ }
210
+
211
+ if (!relativePath) return null;
212
+
213
+ try {
214
+ relativePath = decodeURIComponent(relativePath).replace(/^[/\\]+/, '');
215
+ } catch {
216
+ return null;
217
+ }
218
+
219
+ const filePath = resolve(PUBLIC_DIR, relativePath);
220
+ const publicPrefix = `${PUBLIC_DIR}${sep}`;
221
+ if (filePath !== PUBLIC_DIR && !filePath.startsWith(publicPrefix)) {
222
+ return null;
223
+ }
224
+ return filePath;
225
+ }
226
+
227
+ function servePublicFile(res, path) {
228
+ const filePath = resolvePublicFilePath(path);
229
+ if (!filePath) return false;
230
+
231
+ mkdirSync(PUBLIC_DIR, { recursive: true });
232
+ if (!existsSync(filePath)) {
233
+ res.writeHead(404);
234
+ res.end('Not Found');
235
+ return true;
236
+ }
237
+
238
+ try {
239
+ const body = readFileSync(filePath);
240
+ res.writeHead(200, {
241
+ 'Content-Type': STATIC_CONTENT_TYPES[extname(filePath).toLowerCase()] || 'application/octet-stream',
242
+ });
243
+ res.end(body);
244
+ } catch {
245
+ res.writeHead(404);
246
+ res.end('Not Found');
247
+ }
248
+ return true;
249
+ }
250
+
251
+ /**
252
+ * tfx-hub 시작
253
+ * @param {object} opts
254
+ * @param {number} [opts.port]
255
+ * @param {string} [opts.dbPath]
256
+ * @param {string} [opts.host]
257
+ * @param {string|number} [opts.sessionId]
258
+ */
259
+ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessionId = process.pid } = {}) {
260
+ if (!dbPath) {
261
+ dbPath = ensurePipelineStateDbPath(PROJECT_ROOT);
262
+ }
263
+
264
+ mkdirSync(PUBLIC_DIR, { recursive: true });
265
+
266
+ const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
267
+ if (HUB_TOKEN) {
268
+ mkdirSync(join(homedir(), '.claude'), { recursive: true });
269
+ writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
270
+ } else {
271
+ try { unlinkSync(TOKEN_FILE); } catch {}
272
+ }
273
+
274
+ const store = createStore(dbPath);
275
+ const router = createRouter(store);
276
+
277
+ // Delegator MCP resident service 초기화
278
+ const delegatorWorker = createDelegatorMcpWorker({ cwd: PROJECT_ROOT });
279
+ await delegatorWorker.start();
280
+ const delegatorService = new DelegatorService({ worker: delegatorWorker });
281
+
282
+ const pipe = createPipeServer({ router, store, sessionId, delegatorService });
283
+ const assignCallbacks = createAssignCallbackServer({ store, sessionId });
284
+ const hitl = createHitlManager(store, router);
285
+ const tools = createTools(store, router, hitl, pipe);
286
+ const transports = new Map();
287
+
288
+ function createMcpForSession() {
289
+ const mcp = new Server(
290
+ { name: 'tfx-hub', version: '1.0.0' },
291
+ { capabilities: { tools: {} } },
292
+ );
293
+
294
+ mcp.setRequestHandler(
295
+ ListToolsRequestSchema,
296
+ async () => ({
297
+ tools: tools.map((tool) => ({
298
+ name: tool.name,
299
+ description: tool.description,
300
+ inputSchema: tool.inputSchema,
301
+ })),
302
+ }),
303
+ );
304
+
305
+ mcp.setRequestHandler(
306
+ CallToolRequestSchema,
307
+ async (request) => {
308
+ const { name, arguments: args } = request.params;
309
+ const tool = tools.find((candidate) => candidate.name === name);
310
+ if (!tool) {
311
+ return {
312
+ content: [{ type: 'text', text: JSON.stringify({ ok: false, error: { code: 'UNKNOWN_TOOL', message: `도구 없음: ${name}` } }) }],
313
+ isError: true,
314
+ };
315
+ }
316
+ return tool.handler(args || {});
317
+ },
318
+ );
319
+
320
+ return mcp;
321
+ }
322
+
323
+ const httpServer = createHttpServer(async (req, res) => {
324
+ const path = getRequestPath(req.url);
325
+ const corsAllowed = applyCorsHeaders(req, res);
326
+
327
+ if (req.method === 'OPTIONS') {
328
+ const localOnlyMode = !HUB_TOKEN;
329
+ const isLoopbackRequest = isLoopbackRemoteAddress(req.socket.remoteAddress);
330
+ res.writeHead(corsAllowed && (!localOnlyMode || isLoopbackRequest) ? 204 : 403);
331
+ return res.end();
332
+ }
333
+
334
+ if (!isAuthorizedRequest(req, path, HUB_TOKEN)) {
335
+ if (!HUB_TOKEN) {
336
+ return writeJson(res, 403, { ok: false, error: 'Forbidden: localhost only' });
337
+ }
338
+ return writeJson(
339
+ res,
340
+ 401,
341
+ { ok: false, error: 'Unauthorized' },
342
+ { 'WWW-Authenticate': 'Bearer realm="tfx-hub"' },
343
+ );
344
+ }
345
+
346
+ if (path === '/' || path === '/status') {
347
+ const status = router.getStatus('hub').data;
348
+ return writeJson(res, 200, {
349
+ ...status,
350
+ sessions: transports.size,
351
+ pid: process.pid,
352
+ port,
353
+ auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
354
+ pipe_path: pipe.path,
355
+ pipe: pipe.getStatus(),
356
+ assign_callback_pipe_path: assignCallbacks.path,
357
+ assign_callback_pipe: assignCallbacks.getStatus(),
358
+ });
359
+ }
360
+
361
+ if (path === '/health' || path === '/healthz') {
362
+ const status = router.getStatus('hub').data;
363
+ const healthy = status?.hub?.state === 'healthy';
364
+ return writeJson(res, healthy ? 200 : 503, { ok: healthy });
365
+ }
366
+
367
+ if (path === '/api/qos-stats' && req.method === 'GET') {
368
+ return writeJson(res, 200, getQosStatsPayload());
369
+ }
370
+
371
+ if (path.startsWith('/bridge')) {
372
+ if (req.method !== 'POST' && req.method !== 'DELETE') {
373
+ return writeJson(res, 405, { ok: false, error: 'Method Not Allowed' });
374
+ }
375
+
376
+ try {
377
+ const body = req.method === 'POST' ? await parseBody(req) : {};
378
+
379
+ if (path === '/bridge/register' && req.method === 'POST') {
380
+ const { agent_id, cli, timeout_sec = 600, topics = [], capabilities = [], metadata = {} } = body;
381
+ if (!agent_id || !cli) {
382
+ return writeJson(res, 400, { ok: false, error: 'agent_id, cli 필수' });
383
+ }
384
+
385
+ const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
386
+ const result = await pipe.executeCommand('register', {
387
+ agent_id,
388
+ cli,
389
+ capabilities,
390
+ topics,
391
+ heartbeat_ttl_ms,
392
+ metadata,
393
+ });
394
+ return writeJson(res, 200, result);
395
+ }
396
+
397
+ if (path === '/bridge/result' && req.method === 'POST') {
398
+ const { agent_id, topic = 'task.result', payload = {}, trace_id, correlation_id } = body;
399
+ if (!agent_id) {
400
+ return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
401
+ }
402
+
403
+ const result = await pipe.executeCommand('result', {
404
+ agent_id,
405
+ topic,
406
+ payload,
407
+ trace_id,
408
+ correlation_id,
409
+ });
410
+ return writeJson(res, 200, result);
411
+ }
412
+
413
+ if (path === '/bridge/control' && req.method === 'POST') {
414
+ const {
415
+ from_agent = 'lead',
416
+ to_agent,
417
+ command,
418
+ reason = '',
419
+ payload = {},
420
+ trace_id,
421
+ correlation_id,
422
+ ttl_ms = 3600000,
423
+ } = body;
424
+
425
+ if (!to_agent || !command) {
426
+ return writeJson(res, 400, { ok: false, error: 'to_agent, command 필수' });
427
+ }
428
+
429
+ const result = await pipe.executeCommand('control', {
430
+ from_agent,
431
+ to_agent,
432
+ command,
433
+ reason,
434
+ payload,
435
+ ttl_ms,
436
+ trace_id,
437
+ correlation_id,
438
+ });
439
+
440
+ return writeJson(res, 200, result);
441
+ }
442
+
443
+ if (path === '/bridge/assign/async' && req.method === 'POST') {
444
+ const {
445
+ supervisor_agent,
446
+ worker_agent,
447
+ task,
448
+ topic = 'assign.job',
449
+ payload = {},
450
+ priority = 5,
451
+ ttl_ms = 600000,
452
+ timeout_ms = 600000,
453
+ max_retries = 0,
454
+ trace_id,
455
+ correlation_id,
456
+ } = body;
457
+
458
+ if (!supervisor_agent || !worker_agent || !task) {
459
+ return writeJson(res, 400, { ok: false, error: 'supervisor_agent, worker_agent, task 필수' });
460
+ }
461
+
462
+ const result = await pipe.executeCommand('assign', {
463
+ supervisor_agent,
464
+ worker_agent,
465
+ task,
466
+ topic,
467
+ payload,
468
+ priority,
469
+ ttl_ms,
470
+ timeout_ms,
471
+ max_retries,
472
+ trace_id,
473
+ correlation_id,
474
+ });
475
+ return writeJson(res, result.ok ? 200 : 400, result);
476
+ }
477
+
478
+ if (path === '/bridge/assign/result' && req.method === 'POST') {
479
+ const {
480
+ job_id,
481
+ worker_agent,
482
+ status,
483
+ attempt,
484
+ result: assignResult,
485
+ error: assignError,
486
+ payload = {},
487
+ metadata = {},
488
+ } = body;
489
+
490
+ if (!job_id || !status) {
491
+ return writeJson(res, 400, { ok: false, error: 'job_id, status 필수' });
492
+ }
493
+
494
+ const result = await pipe.executeCommand('assign_result', {
495
+ job_id,
496
+ worker_agent,
497
+ status,
498
+ attempt,
499
+ result: assignResult,
500
+ error: assignError,
501
+ payload,
502
+ metadata,
503
+ });
504
+ return writeJson(res, result.ok ? 200 : 409, result);
505
+ }
506
+
507
+ if (path === '/bridge/assign/status' && req.method === 'POST') {
508
+ const result = await pipe.executeQuery('assign_status', body);
509
+ const statusCode = result.ok ? 200 : (result.error?.code === 'ASSIGN_NOT_FOUND' ? 404 : 400);
510
+ return writeJson(res, statusCode, result);
511
+ }
512
+
513
+ if (path === '/bridge/assign/retry' && req.method === 'POST') {
514
+ const { job_id, reason, requested_by } = body;
515
+ if (!job_id) {
516
+ return writeJson(res, 400, { ok: false, error: 'job_id 필수' });
517
+ }
518
+
519
+ const result = await pipe.executeCommand('assign_retry', {
520
+ job_id,
521
+ reason,
522
+ requested_by,
523
+ });
524
+ const statusCode = result.ok ? 200
525
+ : result.error?.code === 'ASSIGN_NOT_FOUND' ? 404
526
+ : result.error?.code === 'ASSIGN_RETRY_EXHAUSTED' ? 409
527
+ : 400;
528
+ return writeJson(res, statusCode, result);
529
+ }
530
+
531
+ if (req.method === 'POST') {
532
+ let teamResult = null;
533
+ if (path === '/bridge/team/info' || path === '/bridge/team-info') {
534
+ teamResult = await pipe.executeQuery('team_info', body);
535
+ } else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
536
+ teamResult = await pipe.executeQuery('team_task_list', body);
537
+ } else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
538
+ teamResult = await pipe.executeCommand('team_task_update', body);
539
+ } else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
540
+ teamResult = await pipe.executeCommand('team_send_message', body);
541
+ }
542
+
543
+ if (teamResult) {
544
+ return writeJson(res, resolveTeamStatusCode(teamResult), teamResult);
545
+ }
546
+
547
+ if (path.startsWith('/bridge/team')) {
548
+ return writeJson(res, 404, { ok: false, error: `Unknown team endpoint: ${path}` });
549
+ }
550
+
551
+ // ── 파이프라인 엔드포인트 ──
552
+ if (path === '/bridge/pipeline/state' && req.method === 'POST') {
553
+ const result = await pipe.executeQuery('pipeline_state', body);
554
+ return writeJson(res, resolvePipelineStatusCode(result), result);
555
+ }
556
+
557
+ if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
558
+ const result = await pipe.executeCommand('pipeline_advance', body);
559
+ return writeJson(res, resolvePipelineStatusCode(result), result);
560
+ }
561
+
562
+ if (path === '/bridge/pipeline/init' && req.method === 'POST') {
563
+ const result = await pipe.executeCommand('pipeline_init', body);
564
+ return writeJson(res, resolvePipelineStatusCode(result), result);
565
+ }
566
+
567
+ if (path === '/bridge/pipeline/list' && req.method === 'POST') {
568
+ const result = await pipe.executeQuery('pipeline_list', body);
569
+ return writeJson(res, resolvePipelineStatusCode(result), result);
570
+ }
571
+
572
+ // ── Delegator 엔드포인트 ──
573
+ if (path === '/bridge/delegator/delegate' && req.method === 'POST') {
574
+ const result = await pipe.executeCommand('delegator_delegate', body);
575
+ return writeJson(res, result.ok ? 200 : 400, result);
576
+ }
577
+
578
+ if (path === '/bridge/delegator/reply' && req.method === 'POST') {
579
+ const result = await pipe.executeCommand('delegator_reply', body);
580
+ return writeJson(res, result.ok ? 200 : 400, result);
581
+ }
582
+
583
+ if (path === '/bridge/delegator/status' && req.method === 'POST') {
584
+ const result = await pipe.executeQuery('delegator_status', body);
585
+ return writeJson(res, result.ok ? 200 : 400, result);
586
+ }
587
+ }
588
+
589
+ if (path === '/bridge/context' && req.method === 'POST') {
590
+ const { agent_id, topics, max_messages = 10, auto_ack = true } = body;
591
+ if (!agent_id) {
592
+ return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
593
+ }
594
+
595
+ const result = await pipe.executeQuery('drain', {
596
+ agent_id,
597
+ topics,
598
+ max_messages,
599
+ auto_ack,
600
+ });
601
+ return writeJson(res, 200, result);
602
+ }
603
+
604
+ if (path === '/bridge/deregister' && req.method === 'POST') {
605
+ const { agent_id } = body;
606
+ if (!agent_id) {
607
+ return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
608
+ }
609
+ const result = await pipe.executeCommand('deregister', { agent_id });
610
+ return writeJson(res, 200, result);
611
+ }
612
+
613
+ return writeJson(res, 404, { ok: false, error: 'Unknown bridge endpoint' });
614
+ } catch (error) {
615
+ if (!res.headersSent) {
616
+ writeJson(res, 500, { ok: false, error: error.message });
617
+ }
618
+ return;
619
+ }
620
+ }
621
+
622
+ if (req.method === 'GET' && servePublicFile(res, path)) {
623
+ return;
624
+ }
625
+
626
+ if (path !== '/mcp') {
627
+ res.writeHead(404);
628
+ return res.end('Not Found');
629
+ }
630
+
631
+ try {
632
+ const sessionIdHeader = req.headers['mcp-session-id'];
633
+
634
+ if (req.method === 'POST') {
635
+ const body = await parseBody(req);
636
+
637
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
638
+ const transport = transports.get(sessionIdHeader);
639
+ transport._lastActivity = Date.now();
640
+ await transport.handleRequest(req, res, body);
641
+ } else if (!sessionIdHeader && isInitializeRequest(body)) {
642
+ const transport = new StreamableHTTPServerTransport({
643
+ sessionIdGenerator: () => randomUUID(),
644
+ onsessioninitialized: (sid) => {
645
+ transport._lastActivity = Date.now();
646
+ transports.set(sid, transport);
647
+ },
648
+ });
649
+ transport.onclose = () => {
650
+ if (transport.sessionId) transports.delete(transport.sessionId);
651
+ };
652
+ const mcp = createMcpForSession();
653
+ await mcp.connect(transport);
654
+ await transport.handleRequest(req, res, body);
655
+ } else {
656
+ res.writeHead(400, { 'Content-Type': 'application/json' });
657
+ res.end(JSON.stringify({
658
+ jsonrpc: '2.0',
659
+ error: { code: -32000, message: 'Bad Request: No valid session ID' },
660
+ id: null,
661
+ }));
662
+ }
663
+ } else if (req.method === 'GET') {
664
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
665
+ await transports.get(sessionIdHeader).handleRequest(req, res);
666
+ } else {
667
+ res.writeHead(400);
668
+ res.end('Invalid or missing session ID');
669
+ }
670
+ } else if (req.method === 'DELETE') {
671
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
672
+ await transports.get(sessionIdHeader).handleRequest(req, res);
673
+ } else {
674
+ res.writeHead(400);
675
+ res.end('Invalid or missing session ID');
676
+ }
677
+ } else {
678
+ res.writeHead(405);
679
+ res.end('Method Not Allowed');
680
+ }
681
+ } catch (error) {
682
+ console.error('[tfx-hub] 요청 처리 에러:', error.message);
683
+ if (!res.headersSent) {
684
+ const code = error.statusCode === 413 ? 413
685
+ : error instanceof SyntaxError ? 400 : 500;
686
+ const message = code === 413 ? 'Body too large'
687
+ : code === 400 ? 'Invalid JSON' : 'Internal server error';
688
+ res.writeHead(code, { 'Content-Type': 'application/json' });
689
+ res.end(JSON.stringify({
690
+ jsonrpc: '2.0',
691
+ error: { code: code === 500 ? -32603 : -32700, message },
692
+ id: null,
693
+ }));
694
+ }
695
+ }
696
+ });
697
+
698
+ router.startSweeper();
699
+
700
+ const hitlTimer = setInterval(() => {
701
+ try { hitl.checkTimeouts(); } catch {}
702
+ }, 10000);
703
+ hitlTimer.unref();
704
+
705
+ const SESSION_TTL_MS = 30 * 60 * 1000;
706
+ const sessionTimer = setInterval(() => {
707
+ const now = Date.now();
708
+ for (const [sid, transport] of transports) {
709
+ if (now - (transport._lastActivity || 0) <= SESSION_TTL_MS) continue;
710
+ try { transport.close(); } catch {}
711
+ transports.delete(sid);
712
+ }
713
+ }, 60000);
714
+ sessionTimer.unref();
715
+
716
+ mkdirSync(PID_DIR, { recursive: true });
717
+ await pipe.start();
718
+ await assignCallbacks.start();
719
+
720
+ return new Promise((resolve, reject) => {
721
+ httpServer.listen(port, host, () => {
722
+ const info = {
723
+ port,
724
+ host,
725
+ dbPath,
726
+ pid: process.pid,
727
+ hubToken: HUB_TOKEN,
728
+ authMode: HUB_TOKEN ? 'token-required' : 'localhost-only',
729
+ url: `http://${host}:${port}/mcp`,
730
+ pipe_path: pipe.path,
731
+ pipePath: pipe.path,
732
+ assign_callback_pipe_path: assignCallbacks.path,
733
+ assignCallbackPipePath: assignCallbacks.path,
734
+ };
735
+
736
+ writeFileSync(PID_FILE, JSON.stringify({
737
+ pid: process.pid,
738
+ port,
739
+ host,
740
+ auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
741
+ url: info.url,
742
+ pipe_path: pipe.path,
743
+ pipePath: pipe.path,
744
+ assign_callback_pipe_path: assignCallbacks.path,
745
+ started: Date.now(),
746
+ }));
747
+
748
+ console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} / assign-callback ${assignCallbacks.path} (PID ${process.pid})`);
749
+
750
+ const stopFn = async () => {
751
+ router.stopSweeper();
752
+ clearInterval(hitlTimer);
753
+ clearInterval(sessionTimer);
754
+ for (const [, transport] of transports) {
755
+ try { await transport.close(); } catch {}
756
+ }
757
+ transports.clear();
758
+ await pipe.stop();
759
+ await assignCallbacks.stop();
760
+ await delegatorWorker.stop().catch(() => {});
761
+ store.close();
762
+ try { unlinkSync(PID_FILE); } catch {}
763
+ try { unlinkSync(TOKEN_FILE); } catch {}
764
+ await new Promise((resolveClose) => httpServer.close(resolveClose));
765
+ };
766
+
767
+ resolve({
768
+ ...info,
769
+ httpServer,
770
+ store,
771
+ router,
772
+ hitl,
773
+ pipe,
774
+ assignCallbacks,
775
+ delegatorService,
776
+ delegatorWorker,
777
+ stop: stopFn,
778
+ });
779
+ });
780
+ httpServer.on('error', reject);
781
+ });
782
+ }
783
+
784
+ export function getHubInfo() {
785
+ if (!existsSync(PID_FILE)) return null;
786
+ try {
787
+ return JSON.parse(readFileSync(PID_FILE, 'utf8'));
788
+ } catch {
789
+ return null;
790
+ }
791
+ }
792
+
793
+ const selfRun = process.argv[1]?.replace(/\\/g, '/').endsWith('hub/server.mjs');
794
+ if (selfRun) {
795
+ const port = parseInt(process.env.TFX_HUB_PORT || '27888', 10);
796
+ const dbPath = process.env.TFX_HUB_DB || undefined;
797
+
798
+ startHub({ port, dbPath }).then((info) => {
799
+ const shutdown = async (signal) => {
800
+ console.log(`\n[tfx-hub] ${signal} 수신, 종료 중...`);
801
+ await info.stop();
802
+ process.exit(0);
803
+ };
804
+ process.on('SIGINT', () => shutdown('SIGINT'));
805
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
806
+ }).catch((error) => {
807
+ console.error('[tfx-hub] 시작 실패:', error.message);
808
+ process.exit(1);
809
+ });
810
+ }