triflux 3.2.0-dev.11 → 3.2.0-dev.13

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/server.mjs CHANGED
@@ -14,6 +14,15 @@ import { createRouter } from './router.mjs';
14
14
  import { createHitlManager } from './hitl.mjs';
15
15
  import { createPipeServer } from './pipe.mjs';
16
16
  import { createTools } from './tools.mjs';
17
+ import {
18
+ ensurePipelineTable,
19
+ createPipeline,
20
+ } from './pipeline/index.mjs';
21
+ import {
22
+ readPipelineState,
23
+ initPipelineState,
24
+ listPipelineStates,
25
+ } from './pipeline/state.mjs';
17
26
  import {
18
27
  teamInfo,
19
28
  teamTaskList,
@@ -43,6 +52,14 @@ async function parseBody(req) {
43
52
 
44
53
  const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
45
54
  const PID_FILE = join(PID_DIR, 'hub.pid');
55
+ const TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
56
+
57
+ // localhost 계열 Origin만 허용
58
+ const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
59
+
60
+ function isAllowedOrigin(origin) {
61
+ return origin && ALLOWED_ORIGIN_RE.test(origin);
62
+ }
46
63
 
47
64
  /**
48
65
  * tfx-hub 시작
@@ -57,6 +74,11 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
57
74
  dbPath = join(PID_DIR, 'state.db');
58
75
  }
59
76
 
77
+ // 인증 토큰 생성 (환경변수 우선, 없으면 자동 생성)
78
+ const HUB_TOKEN = process.env.TFX_HUB_TOKEN || randomUUID();
79
+ mkdirSync(join(homedir(), '.claude'), { recursive: true });
80
+ writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
81
+
60
82
  const store = createStore(dbPath);
61
83
  const router = createRouter(store);
62
84
  const pipe = createPipeServer({ router, store, sessionId });
@@ -100,12 +122,16 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
100
122
  }
101
123
 
102
124
  const httpServer = createHttpServer(async (req, res) => {
103
- res.setHeader('Access-Control-Allow-Origin', '*');
104
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
105
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, Last-Event-ID');
125
+ // CORS: localhost 계열 Origin 허용
126
+ const origin = req.headers['origin'];
127
+ if (isAllowedOrigin(origin)) {
128
+ res.setHeader('Access-Control-Allow-Origin', origin);
129
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
130
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
131
+ }
106
132
 
107
133
  if (req.method === 'OPTIONS') {
108
- res.writeHead(204);
134
+ res.writeHead(isAllowedOrigin(origin) ? 204 : 403);
109
135
  return res.end();
110
136
  }
111
137
 
@@ -132,6 +158,14 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
132
158
  if (req.url.startsWith('/bridge')) {
133
159
  res.setHeader('Content-Type', 'application/json');
134
160
 
161
+ // Bearer 토큰 인증
162
+ const authHeader = req.headers['authorization'] || '';
163
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
164
+ if (bearerToken !== HUB_TOKEN) {
165
+ res.writeHead(401);
166
+ return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
167
+ }
168
+
135
169
  if (req.method !== 'POST' && req.method !== 'DELETE') {
136
170
  res.writeHead(405);
137
171
  return res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
@@ -214,13 +248,13 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
214
248
  if (req.method === 'POST') {
215
249
  let teamResult = null;
216
250
  if (path === '/bridge/team/info' || path === '/bridge/team-info') {
217
- teamResult = teamInfo(body);
251
+ teamResult = await teamInfo(body);
218
252
  } else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
219
- teamResult = teamTaskList(body);
253
+ teamResult = await teamTaskList(body);
220
254
  } else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
221
- teamResult = teamTaskUpdate(body);
255
+ teamResult = await teamTaskUpdate(body);
222
256
  } else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
223
- teamResult = teamSendMessage(body);
257
+ teamResult = await teamSendMessage(body);
224
258
  }
225
259
 
226
260
  if (teamResult) {
@@ -240,6 +274,41 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
240
274
  res.writeHead(404);
241
275
  return res.end(JSON.stringify({ ok: false, error: `Unknown team endpoint: ${path}` }));
242
276
  }
277
+
278
+ // ── 파이프라인 엔드포인트 ──
279
+ if (path === '/bridge/pipeline/state' && req.method === 'POST') {
280
+ ensurePipelineTable(store.db);
281
+ const { team_name } = body;
282
+ const state = readPipelineState(store.db, team_name);
283
+ res.writeHead(state ? 200 : 404);
284
+ return res.end(JSON.stringify(state
285
+ ? { ok: true, data: state }
286
+ : { ok: false, error: 'pipeline_not_found' }));
287
+ }
288
+
289
+ if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
290
+ ensurePipelineTable(store.db);
291
+ const { team_name, phase } = body;
292
+ const pipeline = createPipeline(store.db, team_name);
293
+ const result = pipeline.advance(phase);
294
+ res.writeHead(result.ok ? 200 : 400);
295
+ return res.end(JSON.stringify(result));
296
+ }
297
+
298
+ if (path === '/bridge/pipeline/init' && req.method === 'POST') {
299
+ ensurePipelineTable(store.db);
300
+ const { team_name, fix_max, ralph_max } = body;
301
+ const state = initPipelineState(store.db, team_name, { fix_max, ralph_max });
302
+ res.writeHead(200);
303
+ return res.end(JSON.stringify({ ok: true, data: state }));
304
+ }
305
+
306
+ if (path === '/bridge/pipeline/list' && req.method === 'POST') {
307
+ ensurePipelineTable(store.db);
308
+ const states = listPipelineStates(store.db);
309
+ res.writeHead(200);
310
+ return res.end(JSON.stringify({ ok: true, data: states }));
311
+ }
243
312
  }
244
313
 
245
314
  if (path === '/bridge/context' && req.method === 'POST') {
@@ -408,6 +477,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
408
477
  await pipe.stop();
409
478
  store.close();
410
479
  try { unlinkSync(PID_FILE); } catch {}
480
+ try { unlinkSync(TOKEN_FILE); } catch {}
411
481
  await new Promise((resolveClose) => httpServer.close(resolveClose));
412
482
  };
413
483
 
@@ -33,12 +33,16 @@ export function buildSlimWrapperPrompt(cli, opts = {}) {
33
33
  agentName = "",
34
34
  leadName = "team-lead",
35
35
  mcp_profile = "auto",
36
+ pipelinePhase = "",
36
37
  } = opts;
37
38
 
38
39
  // 셸 이스케이프
39
40
  const escaped = subtask.replace(/'/g, "'\\''");
41
+ const pipelineHint = pipelinePhase
42
+ ? `\n파이프라인 단계: ${pipelinePhase}`
43
+ : '';
40
44
 
41
- return `Bash 1회 실행 후 반드시 종료하라. 어떤 경우에도 hang하지 마라.
45
+ return `Bash 1회 실행 후 반드시 종료하라. 어떤 경우에도 hang하지 마라.${pipelineHint}
42
46
  gemini/codex를 직접 호출하지 마라. 반드시 tfx-route.sh를 거쳐야 한다.
43
47
  프롬프트를 파일로 저장하지 마라. tfx-route.sh가 인자로 받는다.
44
48
 
@@ -4,8 +4,6 @@
4
4
  import {
5
5
  existsSync,
6
6
  mkdirSync,
7
- readdirSync,
8
- readFileSync,
9
7
  renameSync,
10
8
  statSync,
11
9
  unlinkSync,
@@ -13,6 +11,7 @@ import {
13
11
  openSync,
14
12
  closeSync,
15
13
  } from 'node:fs';
14
+ import { readdir, stat, readFile } from 'node:fs/promises';
16
15
  import { basename, dirname, join } from 'node:path';
17
16
  import { homedir } from 'node:os';
18
17
  import { randomUUID } from 'node:crypto';
@@ -25,6 +24,7 @@ const TASKS_ROOT = join(CLAUDE_HOME, 'tasks');
25
24
  // ── 인메모리 캐시 (디렉토리 mtime 기반 무효화) ──
26
25
  const _dirCache = new Map(); // tasksDir → { mtimeMs, files: string[] }
27
26
  const _taskIdIndex = new Map(); // taskId → filePath
27
+ const _taskContentCache = new Map(); // filePath → { mtimeMs, data }
28
28
 
29
29
  function _invalidateCache(tasksDir) {
30
30
  _dirCache.delete(tasksDir);
@@ -40,9 +40,9 @@ function validateTeamName(teamName) {
40
40
  }
41
41
  }
42
42
 
43
- function readJsonSafe(path) {
43
+ async function readJsonSafe(path) {
44
44
  try {
45
- return JSON.parse(readFileSync(path, 'utf8'));
45
+ return JSON.parse(await readFile(path, 'utf8'));
46
46
  } catch {
47
47
  return null;
48
48
  }
@@ -66,12 +66,11 @@ function atomicWriteJson(path, value) {
66
66
  }
67
67
  }
68
68
 
69
- function sleepMs(ms) {
70
- // busy-wait를 피하고 Atomics.wait로 동기 대기 (CPU 점유 최소화)
71
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
69
+ async function sleepMs(ms) {
70
+ return new Promise((r) => setTimeout(r, ms));
72
71
  }
73
72
 
74
- function withFileLock(lockPath, fn, retries = 20, delayMs = 25) {
73
+ async function withFileLock(lockPath, fn, retries = 20, delayMs = 25) {
75
74
  let fd = null;
76
75
  for (let i = 0; i < retries; i += 1) {
77
76
  try {
@@ -79,12 +78,12 @@ function withFileLock(lockPath, fn, retries = 20, delayMs = 25) {
79
78
  break;
80
79
  } catch (e) {
81
80
  if (e?.code !== 'EEXIST' || i === retries - 1) throw e;
82
- sleepMs(delayMs);
81
+ await sleepMs(delayMs);
83
82
  }
84
83
  }
85
84
 
86
85
  try {
87
- return fn();
86
+ return await fn();
88
87
  } finally {
89
88
  try { if (fd != null) closeSync(fd); } catch {}
90
89
  try { unlinkSync(lockPath); } catch {}
@@ -99,13 +98,13 @@ function getLeadSessionId(config) {
99
98
  || null;
100
99
  }
101
100
 
102
- export function resolveTeamPaths(teamName) {
101
+ export async function resolveTeamPaths(teamName) {
103
102
  validateTeamName(teamName);
104
103
 
105
104
  const teamDir = join(TEAMS_ROOT, teamName);
106
105
  const configPath = join(teamDir, 'config.json');
107
106
  const inboxesDir = join(teamDir, 'inboxes');
108
- const config = readJsonSafe(configPath);
107
+ const config = await readJsonSafe(configPath);
109
108
  const leadSessionId = getLeadSessionId(config);
110
109
 
111
110
  const byTeam = join(TASKS_ROOT, teamName);
@@ -131,19 +130,20 @@ export function resolveTeamPaths(teamName) {
131
130
  };
132
131
  }
133
132
 
134
- function collectTaskFiles(tasksDir) {
133
+ async function collectTaskFiles(tasksDir) {
135
134
  if (!existsSync(tasksDir)) return [];
136
135
 
137
136
  // 디렉토리 mtime 기반 캐시 — O(N) I/O를 반복 호출 시 O(1)로 축소
138
137
  let dirMtime;
139
- try { dirMtime = statSync(tasksDir).mtimeMs; } catch { return []; }
138
+ try { dirMtime = (await stat(tasksDir)).mtimeMs; } catch { return []; }
140
139
 
141
140
  const cached = _dirCache.get(tasksDir);
142
141
  if (cached && cached.mtimeMs === dirMtime) {
143
142
  return cached.files;
144
143
  }
145
144
 
146
- const files = readdirSync(tasksDir)
145
+ const entries = await readdir(tasksDir);
146
+ const files = entries
147
147
  .filter((name) => name.endsWith('.json'))
148
148
  .filter((name) => !name.endsWith('.lock'))
149
149
  .filter((name) => name !== '.highwatermark')
@@ -153,7 +153,7 @@ function collectTaskFiles(tasksDir) {
153
153
  return files;
154
154
  }
155
155
 
156
- function locateTaskFile(tasksDir, taskId) {
156
+ async function locateTaskFile(tasksDir, taskId) {
157
157
  const direct = join(tasksDir, `${taskId}.json`);
158
158
  if (existsSync(direct)) return direct;
159
159
 
@@ -162,13 +162,13 @@ function locateTaskFile(tasksDir, taskId) {
162
162
  if (indexed && existsSync(indexed)) return indexed;
163
163
 
164
164
  // 캐시된 collectTaskFiles로 풀 스캔
165
- const files = collectTaskFiles(tasksDir);
165
+ const files = await collectTaskFiles(tasksDir);
166
166
  for (const file of files) {
167
167
  if (basename(file, '.json') === taskId) {
168
168
  _taskIdIndex.set(taskId, file);
169
169
  return file;
170
170
  }
171
- const json = readJsonSafe(file);
171
+ const json = await readJsonSafe(file);
172
172
  if (json && String(json.id || '') === taskId) {
173
173
  _taskIdIndex.set(taskId, file);
174
174
  return file;
@@ -181,7 +181,7 @@ function isObject(v) {
181
181
  return !!v && typeof v === 'object' && !Array.isArray(v);
182
182
  }
183
183
 
184
- export function teamInfo(args = {}) {
184
+ export async function teamInfo(args = {}) {
185
185
  const { team_name, include_members = true, include_paths = true } = args;
186
186
  try {
187
187
  validateTeamName(team_name);
@@ -189,7 +189,7 @@ export function teamInfo(args = {}) {
189
189
  return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
190
190
  }
191
191
 
192
- const paths = resolveTeamPaths(team_name);
192
+ const paths = await resolveTeamPaths(team_name);
193
193
  if (!existsSync(paths.team_dir)) {
194
194
  return err('TEAM_NOT_FOUND', `팀 디렉토리가 없습니다: ${paths.team_dir}`);
195
195
  }
@@ -224,7 +224,7 @@ export function teamInfo(args = {}) {
224
224
  };
225
225
  }
226
226
 
227
- export function teamTaskList(args = {}) {
227
+ export async function teamTaskList(args = {}) {
228
228
  const {
229
229
  team_name,
230
230
  owner,
@@ -239,7 +239,7 @@ export function teamTaskList(args = {}) {
239
239
  return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
240
240
  }
241
241
 
242
- const paths = resolveTeamPaths(team_name);
242
+ const paths = await resolveTeamPaths(team_name);
243
243
  if (paths.tasks_dir_resolution === 'not_found') {
244
244
  return err('TASKS_DIR_NOT_FOUND', `task 디렉토리를 찾지 못했습니다: ${team_name}`);
245
245
  }
@@ -249,8 +249,22 @@ export function teamTaskList(args = {}) {
249
249
  let parseWarnings = 0;
250
250
 
251
251
  const tasks = [];
252
- for (const file of collectTaskFiles(paths.tasks_dir)) {
253
- const json = readJsonSafe(file);
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
+
254
268
  if (!json || !isObject(json)) {
255
269
  parseWarnings += 1;
256
270
  continue;
@@ -260,13 +274,10 @@ export function teamTaskList(args = {}) {
260
274
  if (owner && String(json.owner || '') !== String(owner)) continue;
261
275
  if (statusSet.size > 0 && !statusSet.has(String(json.status || ''))) continue;
262
276
 
263
- let mtime = Date.now();
264
- try { mtime = statSync(file).mtimeMs; } catch {}
265
-
266
277
  tasks.push({
267
278
  ...json,
268
279
  task_file: file,
269
- mtime_ms: mtime,
280
+ mtime_ms: fileMtime,
270
281
  });
271
282
  }
272
283
 
@@ -288,7 +299,7 @@ export function teamTaskList(args = {}) {
288
299
  // status 화이트리스트 (Claude Code API 호환)
289
300
  const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
290
301
 
291
- export function teamTaskUpdate(args = {}) {
302
+ export async function teamTaskUpdate(args = {}) {
292
303
  // "failed" → "completed" + metadata.result 자동 매핑
293
304
  if (String(args.status || '') === 'failed') {
294
305
  args = {
@@ -326,12 +337,12 @@ export function teamTaskUpdate(args = {}) {
326
337
  return err('INVALID_TASK_ID', 'task_id가 필요합니다');
327
338
  }
328
339
 
329
- const paths = resolveTeamPaths(team_name);
340
+ const paths = await resolveTeamPaths(team_name);
330
341
  if (paths.tasks_dir_resolution === 'not_found') {
331
342
  return err('TASKS_DIR_NOT_FOUND', `task 디렉토리를 찾지 못했습니다: ${team_name}`);
332
343
  }
333
344
 
334
- const taskFile = locateTaskFile(paths.tasks_dir, String(task_id));
345
+ const taskFile = await locateTaskFile(paths.tasks_dir, String(task_id));
335
346
  if (!taskFile) {
336
347
  return err('TASK_NOT_FOUND', `task를 찾지 못했습니다: ${task_id}`);
337
348
  }
@@ -339,14 +350,14 @@ export function teamTaskUpdate(args = {}) {
339
350
  const lockFile = `${taskFile}.lock`;
340
351
 
341
352
  try {
342
- return withFileLock(lockFile, () => {
343
- const before = readJsonSafe(taskFile);
353
+ return await withFileLock(lockFile, async () => {
354
+ const before = await readJsonSafe(taskFile);
344
355
  if (!before || !isObject(before)) {
345
356
  return err('INVALID_TASK_FILE', `task 파일 파싱 실패: ${taskFile}`);
346
357
  }
347
358
 
348
359
  let beforeMtime = Date.now();
349
- try { beforeMtime = statSync(taskFile).mtimeMs; } catch {}
360
+ try { beforeMtime = (await stat(taskFile)).mtimeMs; } catch {}
350
361
 
351
362
  if (if_match_mtime_ms != null && Number(if_match_mtime_ms) !== Number(beforeMtime)) {
352
363
  return err('MTIME_CONFLICT', 'if_match_mtime_ms가 일치하지 않습니다', {
@@ -427,6 +438,8 @@ export function teamTaskUpdate(args = {}) {
427
438
  if (updated) {
428
439
  atomicWriteJson(taskFile, after);
429
440
  _invalidateCache(dirname(taskFile));
441
+ // 콘텐츠 캐시 무효화
442
+ _taskContentCache.delete(taskFile);
430
443
  }
431
444
 
432
445
  let afterMtime = beforeMtime;
@@ -453,7 +466,7 @@ function sanitizeRecipientName(v) {
453
466
  return String(v || 'team-lead').replace(/[\\/:*?"<>|]/g, '_');
454
467
  }
455
468
 
456
- export function teamSendMessage(args = {}) {
469
+ export async function teamSendMessage(args = {}) {
457
470
  const {
458
471
  team_name,
459
472
  from,
@@ -472,7 +485,7 @@ export function teamSendMessage(args = {}) {
472
485
  if (!String(from || '').trim()) return err('INVALID_FROM', 'from이 필요합니다');
473
486
  if (!String(text || '').trim()) return err('INVALID_TEXT', 'text가 필요합니다');
474
487
 
475
- const paths = resolveTeamPaths(team_name);
488
+ const paths = await resolveTeamPaths(team_name);
476
489
  if (!existsSync(paths.team_dir)) {
477
490
  return err('TEAM_NOT_FOUND', `팀 디렉토리가 없습니다: ${paths.team_dir}`);
478
491
  }
@@ -483,8 +496,8 @@ export function teamSendMessage(args = {}) {
483
496
  let message;
484
497
 
485
498
  try {
486
- const unreadCount = withFileLock(lockFile, () => {
487
- const queue = readJsonSafe(inboxFile);
499
+ const unreadCount = await withFileLock(lockFile, async () => {
500
+ const queue = await readJsonSafe(inboxFile);
488
501
  const list = Array.isArray(queue) ? queue : [];
489
502
 
490
503
  message = {
package/hub/tools.mjs CHANGED
@@ -8,16 +8,25 @@ import {
8
8
  teamTaskUpdate,
9
9
  teamSendMessage,
10
10
  } from './team/nativeProxy.mjs';
11
+ import {
12
+ ensurePipelineTable,
13
+ createPipeline,
14
+ } from './pipeline/index.mjs';
15
+ import {
16
+ readPipelineState,
17
+ initPipelineState,
18
+ listPipelineStates,
19
+ } from './pipeline/state.mjs';
11
20
 
12
21
  /**
13
22
  * MCP 도구 목록 생성
14
23
  * @param {object} store — createStore() 반환
15
24
  * @param {object} router — createRouter() 반환
16
- * @param {object} hitl — createHitlManager() 반환
17
- * @param {object} pipe — createPipeServer() 반환
18
- * @returns {Array<{name, description, inputSchema, handler}>}
19
- */
20
- export function createTools(store, router, hitl, pipe = null) {
25
+ * @param {object} hitl — createHitlManager() 반환
26
+ * @param {object} pipe — createPipeServer() 반환
27
+ * @returns {Array<{name, description, inputSchema, handler}>}
28
+ */
29
+ export function createTools(store, router, hitl, pipe = null) {
21
30
  /** 도구 핸들러 래퍼 — 에러 처리 + MCP content 형식 변환 */
22
31
  function wrap(code, fn) {
23
32
  return async (args) => {
@@ -49,10 +58,10 @@ export function createTools(store, router, hitl, pipe = null) {
49
58
  heartbeat_ttl_ms: { type: 'integer', minimum: 10000, maximum: 7200000 },
50
59
  },
51
60
  },
52
- handler: wrap('REGISTER_FAILED', (args) => {
53
- const data = router.registerAgent(args);
54
- return { ok: true, data };
55
- }),
61
+ handler: wrap('REGISTER_FAILED', (args) => {
62
+ const data = router.registerAgent(args);
63
+ return { ok: true, data };
64
+ }),
56
65
  },
57
66
 
58
67
  // ── 2. status ──
@@ -122,10 +131,10 @@ export function createTools(store, router, hitl, pipe = null) {
122
131
  }),
123
132
  },
124
133
 
125
- // ── 5. poll_messages ──
126
- {
127
- name: 'poll_messages',
128
- description: 'Deprecated. poll_messages 대신 Named Pipe subscribe/publish 채널을 사용합니다',
134
+ // ── 5. poll_messages ──
135
+ {
136
+ name: 'poll_messages',
137
+ description: 'Deprecated. poll_messages 대신 Named Pipe subscribe/publish 채널을 사용합니다',
129
138
  inputSchema: {
130
139
  type: 'object',
131
140
  required: ['agent_id'],
@@ -137,34 +146,34 @@ export function createTools(store, router, hitl, pipe = null) {
137
146
  ack_ids: { type: 'array', items: { type: 'string' }, maxItems: 100 },
138
147
  auto_ack: { type: 'boolean', default: false },
139
148
  },
140
- },
141
- handler: wrap('POLL_DEPRECATED', async (args) => {
142
- const replay = router.drainAgent(args.agent_id, {
143
- max_messages: args.max_messages,
144
- include_topics: args.include_topics,
145
- auto_ack: args.auto_ack,
146
- });
147
- if (args.ack_ids?.length) {
148
- router.ackMessages(args.ack_ids, args.agent_id);
149
- }
150
- return {
151
- ok: false,
152
- error: {
153
- code: 'POLL_DEPRECATED',
154
- message: 'poll_messages는 deprecated 되었습니다. pipe subscribe/publish 채널을 사용하세요.',
155
- },
156
- data: {
157
- pipe_path: pipe?.path || null,
158
- delivery_mode: 'pipe_push',
159
- protocol: 'ndjson',
160
- replay: {
161
- messages: replay,
162
- count: replay.length,
163
- },
164
- server_time_ms: Date.now(),
165
- },
166
- };
167
- }),
149
+ },
150
+ handler: wrap('POLL_DEPRECATED', async (args) => {
151
+ const replay = router.drainAgent(args.agent_id, {
152
+ max_messages: args.max_messages,
153
+ include_topics: args.include_topics,
154
+ auto_ack: args.auto_ack,
155
+ });
156
+ if (args.ack_ids?.length) {
157
+ router.ackMessages(args.ack_ids, args.agent_id);
158
+ }
159
+ return {
160
+ ok: false,
161
+ error: {
162
+ code: 'POLL_DEPRECATED',
163
+ message: 'poll_messages는 deprecated 되었습니다. pipe subscribe/publish 채널을 사용하세요.',
164
+ },
165
+ data: {
166
+ pipe_path: pipe?.path || null,
167
+ delivery_mode: 'pipe_push',
168
+ protocol: 'ndjson',
169
+ replay: {
170
+ messages: replay,
171
+ count: replay.length,
172
+ },
173
+ server_time_ms: Date.now(),
174
+ },
175
+ };
176
+ }),
168
177
  },
169
178
 
170
179
  // ── 6. handoff ──
@@ -325,5 +334,81 @@ export function createTools(store, router, hitl, pipe = null) {
325
334
  return teamSendMessage(args);
326
335
  }),
327
336
  },
337
+
338
+ // ── 13. pipeline_state ──
339
+ {
340
+ name: 'pipeline_state',
341
+ description: '파이프라인 상태를 조회합니다 (--thorough 모드)',
342
+ inputSchema: {
343
+ type: 'object',
344
+ required: ['team_name'],
345
+ properties: {
346
+ team_name: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
347
+ },
348
+ },
349
+ handler: wrap('PIPELINE_STATE_FAILED', (args) => {
350
+ ensurePipelineTable(store.db);
351
+ const state = readPipelineState(store.db, args.team_name);
352
+ return state
353
+ ? { ok: true, data: state }
354
+ : { ok: false, error: { code: 'PIPELINE_NOT_FOUND', message: `파이프라인 없음: ${args.team_name}` } };
355
+ }),
356
+ },
357
+
358
+ // ── 14. pipeline_advance ──
359
+ {
360
+ name: 'pipeline_advance',
361
+ description: '파이프라인을 다음 단계로 전이합니다 (전이 규칙 + fix loop 바운딩 적용)',
362
+ inputSchema: {
363
+ type: 'object',
364
+ required: ['team_name', 'phase'],
365
+ properties: {
366
+ team_name: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
367
+ phase: { type: 'string', enum: ['plan', 'prd', 'exec', 'verify', 'fix', 'complete', 'failed'] },
368
+ },
369
+ },
370
+ handler: wrap('PIPELINE_ADVANCE_FAILED', (args) => {
371
+ ensurePipelineTable(store.db);
372
+ const pipeline = createPipeline(store.db, args.team_name);
373
+ return pipeline.advance(args.phase);
374
+ }),
375
+ },
376
+
377
+ // ── 15. pipeline_init ──
378
+ {
379
+ name: 'pipeline_init',
380
+ description: '새 파이프라인을 초기화합니다 (기존 상태 덮어쓰기)',
381
+ inputSchema: {
382
+ type: 'object',
383
+ required: ['team_name'],
384
+ properties: {
385
+ team_name: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
386
+ fix_max: { type: 'integer', minimum: 1, maximum: 20, default: 3 },
387
+ ralph_max: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
388
+ },
389
+ },
390
+ handler: wrap('PIPELINE_INIT_FAILED', (args) => {
391
+ ensurePipelineTable(store.db);
392
+ const state = initPipelineState(store.db, args.team_name, {
393
+ fix_max: args.fix_max,
394
+ ralph_max: args.ralph_max,
395
+ });
396
+ return { ok: true, data: state };
397
+ }),
398
+ },
399
+
400
+ // ── 16. pipeline_list ──
401
+ {
402
+ name: 'pipeline_list',
403
+ description: '활성 파이프라인 목록을 조회합니다',
404
+ inputSchema: {
405
+ type: 'object',
406
+ properties: {},
407
+ },
408
+ handler: wrap('PIPELINE_LIST_FAILED', () => {
409
+ ensurePipelineTable(store.db);
410
+ return { ok: true, data: listPipelineStates(store.db) };
411
+ }),
412
+ },
328
413
  ];
329
414
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "3.2.0-dev.11",
3
+ "version": "3.2.0-dev.13",
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": {
@@ -226,8 +226,9 @@ route_agent() {
226
226
  CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
227
227
  CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=300; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
228
228
  verifier)
229
- CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
230
- CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=300; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
229
+ CLI_TYPE="codex"; CLI_CMD="codex"
230
+ CLI_ARGS="exec --profile thorough ${codex_base} review"
231
+ CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1200; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
231
232
  test-engineer)
232
233
  CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
233
234
  CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=300; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;