triflux 3.3.0-dev.1 → 3.3.0-dev.5

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 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 = execSync(
68
- process.platform === "win32" ? `where ${cmd} 2>nul` : `which ${cmd} 2>/dev/null`,
69
- { encoding: "utf8", timeout: 5000 }
70
- ).trim();
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 cmds = {
77
- bash: `bash -c "source ~/.bashrc 2>/dev/null && command -v ${cmd} 2>/dev/null"`,
78
- cmd: `cmd /c where ${cmd} 2>nul`,
79
- pwsh: `pwsh -NoProfile -c "(Get-Command ${cmd} -EA SilentlyContinue).Source"`,
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 command = cmds[shell];
82
- if (!command) return null;
80
+ const entry = shellArgs[shell];
81
+ if (!entry) return null;
83
82
  try {
84
- const result = execSync(command, {
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
- execSync(`"${process.execPath}" "${mcpCheck}"`, { timeout: 15000, stdio: "ignore" });
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
- execSync(`"${process.execPath}" "${hudScript}" --refresh-claude-usage`, { timeout: 20000, stdio: "ignore" });
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
- execSync(`"${process.execPath}" "${hudScript}" --refresh-codex-rate-limits`, { timeout: 15000, stdio: "ignore" });
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
- execSync(`"${process.execPath}" "${hudScript}" --refresh-gemini-quota`, { timeout: 15000, stdio: "ignore" });
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. Orphan Teams
878
- section("Orphan Teams");
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
- warn(`${teamDirs.length}개 잔존 발견: ${teamDirs.join(", ")}`);
890
- if (fix) {
891
- let cleaned = 0;
892
- for (const d of teamDirs) {
893
- try {
894
- rmSync(join(teamsDir, d), { recursive: true, force: true });
895
- cleaned++;
896
- } catch {}
897
- // 연관 tasks 디렉토리도 정리
898
- const taskDir = join(tasksDir, d);
899
- if (existsSync(taskDir)) {
900
- try { rmSync(taskDir, { recursive: true, force: true }); } catch {}
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
- ok(`${cleaned}개 잔존 팀 정리 완료`);
904
- } else {
905
- info("정리: /tfx-doctor --fix 또는 수동 rm -rf ~/.claude/teams/{name}/");
906
- issues++;
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
- execSync(`taskkill /PID ${info.pid} /T /F`, {
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
- execSync(`codex mcp add tfx-hub --url ${mcpUrl}`, { timeout: 10000, stdio: "ignore" });
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
- execSync(`"${process.execPath}" "${scriptPath}" ${nrArgs}`, { stdio: "inherit", timeout: 660000 });
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\"",
@@ -0,0 +1,136 @@
1
+ // hub/assign-callbacks.mjs — assign job 상태 변경용 Named Pipe/Unix socket 브로드캐스터
2
+
3
+ import net from 'node:net';
4
+ import { existsSync, unlinkSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ export function getAssignCallbackPipePath(sessionId = process.pid) {
8
+ if (process.platform === 'win32') {
9
+ return `\\\\.\\pipe\\triflux-assign-callback-${sessionId}`;
10
+ }
11
+ return join('/tmp', `triflux-assign-callback-${sessionId}.sock`);
12
+ }
13
+
14
+ function buildAssignCallbackEvent(event = {}, row = null) {
15
+ const source = row || event || {};
16
+ const updatedAtMs = Number(source.updated_at_ms);
17
+ const createdAtMs = Number(source.created_at_ms);
18
+ const timestampMs = Number.isFinite(updatedAtMs)
19
+ ? updatedAtMs
20
+ : (Number.isFinite(createdAtMs) ? createdAtMs : Date.now());
21
+
22
+ return {
23
+ event: 'assign_job_status',
24
+ job_id: source.job_id || event.job_id || null,
25
+ supervisor_agent: source.supervisor_agent || null,
26
+ worker_agent: source.worker_agent || null,
27
+ topic: source.topic || null,
28
+ task: source.task || null,
29
+ status: source.status || event.status || null,
30
+ attempt: Number.isFinite(Number(source.attempt)) ? Number(source.attempt) : null,
31
+ retry_count: Number.isFinite(Number(source.retry_count)) ? Number(source.retry_count) : null,
32
+ max_retries: Number.isFinite(Number(source.max_retries)) ? Number(source.max_retries) : null,
33
+ priority: Number.isFinite(Number(source.priority)) ? Number(source.priority) : null,
34
+ ttl_ms: Number.isFinite(Number(source.ttl_ms)) ? Number(source.ttl_ms) : null,
35
+ timeout_ms: Number.isFinite(Number(source.timeout_ms)) ? Number(source.timeout_ms) : null,
36
+ deadline_ms: Number.isFinite(Number(source.deadline_ms)) ? Number(source.deadline_ms) : null,
37
+ trace_id: source.trace_id || null,
38
+ correlation_id: source.correlation_id || null,
39
+ last_message_id: source.last_message_id || null,
40
+ result: Object.prototype.hasOwnProperty.call(source, 'result')
41
+ ? source.result
42
+ : (Object.prototype.hasOwnProperty.call(event, 'result') ? event.result : null),
43
+ error: Object.prototype.hasOwnProperty.call(source, 'error')
44
+ ? source.error
45
+ : (Object.prototype.hasOwnProperty.call(event, 'error') ? event.error : null),
46
+ created_at_ms: Number.isFinite(createdAtMs) ? createdAtMs : null,
47
+ updated_at_ms: Number.isFinite(updatedAtMs) ? updatedAtMs : null,
48
+ started_at_ms: Number.isFinite(Number(source.started_at_ms)) ? Number(source.started_at_ms) : null,
49
+ completed_at_ms: Number.isFinite(Number(source.completed_at_ms)) ? Number(source.completed_at_ms) : null,
50
+ last_retry_at_ms: Number.isFinite(Number(source.last_retry_at_ms)) ? Number(source.last_retry_at_ms) : null,
51
+ timestamp: new Date(timestampMs).toISOString(),
52
+ };
53
+ }
54
+
55
+ export function createAssignCallbackServer({ store = null, sessionId = process.pid } = {}) {
56
+ const pipePath = getAssignCallbackPipePath(sessionId);
57
+ const clients = new Set();
58
+ let server = null;
59
+ let detachStoreListener = null;
60
+
61
+ function removeSocket(socket) {
62
+ if (!socket) return;
63
+ clients.delete(socket);
64
+ try { socket.destroy(); } catch {}
65
+ }
66
+
67
+ function broadcast(event) {
68
+ const frame = `${JSON.stringify(event)}\n`;
69
+ for (const socket of Array.from(clients)) {
70
+ if (!socket.writable || socket.destroyed) {
71
+ removeSocket(socket);
72
+ continue;
73
+ }
74
+ try {
75
+ socket.write(frame);
76
+ } catch {
77
+ removeSocket(socket);
78
+ }
79
+ }
80
+ }
81
+
82
+ return {
83
+ path: pipePath,
84
+ getStatus() {
85
+ return {
86
+ path: pipePath,
87
+ clients: clients.size,
88
+ };
89
+ },
90
+ async start() {
91
+ if (server) return { path: pipePath };
92
+ if (process.platform !== 'win32' && existsSync(pipePath)) {
93
+ try { unlinkSync(pipePath); } catch {}
94
+ }
95
+
96
+ server = net.createServer((socket) => {
97
+ clients.add(socket);
98
+ socket.setEncoding('utf8');
99
+ socket.on('error', () => removeSocket(socket));
100
+ socket.on('close', () => removeSocket(socket));
101
+ });
102
+
103
+ await new Promise((resolve, reject) => {
104
+ server.once('error', reject);
105
+ server.listen(pipePath, () => {
106
+ server?.off('error', reject);
107
+ resolve();
108
+ });
109
+ });
110
+
111
+ if (store?.onAssignStatusChange && !detachStoreListener) {
112
+ detachStoreListener = store.onAssignStatusChange((event, row) => {
113
+ broadcast(buildAssignCallbackEvent(event, row));
114
+ });
115
+ }
116
+
117
+ return { path: pipePath };
118
+ },
119
+ async stop() {
120
+ if (detachStoreListener) {
121
+ try { detachStoreListener(); } catch {}
122
+ detachStoreListener = null;
123
+ }
124
+ if (!server) return;
125
+ for (const socket of Array.from(clients)) {
126
+ removeSocket(socket);
127
+ }
128
+ await new Promise((resolve) => server.close(resolve));
129
+ server = null;
130
+ if (process.platform !== 'win32' && existsSync(pipePath)) {
131
+ try { unlinkSync(pipePath); } catch {}
132
+ }
133
+ },
134
+ broadcast,
135
+ };
136
+ }