triflux 3.3.0-dev.1 → 3.3.0-dev.3
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/bin/triflux.mjs +169 -39
- package/hooks/hooks.json +5 -0
- package/hub/pipe.mjs +23 -0
- package/hub/router.mjs +322 -1
- package/hub/schema.sql +40 -7
- package/hub/server.mjs +95 -0
- package/hub/store.mjs +259 -1
- package/hub/team/native.mjs +200 -190
- package/hub/team/psmux.mjs +555 -115
- package/hub/tools.mjs +101 -26
- package/hub/workers/delegator-mcp.mjs +900 -0
- package/hub/workers/factory.mjs +3 -0
- package/hub/workers/interface.mjs +2 -2
- package/hud/hud-qos-status.mjs +1735 -1790
- package/package.json +1 -1
- package/scripts/__tests__/keyword-detector.test.mjs +3 -3
- package/scripts/__tests__/smoke.test.mjs +34 -0
- package/scripts/hub-ensure.mjs +21 -3
- package/scripts/setup.mjs +15 -10
package/bin/triflux.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync, rmSync, statSync } from "fs";
|
|
4
4
|
import { join, dirname } from "path";
|
|
5
5
|
import { homedir } from "os";
|
|
6
|
-
import { execSync, spawn } from "child_process";
|
|
6
|
+
import { execSync, execFileSync, spawn } from "child_process";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
import { setTimeout as delay } from "node:timers/promises";
|
|
9
9
|
import { detectMultiplexer, getSessionAttachedCount, killSession, listSessions, tmuxExec } from "../hub/team/session.mjs";
|
|
@@ -64,24 +64,23 @@ function section(title) { console.log(`\n ${AMBER}▸${RESET} ${BOLD}${title}${
|
|
|
64
64
|
|
|
65
65
|
function which(cmd) {
|
|
66
66
|
try {
|
|
67
|
-
const result =
|
|
68
|
-
|
|
69
|
-
{ encoding: "utf8", timeout: 5000 }
|
|
70
|
-
|
|
71
|
-
return result.split(/\r?\n/)[0] || null;
|
|
67
|
+
const result = process.platform === "win32"
|
|
68
|
+
? execFileSync("where", [cmd], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"] })
|
|
69
|
+
: execFileSync("which", [cmd], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"] });
|
|
70
|
+
return result.trim().split(/\r?\n/)[0] || null;
|
|
72
71
|
} catch { return null; }
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
function whichInShell(cmd, shell) {
|
|
76
|
-
const
|
|
77
|
-
bash:
|
|
78
|
-
cmd:
|
|
79
|
-
pwsh:
|
|
75
|
+
const shellArgs = {
|
|
76
|
+
bash: ["bash", ["-c", `source ~/.bashrc 2>/dev/null && command -v "${cmd}" 2>/dev/null`]],
|
|
77
|
+
cmd: ["cmd", ["/c", "where", cmd]],
|
|
78
|
+
pwsh: ["pwsh", ["-NoProfile", "-c", `(Get-Command '${cmd.replace(/'/g, "''")}' -EA SilentlyContinue).Source`]],
|
|
80
79
|
};
|
|
81
|
-
const
|
|
82
|
-
if (!
|
|
80
|
+
const entry = shellArgs[shell];
|
|
81
|
+
if (!entry) return null;
|
|
83
82
|
try {
|
|
84
|
-
const result =
|
|
83
|
+
const result = execFileSync(entry[0], entry[1], {
|
|
85
84
|
encoding: "utf8",
|
|
86
85
|
timeout: 8000,
|
|
87
86
|
stdio: ["pipe", "pipe", "ignore"],
|
|
@@ -513,22 +512,22 @@ async function cmdDoctor(options = {}) {
|
|
|
513
512
|
const mcpCheck = join(PKG_ROOT, "scripts", "mcp-check.mjs");
|
|
514
513
|
if (existsSync(mcpCheck)) {
|
|
515
514
|
try {
|
|
516
|
-
|
|
515
|
+
execFileSync(process.execPath, [mcpCheck], { timeout: 15000, stdio: "ignore" });
|
|
517
516
|
ok("MCP 인벤토리 재생성됨");
|
|
518
517
|
} catch { warn("MCP 인벤토리 재생성 실패 — 다음 세션에서 자동 재시도"); }
|
|
519
518
|
}
|
|
520
519
|
const hudScript = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
|
|
521
520
|
if (existsSync(hudScript)) {
|
|
522
521
|
try {
|
|
523
|
-
|
|
522
|
+
execFileSync(process.execPath, [hudScript, "--refresh-claude-usage"], { timeout: 20000, stdio: "ignore" });
|
|
524
523
|
ok("Claude 사용량 캐시 재생성됨");
|
|
525
524
|
} catch { warn("Claude 사용량 캐시 재생성 실패 — 다음 API 호출 시 자동 생성"); }
|
|
526
525
|
try {
|
|
527
|
-
|
|
526
|
+
execFileSync(process.execPath, [hudScript, "--refresh-codex-rate-limits"], { timeout: 15000, stdio: "ignore" });
|
|
528
527
|
ok("Codex 레이트 리밋 캐시 재생성됨");
|
|
529
528
|
} catch { warn("Codex 레이트 리밋 캐시 재생성 실패"); }
|
|
530
529
|
try {
|
|
531
|
-
|
|
530
|
+
execFileSync(process.execPath, [hudScript, "--refresh-gemini-quota"], { timeout: 15000, stdio: "ignore" });
|
|
532
531
|
ok("Gemini 쿼터 캐시 재생성됨");
|
|
533
532
|
} catch { warn("Gemini 쿼터 캐시 재생성 실패"); }
|
|
534
533
|
}
|
|
@@ -874,8 +873,8 @@ async function cmdDoctor(options = {}) {
|
|
|
874
873
|
}
|
|
875
874
|
}
|
|
876
875
|
|
|
877
|
-
// 13.
|
|
878
|
-
section("
|
|
876
|
+
// 13. Stale Teams (Claude teams/ + tasks/ 자동 감지)
|
|
877
|
+
section("Stale Teams");
|
|
879
878
|
const teamsDir = join(CLAUDE_DIR, "teams");
|
|
880
879
|
const tasksDir = join(CLAUDE_DIR, "tasks");
|
|
881
880
|
if (existsSync(teamsDir)) {
|
|
@@ -886,24 +885,136 @@ async function cmdDoctor(options = {}) {
|
|
|
886
885
|
if (teamDirs.length === 0) {
|
|
887
886
|
ok("잔존 팀 없음");
|
|
888
887
|
} else {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
888
|
+
const nowMs = Date.now();
|
|
889
|
+
const staleMaxAgeMs = STALE_TEAM_MAX_AGE_SEC * 1000;
|
|
890
|
+
const staleTeams = [];
|
|
891
|
+
const activeTeams = [];
|
|
892
|
+
|
|
893
|
+
for (const d of teamDirs) {
|
|
894
|
+
const teamPath = join(teamsDir, d);
|
|
895
|
+
const configPath = join(teamPath, "config.json");
|
|
896
|
+
let teamConfig = null;
|
|
897
|
+
let configMtimeMs = null;
|
|
898
|
+
|
|
899
|
+
// config.json 읽기 — createdAt 또는 mtime으로 나이 판정
|
|
900
|
+
try {
|
|
901
|
+
const configStat = statSync(configPath);
|
|
902
|
+
configMtimeMs = configStat.mtimeMs;
|
|
903
|
+
teamConfig = JSON.parse(readFileSync(configPath, "utf8"));
|
|
904
|
+
} catch {
|
|
905
|
+
// config.json 없으면 디렉토리 mtime 사용
|
|
906
|
+
try { configMtimeMs = statSync(teamPath).mtimeMs; } catch {}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const createdAtMs = teamConfig?.createdAt ?? configMtimeMs;
|
|
910
|
+
const ageMs = createdAtMs != null ? Math.max(0, nowMs - createdAtMs) : null;
|
|
911
|
+
const ageSec = ageMs != null ? Math.floor(ageMs / 1000) : null;
|
|
912
|
+
const aged = ageMs != null && ageMs >= staleMaxAgeMs;
|
|
913
|
+
|
|
914
|
+
// 활성 멤버 확인 — leadSessionId 또는 멤버 agentId로 프로세스 검색
|
|
915
|
+
let hasActiveMember = false;
|
|
916
|
+
if (teamConfig?.members?.length > 0) {
|
|
917
|
+
const searchTokens = [];
|
|
918
|
+
if (teamConfig.leadSessionId) searchTokens.push(teamConfig.leadSessionId.toLowerCase());
|
|
919
|
+
if (teamConfig.name) searchTokens.push(teamConfig.name.toLowerCase());
|
|
920
|
+
for (const member of teamConfig.members) {
|
|
921
|
+
if (member.agentId) searchTokens.push(member.agentId.split("@")[0].toLowerCase());
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// tmux 세션 이름과 매칭
|
|
925
|
+
const liveSessionNames = teamSessionReport.sessions.map(s => s.sessionName.toLowerCase());
|
|
926
|
+
hasActiveMember = searchTokens.some(token =>
|
|
927
|
+
liveSessionNames.some(name => name.includes(token))
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
// 프로세스 명령줄에서 세션 ID 매칭 (tmux 없는 in-process 팀 지원)
|
|
931
|
+
if (!hasActiveMember && teamConfig.leadSessionId) {
|
|
932
|
+
try {
|
|
933
|
+
const sessionToken = teamConfig.leadSessionId.toLowerCase();
|
|
934
|
+
// Claude Code 프로세스에서 세션 ID 검색
|
|
935
|
+
if (process.platform === "win32") {
|
|
936
|
+
const psOut = execSync(
|
|
937
|
+
`powershell -NoProfile -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match '${teamConfig.leadSessionId.slice(0, 8)}' } | Select-Object ProcessId | ConvertTo-Json -Compress"`,
|
|
938
|
+
{ encoding: "utf8", timeout: 8000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
|
|
939
|
+
).trim();
|
|
940
|
+
if (psOut && psOut !== "null") {
|
|
941
|
+
const parsed = JSON.parse(psOut);
|
|
942
|
+
const procs = Array.isArray(parsed) ? parsed : [parsed];
|
|
943
|
+
hasActiveMember = procs.some(p => p.ProcessId > 0);
|
|
944
|
+
}
|
|
945
|
+
} else {
|
|
946
|
+
const psOut = execSync(
|
|
947
|
+
`ps -ax -o pid=,command= | grep -i '${teamConfig.leadSessionId.slice(0, 8)}' | grep -v grep`,
|
|
948
|
+
{ encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] },
|
|
949
|
+
).trim();
|
|
950
|
+
hasActiveMember = psOut.length > 0;
|
|
951
|
+
}
|
|
952
|
+
} catch {
|
|
953
|
+
// 프로세스 검색 실패 — stale로 간주하지 않음 (보수적)
|
|
954
|
+
}
|
|
901
955
|
}
|
|
902
956
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
957
|
+
|
|
958
|
+
const stale = aged && !hasActiveMember;
|
|
959
|
+
const teamEntry = {
|
|
960
|
+
name: d,
|
|
961
|
+
teamName: teamConfig?.name || d,
|
|
962
|
+
description: teamConfig?.description || null,
|
|
963
|
+
memberCount: teamConfig?.members?.length || 0,
|
|
964
|
+
ageSec,
|
|
965
|
+
stale,
|
|
966
|
+
hasActiveMember,
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
if (stale) {
|
|
970
|
+
staleTeams.push(teamEntry);
|
|
971
|
+
} else {
|
|
972
|
+
activeTeams.push(teamEntry);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// 활성 팀 표시
|
|
977
|
+
for (const t of activeTeams) {
|
|
978
|
+
const ageLabel = formatElapsedAge(t.ageSec);
|
|
979
|
+
const memberLabel = `${t.memberCount}명`;
|
|
980
|
+
ok(`${t.name}: 활성 (경과=${ageLabel}, 멤버=${memberLabel})`);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// stale 팀 표시 및 정리
|
|
984
|
+
if (staleTeams.length === 0 && activeTeams.length > 0) {
|
|
985
|
+
ok("stale 팀 없음");
|
|
986
|
+
} else if (staleTeams.length > 0) {
|
|
987
|
+
warn(`${staleTeams.length}개 stale 팀 발견`);
|
|
988
|
+
for (const t of staleTeams) {
|
|
989
|
+
const ageLabel = formatElapsedAge(t.ageSec);
|
|
990
|
+
warn(`${t.name}: stale (경과=${ageLabel}, 멤버=${t.memberCount}명, 활성 프로세스 없음)`);
|
|
991
|
+
if (t.description) info(`설명: ${t.description}`);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (fix) {
|
|
995
|
+
let cleaned = 0;
|
|
996
|
+
for (const t of staleTeams) {
|
|
997
|
+
try {
|
|
998
|
+
rmSync(join(teamsDir, t.name), { recursive: true, force: true });
|
|
999
|
+
cleaned++;
|
|
1000
|
+
ok(`stale 팀 정리: ${t.name}`);
|
|
1001
|
+
} catch (e) {
|
|
1002
|
+
fail(`팀 정리 실패: ${t.name} — ${e.message}`);
|
|
1003
|
+
}
|
|
1004
|
+
// 연관 tasks 디렉토리도 정리
|
|
1005
|
+
const taskDir = join(tasksDir, t.name);
|
|
1006
|
+
if (existsSync(taskDir)) {
|
|
1007
|
+
try {
|
|
1008
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
1009
|
+
ok(`연관 tasks 정리: ${t.name}`);
|
|
1010
|
+
} catch {}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
info(`${cleaned}/${staleTeams.length}개 stale 팀 정리 완료`);
|
|
1014
|
+
} else {
|
|
1015
|
+
info("정리: tfx doctor --fix");
|
|
1016
|
+
issues += staleTeams.length;
|
|
1017
|
+
}
|
|
907
1018
|
}
|
|
908
1019
|
}
|
|
909
1020
|
} catch (e) {
|
|
@@ -1246,6 +1357,24 @@ async function cmdCodexTeam() {
|
|
|
1246
1357
|
}
|
|
1247
1358
|
}
|
|
1248
1359
|
|
|
1360
|
+
// ── Hub preflight 체크 (multi/auto 실행 전) ──
|
|
1361
|
+
|
|
1362
|
+
async function checkHubRunning() {
|
|
1363
|
+
const port = Number(process.env.TFX_HUB_PORT || "27888");
|
|
1364
|
+
try {
|
|
1365
|
+
const res = await fetch(`http://127.0.0.1:${port}/status`, {
|
|
1366
|
+
signal: AbortSignal.timeout(2000),
|
|
1367
|
+
});
|
|
1368
|
+
if (res.ok) return true;
|
|
1369
|
+
} catch {}
|
|
1370
|
+
console.log("");
|
|
1371
|
+
warn(`${AMBER}tfx-hub${RESET}가 실행되고 있지 않습니다.`);
|
|
1372
|
+
info(`Hub 없이 실행하면 Claude 네이티브 에이전트로 폴백되어 토큰이 소비됩니다.`);
|
|
1373
|
+
info(`Codex(무료) 위임을 활용하려면 먼저 Hub를 시작하세요:\n`);
|
|
1374
|
+
console.log(` ${WHITE_BRIGHT}tfx hub start${RESET}\n`);
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1249
1378
|
// ── hub 서브커맨드 ──
|
|
1250
1379
|
|
|
1251
1380
|
const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
|
|
@@ -1268,7 +1397,7 @@ function stopHubForUpdate() {
|
|
|
1268
1397
|
|
|
1269
1398
|
try {
|
|
1270
1399
|
if (process.platform === "win32") {
|
|
1271
|
-
|
|
1400
|
+
execFileSync("taskkill", ["/PID", String(info.pid), "/T", "/F"], {
|
|
1272
1401
|
stdio: ["pipe", "pipe", "ignore"],
|
|
1273
1402
|
timeout: 10000,
|
|
1274
1403
|
});
|
|
@@ -1315,7 +1444,7 @@ function autoRegisterMcp(mcpUrl) {
|
|
|
1315
1444
|
if (list.includes("tfx-hub")) {
|
|
1316
1445
|
ok("Codex: 이미 등록됨");
|
|
1317
1446
|
} else {
|
|
1318
|
-
|
|
1447
|
+
execFileSync("codex", ["mcp", "add", "tfx-hub", "--url", mcpUrl], { timeout: 10000, stdio: "ignore" });
|
|
1319
1448
|
ok("Codex: MCP 등록 완료");
|
|
1320
1449
|
}
|
|
1321
1450
|
} catch {
|
|
@@ -1589,19 +1718,20 @@ switch (cmd) {
|
|
|
1589
1718
|
case "list": case "ls": cmdList(); break;
|
|
1590
1719
|
case "hub": await cmdHub(); break;
|
|
1591
1720
|
case "multi": {
|
|
1721
|
+
await checkHubRunning();
|
|
1592
1722
|
const { pathToFileURL } = await import("node:url");
|
|
1593
1723
|
const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);
|
|
1594
1724
|
await cmdTeam();
|
|
1595
1725
|
break;
|
|
1596
1726
|
}
|
|
1597
1727
|
case "codex-team":
|
|
1728
|
+
await checkHubRunning();
|
|
1598
1729
|
await cmdCodexTeam();
|
|
1599
1730
|
break;
|
|
1600
1731
|
case "notion-read": case "nr": {
|
|
1601
1732
|
const scriptPath = join(PKG_ROOT, "scripts", "notion-read.mjs");
|
|
1602
|
-
const nrArgs = process.argv.slice(3).map(a => `"${a}"`).join(" ");
|
|
1603
1733
|
try {
|
|
1604
|
-
|
|
1734
|
+
execFileSync(process.execPath, [scriptPath, ...process.argv.slice(3)], { stdio: "inherit", timeout: 660000 });
|
|
1605
1735
|
} catch (e) { process.exit(e.status || 1); }
|
|
1606
1736
|
break;
|
|
1607
1737
|
}
|
package/hooks/hooks.json
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/setup.mjs\"",
|
|
11
11
|
"timeout": 10
|
|
12
12
|
},
|
|
13
|
+
{
|
|
14
|
+
"type": "command",
|
|
15
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hub-ensure.mjs\"",
|
|
16
|
+
"timeout": 8
|
|
17
|
+
},
|
|
13
18
|
{
|
|
14
19
|
"type": "command",
|
|
15
20
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/preflight-cache.mjs\"",
|
package/hub/pipe.mjs
CHANGED
|
@@ -173,6 +173,24 @@ export function createPipeServer({
|
|
|
173
173
|
return result;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
case 'assign': {
|
|
177
|
+
const result = router.assignAsync(payload);
|
|
178
|
+
if (client) touchClient(client);
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
case 'assign_result': {
|
|
183
|
+
const result = router.reportAssignResult(payload);
|
|
184
|
+
if (client) touchClient(client);
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case 'assign_retry': {
|
|
189
|
+
const result = router.retryAssign(payload.job_id, payload);
|
|
190
|
+
if (client) touchClient(client);
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
176
194
|
case 'result': {
|
|
177
195
|
const result = router.handlePublish({
|
|
178
196
|
from: payload.agent_id,
|
|
@@ -282,6 +300,11 @@ export function createPipeServer({
|
|
|
282
300
|
return router.getStatus(scope, payload);
|
|
283
301
|
}
|
|
284
302
|
|
|
303
|
+
case 'assign_status': {
|
|
304
|
+
if (client) touchClient(client);
|
|
305
|
+
return router.getAssignStatus(payload);
|
|
306
|
+
}
|
|
307
|
+
|
|
285
308
|
default:
|
|
286
309
|
return {
|
|
287
310
|
ok: false,
|