triflux 3.2.0-dev.8 → 3.2.0-dev.9

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 (42) hide show
  1. package/bin/triflux.mjs +581 -340
  2. package/hooks/keyword-rules.json +16 -0
  3. package/hub/bridge.mjs +410 -318
  4. package/hub/hitl.mjs +45 -31
  5. package/hub/pipe.mjs +457 -0
  6. package/hub/router.mjs +422 -161
  7. package/hub/server.mjs +429 -424
  8. package/hub/store.mjs +388 -314
  9. package/hub/team/cli-team-common.mjs +348 -0
  10. package/hub/team/cli-team-control.mjs +393 -0
  11. package/hub/team/cli-team-start.mjs +512 -0
  12. package/hub/team/cli-team-status.mjs +269 -0
  13. package/hub/team/cli.mjs +59 -1459
  14. package/hub/team/dashboard.mjs +1 -9
  15. package/hub/team/native.mjs +12 -80
  16. package/hub/team/nativeProxy.mjs +121 -47
  17. package/hub/team/pane.mjs +66 -43
  18. package/hub/team/psmux.mjs +297 -0
  19. package/hub/team/session.mjs +354 -291
  20. package/hub/team/shared.mjs +13 -0
  21. package/hub/team/staleState.mjs +299 -0
  22. package/hub/tools.mjs +41 -52
  23. package/hub/workers/claude-worker.mjs +446 -0
  24. package/hub/workers/codex-mcp.mjs +414 -0
  25. package/hub/workers/factory.mjs +18 -0
  26. package/hub/workers/gemini-worker.mjs +349 -0
  27. package/hub/workers/interface.mjs +41 -0
  28. package/hud/hud-qos-status.mjs +4 -2
  29. package/package.json +4 -1
  30. package/scripts/keyword-detector.mjs +15 -0
  31. package/scripts/lib/keyword-rules.mjs +4 -1
  32. package/scripts/psmux-steering-prototype.sh +368 -0
  33. package/scripts/setup.mjs +128 -70
  34. package/scripts/tfx-route-worker.mjs +161 -0
  35. package/scripts/tfx-route.sh +415 -80
  36. package/skills/tfx-auto/SKILL.md +90 -564
  37. package/skills/tfx-auto-codex/SKILL.md +1 -3
  38. package/skills/tfx-codex/SKILL.md +1 -4
  39. package/skills/tfx-doctor/SKILL.md +1 -0
  40. package/skills/tfx-gemini/SKILL.md +1 -4
  41. package/skills/tfx-setup/SKILL.md +1 -4
  42. package/skills/tfx-team/SKILL.md +53 -62
package/hub/server.mjs CHANGED
@@ -1,122 +1,114 @@
1
- // hub/server.mjs — Streamable HTTP MCP 서버 진입점
2
- // Express 없이 Node.js http 모듈 + MCP SDK로 구현
3
- import { createServer as createHttpServer } from 'node:http';
4
- import { randomUUID } from 'node:crypto';
5
- import { join } from 'node:path';
6
- import { homedir } from 'node:os';
7
- import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
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 { createTools } from './tools.mjs';
17
- import {
18
- teamInfo,
19
- teamTaskList,
20
- teamTaskUpdate,
21
- teamSendMessage,
22
- } from './team/nativeProxy.mjs';
23
-
24
- /** initialize 요청 판별 */
25
- function isInitializeRequest(body) {
26
- if (body?.method === 'initialize') return true;
27
- if (Array.isArray(body)) return body.some(m => m.method === 'initialize');
28
- return false;
29
- }
30
-
31
- /** HTTP 요청 body JSON 파싱 (1MB 제한) */
32
- const MAX_BODY_SIZE = 1024 * 1024;
33
- async function parseBody(req) {
34
- const chunks = [];
35
- let size = 0;
36
- for await (const chunk of req) {
37
- size += chunk.length;
38
- if (size > MAX_BODY_SIZE) throw Object.assign(new Error('Body too large'), { statusCode: 413 });
39
- chunks.push(chunk);
40
- }
41
- return JSON.parse(Buffer.concat(chunks).toString());
42
- }
43
-
44
- /** PID 파일 경로 */
45
- const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
46
- const PID_FILE = join(PID_DIR, 'hub.pid');
47
-
48
- /**
49
- * tfx-hub 데몬 시작
50
- * @param {object} opts
51
- * @param {number} opts.port — 리스닝 포트 (기본 27888)
52
- * @param {string} opts.dbPath — SQLite DB 경로
53
- * @param {string} opts.host — 바인드 주소 (기본 127.0.0.1)
54
- */
55
- export async function startHub({ port = 27888, dbPath, host = '127.0.0.1' } = {}) {
56
- if (!dbPath) {
57
- dbPath = join(PID_DIR, 'state.db');
58
- }
59
-
60
- // 코어 모듈 초기화
61
- const store = createStore(dbPath);
62
- const router = createRouter(store);
63
- const hitl = createHitlManager(store);
64
- const tools = createTools(store, router, hitl);
65
-
66
- // 세션별 transport 맵
67
- const transports = new Map();
68
-
69
- /** 세션당 MCP 서버 생성 (low-level Server plain JSON Schema 호환) */
70
- function createMcpForSession() {
71
- const mcp = new Server(
72
- { name: 'tfx-hub', version: '1.0.0' },
73
- { capabilities: { tools: {} } },
74
- );
75
-
76
- // tools/list 핸들러
77
- mcp.setRequestHandler(
78
- ListToolsRequestSchema,
79
- async () => ({
80
- tools: tools.map(t => ({
81
- name: t.name,
82
- description: t.description,
83
- inputSchema: t.inputSchema,
84
- })),
85
- }),
86
- );
87
-
88
- // tools/call 핸들러
89
- mcp.setRequestHandler(
90
- CallToolRequestSchema,
91
- async (request) => {
92
- const { name, arguments: args } = request.params;
93
- const tool = tools.find(t => t.name === name);
94
- if (!tool) {
95
- return {
96
- content: [{ type: 'text', text: JSON.stringify({ ok: false, error: { code: 'UNKNOWN_TOOL', message: `도구 없음: ${name}` } }) }],
97
- isError: true,
98
- };
99
- }
100
- return await tool.handler(args || {});
101
- },
102
- );
103
-
104
- return mcp;
105
- }
106
-
107
- // HTTP 서버
108
- const httpServer = createHttpServer(async (req, res) => {
109
- // CORS (로컬 전용이지만 CLI 클라이언트 호환)
110
- res.setHeader('Access-Control-Allow-Origin', '*');
111
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
112
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, Last-Event-ID');
113
-
114
- if (req.method === 'OPTIONS') {
115
- res.writeHead(204);
116
- return res.end();
117
- }
118
-
119
- // /status — 상세 상태 (브라우저/curl 용)
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 { createTools } from './tools.mjs';
17
+ import {
18
+ teamInfo,
19
+ teamTaskList,
20
+ teamTaskUpdate,
21
+ teamSendMessage,
22
+ } from './team/nativeProxy.mjs';
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
+ const MAX_BODY_SIZE = 1024 * 1024;
31
+ async function parseBody(req) {
32
+ const chunks = [];
33
+ let size = 0;
34
+ for await (const chunk of req) {
35
+ size += chunk.length;
36
+ if (size > MAX_BODY_SIZE) {
37
+ throw Object.assign(new Error('Body too large'), { statusCode: 413 });
38
+ }
39
+ chunks.push(chunk);
40
+ }
41
+ return JSON.parse(Buffer.concat(chunks).toString());
42
+ }
43
+
44
+ const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
45
+ const PID_FILE = join(PID_DIR, 'hub.pid');
46
+
47
+ /**
48
+ * tfx-hub 시작
49
+ * @param {object} opts
50
+ * @param {number} [opts.port]
51
+ * @param {string} [opts.dbPath]
52
+ * @param {string} [opts.host]
53
+ * @param {string|number} [opts.sessionId]
54
+ */
55
+ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessionId = process.pid } = {}) {
56
+ if (!dbPath) {
57
+ dbPath = join(PID_DIR, 'state.db');
58
+ }
59
+
60
+ const store = createStore(dbPath);
61
+ const router = createRouter(store);
62
+ const pipe = createPipeServer({ router, store, sessionId });
63
+ const hitl = createHitlManager(store, router);
64
+ const tools = createTools(store, router, hitl, pipe);
65
+ const transports = new Map();
66
+
67
+ function createMcpForSession() {
68
+ const mcp = new Server(
69
+ { name: 'tfx-hub', version: '1.0.0' },
70
+ { capabilities: { tools: {} } },
71
+ );
72
+
73
+ mcp.setRequestHandler(
74
+ ListToolsRequestSchema,
75
+ async () => ({
76
+ tools: tools.map((tool) => ({
77
+ name: tool.name,
78
+ description: tool.description,
79
+ inputSchema: tool.inputSchema,
80
+ })),
81
+ }),
82
+ );
83
+
84
+ mcp.setRequestHandler(
85
+ CallToolRequestSchema,
86
+ async (request) => {
87
+ const { name, arguments: args } = request.params;
88
+ const tool = tools.find((candidate) => candidate.name === name);
89
+ if (!tool) {
90
+ return {
91
+ content: [{ type: 'text', text: JSON.stringify({ ok: false, error: { code: 'UNKNOWN_TOOL', message: `도구 없음: ${name}` } }) }],
92
+ isError: true,
93
+ };
94
+ }
95
+ return tool.handler(args || {});
96
+ },
97
+ );
98
+
99
+ return mcp;
100
+ }
101
+
102
+ const httpServer = createHttpServer(async (req, res) => {
103
+ res.setHeader('Access-Control-Allow-Origin', '*');
104
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
105
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, Last-Event-ID');
106
+
107
+ if (req.method === 'OPTIONS') {
108
+ res.writeHead(204);
109
+ return res.end();
110
+ }
111
+
120
112
  if (req.url === '/' || req.url === '/status') {
121
113
  const status = router.getStatus('hub').data;
122
114
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -125,317 +117,330 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1' } = {}
125
117
  sessions: transports.size,
126
118
  pid: process.pid,
127
119
  port,
120
+ pipe_path: pipe.path,
121
+ pipe: pipe.getStatus(),
128
122
  }));
129
123
  }
130
124
 
131
- // /health, /healthz — 최소 헬스 응답 (레거시 호환)
132
125
  if (req.url === '/health' || req.url === '/healthz') {
133
126
  const status = router.getStatus('hub').data;
134
127
  const healthy = status?.hub?.state === 'healthy';
135
128
  res.writeHead(healthy ? 200 : 503, { 'Content-Type': 'application/json' });
136
129
  return res.end(JSON.stringify({ ok: healthy }));
137
130
  }
138
-
139
- // /bridge/* — 경량 REST 엔드포인트 (tfx-route.sh 브릿지용)
140
- if (req.url.startsWith('/bridge')) {
141
- res.setHeader('Content-Type', 'application/json');
142
-
143
- if (req.method !== 'POST' && req.method !== 'DELETE') {
144
- res.writeHead(405);
145
- return res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
146
- }
147
-
148
- try {
149
- const body = (req.method === 'POST') ? await parseBody(req) : {};
150
- const path = req.url.replace(/\?.*/, '');
151
-
152
- // POST /bridge/register 에이전트 등록 (프로세스 수명 기반)
153
- if (path === '/bridge/register' && req.method === 'POST') {
154
- const { agent_id, cli, timeout_sec = 600, topics = [], capabilities = [], metadata = {} } = body;
155
- if (!agent_id || !cli) {
156
- res.writeHead(400);
157
- return res.end(JSON.stringify({ ok: false, error: 'agent_id, cli 필수' }));
158
- }
159
- // heartbeat = 프로세스 타임아웃 + 여유 120초
160
- const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
161
- const data = store.registerAgent({ agent_id, cli, capabilities, topics, heartbeat_ttl_ms, metadata });
162
- res.writeHead(200);
163
- return res.end(JSON.stringify({ ok: true, data }));
164
- }
165
-
166
- // POST /bridge/result — 결과 발행
167
- if (path === '/bridge/result' && req.method === 'POST') {
168
- const { agent_id, topic = 'task.result', payload = {}, trace_id, correlation_id } = body;
169
- if (!agent_id) {
170
- res.writeHead(400);
171
- return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
172
- }
173
- const result = router.handlePublish({
174
- from: agent_id, to: 'topic:' + topic, topic, payload,
175
- priority: 5, ttl_ms: 3600000, trace_id, correlation_id,
176
- });
177
- res.writeHead(200);
178
- return res.end(JSON.stringify(result));
179
- }
180
-
181
- // POST /bridge/control — 리드 제어를 특정 워커 메일박스로 직접 전달
182
- if (path === '/bridge/control' && req.method === 'POST') {
183
- const {
184
- from_agent = 'lead',
185
- to_agent,
186
- command,
187
- reason = '',
188
- payload = {},
189
- trace_id,
190
- correlation_id,
191
- ttl_ms = 3600000,
192
- } = body;
193
-
194
- if (!to_agent || !command) {
195
- res.writeHead(400);
196
- return res.end(JSON.stringify({ ok: false, error: 'to_agent, command 필수' }));
197
- }
198
-
199
- const result = router.handlePublish({
200
- from: from_agent,
201
- to: to_agent,
202
- topic: 'lead.control',
203
- payload: {
204
- command,
205
- reason,
206
- ...payload,
207
- issued_at: Date.now(),
208
- },
209
- priority: 8,
210
- ttl_ms: Math.max(1000, Math.min(Number(ttl_ms) || 3600000, 86400000)),
211
- trace_id,
212
- correlation_id,
213
- });
214
-
215
- res.writeHead(200);
216
- return res.end(JSON.stringify(result));
217
- }
218
-
219
- // POST /bridge/team/* — Native Teams 파일 프록시
220
- if (req.method === 'POST') {
221
- let teamResult = null;
222
- if (path === '/bridge/team/info' || path === '/bridge/team-info') {
223
- teamResult = teamInfo(body);
224
- } else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
225
- teamResult = teamTaskList(body);
226
- } else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
227
- teamResult = teamTaskUpdate(body);
228
- } else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
229
- teamResult = teamSendMessage(body);
230
- }
231
-
232
- if (teamResult) {
233
- let status = 200;
234
- const code = teamResult?.error?.code;
235
- if (!teamResult.ok) {
236
- if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') status = 404;
237
- else if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') status = 409;
238
- else if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM') status = 400;
239
- else status = 500;
240
- }
241
- res.writeHead(status);
242
- return res.end(JSON.stringify(teamResult));
243
- }
244
- }
245
-
246
- // POST /bridge/context — 선행 컨텍스트 폴링
247
- if (path === '/bridge/context' && req.method === 'POST') {
248
- const { agent_id, topics, max_messages = 10 } = body;
249
- if (!agent_id) {
250
- res.writeHead(400);
251
- return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
252
- }
253
- const messages = store.pollForAgent(agent_id, {
254
- max_messages,
255
- include_topics: topics,
256
- auto_ack: true,
257
- });
258
- res.writeHead(200);
259
- return res.end(JSON.stringify({ ok: true, data: { messages, count: messages.length } }));
260
- }
261
-
262
- // POST /bridge/deregister — 에이전트 해제
263
- if (path === '/bridge/deregister' && req.method === 'POST') {
264
- const { agent_id } = body;
265
- if (!agent_id) {
266
- res.writeHead(400);
267
- return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
268
- }
269
- store.db.prepare("UPDATE agents SET status='offline' WHERE agent_id=?").run(agent_id);
270
- res.writeHead(200);
271
- return res.end(JSON.stringify({ ok: true, data: { agent_id, status: 'offline' } }));
272
- }
273
-
274
- res.writeHead(404);
275
- return res.end(JSON.stringify({ ok: false, error: 'Unknown bridge endpoint' }));
276
- } catch (e) {
277
- if (!res.headersSent) {
278
- res.writeHead(500);
279
- res.end(JSON.stringify({ ok: false, error: e.message }));
280
- }
281
- return;
282
- }
283
- }
284
-
285
- // /mcp — MCP Streamable HTTP 엔드포인트
286
- if (req.url !== '/mcp') {
287
- res.writeHead(404);
288
- return res.end('Not Found');
289
- }
290
-
291
- try {
292
- const sessionId = req.headers['mcp-session-id'];
293
-
294
- if (req.method === 'POST') {
295
- const body = await parseBody(req);
296
-
297
- if (sessionId && transports.has(sessionId)) {
298
- // 기존 세션
299
- const t = transports.get(sessionId);
300
- t._lastActivity = Date.now();
301
- await t.handleRequest(req, res, body);
302
- } else if (!sessionId && isInitializeRequest(body)) {
303
- // 세션 초기화
304
- const transport = new StreamableHTTPServerTransport({
305
- sessionIdGenerator: () => randomUUID(),
306
- onsessioninitialized: (sid) => {
307
- transport._lastActivity = Date.now();
308
- transports.set(sid, transport);
309
- },
310
- });
311
- transport.onclose = () => {
312
- if (transport.sessionId) transports.delete(transport.sessionId);
313
- };
314
- const mcp = createMcpForSession();
315
- await mcp.connect(transport);
316
- await transport.handleRequest(req, res, body);
317
- } else {
318
- res.writeHead(400, { 'Content-Type': 'application/json' });
319
- res.end(JSON.stringify({
320
- jsonrpc: '2.0',
321
- error: { code: -32000, message: 'Bad Request: No valid session ID' },
322
- id: null,
323
- }));
324
- }
325
- } else if (req.method === 'GET') {
326
- // SSE 스트림 연결
327
- if (sessionId && transports.has(sessionId)) {
328
- await transports.get(sessionId).handleRequest(req, res);
329
- } else {
330
- res.writeHead(400);
331
- res.end('Invalid or missing session ID');
332
- }
333
- } else if (req.method === 'DELETE') {
334
- // 세션 종료
335
- if (sessionId && transports.has(sessionId)) {
336
- await transports.get(sessionId).handleRequest(req, res);
337
- } else {
338
- res.writeHead(400);
339
- res.end('Invalid or missing session ID');
340
- }
341
- } else {
342
- res.writeHead(405);
343
- res.end('Method Not Allowed');
344
- }
345
- } catch (error) {
346
- console.error('[tfx-hub] 요청 처리 에러:', error.message);
347
- if (!res.headersSent) {
348
- const code = error.statusCode === 413 ? 413
349
- : error instanceof SyntaxError ? 400 : 500;
350
- const msg = code === 413 ? 'Body too large'
351
- : code === 400 ? 'Invalid JSON' : 'Internal server error';
352
- res.writeHead(code, { 'Content-Type': 'application/json' });
353
- res.end(JSON.stringify({
354
- jsonrpc: '2.0',
355
- error: { code: code === 500 ? -32603 : -32700, message: msg },
356
- id: null,
357
- }));
358
- }
359
- }
360
- });
361
-
362
- // 스위퍼 시작
363
- router.startSweeper();
364
-
365
- // HITL 타임아웃 체크 (10초 주기)
366
- const hitlTimer = setInterval(() => {
367
- try { hitl.checkTimeouts(); } catch {}
368
- }, 10000);
369
- hitlTimer.unref();
370
-
371
- // 비활성 세션 정리 (60초 주기, 30분 TTL)
372
- const SESSION_TTL_MS = 30 * 60 * 1000;
373
- const sessionTimer = setInterval(() => {
374
- const now = Date.now();
375
- for (const [sid, transport] of transports) {
376
- if (now - (transport._lastActivity || 0) > SESSION_TTL_MS) {
377
- try { transport.close(); } catch {}
378
- transports.delete(sid);
379
- }
380
- }
381
- }, 60000);
382
- sessionTimer.unref();
383
-
384
- // PID 파일 기록
385
- mkdirSync(PID_DIR, { recursive: true });
386
-
387
- return new Promise((resolve, reject) => {
388
- httpServer.listen(port, host, () => {
389
- const info = { port, host, dbPath, pid: process.pid, url: `http://${host}:${port}/mcp` };
390
-
391
- // PID + 포트 기록 (stop/status 용)
392
- writeFileSync(PID_FILE, JSON.stringify({ pid: process.pid, port, host, url: info.url, started: Date.now() }));
393
-
394
- console.log(`[tfx-hub] MCP 서버 시작: ${info.url} (PID ${process.pid})`);
395
-
396
- const stopFn = async () => {
397
- router.stopSweeper();
398
- clearInterval(hitlTimer);
399
- clearInterval(sessionTimer);
400
- for (const [, transport] of transports) {
401
- try { await transport.close(); } catch {}
402
- }
403
- transports.clear();
404
- store.close();
405
- try { unlinkSync(PID_FILE); } catch {}
406
- await new Promise(r => httpServer.close(r));
407
- };
408
-
409
- resolve({ ...info, httpServer, store, router, hitl, stop: stopFn });
410
- });
411
- httpServer.on('error', reject);
412
- });
413
- }
414
-
415
- /** 실행 중인 허브 정보 읽기 */
416
- export function getHubInfo() {
417
- if (!existsSync(PID_FILE)) return null;
418
- try {
419
- return JSON.parse(readFileSync(PID_FILE, 'utf8'));
420
- } catch { return null; }
421
- }
422
-
423
- // CLI 직접 실행
424
- const selfRun = process.argv[1]?.replace(/\\/g, '/').endsWith('hub/server.mjs');
425
- if (selfRun) {
426
- const port = parseInt(process.env.TFX_HUB_PORT || '27888', 10);
427
- const dbPath = process.env.TFX_HUB_DB || undefined;
428
-
429
- startHub({ port, dbPath }).then((info) => {
430
- const shutdown = async (sig) => {
431
- console.log(`\n[tfx-hub] ${sig} 수신, 종료 중...`);
432
- await info.stop();
433
- process.exit(0);
434
- };
435
- process.on('SIGINT', () => shutdown('SIGINT'));
436
- process.on('SIGTERM', () => shutdown('SIGTERM'));
437
- }).catch((err) => {
438
- console.error('[tfx-hub] 시작 실패:', err.message);
439
- process.exit(1);
440
- });
441
- }
131
+
132
+ if (req.url.startsWith('/bridge')) {
133
+ res.setHeader('Content-Type', 'application/json');
134
+
135
+ if (req.method !== 'POST' && req.method !== 'DELETE') {
136
+ res.writeHead(405);
137
+ return res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
138
+ }
139
+
140
+ try {
141
+ const body = req.method === 'POST' ? await parseBody(req) : {};
142
+ const path = req.url.replace(/\?.*/, '');
143
+
144
+ if (path === '/bridge/register' && req.method === 'POST') {
145
+ const { agent_id, cli, timeout_sec = 600, topics = [], capabilities = [], metadata = {} } = body;
146
+ if (!agent_id || !cli) {
147
+ res.writeHead(400);
148
+ return res.end(JSON.stringify({ ok: false, error: 'agent_id, cli 필수' }));
149
+ }
150
+
151
+ const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
152
+ const result = await pipe.executeCommand('register', {
153
+ agent_id,
154
+ cli,
155
+ capabilities,
156
+ topics,
157
+ heartbeat_ttl_ms,
158
+ metadata,
159
+ });
160
+ res.writeHead(200);
161
+ return res.end(JSON.stringify(result));
162
+ }
163
+
164
+ if (path === '/bridge/result' && req.method === 'POST') {
165
+ const { agent_id, topic = 'task.result', payload = {}, trace_id, correlation_id } = body;
166
+ if (!agent_id) {
167
+ res.writeHead(400);
168
+ return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
169
+ }
170
+
171
+ const result = await pipe.executeCommand('result', {
172
+ agent_id,
173
+ topic,
174
+ payload,
175
+ trace_id,
176
+ correlation_id,
177
+ });
178
+ res.writeHead(200);
179
+ return res.end(JSON.stringify(result));
180
+ }
181
+
182
+ if (path === '/bridge/control' && req.method === 'POST') {
183
+ const {
184
+ from_agent = 'lead',
185
+ to_agent,
186
+ command,
187
+ reason = '',
188
+ payload = {},
189
+ trace_id,
190
+ correlation_id,
191
+ ttl_ms = 3600000,
192
+ } = body;
193
+
194
+ if (!to_agent || !command) {
195
+ res.writeHead(400);
196
+ return res.end(JSON.stringify({ ok: false, error: 'to_agent, command 필수' }));
197
+ }
198
+
199
+ const result = await pipe.executeCommand('control', {
200
+ from_agent,
201
+ to_agent,
202
+ command,
203
+ reason,
204
+ payload,
205
+ ttl_ms,
206
+ trace_id,
207
+ correlation_id,
208
+ });
209
+
210
+ res.writeHead(200);
211
+ return res.end(JSON.stringify(result));
212
+ }
213
+
214
+ if (req.method === 'POST') {
215
+ let teamResult = null;
216
+ if (path === '/bridge/team/info' || path === '/bridge/team-info') {
217
+ teamResult = teamInfo(body);
218
+ } else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
219
+ teamResult = teamTaskList(body);
220
+ } else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
221
+ teamResult = teamTaskUpdate(body);
222
+ } else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
223
+ teamResult = teamSendMessage(body);
224
+ }
225
+
226
+ if (teamResult) {
227
+ let status = 200;
228
+ const code = teamResult?.error?.code;
229
+ if (!teamResult.ok) {
230
+ if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') status = 404;
231
+ else if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') status = 409;
232
+ else if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') status = 400;
233
+ else status = 500;
234
+ }
235
+ res.writeHead(status);
236
+ return res.end(JSON.stringify(teamResult));
237
+ }
238
+
239
+ if (path.startsWith('/bridge/team')) {
240
+ res.writeHead(404);
241
+ return res.end(JSON.stringify({ ok: false, error: `Unknown team endpoint: ${path}` }));
242
+ }
243
+ }
244
+
245
+ if (path === '/bridge/context' && req.method === 'POST') {
246
+ const { agent_id, topics, max_messages = 10 } = body;
247
+ if (!agent_id) {
248
+ res.writeHead(400);
249
+ return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
250
+ }
251
+
252
+ const result = await pipe.executeQuery('context', {
253
+ agent_id,
254
+ topics,
255
+ max_messages,
256
+ });
257
+ res.writeHead(200);
258
+ return res.end(JSON.stringify(result));
259
+ }
260
+
261
+ if (path === '/bridge/deregister' && req.method === 'POST') {
262
+ const { agent_id } = body;
263
+ if (!agent_id) {
264
+ res.writeHead(400);
265
+ return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
266
+ }
267
+ const result = await pipe.executeCommand('deregister', { agent_id });
268
+ res.writeHead(200);
269
+ return res.end(JSON.stringify(result));
270
+ }
271
+
272
+ res.writeHead(404);
273
+ return res.end(JSON.stringify({ ok: false, error: 'Unknown bridge endpoint' }));
274
+ } catch (error) {
275
+ if (!res.headersSent) {
276
+ res.writeHead(500);
277
+ res.end(JSON.stringify({ ok: false, error: error.message }));
278
+ }
279
+ return;
280
+ }
281
+ }
282
+
283
+ if (req.url !== '/mcp') {
284
+ res.writeHead(404);
285
+ return res.end('Not Found');
286
+ }
287
+
288
+ try {
289
+ const sessionIdHeader = req.headers['mcp-session-id'];
290
+
291
+ if (req.method === 'POST') {
292
+ const body = await parseBody(req);
293
+
294
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
295
+ const transport = transports.get(sessionIdHeader);
296
+ transport._lastActivity = Date.now();
297
+ await transport.handleRequest(req, res, body);
298
+ } else if (!sessionIdHeader && isInitializeRequest(body)) {
299
+ const transport = new StreamableHTTPServerTransport({
300
+ sessionIdGenerator: () => randomUUID(),
301
+ onsessioninitialized: (sid) => {
302
+ transport._lastActivity = Date.now();
303
+ transports.set(sid, transport);
304
+ },
305
+ });
306
+ transport.onclose = () => {
307
+ if (transport.sessionId) transports.delete(transport.sessionId);
308
+ };
309
+ const mcp = createMcpForSession();
310
+ await mcp.connect(transport);
311
+ await transport.handleRequest(req, res, body);
312
+ } else {
313
+ res.writeHead(400, { 'Content-Type': 'application/json' });
314
+ res.end(JSON.stringify({
315
+ jsonrpc: '2.0',
316
+ error: { code: -32000, message: 'Bad Request: No valid session ID' },
317
+ id: null,
318
+ }));
319
+ }
320
+ } else if (req.method === 'GET') {
321
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
322
+ await transports.get(sessionIdHeader).handleRequest(req, res);
323
+ } else {
324
+ res.writeHead(400);
325
+ res.end('Invalid or missing session ID');
326
+ }
327
+ } else if (req.method === 'DELETE') {
328
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
329
+ await transports.get(sessionIdHeader).handleRequest(req, res);
330
+ } else {
331
+ res.writeHead(400);
332
+ res.end('Invalid or missing session ID');
333
+ }
334
+ } else {
335
+ res.writeHead(405);
336
+ res.end('Method Not Allowed');
337
+ }
338
+ } catch (error) {
339
+ console.error('[tfx-hub] 요청 처리 에러:', error.message);
340
+ if (!res.headersSent) {
341
+ const code = error.statusCode === 413 ? 413
342
+ : error instanceof SyntaxError ? 400 : 500;
343
+ const message = code === 413 ? 'Body too large'
344
+ : code === 400 ? 'Invalid JSON' : 'Internal server error';
345
+ res.writeHead(code, { 'Content-Type': 'application/json' });
346
+ res.end(JSON.stringify({
347
+ jsonrpc: '2.0',
348
+ error: { code: code === 500 ? -32603 : -32700, message },
349
+ id: null,
350
+ }));
351
+ }
352
+ }
353
+ });
354
+
355
+ router.startSweeper();
356
+
357
+ const hitlTimer = setInterval(() => {
358
+ try { hitl.checkTimeouts(); } catch {}
359
+ }, 10000);
360
+ hitlTimer.unref();
361
+
362
+ const SESSION_TTL_MS = 30 * 60 * 1000;
363
+ const sessionTimer = setInterval(() => {
364
+ const now = Date.now();
365
+ for (const [sid, transport] of transports) {
366
+ if (now - (transport._lastActivity || 0) <= SESSION_TTL_MS) continue;
367
+ try { transport.close(); } catch {}
368
+ transports.delete(sid);
369
+ }
370
+ }, 60000);
371
+ sessionTimer.unref();
372
+
373
+ mkdirSync(PID_DIR, { recursive: true });
374
+ await pipe.start();
375
+
376
+ return new Promise((resolve, reject) => {
377
+ httpServer.listen(port, host, () => {
378
+ const info = {
379
+ port,
380
+ host,
381
+ dbPath,
382
+ pid: process.pid,
383
+ url: `http://${host}:${port}/mcp`,
384
+ pipe_path: pipe.path,
385
+ pipePath: pipe.path,
386
+ };
387
+
388
+ writeFileSync(PID_FILE, JSON.stringify({
389
+ pid: process.pid,
390
+ port,
391
+ host,
392
+ url: info.url,
393
+ pipe_path: pipe.path,
394
+ pipePath: pipe.path,
395
+ started: Date.now(),
396
+ }));
397
+
398
+ console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} (PID ${process.pid})`);
399
+
400
+ const stopFn = async () => {
401
+ router.stopSweeper();
402
+ clearInterval(hitlTimer);
403
+ clearInterval(sessionTimer);
404
+ for (const [, transport] of transports) {
405
+ try { await transport.close(); } catch {}
406
+ }
407
+ transports.clear();
408
+ await pipe.stop();
409
+ store.close();
410
+ try { unlinkSync(PID_FILE); } catch {}
411
+ await new Promise((resolveClose) => httpServer.close(resolveClose));
412
+ };
413
+
414
+ resolve({ ...info, httpServer, store, router, hitl, pipe, stop: stopFn });
415
+ });
416
+ httpServer.on('error', reject);
417
+ });
418
+ }
419
+
420
+ export function getHubInfo() {
421
+ if (!existsSync(PID_FILE)) return null;
422
+ try {
423
+ return JSON.parse(readFileSync(PID_FILE, 'utf8'));
424
+ } catch {
425
+ return null;
426
+ }
427
+ }
428
+
429
+ const selfRun = process.argv[1]?.replace(/\\/g, '/').endsWith('hub/server.mjs');
430
+ if (selfRun) {
431
+ const port = parseInt(process.env.TFX_HUB_PORT || '27888', 10);
432
+ const dbPath = process.env.TFX_HUB_DB || undefined;
433
+
434
+ startHub({ port, dbPath }).then((info) => {
435
+ const shutdown = async (signal) => {
436
+ console.log(`\n[tfx-hub] ${signal} 수신, 종료 중...`);
437
+ await info.stop();
438
+ process.exit(0);
439
+ };
440
+ process.on('SIGINT', () => shutdown('SIGINT'));
441
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
442
+ }).catch((error) => {
443
+ console.error('[tfx-hub] 시작 실패:', error.message);
444
+ process.exit(1);
445
+ });
446
+ }