triflux 3.0.0 → 3.1.0-dev.2

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
+ }
@@ -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) {
package/package.json CHANGED
@@ -1,51 +1,56 @@
1
- {
2
- "name": "triflux",
3
- "version": "3.0.0",
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.2",
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
+ }
@@ -0,0 +1,83 @@
1
+ # tfx-hub — MCP 메시지 버스 관리
2
+
3
+ > CLI 에이전트(Codex/Gemini/Claude) 간 실시간 메시지 허브를 관리합니다.
4
+ > **tfx-auto와 완전 독립** — 별도 스킬로 운영됩니다.
5
+
6
+ ## 사용법
7
+
8
+ ```
9
+ /tfx-hub start ← 허브 데몬 시작 (기본 포트 27888)
10
+ /tfx-hub start --port 28000 ← 커스텀 포트
11
+ /tfx-hub stop ← 허브 중지
12
+ /tfx-hub status ← 상태/메트릭 확인
13
+ ```
14
+
15
+ ## 커맨드
16
+
17
+ ### start — 허브 시작
18
+
19
+ ```bash
20
+ Bash("node hub/server.mjs", run_in_background=true)
21
+ ```
22
+
23
+ - Streamable HTTP MCP 서버를 `http://127.0.0.1:27888/mcp` 에서 시작
24
+ - SQLite WAL DB: `~/.claude/cache/tfx-hub/state.db`
25
+ - PID 파일: `~/.claude/cache/tfx-hub/hub.pid`
26
+ - 환경변수: `TFX_HUB_PORT` (포트), `TFX_HUB_DB` (DB 경로)
27
+
28
+ ### stop — 허브 중지
29
+
30
+ ```bash
31
+ # PID 파일에서 프로세스 ID 읽어서 종료
32
+ Bash("node -e \"
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+ const pidFile = path.join(require('os').homedir(), '.claude/cache/tfx-hub/hub.pid');
36
+ if (fs.existsSync(pidFile)) {
37
+ const info = JSON.parse(fs.readFileSync(pidFile, 'utf8'));
38
+ process.kill(info.pid, 'SIGTERM');
39
+ console.log('tfx-hub 종료 (PID ' + info.pid + ')');
40
+ } else {
41
+ console.log('tfx-hub 미실행');
42
+ }
43
+ \"")
44
+ ```
45
+
46
+ ### status — 상태 확인
47
+
48
+ ```bash
49
+ # HTTP 상태 엔드포인트 조회
50
+ Bash("curl -s http://127.0.0.1:27888/status 2>/dev/null || echo '{\"error\":\"hub 미실행\"}'")
51
+ ```
52
+
53
+ ## 각 CLI 등록 방법
54
+
55
+ 허브 시작 후 각 CLI에 MCP 서버로 등록:
56
+
57
+ ```bash
58
+ # Codex
59
+ codex mcp add tfx-hub --url http://127.0.0.1:27888/mcp
60
+
61
+ # Gemini (settings.json)
62
+ # mcpServers.tfx-hub.url = "http://127.0.0.1:27888/mcp"
63
+
64
+ # Claude
65
+ claude mcp add --transport http tfx-hub http://127.0.0.1:27888/mcp
66
+ ```
67
+
68
+ ## MCP 도구 (8개)
69
+
70
+ | 도구 | 설명 |
71
+ |------|------|
72
+ | `register` | 에이전트 등록 + lease 발급 |
73
+ | `status` | 허브/에이전트/큐 상태 조회 |
74
+ | `publish` | 이벤트/응답 메시지 발행 |
75
+ | `ask` | 다른 에이전트에게 질문 (request/reply) |
76
+ | `poll_messages` | 수신함에서 메시지 폴링 |
77
+ | `handoff` | 작업 인계 |
78
+ | `request_human_input` | 사용자 입력 요청 (CAPTCHA/승인) |
79
+ | `submit_human_input` | 사용자 입력 응답 |
80
+
81
+ ## 상태
82
+
83
+ **dev 전용** — 로컬 테스트 목적. 프로덕션 배포 전 안정화 필요.