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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/safety-guard.mjs +71 -0
- package/hub/lib/process-utils.mjs +5 -1
- package/hub/middleware/quota-middleware.mjs +70 -0
- package/hub/server.mjs +5 -0
- package/hub/team/process-cleanup.mjs +22 -9
- package/hud/context-monitor.mjs +50 -4
- package/hud/hud-qos-status.mjs +5 -2
- package/package.json +1 -1
- package/scripts/hub-ensure.mjs +21 -0
- package/scripts/mcp-cleanup.ps1 +2 -2
- package/scripts/setup.mjs +96 -66
- package/scripts/sync-hub-mcp-settings.mjs +157 -2
|
@@ -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.
|
|
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.
|
|
33
|
+
"version": "10.9.32"
|
|
34
34
|
}
|
package/hooks/safety-guard.mjs
CHANGED
|
@@ -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, {
|
|
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(
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
+
killFn(p.pid, "SIGTERM");
|
|
336
349
|
|
|
337
350
|
// 5초 대기 후 살아있으면 강제 종료
|
|
338
|
-
await
|
|
351
|
+
await sleepFn(sigtermGraceMs);
|
|
339
352
|
|
|
340
353
|
try {
|
|
341
354
|
// 프로세스가 아직 살아있는지 확인 (signal 0)
|
|
342
|
-
|
|
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
|
}
|
package/hud/context-monitor.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
305
|
+
warningMessage: showInfoOnlyStatus ? warning.message : "",
|
|
306
|
+
warningTag: !showInfoOnlyStatus
|
|
307
|
+
? ""
|
|
308
|
+
: warning.level === "warn"
|
|
263
309
|
? "⚠ 압축 권장"
|
|
264
310
|
: warning.level === "critical"
|
|
265
311
|
? "‼ 분할 권장"
|
package/hud/hud-qos-status.mjs
CHANGED
|
@@ -26,7 +26,10 @@ import {
|
|
|
26
26
|
GEMINI_SESSION_REFRESH_FLAG,
|
|
27
27
|
QOS_PATH,
|
|
28
28
|
} from "./constants.mjs";
|
|
29
|
-
import {
|
|
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
|
|
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
package/scripts/hub-ensure.mjs
CHANGED
|
@@ -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)",
|
package/scripts/mcp-cleanup.ps1
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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("
|
|
1266
|
+
h.command.includes("hook-orchestrator.mjs"),
|
|
1300
1267
|
),
|
|
1301
1268
|
);
|
|
1302
1269
|
|
|
1303
|
-
if (
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
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("
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
|
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
|
+
}
|