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.
- package/README.ko.md +108 -199
- package/README.md +108 -199
- package/bin/triflux.mjs +2415 -1762
- package/hooks/keyword-rules.json +361 -354
- package/hooks/pipeline-stop.mjs +5 -2
- package/hub/assign-callbacks.mjs +136 -136
- package/hub/bridge.mjs +734 -684
- package/hub/delegator/contracts.mjs +38 -38
- package/hub/delegator/index.mjs +14 -14
- package/hub/delegator/schema/delegator-tools.schema.json +250 -250
- package/hub/delegator/service.mjs +302 -118
- package/hub/delegator/tool-definitions.mjs +35 -35
- package/hub/hitl.mjs +67 -67
- package/hub/paths.mjs +28 -0
- package/hub/pipe.mjs +589 -561
- package/hub/pipeline/state.mjs +23 -0
- package/hub/public/dashboard.html +349 -0
- package/hub/public/tray-icon.ico +0 -0
- package/hub/public/tray-icon.png +0 -0
- package/hub/router.mjs +782 -782
- package/hub/schema.sql +40 -40
- package/hub/server.mjs +810 -637
- package/hub/store.mjs +706 -706
- package/hub/team/cli/commands/attach.mjs +37 -0
- package/hub/team/cli/commands/control.mjs +43 -0
- package/hub/team/cli/commands/debug.mjs +74 -0
- package/hub/team/cli/commands/focus.mjs +53 -0
- package/hub/team/cli/commands/interrupt.mjs +36 -0
- package/hub/team/cli/commands/kill.mjs +37 -0
- package/hub/team/cli/commands/list.mjs +24 -0
- package/hub/team/cli/commands/send.mjs +37 -0
- package/hub/team/cli/commands/start/index.mjs +87 -0
- package/hub/team/cli/commands/start/parse-args.mjs +32 -0
- package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
- package/hub/team/cli/commands/start/start-mux.mjs +73 -0
- package/hub/team/cli/commands/start/start-wt.mjs +69 -0
- package/hub/team/cli/commands/status.mjs +87 -0
- package/hub/team/cli/commands/stop.mjs +31 -0
- package/hub/team/cli/commands/task.mjs +30 -0
- package/hub/team/cli/commands/tasks.mjs +13 -0
- package/hub/team/{cli.mjs → cli/help.mjs} +38 -99
- package/hub/team/cli/index.mjs +39 -0
- package/hub/team/cli/manifest.mjs +28 -0
- package/hub/team/cli/render.mjs +30 -0
- package/hub/team/cli/services/attach-fallback.mjs +54 -0
- package/hub/team/cli/services/hub-client.mjs +171 -0
- package/hub/team/cli/services/member-selector.mjs +30 -0
- package/hub/team/cli/services/native-control.mjs +115 -0
- package/hub/team/cli/services/runtime-mode.mjs +60 -0
- package/hub/team/cli/services/state-store.mjs +34 -0
- package/hub/team/cli/services/task-model.mjs +30 -0
- package/hub/team/native-supervisor.mjs +69 -63
- package/hub/team/native.mjs +367 -367
- package/hub/team/nativeProxy.mjs +217 -173
- package/hub/team/pane.mjs +149 -149
- package/hub/team/psmux.mjs +946 -946
- package/hub/team/session.mjs +608 -608
- package/hub/team/staleState.mjs +369 -299
- package/hub/tools.mjs +107 -107
- package/hub/tray.mjs +332 -0
- package/hub/workers/claude-worker.mjs +446 -446
- package/hub/workers/codex-mcp.mjs +414 -414
- package/hub/workers/delegator-mcp.mjs +1045 -1045
- package/hub/workers/factory.mjs +21 -21
- package/hub/workers/gemini-worker.mjs +349 -349
- package/hub/workers/interface.mjs +41 -41
- package/package.json +61 -60
- package/scripts/__tests__/keyword-detector.test.mjs +234 -234
- package/scripts/hub-ensure.mjs +102 -101
- package/scripts/keyword-detector.mjs +272 -272
- package/scripts/keyword-rules-expander.mjs +521 -521
- package/scripts/lib/keyword-rules.mjs +168 -168
- package/scripts/lib/mcp-filter.mjs +642 -642
- package/scripts/lib/mcp-server-catalog.mjs +118 -118
- package/scripts/mcp-check.mjs +126 -126
- package/scripts/preflight-cache.mjs +19 -0
- package/scripts/run.cjs +62 -62
- package/scripts/setup.mjs +68 -31
- package/scripts/test-tfx-route-no-claude-native.mjs +57 -57
- package/scripts/tfx-route-worker.mjs +161 -161
- package/scripts/tfx-route.sh +1360 -1326
- package/skills/tfx-auto/SKILL.md +196 -196
- package/skills/tfx-auto-codex/SKILL.md +77 -77
- package/skills/tfx-multi/SKILL.md +378 -378
- package/hub/team/cli-team-common.mjs +0 -348
- package/hub/team/cli-team-control.mjs +0 -393
- package/hub/team/cli-team-start.mjs +0 -516
- package/hub/team/cli-team-status.mjs +0 -283
- package/skills/auto-verify/SKILL.md +0 -145
- package/skills/manage-skills/SKILL.md +0 -192
- 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
|
+
}
|