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