triflux 3.1.0-dev.1 → 3.1.0-dev.3
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/.mcp.json +8 -3
- package/bin/triflux.mjs +98 -9
- package/hub/bridge.mjs +224 -0
- package/hub/router.mjs +1 -1
- package/hub/server.mjs +84 -2
- package/hub/store.mjs +1 -1
- package/hub/tools.mjs +1 -1
- package/package.json +1 -1
- package/scripts/tfx-route.sh +61 -1
package/.mcp.json
CHANGED
package/bin/triflux.mjs
CHANGED
|
@@ -221,6 +221,13 @@ function cmdSetup() {
|
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// hub MCP 사전 등록 (서버 미실행이어도 설정만 등록 — hub start 시 즉시 사용 가능)
|
|
225
|
+
if (existsSync(join(PKG_ROOT, "hub", "server.mjs"))) {
|
|
226
|
+
const defaultHubUrl = `http://127.0.0.1:${process.env.TFX_HUB_PORT || "27888"}/mcp`;
|
|
227
|
+
autoRegisterMcp(defaultHubUrl);
|
|
228
|
+
console.log("");
|
|
229
|
+
}
|
|
230
|
+
|
|
224
231
|
// HUD statusLine 설정
|
|
225
232
|
console.log(`${CYAN}[HUD 설정]${RESET}`);
|
|
226
233
|
const settingsPath = join(CLAUDE_DIR, "settings.json");
|
|
@@ -578,7 +585,9 @@ function cmdDoctor(options = {}) {
|
|
|
578
585
|
}
|
|
579
586
|
|
|
580
587
|
function cmdUpdate() {
|
|
581
|
-
|
|
588
|
+
const isDev = process.argv.includes("--dev");
|
|
589
|
+
const tagLabel = isDev ? ` ${YELLOW}@dev${RESET}` : "";
|
|
590
|
+
console.log(`\n${BOLD}triflux update${RESET}${tagLabel}\n`);
|
|
582
591
|
|
|
583
592
|
// 1. 설치 방식 감지
|
|
584
593
|
const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
|
|
@@ -648,17 +657,19 @@ function cmdUpdate() {
|
|
|
648
657
|
break;
|
|
649
658
|
}
|
|
650
659
|
case "npm-global": {
|
|
651
|
-
const
|
|
660
|
+
const npmCmd = isDev ? "npm install -g triflux@dev" : "npm update -g triflux";
|
|
661
|
+
const result = execSync(npmCmd, {
|
|
652
662
|
encoding: "utf8",
|
|
653
663
|
timeout: 60000,
|
|
654
664
|
stdio: ["pipe", "pipe", "ignore"],
|
|
655
665
|
}).trim().split(/\r?\n/)[0];
|
|
656
|
-
ok(
|
|
666
|
+
ok(`${isDev ? "npm install -g @dev" : "npm update -g"} — ${result || "완료"}`);
|
|
657
667
|
updated = true;
|
|
658
668
|
break;
|
|
659
669
|
}
|
|
660
670
|
case "npm-local": {
|
|
661
|
-
const
|
|
671
|
+
const npmLocalCmd = isDev ? "npm install triflux@dev" : "npm update triflux";
|
|
672
|
+
const result = execSync(npmLocalCmd, {
|
|
662
673
|
encoding: "utf8",
|
|
663
674
|
timeout: 60000,
|
|
664
675
|
cwd: process.cwd(),
|
|
@@ -812,6 +823,7 @@ ${updateNotice}
|
|
|
812
823
|
${DIM} --fix${RESET} ${GRAY}진단 + 자동 수정${RESET}
|
|
813
824
|
${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
|
|
814
825
|
${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 버전으로 업데이트${RESET}
|
|
826
|
+
${DIM} --dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
|
|
815
827
|
${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
|
|
816
828
|
${WHITE_BRIGHT}tfx hub${RESET} ${GRAY}MCP 메시지 버스 관리 (start/stop/status)${RESET}
|
|
817
829
|
${WHITE_BRIGHT}tfx notion-read${RESET} ${GRAY}Notion 페이지 → 마크다운 (Codex/Gemini MCP)${RESET}
|
|
@@ -835,6 +847,80 @@ ${updateNotice}
|
|
|
835
847
|
const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
|
|
836
848
|
const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
|
|
837
849
|
|
|
850
|
+
// 설치된 CLI에 tfx-hub MCP 서버 자동 등록 (1회 설정, 이후 재실행 불필요)
|
|
851
|
+
function autoRegisterMcp(mcpUrl) {
|
|
852
|
+
section("MCP 자동 등록");
|
|
853
|
+
|
|
854
|
+
// Codex — codex mcp add
|
|
855
|
+
if (which("codex")) {
|
|
856
|
+
try {
|
|
857
|
+
// 이미 등록됐는지 확인
|
|
858
|
+
const list = execSync("codex mcp list 2>&1", { encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] });
|
|
859
|
+
if (list.includes("tfx-hub")) {
|
|
860
|
+
ok("Codex: 이미 등록됨");
|
|
861
|
+
} else {
|
|
862
|
+
execSync(`codex mcp add tfx-hub --url ${mcpUrl}`, { timeout: 10000, stdio: "ignore" });
|
|
863
|
+
ok("Codex: MCP 등록 완료");
|
|
864
|
+
}
|
|
865
|
+
} catch {
|
|
866
|
+
// mcp list/add 미지원 → 설정 파일 직접 수정
|
|
867
|
+
try {
|
|
868
|
+
const codexDir = join(homedir(), ".codex");
|
|
869
|
+
const configFile = join(codexDir, "config.json");
|
|
870
|
+
let config = {};
|
|
871
|
+
if (existsSync(configFile)) config = JSON.parse(readFileSync(configFile, "utf8"));
|
|
872
|
+
if (!config.mcpServers) config.mcpServers = {};
|
|
873
|
+
if (!config.mcpServers["tfx-hub"]) {
|
|
874
|
+
config.mcpServers["tfx-hub"] = { url: mcpUrl };
|
|
875
|
+
if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
|
|
876
|
+
writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
|
|
877
|
+
ok("Codex: config.json에 등록 완료");
|
|
878
|
+
} else {
|
|
879
|
+
ok("Codex: 이미 등록됨");
|
|
880
|
+
}
|
|
881
|
+
} catch (e) { warn(`Codex 등록 실패: ${e.message}`); }
|
|
882
|
+
}
|
|
883
|
+
} else {
|
|
884
|
+
info("Codex: 미설치 (건너뜀)");
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Gemini — settings.json 직접 수정
|
|
888
|
+
if (which("gemini")) {
|
|
889
|
+
try {
|
|
890
|
+
const geminiDir = join(homedir(), ".gemini");
|
|
891
|
+
const settingsFile = join(geminiDir, "settings.json");
|
|
892
|
+
let settings = {};
|
|
893
|
+
if (existsSync(settingsFile)) settings = JSON.parse(readFileSync(settingsFile, "utf8"));
|
|
894
|
+
if (!settings.mcpServers) settings.mcpServers = {};
|
|
895
|
+
if (!settings.mcpServers["tfx-hub"]) {
|
|
896
|
+
settings.mcpServers["tfx-hub"] = { url: mcpUrl };
|
|
897
|
+
if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
|
|
898
|
+
writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
|
|
899
|
+
ok("Gemini: settings.json에 등록 완료");
|
|
900
|
+
} else {
|
|
901
|
+
ok("Gemini: 이미 등록됨");
|
|
902
|
+
}
|
|
903
|
+
} catch (e) { warn(`Gemini 등록 실패: ${e.message}`); }
|
|
904
|
+
} else {
|
|
905
|
+
info("Gemini: 미설치 (건너뜀)");
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Claude — 프로젝트 .mcp.json에 등록 (오케스트레이터용)
|
|
909
|
+
try {
|
|
910
|
+
const mcpJsonPath = join(PKG_ROOT, ".mcp.json");
|
|
911
|
+
let mcpJson = {};
|
|
912
|
+
if (existsSync(mcpJsonPath)) mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8"));
|
|
913
|
+
if (!mcpJson.mcpServers) mcpJson.mcpServers = {};
|
|
914
|
+
if (!mcpJson.mcpServers["tfx-hub"]) {
|
|
915
|
+
mcpJson.mcpServers["tfx-hub"] = { type: "url", url: mcpUrl };
|
|
916
|
+
writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
|
|
917
|
+
ok("Claude: .mcp.json에 등록 완료");
|
|
918
|
+
} else {
|
|
919
|
+
ok("Claude: 이미 등록됨");
|
|
920
|
+
}
|
|
921
|
+
} catch (e) { warn(`Claude 등록 실패: ${e.message}`); }
|
|
922
|
+
}
|
|
923
|
+
|
|
838
924
|
function cmdHub() {
|
|
839
925
|
const sub = process.argv[3] || "status";
|
|
840
926
|
|
|
@@ -878,11 +964,14 @@ function cmdHub() {
|
|
|
878
964
|
}
|
|
879
965
|
|
|
880
966
|
if (started) {
|
|
881
|
-
const
|
|
967
|
+
const hubInfo = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
882
968
|
console.log(`\n ${GREEN_BRIGHT}✓${RESET} ${BOLD}tfx-hub 시작${RESET}`);
|
|
883
|
-
console.log(` URL: ${AMBER}${
|
|
884
|
-
console.log(` PID: ${
|
|
885
|
-
console.log(` DB: ${DIM}${HUB_PID_DIR}/state.db${RESET}
|
|
969
|
+
console.log(` URL: ${AMBER}${hubInfo.url}${RESET}`);
|
|
970
|
+
console.log(` PID: ${hubInfo.pid}`);
|
|
971
|
+
console.log(` DB: ${DIM}${HUB_PID_DIR}/state.db${RESET}`);
|
|
972
|
+
console.log("");
|
|
973
|
+
autoRegisterMcp(hubInfo.url);
|
|
974
|
+
console.log("");
|
|
886
975
|
} else {
|
|
887
976
|
// 직접 포그라운드 모드로 안내
|
|
888
977
|
console.log(`\n ${YELLOW}⚠${RESET} 백그라운드 시작 실패 — 포그라운드로 실행:`);
|
|
@@ -929,7 +1018,7 @@ function cmdHub() {
|
|
|
929
1018
|
// HTTP 상태 조회 시도
|
|
930
1019
|
try {
|
|
931
1020
|
const statusUrl = info.url.replace("/mcp", "/status");
|
|
932
|
-
const result = execSync(`curl -s "${statusUrl}"
|
|
1021
|
+
const result = execSync(`curl -s "${statusUrl}"`, { encoding: "utf8", timeout: 3000, stdio: ["pipe", "pipe", "ignore"] });
|
|
933
1022
|
const data = JSON.parse(result);
|
|
934
1023
|
if (data.hub) {
|
|
935
1024
|
console.log(` State: ${data.hub.state}`);
|
package/hub/bridge.mjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hub/bridge.mjs — tfx-route.sh ↔ tfx-hub 브릿지 CLI
|
|
3
|
+
//
|
|
4
|
+
// tfx-route.sh에서 CLI 에이전트 실행 전후로 호출하여
|
|
5
|
+
// Hub에 자동 등록/결과 발행/컨텍스트 수신/해제를 수행한다.
|
|
6
|
+
//
|
|
7
|
+
// 사용법:
|
|
8
|
+
// node bridge.mjs register --agent <id> --cli <type> --timeout <sec> [--topics t1,t2]
|
|
9
|
+
// node bridge.mjs result --agent <id> --file <path> [--topic task.result] [--trace <id>]
|
|
10
|
+
// node bridge.mjs context --agent <id> [--topics t1,t2] [--max 10] [--out <path>]
|
|
11
|
+
// node bridge.mjs deregister --agent <id>
|
|
12
|
+
// node bridge.mjs ping
|
|
13
|
+
//
|
|
14
|
+
// Hub 미실행 시 모든 커맨드는 조용히 실패 (exit 0).
|
|
15
|
+
// tfx-route.sh 흐름을 절대 차단하지 않는다.
|
|
16
|
+
|
|
17
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
|
|
21
|
+
const HUB_PID_FILE = join(homedir(), '.claude', 'cache', 'tfx-hub', 'hub.pid');
|
|
22
|
+
|
|
23
|
+
// ── Hub URL 해석 ──
|
|
24
|
+
|
|
25
|
+
function getHubUrl() {
|
|
26
|
+
// 환경변수 우선
|
|
27
|
+
if (process.env.TFX_HUB_URL) return process.env.TFX_HUB_URL.replace(/\/mcp$/, '');
|
|
28
|
+
|
|
29
|
+
// PID 파일에서 읽기
|
|
30
|
+
if (existsSync(HUB_PID_FILE)) {
|
|
31
|
+
try {
|
|
32
|
+
const info = JSON.parse(readFileSync(HUB_PID_FILE, 'utf8'));
|
|
33
|
+
return `http://${info.host || '127.0.0.1'}:${info.port || 27888}`;
|
|
34
|
+
} catch { /* 무시 */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 기본값
|
|
38
|
+
const port = process.env.TFX_HUB_PORT || '27888';
|
|
39
|
+
return `http://127.0.0.1:${port}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── HTTP 요청 ──
|
|
43
|
+
|
|
44
|
+
async function post(path, body, timeoutMs = 5000) {
|
|
45
|
+
const url = `${getHubUrl()}${path}`;
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(url, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
signal: controller.signal,
|
|
55
|
+
});
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
return await res.json();
|
|
58
|
+
} catch {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
return null; // Hub 미실행 — 조용히 실패
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── 인자 파싱 ──
|
|
65
|
+
|
|
66
|
+
function parseArgs(argv) {
|
|
67
|
+
const args = {};
|
|
68
|
+
for (let i = 0; i < argv.length; i++) {
|
|
69
|
+
if (argv[i].startsWith('--')) {
|
|
70
|
+
const key = argv[i].slice(2);
|
|
71
|
+
const next = argv[i + 1];
|
|
72
|
+
if (!next || next.startsWith('--')) {
|
|
73
|
+
args[key] = true;
|
|
74
|
+
} else {
|
|
75
|
+
args[key] = next;
|
|
76
|
+
i++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return args;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── 커맨드 ──
|
|
84
|
+
|
|
85
|
+
async function cmdRegister(args) {
|
|
86
|
+
const agentId = args.agent;
|
|
87
|
+
const cli = args.cli || 'other';
|
|
88
|
+
const timeoutSec = parseInt(args.timeout || '600', 10);
|
|
89
|
+
const topics = args.topics ? args.topics.split(',') : [];
|
|
90
|
+
const capabilities = args.capabilities ? args.capabilities.split(',') : ['code'];
|
|
91
|
+
|
|
92
|
+
const result = await post('/bridge/register', {
|
|
93
|
+
agent_id: agentId,
|
|
94
|
+
cli,
|
|
95
|
+
timeout_sec: timeoutSec,
|
|
96
|
+
topics,
|
|
97
|
+
capabilities,
|
|
98
|
+
metadata: {
|
|
99
|
+
pid: process.ppid, // 부모 프로세스 (tfx-route.sh)
|
|
100
|
+
registered_at: Date.now(),
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (result?.ok) {
|
|
105
|
+
// 에이전트 ID를 stdout으로 출력 (tfx-route.sh에서 캡처)
|
|
106
|
+
console.log(JSON.stringify({ ok: true, agent_id: agentId, lease_expires_ms: result.data?.lease_expires_ms }));
|
|
107
|
+
} else {
|
|
108
|
+
// Hub 미실행 — 조용히 패스
|
|
109
|
+
console.log(JSON.stringify({ ok: false, reason: 'hub_unavailable' }));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function cmdResult(args) {
|
|
114
|
+
const agentId = args.agent;
|
|
115
|
+
const filePath = args.file;
|
|
116
|
+
const topic = args.topic || 'task.result';
|
|
117
|
+
const traceId = args.trace || undefined;
|
|
118
|
+
const correlationId = args.correlation || undefined;
|
|
119
|
+
const exitCode = parseInt(args['exit-code'] || '0', 10);
|
|
120
|
+
|
|
121
|
+
// 결과 파일 읽기 (최대 48KB — Hub 메시지 크기 제한)
|
|
122
|
+
let output = '';
|
|
123
|
+
if (filePath && existsSync(filePath)) {
|
|
124
|
+
output = readFileSync(filePath, 'utf8').slice(0, 49152);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const result = await post('/bridge/result', {
|
|
128
|
+
agent_id: agentId,
|
|
129
|
+
topic,
|
|
130
|
+
payload: {
|
|
131
|
+
agent_id: agentId,
|
|
132
|
+
exit_code: exitCode,
|
|
133
|
+
output_length: output.length,
|
|
134
|
+
output_preview: output.slice(0, 4096), // 미리보기 4KB
|
|
135
|
+
output_full: output, // 전체 (최대 48KB)
|
|
136
|
+
completed_at: Date.now(),
|
|
137
|
+
},
|
|
138
|
+
trace_id: traceId,
|
|
139
|
+
correlation_id: correlationId,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (result?.ok) {
|
|
143
|
+
console.log(JSON.stringify({ ok: true, message_id: result.data?.message_id }));
|
|
144
|
+
} else {
|
|
145
|
+
console.log(JSON.stringify({ ok: false, reason: 'hub_unavailable' }));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function cmdContext(args) {
|
|
150
|
+
const agentId = args.agent;
|
|
151
|
+
const topics = args.topics ? args.topics.split(',') : undefined;
|
|
152
|
+
const maxMessages = parseInt(args.max || '10', 10);
|
|
153
|
+
const outPath = args.out;
|
|
154
|
+
|
|
155
|
+
const result = await post('/bridge/context', {
|
|
156
|
+
agent_id: agentId,
|
|
157
|
+
topics,
|
|
158
|
+
max_messages: maxMessages,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (result?.ok && result.data?.messages?.length) {
|
|
162
|
+
// 컨텍스트 조합
|
|
163
|
+
const parts = result.data.messages.map((m, i) => {
|
|
164
|
+
const from = m.from_agent || 'unknown';
|
|
165
|
+
const topic = m.topic || 'unknown';
|
|
166
|
+
const payload = typeof m.payload === 'string' ? m.payload : JSON.stringify(m.payload, null, 2);
|
|
167
|
+
return `=== Context ${i + 1}: ${from} (${topic}) ===\n${payload}`;
|
|
168
|
+
});
|
|
169
|
+
const combined = parts.join('\n\n');
|
|
170
|
+
|
|
171
|
+
if (outPath) {
|
|
172
|
+
writeFileSync(outPath, combined, 'utf8');
|
|
173
|
+
console.log(JSON.stringify({ ok: true, count: result.data.messages.length, file: outPath }));
|
|
174
|
+
} else {
|
|
175
|
+
console.log(combined);
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
if (outPath) {
|
|
179
|
+
console.log(JSON.stringify({ ok: true, count: 0 }));
|
|
180
|
+
}
|
|
181
|
+
// 메시지 없으면 빈 출력
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function cmdDeregister(args) {
|
|
186
|
+
const agentId = args.agent;
|
|
187
|
+
const result = await post('/bridge/deregister', { agent_id: agentId });
|
|
188
|
+
|
|
189
|
+
if (result?.ok) {
|
|
190
|
+
console.log(JSON.stringify({ ok: true, agent_id: agentId, status: 'offline' }));
|
|
191
|
+
} else {
|
|
192
|
+
console.log(JSON.stringify({ ok: false, reason: 'hub_unavailable' }));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function cmdPing() {
|
|
197
|
+
try {
|
|
198
|
+
const url = `${getHubUrl()}/status`;
|
|
199
|
+
const controller = new AbortController();
|
|
200
|
+
const timer = setTimeout(() => controller.abort(), 3000);
|
|
201
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
const data = await res.json();
|
|
204
|
+
console.log(JSON.stringify({ ok: true, hub: data.hub?.state, sessions: data.sessions }));
|
|
205
|
+
} catch {
|
|
206
|
+
console.log(JSON.stringify({ ok: false, reason: 'hub_unavailable' }));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── 메인 ──
|
|
211
|
+
|
|
212
|
+
const cmd = process.argv[2];
|
|
213
|
+
const args = parseArgs(process.argv.slice(3));
|
|
214
|
+
|
|
215
|
+
switch (cmd) {
|
|
216
|
+
case 'register': await cmdRegister(args); break;
|
|
217
|
+
case 'result': await cmdResult(args); break;
|
|
218
|
+
case 'context': await cmdContext(args); break;
|
|
219
|
+
case 'deregister': await cmdDeregister(args); break;
|
|
220
|
+
case 'ping': await cmdPing(); break;
|
|
221
|
+
default:
|
|
222
|
+
console.error('사용법: bridge.mjs <register|result|context|deregister|ping> [--옵션]');
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
package/hub/router.mjs
CHANGED
package/hub/server.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync } from '
|
|
|
8
8
|
|
|
9
9
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
10
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
11
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
11
12
|
|
|
12
13
|
import { createStore } from './store.mjs';
|
|
13
14
|
import { createRouter } from './router.mjs';
|
|
@@ -68,7 +69,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1' } = {}
|
|
|
68
69
|
|
|
69
70
|
// tools/list 핸들러
|
|
70
71
|
mcp.setRequestHandler(
|
|
71
|
-
|
|
72
|
+
ListToolsRequestSchema,
|
|
72
73
|
async () => ({
|
|
73
74
|
tools: tools.map(t => ({
|
|
74
75
|
name: t.name,
|
|
@@ -80,7 +81,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1' } = {}
|
|
|
80
81
|
|
|
81
82
|
// tools/call 핸들러
|
|
82
83
|
mcp.setRequestHandler(
|
|
83
|
-
|
|
84
|
+
CallToolRequestSchema,
|
|
84
85
|
async (request) => {
|
|
85
86
|
const { name, arguments: args } = request.params;
|
|
86
87
|
const tool = tools.find(t => t.name === name);
|
|
@@ -120,6 +121,87 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1' } = {}
|
|
|
120
121
|
}));
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
// /bridge/* — 경량 REST 엔드포인트 (tfx-route.sh 브릿지용)
|
|
125
|
+
if (req.url.startsWith('/bridge')) {
|
|
126
|
+
res.setHeader('Content-Type', 'application/json');
|
|
127
|
+
|
|
128
|
+
if (req.method !== 'POST' && req.method !== 'DELETE') {
|
|
129
|
+
res.writeHead(405);
|
|
130
|
+
return res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const body = (req.method === 'POST') ? await parseBody(req) : {};
|
|
135
|
+
const path = req.url.replace(/\?.*/, '');
|
|
136
|
+
|
|
137
|
+
// POST /bridge/register — 에이전트 등록 (프로세스 수명 기반)
|
|
138
|
+
if (path === '/bridge/register' && req.method === 'POST') {
|
|
139
|
+
const { agent_id, cli, timeout_sec = 600, topics = [], capabilities = [], metadata = {} } = body;
|
|
140
|
+
if (!agent_id || !cli) {
|
|
141
|
+
res.writeHead(400);
|
|
142
|
+
return res.end(JSON.stringify({ ok: false, error: 'agent_id, cli 필수' }));
|
|
143
|
+
}
|
|
144
|
+
// heartbeat = 프로세스 타임아웃 + 여유 120초
|
|
145
|
+
const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
|
|
146
|
+
const data = store.registerAgent({ agent_id, cli, capabilities, topics, heartbeat_ttl_ms, metadata });
|
|
147
|
+
res.writeHead(200);
|
|
148
|
+
return res.end(JSON.stringify({ ok: true, data }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// POST /bridge/result — 결과 발행
|
|
152
|
+
if (path === '/bridge/result' && req.method === 'POST') {
|
|
153
|
+
const { agent_id, topic = 'task.result', payload = {}, trace_id, correlation_id } = body;
|
|
154
|
+
if (!agent_id) {
|
|
155
|
+
res.writeHead(400);
|
|
156
|
+
return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
|
|
157
|
+
}
|
|
158
|
+
const result = router.handlePublish({
|
|
159
|
+
from: agent_id, to: 'topic:' + topic, topic, payload,
|
|
160
|
+
priority: 5, ttl_ms: 3600000, trace_id, correlation_id,
|
|
161
|
+
});
|
|
162
|
+
res.writeHead(200);
|
|
163
|
+
return res.end(JSON.stringify(result));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// POST /bridge/context — 선행 컨텍스트 폴링
|
|
167
|
+
if (path === '/bridge/context' && req.method === 'POST') {
|
|
168
|
+
const { agent_id, topics, max_messages = 10 } = body;
|
|
169
|
+
if (!agent_id) {
|
|
170
|
+
res.writeHead(400);
|
|
171
|
+
return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
|
|
172
|
+
}
|
|
173
|
+
const messages = store.pollForAgent(agent_id, {
|
|
174
|
+
max_messages,
|
|
175
|
+
include_topics: topics,
|
|
176
|
+
auto_ack: true,
|
|
177
|
+
});
|
|
178
|
+
res.writeHead(200);
|
|
179
|
+
return res.end(JSON.stringify({ ok: true, data: { messages, count: messages.length } }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// POST /bridge/deregister — 에이전트 해제
|
|
183
|
+
if (path === '/bridge/deregister' && req.method === 'POST') {
|
|
184
|
+
const { agent_id } = body;
|
|
185
|
+
if (!agent_id) {
|
|
186
|
+
res.writeHead(400);
|
|
187
|
+
return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
|
|
188
|
+
}
|
|
189
|
+
store.db.prepare("UPDATE agents SET status='offline' WHERE agent_id=?").run(agent_id);
|
|
190
|
+
res.writeHead(200);
|
|
191
|
+
return res.end(JSON.stringify({ ok: true, data: { agent_id, status: 'offline' } }));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
res.writeHead(404);
|
|
195
|
+
return res.end(JSON.stringify({ ok: false, error: 'Unknown bridge endpoint' }));
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (!res.headersSent) {
|
|
198
|
+
res.writeHead(500);
|
|
199
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
123
205
|
// /mcp — MCP Streamable HTTP 엔드포인트
|
|
124
206
|
if (req.url !== '/mcp') {
|
|
125
207
|
res.writeHead(404);
|
package/hub/store.mjs
CHANGED
|
@@ -84,7 +84,7 @@ export function createStore(dbPath) {
|
|
|
84
84
|
allAgents: db.prepare('SELECT * FROM agents'),
|
|
85
85
|
agentsByTopic: db.prepare("SELECT a.* FROM agents a, json_each(a.topics_json) t WHERE t.value=? AND a.status != 'offline'"),
|
|
86
86
|
markStale: db.prepare("UPDATE agents SET status='stale' WHERE status='online' AND lease_expires_ms < ?"),
|
|
87
|
-
markOffline: db.prepare("UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? -
|
|
87
|
+
markOffline: db.prepare("UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? - 300000"),
|
|
88
88
|
|
|
89
89
|
// 메시지
|
|
90
90
|
insertMsg: db.prepare(`
|
package/hub/tools.mjs
CHANGED
|
@@ -38,7 +38,7 @@ export function createTools(store, router, hitl) {
|
|
|
38
38
|
capabilities: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 64 },
|
|
39
39
|
topics: { type: 'array', items: { type: 'string' }, maxItems: 64 },
|
|
40
40
|
metadata: { type: 'object' },
|
|
41
|
-
heartbeat_ttl_ms: { type: 'integer', minimum:
|
|
41
|
+
heartbeat_ttl_ms: { type: 'integer', minimum: 10000, maximum: 7200000 },
|
|
42
42
|
},
|
|
43
43
|
},
|
|
44
44
|
handler: wrap('REGISTER_FAILED', (args) => {
|
package/package.json
CHANGED
package/scripts/tfx-route.sh
CHANGED
|
@@ -38,6 +38,38 @@ STDERR_LOG="/tmp/tfx-route-${AGENT_TYPE}-${TIMESTAMP}-stderr.log"
|
|
|
38
38
|
STDOUT_LOG="/tmp/tfx-route-${AGENT_TYPE}-${TIMESTAMP}-stdout.log"
|
|
39
39
|
TFX_TMP="${TMPDIR:-/tmp}"
|
|
40
40
|
|
|
41
|
+
# ── Hub 브릿지 (선택적 — Hub 미실행 시 무시) ──
|
|
42
|
+
# 패키지 내 브릿지 탐색 (npm global / git local 모두 대응)
|
|
43
|
+
find_bridge() {
|
|
44
|
+
# 1. 환경변수 지정
|
|
45
|
+
[[ -n "${TFX_BRIDGE:-}" && -f "$TFX_BRIDGE" ]] && echo "$TFX_BRIDGE" && return
|
|
46
|
+
# 2. 같은 패키지 내
|
|
47
|
+
local script_dir
|
|
48
|
+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
49
|
+
local pkg_bridge="${script_dir}/../hub/bridge.mjs"
|
|
50
|
+
[[ -f "$pkg_bridge" ]] && echo "$pkg_bridge" && return
|
|
51
|
+
# 3. 설치된 triflux 패키지
|
|
52
|
+
local npm_bridge
|
|
53
|
+
npm_bridge="$(npm root -g 2>/dev/null)/triflux/hub/bridge.mjs"
|
|
54
|
+
[[ -f "$npm_bridge" ]] && echo "$npm_bridge" && return
|
|
55
|
+
echo ""
|
|
56
|
+
}
|
|
57
|
+
BRIDGE_BIN="$(find_bridge)"
|
|
58
|
+
HUB_ENABLED="false"
|
|
59
|
+
if [[ -n "$BRIDGE_BIN" ]]; then
|
|
60
|
+
# Hub 핑 (3초 타임아웃, 실패 시 무시)
|
|
61
|
+
HUB_PING=$(node "$BRIDGE_BIN" ping 2>/dev/null || echo '{"ok":false}')
|
|
62
|
+
if echo "$HUB_PING" | grep -q '"ok":true'; then
|
|
63
|
+
HUB_ENABLED="true"
|
|
64
|
+
fi
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# Hub 브릿지 래퍼 (Hub 꺼져있으면 아무것도 안 함)
|
|
68
|
+
hub_bridge() {
|
|
69
|
+
[[ "$HUB_ENABLED" != "true" ]] && return 0
|
|
70
|
+
node "$BRIDGE_BIN" "$@" 2>/dev/null || true
|
|
71
|
+
}
|
|
72
|
+
|
|
41
73
|
# fallback 시 원래 에이전트 정보 보존
|
|
42
74
|
ORIGINAL_AGENT=""
|
|
43
75
|
ORIGINAL_CLI_ARGS=""
|
|
@@ -353,11 +385,31 @@ ${ctx_content}
|
|
|
353
385
|
|
|
354
386
|
# 메타정보 (stderr)
|
|
355
387
|
echo "[tfx-route] v${VERSION} type=$CLI_TYPE agent=$AGENT_TYPE effort=$CLI_EFFORT mode=$RUN_MODE timeout=${TIMEOUT_SEC}s" >&2
|
|
356
|
-
echo "[tfx-route] opus_oversight=$OPUS_OVERSIGHT mcp_profile=$MCP_PROFILE" >&2
|
|
388
|
+
echo "[tfx-route] opus_oversight=$OPUS_OVERSIGHT mcp_profile=$MCP_PROFILE hub=$HUB_ENABLED" >&2
|
|
357
389
|
|
|
358
390
|
# Per-process 에이전트 등록
|
|
359
391
|
register_agent
|
|
360
392
|
|
|
393
|
+
# Hub 브릿지: 에이전트 등록 (프로세스 수명 기반 lease)
|
|
394
|
+
local hub_agent_id="${AGENT_TYPE}-$$"
|
|
395
|
+
local hub_topics="${AGENT_TYPE},task.result"
|
|
396
|
+
hub_bridge register \
|
|
397
|
+
--agent "$hub_agent_id" \
|
|
398
|
+
--cli "$CLI_TYPE" \
|
|
399
|
+
--timeout "$TIMEOUT_SEC" \
|
|
400
|
+
--topics "$hub_topics" \
|
|
401
|
+
--capabilities "code,${AGENT_TYPE}"
|
|
402
|
+
|
|
403
|
+
# Hub 브릿지: 선행 컨텍스트 폴링 (DAG 의존 태스크용)
|
|
404
|
+
if [[ "$HUB_ENABLED" == "true" && -z "$CONTEXT_FILE" ]]; then
|
|
405
|
+
local hub_ctx_file="${TFX_TMP}/tfx-hub-ctx-${hub_agent_id}.md"
|
|
406
|
+
hub_bridge context --agent "$hub_agent_id" --topics "$hub_topics" --out "$hub_ctx_file"
|
|
407
|
+
if [[ -s "$hub_ctx_file" ]]; then
|
|
408
|
+
CONTEXT_FILE="$hub_ctx_file"
|
|
409
|
+
echo "[tfx-route] hub: 선행 컨텍스트 수신 ($(wc -c < "$hub_ctx_file") bytes)" >&2
|
|
410
|
+
fi
|
|
411
|
+
fi
|
|
412
|
+
|
|
361
413
|
# CLI 실행 (stderr 분리 + 타임아웃 + 소요시간 측정)
|
|
362
414
|
local exit_code=0
|
|
363
415
|
local start_time
|
|
@@ -414,6 +466,14 @@ ${ctx_content}
|
|
|
414
466
|
end_time=$(date +%s)
|
|
415
467
|
local elapsed=$((end_time - start_time))
|
|
416
468
|
|
|
469
|
+
# Hub 브릿지: 결과 발행 + 에이전트 해제
|
|
470
|
+
hub_bridge result \
|
|
471
|
+
--agent "$hub_agent_id" \
|
|
472
|
+
--file "$STDOUT_LOG" \
|
|
473
|
+
--topic "task.result" \
|
|
474
|
+
--exit-code "$exit_code"
|
|
475
|
+
hub_bridge deregister --agent "$hub_agent_id"
|
|
476
|
+
|
|
417
477
|
# ── 후처리: 단일 node 프로세스로 위임 ──
|
|
418
478
|
# 토큰 추출, 출력 필터링, 로그, 토큰 누적, AIMD, 이슈 추적, 결과 출력 전부 처리
|
|
419
479
|
local post_script="${HOME}/.claude/scripts/tfx-route-post.mjs"
|