triflux 3.3.0-dev.5 → 3.3.0-dev.7
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/bin/triflux.mjs +20 -2
- package/hub/bridge.mjs +9 -4
- package/hub/server.mjs +111 -88
- package/hub/team/nativeProxy.mjs +173 -72
- package/hub/tools.mjs +6 -6
- package/package.json +4 -4
- package/scripts/completions/tfx.bash +47 -0
- package/scripts/completions/tfx.fish +44 -0
- package/scripts/completions/tfx.zsh +83 -0
- package/scripts/lib/mcp-filter.mjs +6 -1
- package/scripts/tfx-route.sh +70 -1
- package/skills/tfx-auto/SKILL.md +6 -1
package/bin/triflux.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// triflux CLI — setup, doctor, version
|
|
3
|
-
import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync, rmSync, statSync } from "fs";
|
|
3
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync, rmSync, statSync, openSync, closeSync } from "fs";
|
|
4
4
|
import { join, dirname } from "path";
|
|
5
5
|
import { homedir } from "os";
|
|
6
6
|
import { execSync, execFileSync, spawn } from "child_process";
|
|
@@ -1408,7 +1408,25 @@ function stopHubForUpdate() {
|
|
|
1408
1408
|
try { process.kill(info.pid, "SIGKILL"); } catch {}
|
|
1409
1409
|
}
|
|
1410
1410
|
|
|
1411
|
-
|
|
1411
|
+
// Windows에서 better-sqlite3.node 파일 핸들 해제 대기
|
|
1412
|
+
// taskkill 후 프로세스 종료 + 파일 핸들 해제까지 최대 5초
|
|
1413
|
+
const sqliteNode = join(PKG_ROOT, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node");
|
|
1414
|
+
for (let i = 0; i < 10; i++) {
|
|
1415
|
+
sleepMs(500);
|
|
1416
|
+
try { process.kill(info.pid, 0); } catch { break; }
|
|
1417
|
+
}
|
|
1418
|
+
// 파일 잠금 해제 확인 (Windows EBUSY 방지)
|
|
1419
|
+
if (existsSync(sqliteNode)) {
|
|
1420
|
+
for (let i = 0; i < 6; i++) {
|
|
1421
|
+
try {
|
|
1422
|
+
const fd = openSync(sqliteNode, "r");
|
|
1423
|
+
closeSync(fd);
|
|
1424
|
+
break;
|
|
1425
|
+
} catch {
|
|
1426
|
+
sleepMs(500);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1412
1430
|
try { unlinkSync(HUB_PID_FILE); } catch {}
|
|
1413
1431
|
return info;
|
|
1414
1432
|
}
|
package/hub/bridge.mjs
CHANGED
|
@@ -14,13 +14,18 @@ import { randomUUID } from 'node:crypto';
|
|
|
14
14
|
const HUB_PID_FILE = join(homedir(), '.claude', 'cache', 'tfx-hub', 'hub.pid');
|
|
15
15
|
const HUB_TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
|
|
16
16
|
|
|
17
|
+
function normalizeToken(raw) {
|
|
18
|
+
if (raw == null) return null;
|
|
19
|
+
const token = String(raw).trim();
|
|
20
|
+
return token || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
// Hub 인증 토큰 읽기 (파일 없으면 null → 하위 호환)
|
|
18
24
|
function readHubToken() {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
25
|
+
const envToken = normalizeToken(process.env.TFX_HUB_TOKEN);
|
|
26
|
+
if (envToken) return envToken;
|
|
22
27
|
try {
|
|
23
|
-
return readFileSync(HUB_TOKEN_FILE, 'utf8')
|
|
28
|
+
return normalizeToken(readFileSync(HUB_TOKEN_FILE, 'utf8'));
|
|
24
29
|
} catch {
|
|
25
30
|
return null;
|
|
26
31
|
}
|
package/hub/server.mjs
CHANGED
|
@@ -16,13 +16,17 @@ import { createPipeServer } from './pipe.mjs';
|
|
|
16
16
|
import { createAssignCallbackServer } from './assign-callbacks.mjs';
|
|
17
17
|
import { createTools } from './tools.mjs';
|
|
18
18
|
|
|
19
|
+
const MAX_BODY_SIZE = 1024 * 1024;
|
|
20
|
+
const PUBLIC_PATHS = new Set(['/', '/status', '/health', '/healthz']);
|
|
21
|
+
const LOOPBACK_REMOTE_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
22
|
+
const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
|
|
23
|
+
|
|
19
24
|
function isInitializeRequest(body) {
|
|
20
25
|
if (body?.method === 'initialize') return true;
|
|
21
26
|
if (Array.isArray(body)) return body.some((message) => message.method === 'initialize');
|
|
22
27
|
return false;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
const MAX_BODY_SIZE = 1024 * 1024;
|
|
26
30
|
async function parseBody(req) {
|
|
27
31
|
const chunks = [];
|
|
28
32
|
let size = 0;
|
|
@@ -40,13 +44,56 @@ const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
|
|
|
40
44
|
const PID_FILE = join(PID_DIR, 'hub.pid');
|
|
41
45
|
const TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
|
|
42
46
|
|
|
43
|
-
// localhost 계열 Origin만 허용
|
|
44
|
-
const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
|
|
45
|
-
|
|
46
47
|
function isAllowedOrigin(origin) {
|
|
47
48
|
return origin && ALLOWED_ORIGIN_RE.test(origin);
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
function getRequestPath(url = '/') {
|
|
52
|
+
try {
|
|
53
|
+
return new URL(url, 'http://127.0.0.1').pathname;
|
|
54
|
+
} catch {
|
|
55
|
+
return String(url).replace(/\?.*/, '') || '/';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isLoopbackRemoteAddress(remoteAddress) {
|
|
60
|
+
return typeof remoteAddress === 'string' && LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function extractBearerToken(req) {
|
|
64
|
+
const authHeader = typeof req.headers.authorization === 'string' ? req.headers.authorization : '';
|
|
65
|
+
return authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeJson(res, statusCode, body, headers = {}) {
|
|
69
|
+
res.writeHead(statusCode, {
|
|
70
|
+
'Content-Type': 'application/json',
|
|
71
|
+
...headers,
|
|
72
|
+
});
|
|
73
|
+
res.end(JSON.stringify(body));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function applyCorsHeaders(req, res) {
|
|
77
|
+
const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
|
|
78
|
+
if (origin) {
|
|
79
|
+
res.setHeader('Vary', 'Origin');
|
|
80
|
+
}
|
|
81
|
+
if (!isAllowedOrigin(origin)) return false;
|
|
82
|
+
|
|
83
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
84
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
85
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isAuthorizedRequest(req, path, hubToken) {
|
|
90
|
+
if (!hubToken) {
|
|
91
|
+
return isLoopbackRemoteAddress(req.socket.remoteAddress);
|
|
92
|
+
}
|
|
93
|
+
if (PUBLIC_PATHS.has(path)) return true;
|
|
94
|
+
return extractBearerToken(req) === hubToken;
|
|
95
|
+
}
|
|
96
|
+
|
|
50
97
|
function resolveTeamStatusCode(result) {
|
|
51
98
|
if (result?.ok) return 200;
|
|
52
99
|
const code = result?.error?.code;
|
|
@@ -76,10 +123,13 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
76
123
|
dbPath = join(PID_DIR, 'state.db');
|
|
77
124
|
}
|
|
78
125
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
126
|
+
const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
|
|
127
|
+
if (HUB_TOKEN) {
|
|
128
|
+
mkdirSync(join(homedir(), '.claude'), { recursive: true });
|
|
129
|
+
writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
|
|
130
|
+
} else {
|
|
131
|
+
try { unlinkSync(TOKEN_FILE); } catch {}
|
|
132
|
+
}
|
|
83
133
|
|
|
84
134
|
const store = createStore(dbPath);
|
|
85
135
|
const router = createRouter(store);
|
|
@@ -125,66 +175,61 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
125
175
|
}
|
|
126
176
|
|
|
127
177
|
const httpServer = createHttpServer(async (req, res) => {
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
if (isAllowedOrigin(origin)) {
|
|
131
|
-
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
132
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
133
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
|
|
134
|
-
}
|
|
178
|
+
const path = getRequestPath(req.url);
|
|
179
|
+
const corsAllowed = applyCorsHeaders(req, res);
|
|
135
180
|
|
|
136
181
|
if (req.method === 'OPTIONS') {
|
|
137
|
-
|
|
182
|
+
const localOnlyMode = !HUB_TOKEN;
|
|
183
|
+
const isLoopbackRequest = isLoopbackRemoteAddress(req.socket.remoteAddress);
|
|
184
|
+
res.writeHead(corsAllowed && (!localOnlyMode || isLoopbackRequest) ? 204 : 403);
|
|
138
185
|
return res.end();
|
|
139
186
|
}
|
|
140
187
|
|
|
141
|
-
if (req
|
|
188
|
+
if (!isAuthorizedRequest(req, path, HUB_TOKEN)) {
|
|
189
|
+
if (!HUB_TOKEN) {
|
|
190
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden: localhost only' });
|
|
191
|
+
}
|
|
192
|
+
return writeJson(
|
|
193
|
+
res,
|
|
194
|
+
401,
|
|
195
|
+
{ ok: false, error: 'Unauthorized' },
|
|
196
|
+
{ 'WWW-Authenticate': 'Bearer realm="tfx-hub"' },
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (path === '/' || path === '/status') {
|
|
142
201
|
const status = router.getStatus('hub').data;
|
|
143
|
-
res
|
|
144
|
-
return res.end(JSON.stringify({
|
|
202
|
+
return writeJson(res, 200, {
|
|
145
203
|
...status,
|
|
146
204
|
sessions: transports.size,
|
|
147
205
|
pid: process.pid,
|
|
148
206
|
port,
|
|
207
|
+
auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
|
|
149
208
|
pipe_path: pipe.path,
|
|
150
209
|
pipe: pipe.getStatus(),
|
|
151
210
|
assign_callback_pipe_path: assignCallbacks.path,
|
|
152
211
|
assign_callback_pipe: assignCallbacks.getStatus(),
|
|
153
|
-
})
|
|
212
|
+
});
|
|
154
213
|
}
|
|
155
214
|
|
|
156
|
-
if (
|
|
215
|
+
if (path === '/health' || path === '/healthz') {
|
|
157
216
|
const status = router.getStatus('hub').data;
|
|
158
217
|
const healthy = status?.hub?.state === 'healthy';
|
|
159
|
-
res
|
|
160
|
-
return res.end(JSON.stringify({ ok: healthy }));
|
|
218
|
+
return writeJson(res, healthy ? 200 : 503, { ok: healthy });
|
|
161
219
|
}
|
|
162
220
|
|
|
163
|
-
if (
|
|
164
|
-
res.setHeader('Content-Type', 'application/json');
|
|
165
|
-
|
|
166
|
-
// Bearer 토큰 인증
|
|
167
|
-
const authHeader = req.headers['authorization'] || '';
|
|
168
|
-
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
|
169
|
-
if (bearerToken !== HUB_TOKEN) {
|
|
170
|
-
res.writeHead(401);
|
|
171
|
-
return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
|
|
172
|
-
}
|
|
173
|
-
|
|
221
|
+
if (path.startsWith('/bridge')) {
|
|
174
222
|
if (req.method !== 'POST' && req.method !== 'DELETE') {
|
|
175
|
-
res
|
|
176
|
-
return res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
|
|
223
|
+
return writeJson(res, 405, { ok: false, error: 'Method Not Allowed' });
|
|
177
224
|
}
|
|
178
225
|
|
|
179
226
|
try {
|
|
180
227
|
const body = req.method === 'POST' ? await parseBody(req) : {};
|
|
181
|
-
const path = req.url.replace(/\?.*/, '');
|
|
182
228
|
|
|
183
229
|
if (path === '/bridge/register' && req.method === 'POST') {
|
|
184
230
|
const { agent_id, cli, timeout_sec = 600, topics = [], capabilities = [], metadata = {} } = body;
|
|
185
231
|
if (!agent_id || !cli) {
|
|
186
|
-
res
|
|
187
|
-
return res.end(JSON.stringify({ ok: false, error: 'agent_id, cli 필수' }));
|
|
232
|
+
return writeJson(res, 400, { ok: false, error: 'agent_id, cli 필수' });
|
|
188
233
|
}
|
|
189
234
|
|
|
190
235
|
const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
|
|
@@ -196,15 +241,13 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
196
241
|
heartbeat_ttl_ms,
|
|
197
242
|
metadata,
|
|
198
243
|
});
|
|
199
|
-
res
|
|
200
|
-
return res.end(JSON.stringify(result));
|
|
244
|
+
return writeJson(res, 200, result);
|
|
201
245
|
}
|
|
202
246
|
|
|
203
247
|
if (path === '/bridge/result' && req.method === 'POST') {
|
|
204
248
|
const { agent_id, topic = 'task.result', payload = {}, trace_id, correlation_id } = body;
|
|
205
249
|
if (!agent_id) {
|
|
206
|
-
res
|
|
207
|
-
return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
|
|
250
|
+
return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
|
|
208
251
|
}
|
|
209
252
|
|
|
210
253
|
const result = await pipe.executeCommand('result', {
|
|
@@ -214,8 +257,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
214
257
|
trace_id,
|
|
215
258
|
correlation_id,
|
|
216
259
|
});
|
|
217
|
-
res
|
|
218
|
-
return res.end(JSON.stringify(result));
|
|
260
|
+
return writeJson(res, 200, result);
|
|
219
261
|
}
|
|
220
262
|
|
|
221
263
|
if (path === '/bridge/control' && req.method === 'POST') {
|
|
@@ -231,8 +273,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
231
273
|
} = body;
|
|
232
274
|
|
|
233
275
|
if (!to_agent || !command) {
|
|
234
|
-
res
|
|
235
|
-
return res.end(JSON.stringify({ ok: false, error: 'to_agent, command 필수' }));
|
|
276
|
+
return writeJson(res, 400, { ok: false, error: 'to_agent, command 필수' });
|
|
236
277
|
}
|
|
237
278
|
|
|
238
279
|
const result = await pipe.executeCommand('control', {
|
|
@@ -246,8 +287,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
246
287
|
correlation_id,
|
|
247
288
|
});
|
|
248
289
|
|
|
249
|
-
res
|
|
250
|
-
return res.end(JSON.stringify(result));
|
|
290
|
+
return writeJson(res, 200, result);
|
|
251
291
|
}
|
|
252
292
|
|
|
253
293
|
if (path === '/bridge/assign/async' && req.method === 'POST') {
|
|
@@ -266,8 +306,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
266
306
|
} = body;
|
|
267
307
|
|
|
268
308
|
if (!supervisor_agent || !worker_agent || !task) {
|
|
269
|
-
res
|
|
270
|
-
return res.end(JSON.stringify({ ok: false, error: 'supervisor_agent, worker_agent, task 필수' }));
|
|
309
|
+
return writeJson(res, 400, { ok: false, error: 'supervisor_agent, worker_agent, task 필수' });
|
|
271
310
|
}
|
|
272
311
|
|
|
273
312
|
const result = await pipe.executeCommand('assign', {
|
|
@@ -283,8 +322,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
283
322
|
trace_id,
|
|
284
323
|
correlation_id,
|
|
285
324
|
});
|
|
286
|
-
res
|
|
287
|
-
return res.end(JSON.stringify(result));
|
|
325
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
288
326
|
}
|
|
289
327
|
|
|
290
328
|
if (path === '/bridge/assign/result' && req.method === 'POST') {
|
|
@@ -300,8 +338,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
300
338
|
} = body;
|
|
301
339
|
|
|
302
340
|
if (!job_id || !status) {
|
|
303
|
-
res
|
|
304
|
-
return res.end(JSON.stringify({ ok: false, error: 'job_id, status 필수' }));
|
|
341
|
+
return writeJson(res, 400, { ok: false, error: 'job_id, status 필수' });
|
|
305
342
|
}
|
|
306
343
|
|
|
307
344
|
const result = await pipe.executeCommand('assign_result', {
|
|
@@ -314,22 +351,19 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
314
351
|
payload,
|
|
315
352
|
metadata,
|
|
316
353
|
});
|
|
317
|
-
res
|
|
318
|
-
return res.end(JSON.stringify(result));
|
|
354
|
+
return writeJson(res, result.ok ? 200 : 409, result);
|
|
319
355
|
}
|
|
320
356
|
|
|
321
357
|
if (path === '/bridge/assign/status' && req.method === 'POST') {
|
|
322
358
|
const result = await pipe.executeQuery('assign_status', body);
|
|
323
359
|
const statusCode = result.ok ? 200 : (result.error?.code === 'ASSIGN_NOT_FOUND' ? 404 : 400);
|
|
324
|
-
res
|
|
325
|
-
return res.end(JSON.stringify(result));
|
|
360
|
+
return writeJson(res, statusCode, result);
|
|
326
361
|
}
|
|
327
362
|
|
|
328
363
|
if (path === '/bridge/assign/retry' && req.method === 'POST') {
|
|
329
364
|
const { job_id, reason, requested_by } = body;
|
|
330
365
|
if (!job_id) {
|
|
331
|
-
res
|
|
332
|
-
return res.end(JSON.stringify({ ok: false, error: 'job_id 필수' }));
|
|
366
|
+
return writeJson(res, 400, { ok: false, error: 'job_id 필수' });
|
|
333
367
|
}
|
|
334
368
|
|
|
335
369
|
const result = await pipe.executeCommand('assign_retry', {
|
|
@@ -341,8 +375,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
341
375
|
: result.error?.code === 'ASSIGN_NOT_FOUND' ? 404
|
|
342
376
|
: result.error?.code === 'ASSIGN_RETRY_EXHAUSTED' ? 409
|
|
343
377
|
: 400;
|
|
344
|
-
res
|
|
345
|
-
return res.end(JSON.stringify(result));
|
|
378
|
+
return writeJson(res, statusCode, result);
|
|
346
379
|
}
|
|
347
380
|
|
|
348
381
|
if (req.method === 'POST') {
|
|
@@ -358,46 +391,39 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
358
391
|
}
|
|
359
392
|
|
|
360
393
|
if (teamResult) {
|
|
361
|
-
res
|
|
362
|
-
return res.end(JSON.stringify(teamResult));
|
|
394
|
+
return writeJson(res, resolveTeamStatusCode(teamResult), teamResult);
|
|
363
395
|
}
|
|
364
396
|
|
|
365
397
|
if (path.startsWith('/bridge/team')) {
|
|
366
|
-
res
|
|
367
|
-
return res.end(JSON.stringify({ ok: false, error: `Unknown team endpoint: ${path}` }));
|
|
398
|
+
return writeJson(res, 404, { ok: false, error: `Unknown team endpoint: ${path}` });
|
|
368
399
|
}
|
|
369
400
|
|
|
370
401
|
// ── 파이프라인 엔드포인트 ──
|
|
371
402
|
if (path === '/bridge/pipeline/state' && req.method === 'POST') {
|
|
372
403
|
const result = await pipe.executeQuery('pipeline_state', body);
|
|
373
|
-
res
|
|
374
|
-
return res.end(JSON.stringify(result));
|
|
404
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
375
405
|
}
|
|
376
406
|
|
|
377
407
|
if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
|
|
378
408
|
const result = await pipe.executeCommand('pipeline_advance', body);
|
|
379
|
-
res
|
|
380
|
-
return res.end(JSON.stringify(result));
|
|
409
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
381
410
|
}
|
|
382
411
|
|
|
383
412
|
if (path === '/bridge/pipeline/init' && req.method === 'POST') {
|
|
384
413
|
const result = await pipe.executeCommand('pipeline_init', body);
|
|
385
|
-
res
|
|
386
|
-
return res.end(JSON.stringify(result));
|
|
414
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
387
415
|
}
|
|
388
416
|
|
|
389
417
|
if (path === '/bridge/pipeline/list' && req.method === 'POST') {
|
|
390
418
|
const result = await pipe.executeQuery('pipeline_list', body);
|
|
391
|
-
res
|
|
392
|
-
return res.end(JSON.stringify(result));
|
|
419
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
393
420
|
}
|
|
394
421
|
}
|
|
395
422
|
|
|
396
423
|
if (path === '/bridge/context' && req.method === 'POST') {
|
|
397
424
|
const { agent_id, topics, max_messages = 10, auto_ack = true } = body;
|
|
398
425
|
if (!agent_id) {
|
|
399
|
-
res
|
|
400
|
-
return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
|
|
426
|
+
return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
|
|
401
427
|
}
|
|
402
428
|
|
|
403
429
|
const result = await pipe.executeQuery('drain', {
|
|
@@ -406,33 +432,28 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
406
432
|
max_messages,
|
|
407
433
|
auto_ack,
|
|
408
434
|
});
|
|
409
|
-
res
|
|
410
|
-
return res.end(JSON.stringify(result));
|
|
435
|
+
return writeJson(res, 200, result);
|
|
411
436
|
}
|
|
412
437
|
|
|
413
438
|
if (path === '/bridge/deregister' && req.method === 'POST') {
|
|
414
439
|
const { agent_id } = body;
|
|
415
440
|
if (!agent_id) {
|
|
416
|
-
res
|
|
417
|
-
return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
|
|
441
|
+
return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
|
|
418
442
|
}
|
|
419
443
|
const result = await pipe.executeCommand('deregister', { agent_id });
|
|
420
|
-
res
|
|
421
|
-
return res.end(JSON.stringify(result));
|
|
444
|
+
return writeJson(res, 200, result);
|
|
422
445
|
}
|
|
423
446
|
|
|
424
|
-
res
|
|
425
|
-
return res.end(JSON.stringify({ ok: false, error: 'Unknown bridge endpoint' }));
|
|
447
|
+
return writeJson(res, 404, { ok: false, error: 'Unknown bridge endpoint' });
|
|
426
448
|
} catch (error) {
|
|
427
449
|
if (!res.headersSent) {
|
|
428
|
-
res.
|
|
429
|
-
res.end(JSON.stringify({ ok: false, error: error.message }));
|
|
450
|
+
writeJson(res, 500, { ok: false, error: error.message });
|
|
430
451
|
}
|
|
431
452
|
return;
|
|
432
453
|
}
|
|
433
454
|
}
|
|
434
455
|
|
|
435
|
-
if (
|
|
456
|
+
if (path !== '/mcp') {
|
|
436
457
|
res.writeHead(404);
|
|
437
458
|
return res.end('Not Found');
|
|
438
459
|
}
|
|
@@ -534,6 +555,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
534
555
|
dbPath,
|
|
535
556
|
pid: process.pid,
|
|
536
557
|
hubToken: HUB_TOKEN,
|
|
558
|
+
authMode: HUB_TOKEN ? 'token-required' : 'localhost-only',
|
|
537
559
|
url: `http://${host}:${port}/mcp`,
|
|
538
560
|
pipe_path: pipe.path,
|
|
539
561
|
pipePath: pipe.path,
|
|
@@ -545,6 +567,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
545
567
|
pid: process.pid,
|
|
546
568
|
port,
|
|
547
569
|
host,
|
|
570
|
+
auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
|
|
548
571
|
url: info.url,
|
|
549
572
|
pipe_path: pipe.path,
|
|
550
573
|
pipePath: pipe.path,
|
package/hub/team/nativeProxy.mjs
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
// hub/team/nativeProxy.mjs
|
|
2
2
|
// Claude Native Teams 파일을 Hub tool/REST에서 안전하게 읽고 쓰기 위한 유틸.
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
existsSync,
|
|
6
|
-
mkdirSync,
|
|
7
|
-
renameSync,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import {
|
|
12
|
+
open as openFile,
|
|
13
|
+
readdir,
|
|
14
|
+
readFile,
|
|
15
|
+
stat,
|
|
16
|
+
unlink as unlinkFile,
|
|
17
|
+
} from 'node:fs/promises';
|
|
15
18
|
import { basename, dirname, join } from 'node:path';
|
|
16
19
|
import { homedir } from 'node:os';
|
|
17
20
|
import { randomUUID } from 'node:crypto';
|
|
18
21
|
|
|
19
22
|
const TEAM_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
20
23
|
const CLAUDE_HOME = join(homedir(), '.claude');
|
|
21
|
-
const TEAMS_ROOT = join(CLAUDE_HOME, 'teams');
|
|
22
|
-
const TASKS_ROOT = join(CLAUDE_HOME, 'tasks');
|
|
24
|
+
const TEAMS_ROOT = join(CLAUDE_HOME, 'teams');
|
|
25
|
+
const TASKS_ROOT = join(CLAUDE_HOME, 'tasks');
|
|
26
|
+
const LOCK_STALE_MS = 30000;
|
|
23
27
|
|
|
24
28
|
// ── 인메모리 캐시 (디렉토리 mtime 기반 무효화) ──
|
|
25
29
|
const _dirCache = new Map(); // tasksDir → { mtimeMs, files: string[] }
|
|
@@ -66,29 +70,118 @@ function atomicWriteJson(path, value) {
|
|
|
66
70
|
}
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
async function sleepMs(ms) {
|
|
70
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function
|
|
74
|
-
let
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
73
|
+
async function sleepMs(ms) {
|
|
74
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readLockInfo(lockPath) {
|
|
78
|
+
let lockStat;
|
|
79
|
+
try {
|
|
80
|
+
lockStat = await stat(lockPath);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let parsed = null;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(await readFile(lockPath, 'utf8'));
|
|
88
|
+
} catch {}
|
|
89
|
+
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const createdAtMs = Number(
|
|
92
|
+
parsed?.created_at_ms
|
|
93
|
+
?? parsed?.timestamp_ms
|
|
94
|
+
?? parsed?.timestamp
|
|
95
|
+
?? lockStat.mtimeMs,
|
|
96
|
+
);
|
|
97
|
+
const pid = Number(parsed?.pid);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
token: typeof parsed?.token === 'string' ? parsed.token : null,
|
|
101
|
+
pid: Number.isInteger(pid) && pid > 0 ? pid : null,
|
|
102
|
+
created_at_ms: Number.isFinite(createdAtMs) ? createdAtMs : lockStat.mtimeMs,
|
|
103
|
+
mtime_ms: lockStat.mtimeMs,
|
|
104
|
+
age_ms: Math.max(0, now - (Number.isFinite(createdAtMs) ? createdAtMs : lockStat.mtimeMs)),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isPidAlive(pid) {
|
|
109
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
process.kill(pid, 0);
|
|
113
|
+
return true;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e?.code === 'EPERM') return true;
|
|
116
|
+
if (e?.code === 'ESRCH') return false;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function releaseFileLock(lockPath, token, handle) {
|
|
122
|
+
try { await handle?.close(); } catch {}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const current = await readLockInfo(lockPath);
|
|
126
|
+
if (!current || current.token === token) {
|
|
127
|
+
await unlinkFile(lockPath);
|
|
128
|
+
}
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function withFileLock(lockPath, fn, retries = 20, delayMs = 25, staleMs = LOCK_STALE_MS) {
|
|
133
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
134
|
+
const lockOwner = {
|
|
135
|
+
pid: process.pid,
|
|
136
|
+
token: randomUUID(),
|
|
137
|
+
created_at: new Date().toISOString(),
|
|
138
|
+
created_at_ms: Date.now(),
|
|
139
|
+
};
|
|
140
|
+
let handle = null;
|
|
141
|
+
let lastError = null;
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < retries; i += 1) {
|
|
144
|
+
try {
|
|
145
|
+
handle = await openFile(lockPath, 'wx');
|
|
146
|
+
try {
|
|
147
|
+
await handle.writeFile(`${JSON.stringify(lockOwner)}\n`, 'utf8');
|
|
148
|
+
} catch (writeError) {
|
|
149
|
+
await releaseFileLock(lockPath, lockOwner.token, handle);
|
|
150
|
+
throw writeError;
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
} catch (e) {
|
|
154
|
+
lastError = e;
|
|
155
|
+
if (e?.code !== 'EEXIST') throw e;
|
|
156
|
+
|
|
157
|
+
const current = await readLockInfo(lockPath);
|
|
158
|
+
const staleByAge = !current || current.age_ms > staleMs;
|
|
159
|
+
const staleByDeadPid = current?.pid != null && !isPidAlive(current.pid);
|
|
160
|
+
if (staleByAge || staleByDeadPid) {
|
|
161
|
+
try {
|
|
162
|
+
await unlinkFile(lockPath);
|
|
163
|
+
continue;
|
|
164
|
+
} catch (unlinkError) {
|
|
165
|
+
if (unlinkError?.code === 'ENOENT') continue;
|
|
166
|
+
lastError = unlinkError;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (i === retries - 1) throw e;
|
|
171
|
+
await sleepMs(delayMs);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!handle) {
|
|
176
|
+
throw lastError || new Error(`LOCK_NOT_ACQUIRED: ${lockPath}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
return await fn();
|
|
181
|
+
} finally {
|
|
182
|
+
await releaseFileLock(lockPath, lockOwner.token, handle);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
92
185
|
|
|
93
186
|
function getLeadSessionId(config) {
|
|
94
187
|
return config?.leadSessionId
|
|
@@ -130,8 +223,8 @@ export async function resolveTeamPaths(teamName) {
|
|
|
130
223
|
};
|
|
131
224
|
}
|
|
132
225
|
|
|
133
|
-
async function collectTaskFiles(tasksDir) {
|
|
134
|
-
if (!existsSync(tasksDir)) return [];
|
|
226
|
+
async function collectTaskFiles(tasksDir) {
|
|
227
|
+
if (!existsSync(tasksDir)) return [];
|
|
135
228
|
|
|
136
229
|
// 디렉토리 mtime 기반 캐시 — O(N) I/O를 반복 호출 시 O(1)로 축소
|
|
137
230
|
let dirMtime;
|
|
@@ -149,9 +242,30 @@ async function collectTaskFiles(tasksDir) {
|
|
|
149
242
|
.filter((name) => name !== '.highwatermark')
|
|
150
243
|
.map((name) => join(tasksDir, name));
|
|
151
244
|
|
|
152
|
-
_dirCache.set(tasksDir, { mtimeMs: dirMtime, files });
|
|
153
|
-
return files;
|
|
154
|
-
}
|
|
245
|
+
_dirCache.set(tasksDir, { mtimeMs: dirMtime, files });
|
|
246
|
+
return files;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function readTaskFileCached(file) {
|
|
250
|
+
let fileMtime;
|
|
251
|
+
try {
|
|
252
|
+
fileMtime = (await stat(file)).mtimeMs;
|
|
253
|
+
} catch {
|
|
254
|
+
return { file, mtimeMs: null, json: null };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const contentCached = _taskContentCache.get(file);
|
|
258
|
+
if (contentCached && contentCached.mtimeMs === fileMtime) {
|
|
259
|
+
return { file, mtimeMs: fileMtime, json: contentCached.data };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const json = await readJsonSafe(file);
|
|
263
|
+
if (json && isObject(json)) {
|
|
264
|
+
_taskContentCache.set(file, { mtimeMs: fileMtime, data: json });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { file, mtimeMs: fileMtime, json };
|
|
268
|
+
}
|
|
155
269
|
|
|
156
270
|
async function locateTaskFile(tasksDir, taskId) {
|
|
157
271
|
const direct = join(tasksDir, `${taskId}.json`);
|
|
@@ -244,30 +358,17 @@ export async function teamTaskList(args = {}) {
|
|
|
244
358
|
return err('TASKS_DIR_NOT_FOUND', `task 디렉토리를 찾지 못했습니다: ${team_name}`);
|
|
245
359
|
}
|
|
246
360
|
|
|
247
|
-
const statusSet = new Set((statuses || []).map((s) => String(s)));
|
|
248
|
-
const maxCount = Math.max(1, Math.min(Number(limit) || 200, 1000));
|
|
249
|
-
let parseWarnings = 0;
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
let json;
|
|
259
|
-
if (contentCached && contentCached.mtimeMs === fileMtime) {
|
|
260
|
-
json = contentCached.data;
|
|
261
|
-
} else {
|
|
262
|
-
json = await readJsonSafe(file);
|
|
263
|
-
if (json && isObject(json)) {
|
|
264
|
-
_taskContentCache.set(file, { mtimeMs: fileMtime, data: json });
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (!json || !isObject(json)) {
|
|
269
|
-
parseWarnings += 1;
|
|
270
|
-
continue;
|
|
361
|
+
const statusSet = new Set((statuses || []).map((s) => String(s)));
|
|
362
|
+
const maxCount = Math.max(1, Math.min(Number(limit) || 200, 1000));
|
|
363
|
+
let parseWarnings = 0;
|
|
364
|
+
const files = await collectTaskFiles(paths.tasks_dir);
|
|
365
|
+
const records = await Promise.all(files.map((file) => readTaskFileCached(file)));
|
|
366
|
+
|
|
367
|
+
const tasks = [];
|
|
368
|
+
for (const { file, mtimeMs: fileMtime, json } of records) {
|
|
369
|
+
if (!json || !isObject(json)) {
|
|
370
|
+
parseWarnings += 1;
|
|
371
|
+
continue;
|
|
271
372
|
}
|
|
272
373
|
|
|
273
374
|
if (!include_internal && json?.metadata?._internal === true) continue;
|
|
@@ -440,13 +541,13 @@ export async function teamTaskUpdate(args = {}) {
|
|
|
440
541
|
_invalidateCache(dirname(taskFile));
|
|
441
542
|
// 콘텐츠 캐시 무효화
|
|
442
543
|
_taskContentCache.delete(taskFile);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
let afterMtime = beforeMtime;
|
|
446
|
-
try { afterMtime =
|
|
447
|
-
|
|
448
|
-
return {
|
|
449
|
-
ok: true,
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
let afterMtime = beforeMtime;
|
|
547
|
+
try { afterMtime = (await stat(taskFile)).mtimeMs; } catch {}
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
ok: true,
|
|
450
551
|
data: {
|
|
451
552
|
claimed,
|
|
452
553
|
updated,
|
package/hub/tools.mjs
CHANGED
|
@@ -338,9 +338,9 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
338
338
|
},
|
|
339
339
|
|
|
340
340
|
// ── 13. team_task_list ──
|
|
341
|
-
{
|
|
342
|
-
name: 'team_task_list',
|
|
343
|
-
description: 'Claude Native Teams task 목록을 owner/status 조건으로
|
|
341
|
+
{
|
|
342
|
+
name: 'team_task_list',
|
|
343
|
+
description: 'Claude Native Teams task 목록을 owner/status 조건으로 조회합니다. 실패 판정은 completed + metadata.result도 함께 확인해야 합니다',
|
|
344
344
|
inputSchema: {
|
|
345
345
|
type: 'object',
|
|
346
346
|
required: ['team_name'],
|
|
@@ -362,9 +362,9 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
362
362
|
},
|
|
363
363
|
|
|
364
364
|
// ── 14. team_task_update ──
|
|
365
|
-
{
|
|
366
|
-
name: 'team_task_update',
|
|
367
|
-
description: 'Claude Native Teams task를 claim/update
|
|
365
|
+
{
|
|
366
|
+
name: 'team_task_update',
|
|
367
|
+
description: 'Claude Native Teams task를 claim/update 합니다. status: "failed" 입력은 completed + metadata.result="failed"로 정규화됩니다',
|
|
368
368
|
inputSchema: {
|
|
369
369
|
type: 'object',
|
|
370
370
|
required: ['team_name', 'task_id'],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triflux",
|
|
3
|
-
"version": "3.3.0-dev.
|
|
3
|
+
"version": "3.3.0-dev.7",
|
|
4
4
|
"description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"scripts": {
|
|
28
28
|
"setup": "node scripts/setup.mjs",
|
|
29
29
|
"postinstall": "node scripts/setup.mjs",
|
|
30
|
-
"test": "node --test --test-force-exit \"tests/**/*.test.mjs\" \"scripts/__tests__/**/*.test.mjs\"",
|
|
31
|
-
"test:unit": "node --test --test-force-exit tests/unit/**/*.test.mjs",
|
|
32
|
-
"test:integration": "node --test --test-force-exit tests/integration/**/*.test.mjs",
|
|
30
|
+
"test": "node --test --test-force-exit --test-concurrency=1 \"tests/**/*.test.mjs\" \"scripts/__tests__/**/*.test.mjs\"",
|
|
31
|
+
"test:unit": "node --test --test-force-exit --test-concurrency=1 tests/unit/**/*.test.mjs",
|
|
32
|
+
"test:integration": "node --test --test-force-exit --test-concurrency=1 tests/integration/**/*.test.mjs",
|
|
33
33
|
"test:route-smoke": "node --test scripts/test-tfx-route-no-claude-native.mjs"
|
|
34
34
|
},
|
|
35
35
|
"engines": {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Installation: source /path/to/tfx.bash 또는 ~/.bashrc에 추가
|
|
3
|
+
|
|
4
|
+
_tfx_completion() {
|
|
5
|
+
local cur prev words cword
|
|
6
|
+
COMPREPLY=()
|
|
7
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
8
|
+
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
9
|
+
words=("${COMP_WORDS[@]}")
|
|
10
|
+
cword=$COMP_CWORD
|
|
11
|
+
|
|
12
|
+
local commands="setup doctor multi hub auto codex gemini"
|
|
13
|
+
local multi_cmds="status stop kill attach list"
|
|
14
|
+
local hub_cmds="start stop status restart"
|
|
15
|
+
local flags="--thorough --quick --tmux --psmux --agents --no-attach --timeout"
|
|
16
|
+
|
|
17
|
+
if [[ $cword -eq 1 ]]; then
|
|
18
|
+
COMPREPLY=( $(compgen -W "${commands}" -- "$cur") )
|
|
19
|
+
return 0
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
local cmd="${words[1]}"
|
|
23
|
+
case "${cmd}" in
|
|
24
|
+
multi)
|
|
25
|
+
if [[ $cword -eq 2 && ! "$cur" == -* ]]; then
|
|
26
|
+
COMPREPLY=( $(compgen -W "${multi_cmds}" -- "$cur") )
|
|
27
|
+
else
|
|
28
|
+
COMPREPLY=( $(compgen -W "${flags}" -- "$cur") )
|
|
29
|
+
fi
|
|
30
|
+
;;
|
|
31
|
+
hub)
|
|
32
|
+
if [[ $cword -eq 2 ]]; then
|
|
33
|
+
COMPREPLY=( $(compgen -W "${hub_cmds}" -- "$cur") )
|
|
34
|
+
fi
|
|
35
|
+
;;
|
|
36
|
+
doctor)
|
|
37
|
+
COMPREPLY=( $(compgen -W "--fix --reset" -- "$cur") )
|
|
38
|
+
;;
|
|
39
|
+
setup|auto|codex|gemini)
|
|
40
|
+
if [[ "$cur" == -* ]]; then
|
|
41
|
+
COMPREPLY=( $(compgen -W "${flags}" -- "$cur") )
|
|
42
|
+
fi
|
|
43
|
+
;;
|
|
44
|
+
esac
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
complete -F _tfx_completion tfx
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Installation: ~/.config/fish/completions/에 복사
|
|
2
|
+
# e.g., cp /path/to/tfx.fish ~/.config/fish/completions/tfx.fish
|
|
3
|
+
|
|
4
|
+
set -l commands setup doctor multi hub auto codex gemini
|
|
5
|
+
set -l multi_cmds status stop kill attach list
|
|
6
|
+
set -l hub_cmds start stop status restart
|
|
7
|
+
|
|
8
|
+
complete -c tfx -f
|
|
9
|
+
|
|
10
|
+
# Subcommands
|
|
11
|
+
complete -c tfx -n "not __fish_seen_subcommand_from $commands" -a "setup" -d "Setup and sync files"
|
|
12
|
+
complete -c tfx -n "not __fish_seen_subcommand_from $commands" -a "doctor" -d "Diagnose CLI and issues"
|
|
13
|
+
complete -c tfx -n "not __fish_seen_subcommand_from $commands" -a "multi" -d "Multi-CLI team mode"
|
|
14
|
+
complete -c tfx -n "not __fish_seen_subcommand_from $commands" -a "hub" -d "MCP message bus management"
|
|
15
|
+
complete -c tfx -n "not __fish_seen_subcommand_from $commands" -a "auto" -d "Auto mode"
|
|
16
|
+
complete -c tfx -n "not __fish_seen_subcommand_from $commands" -a "codex" -d "Codex mode"
|
|
17
|
+
complete -c tfx -n "not __fish_seen_subcommand_from $commands" -a "gemini" -d "Gemini mode"
|
|
18
|
+
|
|
19
|
+
# Doctor flags
|
|
20
|
+
complete -c tfx -n "__fish_seen_subcommand_from doctor" -l fix -d "Auto fix issues"
|
|
21
|
+
complete -c tfx -n "__fish_seen_subcommand_from doctor" -l reset -d "Reset all caches"
|
|
22
|
+
|
|
23
|
+
# Multi subcommands
|
|
24
|
+
complete -c tfx -n "__fish_seen_subcommand_from multi; and not __fish_seen_subcommand_from $multi_cmds" -a "status"
|
|
25
|
+
complete -c tfx -n "__fish_seen_subcommand_from multi; and not __fish_seen_subcommand_from $multi_cmds" -a "stop"
|
|
26
|
+
complete -c tfx -n "__fish_seen_subcommand_from multi; and not __fish_seen_subcommand_from $multi_cmds" -a "kill"
|
|
27
|
+
complete -c tfx -n "__fish_seen_subcommand_from multi; and not __fish_seen_subcommand_from $multi_cmds" -a "attach"
|
|
28
|
+
complete -c tfx -n "__fish_seen_subcommand_from multi; and not __fish_seen_subcommand_from $multi_cmds" -a "list"
|
|
29
|
+
|
|
30
|
+
# Hub subcommands
|
|
31
|
+
complete -c tfx -n "__fish_seen_subcommand_from hub; and not __fish_seen_subcommand_from $hub_cmds" -a "start"
|
|
32
|
+
complete -c tfx -n "__fish_seen_subcommand_from hub; and not __fish_seen_subcommand_from $hub_cmds" -a "stop"
|
|
33
|
+
complete -c tfx -n "__fish_seen_subcommand_from hub; and not __fish_seen_subcommand_from $hub_cmds" -a "status"
|
|
34
|
+
complete -c tfx -n "__fish_seen_subcommand_from hub; and not __fish_seen_subcommand_from $hub_cmds" -a "restart"
|
|
35
|
+
|
|
36
|
+
# Global or multi flags
|
|
37
|
+
set -l flags_cond "__fish_seen_subcommand_from setup multi auto codex gemini"
|
|
38
|
+
complete -c tfx -n "$flags_cond" -l thorough -d "Thorough execution"
|
|
39
|
+
complete -c tfx -n "$flags_cond" -l quick -d "Quick execution"
|
|
40
|
+
complete -c tfx -n "$flags_cond" -l tmux -d "Use tmux"
|
|
41
|
+
complete -c tfx -n "$flags_cond" -l psmux -d "Use psmux"
|
|
42
|
+
complete -c tfx -n "$flags_cond" -l agents -d "Specify agents"
|
|
43
|
+
complete -c tfx -n "$flags_cond" -l no-attach -d "Do not attach"
|
|
44
|
+
complete -c tfx -n "$flags_cond" -l timeout -d "Set timeout"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#compdef tfx
|
|
2
|
+
# Installation: fpath에 추가 후 compinit
|
|
3
|
+
# e.g., fpath=(/path/to/dir $fpath) && compinit
|
|
4
|
+
|
|
5
|
+
_tfx() {
|
|
6
|
+
local line state
|
|
7
|
+
local -a commands multi_cmds hub_cmds flags
|
|
8
|
+
|
|
9
|
+
commands=(
|
|
10
|
+
'setup:Setup and sync files'
|
|
11
|
+
'doctor:Diagnose CLI and issues'
|
|
12
|
+
'multi:Multi-CLI team mode'
|
|
13
|
+
'hub:MCP message bus management'
|
|
14
|
+
'auto:Auto mode'
|
|
15
|
+
'codex:Codex mode'
|
|
16
|
+
'gemini:Gemini mode'
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
multi_cmds=(
|
|
20
|
+
'status:Show status'
|
|
21
|
+
'stop:Stop multi'
|
|
22
|
+
'kill:Kill multi'
|
|
23
|
+
'attach:Attach to multi'
|
|
24
|
+
'list:List multi sessions'
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
hub_cmds=(
|
|
28
|
+
'start:Start hub'
|
|
29
|
+
'stop:Stop hub'
|
|
30
|
+
'status:Show hub status'
|
|
31
|
+
'restart:Restart hub'
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_arguments -C \
|
|
35
|
+
'1: :->cmds' \
|
|
36
|
+
'*: :->args'
|
|
37
|
+
|
|
38
|
+
case $state in
|
|
39
|
+
cmds)
|
|
40
|
+
_describe -t commands 'tfx commands' commands
|
|
41
|
+
;;
|
|
42
|
+
args)
|
|
43
|
+
case $words[2] in
|
|
44
|
+
multi)
|
|
45
|
+
if (( CURRENT == 3 )) && [[ $words[CURRENT] != -* ]]; then
|
|
46
|
+
_describe -t multi_cmds 'multi commands' multi_cmds
|
|
47
|
+
else
|
|
48
|
+
_arguments \
|
|
49
|
+
'--thorough[Thorough execution]' \
|
|
50
|
+
'--quick[Quick execution]' \
|
|
51
|
+
'--tmux[Use tmux]' \
|
|
52
|
+
'--psmux[Use psmux]' \
|
|
53
|
+
'--agents[Specify agents]' \
|
|
54
|
+
'--no-attach[Do not attach]' \
|
|
55
|
+
'--timeout[Set timeout]'
|
|
56
|
+
fi
|
|
57
|
+
;;
|
|
58
|
+
hub)
|
|
59
|
+
if (( CURRENT == 3 )); then
|
|
60
|
+
_describe -t hub_cmds 'hub commands' hub_cmds
|
|
61
|
+
fi
|
|
62
|
+
;;
|
|
63
|
+
doctor)
|
|
64
|
+
_arguments \
|
|
65
|
+
'--fix[Auto fix issues]' \
|
|
66
|
+
'--reset[Reset all caches]'
|
|
67
|
+
;;
|
|
68
|
+
*)
|
|
69
|
+
_arguments \
|
|
70
|
+
'--thorough[Thorough execution]' \
|
|
71
|
+
'--quick[Quick execution]' \
|
|
72
|
+
'--tmux[Use tmux]' \
|
|
73
|
+
'--psmux[Use psmux]' \
|
|
74
|
+
'--agents[Specify agents]' \
|
|
75
|
+
'--no-attach[Do not attach]' \
|
|
76
|
+
'--timeout[Set timeout]'
|
|
77
|
+
;;
|
|
78
|
+
esac
|
|
79
|
+
;;
|
|
80
|
+
esac
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_tfx "$@"
|
|
@@ -226,7 +226,12 @@ function buildInventoryIndex(inventory = null) {
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
function getServerMetadata(server, inventoryIndex) {
|
|
229
|
-
|
|
229
|
+
const inventoryMetadata = inventoryIndex.get(server) || {};
|
|
230
|
+
return normalizeServerMetadata(server, {
|
|
231
|
+
// Inventory tool_count is useful for tie-breaks, but dynamic domain tags
|
|
232
|
+
// can over-broaden role policies compared to the static catalog.
|
|
233
|
+
tool_count: inventoryMetadata.tool_count,
|
|
234
|
+
});
|
|
230
235
|
}
|
|
231
236
|
|
|
232
237
|
function scoreServer(server, taskText = '', inventoryIndex = new Map()) {
|
package/scripts/tfx-route.sh
CHANGED
|
@@ -55,10 +55,69 @@ TFX_HUB_URL="${TFX_HUB_URL:-http://127.0.0.1:27888}" # bridge.mjs HTTP fallback
|
|
|
55
55
|
ORIGINAL_AGENT=""
|
|
56
56
|
ORIGINAL_CLI_ARGS=""
|
|
57
57
|
|
|
58
|
+
# JSON 문자열 이스케이프:
|
|
59
|
+
# - "\", """ 필수 이스케이프
|
|
60
|
+
# - 제어문자 U+0000..U+001F 이스케이프
|
|
61
|
+
# - 비ASCII 문자는 \uXXXX(또는 surrogate pair)로 강제
|
|
62
|
+
json_escape() {
|
|
63
|
+
local s="${1:-}"
|
|
64
|
+
|
|
65
|
+
if command -v "$NODE_BIN" &>/dev/null; then
|
|
66
|
+
"$NODE_BIN" -e '
|
|
67
|
+
const input = process.argv[1] ?? "";
|
|
68
|
+
let out = "";
|
|
69
|
+
for (const ch of input) {
|
|
70
|
+
const cp = ch.codePointAt(0);
|
|
71
|
+
if (cp === 0x22) { out += "\\\""; continue; } // "
|
|
72
|
+
if (cp === 0x5c) { out += "\\\\"; continue; } // \
|
|
73
|
+
if (cp <= 0x1f) {
|
|
74
|
+
if (cp === 0x08) { out += "\\b"; continue; }
|
|
75
|
+
if (cp === 0x09) { out += "\\t"; continue; }
|
|
76
|
+
if (cp === 0x0a) { out += "\\n"; continue; }
|
|
77
|
+
if (cp === 0x0c) { out += "\\f"; continue; }
|
|
78
|
+
if (cp === 0x0d) { out += "\\r"; continue; }
|
|
79
|
+
out += `\\u${cp.toString(16).padStart(4, "0")}`;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (cp >= 0x20 && cp <= 0x7e) {
|
|
83
|
+
out += ch;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (cp <= 0xffff) {
|
|
87
|
+
out += `\\u${cp.toString(16).padStart(4, "0")}`;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const v = cp - 0x10000;
|
|
91
|
+
const hi = 0xd800 + (v >> 10);
|
|
92
|
+
const lo = 0xdc00 + (v & 0x3ff);
|
|
93
|
+
out += `\\u${hi.toString(16).padStart(4, "0")}\\u${lo.toString(16).padStart(4, "0")}`;
|
|
94
|
+
}
|
|
95
|
+
process.stdout.write(out);
|
|
96
|
+
' -- "$s"
|
|
97
|
+
return
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
echo "[tfx-route] ERROR: node 미설치로 안전한 JSON 이스케이프를 수행할 수 없습니다." >&2
|
|
101
|
+
return 1
|
|
102
|
+
}
|
|
103
|
+
|
|
58
104
|
# ── Per-process 에이전트 등록 (원자적, 락 불필요) ──
|
|
59
105
|
register_agent() {
|
|
60
106
|
local agent_file="${TFX_TMP}/tfx-agent-$$.json"
|
|
61
|
-
|
|
107
|
+
local safe_cli safe_agent started_at
|
|
108
|
+
safe_cli=$(json_escape "$CLI_TYPE" 2>/dev/null || true)
|
|
109
|
+
safe_agent=$(json_escape "$AGENT_TYPE" 2>/dev/null || true)
|
|
110
|
+
started_at=$(date +%s)
|
|
111
|
+
|
|
112
|
+
# fail-closed: 안전 인코딩 불가 시 agent 파일을 쓰지 않는다
|
|
113
|
+
if [[ -n "$CLI_TYPE" && -z "$safe_cli" ]]; then
|
|
114
|
+
return 0
|
|
115
|
+
fi
|
|
116
|
+
if [[ -n "$AGENT_TYPE" && -z "$safe_agent" ]]; then
|
|
117
|
+
return 0
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
printf '{"pid":%s,"cli":"%s","agent":"%s","started":%s}\n' "$$" "$safe_cli" "$safe_agent" "$started_at" \
|
|
62
121
|
> "$agent_file" 2>/dev/null || true
|
|
63
122
|
}
|
|
64
123
|
|
|
@@ -329,6 +388,16 @@ team_complete_task() {
|
|
|
329
388
|
echo "[tfx-route] 경고: Hub result 발행 실패 (agent=$TFX_TEAM_AGENT_NAME, task=$TFX_TEAM_TASK_ID)" >&2
|
|
330
389
|
fi
|
|
331
390
|
fi
|
|
391
|
+
|
|
392
|
+
# 로컬 결과 파일 백업 (세션 끊김 복구용)
|
|
393
|
+
# Claude 재로그인 시 Agent 래퍼가 죽어도 이 파일로 결과 수집 가능
|
|
394
|
+
local result_dir="${TFX_RESULT_DIR:-${HOME}/.claude/tfx-results/${TFX_TEAM_NAME}}"
|
|
395
|
+
if mkdir -p "$result_dir" 2>/dev/null; then
|
|
396
|
+
cat > "${result_dir}/${TFX_TEAM_TASK_ID}.json" 2>/dev/null <<RESULT_EOF
|
|
397
|
+
{"taskId":"${TFX_TEAM_TASK_ID}","agent":"${TFX_TEAM_AGENT_NAME}","team":"${TFX_TEAM_NAME}","result":"${result}","summary":$(printf '%s' "$summary_trimmed" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.stringify(d)))" 2>/dev/null || echo '""'),"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)"}
|
|
398
|
+
RESULT_EOF
|
|
399
|
+
[[ $? -eq 0 ]] && echo "[tfx-route] 결과 백업: ${result_dir}/${TFX_TEAM_TASK_ID}.json" >&2
|
|
400
|
+
fi
|
|
332
401
|
}
|
|
333
402
|
|
|
334
403
|
capture_workspace_signature() {
|
package/skills/tfx-auto/SKILL.md
CHANGED
|
@@ -141,6 +141,8 @@ Agent(subagent_type="oh-my-claudecode:{agent}", model="{model}", prompt="{prompt
|
|
|
141
141
|
|
|
142
142
|
### 결과 파싱
|
|
143
143
|
|
|
144
|
+
여기서 `failed`는 `tfx-route.sh`/CLI 종료 결과를 뜻한다. Claude Code `TaskUpdate` 상태값이 아니다.
|
|
145
|
+
|
|
144
146
|
| exit_code + status | 사용할 출력 |
|
|
145
147
|
|--------------------|-----------|
|
|
146
148
|
| 0 + success | `=== OUTPUT ===` 섹션 |
|
|
@@ -177,11 +179,14 @@ OUTPUT 추출: `echo "$result" | sed -n '/^=== OUTPUT ===/,/^=== /{/^=== OUTPUT
|
|
|
177
179
|
|------|------|
|
|
178
180
|
| `tfx-route.sh: not found` | tfx-route.sh 생성 |
|
|
179
181
|
| `codex/gemini: not found` | npm install -g |
|
|
180
|
-
| timeout / failed | stderr → Claude fallback |
|
|
182
|
+
| timeout / failed (`tfx-route.sh` 결과) | stderr → Claude fallback |
|
|
181
183
|
| N > 10 | 10 이하로 조정 |
|
|
182
184
|
| 순환 의존 | 분해 재시도 |
|
|
183
185
|
| 컨텍스트 > 32KB | 비례 절삭 |
|
|
184
186
|
|
|
187
|
+
> Claude Code `TaskUpdate`를 사용할 때는 `status: "failed"`를 쓰지 않는다.
|
|
188
|
+
> 실패 보고는 `status: "completed"` + `metadata.result: "failed"`로 표현한다.
|
|
189
|
+
|
|
185
190
|
## Troubleshooting
|
|
186
191
|
|
|
187
192
|
`/tfx-doctor` 진단 | `/tfx-doctor --fix` 자동 수정 | `/tfx-doctor --reset` 캐시 초기화
|