triflux 10.9.31 → 10.9.32

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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "triflux",
11
11
  "description": "Tri-CLI orchestrator for Claude Code. Routes tasks across Claude + Codex + Gemini with consensus intelligence, natural language routing, 42 skills, and cross-model review.",
12
- "version": "10.9.31",
12
+ "version": "10.9.32",
13
13
  "author": {
14
14
  "name": "tellang"
15
15
  },
@@ -30,5 +30,5 @@
30
30
  ]
31
31
  }
32
32
  ],
33
- "version": "10.9.31"
33
+ "version": "10.9.32"
34
34
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.9.31",
3
+ "version": "10.9.32",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "author": {
6
6
  "name": "tellang"
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+
2
3
  // hooks/safety-guard.mjs — PreToolUse:Bash 훅
3
4
  //
4
5
  // 위험한 Bash 명령을 사전 차단(exit 2)하거나 경고(additionalContext)한다.
@@ -8,6 +9,7 @@
8
9
  // BLOCK (exit 2) — 복구 불가능한 파괴적 명령
9
10
  // WARN (allow + context) — 주의가 필요한 명령
10
11
 
12
+ import { spawnSync } from "node:child_process";
11
13
  import { existsSync, readFileSync } from "node:fs";
12
14
  import { join } from "node:path";
13
15
 
@@ -251,6 +253,60 @@ function hasSegmentInvocation(cmd, patterns) {
251
253
  });
252
254
  }
253
255
 
256
+ function isGitCommitInvocation(command) {
257
+ const lines = command.split(/\n/);
258
+ let heredocDelimiter = null;
259
+ return lines.some((line) => {
260
+ if (heredocDelimiter !== null) {
261
+ if (line.trim() === heredocDelimiter) heredocDelimiter = null;
262
+ return false;
263
+ }
264
+
265
+ const heredocMatch = line.match(/<<['"]?(\w+)['"]?/);
266
+ if (heredocMatch) {
267
+ heredocDelimiter = heredocMatch[1];
268
+ }
269
+
270
+ return line
271
+ .split(/\s*(?:&&|;|\|\|)\s*/)
272
+ .some((segment) => /^\s*git\s+commit\b/i.test(segment.trim()));
273
+ });
274
+ }
275
+
276
+ function resolveHookCwd(input) {
277
+ return String(
278
+ input?.cwd ||
279
+ input?.tool_input?.cwd ||
280
+ process.env.CLAUDE_CWD ||
281
+ process.cwd() ||
282
+ process.env.PWD,
283
+ );
284
+ }
285
+
286
+ function getCurrentGitBranch(cwd) {
287
+ try {
288
+ const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
289
+ cwd,
290
+ encoding: "utf8",
291
+ stdio: ["ignore", "pipe", "ignore"],
292
+ });
293
+ if (result.status !== 0) return "";
294
+ return String(result.stdout || "").trim();
295
+ } catch {
296
+ return "";
297
+ }
298
+ }
299
+
300
+ function isProtectedMainBranch(branch) {
301
+ return /^(main|master)$/i.test(String(branch || "").trim());
302
+ }
303
+
304
+ function isSwarmWorktreeCwd(cwd) {
305
+ return /(?:^|\/)\.codex-swarm\/wt-[^/]+(?:\/|$)/.test(
306
+ String(cwd || "").replace(/\\/g, "/"),
307
+ );
308
+ }
309
+
254
310
  function blockCommand(message, command) {
255
311
  process.stderr.write(
256
312
  `${message}\n` +
@@ -275,6 +331,7 @@ function main() {
275
331
 
276
332
  const command = (input.tool_input?.command || "").trim();
277
333
  if (!command) process.exit(0);
334
+ const hookCwd = resolveHookCwd(input);
278
335
 
279
336
  // psmux 명령이 실제 CLI 호출인지 판별 (오탐 방지)
280
337
  // git commit 메시지, echo, grep, cat, heredoc 안의 텍스트는 무시
@@ -298,6 +355,20 @@ function main() {
298
355
  process.exit(0);
299
356
  }
300
357
 
358
+ const codexPrdActive = process.env.CODEX_PRD_ACTIVE === "1";
359
+ if (codexPrdActive && isGitCommitInvocation(command)) {
360
+ const branch = getCurrentGitBranch(hookCwd);
361
+ if (isProtectedMainBranch(branch)) {
362
+ const locationHint = isSwarmWorktreeCwd(hookCwd)
363
+ ? "swarm worktree 내부에서도 main/master 직접 commit은 금지됩니다."
364
+ : "현재 PWD가 swarm worktree가 아닙니다.";
365
+ blockCommand(
366
+ `[safety-guard] Codex PRD 실행 중 ${branch} 직접 commit 차단됨. ${locationHint}`,
367
+ command,
368
+ );
369
+ }
370
+ }
371
+
301
372
  // 0.1. reflexion 적응형 패널티 — 이전 세션에서 차단된 패턴 사전 경고
302
373
  const penalties = loadReflexionPenalties();
303
374
  if (penalties.length > 0) {
@@ -102,7 +102,11 @@ function killWithEscalation(orphanPids, procMap) {
102
102
  }
103
103
  }
104
104
  }
105
- killProcess(pid, { signal: "SIGKILL", force: true });
105
+ killProcess(pid, {
106
+ signal: "SIGKILL",
107
+ force: true,
108
+ tree: IS_WINDOWS,
109
+ });
106
110
  }
107
111
  if (!isPidAlive(pid)) killed++;
108
112
  }
@@ -0,0 +1,70 @@
1
+ const FAILURE_STATUSES = new Set(["quota_hit", "error"]);
2
+
3
+ function buildFailureRecord(result = {}) {
4
+ const failure = {
5
+ id: result.id ?? "unknown",
6
+ status: result.status ?? "unknown",
7
+ };
8
+
9
+ if (Number.isFinite(result.http)) {
10
+ failure.http = result.http;
11
+ }
12
+
13
+ if (typeof result.message === "string" && result.message.length > 0) {
14
+ failure.message = result.message;
15
+ }
16
+
17
+ if (result.headers && Object.keys(result.headers).length > 0) {
18
+ failure.headers = result.headers;
19
+ }
20
+
21
+ return failure;
22
+ }
23
+
24
+ export function summarizeQuotaResults(results = []) {
25
+ const metrics = {
26
+ checked: results.length,
27
+ ok: 0,
28
+ quotaHit: 0,
29
+ error: 0,
30
+ failed: 0,
31
+ };
32
+ const failures = [];
33
+
34
+ for (const result of results) {
35
+ const status = result?.status ?? "unknown";
36
+
37
+ if (status === "ok") {
38
+ metrics.ok += 1;
39
+ } else if (status === "quota_hit") {
40
+ metrics.quotaHit += 1;
41
+ } else if (status === "error") {
42
+ metrics.error += 1;
43
+ }
44
+
45
+ if (FAILURE_STATUSES.has(status)) {
46
+ failures.push(buildFailureRecord(result));
47
+ }
48
+ }
49
+
50
+ metrics.failed = failures.length;
51
+ return { metrics, failures };
52
+ }
53
+
54
+ export function logQuotaRefreshFailures(logger, results = []) {
55
+ const { metrics, failures } = summarizeQuotaResults(results);
56
+ if (failures.length === 0) {
57
+ return { logged: false, metrics, failures };
58
+ }
59
+
60
+ logger.warn(
61
+ {
62
+ tag: "hub-quota",
63
+ metrics,
64
+ failures,
65
+ },
66
+ "broker.quota_refresh_degraded",
67
+ );
68
+
69
+ return { logged: true, metrics, failures };
70
+ }
package/hub/server.mjs CHANGED
@@ -29,6 +29,7 @@ import { DelegatorService } from "./delegator/index.mjs";
29
29
  import { createHitlManager } from "./hitl.mjs";
30
30
  import { cleanupOrphanNodeProcesses } from "./lib/process-utils.mjs";
31
31
  import * as spawnTrace from "./lib/spawn-trace.mjs";
32
+ import { logQuotaRefreshFailures } from "./middleware/quota-middleware.mjs";
32
33
  import { wrapRequestHandler } from "./middleware/request-logger.mjs";
33
34
  import { createPipeServer } from "./pipe.mjs";
34
35
  import { createRouter } from "./router.mjs";
@@ -299,6 +300,9 @@ async function syncHubMcpSettingsIfAvailable({ hubUrl }) {
299
300
  return;
300
301
  }
301
302
  await mod.syncHubMcpSettings({ hubUrl });
303
+ if (typeof mod?.syncCodexHubUrl === "function") {
304
+ await mod.syncCodexHubUrl({ hubUrl });
305
+ }
302
306
  } catch (error) {
303
307
  const message = error?.message || String(error);
304
308
  if (error?.code === "ERR_MODULE_NOT_FOUND") {
@@ -988,6 +992,7 @@ export async function startHub({
988
992
  ) {
989
993
  try {
990
994
  const results = await refreshAllAccountQuotas();
995
+ logQuotaRefreshFailures(hubLog, results);
991
996
  return writeJson(res, 200, { ok: true, results, ts: Date.now() });
992
997
  } catch (err) {
993
998
  hubLog.error(
@@ -12,10 +12,17 @@ const execFileAsync = promisify(nodeExecFile);
12
12
  const TARGET_PROCESS_NAMES = ["node", "python", "python3"];
13
13
  const SIGTERM_GRACE_MS = 5000;
14
14
 
15
- function forceKillPid(pid) {
16
- if (IS_WINDOWS) {
15
+ export function forceKillPid(
16
+ pid,
17
+ {
18
+ isWindows = IS_WINDOWS,
19
+ execFileSyncFn = execFileSync,
20
+ killFn = process.kill,
21
+ } = {},
22
+ ) {
23
+ if (isWindows) {
17
24
  try {
18
- execFileSync("taskkill", ["/F", "/PID", String(pid)], {
25
+ execFileSyncFn("taskkill", ["/F", "/T", "/PID", String(pid)], {
19
26
  stdio: "ignore",
20
27
  timeout: 5000,
21
28
  windowsHide: true,
@@ -23,7 +30,7 @@ function forceKillPid(pid) {
23
30
  return;
24
31
  } catch (taskkillError) {
25
32
  try {
26
- process.kill(pid);
33
+ killFn(pid);
27
34
  return;
28
35
  } catch {
29
36
  throw taskkillError;
@@ -31,7 +38,7 @@ function forceKillPid(pid) {
31
38
  }
32
39
  }
33
40
 
34
- process.kill(pid, "SIGKILL");
41
+ killFn(pid, "SIGKILL");
35
42
  }
36
43
 
37
44
  // cmdLine 패턴 기반 화이트리스트 (고아 후보에서 제외)
@@ -296,6 +303,12 @@ export async function findOrphanProcesses(opts = {}) {
296
303
  export function createProcessCleanup(opts = {}) {
297
304
  const execFileFn = opts.execFileFn ?? execFileAsync;
298
305
  const dryRun = opts.dryRun ?? false;
306
+ const isWindows = opts.isWindows ?? IS_WINDOWS;
307
+ const execFileSyncFn = opts.execFileSyncFn ?? execFileSync;
308
+ const killFn = opts.killFn ?? process.kill;
309
+ const sleepFn =
310
+ opts.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
311
+ const sigtermGraceMs = opts.sigtermGraceMs ?? SIGTERM_GRACE_MS;
299
312
 
300
313
  let lastOrphans = [];
301
314
 
@@ -332,16 +345,16 @@ export function createProcessCleanup(opts = {}) {
332
345
  lastOrphans.map(async (p) => {
333
346
  try {
334
347
  // SIGTERM
335
- process.kill(p.pid, "SIGTERM");
348
+ killFn(p.pid, "SIGTERM");
336
349
 
337
350
  // 5초 대기 후 살아있으면 강제 종료
338
- await new Promise((resolve) => setTimeout(resolve, SIGTERM_GRACE_MS));
351
+ await sleepFn(sigtermGraceMs);
339
352
 
340
353
  try {
341
354
  // 프로세스가 아직 살아있는지 확인 (signal 0)
342
- process.kill(p.pid, 0);
355
+ killFn(p.pid, 0);
343
356
  // 여전히 살아있음 → Windows는 taskkill/process.kill, 그 외는 SIGKILL
344
- forceKillPid(p.pid);
357
+ forceKillPid(p.pid, { isWindows, execFileSyncFn, killFn });
345
358
  } catch {
346
359
  // ESRCH: 이미 종료됨 — 정상
347
360
  }
@@ -10,9 +10,40 @@ import {
10
10
  import { clampPercent, formatTokenCount, readJsonMigrate } from "./utils.mjs";
11
11
 
12
12
  const DEFAULT_CONTEXT_LIMIT = 200_000;
13
+ const MILLION_CONTEXT_LIMIT = 1_000_000;
13
14
  const MAX_CAPTURE_BYTES = 256 * 1024;
14
15
  const MAX_TOP_KEYS = 20;
15
16
 
17
+ // stdin 이 context_window_size 를 제공하지 않을 때 모델 ID 로 한도를 추정한다.
18
+ // Anthropic 공식 문서(2026-04 기준): Opus 4.7 / Opus 4.6 / Sonnet 4.6 = 1M,
19
+ // Sonnet 4.5 / Haiku 4.5 = 200K. 그 외 모델은 [1m] suffix 로 opt-in 가능.
20
+ const MODEL_HINT_1M_PREFIXES = [
21
+ "claude-opus-4-7",
22
+ "claude-opus-4-6",
23
+ "claude-sonnet-4-6",
24
+ ];
25
+
26
+ function normalizeModelId(modelId) {
27
+ if (!modelId) return "";
28
+ return String(modelId).toLowerCase();
29
+ }
30
+
31
+ function resolveModelLimit(modelId) {
32
+ const id = normalizeModelId(modelId);
33
+ if (!id) return DEFAULT_CONTEXT_LIMIT;
34
+ if (id.includes("[1m]")) return MILLION_CONTEXT_LIMIT;
35
+ for (const prefix of MODEL_HINT_1M_PREFIXES) {
36
+ if (id.startsWith(prefix)) return MILLION_CONTEXT_LIMIT;
37
+ }
38
+ return DEFAULT_CONTEXT_LIMIT;
39
+ }
40
+
41
+ export function shouldSuppressInfoOnlyContextStatus(modelId) {
42
+ const id = normalizeModelId(modelId);
43
+ if (!id) return false;
44
+ return id.startsWith("claude-opus-4-7") || id.includes("[1m]");
45
+ }
46
+
16
47
  const WARNING_LEVELS = Object.freeze({
17
48
  ok: { min: 0, message: "" },
18
49
  info: { min: 60, message: "컨텍스트 절반 이상 사용" },
@@ -239,10 +270,21 @@ function getStdinContextUsage(stdin) {
239
270
  return null;
240
271
  }
241
272
 
273
+ export function deriveContextLimit(stdin) {
274
+ const explicit = Number(stdin?.context_window?.context_window_size || 0);
275
+ if (explicit > 0) return explicit;
276
+ return resolveModelLimit(stdin?.model?.id ?? stdin?.model);
277
+ }
278
+
242
279
  export function buildContextUsageView(stdin, snapshot = null) {
243
280
  const stdinUsage = getStdinContextUsage(stdin);
244
281
  const monitor = snapshot || readContextMonitorSnapshot();
245
- const fallbackLimit = Number(monitor?.limitTokens || DEFAULT_CONTEXT_LIMIT);
282
+ const modelId = stdin?.model?.id ?? stdin?.model;
283
+ const modelHintLimit = resolveModelLimit(modelId);
284
+ const fallbackLimit = Math.max(
285
+ modelHintLimit,
286
+ Number(monitor?.limitTokens || 0),
287
+ );
246
288
 
247
289
  const usedTokens = stdinUsage?.usedTokens ?? Number(monitor?.usedTokens || 0);
248
290
  const limitTokens = stdinUsage?.limitTokens ?? Math.max(1, fallbackLimit);
@@ -251,15 +293,19 @@ export function buildContextUsageView(stdin, snapshot = null) {
251
293
  (limitTokens > 0 ? clampPercent((usedTokens / limitTokens) * 100) : 0);
252
294
 
253
295
  const warning = classifyContextThreshold(percent);
296
+ const showInfoOnlyStatus = !(
297
+ warning.level === "info" && shouldSuppressInfoOnlyContextStatus(modelId)
298
+ );
254
299
  return {
255
300
  usedTokens,
256
301
  limitTokens,
257
302
  percent,
258
303
  display: formatContextUsage(usedTokens, limitTokens, percent),
259
304
  warningLevel: warning.level,
260
- warningMessage: warning.message,
261
- warningTag:
262
- warning.level === "warn"
305
+ warningMessage: showInfoOnlyStatus ? warning.message : "",
306
+ warningTag: !showInfoOnlyStatus
307
+ ? ""
308
+ : warning.level === "warn"
263
309
  ? "⚠ 압축 권장"
264
310
  : warning.level === "critical"
265
311
  ? "‼ 분할 권장"
@@ -26,7 +26,10 @@ import {
26
26
  GEMINI_SESSION_REFRESH_FLAG,
27
27
  QOS_PATH,
28
28
  } from "./constants.mjs";
29
- import { buildContextUsageView } from "./context-monitor.mjs";
29
+ import {
30
+ buildContextUsageView,
31
+ deriveContextLimit,
32
+ } from "./context-monitor.mjs";
30
33
  import { getMissionBoardState } from "./mission-board.mjs";
31
34
  // Claude provider
32
35
  import {
@@ -153,7 +156,7 @@ async function main() {
153
156
  svSavings?.totalSaved || svAccumulator?.totalCostSaved || 0;
154
157
 
155
158
  // 세션/누적 토큰 → context 대비 절약 배수 (개별 provider sv%)
156
- const ctxCapacity = stdin?.context_window?.context_window_size || 200000;
159
+ const ctxCapacity = deriveContextLimit(stdin);
157
160
  let codexSv = null;
158
161
  if (svAccumulator?.codex?.tokens > 0) {
159
162
  codexSv = svAccumulator.codex.tokens / ctxCapacity;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.9.31",
3
+ "version": "10.9.32",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,22 @@ function buildHubBaseUrl(host, port) {
22
22
  return `http://${formatHostForUrl(host)}:${port}`;
23
23
  }
24
24
 
25
+ async function syncHubConfigsIfAvailable({ hubUrl }) {
26
+ try {
27
+ const mod = await import(
28
+ new URL("./sync-hub-mcp-settings.mjs", import.meta.url)
29
+ );
30
+ if (typeof mod?.syncHubMcpSettings === "function") {
31
+ await mod.syncHubMcpSettings({ hubUrl });
32
+ }
33
+ if (typeof mod?.syncCodexHubUrl === "function") {
34
+ await mod.syncCodexHubUrl({ hubUrl });
35
+ }
36
+ } catch {
37
+ // sync는 best-effort이며 hub-ensure 성공/실패를 좌우하지 않는다.
38
+ }
39
+ }
40
+
25
41
  function resolveHubTarget() {
26
42
  const envPortRaw = Number(process.env.TFX_HUB_PORT || "");
27
43
  const envPort =
@@ -96,7 +112,9 @@ export async function run(stdinData) {
96
112
  void stdinData;
97
113
 
98
114
  const { host, port } = resolveHubTarget();
115
+ const hubUrl = `${buildHubBaseUrl(host, port)}/mcp`;
99
116
  if (await isHubHealthy(host, port)) {
117
+ await syncHubConfigsIfAvailable({ hubUrl });
100
118
  return { code: 0, stdout: "hub: ok", stderr: "" };
101
119
  }
102
120
 
@@ -106,6 +124,9 @@ export async function run(stdinData) {
106
124
  }
107
125
 
108
126
  const ready = await waitForHubReady(host, port, 5000);
127
+ if (ready) {
128
+ await syncHubConfigsIfAvailable({ hubUrl });
129
+ }
109
130
  return {
110
131
  code: ready ? 0 : 2,
111
132
  stdout: ready ? "hub: ok" : "hub: starting (timeout)",
@@ -9,9 +9,9 @@ $ErrorActionPreference = 'SilentlyContinue'
9
9
  # + omc bridge
10
10
  Get-CimInstance Win32_Process -Filter "Name='node.exe' OR Name='cmd.exe'" |
11
11
  Where-Object { $_.CommandLine -match 'npx-cli|oh-my-codex[\\/]dist[\\/]mcp|omc.*bridge.*mcp-server' } |
12
- ForEach-Object { taskkill /F /PID $_.ProcessId 2>$null }
12
+ ForEach-Object { taskkill /F /T /PID $_.ProcessId 2>$null }
13
13
 
14
14
  # serena (uvx) + python MCP orphans
15
15
  Get-CimInstance Win32_Process -Filter "Name='python.exe' OR Name='uvx.exe'" |
16
16
  Where-Object { $_.CommandLine -match 'serena|uv[\\/](cache|python)' } |
17
- ForEach-Object { taskkill /F /PID $_.ProcessId 2>$null }
17
+ ForEach-Object { taskkill /F /T /PID $_.ProcessId 2>$null }
package/scripts/setup.mjs CHANGED
@@ -1237,7 +1237,12 @@ export async function runDeferred(stdinData) {
1237
1237
  changed = true;
1238
1238
  }
1239
1239
 
1240
- // ── PreToolUse 훅: headless-guard (auto-route) ──
1240
+ // ── PreToolUse 훅: headless-guard + tfx-gate-activate ──
1241
+ // orchestrator 가 registry 기반으로 omc-headless-guard / omc-tfx-gate-activate 를
1242
+ // 이미 디스패치하므로, `*` orchestrator entry 와 별도로 등록된 직접 entry 는
1243
+ // 2배 발화를 유발한다 (#76). 이 블록은 orchestrator 유무에 따라 다르게 동작한다:
1244
+ // - orchestrator 가 있으면: 직접 등록된 중복 entry 를 제거 (prune).
1245
+ // - orchestrator 가 없으면: legacy ADD 경로로 직접 entry 주입 (구 설치 fallback).
1241
1246
  if (!Array.isArray(s.hooks.PreToolUse)) s.hooks.PreToolUse = [];
1242
1247
 
1243
1248
  const guardScriptPath = join(
@@ -1245,85 +1250,110 @@ export async function runDeferred(stdinData) {
1245
1250
  "scripts",
1246
1251
  "headless-guard-fast.sh",
1247
1252
  ).replace(/\\/g, "/");
1248
- const hasGuardHook = s.hooks.PreToolUse.some(
1249
- (entry) =>
1250
- Array.isArray(entry.hooks) &&
1251
- entry.hooks.some(
1252
- (h) =>
1253
- typeof h.command === "string" &&
1254
- h.command.includes("headless-guard"),
1255
- ),
1256
- );
1257
-
1258
- if (!hasGuardHook && existsSync(guardScriptPath.replace(/\//g, "\\"))) {
1259
- s.hooks.PreToolUse.push({
1260
- matcher: "Bash|Agent",
1261
- hooks: [
1262
- {
1263
- type: "command",
1264
- command: `bash "${guardScriptPath}"`,
1265
- timeout: 3,
1266
- },
1267
- ],
1268
- });
1269
- changed = true;
1270
- } else if (hasGuardHook) {
1271
- // 기존 훅 경로를 동기화된 경로로 업데이트
1272
- for (const entry of s.hooks.PreToolUse) {
1273
- if (!Array.isArray(entry.hooks)) continue;
1274
- for (const h of entry.hooks) {
1275
- if (
1276
- typeof h.command === "string" &&
1277
- h.command.includes("headless-guard") &&
1278
- !h.command.includes(guardScriptPath)
1279
- ) {
1280
- h.command = `bash "${guardScriptPath}"`;
1281
- changed = true;
1282
- }
1283
- }
1284
- }
1285
- }
1286
-
1287
- // ── PreToolUse 훅: tfx-gate-activate (Skill 감지 → A+B gate) ──
1288
1253
  const gateScriptPath = join(
1289
1254
  CLAUDE_DIR,
1290
1255
  "scripts",
1291
1256
  "tfx-gate-activate.mjs",
1292
1257
  ).replace(/\\/g, "/");
1293
- const hasGateHook = s.hooks.PreToolUse.some(
1258
+
1259
+ const hasPreToolUseOrchestrator = s.hooks.PreToolUse.some(
1294
1260
  (entry) =>
1261
+ entry.matcher === "*" &&
1295
1262
  Array.isArray(entry.hooks) &&
1296
1263
  entry.hooks.some(
1297
1264
  (h) =>
1298
1265
  typeof h.command === "string" &&
1299
- h.command.includes("tfx-gate-activate"),
1266
+ h.command.includes("hook-orchestrator.mjs"),
1300
1267
  ),
1301
1268
  );
1302
1269
 
1303
- if (!hasGateHook && existsSync(gateScriptPath.replace(/\//g, "\\"))) {
1304
- s.hooks.PreToolUse.push({
1305
- matcher: "Skill",
1306
- hooks: [
1307
- {
1308
- type: "command",
1309
- command: `node "${gateScriptPath}"`,
1310
- timeout: 2,
1311
- },
1312
- ],
1313
- });
1314
- changed = true;
1315
- } else if (hasGateHook) {
1316
- for (const entry of s.hooks.PreToolUse) {
1317
- if (!Array.isArray(entry.hooks)) continue;
1318
- for (const h of entry.hooks) {
1319
- if (
1270
+ if (hasPreToolUseOrchestrator) {
1271
+ // prune: 직접 등록된 headless-guard / tfx-gate-activate 전용 entry 제거
1272
+ const DUP_MARKERS = ["headless-guard", "tfx-gate-activate"];
1273
+ const before = s.hooks.PreToolUse.length;
1274
+ s.hooks.PreToolUse = s.hooks.PreToolUse.filter((entry) => {
1275
+ if (entry.matcher === "*") return true;
1276
+ if (!Array.isArray(entry.hooks) || entry.hooks.length === 0)
1277
+ return true;
1278
+ const allDup = entry.hooks.every(
1279
+ (h) =>
1320
1280
  typeof h.command === "string" &&
1321
- h.command.includes("tfx-gate-activate") &&
1322
- !h.command.includes(gateScriptPath)
1323
- ) {
1324
- h.command = `node "${gateScriptPath}"`;
1325
- changed = true;
1326
- }
1281
+ !h.command.includes("hook-orchestrator") &&
1282
+ DUP_MARKERS.some((m) => h.command.includes(m)),
1283
+ );
1284
+ return !allDup;
1285
+ });
1286
+ if (s.hooks.PreToolUse.length !== before) changed = true;
1287
+ } else {
1288
+ // legacy: orchestrator 부재 시 직접 entry 주입
1289
+ const hasGuardHook = s.hooks.PreToolUse.some(
1290
+ (entry) =>
1291
+ Array.isArray(entry.hooks) &&
1292
+ entry.hooks.some(
1293
+ (h) =>
1294
+ typeof h.command === "string" &&
1295
+ h.command.includes("headless-guard"),
1296
+ ),
1297
+ );
1298
+
1299
+ if (!hasGuardHook && existsSync(guardScriptPath.replace(/\//g, "\\"))) {
1300
+ s.hooks.PreToolUse.push({
1301
+ matcher: "Bash|Agent",
1302
+ hooks: [
1303
+ {
1304
+ type: "command",
1305
+ command: `bash "${guardScriptPath}"`,
1306
+ timeout: 3,
1307
+ },
1308
+ ],
1309
+ });
1310
+ changed = true;
1311
+ }
1312
+
1313
+ const hasGateHook = s.hooks.PreToolUse.some(
1314
+ (entry) =>
1315
+ Array.isArray(entry.hooks) &&
1316
+ entry.hooks.some(
1317
+ (h) =>
1318
+ typeof h.command === "string" &&
1319
+ h.command.includes("tfx-gate-activate"),
1320
+ ),
1321
+ );
1322
+
1323
+ if (!hasGateHook && existsSync(gateScriptPath.replace(/\//g, "\\"))) {
1324
+ s.hooks.PreToolUse.push({
1325
+ matcher: "Skill",
1326
+ hooks: [
1327
+ {
1328
+ type: "command",
1329
+ command: `node "${gateScriptPath}"`,
1330
+ timeout: 2,
1331
+ },
1332
+ ],
1333
+ });
1334
+ changed = true;
1335
+ }
1336
+ }
1337
+
1338
+ // 남아있는 직접 entry 경로 동기화 (legacy 또는 외부 등록 대응)
1339
+ for (const entry of s.hooks.PreToolUse) {
1340
+ if (!Array.isArray(entry.hooks)) continue;
1341
+ for (const h of entry.hooks) {
1342
+ if (typeof h.command !== "string") continue;
1343
+ if (h.command.includes("hook-orchestrator")) continue;
1344
+ if (
1345
+ h.command.includes("headless-guard") &&
1346
+ !h.command.includes(guardScriptPath)
1347
+ ) {
1348
+ h.command = `bash "${guardScriptPath}"`;
1349
+ changed = true;
1350
+ }
1351
+ if (
1352
+ h.command.includes("tfx-gate-activate") &&
1353
+ !h.command.includes(gateScriptPath)
1354
+ ) {
1355
+ h.command = `node "${gateScriptPath}"`;
1356
+ changed = true;
1327
1357
  }
1328
1358
  }
1329
1359
  }
@@ -8,6 +8,8 @@ const TARGET_FILES = [
8
8
  [".claude", "settings.json"],
9
9
  [".claude", "settings.local.json"],
10
10
  ];
11
+ const CODEX_CONFIG_FILE = [".codex", "config.toml"];
12
+ const TFX_HUB_SECTION = "tfx-hub";
11
13
  const FILE_LOCKS = new Map();
12
14
 
13
15
  function getSettingsPaths() {
@@ -15,6 +17,14 @@ function getSettingsPaths() {
15
17
  return TARGET_FILES.map((segments) => join(home, ...segments));
16
18
  }
17
19
 
20
+ function getCodexConfigPath(codexConfigPath) {
21
+ if (typeof codexConfigPath === "string" && codexConfigPath.length > 0) {
22
+ return codexConfigPath;
23
+ }
24
+ const home = process.env.HOME || homedir();
25
+ return join(home, ...CODEX_CONFIG_FILE);
26
+ }
27
+
18
28
  function getReason(error, fallback) {
19
29
  if (typeof error?.message === "string" && error.message.length > 0) {
20
30
  return error.message;
@@ -63,9 +73,8 @@ async function fileExists(filePath) {
63
73
  }
64
74
  }
65
75
 
66
- async function writeJsonAtomic(filePath, value) {
76
+ async function writeTextAtomic(filePath, payload) {
67
77
  const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
68
- const payload = `${JSON.stringify(value, null, 2)}\n`;
69
78
 
70
79
  try {
71
80
  await writeFile(tmpPath, payload, "utf8");
@@ -89,6 +98,53 @@ async function writeJsonAtomic(filePath, value) {
89
98
  }
90
99
  }
91
100
 
101
+ async function writeJsonAtomic(filePath, value) {
102
+ const payload = `${JSON.stringify(value, null, 2)}\n`;
103
+ await writeTextAtomic(filePath, payload);
104
+ }
105
+
106
+ function escapeRegExp(value) {
107
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
108
+ }
109
+
110
+ function formatTomlString(value) {
111
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
112
+ }
113
+
114
+ function parseTomlScalar(rawValue) {
115
+ const value = String(rawValue || "").trim();
116
+ if (!value) return "";
117
+ if (value === "true") return true;
118
+ if (value === "false") return false;
119
+ if (/^-?\d[\d_]*$/.test(value)) return Number(value.replace(/_/g, ""));
120
+ if (value.startsWith('"') && value.endsWith('"')) {
121
+ return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
122
+ }
123
+ return value;
124
+ }
125
+
126
+ function findMcpServerSection(raw, sectionName) {
127
+ const headerRegex = new RegExp(
128
+ `^\\[mcp_servers\\.${escapeRegExp(sectionName)}\\]\\s*$`,
129
+ "m",
130
+ );
131
+ const headerMatch = headerRegex.exec(raw);
132
+ if (!headerMatch) return null;
133
+
134
+ const headerLineEnd = raw.indexOf("\n", headerMatch.index);
135
+ const bodyStart = headerLineEnd === -1 ? raw.length : headerLineEnd + 1;
136
+ const nextSectionRegex = /^\s*\[/gm;
137
+ nextSectionRegex.lastIndex = bodyStart;
138
+ const nextSectionMatch = nextSectionRegex.exec(raw);
139
+ const sectionEnd = nextSectionMatch ? nextSectionMatch.index : raw.length;
140
+
141
+ return {
142
+ body: raw.slice(bodyStart, sectionEnd),
143
+ bodyStart,
144
+ sectionEnd,
145
+ };
146
+ }
147
+
92
148
  async function syncSingleFile({ filePath, hubUrl, dryRun, logger }) {
93
149
  return withFileLock(filePath, async () => {
94
150
  if (!(await fileExists(filePath))) {
@@ -157,6 +213,75 @@ async function syncSingleFile({ filePath, hubUrl, dryRun, logger }) {
157
213
  });
158
214
  }
159
215
 
216
+ async function syncCodexConfigFile({ filePath, hubUrl, dryRun, logger }) {
217
+ return withFileLock(filePath, async () => {
218
+ if (!(await fileExists(filePath))) {
219
+ log(logger, "info", `[codex-mcp-sync] skipped: ${filePath}`);
220
+ return { kind: "skipped", path: filePath };
221
+ }
222
+
223
+ let raw;
224
+ try {
225
+ raw = await readFile(filePath, "utf8");
226
+ } catch (error) {
227
+ const reason = getReason(error, "read failed");
228
+ log(logger, "error", `[codex-mcp-sync] error: ${filePath} (${reason})`);
229
+ return { kind: "error", path: filePath, reason };
230
+ }
231
+
232
+ const section = findMcpServerSection(raw, TFX_HUB_SECTION);
233
+ if (!section) {
234
+ log(logger, "info", `[codex-mcp-sync] skipped: ${filePath}`);
235
+ return { kind: "skipped", path: filePath };
236
+ }
237
+
238
+ const urlMatch = /^(\s*url\s*=\s*)(.+?)(\s*(?:#.*)?)$/m.exec(section.body);
239
+ if (!urlMatch) {
240
+ const reason = "missing tfx-hub url";
241
+ log(logger, "error", `[codex-mcp-sync] error: ${filePath} (${reason})`);
242
+ return { kind: "error", path: filePath, reason };
243
+ }
244
+
245
+ const currentUrl = parseTomlScalar(urlMatch[2]);
246
+ if (typeof currentUrl !== "string" || currentUrl.length === 0) {
247
+ const reason = "invalid tfx-hub url";
248
+ log(logger, "error", `[codex-mcp-sync] error: ${filePath} (${reason})`);
249
+ return { kind: "error", path: filePath, reason };
250
+ }
251
+
252
+ if (currentUrl === hubUrl) {
253
+ log(logger, "info", `[codex-mcp-sync] skipped: ${filePath}`);
254
+ return { kind: "skipped", path: filePath };
255
+ }
256
+
257
+ const nextBody = section.body.replace(
258
+ /^(\s*url\s*=\s*)(.+?)(\s*(?:#.*)?)$/m,
259
+ (_, prefix, _value, suffix = "") =>
260
+ `${prefix}${formatTomlString(hubUrl)}${suffix}`,
261
+ );
262
+ const nextRaw = `${raw.slice(0, section.bodyStart)}${nextBody}${raw.slice(section.sectionEnd)}`;
263
+
264
+ log(
265
+ logger,
266
+ "debug",
267
+ `[codex-mcp-sync] ${filePath} url: ${String(currentUrl)} -> ${hubUrl}`,
268
+ );
269
+
270
+ if (!dryRun) {
271
+ try {
272
+ await writeTextAtomic(filePath, nextRaw);
273
+ } catch (error) {
274
+ const reason = getReason(error, "write failed");
275
+ log(logger, "error", `[codex-mcp-sync] error: ${filePath} (${reason})`);
276
+ return { kind: "error", path: filePath, reason };
277
+ }
278
+ }
279
+
280
+ log(logger, "info", `[codex-mcp-sync] updated: ${filePath}`);
281
+ return { kind: "updated", path: filePath };
282
+ });
283
+ }
284
+
160
285
  export async function syncHubMcpSettings({
161
286
  hubUrl,
162
287
  dryRun = false,
@@ -183,3 +308,33 @@ export async function syncHubMcpSettings({
183
308
 
184
309
  return result;
185
310
  }
311
+
312
+ export async function syncCodexHubUrl({
313
+ hubUrl,
314
+ codexConfigPath,
315
+ dryRun = false,
316
+ logger = console,
317
+ }) {
318
+ const result = {
319
+ updated: [],
320
+ skipped: [],
321
+ errors: [],
322
+ };
323
+
324
+ const outcome = await syncCodexConfigFile({
325
+ filePath: getCodexConfigPath(codexConfigPath),
326
+ hubUrl,
327
+ dryRun,
328
+ logger,
329
+ });
330
+
331
+ if (outcome.kind === "updated") {
332
+ result.updated.push(outcome.path);
333
+ } else if (outcome.kind === "skipped") {
334
+ result.skipped.push(outcome.path);
335
+ } else {
336
+ result.errors.push({ path: outcome.path, reason: outcome.reason });
337
+ }
338
+
339
+ return result;
340
+ }