triflux 10.2.0 → 10.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hub/server.mjs CHANGED
@@ -1,51 +1,75 @@
1
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, isServerHealthy, readState, releaseLock, writeState } from './state.mjs';
26
- import { createAdaptiveFingerprintService } from './session-fingerprint.mjs';
27
- import { createAdaptiveEngine } from './adaptive.mjs';
28
- import { registerTeamBridge } from './team-bridge.mjs';
29
- import { nativeProxy } from './team/nativeProxy.mjs';
2
+
3
+ import { execSync as execSyncHub } from "node:child_process";
4
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { createServer as createHttpServer } from "node:http";
13
+ import { homedir } from "node:os";
14
+ import { extname, join, resolve, sep } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
19
+ import {
20
+ CallToolRequestSchema,
21
+ ListToolsRequestSchema,
22
+ } from "@modelcontextprotocol/sdk/types.js";
23
+ import { createModuleLogger } from "../scripts/lib/logger.mjs";
24
+ import { createAdaptiveEngine } from "./adaptive.mjs";
25
+ import { createAssignCallbackServer } from "./assign-callbacks.mjs";
26
+ import { DelegatorService } from "./delegator/index.mjs";
27
+ import { createHitlManager } from "./hitl.mjs";
28
+ import { cleanupOrphanNodeProcesses } from "./lib/process-utils.mjs";
29
+ import { wrapRequestHandler } from "./middleware/request-logger.mjs";
30
+ import { createPipeServer } from "./pipe.mjs";
31
+ import { createRouter } from "./router.mjs";
32
+ import { createAdaptiveFingerprintService } from "./session-fingerprint.mjs";
33
+ import {
34
+ acquireLock,
35
+ getVersionHash,
36
+ isServerHealthy,
37
+ readState,
38
+ releaseLock,
39
+ writeState,
40
+ } from "./state.mjs";
41
+ import { createStoreAdapter } from "./store-adapter.mjs";
42
+ import { nativeProxy } from "./team/nativeProxy.mjs";
43
+ import { registerTeamBridge } from "./team-bridge.mjs";
44
+ import { createTools } from "./tools.mjs";
45
+ import { createDelegatorMcpWorker } from "./workers/delegator-mcp.mjs";
30
46
 
31
47
  registerTeamBridge(nativeProxy);
32
48
 
33
- const hubLog = createModuleLogger('hub');
49
+ const hubLog = createModuleLogger("hub");
34
50
 
35
51
  const MAX_BODY_SIZE = 1024 * 1024;
36
- const PUBLIC_PATHS = new Set(['/', '/status', '/health', '/healthz']);
37
- const RATE_LIMIT_MAX = 100; // requests per window
52
+ const PUBLIC_PATHS = new Set(["/", "/status", "/health", "/healthz"]);
53
+ const RATE_LIMIT_MAX = 100; // requests per window
38
54
  const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute sliding window
39
- const LOOPBACK_REMOTE_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
40
- const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
41
- const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url));
42
- const PUBLIC_DIR = resolve(join(PROJECT_ROOT, 'hub', 'public'));
43
- const CACHE_DIR = join(homedir(), '.claude', 'cache');
44
- const BATCH_EVENTS_PATH = join(CACHE_DIR, 'batch-events.jsonl');
45
- const SV_ACCUMULATOR_PATH = join(CACHE_DIR, 'sv-accumulator.json');
46
- const CODEX_RATE_LIMITS_CACHE_PATH = join(CACHE_DIR, 'codex-rate-limits-cache.json');
47
- const GEMINI_QUOTA_CACHE_PATH = join(CACHE_DIR, 'gemini-quota-cache.json');
48
- const CLAUDE_USAGE_CACHE_PATH = join(CACHE_DIR, 'claude-usage-cache.json');
55
+ const LOOPBACK_REMOTE_ADDRESSES = new Set([
56
+ "127.0.0.1",
57
+ "::1",
58
+ "::ffff:127.0.0.1",
59
+ ]);
60
+ const ALLOWED_ORIGIN_RE =
61
+ /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
62
+ const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
63
+ const PUBLIC_DIR = resolve(join(PROJECT_ROOT, "hub", "public"));
64
+ const CACHE_DIR = join(homedir(), ".claude", "cache");
65
+ const BATCH_EVENTS_PATH = join(CACHE_DIR, "batch-events.jsonl");
66
+ const SV_ACCUMULATOR_PATH = join(CACHE_DIR, "sv-accumulator.json");
67
+ const CODEX_RATE_LIMITS_CACHE_PATH = join(
68
+ CACHE_DIR,
69
+ "codex-rate-limits-cache.json",
70
+ );
71
+ const GEMINI_QUOTA_CACHE_PATH = join(CACHE_DIR, "gemini-quota-cache.json");
72
+ const CLAUDE_USAGE_CACHE_PATH = join(CACHE_DIR, "claude-usage-cache.json");
49
73
  const AIMD_WINDOW_MS = 30 * 60 * 1000;
50
74
  const AIMD_INITIAL_BATCH_SIZE = 3;
51
75
  const AIMD_MIN_BATCH_SIZE = 1;
@@ -53,22 +77,22 @@ const AIMD_MAX_BATCH_SIZE = 10;
53
77
  const HUB_IDLE_TIMEOUT_DEFAULT_MS = 10 * 60 * 1000;
54
78
  const HUB_IDLE_SWEEP_DEFAULT_MS = 60 * 1000;
55
79
  const STATIC_CONTENT_TYPES = Object.freeze({
56
- '.html': 'text/html',
57
- '.css': 'text/css',
58
- '.js': 'application/javascript',
59
- '.png': 'image/png',
80
+ ".html": "text/html",
81
+ ".css": "text/css",
82
+ ".js": "application/javascript",
83
+ ".png": "image/png",
60
84
  });
61
85
 
62
86
  // IP-based sliding window rate limiter (in-memory, no external deps)
63
87
  // Each entry is an array of request timestamps within the current window.
64
88
  const rateLimitMap = new Map();
65
89
 
66
- function formatHostForUrl(host = '127.0.0.1') {
67
- return String(host).includes(':') ? `[${host}]` : host;
90
+ function formatHostForUrl(host = "127.0.0.1") {
91
+ return String(host).includes(":") ? `[${host}]` : host;
68
92
  }
69
93
 
70
94
  function buildHubUrl(host, port) {
71
- return `http://${formatHostForUrl(host || '127.0.0.1')}:${port}/mcp`;
95
+ return `http://${formatHostForUrl(host || "127.0.0.1")}:${port}/mcp`;
72
96
  }
73
97
 
74
98
  function isPidAlive(pid, killFn = process.kill) {
@@ -84,7 +108,7 @@ function isPidAlive(pid, killFn = process.kill) {
84
108
 
85
109
  async function tryReuseExistingHub({
86
110
  port,
87
- host = '127.0.0.1',
111
+ host = "127.0.0.1",
88
112
  readCurrentState = readState,
89
113
  readInfo = getHubInfo,
90
114
  checkHealth = isServerHealthy,
@@ -92,14 +116,19 @@ async function tryReuseExistingHub({
92
116
  } = {}) {
93
117
  const existing = readCurrentState();
94
118
  const existingPort = Number(existing?.port);
95
- if (!isPidAlive(existing?.pid, killFn) || !Number.isFinite(existingPort) || existingPort <= 0) {
119
+ if (
120
+ !isPidAlive(existing?.pid, killFn) ||
121
+ !Number.isFinite(existingPort) ||
122
+ existingPort <= 0
123
+ ) {
96
124
  return null;
97
125
  }
98
- if (Number.isFinite(Number(port)) && existingPort !== Number(port)) return null;
126
+ if (Number.isFinite(Number(port)) && existingPort !== Number(port))
127
+ return null;
99
128
  if (!(await checkHealth(existingPort))) return null;
100
129
 
101
130
  const info = readInfo() ?? existing;
102
- const infoHost = typeof info?.host === 'string' ? info.host : host;
131
+ const infoHost = typeof info?.host === "string" ? info.host : host;
103
132
  return {
104
133
  reused: true,
105
134
  external: true,
@@ -125,8 +154,9 @@ function checkRateLimit(ip) {
125
154
  }
126
155
 
127
156
  function isInitializeRequest(body) {
128
- if (body?.method === 'initialize') return true;
129
- if (Array.isArray(body)) return body.some((message) => message.method === 'initialize');
157
+ if (body?.method === "initialize") return true;
158
+ if (Array.isArray(body))
159
+ return body.some((message) => message.method === "initialize");
130
160
  return false;
131
161
  }
132
162
 
@@ -136,69 +166,81 @@ async function parseBody(req) {
136
166
  for await (const chunk of req) {
137
167
  size += chunk.length;
138
168
  if (size > MAX_BODY_SIZE) {
139
- throw Object.assign(new Error('Body too large'), { statusCode: 413 });
169
+ throw Object.assign(new Error("Body too large"), { statusCode: 413 });
140
170
  }
141
171
  chunks.push(chunk);
142
172
  }
143
173
  return JSON.parse(Buffer.concat(chunks).toString());
144
174
  }
145
175
 
146
- const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
147
- const PID_FILE = join(PID_DIR, 'hub.pid');
148
- const TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
176
+ const PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
177
+ const PID_FILE = join(PID_DIR, "hub.pid");
178
+ const TOKEN_FILE = join(homedir(), ".claude", ".tfx-hub-token");
149
179
 
150
180
  function isPublicPath(path) {
151
- return PUBLIC_PATHS.has(path)
152
- || path === '/dashboard'
153
- || path === '/api/qos-stats'
154
- || path.startsWith('/public/');
181
+ return (
182
+ PUBLIC_PATHS.has(path) ||
183
+ path === "/dashboard" ||
184
+ path === "/api/qos-stats" ||
185
+ path.startsWith("/public/")
186
+ );
155
187
  }
156
188
 
157
189
  function isAllowedOrigin(origin) {
158
190
  return origin && ALLOWED_ORIGIN_RE.test(origin);
159
191
  }
160
192
 
161
- function getRequestPath(url = '/') {
193
+ function getRequestPath(url = "/") {
162
194
  try {
163
- return new URL(url, 'http://127.0.0.1').pathname;
195
+ return new URL(url, "http://127.0.0.1").pathname;
164
196
  } catch {
165
- return String(url).replace(/\?.*/, '') || '/';
197
+ return String(url).replace(/\?.*/, "") || "/";
166
198
  }
167
199
  }
168
200
 
169
201
  function isLoopbackRemoteAddress(remoteAddress) {
170
- return typeof remoteAddress === 'string' && LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress);
202
+ return (
203
+ typeof remoteAddress === "string" &&
204
+ LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress)
205
+ );
171
206
  }
172
207
 
173
208
  function extractBearerToken(req) {
174
- const authHeader = typeof req.headers.authorization === 'string' ? req.headers.authorization : '';
175
- return authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
209
+ const authHeader =
210
+ typeof req.headers.authorization === "string"
211
+ ? req.headers.authorization
212
+ : "";
213
+ return authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
176
214
  }
177
215
 
178
216
  function writeJson(res, statusCode, body, headers = {}) {
179
217
  res.writeHead(statusCode, {
180
- 'Content-Type': 'application/json',
218
+ "Content-Type": "application/json",
181
219
  ...headers,
182
220
  });
183
221
  res.end(JSON.stringify(body));
184
222
  }
185
223
 
186
224
  function applyCorsHeaders(req, res) {
187
- const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
225
+ const origin =
226
+ typeof req.headers.origin === "string" ? req.headers.origin : "";
188
227
  if (origin) {
189
- res.setHeader('Vary', 'Origin');
228
+ res.setHeader("Vary", "Origin");
190
229
  }
191
230
  if (!isAllowedOrigin(origin)) return false;
192
231
 
193
- res.setHeader('Access-Control-Allow-Origin', origin);
194
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
195
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
232
+ res.setHeader("Access-Control-Allow-Origin", origin);
233
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
234
+ res.setHeader(
235
+ "Access-Control-Allow-Headers",
236
+ "Content-Type, Authorization, mcp-session-id, Last-Event-ID",
237
+ );
196
238
  return true;
197
239
  }
198
240
 
199
241
  function safeTokenCompare(a, b) {
200
- const ha = createHash('sha256').update(a).digest();
201
- const hb = createHash('sha256').update(b).digest();
242
+ const ha = createHash("sha256").update(a).digest();
243
+ const hb = createHash("sha256").update(b).digest();
202
244
  return timingSafeEqual(ha, hb);
203
245
  }
204
246
 
@@ -215,30 +257,83 @@ function isAuthorizedRequest(req, path, hubToken) {
215
257
  function resolveTeamStatusCode(result) {
216
258
  if (result?.ok) return 200;
217
259
  const code = result?.error?.code;
218
- if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') return 404;
219
- if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') return 409;
220
- if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') return 400;
260
+ if (
261
+ code === "TEAM_NOT_FOUND" ||
262
+ code === "TASK_NOT_FOUND" ||
263
+ code === "TASKS_DIR_NOT_FOUND"
264
+ )
265
+ return 404;
266
+ if (code === "CLAIM_CONFLICT" || code === "MTIME_CONFLICT") return 409;
267
+ if (
268
+ code === "INVALID_TEAM_NAME" ||
269
+ code === "INVALID_TASK_ID" ||
270
+ code === "INVALID_TEXT" ||
271
+ code === "INVALID_FROM" ||
272
+ code === "INVALID_STATUS"
273
+ )
274
+ return 400;
221
275
  return 500;
222
276
  }
223
277
 
224
278
  function resolvePipelineStatusCode(result) {
225
279
  if (result?.ok) return 200;
226
- if (result?.error === 'pipeline_not_found') return 404;
227
- if (result?.error === 'hub_db_not_found') return 503;
280
+ if (result?.error === "pipeline_not_found") return 404;
281
+ if (result?.error === "hub_db_not_found") return 503;
228
282
  return 400;
229
283
  }
230
284
 
285
+ function resolveSendInputStatusCode(result) {
286
+ if (result?.ok) return 200;
287
+ const code = result?.error?.code;
288
+ if (code === "CONDUCTOR_REGISTRY_NOT_AVAILABLE") return 503;
289
+ if (code === "CONDUCTOR_SESSION_NOT_FOUND") return 404;
290
+ if (code === "SEND_INPUT_FAILED") return 409;
291
+ return 400;
292
+ }
293
+
294
+ function normalizeBridgePayload(payload) {
295
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
296
+ return {};
297
+ }
298
+ return payload;
299
+ }
300
+
301
+ function normalizeHandoffBody(body) {
302
+ const payload = normalizeBridgePayload(body?.payload);
303
+ return {
304
+ ...payload,
305
+ ...body,
306
+ from: body?.from,
307
+ to: body?.to,
308
+ payload,
309
+ };
310
+ }
311
+
312
+ function normalizePublishBody(body) {
313
+ const payload = normalizeBridgePayload(body?.payload);
314
+ const type = body?.type || body?.message_type || "event";
315
+ return {
316
+ ...payload,
317
+ ...body,
318
+ from: body?.from,
319
+ to: body?.to,
320
+ type,
321
+ message_type: type,
322
+ payload,
323
+ };
324
+ }
325
+
231
326
  function safeReadJsonFile(filePath) {
232
327
  try {
233
328
  if (!existsSync(filePath)) return null;
234
- return JSON.parse(readFileSync(filePath, 'utf8'));
329
+ return JSON.parse(readFileSync(filePath, "utf8"));
235
330
  } catch {
236
331
  return null;
237
332
  }
238
333
  }
239
334
 
240
335
  function parsePositiveInt(value, fallback) {
241
- const parsed = Number.parseInt(String(value ?? ''), 10);
336
+ const parsed = Number.parseInt(String(value ?? ""), 10);
242
337
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
243
338
  }
244
339
 
@@ -246,7 +341,7 @@ function readRecentAimdEvents(now = Date.now()) {
246
341
  try {
247
342
  if (!existsSync(BATCH_EVENTS_PATH)) return [];
248
343
  const cutoff = now - AIMD_WINDOW_MS;
249
- return readFileSync(BATCH_EVENTS_PATH, 'utf8')
344
+ return readFileSync(BATCH_EVENTS_PATH, "utf8")
250
345
  .split(/\r?\n/)
251
346
  .filter(Boolean)
252
347
  .map((line) => {
@@ -270,9 +365,9 @@ function calculateAimdBatchSize(events) {
270
365
 
271
366
  for (const event of events) {
272
367
  const result = event?.result;
273
- if (result === 'success' || result === 'success_with_warnings') {
368
+ if (result === "success" || result === "success_with_warnings") {
274
369
  batchSize = Math.min(AIMD_MAX_BATCH_SIZE, batchSize + 1);
275
- } else if (result === 'failed' || result === 'timeout') {
370
+ } else if (result === "failed" || result === "timeout") {
276
371
  batchSize = Math.max(AIMD_MIN_BATCH_SIZE, batchSize * 0.5);
277
372
  }
278
373
  }
@@ -296,16 +391,16 @@ function getQosStatsPayload() {
296
391
 
297
392
  function resolvePublicFilePath(path) {
298
393
  let relativePath = null;
299
- if (path === '/dashboard') {
300
- relativePath = 'dashboard.html';
301
- } else if (path.startsWith('/public/')) {
302
- relativePath = path.slice('/public/'.length);
394
+ if (path === "/dashboard") {
395
+ relativePath = "dashboard.html";
396
+ } else if (path.startsWith("/public/")) {
397
+ relativePath = path.slice("/public/".length);
303
398
  }
304
399
 
305
400
  if (!relativePath) return null;
306
401
 
307
402
  try {
308
- relativePath = decodeURIComponent(relativePath).replace(/^[/\\]+/, '');
403
+ relativePath = decodeURIComponent(relativePath).replace(/^[/\\]+/, "");
309
404
  } catch {
310
405
  return null;
311
406
  }
@@ -324,21 +419,23 @@ function servePublicFile(res, path) {
324
419
 
325
420
  mkdirSync(PUBLIC_DIR, { recursive: true });
326
421
  if (!existsSync(filePath)) {
327
- hubLog.warn({ filePath }, 'static.not_found');
422
+ hubLog.warn({ filePath }, "static.not_found");
328
423
  res.writeHead(404);
329
- res.end('Not Found (static file missing)');
424
+ res.end("Not Found (static file missing)");
330
425
  return true;
331
426
  }
332
427
 
333
428
  try {
334
429
  const body = readFileSync(filePath);
335
430
  res.writeHead(200, {
336
- 'Content-Type': STATIC_CONTENT_TYPES[extname(filePath).toLowerCase()] || 'application/octet-stream',
431
+ "Content-Type":
432
+ STATIC_CONTENT_TYPES[extname(filePath).toLowerCase()] ||
433
+ "application/octet-stream",
337
434
  });
338
435
  res.end(body);
339
436
  } catch {
340
437
  res.writeHead(404);
341
- res.end('Not Found');
438
+ res.end("Not Found");
342
439
  }
343
440
  return true;
344
441
  }
@@ -355,16 +452,19 @@ function servePublicFile(res, path) {
355
452
  export async function startHub({
356
453
  port: portOpt,
357
454
  dbPath,
358
- host = '127.0.0.1',
455
+ host = "127.0.0.1",
359
456
  sessionId = process.pid,
360
457
  createDelegatorWorker = createDelegatorMcpWorker,
361
458
  } = {}) {
362
- const port = portOpt ?? parseInt(process.env.TFX_HUB_PORT || '27888', 10);
459
+ const port = portOpt ?? parseInt(process.env.TFX_HUB_PORT || "27888", 10);
363
460
 
364
461
  const existingHub = await tryReuseExistingHub({ port, host });
365
462
  if (existingHub) return existingHub;
366
463
 
367
- const hubIdleTimeoutMs = parsePositiveInt(process.env.TFX_HUB_IDLE_TIMEOUT_MS, HUB_IDLE_TIMEOUT_DEFAULT_MS);
464
+ const hubIdleTimeoutMs = parsePositiveInt(
465
+ process.env.TFX_HUB_IDLE_TIMEOUT_MS,
466
+ HUB_IDLE_TIMEOUT_DEFAULT_MS,
467
+ );
368
468
  const hubIdleSweepMs = parsePositiveInt(
369
469
  process.env.TFX_HUB_IDLE_SWEEP_MS,
370
470
  Math.min(HUB_IDLE_SWEEP_DEFAULT_MS, hubIdleTimeoutMs),
@@ -378,9 +478,9 @@ export async function startHub({
378
478
  // DB를 npm 패키지 밖에 저장하여 npm update 시 EBUSY 방지
379
479
  // 기존: PROJECT_ROOT/.tfx/state/state.db (패키지 내부 → 락 충돌)
380
480
  // 변경: ~/.claude/cache/tfx-hub/state.db (패키지 외부 → 안전)
381
- const hubCacheDir = join(homedir(), '.claude', 'cache', 'tfx-hub');
481
+ const hubCacheDir = join(homedir(), ".claude", "cache", "tfx-hub");
382
482
  mkdirSync(hubCacheDir, { recursive: true });
383
- dbPath = join(hubCacheDir, 'state.db');
483
+ dbPath = join(hubCacheDir, "state.db");
384
484
  }
385
485
 
386
486
  mkdirSync(PUBLIC_DIR, { recursive: true });
@@ -404,10 +504,12 @@ export async function startHub({
404
504
 
405
505
  const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
406
506
  if (HUB_TOKEN) {
407
- mkdirSync(join(homedir(), '.claude'), { recursive: true });
507
+ mkdirSync(join(homedir(), ".claude"), { recursive: true });
408
508
  writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
409
509
  } else {
410
- try { unlinkSync(TOKEN_FILE); } catch {}
510
+ try {
511
+ unlinkSync(TOKEN_FILE);
512
+ } catch {}
411
513
  }
412
514
 
413
515
  const store = await createStoreAdapter(dbPath);
@@ -415,7 +517,10 @@ export async function startHub({
415
517
  const fingerprintService = createAdaptiveFingerprintService({ store });
416
518
 
417
519
  // Neural Memory adaptive engine 초기화
418
- const adaptiveEngine = createAdaptiveEngine({ repoRoot: PROJECT_ROOT, fingerprintService });
520
+ const adaptiveEngine = createAdaptiveEngine({
521
+ repoRoot: PROJECT_ROOT,
522
+ fingerprintService,
523
+ });
419
524
  adaptiveEngine.startSession();
420
525
 
421
526
  // safety-guard → reflexion 패널티 승격 + 적응형 규칙 유지보수
@@ -424,22 +529,38 @@ export async function startHub({
424
529
  const { promotePenalties } = await import("./promote-penalties.mjs");
425
530
  const result = promotePenalties(store, { projectSlug });
426
531
  if (result.promoted > 0) {
427
- console.log(`[reflexion] ${result.promoted} penalties promoted to adaptive rules`);
532
+ console.log(
533
+ `[reflexion] ${result.promoted} penalties promoted to adaptive rules`,
534
+ );
428
535
  }
429
- } catch { /* promote-penalties 실패는 Hub 시작을 막지 않음 */ }
536
+ } catch {
537
+ /* promote-penalties 실패는 Hub 시작을 막지 않음 */
538
+ }
430
539
 
431
540
  // stale adaptive_rules 정리 (30일 초과 + confidence 0.2 미만)
432
541
  try {
433
542
  const pruned = store.pruneStaleRules();
434
- if (pruned > 0) console.log(`[reflexion] ${pruned} stale adaptive rules pruned`);
435
- } catch { /* prune 실패 무시 */ }
543
+ if (pruned > 0)
544
+ console.log(`[reflexion] ${pruned} stale adaptive rules pruned`);
545
+ } catch {
546
+ /* prune 실패 무시 */
547
+ }
436
548
 
437
549
  // adaptive rule confidence decay (7일 이상 미관측 규칙 -0.1 감소)
438
550
  try {
439
551
  const { decayRules } = await import("./reflexion.mjs");
440
- const decay = decayRules(store, adaptiveEngine.sessionCount?.() || 1, projectSlug);
441
- if (decay.deleted.length > 0) console.log(`[reflexion] ${decay.deleted.length} low-confidence rules removed`);
442
- } catch { /* decay 실패 무시 */ }
552
+ const decay = decayRules(
553
+ store,
554
+ adaptiveEngine.sessionCount?.() || 1,
555
+ projectSlug,
556
+ );
557
+ if (decay.deleted.length > 0)
558
+ console.log(
559
+ `[reflexion] ${decay.deleted.length} low-confidence rules removed`,
560
+ );
561
+ } catch {
562
+ /* decay 실패 무시 */
563
+ }
443
564
 
444
565
  // Delegator MCP resident service 초기화
445
566
  const delegatorWorker = createDelegatorWorker({ cwd: PROJECT_ROOT });
@@ -451,518 +572,687 @@ export async function startHub({
451
572
  }
452
573
  const delegatorService = new DelegatorService({ worker: delegatorWorker });
453
574
 
454
- const pipe = createPipeServer({ router, store, sessionId, delegatorService });
455
- const assignCallbacks = createAssignCallbackServer({ store, sessionId });
456
575
  const hitl = createHitlManager(store, router);
576
+ const pipe = createPipeServer({
577
+ router,
578
+ store,
579
+ sessionId,
580
+ delegatorService,
581
+ hitlManager: hitl,
582
+ });
583
+ const assignCallbacks = createAssignCallbackServer({ store, sessionId });
457
584
  const tools = createTools(store, router, hitl, pipe);
458
585
  const transports = new Map();
459
586
 
460
587
  function createMcpForSession() {
461
588
  const mcp = new Server(
462
- { name: 'tfx-hub', version: '1.0.0' },
589
+ { name: "tfx-hub", version: "1.0.0" },
463
590
  { capabilities: { tools: {} } },
464
591
  );
465
592
 
466
- mcp.setRequestHandler(
467
- ListToolsRequestSchema,
468
- async () => ({
469
- tools: tools.map((tool) => ({
470
- name: tool.name,
471
- description: tool.description,
472
- inputSchema: tool.inputSchema,
473
- })),
474
- }),
475
- );
476
-
477
- mcp.setRequestHandler(
478
- CallToolRequestSchema,
479
- async (request) => {
480
- const { name, arguments: args } = request.params;
481
- const tool = tools.find((candidate) => candidate.name === name);
482
- if (!tool) {
483
- return {
484
- content: [{ type: 'text', text: JSON.stringify({ ok: false, error: { code: 'UNKNOWN_TOOL', message: `도구 없음: ${name}` } }) }],
485
- isError: true,
486
- };
487
- }
488
- return tool.handler(args || {});
489
- },
490
- );
593
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
594
+ tools: tools.map((tool) => ({
595
+ name: tool.name,
596
+ description: tool.description,
597
+ inputSchema: tool.inputSchema,
598
+ })),
599
+ }));
600
+
601
+ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
602
+ const { name, arguments: args } = request.params;
603
+ const tool = tools.find((candidate) => candidate.name === name);
604
+ if (!tool) {
605
+ return {
606
+ content: [
607
+ {
608
+ type: "text",
609
+ text: JSON.stringify({
610
+ ok: false,
611
+ error: { code: "UNKNOWN_TOOL", message: `도구 없음: ${name}` },
612
+ }),
613
+ },
614
+ ],
615
+ isError: true,
616
+ };
617
+ }
618
+ return tool.handler(args || {});
619
+ });
491
620
 
492
621
  return mcp;
493
622
  }
494
623
 
495
- const httpServer = createHttpServer(wrapRequestHandler(async (req, res) => {
496
- markRequestActivity();
497
- res.setHeader('X-Content-Type-Options', 'nosniff');
498
- res.setHeader('X-Frame-Options', 'DENY');
499
- const path = getRequestPath(req.url);
500
- const corsAllowed = applyCorsHeaders(req, res);
501
-
502
- if (req.method === 'OPTIONS') {
503
- const localOnlyMode = !HUB_TOKEN;
504
- const isLoopbackRequest = isLoopbackRemoteAddress(req.socket.remoteAddress);
505
- res.writeHead(corsAllowed && (!localOnlyMode || isLoopbackRequest) ? 204 : 403);
506
- return res.end();
507
- }
624
+ const httpServer = createHttpServer(
625
+ wrapRequestHandler(async (req, res) => {
626
+ markRequestActivity();
627
+ res.setHeader("X-Content-Type-Options", "nosniff");
628
+ res.setHeader("X-Frame-Options", "DENY");
629
+ const path = getRequestPath(req.url);
630
+ const corsAllowed = applyCorsHeaders(req, res);
631
+
632
+ if (req.method === "OPTIONS") {
633
+ const localOnlyMode = !HUB_TOKEN;
634
+ const isLoopbackRequest = isLoopbackRemoteAddress(
635
+ req.socket.remoteAddress,
636
+ );
637
+ res.writeHead(
638
+ corsAllowed && (!localOnlyMode || isLoopbackRequest) ? 204 : 403,
639
+ );
640
+ return res.end();
641
+ }
642
+
643
+ const clientIp = req.socket.remoteAddress || "unknown";
644
+ if (!isLoopbackRemoteAddress(clientIp)) {
645
+ const rateCheck = checkRateLimit(clientIp);
646
+ if (!rateCheck.allowed) {
647
+ return writeJson(
648
+ res,
649
+ 429,
650
+ { ok: false, error: "Too Many Requests" },
651
+ { "Retry-After": String(rateCheck.retryAfterSec) },
652
+ );
653
+ }
654
+ }
508
655
 
509
- const clientIp = req.socket.remoteAddress || 'unknown';
510
- if (!isLoopbackRemoteAddress(clientIp)) {
511
- const rateCheck = checkRateLimit(clientIp);
512
- if (!rateCheck.allowed) {
656
+ if (!isAuthorizedRequest(req, path, HUB_TOKEN)) {
657
+ if (!HUB_TOKEN) {
658
+ return writeJson(res, 403, {
659
+ ok: false,
660
+ error: "Forbidden: localhost only",
661
+ });
662
+ }
513
663
  return writeJson(
514
664
  res,
515
- 429,
516
- { ok: false, error: 'Too Many Requests' },
517
- { 'Retry-After': String(rateCheck.retryAfterSec) },
665
+ 401,
666
+ { ok: false, error: "Unauthorized" },
667
+ { "WWW-Authenticate": 'Bearer realm="tfx-hub"' },
518
668
  );
519
669
  }
520
- }
521
670
 
522
- if (!isAuthorizedRequest(req, path, HUB_TOKEN)) {
523
- if (!HUB_TOKEN) {
524
- return writeJson(res, 403, { ok: false, error: 'Forbidden: localhost only' });
671
+ if (path === "/" || path === "/status") {
672
+ const status = router.getStatus("hub").data;
673
+ return writeJson(res, 200, {
674
+ ...status,
675
+ sessions: transports.size,
676
+ pid: process.pid,
677
+ port,
678
+ auth_mode: HUB_TOKEN ? "token-required" : "localhost-only",
679
+ idle_timeout_ms: hubIdleTimeoutMs,
680
+ last_request_at: new Date(lastRequestAt).toISOString(),
681
+ pipe_path: pipe.path,
682
+ pipe: pipe.getStatus(),
683
+ assign_callback_pipe_path: assignCallbacks.path,
684
+ assign_callback_pipe: assignCallbacks.getStatus(),
685
+ });
525
686
  }
526
- return writeJson(
527
- res,
528
- 401,
529
- { ok: false, error: 'Unauthorized' },
530
- { 'WWW-Authenticate': 'Bearer realm="tfx-hub"' },
531
- );
532
- }
533
-
534
- if (path === '/' || path === '/status') {
535
- const status = router.getStatus('hub').data;
536
- return writeJson(res, 200, {
537
- ...status,
538
- sessions: transports.size,
539
- pid: process.pid,
540
- port,
541
- auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
542
- idle_timeout_ms: hubIdleTimeoutMs,
543
- last_request_at: new Date(lastRequestAt).toISOString(),
544
- pipe_path: pipe.path,
545
- pipe: pipe.getStatus(),
546
- assign_callback_pipe_path: assignCallbacks.path,
547
- assign_callback_pipe: assignCallbacks.getStatus(),
548
- });
549
- }
550
687
 
551
- if (path === '/health' || path === '/healthz') {
552
- const status = router.getStatus('hub').data;
553
- const healthy = status?.hub?.state === 'healthy';
554
- return writeJson(res, healthy ? 200 : 503, {
555
- ok: healthy,
556
- version,
557
- platform: process.platform,
558
- uptime_s: Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)),
559
- node: process.version,
560
- sessions: transports.size,
561
- store: store.type || 'sqlite',
562
- idle_timeout_ms: hubIdleTimeoutMs,
563
- idle_ms: Math.max(0, Date.now() - lastRequestAt),
564
- fingerprint: fingerprintService.getHealth(),
565
- });
566
- }
567
-
568
- if (path === '/api/qos-stats' && req.method === 'GET') {
569
- return writeJson(res, 200, getQosStatsPayload());
570
- }
688
+ if (path === "/health" || path === "/healthz") {
689
+ const status = router.getStatus("hub").data;
690
+ const healthy = status?.hub?.state === "healthy";
691
+ return writeJson(res, healthy ? 200 : 503, {
692
+ ok: healthy,
693
+ version,
694
+ platform: process.platform,
695
+ uptime_s: Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)),
696
+ node: process.version,
697
+ sessions: transports.size,
698
+ store: store.type || "sqlite",
699
+ idle_timeout_ms: hubIdleTimeoutMs,
700
+ idle_ms: Math.max(0, Date.now() - lastRequestAt),
701
+ fingerprint: fingerprintService.getHealth(),
702
+ });
703
+ }
571
704
 
572
- if (path.startsWith('/bridge')) {
573
- const isBridgeStatusGet = path === '/bridge/status' && req.method === 'GET';
574
- if (req.method !== 'POST' && req.method !== 'DELETE' && !isBridgeStatusGet) {
575
- return writeJson(res, 405, { ok: false, error: 'Method Not Allowed' });
705
+ if (path === "/api/qos-stats" && req.method === "GET") {
706
+ return writeJson(res, 200, getQosStatsPayload());
576
707
  }
577
708
 
578
- try {
579
- const body = req.method === 'POST' ? await parseBody(req) : {};
580
- const requestUrl = new URL(req.url || path, 'http://127.0.0.1');
581
-
582
- if (path === '/bridge/status' && req.method === 'GET') {
583
- const scope = requestUrl.searchParams.get('scope') || 'hub';
584
- const include_metrics = requestUrl.searchParams.get('include_metrics') !== '0';
585
- const agent_id = requestUrl.searchParams.get('agent_id') || undefined;
586
- const trace_id = requestUrl.searchParams.get('trace_id') || undefined;
587
- const result = await pipe.executeQuery('status', {
588
- scope,
589
- include_metrics,
590
- agent_id,
591
- trace_id,
709
+ if (path.startsWith("/bridge")) {
710
+ const isBridgeStatusGet =
711
+ path === "/bridge/status" && req.method === "GET";
712
+ const isBridgeHitlPendingGet =
713
+ path === "/bridge/hitl/pending" && req.method === "GET";
714
+ if (
715
+ req.method !== "POST" &&
716
+ req.method !== "DELETE" &&
717
+ !isBridgeStatusGet &&
718
+ !isBridgeHitlPendingGet
719
+ ) {
720
+ return writeJson(res, 405, {
721
+ ok: false,
722
+ error: "Method Not Allowed",
592
723
  });
593
- return writeJson(res, 200, result);
594
724
  }
595
725
 
596
- if (path === '/bridge/register' && req.method === 'POST') {
597
- const { agent_id, cli, timeout_sec = 600, topics = [], capabilities = [], metadata = {} } = body;
598
- if (!agent_id || !cli) {
599
- return writeJson(res, 400, { ok: false, error: 'agent_id, cli 필수' });
726
+ try {
727
+ const body = req.method === "POST" ? await parseBody(req) : {};
728
+ const requestUrl = new URL(req.url || path, "http://127.0.0.1");
729
+
730
+ if (path === "/bridge/status" && req.method === "GET") {
731
+ const scope = requestUrl.searchParams.get("scope") || "hub";
732
+ const include_metrics =
733
+ requestUrl.searchParams.get("include_metrics") !== "0";
734
+ const agent_id =
735
+ requestUrl.searchParams.get("agent_id") || undefined;
736
+ const trace_id =
737
+ requestUrl.searchParams.get("trace_id") || undefined;
738
+ const result = await pipe.executeQuery("status", {
739
+ scope,
740
+ include_metrics,
741
+ agent_id,
742
+ trace_id,
743
+ });
744
+ return writeJson(res, 200, result);
600
745
  }
601
746
 
602
- const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
603
- const result = await pipe.executeCommand('register', {
604
- agent_id,
605
- cli,
606
- capabilities,
607
- topics,
608
- heartbeat_ttl_ms,
609
- metadata,
610
- });
611
- return writeJson(res, 200, result);
612
- }
613
-
614
- if (path === '/bridge/result' && req.method === 'POST') {
615
- const { agent_id, topic = 'task.result', payload = {}, trace_id, correlation_id } = body;
616
- if (!agent_id) {
617
- return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
747
+ if (path === "/bridge/hitl/pending" && req.method === "GET") {
748
+ const result = { ok: true, data: hitl.getPendingRequests() };
749
+ return writeJson(res, result.ok ? 200 : 400, result);
618
750
  }
619
751
 
620
- const result = await pipe.executeCommand('result', {
621
- agent_id,
622
- topic,
623
- payload,
624
- trace_id,
625
- correlation_id,
626
- });
627
- return writeJson(res, 200, result);
628
- }
752
+ if (path === "/bridge/register" && req.method === "POST") {
753
+ const {
754
+ agent_id,
755
+ cli,
756
+ timeout_sec = 600,
757
+ topics = [],
758
+ capabilities = [],
759
+ metadata = {},
760
+ } = body;
761
+ if (!agent_id || !cli) {
762
+ return writeJson(res, 400, {
763
+ ok: false,
764
+ error: "agent_id, cli 필수",
765
+ });
766
+ }
629
767
 
630
- if (path === '/bridge/control' && req.method === 'POST') {
631
- const {
632
- from_agent = 'lead',
633
- to_agent,
634
- command,
635
- reason = '',
636
- payload = {},
637
- trace_id,
638
- correlation_id,
639
- ttl_ms = 3600000,
640
- } = body;
641
-
642
- if (!to_agent || !command) {
643
- return writeJson(res, 400, { ok: false, error: 'to_agent, command 필수' });
768
+ const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
769
+ const result = await pipe.executeCommand("register", {
770
+ agent_id,
771
+ cli,
772
+ capabilities,
773
+ topics,
774
+ heartbeat_ttl_ms,
775
+ metadata,
776
+ });
777
+ return writeJson(res, 200, result);
644
778
  }
645
779
 
646
- const result = await pipe.executeCommand('control', {
647
- from_agent,
648
- to_agent,
649
- command,
650
- reason,
651
- payload,
652
- ttl_ms,
653
- trace_id,
654
- correlation_id,
655
- });
780
+ if (path === "/bridge/result" && req.method === "POST") {
781
+ const {
782
+ agent_id,
783
+ topic = "task.result",
784
+ payload = {},
785
+ trace_id,
786
+ correlation_id,
787
+ } = body;
788
+ if (!agent_id) {
789
+ return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
790
+ }
656
791
 
657
- return writeJson(res, 200, result);
658
- }
792
+ const result = await pipe.executeCommand("result", {
793
+ agent_id,
794
+ topic,
795
+ payload,
796
+ trace_id,
797
+ correlation_id,
798
+ });
799
+ return writeJson(res, 200, result);
800
+ }
659
801
 
660
- if (path === '/bridge/status' && req.method === 'POST') {
661
- const {
662
- scope = 'hub',
663
- agent_id,
664
- status,
665
- include_metrics = true,
666
- trace_id,
667
- } = body;
668
-
669
- if (agent_id && status) {
670
- const normalizedAgentId = String(agent_id || '').trim();
671
- const normalizedStatus = String(status || '').trim().toLowerCase();
672
- if (!normalizedAgentId || !normalizedStatus) {
673
- return writeJson(res, 400, { ok: false, error: 'agent_id, status 필수' });
802
+ if (path === "/bridge/control" && req.method === "POST") {
803
+ const {
804
+ from_agent = "lead",
805
+ to_agent,
806
+ command,
807
+ reason = "",
808
+ payload = {},
809
+ trace_id,
810
+ correlation_id,
811
+ ttl_ms = 3600000,
812
+ } = body;
813
+
814
+ if (!to_agent || !command) {
815
+ return writeJson(res, 400, {
816
+ ok: false,
817
+ error: "to_agent, command 필수",
818
+ });
674
819
  }
675
- const statusForStore = new Set(['online', 'stale', 'offline']).has(normalizedStatus)
676
- ? normalizedStatus
677
- : 'online';
678
- router.updateAgentStatus(normalizedAgentId, statusForStore);
679
- const snapshot = await pipe.executeQuery('status', {
680
- scope: 'agent',
681
- agent_id: normalizedAgentId,
682
- include_metrics: false,
683
- });
684
- return writeJson(res, 200, {
685
- ok: true,
686
- data: {
687
- agent_id: normalizedAgentId,
688
- status: statusForStore,
689
- reported_status: normalizedStatus,
690
- reported_at_ms: Date.now(),
691
- snapshot: snapshot?.data?.agent || null,
692
- },
820
+
821
+ const result = await pipe.executeCommand("control", {
822
+ from_agent,
823
+ to_agent,
824
+ command,
825
+ reason,
826
+ payload,
827
+ ttl_ms,
828
+ trace_id,
829
+ correlation_id,
693
830
  });
831
+
832
+ return writeJson(res, 200, result);
694
833
  }
695
834
 
696
- const result = await pipe.executeQuery('status', {
697
- scope,
698
- agent_id,
699
- include_metrics,
700
- trace_id,
701
- });
702
- return writeJson(res, 200, result);
703
- }
835
+ if (path === "/bridge/handoff" && req.method === "POST") {
836
+ const result = router.handleHandoff(normalizeHandoffBody(body));
837
+ return writeJson(res, result.ok ? 200 : 400, result);
838
+ }
704
839
 
705
- if (path === '/bridge/assign/async' && req.method === 'POST') {
706
- const {
707
- supervisor_agent,
708
- worker_agent,
709
- task,
710
- topic = 'assign.job',
711
- payload = {},
712
- priority = 5,
713
- ttl_ms = 600000,
714
- timeout_ms = 600000,
715
- max_retries = 0,
716
- trace_id,
717
- correlation_id,
718
- } = body;
719
-
720
- if (!supervisor_agent || !worker_agent || !task) {
721
- return writeJson(res, 400, { ok: false, error: 'supervisor_agent, worker_agent, task 필수' });
840
+ if (path === "/bridge/publish" && req.method === "POST") {
841
+ const result = router.handlePublish(normalizePublishBody(body));
842
+ return writeJson(res, result.ok ? 200 : 400, result);
722
843
  }
723
844
 
724
- const result = await pipe.executeCommand('assign', {
725
- supervisor_agent,
726
- worker_agent,
727
- task,
728
- topic,
729
- payload,
730
- priority,
731
- ttl_ms,
732
- timeout_ms,
733
- max_retries,
734
- trace_id,
735
- correlation_id,
736
- });
737
- return writeJson(res, result.ok ? 200 : 400, result);
738
- }
845
+ if (path === "/bridge/send-input" && req.method === "POST") {
846
+ const { session_id, text } = body;
847
+ if (!session_id || typeof text !== "string" || text.length === 0) {
848
+ return writeJson(res, 400, {
849
+ ok: false,
850
+ error: "session_id, text 필수",
851
+ });
852
+ }
739
853
 
740
- if (path === '/bridge/assign/result' && req.method === 'POST') {
741
- const {
742
- job_id,
743
- worker_agent,
744
- status,
745
- attempt,
746
- result: assignResult,
747
- error: assignError,
748
- payload = {},
749
- metadata = {},
750
- } = body;
751
-
752
- if (!job_id || !status) {
753
- return writeJson(res, 400, { ok: false, error: 'job_id, status 필수' });
854
+ const result = await pipe.executeCommand("send_input", {
855
+ session_id,
856
+ text,
857
+ });
858
+ return writeJson(res, resolveSendInputStatusCode(result), result);
754
859
  }
755
860
 
756
- const result = await pipe.executeCommand('assign_result', {
757
- job_id,
758
- worker_agent,
759
- status,
760
- attempt,
761
- result: assignResult,
762
- error: assignError,
763
- payload,
764
- metadata,
765
- });
766
- return writeJson(res, result.ok ? 200 : 409, result);
767
- }
768
-
769
- if (path === '/bridge/assign/status' && req.method === 'POST') {
770
- const result = await pipe.executeQuery('assign_status', body);
771
- const statusCode = result.ok ? 200 : (result.error?.code === 'ASSIGN_NOT_FOUND' ? 404 : 400);
772
- return writeJson(res, statusCode, result);
773
- }
861
+ if (path === "/bridge/status" && req.method === "POST") {
862
+ const {
863
+ scope = "hub",
864
+ agent_id,
865
+ status,
866
+ include_metrics = true,
867
+ trace_id,
868
+ } = body;
869
+
870
+ if (agent_id && status) {
871
+ const normalizedAgentId = String(agent_id || "").trim();
872
+ const normalizedStatus = String(status || "")
873
+ .trim()
874
+ .toLowerCase();
875
+ if (!normalizedAgentId || !normalizedStatus) {
876
+ return writeJson(res, 400, {
877
+ ok: false,
878
+ error: "agent_id, status 필수",
879
+ });
880
+ }
881
+ const statusForStore = new Set([
882
+ "online",
883
+ "stale",
884
+ "offline",
885
+ ]).has(normalizedStatus)
886
+ ? normalizedStatus
887
+ : "online";
888
+ router.updateAgentStatus(normalizedAgentId, statusForStore);
889
+ const snapshot = await pipe.executeQuery("status", {
890
+ scope: "agent",
891
+ agent_id: normalizedAgentId,
892
+ include_metrics: false,
893
+ });
894
+ return writeJson(res, 200, {
895
+ ok: true,
896
+ data: {
897
+ agent_id: normalizedAgentId,
898
+ status: statusForStore,
899
+ reported_status: normalizedStatus,
900
+ reported_at_ms: Date.now(),
901
+ snapshot: snapshot?.data?.agent || null,
902
+ },
903
+ });
904
+ }
774
905
 
775
- if (path === '/bridge/assign/retry' && req.method === 'POST') {
776
- const { job_id, reason, requested_by } = body;
777
- if (!job_id) {
778
- return writeJson(res, 400, { ok: false, error: 'job_id 필수' });
906
+ const result = await pipe.executeQuery("status", {
907
+ scope,
908
+ agent_id,
909
+ include_metrics,
910
+ trace_id,
911
+ });
912
+ return writeJson(res, 200, result);
779
913
  }
780
914
 
781
- const result = await pipe.executeCommand('assign_retry', {
782
- job_id,
783
- reason,
784
- requested_by,
785
- });
786
- const statusCode = result.ok ? 200
787
- : result.error?.code === 'ASSIGN_NOT_FOUND' ? 404
788
- : result.error?.code === 'ASSIGN_RETRY_EXHAUSTED' ? 409
789
- : 400;
790
- return writeJson(res, statusCode, result);
791
- }
915
+ if (path === "/bridge/assign/async" && req.method === "POST") {
916
+ const {
917
+ supervisor_agent,
918
+ worker_agent,
919
+ task,
920
+ topic = "assign.job",
921
+ payload = {},
922
+ priority = 5,
923
+ ttl_ms = 600000,
924
+ timeout_ms = 600000,
925
+ max_retries = 0,
926
+ trace_id,
927
+ correlation_id,
928
+ } = body;
929
+
930
+ if (!supervisor_agent || !worker_agent || !task) {
931
+ return writeJson(res, 400, {
932
+ ok: false,
933
+ error: "supervisor_agent, worker_agent, task 필수",
934
+ });
935
+ }
792
936
 
793
- if (req.method === 'POST') {
794
- let teamResult = null;
795
- if (path === '/bridge/team/info' || path === '/bridge/team-info') {
796
- teamResult = await pipe.executeQuery('team_info', body);
797
- } else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
798
- teamResult = await pipe.executeQuery('team_task_list', body);
799
- } else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
800
- teamResult = await pipe.executeCommand('team_task_update', body);
801
- } else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
802
- teamResult = await pipe.executeCommand('team_send_message', body);
937
+ const result = await pipe.executeCommand("assign", {
938
+ supervisor_agent,
939
+ worker_agent,
940
+ task,
941
+ topic,
942
+ payload,
943
+ priority,
944
+ ttl_ms,
945
+ timeout_ms,
946
+ max_retries,
947
+ trace_id,
948
+ correlation_id,
949
+ });
950
+ return writeJson(res, result.ok ? 200 : 400, result);
803
951
  }
804
952
 
805
- if (teamResult) {
806
- return writeJson(res, resolveTeamStatusCode(teamResult), teamResult);
807
- }
953
+ if (path === "/bridge/assign/result" && req.method === "POST") {
954
+ const {
955
+ job_id,
956
+ worker_agent,
957
+ status,
958
+ attempt,
959
+ result: assignResult,
960
+ error: assignError,
961
+ payload = {},
962
+ metadata = {},
963
+ } = body;
964
+
965
+ if (!job_id || !status) {
966
+ return writeJson(res, 400, {
967
+ ok: false,
968
+ error: "job_id, status 필수",
969
+ });
970
+ }
808
971
 
809
- if (path.startsWith('/bridge/team')) {
810
- return writeJson(res, 404, { ok: false, error: `Unknown team endpoint: ${path}` });
972
+ const result = await pipe.executeCommand("assign_result", {
973
+ job_id,
974
+ worker_agent,
975
+ status,
976
+ attempt,
977
+ result: assignResult,
978
+ error: assignError,
979
+ payload,
980
+ metadata,
981
+ });
982
+ return writeJson(res, result.ok ? 200 : 409, result);
811
983
  }
812
984
 
813
- // ── 파이프라인 엔드포인트 ──
814
- if (path === '/bridge/pipeline/state' && req.method === 'POST') {
815
- const result = await pipe.executeQuery('pipeline_state', body);
816
- return writeJson(res, resolvePipelineStatusCode(result), result);
985
+ if (path === "/bridge/assign/status" && req.method === "POST") {
986
+ const result = await pipe.executeQuery("assign_status", body);
987
+ const statusCode = result.ok
988
+ ? 200
989
+ : result.error?.code === "ASSIGN_NOT_FOUND"
990
+ ? 404
991
+ : 400;
992
+ return writeJson(res, statusCode, result);
817
993
  }
818
994
 
819
- if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
820
- const result = await pipe.executeCommand('pipeline_advance', body);
821
- return writeJson(res, resolvePipelineStatusCode(result), result);
822
- }
995
+ if (path === "/bridge/assign/retry" && req.method === "POST") {
996
+ const { job_id, reason, requested_by } = body;
997
+ if (!job_id) {
998
+ return writeJson(res, 400, { ok: false, error: "job_id 필수" });
999
+ }
823
1000
 
824
- if (path === '/bridge/pipeline/init' && req.method === 'POST') {
825
- const result = await pipe.executeCommand('pipeline_init', body);
826
- return writeJson(res, resolvePipelineStatusCode(result), result);
1001
+ const result = await pipe.executeCommand("assign_retry", {
1002
+ job_id,
1003
+ reason,
1004
+ requested_by,
1005
+ });
1006
+ const statusCode = result.ok
1007
+ ? 200
1008
+ : result.error?.code === "ASSIGN_NOT_FOUND"
1009
+ ? 404
1010
+ : result.error?.code === "ASSIGN_RETRY_EXHAUSTED"
1011
+ ? 409
1012
+ : 400;
1013
+ return writeJson(res, statusCode, result);
827
1014
  }
828
1015
 
829
- if (path === '/bridge/pipeline/list' && req.method === 'POST') {
830
- const result = await pipe.executeQuery('pipeline_list', body);
831
- return writeJson(res, resolvePipelineStatusCode(result), result);
832
- }
1016
+ if (req.method === "POST") {
1017
+ let teamResult = null;
1018
+ if (path === "/bridge/team/info" || path === "/bridge/team-info") {
1019
+ teamResult = await pipe.executeQuery("team_info", body);
1020
+ } else if (
1021
+ path === "/bridge/team/task-list" ||
1022
+ path === "/bridge/team-task-list"
1023
+ ) {
1024
+ teamResult = await pipe.executeQuery("team_task_list", body);
1025
+ } else if (
1026
+ path === "/bridge/team/task-update" ||
1027
+ path === "/bridge/team-task-update"
1028
+ ) {
1029
+ teamResult = await pipe.executeCommand("team_task_update", body);
1030
+ } else if (
1031
+ path === "/bridge/team/send-message" ||
1032
+ path === "/bridge/team-send-message"
1033
+ ) {
1034
+ teamResult = await pipe.executeCommand("team_send_message", body);
1035
+ }
833
1036
 
834
- // ── Delegator 엔드포인트 ──
835
- if (path === '/bridge/delegator/delegate' && req.method === 'POST') {
836
- const result = await pipe.executeCommand('delegator_delegate', body);
837
- return writeJson(res, result.ok ? 200 : 400, result);
838
- }
1037
+ if (teamResult) {
1038
+ return writeJson(
1039
+ res,
1040
+ resolveTeamStatusCode(teamResult),
1041
+ teamResult,
1042
+ );
1043
+ }
839
1044
 
840
- if (path === '/bridge/delegator/reply' && req.method === 'POST') {
841
- const result = await pipe.executeCommand('delegator_reply', body);
842
- return writeJson(res, result.ok ? 200 : 400, result);
1045
+ if (path.startsWith("/bridge/team")) {
1046
+ return writeJson(res, 404, {
1047
+ ok: false,
1048
+ error: `Unknown team endpoint: ${path}`,
1049
+ });
1050
+ }
1051
+
1052
+ // ── 파이프라인 엔드포인트 ──
1053
+ if (path === "/bridge/pipeline/state" && req.method === "POST") {
1054
+ const result = await pipe.executeQuery("pipeline_state", body);
1055
+ return writeJson(res, resolvePipelineStatusCode(result), result);
1056
+ }
1057
+
1058
+ if (path === "/bridge/pipeline/advance" && req.method === "POST") {
1059
+ const result = await pipe.executeCommand(
1060
+ "pipeline_advance",
1061
+ body,
1062
+ );
1063
+ return writeJson(res, resolvePipelineStatusCode(result), result);
1064
+ }
1065
+
1066
+ if (path === "/bridge/pipeline/init" && req.method === "POST") {
1067
+ const result = await pipe.executeCommand("pipeline_init", body);
1068
+ return writeJson(res, resolvePipelineStatusCode(result), result);
1069
+ }
1070
+
1071
+ if (path === "/bridge/pipeline/list" && req.method === "POST") {
1072
+ const result = await pipe.executeQuery("pipeline_list", body);
1073
+ return writeJson(res, resolvePipelineStatusCode(result), result);
1074
+ }
1075
+
1076
+ // ── Delegator 엔드포인트 ──
1077
+ if (
1078
+ path === "/bridge/delegator/delegate" &&
1079
+ req.method === "POST"
1080
+ ) {
1081
+ const result = await pipe.executeCommand(
1082
+ "delegator_delegate",
1083
+ body,
1084
+ );
1085
+ return writeJson(res, result.ok ? 200 : 400, result);
1086
+ }
1087
+
1088
+ if (path === "/bridge/delegator/reply" && req.method === "POST") {
1089
+ const result = await pipe.executeCommand("delegator_reply", body);
1090
+ return writeJson(res, result.ok ? 200 : 400, result);
1091
+ }
1092
+
1093
+ if (path === "/bridge/delegator/status" && req.method === "POST") {
1094
+ const result = await pipe.executeQuery("delegator_status", body);
1095
+ return writeJson(res, result.ok ? 200 : 400, result);
1096
+ }
1097
+
1098
+ if (path === "/bridge/hitl/request" && req.method === "POST") {
1099
+ const result = hitl.requestHumanInput(body);
1100
+ return writeJson(res, result.ok ? 200 : 400, result);
1101
+ }
1102
+
1103
+ if (path === "/bridge/hitl/submit" && req.method === "POST") {
1104
+ const result = hitl.submitHumanInput(body);
1105
+ return writeJson(res, result.ok ? 200 : 400, result);
1106
+ }
843
1107
  }
844
1108
 
845
- if (path === '/bridge/delegator/status' && req.method === 'POST') {
846
- const result = await pipe.executeQuery('delegator_status', body);
847
- return writeJson(res, result.ok ? 200 : 400, result);
1109
+ if (path === "/bridge/context" && req.method === "POST") {
1110
+ const {
1111
+ agent_id,
1112
+ topics,
1113
+ max_messages = 10,
1114
+ auto_ack = true,
1115
+ } = body;
1116
+ if (!agent_id) {
1117
+ return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
1118
+ }
1119
+
1120
+ const result = await pipe.executeQuery("drain", {
1121
+ agent_id,
1122
+ topics,
1123
+ max_messages,
1124
+ auto_ack,
1125
+ });
1126
+ return writeJson(res, 200, result);
848
1127
  }
849
- }
850
1128
 
851
- if (path === '/bridge/context' && req.method === 'POST') {
852
- const { agent_id, topics, max_messages = 10, auto_ack = true } = body;
853
- if (!agent_id) {
854
- return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
1129
+ if (path === "/bridge/deregister" && req.method === "POST") {
1130
+ const { agent_id } = body;
1131
+ if (!agent_id) {
1132
+ return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
1133
+ }
1134
+ const result = await pipe.executeCommand("deregister", {
1135
+ agent_id,
1136
+ });
1137
+ return writeJson(res, 200, result);
855
1138
  }
856
1139
 
857
- const result = await pipe.executeQuery('drain', {
858
- agent_id,
859
- topics,
860
- max_messages,
861
- auto_ack,
1140
+ return writeJson(res, 404, {
1141
+ ok: false,
1142
+ error: "Unknown bridge endpoint",
862
1143
  });
863
- return writeJson(res, 200, result);
864
- }
865
-
866
- if (path === '/bridge/deregister' && req.method === 'POST') {
867
- const { agent_id } = body;
868
- if (!agent_id) {
869
- return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
1144
+ } catch (error) {
1145
+ if (!res.headersSent) {
1146
+ console.error("[tfx-hub] bridge error:", error);
1147
+ writeJson(res, 500, { ok: false, error: "Internal server error" });
870
1148
  }
871
- const result = await pipe.executeCommand('deregister', { agent_id });
872
- return writeJson(res, 200, result);
1149
+ return;
873
1150
  }
1151
+ }
874
1152
 
875
- return writeJson(res, 404, { ok: false, error: 'Unknown bridge endpoint' });
876
- } catch (error) {
877
- if (!res.headersSent) {
878
- console.error('[tfx-hub] bridge error:', error);
879
- writeJson(res, 500, { ok: false, error: 'Internal server error' });
880
- }
1153
+ if (req.method === "GET" && servePublicFile(res, path)) {
881
1154
  return;
882
1155
  }
883
- }
884
-
885
- if (req.method === 'GET' && servePublicFile(res, path)) {
886
- return;
887
- }
888
1156
 
889
- if (path !== '/mcp') {
890
- res.writeHead(404);
891
- return res.end('Not Found');
892
- }
1157
+ if (path !== "/mcp") {
1158
+ res.writeHead(404);
1159
+ return res.end("Not Found");
1160
+ }
893
1161
 
894
- try {
895
- const sessionIdHeader = req.headers['mcp-session-id'];
896
-
897
- if (req.method === 'POST') {
898
- const body = await parseBody(req);
899
-
900
- if (sessionIdHeader && transports.has(sessionIdHeader)) {
901
- const session = transports.get(sessionIdHeader);
902
- session.transport._lastActivity = Date.now();
903
- await session.transport.handleRequest(req, res, body);
904
- } else if (!sessionIdHeader && isInitializeRequest(body)) {
905
- const transport = new StreamableHTTPServerTransport({
906
- sessionIdGenerator: () => randomUUID(),
907
- onsessioninitialized: (sid) => {
908
- transport._lastActivity = Date.now();
909
- transports.set(sid, { transport, mcp });
910
- },
911
- });
912
- transport.onclose = () => {
913
- if (transport.sessionId) {
914
- const session = transports.get(transport.sessionId);
915
- if (session) {
916
- try { session.mcp.close(); } catch {}
1162
+ try {
1163
+ const sessionIdHeader = req.headers["mcp-session-id"];
1164
+
1165
+ if (req.method === "POST") {
1166
+ const body = await parseBody(req);
1167
+
1168
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
1169
+ const session = transports.get(sessionIdHeader);
1170
+ session.transport._lastActivity = Date.now();
1171
+ await session.transport.handleRequest(req, res, body);
1172
+ } else if (!sessionIdHeader && isInitializeRequest(body)) {
1173
+ const transport = new StreamableHTTPServerTransport({
1174
+ sessionIdGenerator: () => randomUUID(),
1175
+ onsessioninitialized: (sid) => {
1176
+ transport._lastActivity = Date.now();
1177
+ transports.set(sid, { transport, mcp });
1178
+ },
1179
+ });
1180
+ transport.onclose = () => {
1181
+ if (transport.sessionId) {
1182
+ const session = transports.get(transport.sessionId);
1183
+ if (session) {
1184
+ try {
1185
+ session.mcp.close();
1186
+ } catch {}
1187
+ }
1188
+ transports.delete(transport.sessionId);
917
1189
  }
918
- transports.delete(transport.sessionId);
919
- }
920
- };
921
- const mcp = createMcpForSession();
922
- await mcp.connect(transport);
923
- await transport.handleRequest(req, res, body);
924
- } else {
925
- res.writeHead(400, { 'Content-Type': 'application/json' });
926
- res.end(JSON.stringify({
927
- jsonrpc: '2.0',
928
- error: { code: -32000, message: 'Bad Request: No valid session ID' },
929
- id: null,
930
- }));
931
- }
932
- } else if (req.method === 'GET') {
933
- if (sessionIdHeader && transports.has(sessionIdHeader)) {
934
- await transports.get(sessionIdHeader).transport.handleRequest(req, res);
1190
+ };
1191
+ const mcp = createMcpForSession();
1192
+ await mcp.connect(transport);
1193
+ await transport.handleRequest(req, res, body);
1194
+ } else {
1195
+ res.writeHead(400, { "Content-Type": "application/json" });
1196
+ res.end(
1197
+ JSON.stringify({
1198
+ jsonrpc: "2.0",
1199
+ error: {
1200
+ code: -32000,
1201
+ message: "Bad Request: No valid session ID",
1202
+ },
1203
+ id: null,
1204
+ }),
1205
+ );
1206
+ }
1207
+ } else if (req.method === "GET") {
1208
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
1209
+ await transports
1210
+ .get(sessionIdHeader)
1211
+ .transport.handleRequest(req, res);
1212
+ } else {
1213
+ res.writeHead(400);
1214
+ res.end("Invalid or missing session ID");
1215
+ }
1216
+ } else if (req.method === "DELETE") {
1217
+ if (sessionIdHeader && transports.has(sessionIdHeader)) {
1218
+ await transports
1219
+ .get(sessionIdHeader)
1220
+ .transport.handleRequest(req, res);
1221
+ } else {
1222
+ res.writeHead(400);
1223
+ res.end("Invalid or missing session ID");
1224
+ }
935
1225
  } else {
936
- res.writeHead(400);
937
- res.end('Invalid or missing session ID');
1226
+ res.writeHead(405);
1227
+ res.end("Method Not Allowed");
938
1228
  }
939
- } else if (req.method === 'DELETE') {
940
- if (sessionIdHeader && transports.has(sessionIdHeader)) {
941
- await transports.get(sessionIdHeader).transport.handleRequest(req, res);
942
- } else {
943
- res.writeHead(400);
944
- res.end('Invalid or missing session ID');
1229
+ } catch (error) {
1230
+ hubLog.error({ err: error }, "http.error");
1231
+ if (!res.headersSent) {
1232
+ const code =
1233
+ error.statusCode === 413
1234
+ ? 413
1235
+ : error instanceof SyntaxError
1236
+ ? 400
1237
+ : 500;
1238
+ const message =
1239
+ code === 413
1240
+ ? "Body too large"
1241
+ : code === 400
1242
+ ? "Invalid JSON"
1243
+ : "Internal server error";
1244
+ res.writeHead(code, { "Content-Type": "application/json" });
1245
+ res.end(
1246
+ JSON.stringify({
1247
+ jsonrpc: "2.0",
1248
+ error: { code: code === 500 ? -32603 : -32700, message },
1249
+ id: null,
1250
+ }),
1251
+ );
945
1252
  }
946
- } else {
947
- res.writeHead(405);
948
- res.end('Method Not Allowed');
949
1253
  }
950
- } catch (error) {
951
- hubLog.error({ err: error }, 'http.error');
952
- if (!res.headersSent) {
953
- const code = error.statusCode === 413 ? 413
954
- : error instanceof SyntaxError ? 400 : 500;
955
- const message = code === 413 ? 'Body too large'
956
- : code === 400 ? 'Invalid JSON' : 'Internal server error';
957
- res.writeHead(code, { 'Content-Type': 'application/json' });
958
- res.end(JSON.stringify({
959
- jsonrpc: '2.0',
960
- error: { code: code === 500 ? -32603 : -32700, message },
961
- id: null,
962
- }));
963
- }
964
- }
965
- }));
1254
+ }),
1255
+ );
966
1256
 
967
1257
  httpServer.requestTimeout = 30000;
968
1258
  httpServer.headersTimeout = 10000;
@@ -970,41 +1260,54 @@ export async function startHub({
970
1260
  router.startSweeper();
971
1261
 
972
1262
  const hitlTimer = setInterval(() => {
973
- try { hitl.checkTimeouts(); } catch (err) { hubLog.warn({ err }, 'hitl.timeout_check_failed'); }
1263
+ try {
1264
+ hitl.checkTimeouts();
1265
+ } catch (err) {
1266
+ hubLog.warn({ err }, "hitl.timeout_check_failed");
1267
+ }
974
1268
  }, 10000);
975
1269
  hitlTimer.unref();
976
1270
 
977
1271
  // MCP session TTL: sessions idle for SESSION_TTL_MS are closed automatically.
978
1272
  // Configurable via SESSION_TTL_MS (default 30 minutes). The sweep runs every 60 s.
979
- const SESSION_TTL_MS = parseInt(process.env.TFX_SESSION_TTL_MS || '', 10) || 30 * 60 * 1000;
1273
+ const SESSION_TTL_MS =
1274
+ parseInt(process.env.TFX_SESSION_TTL_MS || "", 10) || 30 * 60 * 1000;
980
1275
  const sessionTimer = setInterval(() => {
981
1276
  const now = Date.now();
982
1277
  for (const [sid, session] of transports) {
983
- if (now - (session.transport._lastActivity || 0) <= SESSION_TTL_MS) continue;
984
- try { session.mcp.close(); } catch {}
985
- try { session.transport.close(); } catch {}
1278
+ if (now - (session.transport._lastActivity || 0) <= SESSION_TTL_MS)
1279
+ continue;
1280
+ try {
1281
+ session.mcp.close();
1282
+ } catch {}
1283
+ try {
1284
+ session.transport.close();
1285
+ } catch {}
986
1286
  transports.delete(sid);
987
1287
  }
988
1288
  }, 60000);
989
1289
  sessionTimer.unref();
990
1290
 
991
1291
  // 고아 node.exe 프로세스 + stale spawn 세션 주기적 정리 (5분마다)
992
- const orphanCleanupTimer = setInterval(() => {
993
- try {
994
- const { killed } = cleanupOrphanNodeProcesses();
995
- if (killed > 0) {
996
- hubLog.info({ killed }, 'hub.orphan_cleanup');
997
- }
998
- } catch {}
1292
+ const orphanCleanupTimer = setInterval(
1293
+ () => {
1294
+ try {
1295
+ const { killed } = cleanupOrphanNodeProcesses();
1296
+ if (killed > 0) {
1297
+ hubLog.info({ killed }, "hub.orphan_cleanup");
1298
+ }
1299
+ } catch {}
999
1300
 
1000
- // stale tfx-spawn-* psmux 세션 정리 (30분 이상 idle)
1001
- try {
1002
- const staleKilled = cleanupStaleSpawnSessions(hubLog);
1003
- if (staleKilled > 0) {
1004
- hubLog.info({ killed: staleKilled }, 'hub.stale_spawn_cleanup');
1005
- }
1006
- } catch {}
1007
- }, 5 * 60 * 1000);
1301
+ // stale tfx-spawn-* psmux 세션 정리 (30분 이상 idle)
1302
+ try {
1303
+ const staleKilled = cleanupStaleSpawnSessions(hubLog);
1304
+ if (staleKilled > 0) {
1305
+ hubLog.info({ killed: staleKilled }, "hub.stale_spawn_cleanup");
1306
+ }
1307
+ } catch {}
1308
+ },
1309
+ 5 * 60 * 1000,
1310
+ );
1008
1311
  orphanCleanupTimer.unref();
1009
1312
 
1010
1313
  // Evict stale rate-limit buckets once per minute to bound memory usage.
@@ -1026,35 +1329,56 @@ export async function startHub({
1026
1329
  // Stale PID 파일 정리 — 이전 Hub 프로세스가 비정상 종료된 경우
1027
1330
  if (existsSync(PID_FILE)) {
1028
1331
  try {
1029
- const prevInfo = JSON.parse(readFileSync(PID_FILE, 'utf8'));
1332
+ const prevInfo = JSON.parse(readFileSync(PID_FILE, "utf8"));
1030
1333
  const prevPid = Number(prevInfo?.pid);
1031
1334
  if (Number.isFinite(prevPid) && prevPid > 0) {
1032
1335
  try {
1033
1336
  process.kill(prevPid, 0); // alive 체크만
1034
1337
  // 프로세스가 살아있으면 포트 충돌 가능성 — 기존 Hub 재사용 안내
1035
1338
  if (Number(prevInfo.port) === Number(port)) {
1036
- hubLog.warn({ prevPid, port }, 'hub.stale_pid: previous hub still alive on same port');
1339
+ hubLog.warn(
1340
+ { prevPid, port },
1341
+ "hub.stale_pid: previous hub still alive on same port",
1342
+ );
1037
1343
  }
1038
1344
  } catch {
1039
1345
  // 프로세스 죽음 → stale PID 파일 삭제
1040
- try { unlinkSync(PID_FILE); } catch {}
1041
- hubLog.info({ prevPid }, 'hub.stale_pid_cleaned');
1346
+ try {
1347
+ unlinkSync(PID_FILE);
1348
+ } catch {}
1349
+ hubLog.info({ prevPid }, "hub.stale_pid_cleaned");
1042
1350
  }
1043
1351
  } else {
1044
- try { unlinkSync(PID_FILE); } catch {}
1352
+ try {
1353
+ unlinkSync(PID_FILE);
1354
+ } catch {}
1045
1355
  }
1046
1356
  } catch {
1047
- try { unlinkSync(PID_FILE); } catch {}
1357
+ try {
1358
+ unlinkSync(PID_FILE);
1359
+ } catch {}
1048
1360
  }
1049
1361
  }
1050
1362
 
1051
1363
  const cleanupStartupFailure = async () => {
1052
- try { router.stopSweeper(); } catch {}
1053
- try { await pipe.stop(); } catch {}
1054
- try { await assignCallbacks.stop(); } catch {}
1055
- try { await delegatorWorker.stop(); } catch {}
1056
- try { store.close(); } catch {}
1057
- try { unlinkSync(TOKEN_FILE); } catch {}
1364
+ try {
1365
+ router.stopSweeper();
1366
+ } catch {}
1367
+ try {
1368
+ await pipe.stop();
1369
+ } catch {}
1370
+ try {
1371
+ await assignCallbacks.stop();
1372
+ } catch {}
1373
+ try {
1374
+ await delegatorWorker.stop();
1375
+ } catch {}
1376
+ try {
1377
+ store.close();
1378
+ } catch {}
1379
+ try {
1380
+ unlinkSync(TOKEN_FILE);
1381
+ } catch {}
1058
1382
  releaseStartupLock();
1059
1383
  };
1060
1384
 
@@ -1078,14 +1402,14 @@ export async function startHub({
1078
1402
  dbPath,
1079
1403
  pid: process.pid,
1080
1404
  hubToken: HUB_TOKEN,
1081
- authMode: HUB_TOKEN ? 'token-required' : 'localhost-only',
1405
+ authMode: HUB_TOKEN ? "token-required" : "localhost-only",
1082
1406
  url: buildHubUrl(host, port),
1083
1407
  pipe_path: pipe.path,
1084
1408
  pipePath: pipe.path,
1085
1409
  assign_callback_pipe_path: assignCallbacks.path,
1086
1410
  assignCallbackPipePath: assignCallbacks.path,
1087
1411
  version,
1088
- storeType: store.type || 'sqlite',
1412
+ storeType: store.type || "sqlite",
1089
1413
  idleTimeoutMs: hubIdleTimeoutMs,
1090
1414
  };
1091
1415
 
@@ -1093,13 +1417,13 @@ export async function startHub({
1093
1417
  pid: process.pid,
1094
1418
  port,
1095
1419
  host,
1096
- auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
1420
+ auth_mode: HUB_TOKEN ? "token-required" : "localhost-only",
1097
1421
  url: info.url,
1098
1422
  pipe_path: pipe.path,
1099
1423
  pipePath: pipe.path,
1100
1424
  assign_callback_pipe_path: assignCallbacks.path,
1101
1425
  assignCallbackPipePath: assignCallbacks.path,
1102
- authMode: HUB_TOKEN ? 'token-required' : 'localhost-only',
1426
+ authMode: HUB_TOKEN ? "token-required" : "localhost-only",
1103
1427
  startedAt,
1104
1428
  started: startedAtMs,
1105
1429
  version,
@@ -1108,8 +1432,25 @@ export async function startHub({
1108
1432
  });
1109
1433
  releaseStartupLock();
1110
1434
 
1111
- hubLog.info({ url: info.url, pipePath: pipe.path, assignCallbackPath: assignCallbacks.path, pid: process.pid, storeType: info.storeType, version }, 'hub.started');
1112
- hubLog.debug({ publicDir: PUBLIC_DIR, exists: existsSync(PUBLIC_DIR), hasDashboard: existsSync(resolve(PUBLIC_DIR, 'dashboard.html')) }, 'hub.public_dir');
1435
+ hubLog.info(
1436
+ {
1437
+ url: info.url,
1438
+ pipePath: pipe.path,
1439
+ assignCallbackPath: assignCallbacks.path,
1440
+ pid: process.pid,
1441
+ storeType: info.storeType,
1442
+ version,
1443
+ },
1444
+ "hub.started",
1445
+ );
1446
+ hubLog.debug(
1447
+ {
1448
+ publicDir: PUBLIC_DIR,
1449
+ exists: existsSync(PUBLIC_DIR),
1450
+ hasDashboard: existsSync(resolve(PUBLIC_DIR, "dashboard.html")),
1451
+ },
1452
+ "hub.public_dir",
1453
+ );
1113
1454
 
1114
1455
  const stopFn = async () => {
1115
1456
  if (stopPromise) return stopPromise;
@@ -1124,16 +1465,24 @@ export async function startHub({
1124
1465
  clearInterval(idleTimer);
1125
1466
  }
1126
1467
  for (const [, session] of transports) {
1127
- try { await session.mcp.close(); } catch {}
1128
- try { await session.transport.close(); } catch {}
1468
+ try {
1469
+ await session.mcp.close();
1470
+ } catch {}
1471
+ try {
1472
+ await session.transport.close();
1473
+ } catch {}
1129
1474
  }
1130
1475
  transports.clear();
1131
1476
  await pipe.stop();
1132
1477
  await assignCallbacks.stop();
1133
1478
  await delegatorWorker.stop().catch(() => {});
1134
1479
  store.close();
1135
- try { unlinkSync(PID_FILE); } catch {}
1136
- try { unlinkSync(TOKEN_FILE); } catch {}
1480
+ try {
1481
+ unlinkSync(PID_FILE);
1482
+ } catch {}
1483
+ try {
1484
+ unlinkSync(TOKEN_FILE);
1485
+ } catch {}
1137
1486
  httpServer.closeAllConnections();
1138
1487
  await new Promise((resolveClose) => httpServer.close(resolveClose));
1139
1488
  })().catch((error) => {
@@ -1147,9 +1496,15 @@ export async function startHub({
1147
1496
  idleTimer = setInterval(() => {
1148
1497
  const idleMs = Date.now() - lastRequestAt;
1149
1498
  if (idleMs < hubIdleTimeoutMs) return;
1150
- hubLog.warn({ idleMs, idleTimeoutMs: hubIdleTimeoutMs, port }, 'hub.idle_timeout_shutdown');
1499
+ hubLog.warn(
1500
+ { idleMs, idleTimeoutMs: hubIdleTimeoutMs, port },
1501
+ "hub.idle_timeout_shutdown",
1502
+ );
1151
1503
  void stopFn().catch((error) => {
1152
- hubLog.error({ err: error, idleMs, idleTimeoutMs: hubIdleTimeoutMs, port }, 'hub.idle_timeout_shutdown_failed');
1504
+ hubLog.error(
1505
+ { err: error, idleMs, idleTimeoutMs: hubIdleTimeoutMs, port },
1506
+ "hub.idle_timeout_shutdown_failed",
1507
+ );
1153
1508
  });
1154
1509
  }, hubIdleSweepMs);
1155
1510
  idleTimer.unref();
@@ -1172,11 +1527,18 @@ export async function startHub({
1172
1527
  void cleanupStartupFailure().finally(() => reject(error));
1173
1528
  }
1174
1529
  });
1175
- httpServer.on('error', (err) => {
1530
+ httpServer.on("error", (err) => {
1176
1531
  void cleanupStartupFailure();
1177
- if (err.code === 'EADDRINUSE') {
1178
- hubLog.error({ port, host }, 'hub.port_in_use: port already occupied — check for existing hub or other service');
1179
- reject(new Error(`Hub 포트 ${port}이(가) 이미 사용 중입니다. 기존 Hub 프로세스를 확인하세요. (PID file: ${PID_FILE})`));
1532
+ if (err.code === "EADDRINUSE") {
1533
+ hubLog.error(
1534
+ { port, host },
1535
+ "hub.port_in_use: port already occupied — check for existing hub or other service",
1536
+ );
1537
+ reject(
1538
+ new Error(
1539
+ `Hub 포트 ${port}이(가) 이미 사용 중입니다. 기존 Hub 프로세스를 확인하세요. (PID file: ${PID_FILE})`,
1540
+ ),
1541
+ );
1180
1542
  } else {
1181
1543
  reject(err);
1182
1544
  }
@@ -1221,8 +1583,14 @@ export async function getOrCreateServer(opts = {}) {
1221
1583
  */
1222
1584
  function cleanupStaleSpawnSessions(log) {
1223
1585
  const MAX_AGE_MS = 30 * 60 * 1000;
1224
- const IDLE_PROMPT_RE = /^(PS\s|[$%>#]\s*$|\w+@[\w.-]+[:\s]|╰─|╭─|[fb]wd-i-search:|client_loop:\s|Connection\s+(reset|closed))/;
1225
- const execOpts = { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true };
1586
+ const IDLE_PROMPT_RE =
1587
+ /^(PS\s|[$%>#]\s*$|\w+@[\w.-]+[:\s]|╰─|╭─|[fb]wd-i-search:|client_loop:\s|Connection\s+(reset|closed))/;
1588
+ const execOpts = {
1589
+ encoding: "utf8",
1590
+ timeout: 5000,
1591
+ stdio: ["pipe", "pipe", "pipe"],
1592
+ windowsHide: true,
1593
+ };
1226
1594
 
1227
1595
  let killed = 0;
1228
1596
  let raw;
@@ -1234,7 +1602,9 @@ function cleanupStaleSpawnSessions(log) {
1234
1602
 
1235
1603
  const now = Date.now();
1236
1604
  for (const line of raw.split(/\r?\n/)) {
1237
- const match = line.match(/^(tfx-spawn-[^:]+):\s+\d+\s+windows?\s+\(created\s+(.+)\)/);
1605
+ const match = line.match(
1606
+ /^(tfx-spawn-[^:]+):\s+\d+\s+windows?\s+\(created\s+(.+)\)/,
1607
+ );
1238
1608
  if (!match) continue;
1239
1609
 
1240
1610
  const [, sessionName, createdStr] = match;
@@ -1244,8 +1614,14 @@ function cleanupStaleSpawnSessions(log) {
1244
1614
 
1245
1615
  // pane 내용 확인 — 마지막 3줄 중 idle 쉘 프롬프트가 있는지
1246
1616
  try {
1247
- const pane = execSyncHub(`psmux capture-pane -t "${sessionName}:0.0" -p`, execOpts);
1248
- const tailLines = pane.split(/\r?\n/).filter((l) => l.trim()).slice(-3);
1617
+ const pane = execSyncHub(
1618
+ `psmux capture-pane -t "${sessionName}:0.0" -p`,
1619
+ execOpts,
1620
+ );
1621
+ const tailLines = pane
1622
+ .split(/\r?\n/)
1623
+ .filter((l) => l.trim())
1624
+ .slice(-3);
1249
1625
  const hasIdleLine = tailLines.some((l) => IDLE_PROMPT_RE.test(l.trim()));
1250
1626
  if (!hasIdleLine) continue; // 아직 활성 — 건드리지 않음
1251
1627
  } catch {
@@ -1256,32 +1632,42 @@ function cleanupStaleSpawnSessions(log) {
1256
1632
  try {
1257
1633
  execSyncHub(`psmux kill-session -t "${sessionName}"`, execOpts);
1258
1634
  killed++;
1259
- if (log) log.info({ session: sessionName, ageMin: Math.round((now - created) / 60000) }, "hub.stale_spawn_killed");
1635
+ if (log)
1636
+ log.info(
1637
+ { session: sessionName, ageMin: Math.round((now - created) / 60000) },
1638
+ "hub.stale_spawn_killed",
1639
+ );
1260
1640
  } catch {}
1261
1641
  }
1262
1642
 
1263
1643
  return killed;
1264
1644
  }
1265
1645
 
1266
- const selfRun = process.argv[1]?.replace(/\\/g, '/').endsWith('hub/server.mjs');
1646
+ const selfRun = process.argv[1]?.replace(/\\/g, "/").endsWith("hub/server.mjs");
1267
1647
  if (selfRun) {
1268
- const port = parseInt(process.env.TFX_HUB_PORT || '27888', 10);
1648
+ const port = parseInt(process.env.TFX_HUB_PORT || "27888", 10);
1269
1649
  const dbPath = process.env.TFX_HUB_DB || undefined;
1270
1650
 
1271
- startHub({ port, dbPath }).then((info) => {
1272
- const shutdown = async (signal) => {
1273
- hubLog.info({ signal }, 'hub.stopping');
1274
- try { cleanupOrphanNodeProcesses(); } catch {}
1275
- try { cleanupStaleSpawnSessions(hubLog); } catch {}
1276
- await info.stop();
1277
- process.exit(0);
1278
- };
1279
- process.on('SIGINT', () => shutdown('SIGINT'));
1280
- process.on('SIGTERM', () => shutdown('SIGTERM'));
1281
- }).catch((error) => {
1282
- hubLog.fatal({ err: error }, 'hub.start_failed');
1283
- process.exit(1);
1284
- });
1651
+ startHub({ port, dbPath })
1652
+ .then((info) => {
1653
+ const shutdown = async (signal) => {
1654
+ hubLog.info({ signal }, "hub.stopping");
1655
+ try {
1656
+ cleanupOrphanNodeProcesses();
1657
+ } catch {}
1658
+ try {
1659
+ cleanupStaleSpawnSessions(hubLog);
1660
+ } catch {}
1661
+ await info.stop();
1662
+ process.exit(0);
1663
+ };
1664
+ process.on("SIGINT", () => shutdown("SIGINT"));
1665
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
1666
+ })
1667
+ .catch((error) => {
1668
+ hubLog.fatal({ err: error }, "hub.start_failed");
1669
+ process.exit(1);
1670
+ });
1285
1671
  }
1286
1672
 
1287
1673
  export { startHub as createServer };