@triflux/core 10.27.0 → 10.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -142,7 +142,7 @@ function contextPercentsFromObject(value) {
142
142
  ];
143
143
 
144
144
  const currentUsage = value.current_usage ?? value.currentUsage ?? {};
145
- const maxTokens =
145
+ const explicitMaxTokens =
146
146
  value.context_window_size ??
147
147
  value.contextWindowSize ??
148
148
  value.max_context_tokens ??
@@ -151,6 +151,18 @@ function contextPercentsFromObject(value) {
151
151
  value.maxTokens ??
152
152
  value.total_tokens ??
153
153
  value.totalTokens;
154
+ // Fall back to a 1M context window when the payload omits one — Opus 4.x
155
+ // [1M] and Codex gpt-5.5 both run on a 1M window, and assuming 200K there
156
+ // false-positives at ~15% real usage. Override via
157
+ // TFX_CONTEXT_DEFAULT_MAX_TOKENS for narrower setups.
158
+ const envFallback = Number(process.env.TFX_CONTEXT_DEFAULT_MAX_TOKENS);
159
+ const fallbackMaxTokens =
160
+ Number.isFinite(envFallback) && envFallback > 0 ? envFallback : 1_000_000;
161
+ const explicitNumeric = Number(explicitMaxTokens);
162
+ const maxTokens =
163
+ Number.isFinite(explicitNumeric) && explicitNumeric > 0
164
+ ? explicitNumeric
165
+ : fallbackMaxTokens;
154
166
  candidates.push(
155
167
  tokenPercent(value.used_tokens ?? value.usedTokens, maxTokens),
156
168
  tokenPercent(
@@ -1,8 +1,14 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs/promises";
3
4
  import net from "node:net";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
7
+ import {
8
+ buildClaudeSessionProjection,
9
+ removeClaudeSessionProjection,
10
+ writeClaudeSessionProjection,
11
+ } from "./claude-session-projection.mjs";
6
12
 
7
13
  export function resolveClaudeConfigDir(env = process.env) {
8
14
  if (env.CLAUDE_CONFIG_DIR) return path.resolve(env.CLAUDE_CONFIG_DIR);
@@ -26,12 +32,23 @@ export function deriveClaudeDaemonPaths({
26
32
  hash,
27
33
  daemonDir,
28
34
  controlSock: path.join(daemonDir, "control.sock"),
35
+ rendezvousDir: path.join(daemonDir, "rv"),
36
+ ptyDir: path.join(daemonDir, "pty"),
29
37
  rosterPath: path.join(resolvedConfigDir, "daemon", "roster.json"),
30
38
  sessionsDir: path.join(resolvedConfigDir, "sessions"),
31
39
  jobsDir: path.join(resolvedConfigDir, "jobs"),
32
40
  };
33
41
  }
34
42
 
43
+ export function getProcStart(pid = process.pid) {
44
+ if (!Number.isInteger(pid) || pid <= 0)
45
+ throw new Error(`invalid pid: ${pid}`);
46
+ return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
47
+ encoding: "utf8",
48
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
49
+ }).trim();
50
+ }
51
+
35
52
  export function readFirstJsonLine(text) {
36
53
  const line = String(text)
37
54
  .split("\n")
@@ -991,6 +1008,52 @@ export function buildDaemonExecDispatchPayload({
991
1008
  };
992
1009
  }
993
1010
 
1011
+ export function buildClaudePromptDispatchPayload({
1012
+ short = crypto.randomBytes(4).toString("hex"),
1013
+ sessionId,
1014
+ cwd = process.cwd(),
1015
+ prompt,
1016
+ name = "tfx uds claude prompt",
1017
+ createdAt = Date.now(),
1018
+ cols = 120,
1019
+ rows = 40,
1020
+ } = {}) {
1021
+ if (!prompt) throw new Error("prompt is required");
1022
+ const uuid = crypto.randomUUID();
1023
+ const resolvedSessionId = sessionId || `${short}${uuid.slice(8)}`;
1024
+ return {
1025
+ proto: 1,
1026
+ short,
1027
+ sessionId: resolvedSessionId,
1028
+ createdAt,
1029
+ source: "shell",
1030
+ cwd,
1031
+ agent: "claude",
1032
+ launch: {
1033
+ mode: "prompt",
1034
+ args: [
1035
+ "--session-id",
1036
+ resolvedSessionId,
1037
+ "--agent",
1038
+ "claude",
1039
+ "--permission-mode",
1040
+ "auto",
1041
+ "--",
1042
+ prompt,
1043
+ ],
1044
+ },
1045
+ env: {},
1046
+ isolation: "none",
1047
+ respawnFlags: [],
1048
+ seed: {
1049
+ intent: "[redacted uds orchestration prompt]",
1050
+ name,
1051
+ },
1052
+ cols,
1053
+ rows,
1054
+ };
1055
+ }
1056
+
994
1057
  export function findDaemonJobByShort(listResponse, short) {
995
1058
  if (!Array.isArray(listResponse?.jobs)) return null;
996
1059
  return listResponse.jobs.find((job) => job?.short === short) || null;
@@ -1128,3 +1191,154 @@ export async function sendKillBySessionId({
1128
1191
  };
1129
1192
  }
1130
1193
  }
1194
+
1195
+ /**
1196
+ * Claude 데몬 dispatch 한 건의 공통 시퀀스를 캡슐화한다.
1197
+ * dispatch → waitForDaemonJobPid → resolveDaemonBridgeSessionId →
1198
+ * buildClaudeSessionProjection → writeClaudeSessionProjection.
1199
+ *
1200
+ * 호출 측이 reference 로 들고 있는 record 를 헬퍼가 새로 만들지 않도록
1201
+ * plain 필드만 반환한다. caller 는 이 결과를 자기 record 에 Object.assign 한다.
1202
+ *
1203
+ * @returns {Promise<{ok:boolean, short:string, sessionId:string, job:object,
1204
+ * pid:number, bridgeSessionId:string, sessionProjectionPath:string,
1205
+ * controlSock:string, paths:object}>}
1206
+ */
1207
+ export async function dispatchClaudeDaemonJob({
1208
+ paths,
1209
+ controlSock,
1210
+ payload,
1211
+ agent,
1212
+ name,
1213
+ cwd,
1214
+ projection = "write",
1215
+ dispatchTimeoutMs = 5000,
1216
+ pidTimeoutMs,
1217
+ bridgeTimeoutMs,
1218
+ accessControlSock,
1219
+ _deps = {},
1220
+ } = {}) {
1221
+ if (!payload) throw new Error("payload is required");
1222
+ if (!payload.short) throw new Error("payload.short is required");
1223
+ const resolvedControlSock = controlSock || paths?.controlSock;
1224
+ if (!resolvedControlSock) throw new Error("controlSock is required");
1225
+
1226
+ const sendControl =
1227
+ _deps.sendClaudeControlRequest || sendClaudeControlRequest;
1228
+ const waitForPid = _deps.waitForDaemonJobPid || waitForDaemonJobPid;
1229
+ const resolveBridgeSessionId =
1230
+ _deps.resolveDaemonBridgeSessionId || resolveDaemonBridgeSessionId;
1231
+ const buildProjection =
1232
+ _deps.buildClaudeSessionProjection || buildClaudeSessionProjection;
1233
+ const writeProjection =
1234
+ _deps.writeClaudeSessionProjection || writeClaudeSessionProjection;
1235
+ const readProcStart = _deps.getProcStart || getProcStart;
1236
+
1237
+ const short = payload.short;
1238
+ const sessionsDir =
1239
+ paths?.sessionsDir ||
1240
+ (paths?.configDir ? path.join(paths.configDir, "sessions") : "");
1241
+
1242
+ // native-bridge 는 control.sock 존재를 미리 점검한다 (없으면 fast-fail).
1243
+ if (accessControlSock) await accessControlSock(resolvedControlSock);
1244
+
1245
+ const dispatch = await sendControl(
1246
+ resolvedControlSock,
1247
+ {
1248
+ proto: 1,
1249
+ op: "dispatch",
1250
+ d: payload,
1251
+ timeoutMs: dispatchTimeoutMs,
1252
+ },
1253
+ { timeoutMs: dispatchTimeoutMs },
1254
+ );
1255
+ if (dispatch?.ok !== true) {
1256
+ throw new Error(`Claude daemon dispatch failed for ${name || short}`);
1257
+ }
1258
+
1259
+ const pidOpts =
1260
+ pidTimeoutMs === undefined ? undefined : { timeoutMs: pidTimeoutMs };
1261
+ const job = await waitForPid(resolvedControlSock, short, pidOpts);
1262
+ const pid = job.pid;
1263
+ const bridgeSessionId = await resolveBridgeSessionId({
1264
+ daemonPaths: paths,
1265
+ short,
1266
+ job,
1267
+ ...(bridgeTimeoutMs === undefined ? {} : { timeoutMs: bridgeTimeoutMs }),
1268
+ });
1269
+
1270
+ let sessionProjectionPath = "";
1271
+ if (projection !== "skip") {
1272
+ const projectionRecord = buildProjection({
1273
+ pid,
1274
+ procStart: readProcStart(pid),
1275
+ sessionId: payload.sessionId,
1276
+ short,
1277
+ cwd,
1278
+ name,
1279
+ agent,
1280
+ startedAt: job.startedAt || Date.now(),
1281
+ updatedAt: Date.now(),
1282
+ bridgeSessionId,
1283
+ });
1284
+ sessionProjectionPath = await writeProjection(
1285
+ sessionsDir,
1286
+ projectionRecord,
1287
+ );
1288
+ }
1289
+
1290
+ return {
1291
+ ok: true,
1292
+ short,
1293
+ sessionId: payload.sessionId,
1294
+ job,
1295
+ pid,
1296
+ bridgeSessionId,
1297
+ sessionProjectionPath,
1298
+ controlSock: resolvedControlSock,
1299
+ paths,
1300
+ };
1301
+ }
1302
+
1303
+ /**
1304
+ * dispatchClaudeDaemonJob 의 대칭 정리 경로.
1305
+ * removeProjection + killDaemonJob (+ optional removeClaudeJobState) 를
1306
+ * 모두 시도하며, 각 스텝 오류는 기존 close()/cleanupDaemonDispatches 와
1307
+ * 동일하게 .catch 로 삼킨다.
1308
+ */
1309
+ export async function teardownClaudeDaemonJob({
1310
+ controlSock,
1311
+ paths,
1312
+ short,
1313
+ sessionProjectionPath,
1314
+ sessionId,
1315
+ jobsDir,
1316
+ removeJobState = false,
1317
+ _deps = {},
1318
+ } = {}) {
1319
+ const removeProjection =
1320
+ _deps.removeClaudeSessionProjection || removeClaudeSessionProjection;
1321
+ const killJob = _deps.killDaemonJob || killDaemonJob;
1322
+ const removeJobStateImpl = _deps.removeClaudeJobState;
1323
+ const resolvedControlSock = controlSock || paths?.controlSock;
1324
+ const resolvedJobsDir =
1325
+ jobsDir ||
1326
+ paths?.jobsDir ||
1327
+ (paths?.configDir ? path.join(paths.configDir, "jobs") : "");
1328
+
1329
+ const steps = [];
1330
+ if (sessionProjectionPath) {
1331
+ steps.push(removeProjection(sessionProjectionPath).catch(() => {}));
1332
+ }
1333
+ if (sessionId && (paths?.controlSock || resolvedControlSock)) {
1334
+ const daemonPaths = paths || { controlSock: resolvedControlSock };
1335
+ steps.push(sendKillBySessionId({ daemonPaths, sessionId }).catch(() => {}));
1336
+ }
1337
+ if (resolvedControlSock && short) {
1338
+ steps.push(killJob(resolvedControlSock, short).catch(() => {}));
1339
+ }
1340
+ if (removeJobState && removeJobStateImpl && resolvedJobsDir && short) {
1341
+ steps.push(removeJobStateImpl(resolvedJobsDir, short).catch(() => {}));
1342
+ }
1343
+ await Promise.all(steps);
1344
+ }
@@ -0,0 +1,74 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export function normalizeClaudeBridgeSessionId(bridgeSessionId, short) {
5
+ const value = String(bridgeSessionId || "").trim();
6
+ if (value.startsWith("session_")) return value;
7
+ if (value.startsWith("cse_")) return `session_${value.slice(4)}`;
8
+ if (value) return value;
9
+ return `session_${short}`;
10
+ }
11
+
12
+ export function buildClaudeSessionProjection({
13
+ pid,
14
+ procStart,
15
+ sessionId,
16
+ short,
17
+ cwd,
18
+ name,
19
+ agent,
20
+ startedAt = Date.now(),
21
+ updatedAt = Date.now(),
22
+ status = "busy",
23
+ version = "triflux-native-bridge",
24
+ bridgeSessionId,
25
+ } = {}) {
26
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error("pid is required");
27
+ if (!procStart) throw new Error("procStart is required");
28
+ if (!sessionId) throw new Error("sessionId is required");
29
+ if (!short) throw new Error("short is required");
30
+ if (!cwd) throw new Error("cwd is required");
31
+ if (!name) throw new Error("name is required");
32
+ if (!agent) throw new Error("agent is required");
33
+ return {
34
+ pid,
35
+ sessionId,
36
+ cwd,
37
+ startedAt,
38
+ procStart,
39
+ version,
40
+ peerProtocol: 1,
41
+ kind: "bg",
42
+ entrypoint: "cli",
43
+ name,
44
+ agent,
45
+ jobId: short,
46
+ status,
47
+ updatedAt,
48
+ bridgeSessionId: normalizeClaudeBridgeSessionId(bridgeSessionId, short),
49
+ };
50
+ }
51
+
52
+ export async function writeClaudeSessionProjection(sessionsDir, projection) {
53
+ await fs.mkdir(sessionsDir, { recursive: true });
54
+ const sessionPath = path.join(sessionsDir, `${projection.pid}.json`);
55
+ const tmpPath = `${sessionPath}.${process.pid}.tmp`;
56
+ await fs.writeFile(tmpPath, `${JSON.stringify(projection)}\n`, "utf8");
57
+ await fs.rename(tmpPath, sessionPath);
58
+ return sessionPath;
59
+ }
60
+
61
+ export async function updateClaudeSessionProjection(sessionPath, patch) {
62
+ const parsed = JSON.parse(await fs.readFile(sessionPath, "utf8"));
63
+ const next = {
64
+ ...parsed,
65
+ ...patch,
66
+ updatedAt: patch.updatedAt || Date.now(),
67
+ };
68
+ await fs.writeFile(sessionPath, `${JSON.stringify(next)}\n`, "utf8");
69
+ return next;
70
+ }
71
+
72
+ export async function removeClaudeSessionProjection(sessionPath) {
73
+ await fs.rm(sessionPath, { force: true });
74
+ }
@@ -42,10 +42,10 @@ const ESCALATION_CHAIN_CONFIG_PATH = ".triflux/config/escalation-chain.json";
42
42
 
43
43
  // Escalation chain (2026-05-27 정책):
44
44
  // 1. codex gpt-5.5 — 코드/추론/비용에서 mini/5.3-codex 중간 단계를 대체
45
- // 2. claude opus-4-7 — 최종 수단
45
+ // 2. claude opus-4-8 — 최종 수단
46
46
  const DEFAULT_ESCALATION_CHAIN = Object.freeze([
47
47
  Object.freeze({ cli: "codex", model: "gpt-5.5" }),
48
- Object.freeze({ cli: "claude", model: "opus-4-7" }),
48
+ Object.freeze({ cli: "claude", model: "opus-4-8" }),
49
49
  ]);
50
50
 
51
51
  const STUCK_THRESHOLD = 3;
@@ -15,9 +15,10 @@ const MAX_CAPTURE_BYTES = 256 * 1024;
15
15
  const MAX_TOP_KEYS = 20;
16
16
 
17
17
  // stdin 이 context_window_size 를 제공하지 않을 때 모델 ID 로 한도를 추정한다.
18
- // Anthropic 공식 문서(2026-04 기준): Opus 4.7 / Opus 4.6 / Sonnet 4.6 = 1M,
18
+ // Anthropic 공식 문서(2026-04 기준): Opus 4.8 / Opus 4.7 / Opus 4.6 / Sonnet 4.6 = 1M,
19
19
  // Sonnet 4.5 / Haiku 4.5 = 200K. 그 외 모델은 [1m] suffix 로 opt-in 가능.
20
20
  const MODEL_HINT_1M_PREFIXES = [
21
+ "claude-opus-4-8",
21
22
  "claude-opus-4-7",
22
23
  "claude-opus-4-6",
23
24
  "claude-sonnet-4-6",
@@ -41,7 +42,11 @@ function resolveModelLimit(modelId) {
41
42
  export function shouldSuppressInfoOnlyContextStatus(modelId) {
42
43
  const id = normalizeModelId(modelId);
43
44
  if (!id) return false;
44
- return id.startsWith("claude-opus-4-7") || id.includes("[1m]");
45
+ return (
46
+ id.startsWith("claude-opus-4-8") ||
47
+ id.startsWith("claude-opus-4-7") ||
48
+ id.includes("[1m]")
49
+ );
45
50
  }
46
51
 
47
52
  const WARNING_LEVELS = Object.freeze({
@@ -283,11 +288,16 @@ export function buildContextUsageView(stdin, snapshot = null) {
283
288
  const modelHintLimit = resolveModelLimit(modelId);
284
289
  const monitorLimit = Number(monitor?.limitTokens || 0);
285
290
  const stdinLimit = stdinUsage?.limitTokens;
291
+ // When a model id is known it is the authoritative per-model ceiling: the
292
+ // monitor's cached limitTokens is just a default-derived accumulator (now 1M
293
+ // by default) and must not override a known model's real window in either
294
+ // direction — it would inflate a 200K model (Sonnet 4.5 / Haiku) up to 1M.
295
+ // The model hint already upgrades a stale-low monitor on its own (#88).
286
296
  const limitTokens =
287
297
  stdinLimit != null && stdinLimit > 0
288
298
  ? Math.max(1, stdinLimit)
289
299
  : modelId
290
- ? Math.max(1, monitorLimit, modelHintLimit)
300
+ ? Math.max(1, modelHintLimit)
291
301
  : Math.max(1, monitorLimit || modelHintLimit);
292
302
 
293
303
  const usedTokens = stdinUsage?.usedTokens ?? Number(monitor?.usedTokens || 0);
@@ -319,7 +329,19 @@ export function buildContextUsageView(stdin, snapshot = null) {
319
329
  }
320
330
 
321
331
  export function createContextMonitor(options = {}) {
322
- const limitTokens = Number(options.limitTokens || DEFAULT_CONTEXT_LIMIT);
332
+ // Default to a 1M context window (Opus 4.x [1M] / Codex gpt-5.5 both run on
333
+ // a 1M window) when no explicit limit is supplied, so the cached percent the
334
+ // Stop hook reads is not inflated against a 200K assumption — assuming 200K
335
+ // there false-positives at ~15% real usage and blocks legitimate stops.
336
+ // Narrower setups override via TFX_CONTEXT_DEFAULT_MAX_TOKENS.
337
+ const explicitLimit = Number(options.limitTokens);
338
+ const envLimit = Number(process.env.TFX_CONTEXT_DEFAULT_MAX_TOKENS);
339
+ const limitTokens =
340
+ Number.isFinite(explicitLimit) && explicitLimit > 0
341
+ ? explicitLimit
342
+ : Number.isFinite(envLimit) && envLimit > 0
343
+ ? envLimit
344
+ : MILLION_CONTEXT_LIMIT;
323
345
  const cachePath = options.cachePath || CONTEXT_MONITOR_CACHE_PATH;
324
346
  const logsDir = options.logsDir || CONTEXT_MONITOR_LOG_DIR;
325
347
  const sessionId = options.sessionId || randomUUID().slice(0, 8);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.27.0",
3
+ "version": "10.28.1",
4
4
  "description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -14,6 +14,7 @@
14
14
  // 침묵한다 (false-positive 방지).
15
15
 
16
16
  import { readFileSync, statSync } from "node:fs";
17
+ import { createConnection } from "node:net";
17
18
  import { homedir } from "node:os";
18
19
  import { join } from "node:path";
19
20
 
@@ -33,6 +34,27 @@ const START_RE = /^\[START\]\s+(\S+)\s+on\s+:(\d+)$/;
33
34
  const SKIP_RUNNING_RE = /^\[SKIP\]\s+(\S+)\s+already running on\s+:(\d+)$/;
34
35
  const SKIP_DISABLED_RE = /^\[SKIP\]\s+(\S+)\s+—\s+manifest에서 비활성$/;
35
36
 
37
+ function probePortListening(
38
+ port,
39
+ { host = "127.0.0.1", timeoutMs = 800 } = {},
40
+ ) {
41
+ return new Promise((resolve) => {
42
+ const socket = createConnection({ host, port });
43
+ let settled = false;
44
+
45
+ function finish(value) {
46
+ if (settled) return;
47
+ settled = true;
48
+ socket.destroy();
49
+ resolve(value);
50
+ }
51
+
52
+ socket.once("connect", () => finish(true));
53
+ socket.once("error", () => finish(false));
54
+ socket.setTimeout(timeoutMs, () => finish(false));
55
+ });
56
+ }
57
+
36
58
  export function checkMcpGatewayHealth({
37
59
  fs = { readFileSync, statSync },
38
60
  logPath = DEFAULT_LOG_PATH,
@@ -130,6 +152,40 @@ export function checkMcpGatewayHealth({
130
152
  };
131
153
  }
132
154
 
155
+ export async function checkMcpGatewayHealthLive({
156
+ probePort = probePortListening,
157
+ ...options
158
+ } = {}) {
159
+ const result = checkMcpGatewayHealth(options);
160
+ if (!result.available || result.started.length === 0) {
161
+ return {
162
+ ...result,
163
+ live: [],
164
+ };
165
+ }
166
+
167
+ const live = [];
168
+ const findings = [...result.findings];
169
+ for (const entry of result.started) {
170
+ const listening = await probePort(entry.port);
171
+ live.push({ ...entry, listening });
172
+ if (!listening) {
173
+ findings.push({
174
+ server: entry.server,
175
+ reason: "port-down",
176
+ detail: `expected listener on :${entry.port}`,
177
+ port: entry.port,
178
+ });
179
+ }
180
+ }
181
+
182
+ return {
183
+ ...result,
184
+ findings,
185
+ live,
186
+ };
187
+ }
188
+
133
189
  export function summarizeMcpGatewayHealth(result) {
134
190
  if (!result?.available) {
135
191
  return {
@@ -152,6 +208,15 @@ export function summarizeMcpGatewayHealth(result) {
152
208
  fix: "secrets 를 ~/.config/triflux/secrets.env 에 추가하고 `launchctl kickstart -k gui/$(id -u)/com.tellang.mcp-gateway` (macOS) 또는 `systemctl --user restart mcp-gateway` (linux)",
153
209
  };
154
210
  }
211
+ const portDown = result.findings.filter((f) => f.reason === "port-down");
212
+ if (portDown.length > 0) {
213
+ const list = portDown.map((f) => `${f.server} (:${f.port})`).join(", ");
214
+ return {
215
+ level: "warn",
216
+ message: `${portDown.length}개 MCP gateway port down: ${list}`,
217
+ fix: "gateway 를 재기동하세요: `node scripts/mcp-gateway-start.mjs` 또는 macOS `launchctl kickstart -k gui/$(id -u)/com.tellang.mcp-gateway`",
218
+ };
219
+ }
155
220
  return {
156
221
  level: "warn",
157
222
  message: `${result.findings.length}개 gateway 이슈`,
@@ -22,7 +22,12 @@ const PROJECT_ROOT = fileURLToPath(new URL("../../", import.meta.url));
22
22
  const DEFAULT_REGISTRY_PATH = join(PROJECT_ROOT, "config", "mcp-registry.json");
23
23
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
24
24
  const DEFAULT_HUB_PATH = "/mcp";
25
- const SERVER_POLICIES = new Set(["hosted", "gateway-sse", "stdio"]);
25
+ const SERVER_POLICIES = new Set([
26
+ "hosted",
27
+ "gateway-sse",
28
+ "gateway-http",
29
+ "stdio",
30
+ ]);
26
31
  const GATEWAY_PORTS = Object.freeze({
27
32
  context7: 8100,
28
33
  "brave-search": 8101,
@@ -792,6 +797,23 @@ function gatewaySseUrl(name, serverConfig = {}) {
792
797
  return `http://127.0.0.1:${port}/sse`;
793
798
  }
794
799
 
800
+ function gatewayHttpUrl(name, serverConfig = {}) {
801
+ if (typeof serverConfig.url === "string" && serverConfig.url.trim()) {
802
+ return normalizeUrl(serverConfig.url);
803
+ }
804
+ const port =
805
+ Number(serverConfig.gateway_port || serverConfig.port || 0) ||
806
+ GATEWAY_PORTS[name];
807
+ if (!Number.isFinite(port) || port <= 0) return "";
808
+ const path =
809
+ typeof serverConfig.gateway_path === "string" &&
810
+ serverConfig.gateway_path.trim()
811
+ ? serverConfig.gateway_path.trim()
812
+ : "/mcp";
813
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
814
+ return `http://127.0.0.1:${port}${normalizedPath}`;
815
+ }
816
+
795
817
  function syncDenylistEntries(policy = {}) {
796
818
  if (!Array.isArray(policy?.sync_denylist)) return new Set();
797
819
  return new Set(
@@ -836,9 +858,11 @@ export function buildDesiredServerRecord(name, serverConfig, filePath) {
836
858
  const url =
837
859
  policy === "gateway-sse"
838
860
  ? gatewaySseUrl(name, serverConfig)
839
- : serverConfig?.transport === "hub-url"
840
- ? resolveHubUrl()
841
- : normalizeUrl(serverConfig?.url || "");
861
+ : policy === "gateway-http"
862
+ ? gatewayHttpUrl(name, serverConfig)
863
+ : serverConfig?.transport === "hub-url"
864
+ ? resolveHubUrl()
865
+ : normalizeUrl(serverConfig?.url || "");
842
866
  const basenameValue = pathBasename(filePath);
843
867
  const resolvedHeaders = resolveHeaderDescriptors(
844
868
  name,
@@ -849,6 +873,26 @@ export function buildDesiredServerRecord(name, serverConfig, filePath) {
849
873
  ? { headers: resolvedHeaders.headers }
850
874
  : {};
851
875
 
876
+ if (policy === "gateway-http") {
877
+ if (isCodexConfig(filePath)) {
878
+ return {
879
+ name,
880
+ config: { url },
881
+ headerDescriptors: {},
882
+ codex: {},
883
+ warnings: [],
884
+ };
885
+ }
886
+
887
+ return {
888
+ name,
889
+ config: { type: "http", url },
890
+ headerDescriptors: {},
891
+ codex: {},
892
+ warnings: [],
893
+ };
894
+ }
895
+
852
896
  if (policy === "gateway-sse") {
853
897
  if (isCodexConfig(filePath)) {
854
898
  return {
@@ -1237,7 +1281,7 @@ export function validateRegistry(registry) {
1237
1281
  !SERVER_POLICIES.has(server.policy)
1238
1282
  ) {
1239
1283
  errors.push(
1240
- `registry.servers.${name}.policy must be hosted, gateway-sse, or stdio`,
1284
+ `registry.servers.${name}.policy must be hosted, gateway-sse, gateway-http, or stdio`,
1241
1285
  );
1242
1286
  }
1243
1287
  const transport = server.transport || registry.defaults?.transport;
@@ -1252,7 +1296,7 @@ export function validateRegistry(registry) {
1252
1296
  ) {
1253
1297
  errors.push(`registry.servers.${name}.url must be a non-empty string`);
1254
1298
  }
1255
- if (policy === "gateway-sse") {
1299
+ if (policy === "gateway-sse" || policy === "gateway-http") {
1256
1300
  const port = Number(server.gateway_port || server.port || 0);
1257
1301
  const hasGatewayUrl =
1258
1302
  typeof server.url === "string" && server.url.trim().length > 0;
@@ -1261,7 +1305,7 @@ export function validateRegistry(registry) {
1261
1305
  (!Number.isInteger(port) || port <= 0 || port > 65535)
1262
1306
  ) {
1263
1307
  errors.push(
1264
- `registry.servers.${name}.gateway_port must be a valid TCP port for gateway-sse`,
1308
+ `registry.servers.${name}.gateway_port must be a valid TCP port for ${policy}`,
1265
1309
  );
1266
1310
  }
1267
1311
  }
@@ -1913,7 +1957,7 @@ export function addRegistryServer(name, url, options = {}) {
1913
1957
  (transport === "stdio"
1914
1958
  ? "stdio"
1915
1959
  : options.gateway_port || options.port
1916
- ? "gateway-sse"
1960
+ ? "gateway-http"
1917
1961
  : "hosted");
1918
1962
 
1919
1963
  registry.servers[trimmedName] = {