triflux 3.3.0-dev.5 → 3.3.0-dev.6

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 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
- if (process.env.TFX_HUB_TOKEN) {
20
- return String(process.env.TFX_HUB_TOKEN).trim();
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').trim();
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
- const HUB_TOKEN = process.env.TFX_HUB_TOKEN || randomUUID();
81
- mkdirSync(join(homedir(), '.claude'), { recursive: true });
82
- writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
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
- // CORS: localhost 계열 Origin만 허용
129
- const origin = req.headers['origin'];
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
- res.writeHead(isAllowedOrigin(origin) ? 204 : 403);
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.url === '/' || req.url === '/status') {
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.writeHead(200, { 'Content-Type': 'application/json' });
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 (req.url === '/health' || req.url === '/healthz') {
215
+ if (path === '/health' || path === '/healthz') {
157
216
  const status = router.getStatus('hub').data;
158
217
  const healthy = status?.hub?.state === 'healthy';
159
- res.writeHead(healthy ? 200 : 503, { 'Content-Type': 'application/json' });
160
- return res.end(JSON.stringify({ ok: healthy }));
218
+ return writeJson(res, healthy ? 200 : 503, { ok: healthy });
161
219
  }
162
220
 
163
- if (req.url.startsWith('/bridge')) {
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.writeHead(405);
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.writeHead(400);
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.writeHead(200);
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.writeHead(400);
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.writeHead(200);
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.writeHead(400);
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.writeHead(200);
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.writeHead(400);
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.writeHead(result.ok ? 200 : 400);
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.writeHead(400);
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.writeHead(result.ok ? 200 : 409);
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.writeHead(statusCode);
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.writeHead(400);
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.writeHead(statusCode);
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.writeHead(resolveTeamStatusCode(teamResult));
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.writeHead(404);
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.writeHead(resolvePipelineStatusCode(result));
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.writeHead(resolvePipelineStatusCode(result));
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.writeHead(resolvePipelineStatusCode(result));
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.writeHead(resolvePipelineStatusCode(result));
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.writeHead(400);
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.writeHead(200);
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.writeHead(400);
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.writeHead(200);
421
- return res.end(JSON.stringify(result));
444
+ return writeJson(res, 200, result);
422
445
  }
423
446
 
424
- res.writeHead(404);
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.writeHead(500);
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 (req.url !== '/mcp') {
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,
@@ -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
- statSync,
9
- unlinkSync,
10
- writeFileSync,
11
- openSync,
12
- closeSync,
13
- } from 'node:fs';
14
- import { readdir, stat, readFile } from 'node:fs/promises';
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 withFileLock(lockPath, fn, retries = 20, delayMs = 25) {
74
- let fd = null;
75
- for (let i = 0; i < retries; i += 1) {
76
- try {
77
- fd = openSync(lockPath, 'wx');
78
- break;
79
- } catch (e) {
80
- if (e?.code !== 'EEXIST' || i === retries - 1) throw e;
81
- await sleepMs(delayMs);
82
- }
83
- }
84
-
85
- try {
86
- return await fn();
87
- } finally {
88
- try { if (fd != null) closeSync(fd); } catch {}
89
- try { unlinkSync(lockPath); } catch {}
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 tasks = [];
252
- for (const file of await collectTaskFiles(paths.tasks_dir)) {
253
- // mtime 기반 task 콘텐츠 캐시 — 변경 없으면 파일 읽기 생략
254
- let fileMtime = Date.now();
255
- try { fileMtime = (await stat(file)).mtimeMs; } catch {}
256
-
257
- const contentCached = _taskContentCache.get(file);
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 = statSync(taskFile).mtimeMs; } catch {}
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.5",
3
+ "version": "3.3.0-dev.6",
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
- return normalizeServerMetadata(server, inventoryIndex.get(server) || {});
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()) {
@@ -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
- echo "{\"pid\":$$,\"cli\":\"$CLI_TYPE\",\"agent\":\"$AGENT_TYPE\",\"started\":$(date +%s)}" \
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() {
@@ -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` 캐시 초기화