triflux 3.2.0-dev.1 → 3.2.0-dev.11

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