triflux 2.5.1 → 3.1.0-dev.1

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/tools.mjs ADDED
@@ -0,0 +1,238 @@
1
+ // hub/tools.mjs — MCP 도구 8개 정의
2
+ // register, status, publish, ask, poll_messages, handoff, request_human_input, submit_human_input
3
+ // 모든 도구 응답: { ok: boolean, error?: { code, message }, data?: ... }
4
+
5
+ /**
6
+ * MCP 도구 목록 생성
7
+ * @param {object} store — createStore() 반환
8
+ * @param {object} router — createRouter() 반환
9
+ * @param {object} hitl — createHitlManager() 반환
10
+ * @returns {Array<{name, description, inputSchema, handler}>}
11
+ */
12
+ export function createTools(store, router, hitl) {
13
+ /** 도구 핸들러 래퍼 — 에러 처리 + MCP content 형식 변환 */
14
+ function wrap(code, fn) {
15
+ return async (args) => {
16
+ try {
17
+ const result = await fn(args);
18
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
19
+ } catch (e) {
20
+ const err = { ok: false, error: { code, message: e.message } };
21
+ return { content: [{ type: 'text', text: JSON.stringify(err) }], isError: true };
22
+ }
23
+ };
24
+ }
25
+
26
+ return [
27
+ // ── 1. register ──
28
+ {
29
+ name: 'register',
30
+ description: '에이전트를 허브에 등록하고 lease를 발급받습니다',
31
+ inputSchema: {
32
+ type: 'object',
33
+ required: ['agent_id', 'cli', 'capabilities', 'topics', 'heartbeat_ttl_ms'],
34
+ properties: {
35
+ agent_id: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
36
+ cli: { type: 'string', enum: ['codex', 'gemini', 'claude', 'other'] },
37
+ pid: { type: 'integer', minimum: 1 },
38
+ capabilities: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 64 },
39
+ topics: { type: 'array', items: { type: 'string' }, maxItems: 64 },
40
+ metadata: { type: 'object' },
41
+ heartbeat_ttl_ms: { type: 'integer', minimum: 5000, maximum: 300000 },
42
+ },
43
+ },
44
+ handler: wrap('REGISTER_FAILED', (args) => {
45
+ const data = store.registerAgent(args);
46
+ return { ok: true, data };
47
+ }),
48
+ },
49
+
50
+ // ── 2. status ──
51
+ {
52
+ name: 'status',
53
+ description: '허브, 에이전트, 큐, 트레이스 상태를 조회합니다',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ scope: { type: 'string', enum: ['hub', 'agent', 'queue', 'trace'], default: 'hub' },
58
+ agent_id: { type: 'string' },
59
+ trace_id: { type: 'string' },
60
+ include_metrics: { type: 'boolean', default: true },
61
+ },
62
+ },
63
+ handler: wrap('STATUS_FAILED', (args) => {
64
+ return router.getStatus(args.scope || 'hub', args);
65
+ }),
66
+ },
67
+
68
+ // ── 3. publish ──
69
+ {
70
+ name: 'publish',
71
+ description: '이벤트 또는 응답 메시지를 발행합니다. to에 "topic:XXX" 지정 시 구독자 전체 fanout',
72
+ inputSchema: {
73
+ type: 'object',
74
+ required: ['from', 'to', 'topic', 'payload'],
75
+ properties: {
76
+ from: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
77
+ to: { type: 'string' },
78
+ topic: { type: 'string', pattern: '^[a-zA-Z0-9._:-]+$' },
79
+ priority: { type: 'integer', minimum: 1, maximum: 9, default: 5 },
80
+ ttl_ms: { type: 'integer', minimum: 1000, maximum: 86400000, default: 300000 },
81
+ payload: { type: 'object' },
82
+ trace_id: { type: 'string' },
83
+ correlation_id: { type: 'string' },
84
+ },
85
+ },
86
+ handler: wrap('PUBLISH_FAILED', (args) => {
87
+ return router.handlePublish(args);
88
+ }),
89
+ },
90
+
91
+ // ── 4. ask ──
92
+ {
93
+ name: 'ask',
94
+ description: '다른 에이전트에게 질문합니다. await_response_ms > 0이면 짧은 폴링으로 응답 대기',
95
+ inputSchema: {
96
+ type: 'object',
97
+ required: ['from', 'to', 'topic', 'question'],
98
+ properties: {
99
+ from: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
100
+ to: { type: 'string' },
101
+ topic: { type: 'string', pattern: '^[a-zA-Z0-9._:-]+$' },
102
+ question: { type: 'string', minLength: 1, maxLength: 20000 },
103
+ context_refs: { type: 'array', items: { type: 'string' }, maxItems: 32 },
104
+ payload: { type: 'object' },
105
+ priority: { type: 'integer', minimum: 1, maximum: 9, default: 5 },
106
+ ttl_ms: { type: 'integer', minimum: 1000, maximum: 86400000, default: 300000 },
107
+ await_response_ms: { type: 'integer', minimum: 0, maximum: 30000, default: 0 },
108
+ trace_id: { type: 'string' },
109
+ correlation_id: { type: 'string' },
110
+ },
111
+ },
112
+ handler: wrap('ASK_FAILED', async (args) => {
113
+ return await router.handleAsk(args);
114
+ }),
115
+ },
116
+
117
+ // ── 5. poll_messages ──
118
+ {
119
+ name: 'poll_messages',
120
+ description: '에이전트 수신함에서 대기 메시지를 가져옵니다. ack_ids로 이전 메시지 확인 가능',
121
+ inputSchema: {
122
+ type: 'object',
123
+ required: ['agent_id'],
124
+ properties: {
125
+ agent_id: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
126
+ wait_ms: { type: 'integer', minimum: 0, maximum: 30000, default: 1000 },
127
+ max_messages: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
128
+ include_topics: { type: 'array', items: { type: 'string' }, maxItems: 64 },
129
+ ack_ids: { type: 'array', items: { type: 'string' }, maxItems: 100 },
130
+ auto_ack: { type: 'boolean', default: false },
131
+ },
132
+ },
133
+ handler: wrap('POLL_FAILED', async (args) => {
134
+ // ACK 먼저 처리
135
+ const ackedIds = [];
136
+ if (args.ack_ids?.length) {
137
+ store.ackMessages(args.ack_ids, args.agent_id);
138
+ ackedIds.push(...args.ack_ids);
139
+ }
140
+
141
+ // 1차 폴링
142
+ let messages = store.pollForAgent(args.agent_id, {
143
+ max_messages: args.max_messages,
144
+ include_topics: args.include_topics,
145
+ auto_ack: args.auto_ack,
146
+ });
147
+
148
+ // wait_ms > 0 이고 메시지 없으면 대기 후 재시도
149
+ if (!messages.length && args.wait_ms > 0) {
150
+ await new Promise(r => setTimeout(r, Math.min(args.wait_ms, 30000)));
151
+ messages = store.pollForAgent(args.agent_id, {
152
+ max_messages: args.max_messages,
153
+ include_topics: args.include_topics,
154
+ auto_ack: args.auto_ack,
155
+ });
156
+ }
157
+
158
+ return {
159
+ ok: true,
160
+ data: {
161
+ messages,
162
+ acked_ids: ackedIds,
163
+ next_poll_after_ms: messages.length ? 0 : 1000,
164
+ server_time_ms: Date.now(),
165
+ },
166
+ };
167
+ }),
168
+ },
169
+
170
+ // ── 6. handoff ──
171
+ {
172
+ name: 'handoff',
173
+ description: '다른 에이전트에게 작업을 인계합니다. acceptance_criteria로 완료 기준 지정 가능',
174
+ inputSchema: {
175
+ type: 'object',
176
+ required: ['from', 'to', 'topic', 'task'],
177
+ properties: {
178
+ from: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
179
+ to: { type: 'string' },
180
+ topic: { type: 'string', pattern: '^[a-zA-Z0-9._:-]+$' },
181
+ task: { type: 'string', minLength: 1, maxLength: 20000 },
182
+ acceptance_criteria: { type: 'array', items: { type: 'string' }, maxItems: 32 },
183
+ context_refs: { type: 'array', items: { type: 'string' }, maxItems: 32 },
184
+ priority: { type: 'integer', minimum: 1, maximum: 9, default: 5 },
185
+ ttl_ms: { type: 'integer', minimum: 1000, maximum: 86400000, default: 600000 },
186
+ trace_id: { type: 'string' },
187
+ correlation_id: { type: 'string' },
188
+ },
189
+ },
190
+ handler: wrap('HANDOFF_FAILED', (args) => {
191
+ return router.handleHandoff(args);
192
+ }),
193
+ },
194
+
195
+ // ── 7. request_human_input ──
196
+ {
197
+ name: 'request_human_input',
198
+ description: '사용자에게 입력을 요청합니다 (CAPTCHA, 승인, 자격증명, 선택, 텍스트)',
199
+ inputSchema: {
200
+ type: 'object',
201
+ required: ['requester_agent', 'kind', 'prompt', 'requested_schema', 'deadline_ms', 'default_action'],
202
+ properties: {
203
+ requester_agent: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
204
+ kind: { type: 'string', enum: ['captcha', 'approval', 'credential', 'choice', 'text'] },
205
+ prompt: { type: 'string', minLength: 1, maxLength: 20000 },
206
+ requested_schema: { type: 'object' },
207
+ deadline_ms: { type: 'integer', minimum: 1000 },
208
+ default_action: { type: 'string', enum: ['decline', 'cancel', 'timeout_continue'] },
209
+ channel_preference: { type: 'string', enum: ['terminal', 'pipe', 'file_polling'], default: 'terminal' },
210
+ trace_id: { type: 'string' },
211
+ correlation_id: { type: 'string' },
212
+ },
213
+ },
214
+ handler: wrap('HITL_REQUEST_FAILED', (args) => {
215
+ return hitl.requestHumanInput(args);
216
+ }),
217
+ },
218
+
219
+ // ── 8. submit_human_input ──
220
+ {
221
+ name: 'submit_human_input',
222
+ description: '사용자 입력 요청에 응답합니다 (accept, decline, cancel)',
223
+ inputSchema: {
224
+ type: 'object',
225
+ required: ['request_id', 'action'],
226
+ properties: {
227
+ request_id: { type: 'string' },
228
+ action: { type: 'string', enum: ['accept', 'decline', 'cancel'] },
229
+ content: { type: 'object' },
230
+ submitted_by: { type: 'string', default: 'human' },
231
+ },
232
+ },
233
+ handler: wrap('HITL_SUBMIT_FAILED', (args) => {
234
+ return hitl.submitHumanInput(args);
235
+ }),
236
+ },
237
+ ];
238
+ }
@@ -509,7 +509,7 @@ function normalizeTimeToken(value) {
509
509
  }
510
510
  const dayHour = text.match(/^(\d+)d(\d+)h$/);
511
511
  if (dayHour) {
512
- return `${Number(dayHour[1])}d${Number(dayHour[2])}h`;
512
+ return `${Number(dayHour[1])}d${String(Number(dayHour[2])).padStart(2, "0")}h`;
513
513
  }
514
514
  return text;
515
515
  }
@@ -660,14 +660,17 @@ function fetchClaudeUsageFromApi(accessToken) {
660
660
  }
661
661
 
662
662
  function parseClaudeUsageResponse(response) {
663
- const fiveHour = response?.five_hour?.utilization;
664
- const sevenDay = response?.seven_day?.utilization;
665
- if (fiveHour == null && sevenDay == null) return null;
663
+ if (!response || typeof response !== "object") return null;
664
+ // five_hour/seven_day 자체가 없으면 비정상 응답
665
+ if (!response.five_hour && !response.seven_day) return null;
666
+ const fiveHour = response.five_hour?.utilization;
667
+ const sevenDay = response.seven_day?.utilization;
668
+ // utilization이 null이면 0%로 처리 (API 200 성공 시 null = 사용량 없음)
666
669
  return {
667
670
  fiveHourPercent: clampPercent(fiveHour ?? 0),
668
671
  weeklyPercent: clampPercent(sevenDay ?? 0),
669
- fiveHourResetsAt: response?.five_hour?.resets_at || null,
670
- weeklyResetsAt: response?.seven_day?.resets_at || null,
672
+ fiveHourResetsAt: response.five_hour?.resets_at || null,
673
+ weeklyResetsAt: response.seven_day?.resets_at || null,
671
674
  };
672
675
  }
673
676
 
@@ -693,6 +696,13 @@ function readClaudeUsageSnapshot() {
693
696
 
694
697
  // 1차: 자체 캐시에 유효 데이터가 있는 경우
695
698
  if (cache?.data) {
699
+ // 에러 상태에서 보존된 stale 데이터 → backoff 존중하되 표시용 데이터 반환
700
+ if (cache.error) {
701
+ const backoffMs = cache.errorType === "rate_limit"
702
+ ? CLAUDE_USAGE_429_BACKOFF_MS
703
+ : CLAUDE_USAGE_ERROR_BACKOFF_MS;
704
+ return { data: stripStaleResets(cache.data), shouldRefresh: ageMs >= backoffMs };
705
+ }
696
706
  const isFresh = ageMs < getClaudeUsageStaleMs();
697
707
  return { data: cache.data, shouldRefresh: !isFresh };
698
708
  }
@@ -733,13 +743,22 @@ function readClaudeUsageSnapshot() {
733
743
  }
734
744
 
735
745
  function writeClaudeUsageCache(data, errorInfo = null) {
736
- writeJsonSafe(CLAUDE_USAGE_CACHE_PATH, {
746
+ const entry = {
737
747
  timestamp: Date.now(),
738
748
  data,
739
749
  error: !!errorInfo,
740
750
  errorType: errorInfo?.type || null, // "rate_limit" | "auth" | "network" | "unknown"
741
751
  errorStatus: errorInfo?.status || null, // HTTP 상태 코드
742
- });
752
+ };
753
+ // 에러 시 기존 유효 데이터 보존 (--% n/a 방지)
754
+ if (errorInfo && data == null) {
755
+ const prev = readJson(CLAUDE_USAGE_CACHE_PATH, null);
756
+ if (prev?.data) {
757
+ entry.data = prev.data;
758
+ entry.stale = true;
759
+ }
760
+ }
761
+ writeJsonSafe(CLAUDE_USAGE_CACHE_PATH, entry);
743
762
  }
744
763
 
745
764
  async function fetchClaudeUsage(forceRefresh = false) {
@@ -958,7 +977,9 @@ function getGeminiEmail() {
958
977
  // ============================================================================
959
978
  function getCodexRateLimits() {
960
979
  const now = new Date();
961
- let todayHasFiles = false;
980
+ let syntheticBucket = null; // 오늘 token_count에서 합성 (행 활성화 + 토큰 데이터용)
981
+
982
+ // 2일간 스캔: 실제 rate_limits 우선, 합성 버킷은 폴백
962
983
  for (let dayOffset = 0; dayOffset <= 1; dayOffset++) {
963
984
  const d = new Date(now.getTime() - dayOffset * 86_400_000);
964
985
  const sessDir = join(
@@ -971,10 +992,7 @@ function getCodexRateLimits() {
971
992
  let files;
972
993
  try { files = readdirSync(sessDir).filter((f) => f.endsWith(".jsonl")).sort().reverse(); }
973
994
  catch { continue; }
974
- if (dayOffset === 0 && files.length > 0) todayHasFiles = true;
975
995
 
976
- // 당일 모든 세션 파일을 스캔해 limit_id별 가장 최신 버킷을 병합
977
- // (파일 목록은 이름 역순 정렬 → 최신 세션 우선)
978
996
  const mergedBuckets = {};
979
997
  for (const file of files) {
980
998
  try {
@@ -985,7 +1003,7 @@ function getCodexRateLimits() {
985
1003
  const evt = JSON.parse(line);
986
1004
  const rl = evt?.payload?.rate_limits;
987
1005
  if (rl?.limit_id && !mergedBuckets[rl.limit_id]) {
988
- // limit_id별로 발견(=해당 세션의 가장 최신 이벤트)만 기록
1006
+ // 실제 rate_limits: limit_id별 최신 이벤트만 기록
989
1007
  mergedBuckets[rl.limit_id] = {
990
1008
  limitId: rl.limit_id, limitName: rl.limit_name,
991
1009
  primary: rl.primary, secondary: rl.secondary,
@@ -994,18 +1012,33 @@ function getCodexRateLimits() {
994
1012
  contextWindow: evt.payload?.info?.model_context_window,
995
1013
  timestamp: evt.timestamp,
996
1014
  };
1015
+ } else if (dayOffset === 0 && !rl && evt?.payload?.info?.total_token_usage && !syntheticBucket) {
1016
+ // 오늘 token_count: 합성 버킷 (rate_limits가 null일 때 행 활성화용)
1017
+ syntheticBucket = {
1018
+ limitId: "codex", limitName: "codex-session",
1019
+ primary: null, secondary: null,
1020
+ credits: null,
1021
+ tokens: evt.payload.info.total_token_usage,
1022
+ contextWindow: evt.payload.info.model_context_window,
1023
+ timestamp: evt.timestamp,
1024
+ };
997
1025
  }
998
1026
  } catch { /* 라인 파싱 실패 무시 */ }
999
1027
  if (Object.keys(mergedBuckets).length >= CODEX_MIN_BUCKETS) break;
1000
1028
  }
1001
1029
  } catch { /* 파일 읽기 실패 무시 */ }
1002
1030
  }
1003
- if (Object.keys(mergedBuckets).length > 0) return mergedBuckets;
1004
-
1005
- // 오늘 세션 파일이 존재하지만 rate_limits가 없으면 어제 stale 데이터로 폴백하지 않음
1006
- if (todayHasFiles && dayOffset === 0) return null;
1031
+ // 실제 rate_limits 발견 오늘 토큰 데이터 병합 후 즉시 반환
1032
+ if (Object.keys(mergedBuckets).length > 0) {
1033
+ if (syntheticBucket) {
1034
+ const main = mergedBuckets.codex || mergedBuckets[Object.keys(mergedBuckets)[0]];
1035
+ if (main && !main.tokens) main.tokens = syntheticBucket.tokens;
1036
+ }
1037
+ return mergedBuckets;
1038
+ }
1007
1039
  }
1008
- return null;
1040
+ // 실제 rate_limits 없음 → 합성 버킷이라도 반환 (행 활성화)
1041
+ return syntheticBucket ? { codex: syntheticBucket } : null;
1009
1042
  }
1010
1043
 
1011
1044
  // ============================================================================
package/package.json CHANGED
@@ -1,51 +1,56 @@
1
- {
2
- "name": "triflux",
3
- "version": "2.5.1",
4
- "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
- "type": "module",
6
- "bin": {
7
- "triflux": "bin/triflux.mjs",
8
- "tfx": "bin/triflux.mjs",
9
- "tfl": "bin/triflux.mjs",
10
- "tfx-setup": "bin/tfx-setup.mjs",
11
- "tfx-doctor": "bin/tfx-doctor.mjs"
12
- },
13
- "files": [
14
- "bin",
15
- "skills",
16
- "!**/failure-reports",
17
- "scripts",
18
- "hooks",
19
- "hud",
20
- ".claude-plugin",
21
- ".mcp.json",
22
- "README.md",
23
- "README.ko.md",
24
- "LICENSE"
25
- ],
26
- "scripts": {
27
- "setup": "node scripts/setup.mjs",
28
- "postinstall": "node scripts/setup.mjs"
29
- },
30
- "engines": {
31
- "node": ">=18.0.0"
32
- },
33
- "repository": {
34
- "type": "git",
35
- "url": "git+https://github.com/tellang/triflux.git"
36
- },
37
- "homepage": "https://github.com/tellang/triflux#readme",
38
- "author": "tellang",
39
- "license": "MIT",
40
- "keywords": [
41
- "claude-code",
42
- "plugin",
43
- "codex",
44
- "gemini",
45
- "cli-routing",
46
- "orchestration",
47
- "multi-model",
48
- "triflux",
49
- "tfx"
50
- ]
51
- }
1
+ {
2
+ "name": "triflux",
3
+ "version": "3.1.0-dev.1",
4
+ "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
+ "type": "module",
6
+ "bin": {
7
+ "triflux": "bin/triflux.mjs",
8
+ "tfx": "bin/triflux.mjs",
9
+ "tfl": "bin/triflux.mjs",
10
+ "tfx-setup": "bin/tfx-setup.mjs",
11
+ "tfx-doctor": "bin/tfx-doctor.mjs"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "hub",
16
+ "skills",
17
+ "!**/failure-reports",
18
+ "scripts",
19
+ "hooks",
20
+ "hud",
21
+ ".claude-plugin",
22
+ ".mcp.json",
23
+ "README.md",
24
+ "README.ko.md",
25
+ "LICENSE"
26
+ ],
27
+ "scripts": {
28
+ "setup": "node scripts/setup.mjs",
29
+ "postinstall": "node scripts/setup.mjs"
30
+ },
31
+ "engines": {
32
+ "node": ">=18.0.0"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/tellang/triflux.git"
37
+ },
38
+ "homepage": "https://github.com/tellang/triflux#readme",
39
+ "author": "tellang",
40
+ "license": "MIT",
41
+ "dependencies": {
42
+ "better-sqlite3": "^12.6.2",
43
+ "@modelcontextprotocol/sdk": "^1.27.1"
44
+ },
45
+ "keywords": [
46
+ "claude-code",
47
+ "plugin",
48
+ "codex",
49
+ "gemini",
50
+ "cli-routing",
51
+ "orchestration",
52
+ "multi-model",
53
+ "triflux",
54
+ "tfx"
55
+ ]
56
+ }