triflux 3.2.0-dev.8 → 3.3.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/bin/triflux.mjs +1296 -1055
  2. package/hooks/hooks.json +17 -0
  3. package/hooks/keyword-rules.json +20 -4
  4. package/hooks/pipeline-stop.mjs +54 -0
  5. package/hub/bridge.mjs +517 -318
  6. package/hub/hitl.mjs +45 -31
  7. package/hub/pipe.mjs +457 -0
  8. package/hub/pipeline/index.mjs +121 -0
  9. package/hub/pipeline/state.mjs +164 -0
  10. package/hub/pipeline/transitions.mjs +114 -0
  11. package/hub/router.mjs +422 -161
  12. package/hub/schema.sql +14 -0
  13. package/hub/server.mjs +499 -424
  14. package/hub/store.mjs +388 -314
  15. package/hub/team/cli-team-common.mjs +348 -0
  16. package/hub/team/cli-team-control.mjs +393 -0
  17. package/hub/team/cli-team-start.mjs +516 -0
  18. package/hub/team/cli-team-status.mjs +269 -0
  19. package/hub/team/cli.mjs +75 -1475
  20. package/hub/team/dashboard.mjs +1 -9
  21. package/hub/team/native.mjs +190 -130
  22. package/hub/team/nativeProxy.mjs +165 -78
  23. package/hub/team/orchestrator.mjs +15 -20
  24. package/hub/team/pane.mjs +137 -103
  25. package/hub/team/psmux.mjs +506 -0
  26. package/hub/team/session.mjs +393 -330
  27. package/hub/team/shared.mjs +13 -0
  28. package/hub/team/staleState.mjs +299 -0
  29. package/hub/tools.mjs +105 -31
  30. package/hub/workers/claude-worker.mjs +446 -0
  31. package/hub/workers/codex-mcp.mjs +414 -0
  32. package/hub/workers/factory.mjs +18 -0
  33. package/hub/workers/gemini-worker.mjs +349 -0
  34. package/hub/workers/interface.mjs +41 -0
  35. package/hud/hud-qos-status.mjs +1790 -1788
  36. package/package.json +4 -1
  37. package/scripts/__tests__/keyword-detector.test.mjs +8 -8
  38. package/scripts/keyword-detector.mjs +15 -0
  39. package/scripts/lib/keyword-rules.mjs +4 -1
  40. package/scripts/preflight-cache.mjs +72 -0
  41. package/scripts/psmux-steering-prototype.sh +368 -0
  42. package/scripts/setup.mjs +136 -71
  43. package/scripts/tfx-route-worker.mjs +161 -0
  44. package/scripts/tfx-route.sh +485 -91
  45. package/skills/tfx-auto/SKILL.md +90 -564
  46. package/skills/tfx-auto-codex/SKILL.md +1 -3
  47. package/skills/tfx-codex/SKILL.md +1 -4
  48. package/skills/tfx-doctor/SKILL.md +1 -0
  49. package/skills/tfx-gemini/SKILL.md +1 -4
  50. package/skills/tfx-multi/SKILL.md +378 -0
  51. package/skills/tfx-setup/SKILL.md +1 -4
  52. package/skills/tfx-team/SKILL.md +0 -304
package/hub/server.mjs CHANGED
@@ -1,122 +1,140 @@
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
+ ensurePipelineTable,
19
+ createPipeline,
20
+ } from './pipeline/index.mjs';
21
+ import {
22
+ readPipelineState,
23
+ initPipelineState,
24
+ listPipelineStates,
25
+ } from './pipeline/state.mjs';
26
+ import {
27
+ teamInfo,
28
+ teamTaskList,
29
+ teamTaskUpdate,
30
+ teamSendMessage,
31
+ } from './team/nativeProxy.mjs';
32
+
33
+ function isInitializeRequest(body) {
34
+ if (body?.method === 'initialize') return true;
35
+ if (Array.isArray(body)) return body.some((message) => message.method === 'initialize');
36
+ return false;
37
+ }
38
+
39
+ const MAX_BODY_SIZE = 1024 * 1024;
40
+ async function parseBody(req) {
41
+ const chunks = [];
42
+ let size = 0;
43
+ for await (const chunk of req) {
44
+ size += chunk.length;
45
+ if (size > MAX_BODY_SIZE) {
46
+ throw Object.assign(new Error('Body too large'), { statusCode: 413 });
47
+ }
48
+ chunks.push(chunk);
49
+ }
50
+ return JSON.parse(Buffer.concat(chunks).toString());
51
+ }
52
+
53
+ const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
54
+ const PID_FILE = join(PID_DIR, 'hub.pid');
55
+ const TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
56
+
57
+ // localhost 계열 Origin만 허용
58
+ const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
59
+
60
+ function isAllowedOrigin(origin) {
61
+ return origin && ALLOWED_ORIGIN_RE.test(origin);
62
+ }
63
+
64
+ /**
65
+ * tfx-hub 시작
66
+ * @param {object} opts
67
+ * @param {number} [opts.port]
68
+ * @param {string} [opts.dbPath]
69
+ * @param {string} [opts.host]
70
+ * @param {string|number} [opts.sessionId]
71
+ */
72
+ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessionId = process.pid } = {}) {
73
+ if (!dbPath) {
74
+ dbPath = join(PID_DIR, 'state.db');
75
+ }
76
+
77
+ // 인증 토큰 생성 (환경변수 우선, 없으면 자동 생성)
78
+ const HUB_TOKEN = process.env.TFX_HUB_TOKEN || randomUUID();
79
+ mkdirSync(join(homedir(), '.claude'), { recursive: true });
80
+ writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
81
+
82
+ const store = createStore(dbPath);
83
+ const router = createRouter(store);
84
+ const pipe = createPipeServer({ router, store, sessionId });
85
+ const hitl = createHitlManager(store, router);
86
+ const tools = createTools(store, router, hitl, pipe);
87
+ const transports = new Map();
88
+
89
+ function createMcpForSession() {
90
+ const mcp = new Server(
91
+ { name: 'tfx-hub', version: '1.0.0' },
92
+ { capabilities: { tools: {} } },
93
+ );
94
+
95
+ mcp.setRequestHandler(
96
+ ListToolsRequestSchema,
97
+ async () => ({
98
+ tools: tools.map((tool) => ({
99
+ name: tool.name,
100
+ description: tool.description,
101
+ inputSchema: tool.inputSchema,
102
+ })),
103
+ }),
104
+ );
105
+
106
+ mcp.setRequestHandler(
107
+ CallToolRequestSchema,
108
+ async (request) => {
109
+ const { name, arguments: args } = request.params;
110
+ const tool = tools.find((candidate) => candidate.name === name);
111
+ if (!tool) {
112
+ return {
113
+ content: [{ type: 'text', text: JSON.stringify({ ok: false, error: { code: 'UNKNOWN_TOOL', message: `도구 없음: ${name}` } }) }],
114
+ isError: true,
115
+ };
116
+ }
117
+ return tool.handler(args || {});
118
+ },
119
+ );
120
+
121
+ return mcp;
122
+ }
123
+
124
+ const httpServer = createHttpServer(async (req, res) => {
125
+ // CORS: localhost 계열 Origin만 허용
126
+ const origin = req.headers['origin'];
127
+ if (isAllowedOrigin(origin)) {
128
+ res.setHeader('Access-Control-Allow-Origin', origin);
129
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
130
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
131
+ }
132
+
133
+ if (req.method === 'OPTIONS') {
134
+ res.writeHead(isAllowedOrigin(origin) ? 204 : 403);
135
+ return res.end();
136
+ }
137
+
120
138
  if (req.url === '/' || req.url === '/status') {
121
139
  const status = router.getStatus('hub').data;
122
140
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -125,317 +143,374 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1' } = {}
125
143
  sessions: transports.size,
126
144
  pid: process.pid,
127
145
  port,
146
+ pipe_path: pipe.path,
147
+ pipe: pipe.getStatus(),
128
148
  }));
129
149
  }
130
150
 
131
- // /health, /healthz — 최소 헬스 응답 (레거시 호환)
132
151
  if (req.url === '/health' || req.url === '/healthz') {
133
152
  const status = router.getStatus('hub').data;
134
153
  const healthy = status?.hub?.state === 'healthy';
135
154
  res.writeHead(healthy ? 200 : 503, { 'Content-Type': 'application/json' });
136
155
  return res.end(JSON.stringify({ ok: healthy }));
137
156
  }
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
- }
157
+
158
+ if (req.url.startsWith('/bridge')) {
159
+ res.setHeader('Content-Type', 'application/json');
160
+
161
+ // Bearer 토큰 인증
162
+ const authHeader = req.headers['authorization'] || '';
163
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
164
+ if (bearerToken !== HUB_TOKEN) {
165
+ res.writeHead(401);
166
+ return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
167
+ }
168
+
169
+ if (req.method !== 'POST' && req.method !== 'DELETE') {
170
+ res.writeHead(405);
171
+ return res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
172
+ }
173
+
174
+ try {
175
+ const body = req.method === 'POST' ? await parseBody(req) : {};
176
+ const path = req.url.replace(/\?.*/, '');
177
+
178
+ if (path === '/bridge/register' && req.method === 'POST') {
179
+ const { agent_id, cli, timeout_sec = 600, topics = [], capabilities = [], metadata = {} } = body;
180
+ if (!agent_id || !cli) {
181
+ res.writeHead(400);
182
+ return res.end(JSON.stringify({ ok: false, error: 'agent_id, cli 필수' }));
183
+ }
184
+
185
+ const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
186
+ const result = await pipe.executeCommand('register', {
187
+ agent_id,
188
+ cli,
189
+ capabilities,
190
+ topics,
191
+ heartbeat_ttl_ms,
192
+ metadata,
193
+ });
194
+ res.writeHead(200);
195
+ return res.end(JSON.stringify(result));
196
+ }
197
+
198
+ if (path === '/bridge/result' && req.method === 'POST') {
199
+ const { agent_id, topic = 'task.result', payload = {}, trace_id, correlation_id } = body;
200
+ if (!agent_id) {
201
+ res.writeHead(400);
202
+ return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
203
+ }
204
+
205
+ const result = await pipe.executeCommand('result', {
206
+ agent_id,
207
+ topic,
208
+ payload,
209
+ trace_id,
210
+ correlation_id,
211
+ });
212
+ res.writeHead(200);
213
+ return res.end(JSON.stringify(result));
214
+ }
215
+
216
+ if (path === '/bridge/control' && req.method === 'POST') {
217
+ const {
218
+ from_agent = 'lead',
219
+ to_agent,
220
+ command,
221
+ reason = '',
222
+ payload = {},
223
+ trace_id,
224
+ correlation_id,
225
+ ttl_ms = 3600000,
226
+ } = body;
227
+
228
+ if (!to_agent || !command) {
229
+ res.writeHead(400);
230
+ return res.end(JSON.stringify({ ok: false, error: 'to_agent, command 필수' }));
231
+ }
232
+
233
+ const result = await pipe.executeCommand('control', {
234
+ from_agent,
235
+ to_agent,
236
+ command,
237
+ reason,
238
+ payload,
239
+ ttl_ms,
240
+ trace_id,
241
+ correlation_id,
242
+ });
243
+
244
+ res.writeHead(200);
245
+ return res.end(JSON.stringify(result));
246
+ }
247
+
248
+ if (req.method === 'POST') {
249
+ let teamResult = null;
250
+ if (path === '/bridge/team/info' || path === '/bridge/team-info') {
251
+ teamResult = await teamInfo(body);
252
+ } else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
253
+ teamResult = await teamTaskList(body);
254
+ } else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
255
+ teamResult = await teamTaskUpdate(body);
256
+ } else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
257
+ teamResult = await teamSendMessage(body);
258
+ }
259
+
260
+ if (teamResult) {
261
+ let status = 200;
262
+ const code = teamResult?.error?.code;
263
+ if (!teamResult.ok) {
264
+ if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') status = 404;
265
+ else if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') status = 409;
266
+ else if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') status = 400;
267
+ else status = 500;
268
+ }
269
+ res.writeHead(status);
270
+ return res.end(JSON.stringify(teamResult));
271
+ }
272
+
273
+ if (path.startsWith('/bridge/team')) {
274
+ res.writeHead(404);
275
+ return res.end(JSON.stringify({ ok: false, error: `Unknown team endpoint: ${path}` }));
276
+ }
277
+
278
+ // ── 파이프라인 엔드포인트 ──
279
+ if (path === '/bridge/pipeline/state' && req.method === 'POST') {
280
+ ensurePipelineTable(store.db);
281
+ const { team_name } = body;
282
+ const state = readPipelineState(store.db, team_name);
283
+ res.writeHead(state ? 200 : 404);
284
+ return res.end(JSON.stringify(state
285
+ ? { ok: true, data: state }
286
+ : { ok: false, error: 'pipeline_not_found' }));
287
+ }
288
+
289
+ if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
290
+ ensurePipelineTable(store.db);
291
+ const { team_name, phase } = body;
292
+ const pipeline = createPipeline(store.db, team_name);
293
+ const result = pipeline.advance(phase);
294
+ res.writeHead(result.ok ? 200 : 400);
295
+ return res.end(JSON.stringify(result));
296
+ }
297
+
298
+ if (path === '/bridge/pipeline/init' && req.method === 'POST') {
299
+ ensurePipelineTable(store.db);
300
+ const { team_name, fix_max, ralph_max } = body;
301
+ const state = initPipelineState(store.db, team_name, { fix_max, ralph_max });
302
+ res.writeHead(200);
303
+ return res.end(JSON.stringify({ ok: true, data: state }));
304
+ }
305
+
306
+ if (path === '/bridge/pipeline/list' && req.method === 'POST') {
307
+ ensurePipelineTable(store.db);
308
+ const states = listPipelineStates(store.db);
309
+ res.writeHead(200);
310
+ return res.end(JSON.stringify({ ok: true, data: states }));
311
+ }
312
+ }
313
+
314
+ if (path === '/bridge/context' && req.method === 'POST') {
315
+ const { agent_id, topics, max_messages = 10 } = body;
316
+ if (!agent_id) {
317
+ res.writeHead(400);
318
+ return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
319
+ }
320
+
321
+ const result = await pipe.executeQuery('context', {
322
+ agent_id,
323
+ topics,
324
+ max_messages,
325
+ });
326
+ res.writeHead(200);
327
+ return res.end(JSON.stringify(result));
328
+ }
329
+
330
+ if (path === '/bridge/deregister' && req.method === 'POST') {
331
+ const { agent_id } = body;
332
+ if (!agent_id) {
333
+ res.writeHead(400);
334
+ return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
335
+ }
336
+ const result = await pipe.executeCommand('deregister', { agent_id });
337
+ res.writeHead(200);
338
+ return res.end(JSON.stringify(result));
339
+ }
340
+
341
+ res.writeHead(404);
342
+ return res.end(JSON.stringify({ ok: false, error: 'Unknown bridge endpoint' }));
343
+ } catch (error) {
344
+ if (!res.headersSent) {
345
+ res.writeHead(500);
346
+ res.end(JSON.stringify({ ok: false, error: error.message }));
347
+ }
348
+ return;
349
+ }
350
+ }
351
+
352
+ if (req.url !== '/mcp') {
353
+ res.writeHead(404);
354
+ return res.end('Not Found');
355
+ }
356
+
357
+ try {
358
+ const sessionIdHeader = req.headers['mcp-session-id'];
359
+
360
+ if (req.method === 'POST') {
361
+ const body = await parseBody(req);
362
+
363
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
364
+ const transport = transports.get(sessionIdHeader);
365
+ transport._lastActivity = Date.now();
366
+ await transport.handleRequest(req, res, body);
367
+ } else if (!sessionIdHeader && isInitializeRequest(body)) {
368
+ const transport = new StreamableHTTPServerTransport({
369
+ sessionIdGenerator: () => randomUUID(),
370
+ onsessioninitialized: (sid) => {
371
+ transport._lastActivity = Date.now();
372
+ transports.set(sid, transport);
373
+ },
374
+ });
375
+ transport.onclose = () => {
376
+ if (transport.sessionId) transports.delete(transport.sessionId);
377
+ };
378
+ const mcp = createMcpForSession();
379
+ await mcp.connect(transport);
380
+ await transport.handleRequest(req, res, body);
381
+ } else {
382
+ res.writeHead(400, { 'Content-Type': 'application/json' });
383
+ res.end(JSON.stringify({
384
+ jsonrpc: '2.0',
385
+ error: { code: -32000, message: 'Bad Request: No valid session ID' },
386
+ id: null,
387
+ }));
388
+ }
389
+ } else if (req.method === 'GET') {
390
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
391
+ await transports.get(sessionIdHeader).handleRequest(req, res);
392
+ } else {
393
+ res.writeHead(400);
394
+ res.end('Invalid or missing session ID');
395
+ }
396
+ } else if (req.method === 'DELETE') {
397
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
398
+ await transports.get(sessionIdHeader).handleRequest(req, res);
399
+ } else {
400
+ res.writeHead(400);
401
+ res.end('Invalid or missing session ID');
402
+ }
403
+ } else {
404
+ res.writeHead(405);
405
+ res.end('Method Not Allowed');
406
+ }
407
+ } catch (error) {
408
+ console.error('[tfx-hub] 요청 처리 에러:', error.message);
409
+ if (!res.headersSent) {
410
+ const code = error.statusCode === 413 ? 413
411
+ : error instanceof SyntaxError ? 400 : 500;
412
+ const message = code === 413 ? 'Body too large'
413
+ : code === 400 ? 'Invalid JSON' : 'Internal server error';
414
+ res.writeHead(code, { 'Content-Type': 'application/json' });
415
+ res.end(JSON.stringify({
416
+ jsonrpc: '2.0',
417
+ error: { code: code === 500 ? -32603 : -32700, message },
418
+ id: null,
419
+ }));
420
+ }
421
+ }
422
+ });
423
+
424
+ router.startSweeper();
425
+
426
+ const hitlTimer = setInterval(() => {
427
+ try { hitl.checkTimeouts(); } catch {}
428
+ }, 10000);
429
+ hitlTimer.unref();
430
+
431
+ const SESSION_TTL_MS = 30 * 60 * 1000;
432
+ const sessionTimer = setInterval(() => {
433
+ const now = Date.now();
434
+ for (const [sid, transport] of transports) {
435
+ if (now - (transport._lastActivity || 0) <= SESSION_TTL_MS) continue;
436
+ try { transport.close(); } catch {}
437
+ transports.delete(sid);
438
+ }
439
+ }, 60000);
440
+ sessionTimer.unref();
441
+
442
+ mkdirSync(PID_DIR, { recursive: true });
443
+ await pipe.start();
444
+
445
+ return new Promise((resolve, reject) => {
446
+ httpServer.listen(port, host, () => {
447
+ const info = {
448
+ port,
449
+ host,
450
+ dbPath,
451
+ pid: process.pid,
452
+ url: `http://${host}:${port}/mcp`,
453
+ pipe_path: pipe.path,
454
+ pipePath: pipe.path,
455
+ };
456
+
457
+ writeFileSync(PID_FILE, JSON.stringify({
458
+ pid: process.pid,
459
+ port,
460
+ host,
461
+ url: info.url,
462
+ pipe_path: pipe.path,
463
+ pipePath: pipe.path,
464
+ started: Date.now(),
465
+ }));
466
+
467
+ console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} (PID ${process.pid})`);
468
+
469
+ const stopFn = async () => {
470
+ router.stopSweeper();
471
+ clearInterval(hitlTimer);
472
+ clearInterval(sessionTimer);
473
+ for (const [, transport] of transports) {
474
+ try { await transport.close(); } catch {}
475
+ }
476
+ transports.clear();
477
+ await pipe.stop();
478
+ store.close();
479
+ try { unlinkSync(PID_FILE); } catch {}
480
+ try { unlinkSync(TOKEN_FILE); } catch {}
481
+ await new Promise((resolveClose) => httpServer.close(resolveClose));
482
+ };
483
+
484
+ resolve({ ...info, httpServer, store, router, hitl, pipe, stop: stopFn });
485
+ });
486
+ httpServer.on('error', reject);
487
+ });
488
+ }
489
+
490
+ export function getHubInfo() {
491
+ if (!existsSync(PID_FILE)) return null;
492
+ try {
493
+ return JSON.parse(readFileSync(PID_FILE, 'utf8'));
494
+ } catch {
495
+ return null;
496
+ }
497
+ }
498
+
499
+ const selfRun = process.argv[1]?.replace(/\\/g, '/').endsWith('hub/server.mjs');
500
+ if (selfRun) {
501
+ const port = parseInt(process.env.TFX_HUB_PORT || '27888', 10);
502
+ const dbPath = process.env.TFX_HUB_DB || undefined;
503
+
504
+ startHub({ port, dbPath }).then((info) => {
505
+ const shutdown = async (signal) => {
506
+ console.log(`\n[tfx-hub] ${signal} 수신, 종료 중...`);
507
+ await info.stop();
508
+ process.exit(0);
509
+ };
510
+ process.on('SIGINT', () => shutdown('SIGINT'));
511
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
512
+ }).catch((error) => {
513
+ console.error('[tfx-hub] 시작 실패:', error.message);
514
+ process.exit(1);
515
+ });
516
+ }