triflux 3.3.0-dev.8 → 4.0.0

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.
Files changed (91) hide show
  1. package/README.ko.md +108 -199
  2. package/README.md +108 -199
  3. package/bin/triflux.mjs +2415 -1762
  4. package/hooks/keyword-rules.json +361 -354
  5. package/hooks/pipeline-stop.mjs +5 -2
  6. package/hub/assign-callbacks.mjs +136 -136
  7. package/hub/bridge.mjs +734 -684
  8. package/hub/delegator/contracts.mjs +38 -38
  9. package/hub/delegator/index.mjs +14 -14
  10. package/hub/delegator/schema/delegator-tools.schema.json +250 -250
  11. package/hub/delegator/service.mjs +302 -118
  12. package/hub/delegator/tool-definitions.mjs +35 -35
  13. package/hub/hitl.mjs +67 -67
  14. package/hub/paths.mjs +28 -0
  15. package/hub/pipe.mjs +589 -561
  16. package/hub/pipeline/state.mjs +23 -0
  17. package/hub/public/dashboard.html +349 -0
  18. package/hub/public/tray-icon.ico +0 -0
  19. package/hub/public/tray-icon.png +0 -0
  20. package/hub/router.mjs +782 -782
  21. package/hub/schema.sql +40 -40
  22. package/hub/server.mjs +810 -637
  23. package/hub/store.mjs +706 -706
  24. package/hub/team/cli/commands/attach.mjs +37 -0
  25. package/hub/team/cli/commands/control.mjs +43 -0
  26. package/hub/team/cli/commands/debug.mjs +74 -0
  27. package/hub/team/cli/commands/focus.mjs +53 -0
  28. package/hub/team/cli/commands/interrupt.mjs +36 -0
  29. package/hub/team/cli/commands/kill.mjs +37 -0
  30. package/hub/team/cli/commands/list.mjs +24 -0
  31. package/hub/team/cli/commands/send.mjs +37 -0
  32. package/hub/team/cli/commands/start/index.mjs +87 -0
  33. package/hub/team/cli/commands/start/parse-args.mjs +32 -0
  34. package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
  35. package/hub/team/cli/commands/start/start-mux.mjs +73 -0
  36. package/hub/team/cli/commands/start/start-wt.mjs +69 -0
  37. package/hub/team/cli/commands/status.mjs +87 -0
  38. package/hub/team/cli/commands/stop.mjs +31 -0
  39. package/hub/team/cli/commands/task.mjs +30 -0
  40. package/hub/team/cli/commands/tasks.mjs +13 -0
  41. package/hub/team/{cli.mjs → cli/help.mjs} +38 -99
  42. package/hub/team/cli/index.mjs +39 -0
  43. package/hub/team/cli/manifest.mjs +28 -0
  44. package/hub/team/cli/render.mjs +30 -0
  45. package/hub/team/cli/services/attach-fallback.mjs +54 -0
  46. package/hub/team/cli/services/hub-client.mjs +171 -0
  47. package/hub/team/cli/services/member-selector.mjs +30 -0
  48. package/hub/team/cli/services/native-control.mjs +115 -0
  49. package/hub/team/cli/services/runtime-mode.mjs +60 -0
  50. package/hub/team/cli/services/state-store.mjs +34 -0
  51. package/hub/team/cli/services/task-model.mjs +30 -0
  52. package/hub/team/native-supervisor.mjs +69 -63
  53. package/hub/team/native.mjs +367 -367
  54. package/hub/team/nativeProxy.mjs +217 -173
  55. package/hub/team/pane.mjs +149 -149
  56. package/hub/team/psmux.mjs +946 -946
  57. package/hub/team/session.mjs +608 -608
  58. package/hub/team/staleState.mjs +369 -299
  59. package/hub/tools.mjs +107 -107
  60. package/hub/tray.mjs +332 -0
  61. package/hub/workers/claude-worker.mjs +446 -446
  62. package/hub/workers/codex-mcp.mjs +414 -414
  63. package/hub/workers/delegator-mcp.mjs +1045 -1045
  64. package/hub/workers/factory.mjs +21 -21
  65. package/hub/workers/gemini-worker.mjs +349 -349
  66. package/hub/workers/interface.mjs +41 -41
  67. package/package.json +61 -60
  68. package/scripts/__tests__/keyword-detector.test.mjs +234 -234
  69. package/scripts/hub-ensure.mjs +102 -101
  70. package/scripts/keyword-detector.mjs +272 -272
  71. package/scripts/keyword-rules-expander.mjs +521 -521
  72. package/scripts/lib/keyword-rules.mjs +168 -168
  73. package/scripts/lib/mcp-filter.mjs +642 -642
  74. package/scripts/lib/mcp-server-catalog.mjs +118 -118
  75. package/scripts/mcp-check.mjs +126 -126
  76. package/scripts/preflight-cache.mjs +19 -0
  77. package/scripts/run.cjs +62 -62
  78. package/scripts/setup.mjs +68 -31
  79. package/scripts/test-tfx-route-no-claude-native.mjs +57 -57
  80. package/scripts/tfx-route-worker.mjs +161 -161
  81. package/scripts/tfx-route.sh +1360 -1326
  82. package/skills/tfx-auto/SKILL.md +196 -196
  83. package/skills/tfx-auto-codex/SKILL.md +77 -77
  84. package/skills/tfx-multi/SKILL.md +378 -378
  85. package/hub/team/cli-team-common.mjs +0 -348
  86. package/hub/team/cli-team-control.mjs +0 -393
  87. package/hub/team/cli-team-start.mjs +0 -516
  88. package/hub/team/cli-team-status.mjs +0 -283
  89. package/skills/auto-verify/SKILL.md +0 -145
  90. package/skills/manage-skills/SKILL.md +0 -192
  91. package/skills/verify-implementation/SKILL.md +0 -138
package/hub/tools.mjs CHANGED
@@ -177,9 +177,9 @@ export function createTools(store, router, hitl, pipe = null) {
177
177
  },
178
178
 
179
179
  // ── 6. handoff ──
180
- {
181
- name: 'handoff',
182
- description: '다른 에이전트에게 작업을 인계합니다. acceptance_criteria로 완료 기준 지정 가능',
180
+ {
181
+ name: 'handoff',
182
+ description: '다른 에이전트에게 작업을 인계합니다. acceptance_criteria로 완료 기준 지정 가능',
183
183
  inputSchema: {
184
184
  type: 'object',
185
185
  required: ['from', 'to', 'topic', 'task'],
@@ -196,90 +196,90 @@ export function createTools(store, router, hitl, pipe = null) {
196
196
  correlation_id: { type: 'string' },
197
197
  },
198
198
  },
199
- handler: wrap('HANDOFF_FAILED', (args) => {
200
- return router.handleHandoff(args);
201
- }),
202
- },
203
-
204
- // ── 7. assign_async ──
205
- {
206
- name: 'assign_async',
207
- description: 'AWS CAO 스타일 비차단 assign job을 생성하고 워커에게 실시간 전달합니다',
208
- inputSchema: {
209
- type: 'object',
210
- required: ['supervisor_agent', 'worker_agent', 'task'],
211
- properties: {
212
- supervisor_agent: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
213
- worker_agent: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
214
- topic: { type: 'string', pattern: '^[a-zA-Z0-9._:-]+$', default: 'assign.job' },
215
- task: { type: 'string', minLength: 1, maxLength: 20000 },
216
- payload: { type: 'object' },
217
- priority: { type: 'integer', minimum: 1, maximum: 9, default: 5 },
218
- ttl_ms: { type: 'integer', minimum: 1000, maximum: 86400000, default: 600000 },
219
- timeout_ms: { type: 'integer', minimum: 1000, maximum: 86400000, default: 600000 },
220
- max_retries: { type: 'integer', minimum: 0, maximum: 20, default: 0 },
221
- trace_id: { type: 'string' },
222
- correlation_id: { type: 'string' },
223
- },
224
- },
225
- handler: wrap('ASSIGN_ASYNC_FAILED', (args) => {
226
- return router.assignAsync(args);
227
- }),
228
- },
229
-
230
- // ── 8. assign_result ──
231
- {
232
- name: 'assign_result',
233
- description: 'assign job의 진행/완료 결과를 보고합니다. completed + metadata.result 관례를 지원합니다',
234
- inputSchema: {
235
- type: 'object',
236
- required: ['job_id', 'status'],
237
- properties: {
238
- job_id: { type: 'string', minLength: 1, maxLength: 128 },
239
- worker_agent: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
240
- status: { type: 'string', enum: ['queued', 'running', 'in_progress', 'completed', 'succeeded', 'success', 'failed', 'error', 'timed_out', 'timeout'] },
241
- attempt: { type: 'integer', minimum: 1 },
242
- result: {},
243
- error: {},
244
- metadata: { type: 'object' },
245
- payload: { type: 'object' },
246
- },
247
- },
248
- handler: wrap('ASSIGN_RESULT_FAILED', (args) => {
249
- return router.reportAssignResult(args);
250
- }),
251
- },
252
-
253
- // ── 9. assign_status ──
254
- {
255
- name: 'assign_status',
256
- description: 'assign job 단건 상태 또는 supervisor/worker/status 기준 목록을 조회합니다',
257
- inputSchema: {
258
- type: 'object',
259
- properties: {
260
- job_id: { type: 'string', minLength: 1, maxLength: 128 },
261
- supervisor_agent: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
262
- worker_agent: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
263
- status: { type: 'string', enum: ['queued', 'running', 'succeeded', 'failed', 'timed_out'] },
264
- statuses: {
265
- type: 'array',
266
- items: { type: 'string', enum: ['queued', 'running', 'succeeded', 'failed', 'timed_out'] },
267
- maxItems: 8,
268
- },
269
- trace_id: { type: 'string' },
270
- correlation_id: { type: 'string' },
271
- limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
272
- },
273
- },
274
- handler: wrap('ASSIGN_STATUS_FAILED', (args) => {
275
- return router.getAssignStatus(args);
276
- }),
277
- },
278
-
279
- // ── 10. request_human_input ──
280
- {
281
- name: 'request_human_input',
282
- description: '사용자에게 입력을 요청합니다 (CAPTCHA, 승인, 자격증명, 선택, 텍스트)',
199
+ handler: wrap('HANDOFF_FAILED', (args) => {
200
+ return router.handleHandoff(args);
201
+ }),
202
+ },
203
+
204
+ // ── 7. assign_async ──
205
+ {
206
+ name: 'assign_async',
207
+ description: 'AWS CAO 스타일 비차단 assign job을 생성하고 워커에게 실시간 전달합니다',
208
+ inputSchema: {
209
+ type: 'object',
210
+ required: ['supervisor_agent', 'worker_agent', 'task'],
211
+ properties: {
212
+ supervisor_agent: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
213
+ worker_agent: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
214
+ topic: { type: 'string', pattern: '^[a-zA-Z0-9._:-]+$', default: 'assign.job' },
215
+ task: { type: 'string', minLength: 1, maxLength: 20000 },
216
+ payload: { type: 'object' },
217
+ priority: { type: 'integer', minimum: 1, maximum: 9, default: 5 },
218
+ ttl_ms: { type: 'integer', minimum: 1000, maximum: 86400000, default: 600000 },
219
+ timeout_ms: { type: 'integer', minimum: 1000, maximum: 86400000, default: 600000 },
220
+ max_retries: { type: 'integer', minimum: 0, maximum: 20, default: 0 },
221
+ trace_id: { type: 'string' },
222
+ correlation_id: { type: 'string' },
223
+ },
224
+ },
225
+ handler: wrap('ASSIGN_ASYNC_FAILED', (args) => {
226
+ return router.assignAsync(args);
227
+ }),
228
+ },
229
+
230
+ // ── 8. assign_result ──
231
+ {
232
+ name: 'assign_result',
233
+ description: 'assign job의 진행/완료 결과를 보고합니다. completed + metadata.result 관례를 지원합니다',
234
+ inputSchema: {
235
+ type: 'object',
236
+ required: ['job_id', 'status'],
237
+ properties: {
238
+ job_id: { type: 'string', minLength: 1, maxLength: 128 },
239
+ worker_agent: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
240
+ status: { type: 'string', enum: ['queued', 'running', 'in_progress', 'completed', 'succeeded', 'success', 'failed', 'error', 'timed_out', 'timeout'] },
241
+ attempt: { type: 'integer', minimum: 1 },
242
+ result: {},
243
+ error: {},
244
+ metadata: { type: 'object' },
245
+ payload: { type: 'object' },
246
+ },
247
+ },
248
+ handler: wrap('ASSIGN_RESULT_FAILED', (args) => {
249
+ return router.reportAssignResult(args);
250
+ }),
251
+ },
252
+
253
+ // ── 9. assign_status ──
254
+ {
255
+ name: 'assign_status',
256
+ description: 'assign job 단건 상태 또는 supervisor/worker/status 기준 목록을 조회합니다',
257
+ inputSchema: {
258
+ type: 'object',
259
+ properties: {
260
+ job_id: { type: 'string', minLength: 1, maxLength: 128 },
261
+ supervisor_agent: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
262
+ worker_agent: { type: 'string', pattern: '^[a-zA-Z0-9._:-]{3,64}$' },
263
+ status: { type: 'string', enum: ['queued', 'running', 'succeeded', 'failed', 'timed_out'] },
264
+ statuses: {
265
+ type: 'array',
266
+ items: { type: 'string', enum: ['queued', 'running', 'succeeded', 'failed', 'timed_out'] },
267
+ maxItems: 8,
268
+ },
269
+ trace_id: { type: 'string' },
270
+ correlation_id: { type: 'string' },
271
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
272
+ },
273
+ },
274
+ handler: wrap('ASSIGN_STATUS_FAILED', (args) => {
275
+ return router.getAssignStatus(args);
276
+ }),
277
+ },
278
+
279
+ // ── 10. request_human_input ──
280
+ {
281
+ name: 'request_human_input',
282
+ description: '사용자에게 입력을 요청합니다 (CAPTCHA, 승인, 자격증명, 선택, 텍스트)',
283
283
  inputSchema: {
284
284
  type: 'object',
285
285
  required: ['requester_agent', 'kind', 'prompt', 'requested_schema', 'deadline_ms', 'default_action'],
@@ -295,12 +295,12 @@ export function createTools(store, router, hitl, pipe = null) {
295
295
  correlation_id: { type: 'string' },
296
296
  },
297
297
  },
298
- handler: wrap('HITL_REQUEST_FAILED', (args) => {
299
- return hitl.requestHumanInput(args);
300
- }),
301
- },
302
-
303
- // ── 11. submit_human_input ──
298
+ handler: wrap('HITL_REQUEST_FAILED', (args) => {
299
+ return hitl.requestHumanInput(args);
300
+ }),
301
+ },
302
+
303
+ // ── 11. submit_human_input ──
304
304
  {
305
305
  name: 'submit_human_input',
306
306
  description: '사용자 입력 요청에 응답합니다 (accept, decline, cancel)',
@@ -319,7 +319,7 @@ export function createTools(store, router, hitl, pipe = null) {
319
319
  }),
320
320
  },
321
321
 
322
- // ── 12. team_info ──
322
+ // ── 12. team_info ──
323
323
  {
324
324
  name: 'team_info',
325
325
  description: 'Claude Native Teams 메타/멤버/경로 정보를 조회합니다',
@@ -337,10 +337,10 @@ export function createTools(store, router, hitl, pipe = null) {
337
337
  }),
338
338
  },
339
339
 
340
- // ── 13. team_task_list ──
341
- {
342
- name: 'team_task_list',
343
- description: 'Claude Native Teams task 목록을 owner/status 조건으로 조회합니다. 실패 판정은 completed + metadata.result도 함께 확인해야 합니다',
340
+ // ── 13. team_task_list ──
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'],
@@ -361,10 +361,10 @@ export function createTools(store, router, hitl, pipe = null) {
361
361
  }),
362
362
  },
363
363
 
364
- // ── 14. team_task_update ──
365
- {
366
- name: 'team_task_update',
367
- description: 'Claude Native Teams task를 claim/update 합니다. status: "failed" 입력은 completed + metadata.result="failed"로 정규화됩니다',
364
+ // ── 14. team_task_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'],
@@ -389,7 +389,7 @@ export function createTools(store, router, hitl, pipe = null) {
389
389
  }),
390
390
  },
391
391
 
392
- // ── 15. team_send_message ──
392
+ // ── 15. team_send_message ──
393
393
  {
394
394
  name: 'team_send_message',
395
395
  description: 'Claude Native Teams inbox에 메시지를 append 합니다',
@@ -410,7 +410,7 @@ export function createTools(store, router, hitl, pipe = null) {
410
410
  }),
411
411
  },
412
412
 
413
- // ── 16. pipeline_state ──
413
+ // ── 16. pipeline_state ──
414
414
  {
415
415
  name: 'pipeline_state',
416
416
  description: '파이프라인 상태를 조회합니다 (--thorough 모드)',
@@ -430,7 +430,7 @@ export function createTools(store, router, hitl, pipe = null) {
430
430
  }),
431
431
  },
432
432
 
433
- // ── 17. pipeline_advance ──
433
+ // ── 17. pipeline_advance ──
434
434
  {
435
435
  name: 'pipeline_advance',
436
436
  description: '파이프라인을 다음 단계로 전이합니다 (전이 규칙 + fix loop 바운딩 적용)',
@@ -449,7 +449,7 @@ export function createTools(store, router, hitl, pipe = null) {
449
449
  }),
450
450
  },
451
451
 
452
- // ── 18. pipeline_init ──
452
+ // ── 18. pipeline_init ──
453
453
  {
454
454
  name: 'pipeline_init',
455
455
  description: '새 파이프라인을 초기화합니다 (기존 상태 덮어쓰기)',
@@ -472,7 +472,7 @@ export function createTools(store, router, hitl, pipe = null) {
472
472
  }),
473
473
  },
474
474
 
475
- // ── 19. pipeline_list ──
475
+ // ── 19. pipeline_list ──
476
476
  {
477
477
  name: 'pipeline_list',
478
478
  description: '활성 파이프라인 목록을 조회합니다',
package/hub/tray.mjs ADDED
@@ -0,0 +1,332 @@
1
+ #!/usr/bin/env node
2
+
3
+ import _SysTrayModule from "systray2";
4
+ const SysTray = _SysTrayModule.default || _SysTrayModule;
5
+ import { exec } from "node:child_process";
6
+ import { readFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const DASHBOARD_URL = "http://127.0.0.1:27888/dashboard";
12
+ const HUB_STATUS_URL = "http://127.0.0.1:27888/status";
13
+ const POLL_INTERVAL_MS = 10_000;
14
+ const HUB_TIMEOUT_MS = 3_000;
15
+ const AIMD_INITIAL = 3;
16
+ const AIMD_MIN = 1;
17
+ const AIMD_MAX = 10;
18
+ const AIMD_WINDOW_MS = 30 * 60 * 1000;
19
+
20
+ const CACHE_DIR = join(homedir(), ".claude", "cache");
21
+ const BATCH_EVENTS_FILE = join(CACHE_DIR, "batch-events.jsonl");
22
+ const CODEX_RATE_LIMITS_FILE = join(CACHE_DIR, "codex-rate-limits-cache.json");
23
+ const GEMINI_QUOTA_FILE = join(CACHE_DIR, "gemini-quota-cache.json");
24
+ const CLAUDE_USAGE_FILE = join(CACHE_DIR, "claude-usage-cache.json");
25
+
26
+ const TRAY_ICON_BASE64 = "AAABAAEAICAAAAEAIAADAQAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAAgAAAAIAgGAAAAc3p69AAAAMpJREFUeJzV1UEKgzAQheEcwnXP4a17gl6n6yy7U1IIqDSTeW/m0TYwK8X/E6OW8m9rWW5Pa74SlWLYeBgRDcOQ7V42a+SIGSALkwKIQKDnroJQmy4bAgO8EDnAA4EBkV3do7VWeAOfAO0Cx/EC+vnMG2QCLND12Pp4vUcK+DQ9fBxqI47uDI23oV7F2fNVxF2A64zCTBwCWGE2Pv0WzKKp8ba8wWg4DIiGKUBWdBjP+i+E4z8BUCJccRUCimdC6HAGIiWOYiRR5doBauXshzcEs0UAAAAASUVORK5CYII=";
27
+
28
+ function clampPercent(value) {
29
+ if (!Number.isFinite(Number(value))) return null;
30
+ return Math.max(0, Math.min(100, Math.round(Number(value))));
31
+ }
32
+
33
+ function readJson(filePath) {
34
+ try {
35
+ return JSON.parse(readFileSync(filePath, "utf8"));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function readLines(filePath) {
42
+ try {
43
+ return readFileSync(filePath, "utf8").split(/\r?\n/).filter(Boolean);
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ function isSuccessResult(result) {
50
+ return result === "success" || result === "success_with_warnings";
51
+ }
52
+
53
+ function getAimdBatchSize(now = Date.now()) {
54
+ const sinceMs = now - AIMD_WINDOW_MS;
55
+ const events = readLines(BATCH_EVENTS_FILE)
56
+ .map((line) => {
57
+ try {
58
+ return JSON.parse(line);
59
+ } catch {
60
+ return null;
61
+ }
62
+ })
63
+ .filter((event) => event && Number(event.ts) >= sinceMs);
64
+
65
+ if (events.length === 0) return AIMD_INITIAL;
66
+
67
+ let batchSize = AIMD_INITIAL;
68
+ for (const event of events) {
69
+ if (isSuccessResult(event.result)) {
70
+ batchSize = Math.min(AIMD_MAX, batchSize + 1);
71
+ } else {
72
+ batchSize = Math.max(AIMD_MIN, Math.floor(batchSize * 0.5));
73
+ }
74
+ }
75
+ return batchSize;
76
+ }
77
+
78
+ function getCodexPercent() {
79
+ const data = readJson(CODEX_RATE_LIMITS_FILE);
80
+ const buckets = data?.buckets && typeof data.buckets === "object" ? data.buckets : null;
81
+ const primaryBucket = buckets?.codex ?? Object.values(buckets ?? {})[0] ?? null;
82
+ return clampPercent(primaryBucket?.primary?.used_percent);
83
+ }
84
+
85
+ function pickGeminiBucket(data) {
86
+ const buckets = Array.isArray(data?.buckets) ? data.buckets : [];
87
+ if (buckets.length === 0) return null;
88
+
89
+ const preferredModels = [
90
+ "gemini-3-flash-preview",
91
+ "gemini-2.5-flash",
92
+ "gemini-3-flash",
93
+ "gemini-2.5-flash-lite",
94
+ ];
95
+
96
+ for (const modelId of preferredModels) {
97
+ const match = buckets.find((bucket) => bucket?.modelId === modelId);
98
+ if (match) return match;
99
+ }
100
+
101
+ return buckets.find((bucket) => String(bucket?.modelId ?? "").includes("flash")) ?? buckets[0];
102
+ }
103
+
104
+ function getGeminiPercent() {
105
+ const data = readJson(GEMINI_QUOTA_FILE);
106
+ const bucket = pickGeminiBucket(data);
107
+ if (!bucket) return null;
108
+ return clampPercent((1 - Number(bucket.remainingFraction ?? 1)) * 100);
109
+ }
110
+
111
+ function getClaudePercent() {
112
+ const data = readJson(CLAUDE_USAGE_FILE);
113
+ return clampPercent(data?.data?.fiveHourPercent);
114
+ }
115
+
116
+ async function getHubStatusLabel() {
117
+ try {
118
+ const response = await fetch(HUB_STATUS_URL, {
119
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
120
+ });
121
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
122
+
123
+ const data = await response.json();
124
+ const state = typeof data?.hub?.state === "string" ? data.hub.state : "connected";
125
+ const sessions = Number.isFinite(Number(data?.sessions)) ? Number(data.sessions) : null;
126
+ return sessions == null ? `Hub: ${state}` : `Hub: ${state} | S:${sessions}`;
127
+ } catch {
128
+ return "Hub 미연결";
129
+ }
130
+ }
131
+
132
+ function formatTooltipPercent(value) {
133
+ return value == null ? "--%" : `${value}%`;
134
+ }
135
+
136
+ function formatMenuPercent(value) {
137
+ return value == null ? "--%" : `${value}%`;
138
+ }
139
+
140
+ function buildTooltip(snapshot) {
141
+ return `tfx AIMD:${snapshot.aimd}/10 | C:${formatTooltipPercent(snapshot.claude)} X:${formatTooltipPercent(snapshot.codex)} G:${formatTooltipPercent(snapshot.gemini)}`;
142
+ }
143
+
144
+ function buildUsageTitle(snapshot) {
145
+ return `C: ${formatMenuPercent(snapshot.claude)} | X: ${formatMenuPercent(snapshot.codex)} | G: ${formatMenuPercent(snapshot.gemini)}`;
146
+ }
147
+
148
+ function openDashboard() {
149
+ exec(`start "" "${DASHBOARD_URL}"`, {
150
+ shell: process.env.ComSpec || "cmd.exe",
151
+ windowsHide: true,
152
+ }, () => {});
153
+ }
154
+
155
+ const openDashboardItem = {
156
+ title: "대시보드 열기",
157
+ tooltip: "브라우저에서 대시보드 열기",
158
+ enabled: true,
159
+ click: openDashboard,
160
+ };
161
+
162
+ const aimdItem = {
163
+ title: "AIMD: 3/10",
164
+ tooltip: "최근 30분 AIMD 동시 워커",
165
+ enabled: false,
166
+ };
167
+
168
+ const quotaItem = {
169
+ title: "C: --% | X: --% | G: --%",
170
+ tooltip: "Claude | Codex | Gemini 사용률",
171
+ enabled: false,
172
+ };
173
+
174
+ const hubItem = {
175
+ title: "Hub 미연결",
176
+ tooltip: "Hub 연결 상태",
177
+ enabled: false,
178
+ };
179
+
180
+ const refreshItem = {
181
+ title: "새로고침",
182
+ tooltip: "캐시 재읽기",
183
+ enabled: true,
184
+ click: () => {
185
+ void scheduleRefresh();
186
+ },
187
+ };
188
+
189
+ const exitItem = {
190
+ title: "종료",
191
+ tooltip: "트레이 종료",
192
+ enabled: true,
193
+ click: () => {
194
+ void shutdown("menu-exit");
195
+ },
196
+ };
197
+
198
+ const menu = {
199
+ icon: TRAY_ICON_BASE64,
200
+ title: "tfx",
201
+ tooltip: "tfx AIMD:3/10 | C:--% X:--% G:--%",
202
+ items: [
203
+ openDashboardItem,
204
+ SysTray.separator,
205
+ aimdItem,
206
+ quotaItem,
207
+ hubItem,
208
+ SysTray.separator,
209
+ refreshItem,
210
+ exitItem,
211
+ ],
212
+ };
213
+
214
+ let systray = null;
215
+ let pollTimer = null;
216
+ let refreshPromise = null;
217
+ let shuttingDown = false;
218
+
219
+ async function refreshMenu() {
220
+ const snapshot = {
221
+ aimd: getAimdBatchSize(),
222
+ codex: getCodexPercent(),
223
+ gemini: getGeminiPercent(),
224
+ claude: getClaudePercent(),
225
+ hubLabel: await getHubStatusLabel(),
226
+ };
227
+
228
+ aimdItem.title = `AIMD: ${snapshot.aimd}/10`;
229
+ quotaItem.title = buildUsageTitle(snapshot);
230
+ hubItem.title = snapshot.hubLabel;
231
+
232
+ if (systray) {
233
+ await systray.sendAction({ type: "update-item", item: aimdItem });
234
+ await systray.sendAction({ type: "update-item", item: quotaItem });
235
+ await systray.sendAction({ type: "update-item", item: hubItem });
236
+ await systray.sendAction({ type: "update-item-and-title", item: { title: buildTooltip(snapshot) } });
237
+ }
238
+ }
239
+
240
+ function scheduleRefresh() {
241
+ if (refreshPromise) return refreshPromise;
242
+ refreshPromise = refreshMenu().catch((error) => {
243
+ console.error(`[tfx-tray] refresh failed: ${error.message}`);
244
+ }).finally(() => {
245
+ refreshPromise = null;
246
+ });
247
+ return refreshPromise;
248
+ }
249
+
250
+ async function shutdown(reason = "shutdown") {
251
+ if (shuttingDown) return;
252
+ shuttingDown = true;
253
+
254
+ if (pollTimer) {
255
+ clearInterval(pollTimer);
256
+ pollTimer = null;
257
+ }
258
+
259
+ try {
260
+ if (systray && !systray.killed) {
261
+ await systray.kill(false);
262
+ }
263
+ } catch (error) {
264
+ console.error(`[tfx-tray] ${reason} cleanup failed: ${error.message}`);
265
+ } finally {
266
+ systray = null;
267
+ process.exit(0);
268
+ }
269
+ }
270
+
271
+ export async function startTray() {
272
+ if (process.platform !== "win32") {
273
+ throw new Error("tray command is only supported on Windows.");
274
+ }
275
+
276
+ systray = new SysTray({
277
+ menu,
278
+ debug: false,
279
+ copyDir: false,
280
+ });
281
+
282
+ await systray.ready();
283
+
284
+ systray.onError((error) => {
285
+ console.error(`[tfx-tray] ${error.message}`);
286
+ });
287
+
288
+ systray.onExit((code, signal) => {
289
+ if (shuttingDown) return;
290
+ const detail = signal ? `signal ${signal}` : `code ${code ?? 0}`;
291
+ console.error(`[tfx-tray] tray exited unexpectedly (${detail})`);
292
+ process.exit(typeof code === "number" ? code : 1);
293
+ });
294
+ await systray.onClick((action) => {
295
+ if (action.item?.click) {
296
+ action.item.click();
297
+ return;
298
+ }
299
+
300
+ if (action.item?.__id === openDashboardItem.__id) {
301
+ openDashboard();
302
+ }
303
+ });
304
+
305
+ await scheduleRefresh();
306
+
307
+ pollTimer = setInterval(() => {
308
+ void scheduleRefresh();
309
+ }, POLL_INTERVAL_MS);
310
+ pollTimer.unref();
311
+
312
+ process.on("SIGINT", () => {
313
+ void shutdown("SIGINT");
314
+ });
315
+ process.on("SIGTERM", () => {
316
+ void shutdown("SIGTERM");
317
+ });
318
+
319
+ return {
320
+ systray,
321
+ stop: shutdown,
322
+ };
323
+ }
324
+
325
+ const selfRun = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
326
+
327
+ if (selfRun) {
328
+ startTray().catch((error) => {
329
+ console.error(`[tfx-tray] start failed: ${error.message}`);
330
+ process.exit(1);
331
+ });
332
+ }