triflux 3.1.0-dev.2 → 3.1.0-dev.4
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/hub/bridge.mjs +232 -0
- package/hub/hitl.mjs +32 -36
- package/hub/router.mjs +56 -45
- package/hub/server.mjs +110 -23
- package/hub/store.mjs +32 -10
- package/hub/tools.mjs +14 -10
- package/package.json +1 -1
- package/scripts/setup.mjs +13 -0
- package/scripts/tfx-route.sh +61 -1
- package/skills/tfx-hub/SKILL.md +57 -7
package/hub/bridge.mjs
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
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
|
+
import { parseArgs as nodeParseArgs } from 'node:util';
|
|
21
|
+
|
|
22
|
+
const HUB_PID_FILE = join(homedir(), '.claude', 'cache', 'tfx-hub', 'hub.pid');
|
|
23
|
+
|
|
24
|
+
// ── Hub URL 해석 ──
|
|
25
|
+
|
|
26
|
+
function getHubUrl() {
|
|
27
|
+
// 환경변수 우선
|
|
28
|
+
if (process.env.TFX_HUB_URL) return process.env.TFX_HUB_URL.replace(/\/mcp$/, '');
|
|
29
|
+
|
|
30
|
+
// PID 파일에서 읽기
|
|
31
|
+
if (existsSync(HUB_PID_FILE)) {
|
|
32
|
+
try {
|
|
33
|
+
const info = JSON.parse(readFileSync(HUB_PID_FILE, 'utf8'));
|
|
34
|
+
return `http://${info.host || '127.0.0.1'}:${info.port || 27888}`;
|
|
35
|
+
} catch { /* 무시 */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 기본값
|
|
39
|
+
const port = process.env.TFX_HUB_PORT || '27888';
|
|
40
|
+
return `http://127.0.0.1:${port}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const _cachedHubUrl = getHubUrl();
|
|
44
|
+
|
|
45
|
+
// ── HTTP 요청 ──
|
|
46
|
+
|
|
47
|
+
async function post(path, body, timeoutMs = 5000) {
|
|
48
|
+
const url = `${_cachedHubUrl}${path}`;
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(url, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
});
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
return await res.json();
|
|
61
|
+
} catch {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
return null; // Hub 미실행 — 조용히 실패
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── 인자 파싱 ──
|
|
68
|
+
|
|
69
|
+
function parseArgs(argv) {
|
|
70
|
+
const { values } = nodeParseArgs({
|
|
71
|
+
args: argv,
|
|
72
|
+
options: {
|
|
73
|
+
agent: { type: 'string' },
|
|
74
|
+
cli: { type: 'string' },
|
|
75
|
+
timeout: { type: 'string' },
|
|
76
|
+
topics: { type: 'string' },
|
|
77
|
+
capabilities: { type: 'string' },
|
|
78
|
+
file: { type: 'string' },
|
|
79
|
+
topic: { type: 'string' },
|
|
80
|
+
trace: { type: 'string' },
|
|
81
|
+
correlation: { type: 'string' },
|
|
82
|
+
'exit-code': { type: 'string' },
|
|
83
|
+
max: { type: 'string' },
|
|
84
|
+
out: { type: 'string' },
|
|
85
|
+
},
|
|
86
|
+
strict: false,
|
|
87
|
+
});
|
|
88
|
+
return values;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── 커맨드 ──
|
|
92
|
+
|
|
93
|
+
async function cmdRegister(args) {
|
|
94
|
+
const agentId = args.agent;
|
|
95
|
+
const cli = args.cli || 'other';
|
|
96
|
+
const timeoutSec = parseInt(args.timeout || '600', 10);
|
|
97
|
+
const topics = args.topics ? args.topics.split(',') : [];
|
|
98
|
+
const capabilities = args.capabilities ? args.capabilities.split(',') : ['code'];
|
|
99
|
+
|
|
100
|
+
const result = await post('/bridge/register', {
|
|
101
|
+
agent_id: agentId,
|
|
102
|
+
cli,
|
|
103
|
+
timeout_sec: timeoutSec,
|
|
104
|
+
topics,
|
|
105
|
+
capabilities,
|
|
106
|
+
metadata: {
|
|
107
|
+
pid: process.ppid, // 부모 프로세스 (tfx-route.sh)
|
|
108
|
+
registered_at: Date.now(),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (result?.ok) {
|
|
113
|
+
// 에이전트 ID를 stdout으로 출력 (tfx-route.sh에서 캡처)
|
|
114
|
+
console.log(JSON.stringify({ ok: true, agent_id: agentId, lease_expires_ms: result.data?.lease_expires_ms }));
|
|
115
|
+
} else {
|
|
116
|
+
// Hub 미실행 — 조용히 패스
|
|
117
|
+
console.log(JSON.stringify({ ok: false, reason: 'hub_unavailable' }));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function cmdResult(args) {
|
|
122
|
+
const agentId = args.agent;
|
|
123
|
+
const filePath = args.file;
|
|
124
|
+
const topic = args.topic || 'task.result';
|
|
125
|
+
const traceId = args.trace || undefined;
|
|
126
|
+
const correlationId = args.correlation || undefined;
|
|
127
|
+
const exitCode = parseInt(args['exit-code'] || '0', 10);
|
|
128
|
+
|
|
129
|
+
// 결과 파일 읽기 (최대 48KB — Hub 메시지 크기 제한)
|
|
130
|
+
let output = '';
|
|
131
|
+
if (filePath && existsSync(filePath)) {
|
|
132
|
+
output = readFileSync(filePath, 'utf8').slice(0, 49152);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const result = await post('/bridge/result', {
|
|
136
|
+
agent_id: agentId,
|
|
137
|
+
topic,
|
|
138
|
+
payload: {
|
|
139
|
+
agent_id: agentId,
|
|
140
|
+
exit_code: exitCode,
|
|
141
|
+
output_length: output.length,
|
|
142
|
+
output_preview: output.slice(0, 4096), // 미리보기 4KB
|
|
143
|
+
output_file: filePath || null,
|
|
144
|
+
completed_at: Date.now(),
|
|
145
|
+
},
|
|
146
|
+
trace_id: traceId,
|
|
147
|
+
correlation_id: correlationId,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (result?.ok) {
|
|
151
|
+
console.log(JSON.stringify({ ok: true, message_id: result.data?.message_id }));
|
|
152
|
+
} else {
|
|
153
|
+
console.log(JSON.stringify({ ok: false, reason: 'hub_unavailable' }));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function cmdContext(args) {
|
|
158
|
+
const agentId = args.agent;
|
|
159
|
+
const topics = args.topics ? args.topics.split(',') : undefined;
|
|
160
|
+
const maxMessages = parseInt(args.max || '10', 10);
|
|
161
|
+
const outPath = args.out;
|
|
162
|
+
|
|
163
|
+
const result = await post('/bridge/context', {
|
|
164
|
+
agent_id: agentId,
|
|
165
|
+
topics,
|
|
166
|
+
max_messages: maxMessages,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (result?.ok && result.data?.messages?.length) {
|
|
170
|
+
// 컨텍스트 조합
|
|
171
|
+
const parts = result.data.messages.map((m, i) => {
|
|
172
|
+
const from = m.from_agent || 'unknown';
|
|
173
|
+
const topic = m.topic || 'unknown';
|
|
174
|
+
const payload = typeof m.payload === 'string' ? m.payload : JSON.stringify(m.payload, null, 2);
|
|
175
|
+
return `=== Context ${i + 1}: ${from} (${topic}) ===\n${payload}`;
|
|
176
|
+
});
|
|
177
|
+
const combined = parts.join('\n\n');
|
|
178
|
+
|
|
179
|
+
if (outPath) {
|
|
180
|
+
writeFileSync(outPath, combined, 'utf8');
|
|
181
|
+
console.log(JSON.stringify({ ok: true, count: result.data.messages.length, file: outPath }));
|
|
182
|
+
} else {
|
|
183
|
+
console.log(combined);
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
if (outPath) {
|
|
187
|
+
console.log(JSON.stringify({ ok: true, count: 0 }));
|
|
188
|
+
}
|
|
189
|
+
// 메시지 없으면 빈 출력
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function cmdDeregister(args) {
|
|
194
|
+
const agentId = args.agent;
|
|
195
|
+
const result = await post('/bridge/deregister', { agent_id: agentId });
|
|
196
|
+
|
|
197
|
+
if (result?.ok) {
|
|
198
|
+
console.log(JSON.stringify({ ok: true, agent_id: agentId, status: 'offline' }));
|
|
199
|
+
} else {
|
|
200
|
+
console.log(JSON.stringify({ ok: false, reason: 'hub_unavailable' }));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function cmdPing() {
|
|
205
|
+
try {
|
|
206
|
+
const url = `${_cachedHubUrl}/status`;
|
|
207
|
+
const controller = new AbortController();
|
|
208
|
+
const timer = setTimeout(() => controller.abort(), 3000);
|
|
209
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
const data = await res.json();
|
|
212
|
+
console.log(JSON.stringify({ ok: true, hub: data.hub?.state, sessions: data.sessions }));
|
|
213
|
+
} catch {
|
|
214
|
+
console.log(JSON.stringify({ ok: false, reason: 'hub_unavailable' }));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── 메인 ──
|
|
219
|
+
|
|
220
|
+
const cmd = process.argv[2];
|
|
221
|
+
const args = parseArgs(process.argv.slice(3));
|
|
222
|
+
|
|
223
|
+
switch (cmd) {
|
|
224
|
+
case 'register': await cmdRegister(args); break;
|
|
225
|
+
case 'result': await cmdResult(args); break;
|
|
226
|
+
case 'context': await cmdContext(args); break;
|
|
227
|
+
case 'deregister': await cmdDeregister(args); break;
|
|
228
|
+
case 'ping': await cmdPing(); break;
|
|
229
|
+
default:
|
|
230
|
+
console.error('사용법: bridge.mjs <register|result|context|deregister|ping> [--옵션]');
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
package/hub/hitl.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// hub/hitl.mjs — Human-in-the-Loop 매니저
|
|
2
|
-
// 사용자 입력 요청/응답, 타임아웃 자동 처리
|
|
3
|
-
import { uuidv7 } from './store.mjs';
|
|
1
|
+
// hub/hitl.mjs — Human-in-the-Loop 매니저
|
|
2
|
+
// 사용자 입력 요청/응답, 타임아웃 자동 처리
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* HITL 매니저 생성
|
|
@@ -88,39 +87,36 @@ export function createHitlManager(store) {
|
|
|
88
87
|
* 만료된 요청 자동 처리
|
|
89
88
|
* deadline 초과 시 default_action 적용
|
|
90
89
|
*/
|
|
91
|
-
checkTimeouts() {
|
|
92
|
-
const pending = store.getPendingHumanRequests();
|
|
93
|
-
const now = Date.now();
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return processed;
|
|
123
|
-
},
|
|
90
|
+
checkTimeouts() {
|
|
91
|
+
const pending = store.getPendingHumanRequests();
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
const expired = pending.filter(hr => hr.deadline_ms <= now);
|
|
94
|
+
if (!expired.length) return 0;
|
|
95
|
+
|
|
96
|
+
// 트랜잭션으로 만료 요청을 일괄 처리해 DB 왕복을 줄인다.
|
|
97
|
+
const processExpired = store.db.transaction(() => {
|
|
98
|
+
for (const hr of expired) {
|
|
99
|
+
store.updateHumanRequest(hr.request_id, 'timed_out', null);
|
|
100
|
+
if (hr.default_action === 'timeout_continue') {
|
|
101
|
+
const msg = store.enqueueMessage({
|
|
102
|
+
type: 'human_response',
|
|
103
|
+
from: 'hub:hitl',
|
|
104
|
+
to: hr.requester_agent,
|
|
105
|
+
topic: 'human.response',
|
|
106
|
+
priority: 5,
|
|
107
|
+
ttl_ms: 300000,
|
|
108
|
+
payload: { request_id: hr.request_id, action: 'timeout_continue', content: null },
|
|
109
|
+
correlation_id: hr.correlation_id,
|
|
110
|
+
trace_id: hr.trace_id,
|
|
111
|
+
});
|
|
112
|
+
store.deliverToAgent(msg.id, hr.requester_agent);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return expired.length;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return processExpired();
|
|
119
|
+
},
|
|
124
120
|
|
|
125
121
|
/** 대기 중인 요청 목록 */
|
|
126
122
|
getPendingRequests() {
|
package/hub/router.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
// hub/router.mjs — Actor mailbox 라우터 + QoS 스케줄러
|
|
2
|
-
// 메시지 라우팅, ask/publish/handoff 처리, TTL 정리
|
|
3
|
-
import {
|
|
1
|
+
// hub/router.mjs — Actor mailbox 라우터 + QoS 스케줄러
|
|
2
|
+
// 메시지 라우팅, ask/publish/handoff 처리, TTL 정리
|
|
3
|
+
import { EventEmitter, once } from 'node:events';
|
|
4
|
+
import { uuidv7 } from './store.mjs';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* 라우터 생성
|
|
7
8
|
* @param {object} store — createStore() 반환 객체
|
|
8
9
|
*/
|
|
9
|
-
export function createRouter(store) {
|
|
10
|
-
let sweepTimer = null;
|
|
11
|
-
let staleTimer = null;
|
|
10
|
+
export function createRouter(store) {
|
|
11
|
+
let sweepTimer = null;
|
|
12
|
+
let staleTimer = null;
|
|
13
|
+
const responseEmitter = new EventEmitter();
|
|
14
|
+
responseEmitter.setMaxListeners(200);
|
|
12
15
|
|
|
13
16
|
const router = {
|
|
14
17
|
/**
|
|
@@ -54,24 +57,29 @@ export function createRouter(store) {
|
|
|
54
57
|
};
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
60
|
+
// 이벤트 기반 대기 (최대 30초 제한)
|
|
61
|
+
try {
|
|
62
|
+
const [payload] = await once(responseEmitter, cid, {
|
|
63
|
+
signal: AbortSignal.timeout(Math.min(await_response_ms, 30000)),
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
data: { request_message_id: msg.id, correlation_id: cid, trace_id: tid, state: 'answered', response: payload },
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
// 타임아웃 — DB에서 최종 확인
|
|
71
|
+
const resp = store.getResponseByCorrelation(cid);
|
|
72
|
+
if (resp) {
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
data: { request_message_id: msg.id, correlation_id: cid, trace_id: tid, state: 'answered', response: resp.payload },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
data: { request_message_id: msg.id, correlation_id: cid, trace_id: tid, state: 'delivered' },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
75
83
|
},
|
|
76
84
|
|
|
77
85
|
/**
|
|
@@ -83,16 +91,19 @@ export function createRouter(store) {
|
|
|
83
91
|
payload = {}, trace_id, correlation_id,
|
|
84
92
|
}) {
|
|
85
93
|
const type = correlation_id ? 'response' : 'event';
|
|
86
|
-
const msg = store.enqueueMessage({
|
|
87
|
-
type, from, to, topic, priority, ttl_ms, payload,
|
|
88
|
-
correlation_id: correlation_id || uuidv7(),
|
|
89
|
-
trace_id: trace_id || uuidv7(),
|
|
90
|
-
});
|
|
91
|
-
const fanout = router.route(msg);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
const msg = store.enqueueMessage({
|
|
95
|
+
type, from, to, topic, priority, ttl_ms, payload,
|
|
96
|
+
correlation_id: correlation_id || uuidv7(),
|
|
97
|
+
trace_id: trace_id || uuidv7(),
|
|
98
|
+
});
|
|
99
|
+
const fanout = router.route(msg);
|
|
100
|
+
if (correlation_id) {
|
|
101
|
+
responseEmitter.emit(correlation_id, msg.payload);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
data: { message_id: msg.id, fanout_count: fanout, expires_at_ms: msg.expires_at_ms },
|
|
106
|
+
};
|
|
96
107
|
},
|
|
97
108
|
|
|
98
109
|
/**
|
|
@@ -118,15 +129,15 @@ export function createRouter(store) {
|
|
|
118
129
|
|
|
119
130
|
// ── 스위퍼 ──
|
|
120
131
|
|
|
121
|
-
/** 주기적 만료 정리 시작 (
|
|
122
|
-
startSweeper() {
|
|
123
|
-
if (sweepTimer) return;
|
|
124
|
-
sweepTimer = setInterval(() => {
|
|
125
|
-
try { store.sweepExpired(); } catch { /* 무시 */ }
|
|
126
|
-
},
|
|
127
|
-
staleTimer = setInterval(() => {
|
|
128
|
-
try { store.sweepStaleAgents(); } catch { /* 무시 */ }
|
|
129
|
-
},
|
|
132
|
+
/** 주기적 만료 정리 시작 (10초: 메시지, 60초: 비활성 에이전트) */
|
|
133
|
+
startSweeper() {
|
|
134
|
+
if (sweepTimer) return;
|
|
135
|
+
sweepTimer = setInterval(() => {
|
|
136
|
+
try { store.sweepExpired(); } catch { /* 무시 */ }
|
|
137
|
+
}, 10000);
|
|
138
|
+
staleTimer = setInterval(() => {
|
|
139
|
+
try { store.sweepStaleAgents(); } catch { /* 무시 */ }
|
|
140
|
+
}, 120000);
|
|
130
141
|
sweepTimer.unref();
|
|
131
142
|
staleTimer.unref();
|
|
132
143
|
},
|
|
@@ -185,5 +196,5 @@ export function createRouter(store) {
|
|
|
185
196
|
},
|
|
186
197
|
};
|
|
187
198
|
|
|
188
|
-
return router;
|
|
189
|
-
}
|
|
199
|
+
return { ...router, responseEmitter };
|
|
200
|
+
}
|
package/hub/server.mjs
CHANGED
|
@@ -121,6 +121,87 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1' } = {}
|
|
|
121
121
|
}));
|
|
122
122
|
}
|
|
123
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
|
+
|
|
124
205
|
// /mcp — MCP Streamable HTTP 엔드포인트
|
|
125
206
|
if (req.url !== '/mcp') {
|
|
126
207
|
res.writeHead(404);
|
|
@@ -133,18 +214,23 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1' } = {}
|
|
|
133
214
|
if (req.method === 'POST') {
|
|
134
215
|
const body = await parseBody(req);
|
|
135
216
|
|
|
136
|
-
if (sessionId && transports.has(sessionId)) {
|
|
137
|
-
// 기존 세션
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
217
|
+
if (sessionId && transports.has(sessionId)) {
|
|
218
|
+
// 기존 세션
|
|
219
|
+
const t = transports.get(sessionId);
|
|
220
|
+
t._lastActivity = Date.now();
|
|
221
|
+
await t.handleRequest(req, res, body);
|
|
222
|
+
} else if (!sessionId && isInitializeRequest(body)) {
|
|
223
|
+
// 새 세션 초기화
|
|
224
|
+
const transport = new StreamableHTTPServerTransport({
|
|
225
|
+
sessionIdGenerator: () => randomUUID(),
|
|
226
|
+
onsessioninitialized: (sid) => {
|
|
227
|
+
transport._lastActivity = Date.now();
|
|
228
|
+
transports.set(sid, transport);
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
transport.onclose = () => {
|
|
232
|
+
if (transport.sessionId) transports.delete(transport.sessionId);
|
|
233
|
+
};
|
|
148
234
|
const mcp = createMcpForSession();
|
|
149
235
|
await mcp.connect(transport);
|
|
150
236
|
await transport.handleRequest(req, res, body);
|
|
@@ -202,17 +288,18 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1' } = {}
|
|
|
202
288
|
}, 10000);
|
|
203
289
|
hitlTimer.unref();
|
|
204
290
|
|
|
205
|
-
//
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
291
|
+
// 비활성 세션 정리 (60초 주기, 30분 TTL)
|
|
292
|
+
const SESSION_TTL_MS = 30 * 60 * 1000;
|
|
293
|
+
const sessionTimer = setInterval(() => {
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
for (const [sid, transport] of transports) {
|
|
296
|
+
if (now - (transport._lastActivity || 0) > SESSION_TTL_MS) {
|
|
297
|
+
try { transport.close(); } catch {}
|
|
298
|
+
transports.delete(sid);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}, 60000);
|
|
302
|
+
sessionTimer.unref();
|
|
216
303
|
|
|
217
304
|
// PID 파일 기록
|
|
218
305
|
mkdirSync(PID_DIR, { recursive: true });
|
package/hub/store.mjs
CHANGED
|
@@ -6,12 +6,23 @@ import { join, dirname } from 'node:path';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { randomBytes } from 'node:crypto';
|
|
8
8
|
|
|
9
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
let _rndPool = Buffer.alloc(0), _rndOff = 0;
|
|
11
|
+
|
|
12
|
+
function pooledRandom(n) {
|
|
13
|
+
if (_rndOff + n > _rndPool.length) {
|
|
14
|
+
_rndPool = randomBytes(256);
|
|
15
|
+
_rndOff = 0;
|
|
16
|
+
}
|
|
17
|
+
const out = Buffer.from(_rndPool.subarray(_rndOff, _rndOff + n));
|
|
18
|
+
_rndOff += n;
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** UUIDv7 생성 (RFC 9562) */
|
|
23
|
+
export function uuidv7() {
|
|
24
|
+
const now = BigInt(Date.now());
|
|
25
|
+
const buf = pooledRandom(16);
|
|
15
26
|
buf[0] = Number((now >> 40n) & 0xffn);
|
|
16
27
|
buf[1] = Number((now >> 32n) & 0xffn);
|
|
17
28
|
buf[2] = Number((now >> 24n) & 0xffn);
|
|
@@ -62,9 +73,20 @@ export function createStore(dbPath) {
|
|
|
62
73
|
db.pragma('busy_timeout = 5000');
|
|
63
74
|
db.pragma('wal_autocheckpoint = 1000');
|
|
64
75
|
|
|
65
|
-
// 스키마 초기화 (schema.sql 전체 실행 — 주석 포함 안전 처리)
|
|
66
|
-
const schemaSQL = readFileSync(join(__dirname, 'schema.sql'), 'utf8');
|
|
67
|
-
|
|
76
|
+
// 스키마 초기화 (schema.sql 전체 실행 — 주석 포함 안전 처리)
|
|
77
|
+
const schemaSQL = readFileSync(join(__dirname, 'schema.sql'), 'utf8');
|
|
78
|
+
|
|
79
|
+
// 스키마 버전 체크 — 불필요한 재실행 방지
|
|
80
|
+
db.exec("CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)");
|
|
81
|
+
const SCHEMA_VERSION = '1';
|
|
82
|
+
const curVer = (() => {
|
|
83
|
+
try { return db.prepare("SELECT value FROM _meta WHERE key='schema_version'").pluck().get(); }
|
|
84
|
+
catch { return null; }
|
|
85
|
+
})();
|
|
86
|
+
if (curVer !== SCHEMA_VERSION) {
|
|
87
|
+
db.exec(schemaSQL);
|
|
88
|
+
db.prepare("INSERT OR REPLACE INTO _meta (key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
|
|
89
|
+
}
|
|
68
90
|
|
|
69
91
|
// ── 준비된 구문 ──
|
|
70
92
|
|
|
@@ -84,7 +106,7 @@ export function createStore(dbPath) {
|
|
|
84
106
|
allAgents: db.prepare('SELECT * FROM agents'),
|
|
85
107
|
agentsByTopic: db.prepare("SELECT a.* FROM agents a, json_each(a.topics_json) t WHERE t.value=? AND a.status != 'offline'"),
|
|
86
108
|
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 < ? -
|
|
109
|
+
markOffline: db.prepare("UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? - 300000"),
|
|
88
110
|
|
|
89
111
|
// 메시지
|
|
90
112
|
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) => {
|
|
@@ -145,15 +145,19 @@ export function createTools(store, router, hitl) {
|
|
|
145
145
|
auto_ack: args.auto_ack,
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
-
// wait_ms > 0 이고 메시지 없으면
|
|
149
|
-
if (!messages.length && args.wait_ms > 0) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
148
|
+
// wait_ms > 0 이고 메시지 없으면 짧은 간격으로 반복 재시도
|
|
149
|
+
if (!messages.length && args.wait_ms > 0) {
|
|
150
|
+
const interval = Math.min(args.wait_ms, 500);
|
|
151
|
+
const deadline = Date.now() + Math.min(args.wait_ms, 30000);
|
|
152
|
+
while (!messages.length && Date.now() < deadline) {
|
|
153
|
+
await new Promise(r => setTimeout(r, interval));
|
|
154
|
+
messages = store.pollForAgent(args.agent_id, {
|
|
155
|
+
max_messages: args.max_messages,
|
|
156
|
+
include_topics: args.include_topics,
|
|
157
|
+
auto_ack: args.auto_ack,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
157
161
|
|
|
158
162
|
return {
|
|
159
163
|
ok: true,
|
package/package.json
CHANGED
package/scripts/setup.mjs
CHANGED
|
@@ -130,6 +130,19 @@ if (existsSync(hudPath)) {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// ── Stale PID 파일 정리 (hub 좀비 방지) ──
|
|
134
|
+
|
|
135
|
+
const HUB_PID_FILE = join(CLAUDE_DIR, "cache", "tfx-hub", "hub.pid");
|
|
136
|
+
if (existsSync(HUB_PID_FILE)) {
|
|
137
|
+
try {
|
|
138
|
+
const pidInfo = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
139
|
+
process.kill(pidInfo.pid, 0); // 프로세스 존재 확인 (신호 미전송)
|
|
140
|
+
} catch {
|
|
141
|
+
try { unlinkSync(HUB_PID_FILE); } catch {} // 죽은 프로세스면 PID 파일 삭제
|
|
142
|
+
synced++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
133
146
|
// ── HUD 에러 캐시 자동 클리어 (업데이트/재설치 시) ──
|
|
134
147
|
|
|
135
148
|
const cacheDir = join(CLAUDE_DIR, "cache");
|
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"
|
package/skills/tfx-hub/SKILL.md
CHANGED
|
@@ -1,15 +1,43 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
name: tfx-hub
|
|
3
|
+
description: tfx-hub 개방형 스킬 — 커맨드(start/stop/status) + 자유형 작업 모두 처리
|
|
4
|
+
triggers:
|
|
5
|
+
- tfx-hub
|
|
6
|
+
argument-hint: "<start|stop|status|자유형 작업 설명>"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# tfx-hub — MCP 메시지 버스 관리 + 개방형 작업
|
|
2
10
|
|
|
3
11
|
> CLI 에이전트(Codex/Gemini/Claude) 간 실시간 메시지 허브를 관리합니다.
|
|
4
|
-
>
|
|
12
|
+
> **커맨드 매칭 + fallthrough**: start/stop/status에 매칭되면 즉시 실행,
|
|
13
|
+
> 매칭 안 되면 **hub 도메인 컨텍스트를 활용한 범용 작업**으로 처리합니다.
|
|
5
14
|
|
|
6
|
-
##
|
|
15
|
+
## 입력 해석 규칙
|
|
7
16
|
|
|
8
17
|
```
|
|
9
|
-
/tfx-hub start
|
|
10
|
-
/tfx-hub
|
|
11
|
-
/tfx-hub
|
|
12
|
-
/tfx-hub
|
|
18
|
+
/tfx-hub start → 커맨드 매칭 → 허브 시작
|
|
19
|
+
/tfx-hub stop → 커맨드 매칭 → 허브 중지
|
|
20
|
+
/tfx-hub status → 커맨드 매칭 → 상태 확인
|
|
21
|
+
/tfx-hub 테스트해줘 → fallthrough → hub 관련 범용 작업으로 처리
|
|
22
|
+
/tfx-hub 문서 저장해 → fallthrough → hub 관련 범용 작업으로 처리
|
|
23
|
+
/tfx-hub 브릿지 분석해 → fallthrough → hub 관련 범용 작업으로 처리
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**fallthrough 규칙**: 인자가 start/stop/status/--port 등 커맨드 키워드에 매칭되지 않으면,
|
|
27
|
+
사용자의 입력을 **hub/브릿지/메시지버스 도메인의 자유형 작업**으로 해석한다.
|
|
28
|
+
|
|
29
|
+
fallthrough 라우팅:
|
|
30
|
+
```bash
|
|
31
|
+
# tfx-route.sh 경유 (권장)
|
|
32
|
+
Bash("bash ~/.claude/scripts/tfx-route.sh {에이전트} '{hub 컨텍스트 + 작업}' {mcp_profile}")
|
|
33
|
+
|
|
34
|
+
# codex 직접 호출 시 — 반드시 exec 서브커맨드 포함
|
|
35
|
+
Bash("codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check '{작업}'")
|
|
36
|
+
Bash("codex --profile xhigh exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check '{작업}'")
|
|
37
|
+
# ↑ --profile은 exec 앞에, --skip-git-repo-check은 exec 뒤에
|
|
38
|
+
|
|
39
|
+
# Claude 네이티브 (탐색/검증)
|
|
40
|
+
Agent(subagent_type="oh-my-claudecode:explore", prompt="{작업}")
|
|
13
41
|
```
|
|
14
42
|
|
|
15
43
|
## 커맨드
|
|
@@ -78,6 +106,28 @@ claude mcp add --transport http tfx-hub http://127.0.0.1:27888/mcp
|
|
|
78
106
|
| `request_human_input` | 사용자 입력 요청 (CAPTCHA/승인) |
|
|
79
107
|
| `submit_human_input` | 사용자 입력 응답 |
|
|
80
108
|
|
|
109
|
+
## 브릿지 REST 엔드포인트 (4개)
|
|
110
|
+
|
|
111
|
+
| 엔드포인트 | 설명 |
|
|
112
|
+
|-----------|------|
|
|
113
|
+
| `POST /bridge/register` | 에이전트 등록 (프로세스 수명 기반 lease) |
|
|
114
|
+
| `POST /bridge/result` | 결과 발행 (topic fanout) |
|
|
115
|
+
| `POST /bridge/context` | 선행 컨텍스트 폴링 (auto_ack) |
|
|
116
|
+
| `POST /bridge/deregister` | 에이전트 offline 마킹 |
|
|
117
|
+
|
|
118
|
+
## 프로젝트 구조
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
hub/
|
|
122
|
+
├── server.mjs # MCP 서버 + REST 브릿지 엔드포인트
|
|
123
|
+
├── store.mjs # SQLite WAL 상태 저장소
|
|
124
|
+
├── router.mjs # Actor mailbox 라우터 + QoS
|
|
125
|
+
├── tools.mjs # MCP 도구 8개 정의
|
|
126
|
+
├── hitl.mjs # Human-in-the-Loop 매니저
|
|
127
|
+
├── bridge.mjs # tfx-route.sh ↔ hub 브릿지 CLI
|
|
128
|
+
└── schema.sql # DB 스키마
|
|
129
|
+
```
|
|
130
|
+
|
|
81
131
|
## 상태
|
|
82
132
|
|
|
83
133
|
**dev 전용** — 로컬 테스트 목적. 프로덕션 배포 전 안정화 필요.
|