triflux 3.1.0-dev.3 → 3.1.0-dev.5
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 +50 -42
- package/hub/hitl.mjs +32 -36
- package/hub/router.mjs +56 -45
- package/hub/server.mjs +29 -23
- package/hub/store.mjs +31 -9
- package/hub/tools.mjs +13 -9
- package/hud/hud-qos-status.mjs +1 -1
- package/package.json +1 -1
- package/scripts/setup.mjs +13 -0
- package/skills/tfx-hub/SKILL.md +57 -7
package/hub/bridge.mjs
CHANGED
|
@@ -14,15 +14,16 @@
|
|
|
14
14
|
// Hub 미실행 시 모든 커맨드는 조용히 실패 (exit 0).
|
|
15
15
|
// tfx-route.sh 흐름을 절대 차단하지 않는다.
|
|
16
16
|
|
|
17
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
import { homedir } from 'node:os';
|
|
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';
|
|
20
21
|
|
|
21
22
|
const HUB_PID_FILE = join(homedir(), '.claude', 'cache', 'tfx-hub', 'hub.pid');
|
|
22
23
|
|
|
23
24
|
// ── Hub URL 해석 ──
|
|
24
25
|
|
|
25
|
-
function getHubUrl() {
|
|
26
|
+
function getHubUrl() {
|
|
26
27
|
// 환경변수 우선
|
|
27
28
|
if (process.env.TFX_HUB_URL) return process.env.TFX_HUB_URL.replace(/\/mcp$/, '');
|
|
28
29
|
|
|
@@ -35,16 +36,18 @@ function getHubUrl() {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
// 기본값
|
|
38
|
-
const port = process.env.TFX_HUB_PORT || '27888';
|
|
39
|
-
return `http://127.0.0.1:${port}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
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);
|
|
48
51
|
|
|
49
52
|
try {
|
|
50
53
|
const res = await fetch(url, {
|
|
@@ -63,22 +66,27 @@ async function post(path, body, timeoutMs = 5000) {
|
|
|
63
66
|
|
|
64
67
|
// ── 인자 파싱 ──
|
|
65
68
|
|
|
66
|
-
function parseArgs(argv) {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
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
|
+
}
|
|
82
90
|
|
|
83
91
|
// ── 커맨드 ──
|
|
84
92
|
|
|
@@ -127,14 +135,14 @@ async function cmdResult(args) {
|
|
|
127
135
|
const result = await post('/bridge/result', {
|
|
128
136
|
agent_id: agentId,
|
|
129
137
|
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
|
-
|
|
136
|
-
completed_at: Date.now(),
|
|
137
|
-
},
|
|
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
|
+
},
|
|
138
146
|
trace_id: traceId,
|
|
139
147
|
correlation_id: correlationId,
|
|
140
148
|
});
|
|
@@ -193,10 +201,10 @@ async function cmdDeregister(args) {
|
|
|
193
201
|
}
|
|
194
202
|
}
|
|
195
203
|
|
|
196
|
-
async function cmdPing() {
|
|
197
|
-
try {
|
|
198
|
-
const url = `${
|
|
199
|
-
const controller = new AbortController();
|
|
204
|
+
async function cmdPing() {
|
|
205
|
+
try {
|
|
206
|
+
const url = `${_cachedHubUrl}/status`;
|
|
207
|
+
const controller = new AbortController();
|
|
200
208
|
const timer = setTimeout(() => controller.abort(), 3000);
|
|
201
209
|
const res = await fetch(url, { signal: controller.signal });
|
|
202
210
|
clearTimeout(timer);
|
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
|
-
}, 120000);
|
|
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
|
@@ -214,18 +214,23 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1' } = {}
|
|
|
214
214
|
if (req.method === 'POST') {
|
|
215
215
|
const body = await parseBody(req);
|
|
216
216
|
|
|
217
|
-
if (sessionId && transports.has(sessionId)) {
|
|
218
|
-
// 기존 세션
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
};
|
|
229
234
|
const mcp = createMcpForSession();
|
|
230
235
|
await mcp.connect(transport);
|
|
231
236
|
await transport.handleRequest(req, res, body);
|
|
@@ -283,17 +288,18 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1' } = {}
|
|
|
283
288
|
}, 10000);
|
|
284
289
|
hitlTimer.unref();
|
|
285
290
|
|
|
286
|
-
//
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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();
|
|
297
303
|
|
|
298
304
|
// PID 파일 기록
|
|
299
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
|
|
package/hub/tools.mjs
CHANGED
|
@@ -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/hud/hud-qos-status.mjs
CHANGED
|
@@ -791,7 +791,7 @@ async function fetchClaudeUsage(forceRefresh = false) {
|
|
|
791
791
|
: result.status === 401 || result.status === 403 ? "auth"
|
|
792
792
|
: result.error === "timeout" || result.error === "network" ? "network"
|
|
793
793
|
: "unknown";
|
|
794
|
-
writeClaudeUsageCache(
|
|
794
|
+
writeClaudeUsageCache(existingSnapshot.data, { type: errorType, status: result.status });
|
|
795
795
|
return existingSnapshot.data || null;
|
|
796
796
|
}
|
|
797
797
|
const usage = parseClaudeUsageResponse(result.data);
|
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/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 전용** — 로컬 테스트 목적. 프로덕션 배포 전 안정화 필요.
|