@triflux/remote 10.51.0 → 10.52.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.
Files changed (59) hide show
  1. package/cto/brief.mjs +0 -8
  2. package/cto/collect.mjs +1 -127
  3. package/cto/current.schema.json +2 -19
  4. package/cto/hygiene.mjs +2 -61
  5. package/cto/lake-root.mjs +0 -56
  6. package/cto/status.mjs +5 -139
  7. package/hub/index.mjs +0 -9
  8. package/hub/team/agents-row-attach.py +120 -0
  9. package/hub/team/agents-row.mjs +200 -0
  10. package/hub/team/claude-daemon-control.mjs +1 -2
  11. package/hub/team/claude-session-projection.mjs +5 -73
  12. package/hub/team/cli/commands/debug.mjs +1 -2
  13. package/hub/team/cli/commands/start/index.mjs +2 -1
  14. package/hub/team/cli/commands/start/start-mux.mjs +2 -4
  15. package/hub/team/cli/services/state-store.mjs +5 -20
  16. package/hub/team/headless.mjs +96 -543
  17. package/hub/team/psmux.mjs +3 -33
  18. package/hub/team/session-context.mjs +1 -1
  19. package/hub/team/tui-viewer.mjs +9 -56
  20. package/hub/team/tui.mjs +0 -37
  21. package/hub/team/uds-orchestrator.mjs +2 -580
  22. package/hub/workers/factory.mjs +6 -51
  23. package/hub/workers/interface.mjs +1 -7
  24. package/hub/workers/lib/jsonrpc-core.mjs +11 -0
  25. package/hub/workers/lib/jsonrpc-ws-uds.mjs +5 -6
  26. package/package.json +3 -8
  27. package/scripts/lib/agent-route-policy.mjs +0 -67
  28. package/scripts/lib/cli-claude.mjs +0 -12
  29. package/scripts/lib/env-probe.mjs +0 -70
  30. package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
  31. package/scripts/lib/mcp-guard-engine.mjs +40 -98
  32. package/hub/hub-lifecycle.mjs +0 -248
  33. package/hub/pipe.mjs +0 -825
  34. package/hub/promote-penalties.mjs +0 -138
  35. package/hub/public/dashboard.html +0 -416
  36. package/hub/schema.sql +0 -209
  37. package/hub/server.mjs +0 -3048
  38. package/hub/store-adapter.mjs +0 -182
  39. package/hub/store.mjs +0 -1736
  40. package/hub/team/cli/services/hub-client.mjs +0 -178
  41. package/hub/team/conductor-registry.mjs +0 -121
  42. package/hub/team/cto-auto-collect.mjs +0 -115
  43. package/hub/team/lead-control.mjs +0 -191
  44. package/hub/team/session-sync.mjs +0 -316
  45. package/hub/team/swarm-intent.mjs +0 -18
  46. package/hub/team/synapse-cli.mjs +0 -458
  47. package/hub/team/synapse-http.mjs +0 -311
  48. package/hub/team/synapse-registry.mjs +0 -618
  49. package/hub/team/tui-synapse.mjs +0 -223
  50. package/hub/team/worker-sandbox.mjs +0 -125
  51. package/hub/tools.mjs +0 -900
  52. package/hub/workers/codex-app-server-worker.mjs +0 -1067
  53. package/hub/workers/delegator-mcp.mjs +0 -1098
  54. package/hub/workers/lib/jsonrpc-stdio.mjs +0 -204
  55. package/scripts/lib/claudemd-scanner.mjs +0 -26
  56. package/scripts/lib/context.mjs +0 -51
  57. package/scripts/lib/handoff.mjs +0 -187
  58. package/scripts/lib/logger.mjs +0 -122
  59. package/scripts/lib/stealth-fetch.mjs +0 -176
package/hub/server.mjs DELETED
@@ -1,3048 +0,0 @@
1
- // hub/server.mjs — HTTP MCP + REST bridge + Named Pipe 서버 진입점
2
-
3
- import { execSync as execSyncHub } from "node:child_process";
4
- import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
5
- import { EventEmitter } from "node:events";
6
- import {
7
- existsSync,
8
- mkdirSync,
9
- readFileSync,
10
- unlinkSync,
11
- writeFileSync,
12
- } from "node:fs";
13
- import { createServer as createHttpServer } from "node:http";
14
- import { homedir } from "node:os";
15
- import { extname, join, resolve, sep } from "node:path";
16
- import { performance } from "node:perf_hooks";
17
- import { fileURLToPath } from "node:url";
18
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
19
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
20
- import {
21
- CallToolRequestSchema,
22
- ListToolsRequestSchema,
23
- } from "@modelcontextprotocol/sdk/types.js";
24
- import { runCollect } from "../cto/collect.mjs";
25
- import { createModuleLogger } from "../scripts/lib/logger.mjs";
26
- import { broker as brokerInstance, reloadBroker } from "@triflux/core/hub/account-broker.mjs";
27
- import { createAdaptiveEngine } from "@triflux/core/hub/adaptive.mjs";
28
- import { createAssignCallbackServer } from "@triflux/core/hub/assign-callbacks.mjs";
29
- import { DelegatorService } from "@triflux/core/hub/delegator/index.mjs";
30
- import { createHitlManager } from "@triflux/core/hub/hitl.mjs";
31
- import {
32
- reapExistingHubProcesses,
33
- resolveHubPortForContext,
34
- } from "./hub-lifecycle.mjs";
35
- import {
36
- cleanupOrphanNodeProcesses,
37
- cleanupOrphanRuntimeProcesses,
38
- cleanupStaleFsmonitorDaemons,
39
- } from "@triflux/core/hub/lib/process-utils.mjs";
40
- import * as spawnTrace from "@triflux/core/hub/lib/spawn-trace.mjs";
41
- import {
42
- clampDurationMs,
43
- DEFAULT_ASSIGN_TIMEOUT_MS,
44
- DEFAULT_ASSIGN_TTL_MS,
45
- DEFAULT_REGISTER_TIMEOUT_SEC,
46
- resolveWorkerLeaseTtlMs,
47
- } from "@triflux/core/hub/lib/timeout-defaults.mjs";
48
- import {
49
- recordRequest,
50
- recordWorker,
51
- snapshot as traceSnapshot,
52
- } from "@triflux/core/hub/lib/trace-recorder.mjs";
53
- import { logQuotaRefreshFailures } from "@triflux/core/hub/middleware/quota-middleware.mjs";
54
- import { wrapRequestHandler } from "@triflux/core/hub/middleware/request-logger.mjs";
55
- import { createPipeServer } from "./pipe.mjs";
56
- import { createRouter } from "@triflux/core/hub/router.mjs";
57
- import { createAdaptiveFingerprintService } from "@triflux/core/hub/session-fingerprint.mjs";
58
- import {
59
- acquireLock,
60
- getVersionHash,
61
- isServerHealthy,
62
- readState,
63
- releaseLock,
64
- writeState,
65
- } from "@triflux/core/hub/state.mjs";
66
- import { createStoreAdapter } from "./store-adapter.mjs";
67
- import { createCtoAutoCollector } from "./team/cto-auto-collect.mjs";
68
- import { nativeProxy } from "./team/nativeProxy.mjs";
69
- import {
70
- createSynapseRegistry,
71
- projectPeer,
72
- } from "./team/synapse-registry.mjs";
73
- import { registerTeamBridge } from "@triflux/core/hub/team-bridge.mjs";
74
- import { createTools } from "./tools.mjs";
75
- import { createDelegatorMcpWorker } from "./workers/delegator-mcp.mjs";
76
-
77
- registerTeamBridge(nativeProxy);
78
-
79
- const hubLog = createModuleLogger("hub");
80
- const brokerDiagnosticsAttached = new WeakSet();
81
-
82
- const MAX_BODY_SIZE = 1024 * 1024;
83
- const PUBLIC_PATHS = new Set(["/", "/status", "/health", "/healthz"]);
84
- const RATE_LIMIT_MAX = 100; // requests per window
85
- const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute sliding window
86
- const LOOPBACK_REMOTE_ADDRESSES = new Set([
87
- "127.0.0.1",
88
- "::1",
89
- "::ffff:127.0.0.1",
90
- ]);
91
- const ALLOWED_ORIGIN_RE =
92
- /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
93
- const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
94
- const PUBLIC_DIR = resolve(join(PROJECT_ROOT, "hub", "public"));
95
- const CACHE_DIR = join(homedir(), ".claude", "cache");
96
- const HUB_DEFAULT_PORT = 27888;
97
- const BATCH_EVENTS_PATH = join(CACHE_DIR, "batch-events.jsonl");
98
- const SV_ACCUMULATOR_PATH = join(CACHE_DIR, "sv-accumulator.json");
99
- const CODEX_RATE_LIMITS_CACHE_PATH = join(
100
- CACHE_DIR,
101
- "codex-rate-limits-cache.json",
102
- );
103
- const GEMINI_QUOTA_CACHE_PATH = join(CACHE_DIR, "gemini-quota-cache.json");
104
- const CLAUDE_USAGE_CACHE_PATH = join(CACHE_DIR, "claude-usage-cache.json");
105
- const AIMD_WINDOW_MS = 30 * 60 * 1000;
106
- const AIMD_INITIAL_BATCH_SIZE = 3;
107
- const AIMD_MIN_BATCH_SIZE = 1;
108
- const AIMD_MAX_BATCH_SIZE = 10;
109
- const HUB_IDLE_TIMEOUT_DEFAULT_MS = 30 * 60 * 1000;
110
- const HUB_IDLE_SWEEP_DEFAULT_MS = 60 * 1000;
111
- const STATIC_CONTENT_TYPES = Object.freeze({
112
- ".html": "text/html",
113
- ".css": "text/css",
114
- ".js": "application/javascript",
115
- ".png": "image/png",
116
- });
117
-
118
- // IP-based sliding window rate limiter (in-memory, no external deps)
119
- // Each entry is an array of request timestamps within the current window.
120
- const rateLimitMap = new Map();
121
-
122
- function formatHostForUrl(host = "127.0.0.1") {
123
- return String(host).includes(":") ? `[${host}]` : host;
124
- }
125
-
126
- function buildHubUrl(host, port) {
127
- return `http://${formatHostForUrl(host || "127.0.0.1")}:${port}/mcp`;
128
- }
129
-
130
- function parseHubPort(value) {
131
- const parsed = Number.parseInt(String(value ?? ""), 10);
132
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
133
- }
134
-
135
- function isPidAlive(pid, killFn = process.kill) {
136
- const resolvedPid = Number(pid);
137
- if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return false;
138
- try {
139
- killFn(resolvedPid, 0);
140
- return true;
141
- } catch {
142
- return false;
143
- }
144
- }
145
-
146
- export async function tryReuseExistingHub({
147
- port,
148
- portSpecified = Number.isFinite(Number(port)) && Number(port) > 0,
149
- host = "127.0.0.1",
150
- readCurrentState = readState,
151
- readInfo = getHubInfo,
152
- checkHealth = isServerHealthy,
153
- killFn = process.kill,
154
- detectPeer = detectLivePeer,
155
- log = hubLog,
156
- } = {}) {
157
- const existing = readCurrentState();
158
- const existingPort = Number(existing?.port);
159
- const requestedPort = parseHubPort(port) ?? HUB_DEFAULT_PORT;
160
- const livePeer = detectPeer();
161
- const livePidPort = parseHubPort(livePeer?.port);
162
- if (
163
- !isPidAlive(existing?.pid, killFn) ||
164
- !Number.isFinite(existingPort) ||
165
- existingPort <= 0
166
- ) {
167
- return null;
168
- }
169
- if (existingPort !== requestedPort) {
170
- log.warn(
171
- {
172
- requestedPort,
173
- existingPort,
174
- pid: livePeer.pid,
175
- livePidPort,
176
- },
177
- "hub.port_mismatch_not_reusing_live_pid",
178
- );
179
- return null;
180
- }
181
- if (!(await checkHealth(existingPort))) return null;
182
-
183
- const info = readInfo() ?? existing;
184
- const infoHost = typeof info?.host === "string" ? info.host : host;
185
- return {
186
- reused: true,
187
- external: true,
188
- port: existingPort,
189
- pid: Number(existing.pid),
190
- url: info?.url ?? buildHubUrl(infoHost, existingPort),
191
- stop: async () => false,
192
- };
193
- }
194
-
195
- function checkRateLimit(ip) {
196
- const now = Date.now();
197
- const cutoff = now - RATE_LIMIT_WINDOW_MS;
198
- const timestamps = (rateLimitMap.get(ip) || []).filter((t) => t >= cutoff);
199
- if (timestamps.length >= RATE_LIMIT_MAX) {
200
- // Oldest timestamp in window tells us when a slot frees up
201
- const retryAfterMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now;
202
- rateLimitMap.set(ip, timestamps);
203
- return { allowed: false, retryAfterSec: Math.ceil(retryAfterMs / 1000) };
204
- }
205
- rateLimitMap.set(ip, [...timestamps, now]);
206
- return { allowed: true, retryAfterSec: 0 };
207
- }
208
-
209
- function isInitializeRequest(body) {
210
- if (body?.method === "initialize") return true;
211
- if (Array.isArray(body))
212
- return body.some((message) => message.method === "initialize");
213
- return false;
214
- }
215
-
216
- function parseTfxClientHeader(value) {
217
- const raw = Array.isArray(value) ? value[0] : value;
218
- const client = String(raw || "unknown").toLowerCase();
219
- return ["codex", "claude", "antigravity", "gemini"].includes(client)
220
- ? client
221
- : "unknown";
222
- }
223
-
224
- async function parseBody(req) {
225
- const chunks = [];
226
- let size = 0;
227
- for await (const chunk of req) {
228
- size += chunk.length;
229
- if (size > MAX_BODY_SIZE) {
230
- throw Object.assign(new Error("Body too large"), { statusCode: 413 });
231
- }
232
- chunks.push(chunk);
233
- }
234
- return JSON.parse(Buffer.concat(chunks).toString());
235
- }
236
-
237
- const PID_DIR =
238
- process.env.TFX_HUB_PID_DIR?.trim() ||
239
- join(homedir(), ".claude", "cache", "tfx-hub");
240
- const PID_FILE = join(PID_DIR, "hub.pid");
241
- const TOKEN_FILE = join(homedir(), ".claude", ".tfx-hub-token");
242
-
243
- function readHubPidFile(
244
- pidFilePath = PID_FILE,
245
- { exists = existsSync, readFile = readFileSync } = {},
246
- ) {
247
- if (!exists(pidFilePath)) {
248
- return { exists: false, info: null, error: null };
249
- }
250
-
251
- try {
252
- return {
253
- exists: true,
254
- info: JSON.parse(readFile(pidFilePath, "utf8")),
255
- error: null,
256
- };
257
- } catch (error) {
258
- return { exists: true, info: null, error };
259
- }
260
- }
261
-
262
- export function resolveHubPort(env = process.env, opts = {}) {
263
- return resolveHubPortForContext({
264
- env,
265
- cwd: opts.cwd ?? process.cwd(),
266
- defaultPort: HUB_DEFAULT_PORT,
267
- });
268
- }
269
-
270
- export function detectLivePeer(
271
- pidFilePath = PID_FILE,
272
- { exists = existsSync, readFile = readFileSync, killFn = process.kill } = {},
273
- ) {
274
- const state = readHubPidFile(pidFilePath, { exists, readFile });
275
- const info = state.info ?? null;
276
- const pid = Number(info?.pid);
277
- const port = Number(info?.port);
278
- const base = {
279
- alive: false,
280
- pid: Number.isFinite(pid) && pid > 0 ? pid : undefined,
281
- port: Number.isFinite(port) && port > 0 ? port : undefined,
282
- version: typeof info?.version === "string" ? info.version : undefined,
283
- host: typeof info?.host === "string" ? info.host : undefined,
284
- url: typeof info?.url === "string" ? info.url : undefined,
285
- reason: "missing",
286
- };
287
-
288
- if (!state.exists) return base;
289
- if (state.error) return { ...base, reason: "invalid_json" };
290
- if (!Number.isFinite(pid) || pid <= 0) {
291
- return { ...base, reason: "invalid_pid" };
292
- }
293
-
294
- if (isPidAlive(pid, killFn)) {
295
- return {
296
- alive: true,
297
- pid,
298
- port: base.port,
299
- version: base.version,
300
- host: base.host,
301
- url: base.url,
302
- reason: "alive",
303
- };
304
- }
305
-
306
- return { ...base, reason: "dead" };
307
- }
308
-
309
- export function cleanStaleHubPid(
310
- pidFilePath = PID_FILE,
311
- {
312
- exists = existsSync,
313
- readFile = readFileSync,
314
- unlink = unlinkSync,
315
- killFn = process.kill,
316
- } = {},
317
- ) {
318
- const peer = detectLivePeer(pidFilePath, { exists, readFile, killFn });
319
- if (peer.alive) {
320
- return { cleaned: false, reason: "alive", pid: peer.pid };
321
- }
322
- if (peer.reason === "missing") {
323
- return { cleaned: false, reason: "missing" };
324
- }
325
-
326
- try {
327
- unlink(pidFilePath);
328
- return {
329
- cleaned: true,
330
- reason: peer.reason === "dead" ? "stale_pid" : peer.reason,
331
- pid: peer.pid,
332
- };
333
- } catch (error) {
334
- return {
335
- cleaned: false,
336
- reason: "unlink_failed",
337
- pid: peer.pid,
338
- error,
339
- };
340
- }
341
- }
342
-
343
- async function syncHubMcpSettingsIfAvailable({ hubUrl }) {
344
- try {
345
- const mod = await import(
346
- new URL("../scripts/sync-hub-mcp-settings.mjs", import.meta.url)
347
- );
348
- const protectedMcpSyncEnv = isProtectedImplicitMcpSyncEnv();
349
- if (typeof mod?.syncHubMcpSettings === "function") {
350
- if (process.env.TFX_USER_MCP_SYNC === "1" || !protectedMcpSyncEnv) {
351
- await mod.syncHubMcpSettings({ hubUrl });
352
- } else {
353
- hubLog.debug({ hubUrl }, "hub.user_mcp_sync_skipped_protected_env");
354
- }
355
- } else {
356
- hubLog.warn({ hubUrl }, "hub.mcp_sync_missing_export");
357
- }
358
- if (typeof mod?.syncCodexHubUrl === "function") {
359
- await mod.syncCodexHubUrl({ hubUrl });
360
- }
361
- const allowProjectSync =
362
- process.env.TFX_PROJECT_MCP_SYNC === "1" || !protectedMcpSyncEnv;
363
- if (allowProjectSync && typeof mod?.syncProjectMcpJson === "function") {
364
- await mod.syncProjectMcpJson({ hubUrl, projectRoot: PROJECT_ROOT });
365
- }
366
- } catch (error) {
367
- const message = error?.message || String(error);
368
- if (error?.code === "ERR_MODULE_NOT_FOUND") {
369
- hubLog.warn({ hubUrl, err: message }, "hub.mcp_sync_missing");
370
- return;
371
- }
372
- hubLog.warn({ hubUrl, err: message }, "hub.mcp_sync_skipped");
373
- }
374
- }
375
-
376
- function isProtectedImplicitMcpSyncEnv(env = process.env) {
377
- return (
378
- env.NODE_ENV === "test" ||
379
- env.CI === "true" ||
380
- env.TFX_TEST === "1" ||
381
- Boolean(env.TRIFLUX_TEST_HOME) ||
382
- Boolean(env.NODE_TEST_CONTEXT) ||
383
- Boolean(env.NODE_TEST_WORKER_ID)
384
- );
385
- }
386
-
387
- function findListeningPidByPort(
388
- port,
389
- { platform = process.platform, execSync = execSyncHub } = {},
390
- ) {
391
- const targetPort = parseHubPort(port);
392
- if (!targetPort) return null;
393
-
394
- const execOptions = {
395
- encoding: "utf8",
396
- timeout: 5000,
397
- stdio: ["ignore", "pipe", "ignore"],
398
- windowsHide: true,
399
- };
400
-
401
- try {
402
- if (platform === "win32") {
403
- const output = execSync("netstat -ano -p tcp", execOptions);
404
- for (const line of output.split(/\r?\n/)) {
405
- const match = line
406
- .trim()
407
- .match(/^(TCP|UDP)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d+)$/i);
408
- if (!match) continue;
409
- const [, protocol, localAddress, , state, pidText] = match;
410
- if (protocol.toUpperCase() !== "TCP") continue;
411
- if (state.toUpperCase() !== "LISTENING") continue;
412
- if (localAddress.split(":").pop() !== String(targetPort)) continue;
413
- const pid = Number.parseInt(pidText, 10);
414
- if (Number.isFinite(pid) && pid > 0) return pid;
415
- }
416
- return null;
417
- }
418
-
419
- try {
420
- const output = execSync(
421
- `lsof -nP -iTCP:${targetPort} -sTCP:LISTEN -t`,
422
- execOptions,
423
- );
424
- const pid = Number.parseInt(output.trim().split(/\r?\n/)[0] ?? "", 10);
425
- return Number.isFinite(pid) && pid > 0 ? pid : null;
426
- } catch {
427
- const output = execSync(`ss -ltnp 'sport = :${targetPort}'`, execOptions);
428
- const match = output.match(/pid=(\d+)/);
429
- const pid = Number.parseInt(match?.[1] ?? "", 10);
430
- return Number.isFinite(pid) && pid > 0 ? pid : null;
431
- }
432
- } catch {
433
- return null;
434
- }
435
- }
436
-
437
- async function resolvePortInUse({
438
- port,
439
- host,
440
- version,
441
- pidFilePath = PID_FILE,
442
- detectPeer = detectLivePeer,
443
- cleanPid = cleanStaleHubPid,
444
- lookupListeningPid = findListeningPidByPort,
445
- } = {}) {
446
- const peer = detectPeer(pidFilePath);
447
- const ownerPid = lookupListeningPid(port);
448
- const ownerHint = `pid=${ownerPid ?? "unknown"}`;
449
- if (peer.alive) {
450
- const peerPort = Number(peer.port);
451
- const sameHub =
452
- peerPort === Number(port) &&
453
- typeof peer.version === "string" &&
454
- peer.version === version;
455
- if (sameHub) {
456
- return {
457
- action: "reuse",
458
- peer,
459
- url: peer.url ?? buildHubUrl(peer.host || host, peerPort),
460
- };
461
- }
462
-
463
- const peerDetails = [
464
- `hub.pid pid=${peer.pid ?? "unknown"}`,
465
- `hub.pid port=${peerPort || "unknown"}`,
466
- `hub.pid version=${peer.version ?? "unknown"}`,
467
- ownerHint,
468
- ].join(", ");
469
- return {
470
- action: "error",
471
- message: `Hub 포트 ${port}이 이미 사용 중입니다 (${peerDetails}). fallback port는 허용되지 않습니다. \`tfx hub stop\` 후 재시도하세요.`,
472
- };
473
- }
474
-
475
- const cleaned = cleanPid(pidFilePath);
476
- if (cleaned.cleaned) {
477
- return { action: "retry", cleaned, peer };
478
- }
479
-
480
- return {
481
- action: "error",
482
- message: `Hub 포트 ${port}이 이미 사용 중입니다 (${ownerHint}). fallback port는 허용되지 않습니다. \`tfx hub stop\` 후 재시도하세요. (PID file: ${pidFilePath})`,
483
- };
484
- }
485
-
486
- function isPublicPath(path) {
487
- return (
488
- PUBLIC_PATHS.has(path) ||
489
- path === "/dashboard" ||
490
- path === "/api/qos-stats" ||
491
- path.startsWith("/public/")
492
- );
493
- }
494
-
495
- function isAllowedOrigin(origin) {
496
- return origin && ALLOWED_ORIGIN_RE.test(origin);
497
- }
498
-
499
- function getRequestPath(url = "/") {
500
- try {
501
- return new URL(url, "http://127.0.0.1").pathname;
502
- } catch {
503
- return String(url).replace(/\?.*/, "") || "/";
504
- }
505
- }
506
-
507
- export function isLoopbackRemoteAddress(remoteAddress) {
508
- return (
509
- typeof remoteAddress === "string" &&
510
- LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress)
511
- );
512
- }
513
-
514
- function extractBearerToken(req) {
515
- const authHeader =
516
- typeof req.headers.authorization === "string"
517
- ? req.headers.authorization
518
- : "";
519
- return authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
520
- }
521
-
522
- function writeJson(res, statusCode, body, headers = {}) {
523
- res.writeHead(statusCode, {
524
- "Content-Type": "application/json",
525
- ...headers,
526
- });
527
- res.end(JSON.stringify(body));
528
- }
529
-
530
- function applyCorsHeaders(req, res) {
531
- const origin =
532
- typeof req.headers.origin === "string" ? req.headers.origin : "";
533
- if (origin) {
534
- res.setHeader("Vary", "Origin");
535
- }
536
- if (!isAllowedOrigin(origin)) return false;
537
-
538
- res.setHeader("Access-Control-Allow-Origin", origin);
539
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
540
- res.setHeader(
541
- "Access-Control-Allow-Headers",
542
- "Content-Type, Authorization, mcp-session-id, Last-Event-ID",
543
- );
544
- return true;
545
- }
546
-
547
- function safeTokenCompare(a, b) {
548
- const ha = createHash("sha256").update(a).digest();
549
- const hb = createHash("sha256").update(b).digest();
550
- return timingSafeEqual(ha, hb);
551
- }
552
-
553
- export function isAuthorizedRequest(req, path, hubToken) {
554
- if (!hubToken) {
555
- return isLoopbackRemoteAddress(req.socket.remoteAddress);
556
- }
557
- if (isPublicPath(path)) return true;
558
- const supplied = extractBearerToken(req);
559
- if (!supplied) return false;
560
- return safeTokenCompare(supplied, hubToken);
561
- }
562
-
563
- function resolveTeamStatusCode(result) {
564
- if (result?.ok) return 200;
565
- const code = result?.error?.code;
566
- if (
567
- code === "TEAM_NOT_FOUND" ||
568
- code === "TASK_NOT_FOUND" ||
569
- code === "TASKS_DIR_NOT_FOUND"
570
- )
571
- return 404;
572
- if (code === "CLAIM_CONFLICT" || code === "MTIME_CONFLICT") return 409;
573
- if (
574
- code === "INVALID_TEAM_NAME" ||
575
- code === "INVALID_TASK_ID" ||
576
- code === "INVALID_TEXT" ||
577
- code === "INVALID_FROM" ||
578
- code === "INVALID_STATUS"
579
- )
580
- return 400;
581
- return 500;
582
- }
583
-
584
- function resolvePipelineStatusCode(result) {
585
- if (result?.ok) return 200;
586
- if (result?.error === "pipeline_not_found") return 404;
587
- if (result?.error === "hub_db_not_found") return 503;
588
- return 400;
589
- }
590
-
591
- function resolveSendInputStatusCode(result) {
592
- if (result?.ok) return 200;
593
- const code = result?.error?.code;
594
- if (code === "CONDUCTOR_REGISTRY_NOT_AVAILABLE") return 503;
595
- if (code === "CONDUCTOR_SESSION_NOT_FOUND") return 404;
596
- if (code === "SEND_INPUT_FAILED") return 409;
597
- return 400;
598
- }
599
-
600
- function normalizeBridgePayload(payload) {
601
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
602
- return {};
603
- }
604
- return payload;
605
- }
606
-
607
- function normalizeHandoffBody(body) {
608
- const payload = normalizeBridgePayload(body?.payload);
609
- return {
610
- ...payload,
611
- ...body,
612
- from: body?.from,
613
- to: body?.to,
614
- payload,
615
- };
616
- }
617
-
618
- function normalizePublishBody(body) {
619
- const payload = normalizeBridgePayload(body?.payload);
620
- const type = body?.type || body?.message_type || "event";
621
- return {
622
- ...payload,
623
- ...body,
624
- from: body?.from,
625
- to: body?.to,
626
- type,
627
- message_type: type,
628
- payload,
629
- };
630
- }
631
-
632
- function safeReadJsonFile(filePath) {
633
- try {
634
- if (!existsSync(filePath)) return null;
635
- return JSON.parse(readFileSync(filePath, "utf8"));
636
- } catch {
637
- return null;
638
- }
639
- }
640
-
641
- function parsePositiveInt(value, fallback) {
642
- const parsed = Number.parseInt(String(value ?? ""), 10);
643
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
644
- }
645
-
646
- export function resolveHubIdleTimeoutMs({
647
- port,
648
- env = process.env,
649
- defaultPort = HUB_DEFAULT_PORT,
650
- } = {}) {
651
- const parsed = Number.parseInt(
652
- String(env?.TFX_HUB_IDLE_TIMEOUT_MS ?? ""),
653
- 10,
654
- );
655
- if (Number.isFinite(parsed) && parsed >= 0) return parsed;
656
- return Number(port) === defaultPort ? 0 : HUB_IDLE_TIMEOUT_DEFAULT_MS;
657
- }
658
-
659
- export function cleanupOwnedPidFile({
660
- pidFilePath = PID_FILE,
661
- currentPid = process.pid,
662
- readFile = readFileSync,
663
- unlink = unlinkSync,
664
- } = {}) {
665
- try {
666
- const info = JSON.parse(readFile(pidFilePath, "utf8"));
667
- if (Number(info?.pid) !== Number(currentPid)) return false;
668
- unlink(pidFilePath);
669
- return true;
670
- } catch {
671
- return false;
672
- }
673
- }
674
-
675
- export function cleanupOwnedTokenFile({
676
- tokenFilePath = TOKEN_FILE,
677
- token,
678
- port = HUB_DEFAULT_PORT,
679
- readFile = readFileSync,
680
- unlink = unlinkSync,
681
- defaultPort = HUB_DEFAULT_PORT,
682
- } = {}) {
683
- if (Number(port) !== Number(defaultPort)) return false;
684
- const normalizedToken = String(token ?? "").trim();
685
- if (!normalizedToken) return false;
686
- try {
687
- if (String(readFile(tokenFilePath, "utf8")).trim() !== normalizedToken) {
688
- return false;
689
- }
690
- unlink(tokenFilePath);
691
- return true;
692
- } catch {
693
- return false;
694
- }
695
- }
696
-
697
- export function writeOwnedTokenFile({
698
- tokenFilePath = TOKEN_FILE,
699
- tokenDir = join(homedir(), ".claude"),
700
- token,
701
- mkdir = mkdirSync,
702
- writeFile = writeFileSync,
703
- } = {}) {
704
- const normalizedToken = String(token ?? "").trim();
705
- if (!normalizedToken) return false;
706
- mkdir(tokenDir, { recursive: true });
707
- writeFile(tokenFilePath, normalizedToken, { mode: 0o600 });
708
- return true;
709
- }
710
-
711
- function readRecentAimdEvents(now = Date.now()) {
712
- try {
713
- if (!existsSync(BATCH_EVENTS_PATH)) return [];
714
- const cutoff = now - AIMD_WINDOW_MS;
715
- return readFileSync(BATCH_EVENTS_PATH, "utf8")
716
- .split(/\r?\n/)
717
- .filter(Boolean)
718
- .map((line) => {
719
- try {
720
- return JSON.parse(line);
721
- } catch {
722
- return null;
723
- }
724
- })
725
- .filter((event) => {
726
- const timestamp = Number(event?.ts ?? event?.timestamp ?? 0);
727
- return event && Number.isFinite(timestamp) && timestamp >= cutoff;
728
- });
729
- } catch {
730
- return [];
731
- }
732
- }
733
-
734
- function calculateAimdBatchSize(events) {
735
- let batchSize = AIMD_INITIAL_BATCH_SIZE;
736
-
737
- for (const event of events) {
738
- const result = event?.result;
739
- if (result === "success" || result === "success_with_warnings") {
740
- batchSize = Math.min(AIMD_MAX_BATCH_SIZE, batchSize + 1);
741
- } else if (result === "failed" || result === "timeout") {
742
- batchSize = Math.max(AIMD_MIN_BATCH_SIZE, batchSize * 0.5);
743
- }
744
- }
745
-
746
- return batchSize;
747
- }
748
-
749
- function getQosStatsPayload() {
750
- const events = readRecentAimdEvents();
751
- return {
752
- aimd: {
753
- batchSize: calculateAimdBatchSize(events),
754
- events,
755
- },
756
- accumulator: safeReadJsonFile(SV_ACCUMULATOR_PATH),
757
- codex: safeReadJsonFile(CODEX_RATE_LIMITS_CACHE_PATH),
758
- gemini: safeReadJsonFile(GEMINI_QUOTA_CACHE_PATH),
759
- claude: safeReadJsonFile(CLAUDE_USAGE_CACHE_PATH),
760
- };
761
- }
762
-
763
- export function sanitizeBrokerDiagnosticEvent(event = {}) {
764
- const diagnostic = {
765
- accountId: event.accountId ?? event.id,
766
- provider: event.provider,
767
- direction: event.direction,
768
- reason: event.reason,
769
- error: event.error,
770
- };
771
- return Object.fromEntries(
772
- Object.entries(diagnostic).filter(([, value]) => value !== undefined),
773
- );
774
- }
775
-
776
- export function attachBrokerDiagnostics(currentBroker, logger = hubLog) {
777
- if (
778
- !currentBroker ||
779
- typeof currentBroker.on !== "function" ||
780
- brokerDiagnosticsAttached.has(currentBroker)
781
- ) {
782
- return false;
783
- }
784
-
785
- currentBroker.on("securityViolation", (event) => {
786
- logger.warn(
787
- { brokerEvent: sanitizeBrokerDiagnosticEvent(event) },
788
- "broker.security_violation",
789
- );
790
- });
791
- currentBroker.on("authSyncError", (event) => {
792
- logger.warn(
793
- { brokerEvent: sanitizeBrokerDiagnosticEvent(event) },
794
- "broker.auth_sync_error",
795
- );
796
- });
797
-
798
- brokerDiagnosticsAttached.add(currentBroker);
799
- return true;
800
- }
801
-
802
- function syncBrokerAuthCache(currentBroker, logger = hubLog) {
803
- if (
804
- !currentBroker?.snapshot ||
805
- typeof currentBroker.syncAuthFromSource !== "function"
806
- ) {
807
- return [];
808
- }
809
-
810
- const authAccounts = currentBroker
811
- .snapshot()
812
- .filter(
813
- (account) => account.provider === "codex" && account.mode === "auth",
814
- );
815
-
816
- return authAccounts.map((account) => {
817
- try {
818
- const result = currentBroker.syncAuthFromSource(account.id);
819
- if (result?.copied) {
820
- logger.info(
821
- {
822
- accountId: account.id,
823
- reason: result.reason,
824
- sourcePath: result.sourcePath,
825
- cachePath: result.cachePath,
826
- },
827
- "broker.auth_sync_from_source",
828
- );
829
- }
830
- return result;
831
- } catch (error) {
832
- logger.warn(
833
- { accountId: account.id, err: error?.message || String(error) },
834
- "broker.auth_sync_from_source_failed",
835
- );
836
- return {
837
- ok: false,
838
- accountId: account.id,
839
- reason: error?.message || String(error),
840
- };
841
- }
842
- });
843
- }
844
-
845
- function getBrokerPublicSnapshot(currentBroker = brokerInstance) {
846
- if (!currentBroker || typeof currentBroker.publicSnapshot !== "function") {
847
- return [];
848
- }
849
- return currentBroker.publicSnapshot();
850
- }
851
-
852
- function resolvePublicFilePath(path) {
853
- let relativePath = null;
854
- if (path === "/dashboard") {
855
- relativePath = "dashboard.html";
856
- } else if (path.startsWith("/public/")) {
857
- relativePath = path.slice("/public/".length);
858
- }
859
-
860
- if (!relativePath) return null;
861
-
862
- try {
863
- relativePath = decodeURIComponent(relativePath).replace(/^[/\\]+/, "");
864
- } catch {
865
- return null;
866
- }
867
-
868
- const filePath = resolve(PUBLIC_DIR, relativePath);
869
- const publicPrefix = `${PUBLIC_DIR}${sep}`;
870
- if (filePath !== PUBLIC_DIR && !filePath.startsWith(publicPrefix)) {
871
- return null;
872
- }
873
- return filePath;
874
- }
875
-
876
- function servePublicFile(res, path) {
877
- const filePath = resolvePublicFilePath(path);
878
- if (!filePath) return false;
879
-
880
- mkdirSync(PUBLIC_DIR, { recursive: true });
881
- if (!existsSync(filePath)) {
882
- hubLog.warn({ filePath }, "static.not_found");
883
- res.writeHead(404);
884
- res.end("Not Found (static file missing)");
885
- return true;
886
- }
887
-
888
- try {
889
- const body = readFileSync(filePath);
890
- res.writeHead(200, {
891
- "Content-Type":
892
- STATIC_CONTENT_TYPES[extname(filePath).toLowerCase()] ||
893
- "application/octet-stream",
894
- });
895
- res.end(body);
896
- } catch {
897
- res.writeHead(404);
898
- res.end("Not Found");
899
- }
900
- return true;
901
- }
902
-
903
- /**
904
- * tfx-hub 시작
905
- * @param {object} opts
906
- * @param {number} [opts.port]
907
- * @param {string} [opts.dbPath]
908
- * @param {string} [opts.host]
909
- * @param {string|number} [opts.sessionId]
910
- * @param {(options: { cwd: string }) => object} [opts.createDelegatorWorker]
911
- * @param {(dbPath: string) => object|Promise<object>} [opts.createStoreAdapterFn] test seam
912
- */
913
- export async function startHub({
914
- port: portOpt,
915
- dbPath,
916
- host = "127.0.0.1",
917
- sessionId = process.pid,
918
- createDelegatorWorker = createDelegatorMcpWorker,
919
- createStoreAdapterFn = createStoreAdapter,
920
- } = {}) {
921
- const resolvedPort = parseHubPort(portOpt);
922
- const portSpecified = Number.isFinite(resolvedPort);
923
- const ephemeralPort = resolvedPort === 0;
924
- const livePeer = detectLivePeer();
925
- const livePidPort = parseHubPort(livePeer?.port);
926
- let port = portSpecified
927
- ? resolvedPort
928
- : resolveHubPort(process.env, {
929
- detectPeer: () => livePeer,
930
- });
931
-
932
- if (
933
- portSpecified &&
934
- !ephemeralPort &&
935
- livePeer.alive &&
936
- livePidPort &&
937
- port !== livePidPort
938
- ) {
939
- hubLog.warn(
940
- {
941
- requestedPort: port,
942
- livePid: livePeer.pid,
943
- livePidPort,
944
- },
945
- "hub.port_override_pid_mismatch",
946
- );
947
- }
948
-
949
- const existingHub = ephemeralPort
950
- ? null
951
- : await tryReuseExistingHub({
952
- port,
953
- portSpecified,
954
- host,
955
- detectPeer: () => livePeer,
956
- });
957
- if (existingHub) return existingHub;
958
-
959
- attachBrokerDiagnostics(brokerInstance);
960
- syncBrokerAuthCache(brokerInstance);
961
-
962
- const hubIdleTimeoutMs = resolveHubIdleTimeoutMs({ port, env: process.env });
963
- const hubIdleSweepMs = parsePositiveInt(
964
- process.env.TFX_HUB_IDLE_SWEEP_MS,
965
- Math.min(HUB_IDLE_SWEEP_DEFAULT_MS, hubIdleTimeoutMs),
966
- );
967
- let lastRequestAt = Date.now();
968
- const markRequestActivity = () => {
969
- lastRequestAt = Date.now();
970
- };
971
-
972
- if (!dbPath) {
973
- // DB를 npm 패키지 밖에 저장하여 npm update 시 EBUSY 방지
974
- // 기존: PROJECT_ROOT/.tfx/state/state.db (패키지 내부 → 락 충돌)
975
- // 변경: ~/.claude/cache/tfx-hub/state.db (패키지 외부 → 안전)
976
- const hubCacheDir = PID_DIR;
977
- mkdirSync(hubCacheDir, { recursive: true });
978
- dbPath = join(hubCacheDir, "state.db");
979
- }
980
-
981
- mkdirSync(PUBLIC_DIR, { recursive: true });
982
-
983
- const version = getVersionHash();
984
- const startedAtMs = Date.now();
985
- const startedAt = new Date(startedAtMs).toISOString();
986
- await acquireLock();
987
- let lockHeld = true;
988
- const releaseStartupLock = () => {
989
- if (!lockHeld) return;
990
- releaseLock();
991
- lockHeld = false;
992
- };
993
-
994
- const lockedExistingHub = ephemeralPort
995
- ? null
996
- : await tryReuseExistingHub({
997
- port,
998
- portSpecified,
999
- host,
1000
- detectPeer: () => detectLivePeer(),
1001
- });
1002
- if (lockedExistingHub) {
1003
- releaseStartupLock();
1004
- return lockedExistingHub;
1005
- }
1006
-
1007
- const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
1008
- if (!ephemeralPort && HUB_TOKEN) {
1009
- writeOwnedTokenFile({ token: HUB_TOKEN, port });
1010
- } else if (!ephemeralPort) {
1011
- cleanupOwnedTokenFile({ token: HUB_TOKEN, port });
1012
- }
1013
-
1014
- const store = await createStoreAdapterFn(dbPath);
1015
- const router = createRouter(store);
1016
- const fingerprintService = createAdaptiveFingerprintService({ store });
1017
-
1018
- // Neural Memory adaptive engine 초기화
1019
- const adaptiveEngine = createAdaptiveEngine({
1020
- repoRoot: PROJECT_ROOT,
1021
- fingerprintService,
1022
- });
1023
- adaptiveEngine.startSession();
1024
-
1025
- // safety-guard → reflexion 패널티 승격 + 적응형 규칙 유지보수
1026
- const projectSlug = PROJECT_ROOT.split(/[\\/]/).pop();
1027
- try {
1028
- const { promotePenalties } = await import("./promote-penalties.mjs");
1029
- const result = promotePenalties(store, { projectSlug });
1030
- if (result.promoted > 0) {
1031
- console.log(
1032
- `[reflexion] ${result.promoted} penalties promoted to adaptive rules`,
1033
- );
1034
- }
1035
- } catch {
1036
- /* promote-penalties 실패는 Hub 시작을 막지 않음 */
1037
- }
1038
-
1039
- // stale adaptive_rules 정리 (30일 초과 + confidence 0.2 미만)
1040
- try {
1041
- const pruned = store.pruneStaleRules();
1042
- if (pruned > 0)
1043
- console.log(`[reflexion] ${pruned} stale adaptive rules pruned`);
1044
- } catch {
1045
- /* prune 실패 무시 */
1046
- }
1047
-
1048
- // adaptive rule confidence decay (7일 이상 미관측 규칙 -0.1 감소)
1049
- try {
1050
- const { decayRules } = await import("@triflux/core/hub/reflexion.mjs");
1051
- const decay = decayRules(
1052
- store,
1053
- adaptiveEngine.sessionCount?.() || 1,
1054
- projectSlug,
1055
- );
1056
- if (decay.deleted.length > 0)
1057
- console.log(
1058
- `[reflexion] ${decay.deleted.length} low-confidence rules removed`,
1059
- );
1060
- } catch {
1061
- /* decay 실패 무시 */
1062
- }
1063
-
1064
- // Delegator MCP resident service 초기화
1065
- const delegatorWorker = createDelegatorWorker({ cwd: PROJECT_ROOT });
1066
- try {
1067
- await delegatorWorker.start();
1068
- } catch (error) {
1069
- releaseStartupLock();
1070
- throw error;
1071
- }
1072
- const delegatorService = new DelegatorService({ worker: delegatorWorker });
1073
-
1074
- // Synapse Layer 4: session registry
1075
- const synapseEmitter = new EventEmitter();
1076
- synapseEmitter.setMaxListeners(50);
1077
- const synapseRegistry = createSynapseRegistry({
1078
- persistPath: join(CACHE_DIR, "tfx-hub", "synapse-sessions.json"),
1079
- emitter: synapseEmitter,
1080
- });
1081
- const ctoAutoCollector = createCtoAutoCollector({
1082
- registry: synapseRegistry,
1083
- runCollect,
1084
- logger: hubLog,
1085
- });
1086
-
1087
- // Synapse Layer 5: emitter subscribers — bridge events to hub logging
1088
- synapseEmitter.on("synapse.session.started", ({ sessionId, session }) => {
1089
- hubLog.info({ sessionId }, "synapse.session.started");
1090
- ctoAutoCollector.handleSessionStarted({ sessionId, session });
1091
- });
1092
- synapseEmitter.on("synapse.session.heartbeat", ({ sessionId }) => {
1093
- hubLog.debug({ sessionId }, "synapse.session.heartbeat");
1094
- });
1095
- synapseEmitter.on("synapse.session.stale", ({ sessionId }) => {
1096
- hubLog.warn({ sessionId }, "synapse.session.stale");
1097
- });
1098
- synapseEmitter.on("synapse.session.removed", ({ sessionId }) => {
1099
- hubLog.info({ sessionId }, "synapse.session.removed");
1100
- });
1101
-
1102
- const hitl = createHitlManager(store, router);
1103
- const pipe = createPipeServer({
1104
- router,
1105
- store,
1106
- sessionId,
1107
- delegatorService,
1108
- hitlManager: hitl,
1109
- onActivity: markRequestActivity,
1110
- });
1111
- const assignCallbacks = createAssignCallbackServer({ store, sessionId });
1112
- const tools = createTools(store, router, hitl, pipe);
1113
- const transports = new Map();
1114
- const workerSpans = new Map();
1115
-
1116
- async function closeMcpTransportSession(sid, session, reason) {
1117
- if (!sid) return;
1118
- if (!session) {
1119
- transports.delete(sid);
1120
- return;
1121
- }
1122
- if (session.closing) {
1123
- transports.delete(sid);
1124
- return;
1125
- }
1126
-
1127
- session.closing = true;
1128
- transports.delete(sid);
1129
-
1130
- try {
1131
- await session.mcp.close();
1132
- } catch (error) {
1133
- hubLog.debug(
1134
- { sid, reason, err: String(error?.message || error) },
1135
- "hub.mcp_session_close_mcp_failed",
1136
- );
1137
- }
1138
- try {
1139
- await session.transport.close();
1140
- } catch (error) {
1141
- hubLog.debug(
1142
- { sid, reason, err: String(error?.message || error) },
1143
- "hub.mcp_session_close_transport_failed",
1144
- );
1145
- }
1146
- }
1147
-
1148
- function getTraceClient(clientRef) {
1149
- return clientRef?.client || "unknown";
1150
- }
1151
-
1152
- function finishWorkerTrace(workerId, kind) {
1153
- if (!workerId || typeof workerId !== "string") return;
1154
- const span = workerSpans.get(workerId);
1155
- if (!span) return;
1156
- workerSpans.delete(workerId);
1157
- recordWorker(
1158
- workerId,
1159
- "exited",
1160
- span.command || kind,
1161
- performance.now() - span.startedAt,
1162
- );
1163
- }
1164
-
1165
- function createMcpForSession(clientRef = { client: "unknown" }) {
1166
- const mcp = new Server(
1167
- { name: "tfx-hub", version: "1.0.0" },
1168
- { capabilities: { tools: {} } },
1169
- );
1170
-
1171
- mcp.setRequestHandler(ListToolsRequestSchema, async () => {
1172
- const started = performance.now();
1173
- try {
1174
- return {
1175
- tools: tools.map((tool) => ({
1176
- name: tool.name,
1177
- description: tool.description,
1178
- inputSchema: tool.inputSchema,
1179
- })),
1180
- };
1181
- } finally {
1182
- recordRequest(
1183
- "tools.list",
1184
- performance.now() - started,
1185
- getTraceClient(clientRef),
1186
- "handler",
1187
- );
1188
- }
1189
- });
1190
-
1191
- mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
1192
- const { name, arguments: args } = request.params;
1193
- const category = `tools.call.${name || "unknown"}`;
1194
- const started = performance.now();
1195
- try {
1196
- const tool = tools.find((candidate) => candidate.name === name);
1197
- if (!tool) {
1198
- return {
1199
- content: [
1200
- {
1201
- type: "text",
1202
- text: JSON.stringify({
1203
- ok: false,
1204
- error: {
1205
- code: "UNKNOWN_TOOL",
1206
- message: `도구 없음: ${name}`,
1207
- },
1208
- }),
1209
- },
1210
- ],
1211
- isError: true,
1212
- };
1213
- }
1214
- return tool.handler(args || {});
1215
- } finally {
1216
- recordRequest(
1217
- category,
1218
- performance.now() - started,
1219
- getTraceClient(clientRef),
1220
- "handler",
1221
- );
1222
- }
1223
- });
1224
-
1225
- return mcp;
1226
- }
1227
-
1228
- const httpServer = createHttpServer(
1229
- wrapRequestHandler(async (req, res) => {
1230
- markRequestActivity();
1231
- res.setHeader("X-Content-Type-Options", "nosniff");
1232
- res.setHeader("X-Frame-Options", "DENY");
1233
- const path = getRequestPath(req.url);
1234
- const corsAllowed = applyCorsHeaders(req, res);
1235
-
1236
- if (req.method === "OPTIONS") {
1237
- const localOnlyMode = !HUB_TOKEN;
1238
- const isLoopbackRequest = isLoopbackRemoteAddress(
1239
- req.socket.remoteAddress,
1240
- );
1241
- res.writeHead(
1242
- corsAllowed && (!localOnlyMode || isLoopbackRequest) ? 204 : 403,
1243
- );
1244
- return res.end();
1245
- }
1246
-
1247
- const clientIp = req.socket.remoteAddress || "unknown";
1248
- if (!isLoopbackRemoteAddress(clientIp)) {
1249
- const rateCheck = checkRateLimit(clientIp);
1250
- if (!rateCheck.allowed) {
1251
- return writeJson(
1252
- res,
1253
- 429,
1254
- { ok: false, error: "Too Many Requests" },
1255
- { "Retry-After": String(rateCheck.retryAfterSec) },
1256
- );
1257
- }
1258
- }
1259
-
1260
- if (!isAuthorizedRequest(req, path, HUB_TOKEN)) {
1261
- if (!HUB_TOKEN) {
1262
- return writeJson(res, 403, {
1263
- ok: false,
1264
- error: "Forbidden: localhost only",
1265
- });
1266
- }
1267
- return writeJson(
1268
- res,
1269
- 401,
1270
- { ok: false, error: "Unauthorized" },
1271
- { "WWW-Authenticate": 'Bearer realm="tfx-hub"' },
1272
- );
1273
- }
1274
-
1275
- if (path === "/" || path === "/status") {
1276
- const status = router.getStatus("hub").data;
1277
- const includeMetrics =
1278
- new URL(req.url, `http://${host}`).searchParams.get(
1279
- "include_metrics",
1280
- ) === "1";
1281
- const body = {
1282
- ...status,
1283
- sessions: transports.size,
1284
- pid: process.pid,
1285
- port,
1286
- auth_mode: HUB_TOKEN ? "token-required" : "localhost-only",
1287
- idle_timeout_ms: hubIdleTimeoutMs,
1288
- last_request_at: new Date(lastRequestAt).toISOString(),
1289
- pipe_path: pipe.path,
1290
- pipe: pipe.getStatus(),
1291
- assign_callback_pipe_path: assignCallbacks.path,
1292
- assign_callback_pipe: assignCallbacks.getStatus(),
1293
- spawn_trace: {
1294
- max_per_sec: spawnTrace.getMaxSpawnPerSec(),
1295
- max_total_descendants: spawnTrace.MAX_TOTAL_DESCENDANTS,
1296
- },
1297
- version,
1298
- };
1299
- if (includeMetrics) {
1300
- body.metrics = traceSnapshot();
1301
- }
1302
- return writeJson(res, 200, body);
1303
- }
1304
-
1305
- if (path === "/health" || path === "/healthz") {
1306
- const status = router.getStatus("hub").data;
1307
- const healthy = status?.hub?.state === "healthy";
1308
- return writeJson(res, healthy ? 200 : 503, {
1309
- ok: healthy,
1310
- version,
1311
- platform: process.platform,
1312
- uptime_s: Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)),
1313
- node: process.version,
1314
- sessions: transports.size,
1315
- store: store.type || "sqlite",
1316
- idle_timeout_ms: hubIdleTimeoutMs,
1317
- idle_ms: Math.max(0, Date.now() - lastRequestAt),
1318
- fingerprint: fingerprintService.getHealth(),
1319
- });
1320
- }
1321
-
1322
- if (path === "/api/qos-stats" && req.method === "GET") {
1323
- return writeJson(res, 200, getQosStatsPayload());
1324
- }
1325
-
1326
- if (path === "/broker/snapshot" && req.method === "GET") {
1327
- const snap = getBrokerPublicSnapshot(brokerInstance);
1328
- return writeJson(res, 200, {
1329
- ok: true,
1330
- accounts: snap,
1331
- ts: Date.now(),
1332
- });
1333
- }
1334
-
1335
- if (path === "/broker/dashboard" && req.method === "GET") {
1336
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1337
- res.end(renderBrokerDashboard());
1338
- return;
1339
- }
1340
-
1341
- if (
1342
- path === "/broker/quota-refresh" &&
1343
- (req.method === "POST" || req.method === "GET")
1344
- ) {
1345
- try {
1346
- const results = await refreshAllAccountQuotas();
1347
- logQuotaRefreshFailures(hubLog, results);
1348
- return writeJson(res, 200, { ok: true, results, ts: Date.now() });
1349
- } catch (err) {
1350
- hubLog.error(
1351
- { err: String(err?.message || err) },
1352
- "broker.quota_refresh_error",
1353
- );
1354
- return writeJson(res, 200, {
1355
- ok: false,
1356
- error: String(err?.message || err),
1357
- });
1358
- }
1359
- }
1360
-
1361
- if (path === "/broker/reload" && req.method === "POST") {
1362
- const result = reloadBroker();
1363
- if (!result.ok) {
1364
- return writeJson(res, 200, { ok: false, error: result.error });
1365
- }
1366
- attachBrokerDiagnostics(result.broker);
1367
- syncBrokerAuthCache(result.broker);
1368
- const accounts = getBrokerPublicSnapshot(result.broker).length;
1369
- return writeJson(res, 200, { ok: true, accounts });
1370
- }
1371
-
1372
- if (path === "/spawn-trace/reload" && req.method === "POST") {
1373
- return writeJson(res, 200, {
1374
- ok: true,
1375
- max_spawn_per_sec: spawnTrace.reload(),
1376
- });
1377
- }
1378
-
1379
- // ── Synapse Layer 5: session registry routes ──
1380
- // Admin/raw snapshot (loopback-only). Returns raw cwd/pid for local
1381
- // admin/HUD use; the redacted peer surface is GET /synapse/peers.
1382
- if (path === "/synapse/sessions" && req.method === "GET") {
1383
- // Enforce the loopback boundary independent of token mode: the global
1384
- // token gate admits any token-bearing remote client, but this raw
1385
- // snapshot leaks absolute cwd/pid/worktreePath/dirtyFiles for every
1386
- // session, so it must never be reachable off 127.0.0.1 (LOCKED #3: raw
1387
- // cwd is admin-only via loopback). Off-loopback peers use /synapse/peers.
1388
- if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
1389
- return writeJson(res, 403, {
1390
- ok: false,
1391
- error: "Forbidden: /synapse/sessions is loopback-only",
1392
- });
1393
- }
1394
- return writeJson(res, 200, {
1395
- ok: true,
1396
- ...synapseRegistry.snapshot(),
1397
- ts: Date.now(),
1398
- });
1399
- }
1400
-
1401
- // Redacted peer-discovery surface. Returns co-located live peers (same
1402
- // cwd / worktree) with raw cwd/pid stripped — only label/hash + booleans.
1403
- if (path === "/synapse/peers" && req.method === "GET") {
1404
- // formatHostForUrl wraps IPv6 hosts in [...]; a bare `::1` would make
1405
- // `new URL(req.url, "http://::1")` throw (invalid authority).
1406
- const query = new URL(req.url, `http://${formatHostForUrl(host)}`)
1407
- .searchParams;
1408
- const cwd = query.get("cwd") || "";
1409
- const worktree = query.get("worktree") || "";
1410
- const excludeSessionId = query.get("excludeSessionId") || "";
1411
- // Require at least one non-empty locator. Without it querySessions
1412
- // already returns [], but short-circuiting keeps the contract explicit
1413
- // and avoids ever enumerating the registry over the redacted surface.
1414
- if (!cwd && !worktree) {
1415
- return writeJson(res, 200, { ok: true, peers: [], ts: Date.now() });
1416
- }
1417
- const matches = synapseRegistry.querySessions({
1418
- cwd,
1419
- worktree,
1420
- excludeSessionId,
1421
- });
1422
- const peers = matches.map((session) =>
1423
- projectPeer(session, { cwd, worktree }),
1424
- );
1425
- return writeJson(res, 200, { ok: true, peers, ts: Date.now() });
1426
- }
1427
-
1428
- if (path === "/synapse/register" && req.method === "POST") {
1429
- try {
1430
- const body = await parseBody(req);
1431
- const { sessionId } = body || {};
1432
- if (body?.agent_id && body?.cli) {
1433
- const registered = router.registerAgent({
1434
- agent_id: body.agent_id,
1435
- cli: body.cli,
1436
- capabilities: Array.isArray(body.capabilities)
1437
- ? body.capabilities
1438
- : [],
1439
- topics: Array.isArray(body.topics) ? body.topics : [],
1440
- heartbeat_ttl_ms: body.heartbeat_ttl_ms ?? 300000,
1441
- metadata:
1442
- body.metadata && typeof body.metadata === "object"
1443
- ? body.metadata
1444
- : { source: "session_start" },
1445
- ...(Object.hasOwn(body, "project_id")
1446
- ? { project_id: body.project_id }
1447
- : {}),
1448
- ...(Object.hasOwn(body, "session_id")
1449
- ? { session_id: body.session_id }
1450
- : {}),
1451
- ...(Object.hasOwn(body, "host_id")
1452
- ? { host_id: body.host_id }
1453
- : {}),
1454
- ...(Object.hasOwn(body, "transport_locators_json")
1455
- ? { transport_locators_json: body.transport_locators_json }
1456
- : {}),
1457
- });
1458
- if (!registered?.ok) {
1459
- throw new Error(
1460
- registered?.error?.message || "hub agent register failed",
1461
- );
1462
- }
1463
- }
1464
- const result = synapseRegistry.register(sessionId, body);
1465
- if (!result?.ok) {
1466
- throw new Error(result?.reason || "register failed");
1467
- }
1468
- return writeJson(res, 200, {
1469
- ok: true,
1470
- sessionId: result.sessionId || sessionId,
1471
- });
1472
- } catch (err) {
1473
- return writeJson(res, 400, {
1474
- ok: false,
1475
- error: String(err?.message || err),
1476
- });
1477
- }
1478
- }
1479
-
1480
- if (path === "/synapse/heartbeat" && req.method === "POST") {
1481
- try {
1482
- const body = await parseBody(req);
1483
- const { sessionId, partial } = body || {};
1484
- const ok = synapseRegistry.heartbeat(sessionId, partial);
1485
- if (!ok) {
1486
- throw new Error("heartbeat failed");
1487
- }
1488
- return writeJson(res, 200, { ok: true });
1489
- } catch (err) {
1490
- return writeJson(res, 400, {
1491
- ok: false,
1492
- error: String(err?.message || err),
1493
- });
1494
- }
1495
- }
1496
-
1497
- if (path === "/synapse/unregister" && req.method === "POST") {
1498
- try {
1499
- const body = await parseBody(req);
1500
- const { sessionId } = body || {};
1501
- const ok = synapseRegistry.unregister(sessionId);
1502
- if (!ok) {
1503
- throw new Error("unregister failed");
1504
- }
1505
- return writeJson(res, 200, { ok: true });
1506
- } catch (err) {
1507
- return writeJson(res, 400, {
1508
- ok: false,
1509
- error: String(err?.message || err),
1510
- });
1511
- }
1512
- }
1513
-
1514
- if (path.startsWith("/bridge")) {
1515
- const isBridgeStatusGet =
1516
- path === "/bridge/status" && req.method === "GET";
1517
- const isBridgeHitlPendingGet =
1518
- path === "/bridge/hitl/pending" && req.method === "GET";
1519
- if (
1520
- req.method !== "POST" &&
1521
- req.method !== "DELETE" &&
1522
- !isBridgeStatusGet &&
1523
- !isBridgeHitlPendingGet
1524
- ) {
1525
- return writeJson(res, 405, {
1526
- ok: false,
1527
- error: "Method Not Allowed",
1528
- });
1529
- }
1530
-
1531
- try {
1532
- const body = req.method === "POST" ? await parseBody(req) : {};
1533
- const requestUrl = new URL(req.url || path, "http://127.0.0.1");
1534
-
1535
- if (path === "/bridge/status" && req.method === "GET") {
1536
- const scope = requestUrl.searchParams.get("scope") || "hub";
1537
- const include_metrics =
1538
- requestUrl.searchParams.get("include_metrics") !== "0";
1539
- const agent_id =
1540
- requestUrl.searchParams.get("agent_id") || undefined;
1541
- const trace_id =
1542
- requestUrl.searchParams.get("trace_id") || undefined;
1543
- const result = await pipe.executeQuery("status", {
1544
- scope,
1545
- include_metrics,
1546
- agent_id,
1547
- trace_id,
1548
- });
1549
- return writeJson(res, 200, result);
1550
- }
1551
-
1552
- if (path === "/bridge/hitl/pending" && req.method === "GET") {
1553
- const result = { ok: true, data: hitl.getPendingRequests() };
1554
- return writeJson(res, result.ok ? 200 : 400, result);
1555
- }
1556
-
1557
- if (path === "/bridge/takeover-role" && req.method === "POST") {
1558
- const {
1559
- role = "cto",
1560
- agent_id,
1561
- reason = "manual",
1562
- requested_by = "http",
1563
- hub_instance_id,
1564
- store_fingerprint,
1565
- } = body;
1566
- if (!role || !agent_id) {
1567
- return writeJson(res, 400, {
1568
- ok: false,
1569
- error: "role, agent_id 필수",
1570
- });
1571
- }
1572
- const result = await pipe.executeCommand("takeover_role", {
1573
- role,
1574
- agent_id,
1575
- reason,
1576
- requested_by,
1577
- hub_instance_id,
1578
- store_fingerprint,
1579
- });
1580
- return writeJson(res, result.ok ? 200 : 409, result);
1581
- }
1582
-
1583
- if (path === "/bridge/register" && req.method === "POST") {
1584
- const {
1585
- agent_id,
1586
- cli,
1587
- timeout_sec = DEFAULT_REGISTER_TIMEOUT_SEC,
1588
- topics = [],
1589
- capabilities = [],
1590
- metadata = {},
1591
- project_id,
1592
- session_id,
1593
- host_id,
1594
- transport_locators_json,
1595
- hub_instance_id,
1596
- store_fingerprint,
1597
- } = body;
1598
- if (!agent_id || !cli) {
1599
- return writeJson(res, 400, {
1600
- ok: false,
1601
- error: "agent_id, cli 필수",
1602
- });
1603
- }
1604
-
1605
- const command =
1606
- body.command ||
1607
- metadata?.command ||
1608
- metadata?.cmd ||
1609
- metadata?.task ||
1610
- cli;
1611
- const workerStartedAt = performance.now();
1612
- const hasExplicitTimeout = Number.isFinite(
1613
- Number(body.timeout_sec),
1614
- );
1615
- const heartbeat_ttl_ms = hasExplicitTimeout
1616
- ? (timeout_sec + 120) * 1000
1617
- : Math.max((timeout_sec + 120) * 1000, resolveWorkerLeaseTtlMs());
1618
- const result = await pipe.executeCommand("register", {
1619
- agent_id,
1620
- cli,
1621
- capabilities,
1622
- topics,
1623
- heartbeat_ttl_ms,
1624
- metadata,
1625
- ...(project_id === undefined ? {} : { project_id }),
1626
- ...(session_id === undefined ? {} : { session_id }),
1627
- ...(host_id === undefined ? {} : { host_id }),
1628
- ...(transport_locators_json === undefined
1629
- ? {}
1630
- : { transport_locators_json }),
1631
- ...(hub_instance_id === undefined ? {} : { hub_instance_id }),
1632
- ...(store_fingerprint === undefined ? {} : { store_fingerprint }),
1633
- });
1634
- if (result.ok) {
1635
- workerSpans.set(agent_id, {
1636
- command,
1637
- startedAt: workerStartedAt,
1638
- });
1639
- recordWorker(agent_id, "spawned", command, 0);
1640
- }
1641
- return writeJson(res, result.ok ? 200 : 409, result);
1642
- }
1643
-
1644
- if (path === "/bridge/result" && req.method === "POST") {
1645
- const {
1646
- agent_id,
1647
- topic = "task.result",
1648
- payload = {},
1649
- trace_id,
1650
- correlation_id,
1651
- } = body;
1652
- if (!agent_id) {
1653
- return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
1654
- }
1655
-
1656
- const result = await pipe.executeCommand("result", {
1657
- agent_id,
1658
- topic,
1659
- payload,
1660
- trace_id,
1661
- correlation_id,
1662
- });
1663
- return writeJson(res, 200, result);
1664
- }
1665
-
1666
- if (path === "/bridge/heartbeat" && req.method === "POST") {
1667
- const {
1668
- agent_id,
1669
- ttl_ms,
1670
- presence_generation,
1671
- hub_instance_id,
1672
- store_fingerprint,
1673
- } = body;
1674
- if (!agent_id)
1675
- return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
1676
- const result = await pipe.executeCommand("heartbeat", {
1677
- agent_id,
1678
- heartbeat_ttl_ms: clampDurationMs(
1679
- ttl_ms,
1680
- resolveWorkerLeaseTtlMs(),
1681
- ),
1682
- presence_generation,
1683
- hub_instance_id,
1684
- store_fingerprint,
1685
- });
1686
- return writeJson(res, result.ok ? 200 : 404, result);
1687
- }
1688
-
1689
- if (path === "/bridge/control" && req.method === "POST") {
1690
- const {
1691
- from_agent = "lead",
1692
- to_agent,
1693
- command,
1694
- reason = "",
1695
- payload = {},
1696
- trace_id,
1697
- correlation_id,
1698
- ttl_ms = 3600000,
1699
- } = body;
1700
-
1701
- if (!to_agent || !command) {
1702
- return writeJson(res, 400, {
1703
- ok: false,
1704
- error: "to_agent, command 필수",
1705
- });
1706
- }
1707
-
1708
- const result = await pipe.executeCommand("control", {
1709
- from_agent,
1710
- to_agent,
1711
- command,
1712
- reason,
1713
- payload,
1714
- ttl_ms,
1715
- trace_id,
1716
- correlation_id,
1717
- });
1718
-
1719
- return writeJson(res, 200, result);
1720
- }
1721
-
1722
- if (path === "/bridge/handoff" && req.method === "POST") {
1723
- const result = router.handleHandoff(normalizeHandoffBody(body));
1724
- return writeJson(res, result.ok ? 200 : 400, result);
1725
- }
1726
-
1727
- if (path === "/bridge/publish" && req.method === "POST") {
1728
- const normalized = normalizePublishBody(body);
1729
- const result = router.handlePublish(normalized);
1730
- if (result.ok) {
1731
- finishWorkerTrace(
1732
- normalized.from ||
1733
- normalized.agent_id ||
1734
- normalized.worker_agent ||
1735
- normalized.from_agent,
1736
- "publish",
1737
- );
1738
- }
1739
- return writeJson(res, result.ok ? 200 : 400, result);
1740
- }
1741
-
1742
- if (path === "/bridge/send-input" && req.method === "POST") {
1743
- const { session_id, text } = body;
1744
- if (!session_id || typeof text !== "string" || text.length === 0) {
1745
- return writeJson(res, 400, {
1746
- ok: false,
1747
- error: "session_id, text 필수",
1748
- });
1749
- }
1750
-
1751
- const result = await pipe.executeCommand("send_input", {
1752
- session_id,
1753
- text,
1754
- });
1755
- return writeJson(res, resolveSendInputStatusCode(result), result);
1756
- }
1757
-
1758
- if (path === "/bridge/status" && req.method === "POST") {
1759
- const {
1760
- scope = "hub",
1761
- agent_id,
1762
- status,
1763
- include_metrics = true,
1764
- trace_id,
1765
- presence_generation,
1766
- hub_instance_id,
1767
- store_fingerprint,
1768
- } = body;
1769
-
1770
- if (agent_id && status) {
1771
- const normalizedAgentId = String(agent_id || "").trim();
1772
- const normalizedStatus = String(status || "")
1773
- .trim()
1774
- .toLowerCase();
1775
- if (!normalizedAgentId || !normalizedStatus) {
1776
- return writeJson(res, 400, {
1777
- ok: false,
1778
- error: "agent_id, status 필수",
1779
- });
1780
- }
1781
- const statusForStore = new Set([
1782
- "online",
1783
- "stale",
1784
- "offline",
1785
- ]).has(normalizedStatus)
1786
- ? normalizedStatus
1787
- : "online";
1788
- const transition = router.transitionAgentStatus(
1789
- normalizedAgentId,
1790
- statusForStore,
1791
- presence_generation,
1792
- { hub_instance_id, store_fingerprint },
1793
- );
1794
- if (!transition.ok) {
1795
- return writeJson(res, 409, transition);
1796
- }
1797
- const snapshot = await pipe.executeQuery("status", {
1798
- scope: "agent",
1799
- agent_id: normalizedAgentId,
1800
- include_metrics: false,
1801
- });
1802
- return writeJson(res, 200, {
1803
- ok: true,
1804
- data: {
1805
- agent_id: normalizedAgentId,
1806
- status: statusForStore,
1807
- reported_status: normalizedStatus,
1808
- reported_at_ms: Date.now(),
1809
- snapshot: snapshot?.data?.agent || null,
1810
- hub_instance_id: transition.data.hub_instance_id,
1811
- store_fingerprint: transition.data.store_fingerprint,
1812
- },
1813
- });
1814
- }
1815
-
1816
- const result = await pipe.executeQuery("status", {
1817
- scope,
1818
- agent_id,
1819
- include_metrics,
1820
- trace_id,
1821
- hub_instance_id,
1822
- store_fingerprint,
1823
- });
1824
- return writeJson(res, 200, result);
1825
- }
1826
-
1827
- if (path === "/bridge/assign/async" && req.method === "POST") {
1828
- const {
1829
- supervisor_agent,
1830
- worker_agent,
1831
- task,
1832
- topic = "assign.job",
1833
- payload = {},
1834
- priority = 5,
1835
- ttl_ms = DEFAULT_ASSIGN_TTL_MS,
1836
- timeout_ms = DEFAULT_ASSIGN_TIMEOUT_MS,
1837
- max_retries = 0,
1838
- trace_id,
1839
- correlation_id,
1840
- } = body;
1841
-
1842
- if (!supervisor_agent || !worker_agent || !task) {
1843
- return writeJson(res, 400, {
1844
- ok: false,
1845
- error: "supervisor_agent, worker_agent, task 필수",
1846
- });
1847
- }
1848
-
1849
- const result = await pipe.executeCommand("assign", {
1850
- supervisor_agent,
1851
- worker_agent,
1852
- task,
1853
- topic,
1854
- payload,
1855
- priority,
1856
- ttl_ms,
1857
- timeout_ms,
1858
- max_retries,
1859
- trace_id,
1860
- correlation_id,
1861
- });
1862
- return writeJson(res, result.ok ? 200 : 400, result);
1863
- }
1864
-
1865
- if (path === "/bridge/assign/result" && req.method === "POST") {
1866
- const {
1867
- job_id,
1868
- worker_agent,
1869
- status,
1870
- attempt,
1871
- result: assignResult,
1872
- error: assignError,
1873
- payload = {},
1874
- metadata = {},
1875
- } = body;
1876
-
1877
- if (!job_id || !status) {
1878
- return writeJson(res, 400, {
1879
- ok: false,
1880
- error: "job_id, status 필수",
1881
- });
1882
- }
1883
-
1884
- const result = await pipe.executeCommand("assign_result", {
1885
- job_id,
1886
- worker_agent,
1887
- status,
1888
- attempt,
1889
- result: assignResult,
1890
- error: assignError,
1891
- payload,
1892
- metadata,
1893
- });
1894
- return writeJson(res, result.ok ? 200 : 409, result);
1895
- }
1896
-
1897
- if (path === "/bridge/assign/status" && req.method === "POST") {
1898
- const result = await pipe.executeQuery("assign_status", body);
1899
- const statusCode = result.ok
1900
- ? 200
1901
- : result.error?.code === "ASSIGN_NOT_FOUND"
1902
- ? 404
1903
- : 400;
1904
- return writeJson(res, statusCode, result);
1905
- }
1906
-
1907
- if (path === "/bridge/assign/retry" && req.method === "POST") {
1908
- const { job_id, reason, requested_by } = body;
1909
- if (!job_id) {
1910
- return writeJson(res, 400, { ok: false, error: "job_id 필수" });
1911
- }
1912
-
1913
- const result = await pipe.executeCommand("assign_retry", {
1914
- job_id,
1915
- reason,
1916
- requested_by,
1917
- });
1918
- const statusCode = result.ok
1919
- ? 200
1920
- : result.error?.code === "ASSIGN_NOT_FOUND"
1921
- ? 404
1922
- : result.error?.code === "ASSIGN_RETRY_EXHAUSTED"
1923
- ? 409
1924
- : 400;
1925
- return writeJson(res, statusCode, result);
1926
- }
1927
-
1928
- if (req.method === "POST") {
1929
- let teamResult = null;
1930
- if (path === "/bridge/team/info" || path === "/bridge/team-info") {
1931
- teamResult = await pipe.executeQuery("team_info", body);
1932
- } else if (
1933
- path === "/bridge/team/task-list" ||
1934
- path === "/bridge/team-task-list"
1935
- ) {
1936
- teamResult = await pipe.executeQuery("team_task_list", body);
1937
- } else if (
1938
- path === "/bridge/team/task-update" ||
1939
- path === "/bridge/team-task-update"
1940
- ) {
1941
- teamResult = await pipe.executeCommand("team_task_update", body);
1942
- } else if (
1943
- path === "/bridge/team/send-message" ||
1944
- path === "/bridge/team-send-message"
1945
- ) {
1946
- teamResult = await pipe.executeCommand("team_send_message", body);
1947
- }
1948
-
1949
- if (teamResult) {
1950
- return writeJson(
1951
- res,
1952
- resolveTeamStatusCode(teamResult),
1953
- teamResult,
1954
- );
1955
- }
1956
-
1957
- if (path.startsWith("/bridge/team")) {
1958
- return writeJson(res, 404, {
1959
- ok: false,
1960
- error: `Unknown team endpoint: ${path}`,
1961
- });
1962
- }
1963
-
1964
- // ── 파이프라인 엔드포인트 ──
1965
- if (path === "/bridge/pipeline/state" && req.method === "POST") {
1966
- const result = await pipe.executeQuery("pipeline_state", body);
1967
- return writeJson(res, resolvePipelineStatusCode(result), result);
1968
- }
1969
-
1970
- if (path === "/bridge/pipeline/advance" && req.method === "POST") {
1971
- const result = await pipe.executeCommand(
1972
- "pipeline_advance",
1973
- body,
1974
- );
1975
- return writeJson(res, resolvePipelineStatusCode(result), result);
1976
- }
1977
-
1978
- if (path === "/bridge/pipeline/init" && req.method === "POST") {
1979
- const result = await pipe.executeCommand("pipeline_init", body);
1980
- return writeJson(res, resolvePipelineStatusCode(result), result);
1981
- }
1982
-
1983
- if (path === "/bridge/pipeline/list" && req.method === "POST") {
1984
- const result = await pipe.executeQuery("pipeline_list", body);
1985
- return writeJson(res, resolvePipelineStatusCode(result), result);
1986
- }
1987
-
1988
- // ── Delegator 엔드포인트 ──
1989
- if (
1990
- path === "/bridge/delegator/delegate" &&
1991
- req.method === "POST"
1992
- ) {
1993
- const result = await pipe.executeCommand(
1994
- "delegator_delegate",
1995
- body,
1996
- );
1997
- return writeJson(res, result.ok ? 200 : 400, result);
1998
- }
1999
-
2000
- if (path === "/bridge/delegator/reply" && req.method === "POST") {
2001
- const result = await pipe.executeCommand("delegator_reply", body);
2002
- return writeJson(res, result.ok ? 200 : 400, result);
2003
- }
2004
-
2005
- if (path === "/bridge/delegator/status" && req.method === "POST") {
2006
- const result = await pipe.executeQuery("delegator_status", body);
2007
- return writeJson(res, result.ok ? 200 : 400, result);
2008
- }
2009
-
2010
- if (path === "/bridge/hitl/request" && req.method === "POST") {
2011
- const result = hitl.requestHumanInput(body);
2012
- return writeJson(res, result.ok ? 200 : 400, result);
2013
- }
2014
-
2015
- if (path === "/bridge/hitl/submit" && req.method === "POST") {
2016
- const result = hitl.submitHumanInput(body);
2017
- return writeJson(res, result.ok ? 200 : 400, result);
2018
- }
2019
- }
2020
-
2021
- if (path === "/bridge/context" && req.method === "POST") {
2022
- const {
2023
- agent_id,
2024
- topics,
2025
- max_messages = 10,
2026
- auto_ack = true,
2027
- } = body;
2028
- if (!agent_id) {
2029
- return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
2030
- }
2031
-
2032
- const result = await pipe.executeQuery("drain", {
2033
- agent_id,
2034
- topics,
2035
- max_messages,
2036
- auto_ack,
2037
- });
2038
- return writeJson(res, 200, result);
2039
- }
2040
-
2041
- if (path === "/bridge/deregister" && req.method === "POST") {
2042
- const {
2043
- agent_id,
2044
- presence_generation,
2045
- hub_instance_id,
2046
- store_fingerprint,
2047
- } = body;
2048
- if (!agent_id) {
2049
- return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
2050
- }
2051
- const result = await pipe.executeCommand("deregister", {
2052
- agent_id,
2053
- presence_generation,
2054
- hub_instance_id,
2055
- store_fingerprint,
2056
- });
2057
- if (result.ok) {
2058
- finishWorkerTrace(agent_id, "deregister");
2059
- }
2060
- return writeJson(res, 200, result);
2061
- }
2062
-
2063
- return writeJson(res, 404, {
2064
- ok: false,
2065
- error: "Unknown bridge endpoint",
2066
- });
2067
- } catch (error) {
2068
- if (!res.headersSent) {
2069
- console.error("[tfx-hub] bridge error:", error);
2070
- writeJson(res, 500, { ok: false, error: "Internal server error" });
2071
- }
2072
- return;
2073
- }
2074
- }
2075
-
2076
- if (req.method === "GET" && servePublicFile(res, path)) {
2077
- return;
2078
- }
2079
-
2080
- if (path !== "/mcp") {
2081
- res.writeHead(404);
2082
- return res.end("Not Found");
2083
- }
2084
-
2085
- try {
2086
- const sessionIdHeader = req.headers["mcp-session-id"];
2087
-
2088
- if (req.method === "POST") {
2089
- const body = await parseBody(req);
2090
-
2091
- if (sessionIdHeader && transports.has(sessionIdHeader)) {
2092
- const session = transports.get(sessionIdHeader);
2093
- session.transport._lastActivity = Date.now();
2094
- await session.transport.handleRequest(req, res, body);
2095
- } else if (!sessionIdHeader && isInitializeRequest(body)) {
2096
- const client = parseTfxClientHeader(req.headers["x-tfx-client"]);
2097
- const clientRef = { client };
2098
- const initializeStarted = performance.now();
2099
- const transport = new StreamableHTTPServerTransport({
2100
- sessionIdGenerator: () => randomUUID(),
2101
- onsessioninitialized: (sid) => {
2102
- transport._lastActivity = Date.now();
2103
- transports.set(sid, {
2104
- transport,
2105
- mcp,
2106
- closing: false,
2107
- client,
2108
- createdAt: Date.now(),
2109
- });
2110
- },
2111
- });
2112
- transport.onclose = () => {
2113
- const sid = transport.sessionId;
2114
- if (sid)
2115
- void closeMcpTransportSession(
2116
- sid,
2117
- transports.get(sid),
2118
- "transport.onclose",
2119
- );
2120
- };
2121
- const mcp = createMcpForSession(clientRef);
2122
- await mcp.connect(transport);
2123
- try {
2124
- await transport.handleRequest(req, res, body);
2125
- } finally {
2126
- recordRequest(
2127
- "initialize",
2128
- performance.now() - initializeStarted,
2129
- client,
2130
- "ingress",
2131
- );
2132
- }
2133
- } else {
2134
- res.writeHead(400, { "Content-Type": "application/json" });
2135
- res.end(
2136
- JSON.stringify({
2137
- jsonrpc: "2.0",
2138
- error: {
2139
- code: -32000,
2140
- message: "Bad Request: No valid session ID",
2141
- },
2142
- id: null,
2143
- }),
2144
- );
2145
- }
2146
- } else if (req.method === "GET") {
2147
- if (sessionIdHeader && transports.has(sessionIdHeader)) {
2148
- await transports
2149
- .get(sessionIdHeader)
2150
- .transport.handleRequest(req, res);
2151
- } else {
2152
- res.writeHead(400);
2153
- res.end("Invalid or missing session ID");
2154
- }
2155
- } else if (req.method === "DELETE") {
2156
- if (sessionIdHeader && transports.has(sessionIdHeader)) {
2157
- await transports
2158
- .get(sessionIdHeader)
2159
- .transport.handleRequest(req, res);
2160
- } else {
2161
- res.writeHead(400);
2162
- res.end("Invalid or missing session ID");
2163
- }
2164
- } else {
2165
- res.writeHead(405);
2166
- res.end("Method Not Allowed");
2167
- }
2168
- } catch (error) {
2169
- hubLog.error({ err: error }, "http.error");
2170
- if (!res.headersSent) {
2171
- const code =
2172
- error.statusCode === 413
2173
- ? 413
2174
- : error instanceof SyntaxError
2175
- ? 400
2176
- : 500;
2177
- const message =
2178
- code === 413
2179
- ? "Body too large"
2180
- : code === 400
2181
- ? "Invalid JSON"
2182
- : "Internal server error";
2183
- res.writeHead(code, { "Content-Type": "application/json" });
2184
- res.end(
2185
- JSON.stringify({
2186
- jsonrpc: "2.0",
2187
- error: { code: code === 500 ? -32603 : -32700, message },
2188
- id: null,
2189
- }),
2190
- );
2191
- }
2192
- }
2193
- }),
2194
- );
2195
-
2196
- httpServer.requestTimeout = 30000;
2197
- httpServer.headersTimeout = 10000;
2198
-
2199
- let hitlTimer = null;
2200
- let sessionTimer = null;
2201
- let synapsePruneTimer = null;
2202
- let orphanCleanupTimer = null;
2203
- let rateLimitTimer = null;
2204
-
2205
- const startBackgroundTimers = () => {
2206
- hitlTimer = setInterval(() => {
2207
- try {
2208
- hitl.checkTimeouts();
2209
- } catch (err) {
2210
- hubLog.warn({ err }, "hitl.timeout_check_failed");
2211
- }
2212
- }, 10000);
2213
- hitlTimer.unref();
2214
-
2215
- // MCP session TTL: sessions idle for SESSION_TTL_MS are closed automatically.
2216
- // Configurable via SESSION_TTL_MS (default 30 minutes). The sweep runs every 60 s.
2217
- const SESSION_TTL_MS =
2218
- parseInt(process.env.TFX_SESSION_TTL_MS || "", 10) || 30 * 60 * 1000;
2219
- sessionTimer = setInterval(() => {
2220
- const now = Date.now();
2221
- for (const [sid, session] of transports) {
2222
- if (now - (session.transport._lastActivity || 0) <= SESSION_TTL_MS)
2223
- continue;
2224
- void closeMcpTransportSession(sid, session, "session_ttl");
2225
- }
2226
- }, 60000);
2227
- sessionTimer.unref();
2228
-
2229
- synapsePruneTimer = setInterval(() => {
2230
- try {
2231
- const { count } = synapseRegistry.pruneExpired();
2232
- if (count > 0) hubLog.info({ count }, "synapse.prune");
2233
- } catch (err) {
2234
- hubLog.warn({ err }, "synapse.prune_failed");
2235
- }
2236
- }, 60000);
2237
- synapsePruneTimer.unref();
2238
-
2239
- // 고아 node.exe 프로세스 + stale spawn 세션 주기적 정리 (5분마다)
2240
- // TFX_DISABLE_ORPHAN_CLEANUP=1 로 비활성 (active SSH/swarm 세션 보호 회피용 hotfix gate)
2241
- orphanCleanupTimer = setInterval(
2242
- () => {
2243
- if (process.env.TFX_DISABLE_ORPHAN_CLEANUP === "1") return;
2244
- try {
2245
- const { killed, killedProcesses = [] } = cleanupOrphanNodeProcesses();
2246
- const {
2247
- killed: runtimeKilled,
2248
- killedProcesses: runtimeKilledProcesses = [],
2249
- } = cleanupOrphanRuntimeProcesses();
2250
- const totalKilled = killed + runtimeKilled;
2251
- if (totalKilled > 0) {
2252
- hubLog.info(
2253
- {
2254
- killed: totalKilled,
2255
- processes: [...killedProcesses, ...runtimeKilledProcesses],
2256
- caller: "timer",
2257
- },
2258
- "hub.orphan_cleanup",
2259
- );
2260
- }
2261
-
2262
- const { killed: fsmonitorKilled, stale } =
2263
- cleanupStaleFsmonitorDaemons({
2264
- minAgeMs: 24 * 60 * 60 * 1000,
2265
- });
2266
- if (fsmonitorKilled > 0) {
2267
- hubLog.info(
2268
- { killed: fsmonitorKilled, stale: stale.length },
2269
- "hub.fsmonitor_cleanup",
2270
- );
2271
- }
2272
- } catch {}
2273
-
2274
- // stale tfx-spawn-* psmux 세션 정리 (30분 이상 idle)
2275
- try {
2276
- const staleKilled = cleanupStaleSpawnSessions(hubLog);
2277
- if (staleKilled > 0) {
2278
- hubLog.info({ killed: staleKilled }, "hub.stale_spawn_cleanup");
2279
- }
2280
- } catch {}
2281
- },
2282
- 5 * 60 * 1000,
2283
- );
2284
- orphanCleanupTimer.unref();
2285
-
2286
- // Evict stale rate-limit buckets once per minute to bound memory usage.
2287
- rateLimitTimer = setInterval(() => {
2288
- const cutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
2289
- for (const [ip, timestamps] of rateLimitMap) {
2290
- const fresh = timestamps.filter((t) => t >= cutoff);
2291
- if (fresh.length === 0) {
2292
- rateLimitMap.delete(ip);
2293
- } else {
2294
- rateLimitMap.set(ip, fresh);
2295
- }
2296
- }
2297
- }, RATE_LIMIT_WINDOW_MS);
2298
- rateLimitTimer.unref();
2299
- };
2300
-
2301
- mkdirSync(PID_DIR, { recursive: true });
2302
-
2303
- const cleanupStartupFailure = async ({ preserveTokenFile = false } = {}) => {
2304
- try {
2305
- router.stopSweeper();
2306
- } catch {}
2307
- for (const timer of [
2308
- hitlTimer,
2309
- sessionTimer,
2310
- synapsePruneTimer,
2311
- orphanCleanupTimer,
2312
- rateLimitTimer,
2313
- ]) {
2314
- if (timer) clearInterval(timer);
2315
- }
2316
- for (const [sid, session] of Array.from(transports)) {
2317
- await closeMcpTransportSession(sid, session, "hub.startup_failure");
2318
- }
2319
- transports.clear();
2320
- try {
2321
- await pipe.stop();
2322
- } catch {}
2323
- try {
2324
- await assignCallbacks.stop();
2325
- } catch {}
2326
- try {
2327
- await delegatorWorker.stop();
2328
- } catch {}
2329
- try {
2330
- synapseRegistry.destroy();
2331
- } catch {}
2332
- try {
2333
- store.close();
2334
- } catch {}
2335
- if (!ephemeralPort && !preserveTokenFile) {
2336
- cleanupOwnedTokenFile({ token: HUB_TOKEN, port });
2337
- }
2338
- releaseStartupLock();
2339
- };
2340
-
2341
- try {
2342
- router.sweepExpired({ now_ms: Date.now(), source: "startup" });
2343
- router.startSweeper({
2344
- run_immediately: false,
2345
- interval_ms: 10000,
2346
- onSweepError: ({ source, error, consecutive_failures }) => {
2347
- hubLog.warn(
2348
- {
2349
- source,
2350
- code: error?.code || "UNKNOWN",
2351
- consecutive_failures,
2352
- },
2353
- "hub.sweep_failed",
2354
- );
2355
- },
2356
- });
2357
- startBackgroundTimers();
2358
- await pipe.start();
2359
- await assignCallbacks.start();
2360
- } catch (error) {
2361
- await cleanupStartupFailure();
2362
- throw error;
2363
- }
2364
-
2365
- return await new Promise((resolveHub, reject) => {
2366
- let bindAttempts = 0;
2367
-
2368
- const listenOnce = () => {
2369
- const onError = (err) => {
2370
- void (async () => {
2371
- if (err.code !== "EADDRINUSE") {
2372
- await cleanupStartupFailure();
2373
- reject(err);
2374
- return;
2375
- }
2376
-
2377
- const conflict = await resolvePortInUse({
2378
- port,
2379
- host,
2380
- version,
2381
- });
2382
- if (conflict.action === "retry" && bindAttempts < 1) {
2383
- bindAttempts += 1;
2384
- hubLog.warn(
2385
- { port, reason: conflict.cleaned?.reason },
2386
- "hub.port_in_use_retry_after_stale_pid_cleanup",
2387
- );
2388
- listenOnce();
2389
- return;
2390
- }
2391
-
2392
- await cleanupStartupFailure({ preserveTokenFile: true });
2393
- if (conflict.action === "reuse") {
2394
- hubLog.info(
2395
- {
2396
- port,
2397
- pid: conflict.peer?.pid,
2398
- version: conflict.peer?.version,
2399
- url: conflict.url,
2400
- },
2401
- "hub.already_running",
2402
- );
2403
- resolveHub({
2404
- reused: true,
2405
- external: true,
2406
- port,
2407
- pid: conflict.peer?.pid,
2408
- url: conflict.url,
2409
- stop: async () => false,
2410
- });
2411
- return;
2412
- }
2413
-
2414
- hubLog.error({ port, host }, "hub.port_in_use");
2415
- const wrapped = new Error(conflict.message);
2416
- wrapped.code = "EADDRINUSE";
2417
- reject(wrapped);
2418
- })();
2419
- };
2420
-
2421
- httpServer.once("error", onError);
2422
- httpServer.listen(port, host, () => {
2423
- httpServer.off("error", onError);
2424
- try {
2425
- const boundAddress = httpServer.address();
2426
- if (boundAddress && typeof boundAddress === "object")
2427
- port = boundAddress.port;
2428
- let idleTimer = null;
2429
- let stopPromise = null;
2430
-
2431
- const info = {
2432
- port,
2433
- host,
2434
- dbPath,
2435
- pid: process.pid,
2436
- hubToken: HUB_TOKEN,
2437
- authMode: HUB_TOKEN ? "token-required" : "localhost-only",
2438
- url: buildHubUrl(host, port),
2439
- pipe_path: pipe.path,
2440
- pipePath: pipe.path,
2441
- assign_callback_pipe_path: assignCallbacks.path,
2442
- assignCallbackPipePath: assignCallbacks.path,
2443
- version,
2444
- storeType: store.type || "sqlite",
2445
- idleTimeoutMs: hubIdleTimeoutMs,
2446
- };
2447
-
2448
- if (!ephemeralPort) {
2449
- writeState({
2450
- pid: process.pid,
2451
- port,
2452
- host,
2453
- auth_mode: HUB_TOKEN ? "token-required" : "localhost-only",
2454
- url: info.url,
2455
- pipe_path: pipe.path,
2456
- pipePath: pipe.path,
2457
- assign_callback_pipe_path: assignCallbacks.path,
2458
- assignCallbackPipePath: assignCallbacks.path,
2459
- authMode: HUB_TOKEN ? "token-required" : "localhost-only",
2460
- startedAt,
2461
- started: startedAtMs,
2462
- version,
2463
- sessionId,
2464
- session_id: sessionId,
2465
- });
2466
- }
2467
- releaseStartupLock();
2468
- if (!ephemeralPort)
2469
- void syncHubMcpSettingsIfAvailable({ hubUrl: info.url });
2470
-
2471
- hubLog.info(
2472
- {
2473
- url: info.url,
2474
- pipePath: pipe.path,
2475
- assignCallbackPath: assignCallbacks.path,
2476
- pid: process.pid,
2477
- storeType: info.storeType,
2478
- version,
2479
- },
2480
- "hub.started",
2481
- );
2482
- if (port === HUB_DEFAULT_PORT) {
2483
- void reapExistingHubProcesses({ currentPid: process.pid }).then(
2484
- ({ reaped, failed }) => {
2485
- if (reaped.length > 0) {
2486
- hubLog.info(
2487
- {
2488
- killed: reaped.length,
2489
- processes: reaped,
2490
- caller: "startup",
2491
- },
2492
- "hub.startup_reaper",
2493
- );
2494
- }
2495
- // Surface kill-unable orphans (EPERM/defunct). Without this the
2496
- // reaper's failed[] is dropped and an unreapable hub survives
2497
- // with zero log signal (FU3 observability gap).
2498
- if (failed && failed.length > 0) {
2499
- hubLog.warn(
2500
- {
2501
- failed: failed.length,
2502
- failedCount: failed.length,
2503
- processes: failed,
2504
- caller: "startup",
2505
- },
2506
- "hub.startup_reaper_failed",
2507
- );
2508
- }
2509
- },
2510
- );
2511
- }
2512
- hubLog.debug(
2513
- {
2514
- publicDir: PUBLIC_DIR,
2515
- exists: existsSync(PUBLIC_DIR),
2516
- hasDashboard: existsSync(resolve(PUBLIC_DIR, "dashboard.html")),
2517
- },
2518
- "hub.public_dir",
2519
- );
2520
-
2521
- /**
2522
- * Hub 서버 정지 함수.
2523
- *
2524
- * Trade-off (F01 — 영구 poisoning 허용):
2525
- * 첫 정지 호출에서 cleanup 파이프라인이 실패하면 stopPromise는 실패 상태로
2526
- * 고정되고, 이후 모든 호출은 동일한 실패 promise를 반환합니다. stopPromise를
2527
- * null로 리셋하면 재시도가 가능하지만, router sweeper / transports / pipe 등이
2528
- * 이미 부분 해제된 상태에서 두 번째 close가 실행되면 use-after-close 및
2529
- * race condition을 유발합니다. 실패한 stopFn은 프로세스 전체 재시작으로 복구해야
2530
- * 하며, 이 동작은 의도된 설계입니다.
2531
- */
2532
- const stopFn = async () => {
2533
- if (stopPromise) return stopPromise;
2534
-
2535
- stopPromise = (async () => {
2536
- router.stopSweeper();
2537
- clearInterval(hitlTimer);
2538
- clearInterval(sessionTimer);
2539
- clearInterval(synapsePruneTimer);
2540
- clearInterval(rateLimitTimer);
2541
- clearInterval(orphanCleanupTimer);
2542
- if (idleTimer) {
2543
- clearInterval(idleTimer);
2544
- }
2545
- for (const [sid, session] of Array.from(transports)) {
2546
- await closeMcpTransportSession(sid, session, "hub.stop");
2547
- }
2548
- transports.clear();
2549
- await pipe.stop();
2550
- await assignCallbacks.stop();
2551
- await delegatorWorker.stop().catch(() => {});
2552
- try {
2553
- synapseRegistry.destroy();
2554
- } catch {}
2555
- store.close();
2556
- if (!ephemeralPort) {
2557
- cleanupOwnedPidFile();
2558
- cleanupOwnedTokenFile({ token: HUB_TOKEN, port });
2559
- }
2560
- httpServer.closeAllConnections();
2561
- await new Promise((resolveClose) =>
2562
- httpServer.close(resolveClose),
2563
- );
2564
- })().catch((error) => {
2565
- hubLog.error(
2566
- { err: String(error?.message || error) },
2567
- "hub.stop_error",
2568
- );
2569
- // stopPromise를 null로 리셋하지 않음 — double-close 방지
2570
- });
2571
-
2572
- return stopPromise;
2573
- };
2574
-
2575
- if (hubIdleTimeoutMs > 0) {
2576
- idleTimer = setInterval(() => {
2577
- const idleMs = Date.now() - lastRequestAt;
2578
- if (idleMs < hubIdleTimeoutMs) return;
2579
- hubLog.warn(
2580
- { idleMs, idleTimeoutMs: hubIdleTimeoutMs, port },
2581
- "hub.idle_timeout_shutdown",
2582
- );
2583
- void stopFn().catch((error) => {
2584
- hubLog.error(
2585
- {
2586
- err: error,
2587
- idleMs,
2588
- idleTimeoutMs: hubIdleTimeoutMs,
2589
- port,
2590
- },
2591
- "hub.idle_timeout_shutdown_failed",
2592
- );
2593
- });
2594
- }, hubIdleSweepMs);
2595
- idleTimer.unref();
2596
- }
2597
-
2598
- resolveHub({
2599
- reused: false,
2600
- external: false,
2601
- ...info,
2602
- httpServer,
2603
- store,
2604
- router,
2605
- hitl,
2606
- pipe,
2607
- assignCallbacks,
2608
- delegatorService,
2609
- delegatorWorker,
2610
- getMcpSessions: () =>
2611
- Array.from(transports.entries()).map(([id, session]) => ({
2612
- id,
2613
- client: session.client || "unknown",
2614
- createdAt: session.createdAt ?? null,
2615
- closing: Boolean(session.closing),
2616
- })),
2617
- stop: stopFn,
2618
- });
2619
- } catch (error) {
2620
- void cleanupStartupFailure().finally(() => reject(error));
2621
- }
2622
- });
2623
- };
2624
-
2625
- listenOnce();
2626
- });
2627
- }
2628
-
2629
- export function getHubInfo() {
2630
- return readState();
2631
- }
2632
-
2633
- /**
2634
- * MCP 서버 싱글톤 팩토리 — 이미 실행 중인 서버가 있으면 재사용하고,
2635
- * 없거나 응답하지 않으면 새로 startHub()를 호출합니다.
2636
- *
2637
- * @param {object} [opts] - startHub()에 전달할 옵션 (port, dbPath, host, sessionId 등)
2638
- * @param {object} [opts._deps] - 테스트용 의존성 주입 (isHealthy, getInfo, readState, startHub)
2639
- * @returns {Promise<{reused: boolean, port: number, pid: number, url: string, stop?: Function}>}
2640
- */
2641
- export async function getOrCreateServer(opts = {}) {
2642
- const { _deps, ...startOpts } = opts;
2643
- const existingHub = await tryReuseExistingHub({
2644
- port: startOpts.port,
2645
- host: startOpts.host,
2646
- checkHealth: _deps?.isHealthy ?? isServerHealthy,
2647
- readInfo: _deps?.getInfo ?? getHubInfo,
2648
- readCurrentState: _deps?.readState ?? readState,
2649
- });
2650
- const boot = _deps?.startHub ?? startHub;
2651
-
2652
- if (existingHub) return existingHub;
2653
-
2654
- const server = await boot(startOpts);
2655
- return server.reused === true ? server : { reused: false, ...server };
2656
- }
2657
-
2658
- /**
2659
- * stale tfx-spawn-* psmux 세션을 감지하고 정리한다.
2660
- * 30분 이상 경과 + pane이 idle 쉘 프롬프트만 표시 → kill.
2661
- * @param {object} [log] logger (optional)
2662
- * @returns {number} killed session count
2663
- */
2664
- function cleanupStaleSpawnSessions(log) {
2665
- const MAX_AGE_MS = 30 * 60 * 1000;
2666
- const IDLE_PROMPT_RE =
2667
- /^(PS\s|[$%>#]\s*$|\w+@[\w.-]+[:\s]|╰─|╭─|[fb]wd-i-search:|client_loop:\s|Connection\s+(reset|closed))/;
2668
- const execOpts = {
2669
- encoding: "utf8",
2670
- timeout: 5000,
2671
- stdio: ["pipe", "pipe", "pipe"],
2672
- windowsHide: true,
2673
- };
2674
-
2675
- let killed = 0;
2676
- let raw;
2677
- try {
2678
- raw = execSyncHub("psmux list-sessions", execOpts);
2679
- } catch {
2680
- return 0; // psmux 없거나 실패
2681
- }
2682
-
2683
- const now = Date.now();
2684
- for (const line of raw.split(/\r?\n/)) {
2685
- const match = line.match(
2686
- /^(tfx-spawn-[^:]+):\s+\d+\s+windows?\s+\(created\s+(.+)\)/,
2687
- );
2688
- if (!match) continue;
2689
-
2690
- const [, sessionName, createdStr] = match;
2691
- if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) continue; // shell injection 방지
2692
- const created = new Date(createdStr).getTime();
2693
- if (!Number.isFinite(created) || now - created < MAX_AGE_MS) continue;
2694
-
2695
- // pane 내용 확인 — 마지막 3줄 중 idle 쉘 프롬프트가 있는지
2696
- try {
2697
- const pane = execSyncHub(
2698
- `psmux capture-pane -t "${sessionName}:0.0" -p`,
2699
- execOpts,
2700
- );
2701
- const tailLines = pane
2702
- .split(/\r?\n/)
2703
- .filter((l) => l.trim())
2704
- .slice(-3);
2705
- const hasIdleLine = tailLines.some((l) => IDLE_PROMPT_RE.test(l.trim()));
2706
- if (!hasIdleLine) continue; // 아직 활성 — 건드리지 않음
2707
- } catch {
2708
- continue; // pane 접근 실패 — 건드리지 않음
2709
- }
2710
-
2711
- // stale + idle → 정리
2712
- try {
2713
- execSyncHub(`psmux kill-session -t "${sessionName}"`, execOpts);
2714
- killed++;
2715
- if (log)
2716
- log.info(
2717
- { session: sessionName, ageMin: Math.round((now - created) / 60000) },
2718
- "hub.stale_spawn_killed",
2719
- );
2720
- } catch {}
2721
- }
2722
-
2723
- return killed;
2724
- }
2725
-
2726
- // ── Quota check for all accounts ───────────────────────────────
2727
-
2728
- const QUOTA_CACHE_PATH = join(CACHE_DIR, "broker-quota-cache.json");
2729
-
2730
- async function checkSingleAccountQuota(acct) {
2731
- try {
2732
- const authPath = join(PID_DIR, acct.authFile);
2733
- if (!authPath.startsWith(PID_DIR + sep))
2734
- return { id: acct.id, status: "path_blocked" };
2735
- if (!existsSync(authPath)) return { id: acct.id, status: "no_auth" };
2736
- const auth = JSON.parse(readFileSync(authPath, "utf8"));
2737
- if (acct.provider === "codex") {
2738
- const token = auth.tokens?.access_token || auth.OPENAI_API_KEY || "";
2739
- if (!token) return { id: acct.id, status: "no_token" };
2740
- const r = await fetch("https://api.openai.com/v1/chat/completions", {
2741
- method: "POST",
2742
- signal: AbortSignal.timeout(8000),
2743
- headers: {
2744
- Authorization: "Bearer " + token,
2745
- "Content-Type": "application/json",
2746
- },
2747
- body: JSON.stringify({
2748
- model: "gpt-4.1-nano",
2749
- messages: [{ role: "user", content: "hi" }],
2750
- max_tokens: 1,
2751
- }),
2752
- });
2753
- const hdrs = Object.fromEntries(
2754
- [...r.headers.entries()].filter(([k]) => /ratelimit/i.test(k)),
2755
- );
2756
- if (r.status === 429)
2757
- return { id: acct.id, status: "quota_hit", http: 429, headers: hdrs };
2758
- if (r.status === 401) {
2759
- // 401이어도 ratelimit 헤더가 있으면 쿼터 정보 추출 가능
2760
- return { id: acct.id, status: "auth_error", http: 401, headers: hdrs };
2761
- }
2762
- return {
2763
- id: acct.id,
2764
- status: r.ok ? "ok" : "error",
2765
- http: r.status,
2766
- headers: hdrs,
2767
- };
2768
- }
2769
- // gemini — OAuth token refresh needed for accurate check
2770
- return { id: acct.id, status: "oauth_check_needed" };
2771
- } catch (e) {
2772
- return {
2773
- id: acct.id,
2774
- status: "error",
2775
- message: e.message?.substring(0, 60),
2776
- };
2777
- }
2778
- }
2779
-
2780
- async function refreshAllAccountQuotas() {
2781
- const snap = brokerInstance?.snapshot() || [];
2782
- const checks = snap
2783
- .filter((a) => a.authFile)
2784
- .map((a) => checkSingleAccountQuota(a));
2785
- const settled = await Promise.allSettled(checks);
2786
- const results = settled.map((s, i) =>
2787
- s.status === "fulfilled"
2788
- ? s.value
2789
- : {
2790
- id: snap.filter((a) => a.authFile)[i]?.id ?? "unknown",
2791
- status: "error",
2792
- message: String(s.reason?.message || s.reason).substring(0, 60),
2793
- },
2794
- );
2795
- // 캐시 저장
2796
- try {
2797
- writeFileSync(
2798
- QUOTA_CACHE_PATH,
2799
- JSON.stringify({ ts: Date.now(), results }),
2800
- );
2801
- } catch {
2802
- /* best-effort */
2803
- }
2804
- return results;
2805
- }
2806
-
2807
- function _loadQuotaCache() {
2808
- try {
2809
- if (!existsSync(QUOTA_CACHE_PATH)) return null;
2810
- return JSON.parse(readFileSync(QUOTA_CACHE_PATH, "utf8"));
2811
- } catch {
2812
- return null;
2813
- }
2814
- }
2815
-
2816
- // ── Broker Dashboard HTML ──────────────────────────────────────
2817
-
2818
- function renderBrokerDashboard() {
2819
- return `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8">
2820
- <title>Account Broker Dashboard</title>
2821
- <style>
2822
- *{margin:0;padding:0;box-sizing:border-box}
2823
- body{background:#0a0a0b;color:#e8e8ec;font:14px/1.6 'SF Mono',Consolas,monospace;padding:24px}
2824
- h1{font-size:18px;color:#a8b1ff;margin-bottom:8px}
2825
- .toolbar{margin-bottom:16px;display:flex;gap:8px;align-items:center}
2826
- .toolbar button{background:#1a1a2e;color:#a8b1ff;border:1px solid rgba(255,255,255,0.1);border-radius:4px;padding:4px 12px;cursor:pointer;font:12px inherit}
2827
- .toolbar button:hover{background:#252547}
2828
- .toolbar .status-text{font-size:11px;color:#555}
2829
- .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:12px}
2830
- .card{background:#141416;border:1px solid rgba(255,255,255,0.06);border-radius:8px;padding:16px}
2831
- .card.available{border-left:3px solid #34d399}
2832
- .card.busy{border-left:3px solid #fbbf24}
2833
- .card.cooldown{border-left:3px solid #f87171}
2834
- .card.circuit-open{border-left:3px solid #ef4444;opacity:0.7}
2835
- .provider{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#888}
2836
- .id{font-size:13px;color:#c8c8d0;margin:4px 0}
2837
- .status{font-size:12px;font-weight:600;padding:2px 8px;border-radius:4px;display:inline-block}
2838
- .status.available{background:#064e3b;color:#34d399}
2839
- .status.busy{background:#451a03;color:#fbbf24}
2840
- .status.cooldown{background:#450a0a;color:#f87171}
2841
- .status.circuit-open{background:#450a0a;color:#ef4444}
2842
- .gauge{margin-top:8px}
2843
- .gauge-row{display:flex;align-items:center;gap:8px;margin:3px 0}
2844
- .gauge-label{font-size:10px;color:#888;width:28px;text-align:right}
2845
- .gauge-bar{flex:1;height:8px;background:#1a1a1e;border-radius:4px;overflow:hidden}
2846
- .gauge-fill{height:100%;border-radius:4px;transition:width .3s}
2847
- .gauge-fill.green{background:linear-gradient(90deg,#34d399,#059669)}
2848
- .gauge-fill.yellow{background:linear-gradient(90deg,#fbbf24,#d97706)}
2849
- .gauge-fill.red{background:linear-gradient(90deg,#f87171,#dc2626)}
2850
- .gauge-pct{font-size:10px;color:#999;width:32px}
2851
- .gauge-reset{font-size:9px;color:#555}
2852
- .meta{margin-top:6px;font-size:11px;color:#666;line-height:1.8}
2853
- .meta b{color:#999}
2854
- .timer{color:#f87171;font-weight:600}
2855
- .refresh-bar{color:#555;font-size:11px;margin-top:16px}
2856
- </style></head><body>
2857
- <h1>Account Broker Dashboard</h1>
2858
- <div class="toolbar">
2859
- <button onclick="checkQuotas()">Check All Quotas</button>
2860
- <button onclick="reloadBroker()">Reload Broker</button>
2861
- <span id="toolbar-status" class="status-text"></span>
2862
- </div>
2863
- <div id="grid" class="grid"></div>
2864
- <p class="refresh-bar">auto-refresh: 10s | <a href="/broker/snapshot" style="color:#666">JSON</a> | <a href="/broker/quota-refresh" style="color:#666">Quota API</a></p>
2865
- <script>
2866
- let quotaData={};
2867
- function fmt(ms){if(ms<=0)return'-';const s=Math.floor(ms/1000),m=Math.floor(s/60),h=Math.floor(m/60),d=Math.floor(h/24);if(d>0)return d+'d '+h%24+'h';if(h>0)return h+'h '+m%60+'m';if(m>0)return m+'m '+s%60+'s';return s+'s'}
2868
-
2869
- function gaugeColor(pct){return pct<60?'green':pct<85?'yellow':'red'}
2870
-
2871
- function renderGauge(label,pct,resetTs){
2872
- if(pct==null)return '';
2873
- const now=Date.now()/1000;
2874
- const resetIn=resetTs>now?fmt((resetTs-now)*1000):'';
2875
- return '<div class="gauge-row"><span class="gauge-label">'+label+'</span>'+
2876
- '<div class="gauge-bar"><div class="gauge-fill '+gaugeColor(pct)+'" style="width:'+pct+'%"></div></div>'+
2877
- '<span class="gauge-pct">'+pct+'%</span>'+
2878
- (resetIn?'<span class="gauge-reset">reset '+resetIn+'</span>':'')+
2879
- '</div>';
2880
- }
2881
-
2882
- function renderQuotaGauges(acctId){
2883
- const q=quotaData[acctId];
2884
- if(!q)return '<div class="gauge"><div class="gauge-row"><span style="font-size:10px;color:#444">quota: click Check All Quotas</span></div></div>';
2885
- if(q.status==='quota_hit')return '<div class="gauge"><div class="gauge-row"><span style="font-size:10px;color:#f87171">QUOTA EXHAUSTED</span></div></div>';
2886
- if(q.status==='auth_error'||q.status==='no_token'||q.status==='oauth_check_needed'){
2887
- // 401이어도 ratelimit 헤더가 있으면 표시
2888
- const h=q.headers||{};
2889
- const limReq=h['x-ratelimit-limit-requests'],remReq=h['x-ratelimit-remaining-requests'];
2890
- const limTok=h['x-ratelimit-limit-tokens'],remTok=h['x-ratelimit-remaining-tokens'];
2891
- if(limReq){
2892
- const pctReq=Math.round((1-remReq/limReq)*100);
2893
- const pctTok=limTok?Math.round((1-remTok/limTok)*100):null;
2894
- return '<div class="gauge">'+renderGauge('req',pctReq,0)+(pctTok!=null?renderGauge('tok',pctTok,0):'')+'</div>';
2895
- }
2896
- return '<div class="gauge"><div class="gauge-row"><span style="font-size:10px;color:#fbbf24">'+(q.status==='oauth_check_needed'?'OAuth refresh needed':'auth: token refresh needed')+'</span></div></div>';
2897
- }
2898
- if(q.status==='ok'||q.status==='error'){
2899
- const h=q.headers||{};
2900
- const limReq=h['x-ratelimit-limit-requests'],remReq=h['x-ratelimit-remaining-requests'];
2901
- const limTok=h['x-ratelimit-limit-tokens'],remTok=h['x-ratelimit-remaining-tokens'];
2902
- const resetReq=h['x-ratelimit-reset-requests'],resetTok=h['x-ratelimit-reset-tokens'];
2903
- if(!limReq)return '';
2904
- const pctReq=Math.round((1-remReq/limReq)*100);
2905
- const pctTok=limTok?Math.round((1-remTok/limTok)*100):null;
2906
- return '<div class="gauge">'+renderGauge('req',pctReq,0)+(pctTok!=null?renderGauge('tok',pctTok,0):'')+'</div>';
2907
- }
2908
- return '';
2909
- }
2910
-
2911
- async function refresh(){
2912
- try{
2913
- const r=await fetch('/broker/snapshot');
2914
- const d=await r.json();
2915
- if(!d.ok)return;
2916
- const now=d.ts;
2917
- const grid=document.getElementById('grid');
2918
- grid.innerHTML=d.accounts.map(a=>{
2919
- const cd=a.cooldownUntil>now?a.cooldownUntil-now:0;
2920
- const rm=a.remainingMs||0;
2921
- let st='available',sl='Available';
2922
- if(a.circuitState==='open'){st='circuit-open';sl='Circuit Open'}
2923
- else if(cd>0){st='cooldown';sl='Cooldown'}
2924
- else if(a.busy){st='busy';sl='Busy ('+fmt(rm)+')'}
2925
- return '<div class="card '+st+'">'+
2926
- '<div class="provider">'+a.provider+' / '+(a.tier||'unknown')+'</div>'+
2927
- '<div class="id">'+a.id+'</div>'+
2928
- '<span class="status '+st+'">'+sl+'</span>'+
2929
- (cd>0?'<span class="timer" style="margin-left:8px">'+fmt(cd)+' remaining</span>':'')+
2930
- renderQuotaGauges(a.id)+
2931
- '<div class="meta">'+
2932
- '<b>Sessions:</b> '+a.totalSessions+
2933
- (a.lastUsedAt?' | <b>Last:</b> '+new Date(a.lastUsedAt).toLocaleTimeString():'')+
2934
- (a.failureTimestamps?.length?' | <b>Failures:</b> '+a.failureTimestamps.length:'')+
2935
- (a.circuitState!=='closed'?' | <b>Circuit:</b> '+a.circuitState:'')+
2936
- '</div></div>'
2937
- }).join('');
2938
- }catch(e){console.error('refresh failed',e)}
2939
- }
2940
-
2941
- async function checkQuotas(){
2942
- const el=document.getElementById('toolbar-status');
2943
- el.textContent='checking quotas...';
2944
- try{
2945
- const r=await fetch('/broker/quota-refresh',{method:'POST'});
2946
- const d=await r.json();
2947
- if(d.ok){
2948
- d.results.forEach(q=>{quotaData[q.id]=q});
2949
- el.textContent='updated '+d.results.length+' accounts ('+new Date().toLocaleTimeString()+')';
2950
- refresh();
2951
- }else{el.textContent='error';}
2952
- }catch(e){el.textContent='failed: '+e.message;}
2953
- }
2954
-
2955
- async function reloadBroker(){
2956
- await fetch('/broker/reload',{method:'POST'});
2957
- document.getElementById('toolbar-status').textContent='broker reloaded';
2958
- refresh();
2959
- }
2960
-
2961
- // 캐시에서 초기 로드
2962
- fetch('/broker/quota-refresh').catch(()=>{});
2963
- refresh();setInterval(refresh,10000);
2964
- </script></body></html>`;
2965
- }
2966
-
2967
- const selfRun = process.argv[1]?.replace(/\\/g, "/").endsWith("hub/server.mjs");
2968
- if (selfRun) {
2969
- const port = resolveHubPortForContext({
2970
- env: process.env,
2971
- cwd: process.cwd(),
2972
- });
2973
- const dbPath = process.env.TFX_HUB_DB || undefined;
2974
-
2975
- const cleanupPidFile = () => {
2976
- cleanupOwnedPidFile();
2977
- };
2978
-
2979
- process.on("unhandledRejection", (err) => {
2980
- hubLog.fatal({ err }, "hub.unhandledRejection");
2981
- cleanupPidFile();
2982
- process.exit(1);
2983
- });
2984
- process.on("uncaughtException", (err) => {
2985
- hubLog.fatal({ err }, "hub.uncaughtException");
2986
- cleanupPidFile();
2987
- process.exit(1);
2988
- });
2989
-
2990
- startHub({ port, dbPath })
2991
- .then((info) => {
2992
- if (info?.reused && info?.external) {
2993
- hubLog.info(
2994
- { pid: info.pid, port: info.port, url: info.url },
2995
- "hub.already_running_exit",
2996
- );
2997
- process.exit(0);
2998
- return;
2999
- }
3000
- const shutdown = async (signal) => {
3001
- hubLog.info({ signal }, "hub.stopping");
3002
- try {
3003
- if (process.env.TFX_DISABLE_ORPHAN_CLEANUP !== "1") {
3004
- const { killed: orphanKilled, killedProcesses = [] } =
3005
- cleanupOrphanNodeProcesses();
3006
- const {
3007
- killed: runtimeKilled,
3008
- killedProcesses: runtimeKilledProcesses = [],
3009
- } = cleanupOrphanRuntimeProcesses();
3010
- const totalKilled = orphanKilled + runtimeKilled;
3011
- if (totalKilled > 0) {
3012
- hubLog.info(
3013
- {
3014
- killed: totalKilled,
3015
- processes: [...killedProcesses, ...runtimeKilledProcesses],
3016
- caller: "shutdown",
3017
- },
3018
- "hub.orphan_cleanup",
3019
- );
3020
- }
3021
- }
3022
- const { killed, stale } = cleanupStaleFsmonitorDaemons({
3023
- minAgeMs: 24 * 60 * 60 * 1000,
3024
- });
3025
- if (killed > 0) {
3026
- hubLog.info(
3027
- { killed, stale: stale.length },
3028
- "hub.fsmonitor_cleanup",
3029
- );
3030
- }
3031
- } catch {}
3032
- try {
3033
- cleanupStaleSpawnSessions(hubLog);
3034
- } catch {}
3035
- await info.stop();
3036
- process.exit(0);
3037
- };
3038
- process.on("SIGINT", () => shutdown("SIGINT"));
3039
- process.on("SIGTERM", () => shutdown("SIGTERM"));
3040
- })
3041
- .catch((error) => {
3042
- hubLog.fatal({ err: error }, "hub.start_failed");
3043
- cleanupPidFile();
3044
- process.exit(1);
3045
- });
3046
- }
3047
-
3048
- export { startHub as createServer };