@triflux/remote 10.0.0-alpha.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 (68) hide show
  1. package/hub/pipe.mjs +579 -0
  2. package/hub/public/dashboard.html +355 -0
  3. package/hub/public/tray-icon.ico +0 -0
  4. package/hub/public/tray-icon.png +0 -0
  5. package/hub/server.mjs +1124 -0
  6. package/hub/store-adapter.mjs +851 -0
  7. package/hub/store.mjs +897 -0
  8. package/hub/team/agent-map.json +11 -0
  9. package/hub/team/ansi.mjs +379 -0
  10. package/hub/team/backend.mjs +90 -0
  11. package/hub/team/cli/commands/attach.mjs +37 -0
  12. package/hub/team/cli/commands/control.mjs +43 -0
  13. package/hub/team/cli/commands/debug.mjs +74 -0
  14. package/hub/team/cli/commands/focus.mjs +53 -0
  15. package/hub/team/cli/commands/interrupt.mjs +36 -0
  16. package/hub/team/cli/commands/kill.mjs +37 -0
  17. package/hub/team/cli/commands/list.mjs +24 -0
  18. package/hub/team/cli/commands/send.mjs +37 -0
  19. package/hub/team/cli/commands/start/index.mjs +106 -0
  20. package/hub/team/cli/commands/start/parse-args.mjs +130 -0
  21. package/hub/team/cli/commands/start/start-headless.mjs +109 -0
  22. package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
  23. package/hub/team/cli/commands/start/start-mux.mjs +73 -0
  24. package/hub/team/cli/commands/start/start-wt.mjs +69 -0
  25. package/hub/team/cli/commands/status.mjs +87 -0
  26. package/hub/team/cli/commands/stop.mjs +31 -0
  27. package/hub/team/cli/commands/task.mjs +30 -0
  28. package/hub/team/cli/commands/tasks.mjs +13 -0
  29. package/hub/team/cli/help.mjs +42 -0
  30. package/hub/team/cli/index.mjs +41 -0
  31. package/hub/team/cli/manifest.mjs +29 -0
  32. package/hub/team/cli/render.mjs +30 -0
  33. package/hub/team/cli/services/attach-fallback.mjs +54 -0
  34. package/hub/team/cli/services/hub-client.mjs +208 -0
  35. package/hub/team/cli/services/member-selector.mjs +30 -0
  36. package/hub/team/cli/services/native-control.mjs +117 -0
  37. package/hub/team/cli/services/runtime-mode.mjs +62 -0
  38. package/hub/team/cli/services/state-store.mjs +48 -0
  39. package/hub/team/cli/services/task-model.mjs +30 -0
  40. package/hub/team/dashboard-anchor.mjs +14 -0
  41. package/hub/team/dashboard-layout.mjs +33 -0
  42. package/hub/team/dashboard-open.mjs +153 -0
  43. package/hub/team/dashboard.mjs +274 -0
  44. package/hub/team/handoff.mjs +303 -0
  45. package/hub/team/headless.mjs +1149 -0
  46. package/hub/team/native-supervisor.mjs +392 -0
  47. package/hub/team/native.mjs +649 -0
  48. package/hub/team/nativeProxy.mjs +681 -0
  49. package/hub/team/orchestrator.mjs +161 -0
  50. package/hub/team/pane.mjs +153 -0
  51. package/hub/team/psmux.mjs +1354 -0
  52. package/hub/team/routing.mjs +223 -0
  53. package/hub/team/session.mjs +611 -0
  54. package/hub/team/shared.mjs +13 -0
  55. package/hub/team/staleState.mjs +361 -0
  56. package/hub/team/tui-lite.mjs +380 -0
  57. package/hub/team/tui-viewer.mjs +463 -0
  58. package/hub/team/tui.mjs +1245 -0
  59. package/hub/tools.mjs +554 -0
  60. package/hub/tray.mjs +376 -0
  61. package/hub/workers/claude-worker.mjs +475 -0
  62. package/hub/workers/codex-mcp.mjs +504 -0
  63. package/hub/workers/delegator-mcp.mjs +1076 -0
  64. package/hub/workers/factory.mjs +21 -0
  65. package/hub/workers/gemini-worker.mjs +373 -0
  66. package/hub/workers/interface.mjs +52 -0
  67. package/hub/workers/worker-utils.mjs +104 -0
  68. package/package.json +31 -0
package/hub/server.mjs ADDED
@@ -0,0 +1,1124 @@
1
+ // hub/server.mjs — HTTP MCP + REST bridge + Named Pipe 서버 진입점
2
+ import { createServer as createHttpServer } from 'node:http';
3
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
4
+ import { extname, join, resolve, sep } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { execSync as execSyncHub } from 'node:child_process';
9
+
10
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
12
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
13
+
14
+ import { createStoreAdapter } from './store-adapter.mjs';
15
+ import { createRouter } from './router.mjs';
16
+ import { createHitlManager } from './hitl.mjs';
17
+ import { createPipeServer } from './pipe.mjs';
18
+ import { createAssignCallbackServer } from './assign-callbacks.mjs';
19
+ import { createTools } from './tools.mjs';
20
+ import { DelegatorService } from './delegator/index.mjs';
21
+ import { createDelegatorMcpWorker } from './workers/delegator-mcp.mjs';
22
+ import { cleanupOrphanNodeProcesses } from './lib/process-utils.mjs';
23
+ import { createModuleLogger } from '../scripts/lib/logger.mjs';
24
+ import { wrapRequestHandler } from './middleware/request-logger.mjs';
25
+ import { acquireLock, getVersionHash, releaseLock, writeState } from './state.mjs';
26
+ import { createAdaptiveFingerprintService } from './session-fingerprint.mjs';
27
+ import { registerTeamBridge } from './team-bridge.mjs';
28
+ import {
29
+ teamInfo,
30
+ teamTaskList,
31
+ teamTaskUpdate,
32
+ teamSendMessage,
33
+ } from './team/nativeProxy.mjs';
34
+
35
+ registerTeamBridge({ teamInfo, teamTaskList, teamTaskUpdate, teamSendMessage });
36
+
37
+ const hubLog = createModuleLogger('hub');
38
+
39
+ const MAX_BODY_SIZE = 1024 * 1024;
40
+ const PUBLIC_PATHS = new Set(['/', '/status', '/health', '/healthz']);
41
+ const RATE_LIMIT_MAX = 100; // requests per window
42
+ const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute sliding window
43
+ const LOOPBACK_REMOTE_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
44
+ const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
45
+ const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url));
46
+ const PUBLIC_DIR = resolve(join(PROJECT_ROOT, 'hub', 'public'));
47
+ const CACHE_DIR = join(homedir(), '.claude', 'cache');
48
+ const BATCH_EVENTS_PATH = join(CACHE_DIR, 'batch-events.jsonl');
49
+ const SV_ACCUMULATOR_PATH = join(CACHE_DIR, 'sv-accumulator.json');
50
+ const CODEX_RATE_LIMITS_CACHE_PATH = join(CACHE_DIR, 'codex-rate-limits-cache.json');
51
+ const GEMINI_QUOTA_CACHE_PATH = join(CACHE_DIR, 'gemini-quota-cache.json');
52
+ const CLAUDE_USAGE_CACHE_PATH = join(CACHE_DIR, 'claude-usage-cache.json');
53
+ const AIMD_WINDOW_MS = 30 * 60 * 1000;
54
+ const AIMD_INITIAL_BATCH_SIZE = 3;
55
+ const AIMD_MIN_BATCH_SIZE = 1;
56
+ const AIMD_MAX_BATCH_SIZE = 10;
57
+ const HUB_IDLE_TIMEOUT_DEFAULT_MS = 10 * 60 * 1000;
58
+ const HUB_IDLE_SWEEP_DEFAULT_MS = 60 * 1000;
59
+ const STATIC_CONTENT_TYPES = Object.freeze({
60
+ '.html': 'text/html',
61
+ '.css': 'text/css',
62
+ '.js': 'application/javascript',
63
+ '.png': 'image/png',
64
+ });
65
+
66
+ // IP-based sliding window rate limiter (in-memory, no external deps)
67
+ // Each entry is an array of request timestamps within the current window.
68
+ const rateLimitMap = new Map();
69
+
70
+ function checkRateLimit(ip) {
71
+ const now = Date.now();
72
+ const cutoff = now - RATE_LIMIT_WINDOW_MS;
73
+ const timestamps = (rateLimitMap.get(ip) || []).filter((t) => t >= cutoff);
74
+ if (timestamps.length >= RATE_LIMIT_MAX) {
75
+ // Oldest timestamp in window tells us when a slot frees up
76
+ const retryAfterMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now;
77
+ rateLimitMap.set(ip, timestamps);
78
+ return { allowed: false, retryAfterSec: Math.ceil(retryAfterMs / 1000) };
79
+ }
80
+ rateLimitMap.set(ip, [...timestamps, now]);
81
+ return { allowed: true, retryAfterSec: 0 };
82
+ }
83
+
84
+ function isInitializeRequest(body) {
85
+ if (body?.method === 'initialize') return true;
86
+ if (Array.isArray(body)) return body.some((message) => message.method === 'initialize');
87
+ return false;
88
+ }
89
+
90
+ async function parseBody(req) {
91
+ const chunks = [];
92
+ let size = 0;
93
+ for await (const chunk of req) {
94
+ size += chunk.length;
95
+ if (size > MAX_BODY_SIZE) {
96
+ throw Object.assign(new Error('Body too large'), { statusCode: 413 });
97
+ }
98
+ chunks.push(chunk);
99
+ }
100
+ return JSON.parse(Buffer.concat(chunks).toString());
101
+ }
102
+
103
+ const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
104
+ const PID_FILE = join(PID_DIR, 'hub.pid');
105
+ const TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
106
+
107
+ function isPublicPath(path) {
108
+ return PUBLIC_PATHS.has(path)
109
+ || path === '/dashboard'
110
+ || path === '/api/qos-stats'
111
+ || path.startsWith('/public/');
112
+ }
113
+
114
+ function isAllowedOrigin(origin) {
115
+ return origin && ALLOWED_ORIGIN_RE.test(origin);
116
+ }
117
+
118
+ function getRequestPath(url = '/') {
119
+ try {
120
+ return new URL(url, 'http://127.0.0.1').pathname;
121
+ } catch {
122
+ return String(url).replace(/\?.*/, '') || '/';
123
+ }
124
+ }
125
+
126
+ function isLoopbackRemoteAddress(remoteAddress) {
127
+ return typeof remoteAddress === 'string' && LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress);
128
+ }
129
+
130
+ function extractBearerToken(req) {
131
+ const authHeader = typeof req.headers.authorization === 'string' ? req.headers.authorization : '';
132
+ return authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
133
+ }
134
+
135
+ function writeJson(res, statusCode, body, headers = {}) {
136
+ res.writeHead(statusCode, {
137
+ 'Content-Type': 'application/json',
138
+ ...headers,
139
+ });
140
+ res.end(JSON.stringify(body));
141
+ }
142
+
143
+ function applyCorsHeaders(req, res) {
144
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
145
+ if (origin) {
146
+ res.setHeader('Vary', 'Origin');
147
+ }
148
+ if (!isAllowedOrigin(origin)) return false;
149
+
150
+ res.setHeader('Access-Control-Allow-Origin', origin);
151
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
152
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
153
+ return true;
154
+ }
155
+
156
+ function safeTokenCompare(a, b) {
157
+ const ha = createHash('sha256').update(a).digest();
158
+ const hb = createHash('sha256').update(b).digest();
159
+ return timingSafeEqual(ha, hb);
160
+ }
161
+
162
+ function isAuthorizedRequest(req, path, hubToken) {
163
+ if (!hubToken) {
164
+ return isLoopbackRemoteAddress(req.socket.remoteAddress);
165
+ }
166
+ if (isPublicPath(path)) return true;
167
+ const supplied = extractBearerToken(req);
168
+ if (!supplied) return false;
169
+ return safeTokenCompare(supplied, hubToken);
170
+ }
171
+
172
+ function resolveTeamStatusCode(result) {
173
+ if (result?.ok) return 200;
174
+ const code = result?.error?.code;
175
+ if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') return 404;
176
+ if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') return 409;
177
+ if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') return 400;
178
+ return 500;
179
+ }
180
+
181
+ function resolvePipelineStatusCode(result) {
182
+ if (result?.ok) return 200;
183
+ if (result?.error === 'pipeline_not_found') return 404;
184
+ if (result?.error === 'hub_db_not_found') return 503;
185
+ return 400;
186
+ }
187
+
188
+ function safeReadJsonFile(filePath) {
189
+ try {
190
+ if (!existsSync(filePath)) return null;
191
+ return JSON.parse(readFileSync(filePath, 'utf8'));
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ function parsePositiveInt(value, fallback) {
198
+ const parsed = Number.parseInt(String(value ?? ''), 10);
199
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
200
+ }
201
+
202
+ function readRecentAimdEvents(now = Date.now()) {
203
+ try {
204
+ if (!existsSync(BATCH_EVENTS_PATH)) return [];
205
+ const cutoff = now - AIMD_WINDOW_MS;
206
+ return readFileSync(BATCH_EVENTS_PATH, 'utf8')
207
+ .split(/\r?\n/)
208
+ .filter(Boolean)
209
+ .map((line) => {
210
+ try {
211
+ return JSON.parse(line);
212
+ } catch {
213
+ return null;
214
+ }
215
+ })
216
+ .filter((event) => {
217
+ const timestamp = Number(event?.ts ?? event?.timestamp ?? 0);
218
+ return event && Number.isFinite(timestamp) && timestamp >= cutoff;
219
+ });
220
+ } catch {
221
+ return [];
222
+ }
223
+ }
224
+
225
+ function calculateAimdBatchSize(events) {
226
+ let batchSize = AIMD_INITIAL_BATCH_SIZE;
227
+
228
+ for (const event of events) {
229
+ const result = event?.result;
230
+ if (result === 'success' || result === 'success_with_warnings') {
231
+ batchSize = Math.min(AIMD_MAX_BATCH_SIZE, batchSize + 1);
232
+ } else if (result === 'failed' || result === 'timeout') {
233
+ batchSize = Math.max(AIMD_MIN_BATCH_SIZE, batchSize * 0.5);
234
+ }
235
+ }
236
+
237
+ return batchSize;
238
+ }
239
+
240
+ function getQosStatsPayload() {
241
+ const events = readRecentAimdEvents();
242
+ return {
243
+ aimd: {
244
+ batchSize: calculateAimdBatchSize(events),
245
+ events,
246
+ },
247
+ accumulator: safeReadJsonFile(SV_ACCUMULATOR_PATH),
248
+ codex: safeReadJsonFile(CODEX_RATE_LIMITS_CACHE_PATH),
249
+ gemini: safeReadJsonFile(GEMINI_QUOTA_CACHE_PATH),
250
+ claude: safeReadJsonFile(CLAUDE_USAGE_CACHE_PATH),
251
+ };
252
+ }
253
+
254
+ function resolvePublicFilePath(path) {
255
+ let relativePath = null;
256
+ if (path === '/dashboard') {
257
+ relativePath = 'dashboard.html';
258
+ } else if (path.startsWith('/public/')) {
259
+ relativePath = path.slice('/public/'.length);
260
+ }
261
+
262
+ if (!relativePath) return null;
263
+
264
+ try {
265
+ relativePath = decodeURIComponent(relativePath).replace(/^[/\\]+/, '');
266
+ } catch {
267
+ return null;
268
+ }
269
+
270
+ const filePath = resolve(PUBLIC_DIR, relativePath);
271
+ const publicPrefix = `${PUBLIC_DIR}${sep}`;
272
+ if (filePath !== PUBLIC_DIR && !filePath.startsWith(publicPrefix)) {
273
+ return null;
274
+ }
275
+ return filePath;
276
+ }
277
+
278
+ function servePublicFile(res, path) {
279
+ const filePath = resolvePublicFilePath(path);
280
+ if (!filePath) return false;
281
+
282
+ mkdirSync(PUBLIC_DIR, { recursive: true });
283
+ if (!existsSync(filePath)) {
284
+ hubLog.warn({ filePath }, 'static.not_found');
285
+ res.writeHead(404);
286
+ res.end('Not Found (static file missing)');
287
+ return true;
288
+ }
289
+
290
+ try {
291
+ const body = readFileSync(filePath);
292
+ res.writeHead(200, {
293
+ 'Content-Type': STATIC_CONTENT_TYPES[extname(filePath).toLowerCase()] || 'application/octet-stream',
294
+ });
295
+ res.end(body);
296
+ } catch {
297
+ res.writeHead(404);
298
+ res.end('Not Found');
299
+ }
300
+ return true;
301
+ }
302
+
303
+ /**
304
+ * tfx-hub 시작
305
+ * @param {object} opts
306
+ * @param {number} [opts.port]
307
+ * @param {string} [opts.dbPath]
308
+ * @param {string} [opts.host]
309
+ * @param {string|number} [opts.sessionId]
310
+ * @param {(options: { cwd: string }) => object} [opts.createDelegatorWorker]
311
+ */
312
+ export async function startHub({
313
+ port = 27888,
314
+ dbPath,
315
+ host = '127.0.0.1',
316
+ sessionId = process.pid,
317
+ createDelegatorWorker = createDelegatorMcpWorker,
318
+ } = {}) {
319
+ const hubIdleTimeoutMs = parsePositiveInt(process.env.TFX_HUB_IDLE_TIMEOUT_MS, HUB_IDLE_TIMEOUT_DEFAULT_MS);
320
+ const hubIdleSweepMs = parsePositiveInt(
321
+ process.env.TFX_HUB_IDLE_SWEEP_MS,
322
+ Math.min(HUB_IDLE_SWEEP_DEFAULT_MS, hubIdleTimeoutMs),
323
+ );
324
+ let lastRequestAt = Date.now();
325
+ const markRequestActivity = () => {
326
+ lastRequestAt = Date.now();
327
+ };
328
+
329
+ if (!dbPath) {
330
+ // DB를 npm 패키지 밖에 저장하여 npm update 시 EBUSY 방지
331
+ // 기존: PROJECT_ROOT/.tfx/state/state.db (패키지 내부 → 락 충돌)
332
+ // 변경: ~/.claude/cache/tfx-hub/state.db (패키지 외부 → 안전)
333
+ const hubCacheDir = join(homedir(), '.claude', 'cache', 'tfx-hub');
334
+ mkdirSync(hubCacheDir, { recursive: true });
335
+ dbPath = join(hubCacheDir, 'state.db');
336
+ }
337
+
338
+ mkdirSync(PUBLIC_DIR, { recursive: true });
339
+
340
+ const version = getVersionHash();
341
+ const startedAtMs = Date.now();
342
+ const startedAt = new Date(startedAtMs).toISOString();
343
+ await acquireLock();
344
+ let lockHeld = true;
345
+ const releaseStartupLock = () => {
346
+ if (!lockHeld) return;
347
+ releaseLock();
348
+ lockHeld = false;
349
+ };
350
+
351
+ const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
352
+ if (HUB_TOKEN) {
353
+ mkdirSync(join(homedir(), '.claude'), { recursive: true });
354
+ writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
355
+ } else {
356
+ try { unlinkSync(TOKEN_FILE); } catch {}
357
+ }
358
+
359
+ const store = await createStoreAdapter(dbPath);
360
+ const router = createRouter(store);
361
+ const fingerprintService = createAdaptiveFingerprintService({ store });
362
+
363
+ // Delegator MCP resident service 초기화
364
+ const delegatorWorker = createDelegatorWorker({ cwd: PROJECT_ROOT });
365
+ try {
366
+ await delegatorWorker.start();
367
+ } catch (error) {
368
+ releaseStartupLock();
369
+ throw error;
370
+ }
371
+ const delegatorService = new DelegatorService({ worker: delegatorWorker });
372
+
373
+ const pipe = createPipeServer({ router, store, sessionId, delegatorService });
374
+ const assignCallbacks = createAssignCallbackServer({ store, sessionId });
375
+ const hitl = createHitlManager(store, router);
376
+ const tools = createTools(store, router, hitl, pipe);
377
+ const transports = new Map();
378
+
379
+ function createMcpForSession() {
380
+ const mcp = new Server(
381
+ { name: 'tfx-hub', version: '1.0.0' },
382
+ { capabilities: { tools: {} } },
383
+ );
384
+
385
+ mcp.setRequestHandler(
386
+ ListToolsRequestSchema,
387
+ async () => ({
388
+ tools: tools.map((tool) => ({
389
+ name: tool.name,
390
+ description: tool.description,
391
+ inputSchema: tool.inputSchema,
392
+ })),
393
+ }),
394
+ );
395
+
396
+ mcp.setRequestHandler(
397
+ CallToolRequestSchema,
398
+ async (request) => {
399
+ const { name, arguments: args } = request.params;
400
+ const tool = tools.find((candidate) => candidate.name === name);
401
+ if (!tool) {
402
+ return {
403
+ content: [{ type: 'text', text: JSON.stringify({ ok: false, error: { code: 'UNKNOWN_TOOL', message: `도구 없음: ${name}` } }) }],
404
+ isError: true,
405
+ };
406
+ }
407
+ return tool.handler(args || {});
408
+ },
409
+ );
410
+
411
+ return mcp;
412
+ }
413
+
414
+ const httpServer = createHttpServer(wrapRequestHandler(async (req, res) => {
415
+ markRequestActivity();
416
+ res.setHeader('X-Content-Type-Options', 'nosniff');
417
+ res.setHeader('X-Frame-Options', 'DENY');
418
+ const path = getRequestPath(req.url);
419
+ const corsAllowed = applyCorsHeaders(req, res);
420
+
421
+ if (req.method === 'OPTIONS') {
422
+ const localOnlyMode = !HUB_TOKEN;
423
+ const isLoopbackRequest = isLoopbackRemoteAddress(req.socket.remoteAddress);
424
+ res.writeHead(corsAllowed && (!localOnlyMode || isLoopbackRequest) ? 204 : 403);
425
+ return res.end();
426
+ }
427
+
428
+ const clientIp = req.socket.remoteAddress || 'unknown';
429
+ if (!isLoopbackRemoteAddress(clientIp)) {
430
+ const rateCheck = checkRateLimit(clientIp);
431
+ if (!rateCheck.allowed) {
432
+ return writeJson(
433
+ res,
434
+ 429,
435
+ { ok: false, error: 'Too Many Requests' },
436
+ { 'Retry-After': String(rateCheck.retryAfterSec) },
437
+ );
438
+ }
439
+ }
440
+
441
+ if (!isAuthorizedRequest(req, path, HUB_TOKEN)) {
442
+ if (!HUB_TOKEN) {
443
+ return writeJson(res, 403, { ok: false, error: 'Forbidden: localhost only' });
444
+ }
445
+ return writeJson(
446
+ res,
447
+ 401,
448
+ { ok: false, error: 'Unauthorized' },
449
+ { 'WWW-Authenticate': 'Bearer realm="tfx-hub"' },
450
+ );
451
+ }
452
+
453
+ if (path === '/' || path === '/status') {
454
+ const status = router.getStatus('hub').data;
455
+ return writeJson(res, 200, {
456
+ ...status,
457
+ sessions: transports.size,
458
+ pid: process.pid,
459
+ port,
460
+ auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
461
+ idle_timeout_ms: hubIdleTimeoutMs,
462
+ last_request_at: new Date(lastRequestAt).toISOString(),
463
+ pipe_path: pipe.path,
464
+ pipe: pipe.getStatus(),
465
+ assign_callback_pipe_path: assignCallbacks.path,
466
+ assign_callback_pipe: assignCallbacks.getStatus(),
467
+ });
468
+ }
469
+
470
+ if (path === '/health' || path === '/healthz') {
471
+ const status = router.getStatus('hub').data;
472
+ const healthy = status?.hub?.state === 'healthy';
473
+ return writeJson(res, healthy ? 200 : 503, {
474
+ ok: healthy,
475
+ version,
476
+ platform: process.platform,
477
+ uptime_s: Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)),
478
+ node: process.version,
479
+ sessions: transports.size,
480
+ store: store.type || 'sqlite',
481
+ idle_timeout_ms: hubIdleTimeoutMs,
482
+ idle_ms: Math.max(0, Date.now() - lastRequestAt),
483
+ fingerprint: fingerprintService.getHealth(),
484
+ });
485
+ }
486
+
487
+ if (path === '/api/qos-stats' && req.method === 'GET') {
488
+ return writeJson(res, 200, getQosStatsPayload());
489
+ }
490
+
491
+ if (path.startsWith('/bridge')) {
492
+ if (req.method !== 'POST' && req.method !== 'DELETE') {
493
+ return writeJson(res, 405, { ok: false, error: 'Method Not Allowed' });
494
+ }
495
+
496
+ try {
497
+ const body = req.method === 'POST' ? await parseBody(req) : {};
498
+
499
+ if (path === '/bridge/register' && req.method === 'POST') {
500
+ const { agent_id, cli, timeout_sec = 600, topics = [], capabilities = [], metadata = {} } = body;
501
+ if (!agent_id || !cli) {
502
+ return writeJson(res, 400, { ok: false, error: 'agent_id, cli 필수' });
503
+ }
504
+
505
+ const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
506
+ const result = await pipe.executeCommand('register', {
507
+ agent_id,
508
+ cli,
509
+ capabilities,
510
+ topics,
511
+ heartbeat_ttl_ms,
512
+ metadata,
513
+ });
514
+ return writeJson(res, 200, result);
515
+ }
516
+
517
+ if (path === '/bridge/result' && req.method === 'POST') {
518
+ const { agent_id, topic = 'task.result', payload = {}, trace_id, correlation_id } = body;
519
+ if (!agent_id) {
520
+ return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
521
+ }
522
+
523
+ const result = await pipe.executeCommand('result', {
524
+ agent_id,
525
+ topic,
526
+ payload,
527
+ trace_id,
528
+ correlation_id,
529
+ });
530
+ return writeJson(res, 200, result);
531
+ }
532
+
533
+ if (path === '/bridge/control' && req.method === 'POST') {
534
+ const {
535
+ from_agent = 'lead',
536
+ to_agent,
537
+ command,
538
+ reason = '',
539
+ payload = {},
540
+ trace_id,
541
+ correlation_id,
542
+ ttl_ms = 3600000,
543
+ } = body;
544
+
545
+ if (!to_agent || !command) {
546
+ return writeJson(res, 400, { ok: false, error: 'to_agent, command 필수' });
547
+ }
548
+
549
+ const result = await pipe.executeCommand('control', {
550
+ from_agent,
551
+ to_agent,
552
+ command,
553
+ reason,
554
+ payload,
555
+ ttl_ms,
556
+ trace_id,
557
+ correlation_id,
558
+ });
559
+
560
+ return writeJson(res, 200, result);
561
+ }
562
+
563
+ if (path === '/bridge/assign/async' && req.method === 'POST') {
564
+ const {
565
+ supervisor_agent,
566
+ worker_agent,
567
+ task,
568
+ topic = 'assign.job',
569
+ payload = {},
570
+ priority = 5,
571
+ ttl_ms = 600000,
572
+ timeout_ms = 600000,
573
+ max_retries = 0,
574
+ trace_id,
575
+ correlation_id,
576
+ } = body;
577
+
578
+ if (!supervisor_agent || !worker_agent || !task) {
579
+ return writeJson(res, 400, { ok: false, error: 'supervisor_agent, worker_agent, task 필수' });
580
+ }
581
+
582
+ const result = await pipe.executeCommand('assign', {
583
+ supervisor_agent,
584
+ worker_agent,
585
+ task,
586
+ topic,
587
+ payload,
588
+ priority,
589
+ ttl_ms,
590
+ timeout_ms,
591
+ max_retries,
592
+ trace_id,
593
+ correlation_id,
594
+ });
595
+ return writeJson(res, result.ok ? 200 : 400, result);
596
+ }
597
+
598
+ if (path === '/bridge/assign/result' && req.method === 'POST') {
599
+ const {
600
+ job_id,
601
+ worker_agent,
602
+ status,
603
+ attempt,
604
+ result: assignResult,
605
+ error: assignError,
606
+ payload = {},
607
+ metadata = {},
608
+ } = body;
609
+
610
+ if (!job_id || !status) {
611
+ return writeJson(res, 400, { ok: false, error: 'job_id, status 필수' });
612
+ }
613
+
614
+ const result = await pipe.executeCommand('assign_result', {
615
+ job_id,
616
+ worker_agent,
617
+ status,
618
+ attempt,
619
+ result: assignResult,
620
+ error: assignError,
621
+ payload,
622
+ metadata,
623
+ });
624
+ return writeJson(res, result.ok ? 200 : 409, result);
625
+ }
626
+
627
+ if (path === '/bridge/assign/status' && req.method === 'POST') {
628
+ const result = await pipe.executeQuery('assign_status', body);
629
+ const statusCode = result.ok ? 200 : (result.error?.code === 'ASSIGN_NOT_FOUND' ? 404 : 400);
630
+ return writeJson(res, statusCode, result);
631
+ }
632
+
633
+ if (path === '/bridge/assign/retry' && req.method === 'POST') {
634
+ const { job_id, reason, requested_by } = body;
635
+ if (!job_id) {
636
+ return writeJson(res, 400, { ok: false, error: 'job_id 필수' });
637
+ }
638
+
639
+ const result = await pipe.executeCommand('assign_retry', {
640
+ job_id,
641
+ reason,
642
+ requested_by,
643
+ });
644
+ const statusCode = result.ok ? 200
645
+ : result.error?.code === 'ASSIGN_NOT_FOUND' ? 404
646
+ : result.error?.code === 'ASSIGN_RETRY_EXHAUSTED' ? 409
647
+ : 400;
648
+ return writeJson(res, statusCode, result);
649
+ }
650
+
651
+ if (req.method === 'POST') {
652
+ let teamResult = null;
653
+ if (path === '/bridge/team/info' || path === '/bridge/team-info') {
654
+ teamResult = await pipe.executeQuery('team_info', body);
655
+ } else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
656
+ teamResult = await pipe.executeQuery('team_task_list', body);
657
+ } else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
658
+ teamResult = await pipe.executeCommand('team_task_update', body);
659
+ } else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
660
+ teamResult = await pipe.executeCommand('team_send_message', body);
661
+ }
662
+
663
+ if (teamResult) {
664
+ return writeJson(res, resolveTeamStatusCode(teamResult), teamResult);
665
+ }
666
+
667
+ if (path.startsWith('/bridge/team')) {
668
+ return writeJson(res, 404, { ok: false, error: `Unknown team endpoint: ${path}` });
669
+ }
670
+
671
+ // ── 파이프라인 엔드포인트 ──
672
+ if (path === '/bridge/pipeline/state' && req.method === 'POST') {
673
+ const result = await pipe.executeQuery('pipeline_state', body);
674
+ return writeJson(res, resolvePipelineStatusCode(result), result);
675
+ }
676
+
677
+ if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
678
+ const result = await pipe.executeCommand('pipeline_advance', body);
679
+ return writeJson(res, resolvePipelineStatusCode(result), result);
680
+ }
681
+
682
+ if (path === '/bridge/pipeline/init' && req.method === 'POST') {
683
+ const result = await pipe.executeCommand('pipeline_init', body);
684
+ return writeJson(res, resolvePipelineStatusCode(result), result);
685
+ }
686
+
687
+ if (path === '/bridge/pipeline/list' && req.method === 'POST') {
688
+ const result = await pipe.executeQuery('pipeline_list', body);
689
+ return writeJson(res, resolvePipelineStatusCode(result), result);
690
+ }
691
+
692
+ // ── Delegator 엔드포인트 ──
693
+ if (path === '/bridge/delegator/delegate' && req.method === 'POST') {
694
+ const result = await pipe.executeCommand('delegator_delegate', body);
695
+ return writeJson(res, result.ok ? 200 : 400, result);
696
+ }
697
+
698
+ if (path === '/bridge/delegator/reply' && req.method === 'POST') {
699
+ const result = await pipe.executeCommand('delegator_reply', body);
700
+ return writeJson(res, result.ok ? 200 : 400, result);
701
+ }
702
+
703
+ if (path === '/bridge/delegator/status' && req.method === 'POST') {
704
+ const result = await pipe.executeQuery('delegator_status', body);
705
+ return writeJson(res, result.ok ? 200 : 400, result);
706
+ }
707
+ }
708
+
709
+ if (path === '/bridge/context' && req.method === 'POST') {
710
+ const { agent_id, topics, max_messages = 10, auto_ack = true } = body;
711
+ if (!agent_id) {
712
+ return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
713
+ }
714
+
715
+ const result = await pipe.executeQuery('drain', {
716
+ agent_id,
717
+ topics,
718
+ max_messages,
719
+ auto_ack,
720
+ });
721
+ return writeJson(res, 200, result);
722
+ }
723
+
724
+ if (path === '/bridge/deregister' && req.method === 'POST') {
725
+ const { agent_id } = body;
726
+ if (!agent_id) {
727
+ return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
728
+ }
729
+ const result = await pipe.executeCommand('deregister', { agent_id });
730
+ return writeJson(res, 200, result);
731
+ }
732
+
733
+ return writeJson(res, 404, { ok: false, error: 'Unknown bridge endpoint' });
734
+ } catch (error) {
735
+ if (!res.headersSent) {
736
+ console.error('[tfx-hub] bridge error:', error);
737
+ writeJson(res, 500, { ok: false, error: 'Internal server error' });
738
+ }
739
+ return;
740
+ }
741
+ }
742
+
743
+ if (req.method === 'GET' && servePublicFile(res, path)) {
744
+ return;
745
+ }
746
+
747
+ if (path !== '/mcp') {
748
+ res.writeHead(404);
749
+ return res.end('Not Found');
750
+ }
751
+
752
+ try {
753
+ const sessionIdHeader = req.headers['mcp-session-id'];
754
+
755
+ if (req.method === 'POST') {
756
+ const body = await parseBody(req);
757
+
758
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
759
+ const session = transports.get(sessionIdHeader);
760
+ session.transport._lastActivity = Date.now();
761
+ await session.transport.handleRequest(req, res, body);
762
+ } else if (!sessionIdHeader && isInitializeRequest(body)) {
763
+ const transport = new StreamableHTTPServerTransport({
764
+ sessionIdGenerator: () => randomUUID(),
765
+ onsessioninitialized: (sid) => {
766
+ transport._lastActivity = Date.now();
767
+ transports.set(sid, { transport, mcp });
768
+ },
769
+ });
770
+ transport.onclose = () => {
771
+ if (transport.sessionId) {
772
+ const session = transports.get(transport.sessionId);
773
+ if (session) {
774
+ try { session.mcp.close(); } catch {}
775
+ }
776
+ transports.delete(transport.sessionId);
777
+ }
778
+ };
779
+ const mcp = createMcpForSession();
780
+ await mcp.connect(transport);
781
+ await transport.handleRequest(req, res, body);
782
+ } else {
783
+ res.writeHead(400, { 'Content-Type': 'application/json' });
784
+ res.end(JSON.stringify({
785
+ jsonrpc: '2.0',
786
+ error: { code: -32000, message: 'Bad Request: No valid session ID' },
787
+ id: null,
788
+ }));
789
+ }
790
+ } else if (req.method === 'GET') {
791
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
792
+ await transports.get(sessionIdHeader).transport.handleRequest(req, res);
793
+ } else {
794
+ res.writeHead(400);
795
+ res.end('Invalid or missing session ID');
796
+ }
797
+ } else if (req.method === 'DELETE') {
798
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
799
+ await transports.get(sessionIdHeader).transport.handleRequest(req, res);
800
+ } else {
801
+ res.writeHead(400);
802
+ res.end('Invalid or missing session ID');
803
+ }
804
+ } else {
805
+ res.writeHead(405);
806
+ res.end('Method Not Allowed');
807
+ }
808
+ } catch (error) {
809
+ hubLog.error({ err: error }, 'http.error');
810
+ if (!res.headersSent) {
811
+ const code = error.statusCode === 413 ? 413
812
+ : error instanceof SyntaxError ? 400 : 500;
813
+ const message = code === 413 ? 'Body too large'
814
+ : code === 400 ? 'Invalid JSON' : 'Internal server error';
815
+ res.writeHead(code, { 'Content-Type': 'application/json' });
816
+ res.end(JSON.stringify({
817
+ jsonrpc: '2.0',
818
+ error: { code: code === 500 ? -32603 : -32700, message },
819
+ id: null,
820
+ }));
821
+ }
822
+ }
823
+ }));
824
+
825
+ httpServer.requestTimeout = 30000;
826
+ httpServer.headersTimeout = 10000;
827
+
828
+ router.startSweeper();
829
+
830
+ const hitlTimer = setInterval(() => {
831
+ try { hitl.checkTimeouts(); } catch (err) { hubLog.warn({ err }, 'hitl.timeout_check_failed'); }
832
+ }, 10000);
833
+ hitlTimer.unref();
834
+
835
+ // MCP session TTL: sessions idle for SESSION_TTL_MS are closed automatically.
836
+ // Configurable via SESSION_TTL_MS (default 30 minutes). The sweep runs every 60 s.
837
+ const SESSION_TTL_MS = parseInt(process.env.TFX_SESSION_TTL_MS || '', 10) || 30 * 60 * 1000;
838
+ const sessionTimer = setInterval(() => {
839
+ const now = Date.now();
840
+ for (const [sid, session] of transports) {
841
+ if (now - (session.transport._lastActivity || 0) <= SESSION_TTL_MS) continue;
842
+ try { session.mcp.close(); } catch {}
843
+ try { session.transport.close(); } catch {}
844
+ transports.delete(sid);
845
+ }
846
+ }, 60000);
847
+ sessionTimer.unref();
848
+
849
+ // 고아 node.exe 프로세스 + stale spawn 세션 주기적 정리 (5분마다)
850
+ const orphanCleanupTimer = setInterval(() => {
851
+ try {
852
+ const { killed } = cleanupOrphanNodeProcesses();
853
+ if (killed > 0) {
854
+ hubLog.info({ killed }, 'hub.orphan_cleanup');
855
+ }
856
+ } catch {}
857
+
858
+ // stale tfx-spawn-* psmux 세션 정리 (30분 이상 idle)
859
+ try {
860
+ const staleKilled = cleanupStaleSpawnSessions(hubLog);
861
+ if (staleKilled > 0) {
862
+ hubLog.info({ killed: staleKilled }, 'hub.stale_spawn_cleanup');
863
+ }
864
+ } catch {}
865
+ }, 5 * 60 * 1000);
866
+ orphanCleanupTimer.unref();
867
+
868
+ // Evict stale rate-limit buckets once per minute to bound memory usage.
869
+ const rateLimitTimer = setInterval(() => {
870
+ const cutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
871
+ for (const [ip, timestamps] of rateLimitMap) {
872
+ const fresh = timestamps.filter((t) => t >= cutoff);
873
+ if (fresh.length === 0) {
874
+ rateLimitMap.delete(ip);
875
+ } else {
876
+ rateLimitMap.set(ip, fresh);
877
+ }
878
+ }
879
+ }, RATE_LIMIT_WINDOW_MS);
880
+ rateLimitTimer.unref();
881
+
882
+ mkdirSync(PID_DIR, { recursive: true });
883
+
884
+ // Stale PID 파일 정리 — 이전 Hub 프로세스가 비정상 종료된 경우
885
+ if (existsSync(PID_FILE)) {
886
+ try {
887
+ const prevInfo = JSON.parse(readFileSync(PID_FILE, 'utf8'));
888
+ const prevPid = Number(prevInfo?.pid);
889
+ if (Number.isFinite(prevPid) && prevPid > 0) {
890
+ try {
891
+ process.kill(prevPid, 0); // alive 체크만
892
+ // 프로세스가 살아있으면 포트 충돌 가능성 — 기존 Hub 재사용 안내
893
+ if (Number(prevInfo.port) === Number(port)) {
894
+ hubLog.warn({ prevPid, port }, 'hub.stale_pid: previous hub still alive on same port');
895
+ }
896
+ } catch {
897
+ // 프로세스 죽음 → stale PID 파일 삭제
898
+ try { unlinkSync(PID_FILE); } catch {}
899
+ hubLog.info({ prevPid }, 'hub.stale_pid_cleaned');
900
+ }
901
+ } else {
902
+ try { unlinkSync(PID_FILE); } catch {}
903
+ }
904
+ } catch {
905
+ try { unlinkSync(PID_FILE); } catch {}
906
+ }
907
+ }
908
+
909
+ const cleanupStartupFailure = async () => {
910
+ try { router.stopSweeper(); } catch {}
911
+ try { await pipe.stop(); } catch {}
912
+ try { await assignCallbacks.stop(); } catch {}
913
+ try { await delegatorWorker.stop(); } catch {}
914
+ try { store.close(); } catch {}
915
+ try { unlinkSync(TOKEN_FILE); } catch {}
916
+ releaseStartupLock();
917
+ };
918
+
919
+ try {
920
+ await pipe.start();
921
+ await assignCallbacks.start();
922
+ } catch (error) {
923
+ await cleanupStartupFailure();
924
+ throw error;
925
+ }
926
+
927
+ return await new Promise((resolveHub, reject) => {
928
+ httpServer.listen(port, host, () => {
929
+ try {
930
+ let idleTimer = null;
931
+ let stopPromise = null;
932
+
933
+ const info = {
934
+ port,
935
+ host,
936
+ dbPath,
937
+ pid: process.pid,
938
+ hubToken: HUB_TOKEN,
939
+ authMode: HUB_TOKEN ? 'token-required' : 'localhost-only',
940
+ url: `http://${host}:${port}/mcp`,
941
+ pipe_path: pipe.path,
942
+ pipePath: pipe.path,
943
+ assign_callback_pipe_path: assignCallbacks.path,
944
+ assignCallbackPipePath: assignCallbacks.path,
945
+ version,
946
+ storeType: store.type || 'sqlite',
947
+ idleTimeoutMs: hubIdleTimeoutMs,
948
+ };
949
+
950
+ writeFileSync(PID_FILE, JSON.stringify({
951
+ pid: process.pid,
952
+ port,
953
+ host,
954
+ auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
955
+ url: info.url,
956
+ pipe_path: pipe.path,
957
+ pipePath: pipe.path,
958
+ assign_callback_pipe_path: assignCallbacks.path,
959
+ started: startedAtMs,
960
+ version,
961
+ session_id: sessionId,
962
+ }));
963
+ writeState({
964
+ pid: process.pid,
965
+ port,
966
+ version,
967
+ sessionId,
968
+ startedAt,
969
+ });
970
+ releaseStartupLock();
971
+
972
+ hubLog.info({ url: info.url, pipePath: pipe.path, assignCallbackPath: assignCallbacks.path, pid: process.pid, storeType: info.storeType, version }, 'hub.started');
973
+ hubLog.debug({ publicDir: PUBLIC_DIR, exists: existsSync(PUBLIC_DIR), hasDashboard: existsSync(resolve(PUBLIC_DIR, 'dashboard.html')) }, 'hub.public_dir');
974
+
975
+ const stopFn = async () => {
976
+ if (stopPromise) return stopPromise;
977
+
978
+ stopPromise = (async () => {
979
+ router.stopSweeper();
980
+ clearInterval(hitlTimer);
981
+ clearInterval(sessionTimer);
982
+ clearInterval(rateLimitTimer);
983
+ clearInterval(orphanCleanupTimer);
984
+ if (idleTimer) {
985
+ clearInterval(idleTimer);
986
+ }
987
+ for (const [, session] of transports) {
988
+ try { await session.mcp.close(); } catch {}
989
+ try { await session.transport.close(); } catch {}
990
+ }
991
+ transports.clear();
992
+ await pipe.stop();
993
+ await assignCallbacks.stop();
994
+ await delegatorWorker.stop().catch(() => {});
995
+ store.close();
996
+ try { unlinkSync(PID_FILE); } catch {}
997
+ try { unlinkSync(TOKEN_FILE); } catch {}
998
+ httpServer.closeAllConnections();
999
+ await new Promise((resolveClose) => httpServer.close(resolveClose));
1000
+ })().catch((error) => {
1001
+ stopPromise = null;
1002
+ throw error;
1003
+ });
1004
+
1005
+ return stopPromise;
1006
+ };
1007
+
1008
+ idleTimer = setInterval(() => {
1009
+ const idleMs = Date.now() - lastRequestAt;
1010
+ if (idleMs < hubIdleTimeoutMs) return;
1011
+ hubLog.warn({ idleMs, idleTimeoutMs: hubIdleTimeoutMs, port }, 'hub.idle_timeout_shutdown');
1012
+ void stopFn().catch((error) => {
1013
+ hubLog.error({ err: error, idleMs, idleTimeoutMs: hubIdleTimeoutMs, port }, 'hub.idle_timeout_shutdown_failed');
1014
+ });
1015
+ }, hubIdleSweepMs);
1016
+ idleTimer.unref();
1017
+
1018
+ resolveHub({
1019
+ ...info,
1020
+ httpServer,
1021
+ store,
1022
+ router,
1023
+ hitl,
1024
+ pipe,
1025
+ assignCallbacks,
1026
+ delegatorService,
1027
+ delegatorWorker,
1028
+ stop: stopFn,
1029
+ });
1030
+ } catch (error) {
1031
+ void cleanupStartupFailure().finally(() => reject(error));
1032
+ }
1033
+ });
1034
+ httpServer.on('error', (err) => {
1035
+ void cleanupStartupFailure();
1036
+ if (err.code === 'EADDRINUSE') {
1037
+ hubLog.error({ port, host }, 'hub.port_in_use: port already occupied — check for existing hub or other service');
1038
+ reject(new Error(`Hub 포트 ${port}이(가) 이미 사용 중입니다. 기존 Hub 프로세스를 확인하세요. (PID file: ${PID_FILE})`));
1039
+ } else {
1040
+ reject(err);
1041
+ }
1042
+ });
1043
+ });
1044
+ }
1045
+
1046
+ export function getHubInfo() {
1047
+ if (!existsSync(PID_FILE)) return null;
1048
+ try {
1049
+ return JSON.parse(readFileSync(PID_FILE, 'utf8'));
1050
+ } catch {
1051
+ return null;
1052
+ }
1053
+ }
1054
+
1055
+ /**
1056
+ * stale tfx-spawn-* psmux 세션을 감지하고 정리한다.
1057
+ * 30분 이상 경과 + pane이 idle 쉘 프롬프트만 표시 → kill.
1058
+ * @param {object} [log] logger (optional)
1059
+ * @returns {number} killed session count
1060
+ */
1061
+ function cleanupStaleSpawnSessions(log) {
1062
+ const MAX_AGE_MS = 30 * 60 * 1000;
1063
+ const IDLE_PROMPT_RE = /^(PS\s|[$%>#]\s*$|\w+@[\w.-]+[:\s]|╰─|╭─|[fb]wd-i-search:|client_loop:\s|Connection\s+(reset|closed))/;
1064
+ const execOpts = { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true };
1065
+
1066
+ let killed = 0;
1067
+ let raw;
1068
+ try {
1069
+ raw = execSyncHub("psmux list-sessions", execOpts);
1070
+ } catch {
1071
+ return 0; // psmux 없거나 실패
1072
+ }
1073
+
1074
+ const now = Date.now();
1075
+ for (const line of raw.split(/\r?\n/)) {
1076
+ const match = line.match(/^(tfx-spawn-[^:]+):\s+\d+\s+windows?\s+\(created\s+(.+)\)/);
1077
+ if (!match) continue;
1078
+
1079
+ const [, sessionName, createdStr] = match;
1080
+ if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) continue; // shell injection 방지
1081
+ const created = new Date(createdStr).getTime();
1082
+ if (!Number.isFinite(created) || now - created < MAX_AGE_MS) continue;
1083
+
1084
+ // pane 내용 확인 — 마지막 3줄 중 idle 쉘 프롬프트가 있는지
1085
+ try {
1086
+ const pane = execSyncHub(`psmux capture-pane -t "${sessionName}:0.0" -p`, execOpts);
1087
+ const tailLines = pane.split(/\r?\n/).filter((l) => l.trim()).slice(-3);
1088
+ const hasIdleLine = tailLines.some((l) => IDLE_PROMPT_RE.test(l.trim()));
1089
+ if (!hasIdleLine) continue; // 아직 활성 — 건드리지 않음
1090
+ } catch {
1091
+ continue; // pane 접근 실패 — 건드리지 않음
1092
+ }
1093
+
1094
+ // stale + idle → 정리
1095
+ try {
1096
+ execSyncHub(`psmux kill-session -t "${sessionName}"`, execOpts);
1097
+ killed++;
1098
+ if (log) log.info({ session: sessionName, ageMin: Math.round((now - created) / 60000) }, "hub.stale_spawn_killed");
1099
+ } catch {}
1100
+ }
1101
+
1102
+ return killed;
1103
+ }
1104
+
1105
+ const selfRun = process.argv[1]?.replace(/\\/g, '/').endsWith('hub/server.mjs');
1106
+ if (selfRun) {
1107
+ const port = parseInt(process.env.TFX_HUB_PORT || '27888', 10);
1108
+ const dbPath = process.env.TFX_HUB_DB || undefined;
1109
+
1110
+ startHub({ port, dbPath }).then((info) => {
1111
+ const shutdown = async (signal) => {
1112
+ hubLog.info({ signal }, 'hub.stopping');
1113
+ try { cleanupOrphanNodeProcesses(); } catch {}
1114
+ try { cleanupStaleSpawnSessions(hubLog); } catch {}
1115
+ await info.stop();
1116
+ process.exit(0);
1117
+ };
1118
+ process.on('SIGINT', () => shutdown('SIGINT'));
1119
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1120
+ }).catch((error) => {
1121
+ hubLog.fatal({ err: error }, 'hub.start_failed');
1122
+ process.exit(1);
1123
+ });
1124
+ }