triflux 10.22.0 → 10.23.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/CLAUDE.md CHANGED
@@ -97,10 +97,11 @@ conductor, headless, swarm-hypervisor가 하나의 AccountBroker 싱글턴을
97
97
  | 스킬 | 대상 | 방식 |
98
98
  |------|------|------|
99
99
  | tfx-codex-swarm | 로컬 전용 | 로컬 worktree + psmux |
100
- | tfx-remote-spawn | Claude Code 원격 | SSH → Claude Code 세션 → 내부 tfx 라우팅 |
100
+ | tfx-remote | Claude Code 원격 | SSH → Claude Code 세션 → 내부 tfx 라우팅 |
101
+ | tfx-remote-spawn | deprecated alias | tfx-remote로 통합됨; 직접 호출 금지 |
101
102
 
102
103
  codex를 SSH 너머로 직접 실행하지 않는다. config.toml 충돌 + TTY 문제.
103
- 원격에서 codex가 필요하면: remote-spawn → Claude Code → Claude가 내부에서 codex 호출.
104
+ 원격에서 codex가 필요하면: tfx-remote → Claude Code → Claude가 내부에서 codex 호출.
104
105
 
105
106
  ### SSH 패턴
106
107
 
package/bin/triflux.mjs CHANGED
@@ -120,6 +120,8 @@ const VER = `${DIM}v${PKG.version}${RESET}`;
120
120
  const LINE = `${GRAY}${"─".repeat(48)}${RESET}`;
121
121
  const _DOT = `${GRAY}·${RESET}`;
122
122
  const STALE_TEAM_MAX_AGE_SEC = 3600;
123
+ const DEFAULT_TMUX_CLEANUP_PREFIX = "tfx-*";
124
+ const DEFAULT_TMUX_CLEANUP_AGE_MIN = 60;
123
125
  const ANSI_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g;
124
126
  const HUB_DEFAULT_PORT = 27888;
125
127
  const DOCTOR_HUB_PID_FILE = join(CLAUDE_DIR, "cache", "tfx-hub", "hub.pid");
@@ -155,7 +157,7 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
155
157
  },
156
158
  doctor: {
157
159
  usage:
158
- "tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--dynamic-routing] [--cleanup-stale-hubs --dry-run|--apply] [--json]",
160
+ "tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--dynamic-routing] [--cleanup-stale-hubs --dry-run|--apply] [--cleanup-stale-tmux --prefix tfx-* --age-min N --dry-run|--apply] [--json]",
159
161
  description: "설치 상태 진단 및 자동 복구",
160
162
  options: [
161
163
  {
@@ -197,17 +199,33 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
197
199
  description:
198
200
  "PPID=1 hub/server.mjs 후보를 보고하고 opt-in 정리 모드를 활성화",
199
201
  },
202
+ {
203
+ name: "--cleanup-stale-tmux",
204
+ type: "boolean",
205
+ description:
206
+ "detached tmux session 후보를 보고하고 opt-in 정리 모드를 활성화",
207
+ },
208
+ {
209
+ name: "--prefix <glob>",
210
+ type: "string",
211
+ description: "--cleanup-stale-tmux 와 함께 사용. 기본 tfx-*",
212
+ },
213
+ {
214
+ name: "--age-min <minutes>",
215
+ type: "number",
216
+ description: "--cleanup-stale-tmux 와 함께 사용. 기본 60",
217
+ },
200
218
  {
201
219
  name: "--dry-run",
202
220
  type: "boolean",
203
221
  description:
204
- "--cleanup-stale-hubs 와 함께 사용. active healthy hub를 제외하고 정리 후보만 표시",
222
+ "--cleanup-stale-hubs/--cleanup-stale-tmux 와 함께 사용. 정리 후보만 표시",
205
223
  },
206
224
  {
207
225
  name: "--apply",
208
226
  type: "boolean",
209
227
  description:
210
- "--cleanup-stale-hubs 와 함께 사용. active healthy hub 제외 후 stale hub 종료",
228
+ "--cleanup-stale-hubs/--cleanup-stale-tmux 와 함께 사용. stale 대상 종료",
211
229
  },
212
230
  {
213
231
  name: "--json",
@@ -823,14 +841,23 @@ export async function inspectDetachedHubProcesses({
823
841
  healthy = true;
824
842
  break;
825
843
  }
844
+ const activeByPidFile = activeHealthy?.pid === row.pid;
845
+ const activeByConnection = established >= 1;
846
+ const isActiveHealthy = activeByPidFile || activeByConnection;
826
847
  hubs.push({
827
848
  ...row,
828
849
  ports,
829
850
  established,
830
851
  version,
831
- healthy,
832
- activeHealthy: activeHealthy?.pid === row.pid,
833
- staleCandidate: activeHealthy?.pid !== row.pid,
852
+ healthy: healthy || activeByConnection,
853
+ activeHealthy: isActiveHealthy,
854
+ activeReason: activeByPidFile
855
+ ? "pid-file-health"
856
+ : activeByConnection
857
+ ? "established-connection"
858
+ : null,
859
+ healthStatus: isActiveHealthy ? "healthy" : "stale",
860
+ staleCandidate: !isActiveHealthy,
834
861
  });
835
862
  }
836
863
 
@@ -891,12 +918,30 @@ export async function cleanupDetachedHubProcesses({
891
918
  } = {}) {
892
919
  const results = [];
893
920
  for (const hub of hubs || []) {
894
- if (activeHealthy?.pid === hub.pid || hub.activeHealthy) {
895
- results.push({ pid: hub.pid, action: "excluded-active", ok: true, hub });
921
+ const classification =
922
+ activeHealthy?.pid === hub.pid ||
923
+ hub.activeHealthy ||
924
+ Number(hub.established) >= 1
925
+ ? "healthy"
926
+ : "stale";
927
+ if (classification === "healthy") {
928
+ results.push({
929
+ pid: hub.pid,
930
+ classification,
931
+ action: "excluded-active",
932
+ ok: true,
933
+ hub,
934
+ });
896
935
  continue;
897
936
  }
898
937
  if (!apply || dryRun) {
899
- results.push({ pid: hub.pid, action: "dry-run-skip", ok: true, hub });
938
+ results.push({
939
+ pid: hub.pid,
940
+ classification,
941
+ action: "dry-run-skip",
942
+ ok: true,
943
+ hub,
944
+ });
900
945
  continue;
901
946
  }
902
947
  const retired = await retireDetachedHubPid(hub.pid, {
@@ -906,6 +951,7 @@ export async function cleanupDetachedHubProcesses({
906
951
  });
907
952
  results.push({
908
953
  pid: hub.pid,
954
+ classification,
909
955
  action: retired.ok ? "retired" : "failed",
910
956
  ok: retired.ok,
911
957
  reason: retired.reason,
@@ -1110,6 +1156,240 @@ function formatElapsedAge(ageSec) {
1110
1156
  return `${Math.floor(ageSec / 86400)}일`;
1111
1157
  }
1112
1158
 
1159
+ function compileSimpleGlob(pattern) {
1160
+ const source = String(pattern || DEFAULT_TMUX_CLEANUP_PREFIX)
1161
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
1162
+ .replace(/\*/g, ".*");
1163
+ return new RegExp(`^${source}$`);
1164
+ }
1165
+
1166
+ function parseDetachedTmuxSessionRows(output, nowSec) {
1167
+ const sessions = [];
1168
+ for (const line of String(output || "").split(/\r?\n/)) {
1169
+ const [name, attachedText, createdText] = line.split("\t");
1170
+ if (!name) continue;
1171
+ const attached = Number.parseInt(attachedText || "0", 10);
1172
+ const createdAt = parseSessionCreated(createdText);
1173
+ sessions.push({
1174
+ name,
1175
+ attached: Number.isFinite(attached) ? attached : 0,
1176
+ ageSec: createdAt == null ? null : Math.max(0, nowSec - createdAt),
1177
+ });
1178
+ }
1179
+ return sessions;
1180
+ }
1181
+
1182
+ function parseTmuxPaneRows(output) {
1183
+ return String(output || "")
1184
+ .split(/\r?\n/)
1185
+ .filter((line) => line.trim())
1186
+ .map((line) => {
1187
+ const [cwd, command, pidText] = line.split("\t");
1188
+ const pid = Number.parseInt(pidText || "", 10);
1189
+ return {
1190
+ cwd: cwd || null,
1191
+ command: command || null,
1192
+ pid: Number.isFinite(pid) && pid > 0 ? pid : null,
1193
+ };
1194
+ });
1195
+ }
1196
+
1197
+ function queryTmuxPaneRows(sessionName, { execFile = execFileSync } = {}) {
1198
+ try {
1199
+ return parseTmuxPaneRows(
1200
+ execFile(
1201
+ "tmux",
1202
+ [
1203
+ "list-panes",
1204
+ "-t",
1205
+ sessionName,
1206
+ "-F",
1207
+ "#{pane_current_path}\t#{pane_current_command}\t#{pane_pid}",
1208
+ ],
1209
+ {
1210
+ encoding: "utf8",
1211
+ timeout: 1000,
1212
+ stdio: ["ignore", "pipe", "ignore"],
1213
+ windowsHide: true,
1214
+ },
1215
+ ),
1216
+ );
1217
+ } catch {
1218
+ return [];
1219
+ }
1220
+ }
1221
+
1222
+ function queryProcessMemoryMb(pid, { execFile = execFileSync } = {}) {
1223
+ const resolvedPid = Number(pid);
1224
+ if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return null;
1225
+ try {
1226
+ const output = execFile("ps", ["-o", "rss=", "-p", String(resolvedPid)], {
1227
+ encoding: "utf8",
1228
+ timeout: 1000,
1229
+ stdio: ["ignore", "pipe", "ignore"],
1230
+ windowsHide: true,
1231
+ });
1232
+ const rssKb = Number.parseInt(String(output || "").trim(), 10);
1233
+ if (!Number.isFinite(rssKb) || rssKb < 0) return null;
1234
+ return Math.round(rssKb / 1024);
1235
+ } catch {
1236
+ return null;
1237
+ }
1238
+ }
1239
+
1240
+ function sumMemoryEstimateMb(panes, { execFile = execFileSync } = {}) {
1241
+ let total = 0;
1242
+ let seen = false;
1243
+ for (const pane of panes) {
1244
+ const memory = queryProcessMemoryMb(pane.pid, { execFile });
1245
+ if (memory == null) continue;
1246
+ total += memory;
1247
+ seen = true;
1248
+ }
1249
+ return seen ? total : null;
1250
+ }
1251
+
1252
+ export function inspectDetachedTmuxSessions({
1253
+ prefix = DEFAULT_TMUX_CLEANUP_PREFIX,
1254
+ ageMin = DEFAULT_TMUX_CLEANUP_AGE_MIN,
1255
+ platform = process.platform,
1256
+ execFile = execFileSync,
1257
+ now = Date.now(),
1258
+ } = {}) {
1259
+ if (platform === "win32") {
1260
+ return {
1261
+ available: false,
1262
+ reason: "unsupported-platform",
1263
+ prefix,
1264
+ ageMin,
1265
+ sessions: [],
1266
+ staleCandidates: [],
1267
+ };
1268
+ }
1269
+
1270
+ let output = "";
1271
+ try {
1272
+ output = execFile(
1273
+ "tmux",
1274
+ [
1275
+ "list-sessions",
1276
+ "-F",
1277
+ "#{session_name}\t#{session_attached}\t#{session_created}",
1278
+ ],
1279
+ {
1280
+ encoding: "utf8",
1281
+ timeout: 1000,
1282
+ stdio: ["ignore", "pipe", "ignore"],
1283
+ windowsHide: true,
1284
+ },
1285
+ );
1286
+ } catch (error) {
1287
+ return {
1288
+ available: false,
1289
+ reason: error?.code === "ENOENT" ? "tmux-missing" : "tmux-unavailable",
1290
+ prefix,
1291
+ ageMin,
1292
+ sessions: [],
1293
+ staleCandidates: [],
1294
+ };
1295
+ }
1296
+
1297
+ const nowSec = Math.floor(now / 1000);
1298
+ const matcher = compileSimpleGlob(prefix);
1299
+ const minAgeSec = Math.max(0, Number(ageMin) || 0) * 60;
1300
+ const sessions = parseDetachedTmuxSessionRows(output, nowSec)
1301
+ .filter((session) => session.attached === 0 && matcher.test(session.name))
1302
+ .map((session) => {
1303
+ const panes = queryTmuxPaneRows(session.name, { execFile });
1304
+ const commands = [
1305
+ ...new Set(panes.map((pane) => pane.command).filter(Boolean)),
1306
+ ];
1307
+ const cwd = panes.find((pane) => pane.cwd)?.cwd || null;
1308
+ const staleCandidate =
1309
+ session.ageSec != null && session.ageSec >= minAgeSec;
1310
+ return {
1311
+ name: session.name,
1312
+ age: formatElapsedAge(session.ageSec),
1313
+ ageSec: session.ageSec,
1314
+ cwd,
1315
+ command: commands.length > 0 ? commands.join(",") : null,
1316
+ memoryEstimateMb: sumMemoryEstimateMb(panes, { execFile }),
1317
+ staleCandidate,
1318
+ };
1319
+ });
1320
+
1321
+ return {
1322
+ available: true,
1323
+ reason: null,
1324
+ prefix,
1325
+ ageMin,
1326
+ sessions,
1327
+ staleCandidates: sessions.filter((session) => session.staleCandidate),
1328
+ };
1329
+ }
1330
+
1331
+ export async function cleanupDetachedTmuxSessions({
1332
+ sessions,
1333
+ dryRun = true,
1334
+ apply = false,
1335
+ execFile = execFileSync,
1336
+ } = {}) {
1337
+ const results = [];
1338
+ for (const session of sessions || []) {
1339
+ if (!session.staleCandidate) {
1340
+ results.push({
1341
+ name: session.name,
1342
+ action: "excluded-fresh",
1343
+ ok: true,
1344
+ session,
1345
+ });
1346
+ continue;
1347
+ }
1348
+ if (!apply || dryRun) {
1349
+ results.push({
1350
+ name: session.name,
1351
+ action: "dry-run-skip",
1352
+ ok: true,
1353
+ session,
1354
+ });
1355
+ continue;
1356
+ }
1357
+ try {
1358
+ execFile("tmux", ["kill-session", "-t", session.name], {
1359
+ encoding: "utf8",
1360
+ timeout: 5000,
1361
+ stdio: ["ignore", "pipe", "pipe"],
1362
+ windowsHide: true,
1363
+ });
1364
+ results.push({
1365
+ name: session.name,
1366
+ action: "retired",
1367
+ ok: true,
1368
+ session,
1369
+ });
1370
+ } catch (error) {
1371
+ results.push({
1372
+ name: session.name,
1373
+ action: "failed",
1374
+ ok: false,
1375
+ error,
1376
+ session,
1377
+ });
1378
+ }
1379
+ }
1380
+
1381
+ return {
1382
+ dryRun: !apply || dryRun,
1383
+ results,
1384
+ failed: results.filter((result) => result.ok === false).length,
1385
+ retired: results.filter((result) => result.action === "retired").length,
1386
+ skipped: results.filter((result) => result.action === "dry-run-skip")
1387
+ .length,
1388
+ excluded: results.filter((result) => result.action === "excluded-fresh")
1389
+ .length,
1390
+ };
1391
+ }
1392
+
1113
1393
  function readTeamSessionCreatedMap() {
1114
1394
  const createdMap = new Map();
1115
1395
 
@@ -2286,6 +2566,11 @@ async function cmdDoctor(options = {}) {
2286
2566
  cleanupStaleHubs = false,
2287
2567
  cleanupStaleHubsDryRun = true,
2288
2568
  cleanupStaleHubsApply = false,
2569
+ cleanupStaleTmux = false,
2570
+ cleanupStaleTmuxDryRun = true,
2571
+ cleanupStaleTmuxApply = false,
2572
+ cleanupStaleTmuxPrefix = DEFAULT_TMUX_CLEANUP_PREFIX,
2573
+ cleanupStaleTmuxAgeMin = DEFAULT_TMUX_CLEANUP_AGE_MIN,
2289
2574
  json = false,
2290
2575
  } = options;
2291
2576
  const report = {
@@ -2296,6 +2581,13 @@ async function cmdDoctor(options = {}) {
2296
2581
  hook_coverage: { total: 0, registered: 0, missing: [] },
2297
2582
  fsmonitorDaemons: { stale: 0, killed: 0 },
2298
2583
  hubServers: { detached: 0, stale: 0, activeHealthy: null },
2584
+ tmuxSessions: {
2585
+ detached: 0,
2586
+ stale: 0,
2587
+ prefix: cleanupStaleTmuxPrefix,
2588
+ ageMin: cleanupStaleTmuxAgeMin,
2589
+ sessions: [],
2590
+ },
2299
2591
  issue_count: 0,
2300
2592
  };
2301
2593
 
@@ -3583,6 +3875,106 @@ async function cmdDoctor(options = {}) {
3583
3875
  }
3584
3876
  }
3585
3877
 
3878
+ // 12.5. detached tmux session report and opt-in cleanup
3879
+ section("Detached Tmux Sessions");
3880
+ const detachedTmuxReport = inspectDetachedTmuxSessions({
3881
+ prefix: cleanupStaleTmuxPrefix,
3882
+ ageMin: cleanupStaleTmuxAgeMin,
3883
+ });
3884
+ report.tmuxSessions = {
3885
+ detached: detachedTmuxReport.sessions.length,
3886
+ stale: detachedTmuxReport.staleCandidates.length,
3887
+ prefix: detachedTmuxReport.prefix,
3888
+ ageMin: detachedTmuxReport.ageMin,
3889
+ available: detachedTmuxReport.available,
3890
+ reason: detachedTmuxReport.reason,
3891
+ sessions: detachedTmuxReport.sessions.map((session) => ({
3892
+ name: session.name,
3893
+ age: session.age,
3894
+ age_sec: session.ageSec,
3895
+ cwd: session.cwd,
3896
+ command: session.command,
3897
+ memory_estimate_mb: session.memoryEstimateMb,
3898
+ stale: session.staleCandidate,
3899
+ })),
3900
+ };
3901
+ addDoctorCheck(report, {
3902
+ name: "detached-tmux-sessions",
3903
+ status: !detachedTmuxReport.available
3904
+ ? "skipped"
3905
+ : detachedTmuxReport.staleCandidates.length > 0
3906
+ ? "warning"
3907
+ : "ok",
3908
+ prefix: detachedTmuxReport.prefix,
3909
+ age_min: detachedTmuxReport.ageMin,
3910
+ detached: detachedTmuxReport.sessions.length,
3911
+ stale: detachedTmuxReport.staleCandidates.length,
3912
+ reason: detachedTmuxReport.reason,
3913
+ });
3914
+
3915
+ if (!detachedTmuxReport.available) {
3916
+ info(
3917
+ `tmux 미감지 또는 서버 없음 — detached tmux 검사 건너뜀 (${detachedTmuxReport.reason})`,
3918
+ );
3919
+ } else if (detachedTmuxReport.sessions.length === 0) {
3920
+ ok(`detached tmux session 없음 (prefix=${detachedTmuxReport.prefix})`);
3921
+ } else {
3922
+ info(
3923
+ `prefix=${detachedTmuxReport.prefix} age-min=${detachedTmuxReport.ageMin}분`,
3924
+ );
3925
+ for (const session of detachedTmuxReport.sessions) {
3926
+ const memoryLabel =
3927
+ session.memoryEstimateMb == null
3928
+ ? "?"
3929
+ : `${session.memoryEstimateMb}MB`;
3930
+ const cwdLabel = session.cwd || "?";
3931
+ const commandLabel = session.command || "?";
3932
+ const line = `${session.name}: age=${session.age} cwd=${cwdLabel} command=${commandLabel} memory=${memoryLabel}`;
3933
+ if (session.staleCandidate) warn(`${line} stale`);
3934
+ else ok(`${line} fresh`);
3935
+ }
3936
+ }
3937
+
3938
+ if (cleanupStaleTmux) {
3939
+ const cleanupResult = await cleanupDetachedTmuxSessions({
3940
+ sessions: detachedTmuxReport.sessions,
3941
+ dryRun: cleanupStaleTmuxDryRun,
3942
+ apply: cleanupStaleTmuxApply,
3943
+ });
3944
+ report.actions.push({
3945
+ type: "cleanup-stale-tmux",
3946
+ status: cleanupResult.failed > 0 ? "failed" : "ok",
3947
+ dryRun: cleanupResult.dryRun,
3948
+ retired: cleanupResult.retired,
3949
+ skipped: cleanupResult.skipped,
3950
+ excluded: cleanupResult.excluded,
3951
+ failed: cleanupResult.failed,
3952
+ });
3953
+ for (const result of cleanupResult.results) {
3954
+ if (result.action === "excluded-fresh") {
3955
+ ok(`fresh detached tmux excluded: ${result.name}`);
3956
+ } else if (result.action === "dry-run-skip") {
3957
+ info(`dry-run stale tmux target: ${result.name}`);
3958
+ } else if (result.action === "retired") {
3959
+ ok(`stale tmux session killed: ${result.name}`);
3960
+ } else {
3961
+ fail(`stale tmux cleanup failed: ${result.name}`);
3962
+ }
3963
+ }
3964
+ issues += cleanupResult.failed;
3965
+ if (
3966
+ cleanupResult.dryRun &&
3967
+ detachedTmuxReport.staleCandidates.length > 0
3968
+ ) {
3969
+ issues += detachedTmuxReport.staleCandidates.length;
3970
+ }
3971
+ } else if (detachedTmuxReport.staleCandidates.length > 0) {
3972
+ info(
3973
+ "정리: tfx doctor --cleanup-stale-tmux --prefix tfx-* --dry-run|--apply",
3974
+ );
3975
+ issues += detachedTmuxReport.staleCandidates.length;
3976
+ }
3977
+
3586
3978
  // 13. OMC stale team 상태
3587
3979
  section("OMC Stale Teams");
3588
3980
  const omcTeamReport = inspectStaleOmcTeams({
@@ -3673,6 +4065,8 @@ async function cmdDoctor(options = {}) {
3673
4065
  established: hub.established,
3674
4066
  rssKb: hub.rssKb,
3675
4067
  activeHealthy: hub.activeHealthy,
4068
+ activeReason: hub.activeReason,
4069
+ healthStatus: hub.healthStatus,
3676
4070
  staleCandidate: hub.staleCandidate,
3677
4071
  })),
3678
4072
  };
@@ -3692,9 +4086,9 @@ async function cmdDoctor(options = {}) {
3692
4086
  const versionLabel = hub.version || "unknown";
3693
4087
  const rssLabel = hub.rssKb == null ? "?" : `${hub.rssKb}KB`;
3694
4088
  const activeLabel = hub.activeHealthy
3695
- ? " active healthy excluded"
4089
+ ? ` healthy excluded${hub.activeReason ? ` (${hub.activeReason})` : ""}`
3696
4090
  : " stale candidate";
3697
- const line = `PID=${hub.pid} PPID=${hub.ppid} version=${versionLabel} uptime=${hub.uptime} port=${portLabel} ESTABLISHED=${hub.established} RSS=${rssLabel}${activeLabel}`;
4091
+ const line = `PID=${hub.pid} PPID=${hub.ppid} status=${hub.healthStatus} version=${versionLabel} uptime=${hub.uptime} port=${portLabel} ESTABLISHED=${hub.established} RSS=${rssLabel}${activeLabel}`;
3698
4092
  if (hub.activeHealthy) ok(line);
3699
4093
  else warn(line);
3700
4094
  }
@@ -3718,9 +4112,9 @@ async function cmdDoctor(options = {}) {
3718
4112
  });
3719
4113
  for (const result of cleanupResult.results) {
3720
4114
  if (result.action === "excluded-active") {
3721
- ok(`active healthy hub excluded: PID=${result.pid}`);
4115
+ ok(`dry-run healthy hub excluded: PID=${result.pid}`);
3722
4116
  } else if (result.action === "dry-run-skip") {
3723
- info(`dry-run skip stale hub: PID=${result.pid}`);
4117
+ info(`dry-run stale hub target: PID=${result.pid}`);
3724
4118
  } else if (result.action === "retired") {
3725
4119
  ok(`stale hub retired: PID=${result.pid} (${result.reason})`);
3726
4120
  } else {
@@ -6430,6 +6824,28 @@ async function cmdHub(args = [], options = {}) {
6430
6824
 
6431
6825
  // ── 메인 ──
6432
6826
 
6827
+ function readCliOptionValue(args, name) {
6828
+ const index = args.indexOf(name);
6829
+ if (index === -1) return null;
6830
+ const value = args[index + 1];
6831
+ if (!value || value.startsWith("--")) return null;
6832
+ return value;
6833
+ }
6834
+
6835
+ function parsePositiveIntegerOption(args, name, fallback) {
6836
+ const value = readCliOptionValue(args, name);
6837
+ if (value == null) return fallback;
6838
+ const parsed = Number.parseInt(value, 10);
6839
+ if (!Number.isFinite(parsed) || parsed < 0) {
6840
+ throw createCliError(`${name} 값은 0 이상의 정수여야 합니다`, {
6841
+ exitCode: EXIT_ARG_ERROR,
6842
+ reason: "argError",
6843
+ fix: `${name} ${fallback}`,
6844
+ });
6845
+ }
6846
+ return parsed;
6847
+ }
6848
+
6433
6849
  async function main() {
6434
6850
  const cmd = NORMALIZED_ARGS[0] || "help";
6435
6851
  const cmdArgs = NORMALIZED_ARGS.slice(1);
@@ -6526,18 +6942,30 @@ async function main() {
6526
6942
  const reset = cmdArgs.includes("--reset");
6527
6943
  const purgeLogs = cmdArgs.includes("--purge-logs");
6528
6944
  const cleanupStaleHubs = cmdArgs.includes("--cleanup-stale-hubs");
6945
+ const cleanupStaleTmux = cmdArgs.includes("--cleanup-stale-tmux");
6529
6946
  const cleanupApply = cmdArgs.includes("--apply");
6530
6947
  const cleanupDryRun = cmdArgs.includes("--dry-run") || !cleanupApply;
6531
- if (cleanupStaleHubs && cleanupApply && cmdArgs.includes("--dry-run")) {
6948
+ if (
6949
+ (cleanupStaleHubs || cleanupStaleTmux) &&
6950
+ cleanupApply &&
6951
+ cmdArgs.includes("--dry-run")
6952
+ ) {
6532
6953
  throw createCliError(
6533
- "--cleanup-stale-hubs 에서는 --dry-run 과 --apply 중 하나만 지정하세요",
6954
+ "cleanup 옵션에서는 --dry-run 과 --apply 중 하나만 지정하세요",
6534
6955
  {
6535
6956
  exitCode: EXIT_ARG_ERROR,
6536
6957
  reason: "argError",
6537
- fix: "tfx doctor --cleanup-stale-hubs --dry-run",
6958
+ fix: "tfx doctor --cleanup-stale-tmux --dry-run",
6538
6959
  },
6539
6960
  );
6540
6961
  }
6962
+ const cleanupStaleTmuxPrefix =
6963
+ readCliOptionValue(cmdArgs, "--prefix") || DEFAULT_TMUX_CLEANUP_PREFIX;
6964
+ const cleanupStaleTmuxAgeMin = parsePositiveIntegerOption(
6965
+ cmdArgs,
6966
+ "--age-min",
6967
+ DEFAULT_TMUX_CLEANUP_AGE_MIN,
6968
+ );
6541
6969
  await cmdDoctor({
6542
6970
  fix,
6543
6971
  reset,
@@ -6545,6 +6973,11 @@ async function main() {
6545
6973
  cleanupStaleHubs,
6546
6974
  cleanupStaleHubsDryRun: cleanupDryRun,
6547
6975
  cleanupStaleHubsApply: cleanupApply,
6976
+ cleanupStaleTmux,
6977
+ cleanupStaleTmuxDryRun: cleanupDryRun,
6978
+ cleanupStaleTmuxApply: cleanupApply,
6979
+ cleanupStaleTmuxPrefix,
6980
+ cleanupStaleTmuxAgeMin,
6548
6981
  json: JSON_OUTPUT,
6549
6982
  });
6550
6983
  return;
@@ -343,6 +343,23 @@ function recordRouteOutcome(slug, mode, outcome) {
343
343
  writeFileSync(weightsPath, JSON.stringify(weights, null, 2), "utf8");
344
344
  }
345
345
 
346
+ function isHubTokenMonitorSnapshot(snapshot) {
347
+ if (!snapshot || typeof snapshot !== "object") return false;
348
+ return (
349
+ Object.hasOwn(snapshot, "requestTokens") ||
350
+ Object.hasOwn(snapshot, "responseTokens") ||
351
+ Object.hasOwn(snapshot, "totalUpdates") ||
352
+ (snapshot.byTool && typeof snapshot.byTool === "object")
353
+ );
354
+ }
355
+
356
+ function readCompactNudgePercent(snapshot) {
357
+ if (isHubTokenMonitorSnapshot(snapshot)) return null;
358
+ const percent = Number(snapshot?.percent ?? 0);
359
+ if (!Number.isFinite(percent)) return null;
360
+ return Math.max(0, Math.min(100, percent));
361
+ }
362
+
346
363
  // ── 메인 ────────────────────────────────────────────────────
347
364
  async function main() {
348
365
  // CLI: --record-route <slug> <mode> <outcome>
@@ -517,8 +534,8 @@ async function main() {
517
534
  const nudgeMarker = join(tmpdir(), "tfx-compact-nudge-sent");
518
535
  if (existsSync(snapshotPath) && !existsSync(nudgeMarker)) {
519
536
  const snap = JSON.parse(readFileSync(snapshotPath, "utf8"));
520
- const percent = Number(snap.percent || 0);
521
- if (percent >= 80) {
537
+ const percent = readCompactNudgePercent(snap);
538
+ if (percent != null && percent >= 80) {
522
539
  const level = percent >= 90 ? "critical" : "warn";
523
540
  const msg =
524
541
  level === "critical"
@@ -228,6 +228,16 @@ function contextPercentFromTranscript(payload) {
228
228
  return percents.length > 0 ? Math.max(...percents) : null;
229
229
  }
230
230
 
231
+ function isHubTokenMonitorSnapshot(snapshot) {
232
+ if (!snapshot || typeof snapshot !== "object") return false;
233
+ return (
234
+ Object.hasOwn(snapshot, "requestTokens") ||
235
+ Object.hasOwn(snapshot, "responseTokens") ||
236
+ Object.hasOwn(snapshot, "totalUpdates") ||
237
+ (snapshot.byTool && typeof snapshot.byTool === "object")
238
+ );
239
+ }
240
+
231
241
  function contextPercentFromCache() {
232
242
  const home = process.env.HOME || process.env.USERPROFILE;
233
243
  if (!home) return null;
@@ -243,6 +253,7 @@ function contextPercentFromCache() {
243
253
  try {
244
254
  const parsed = parseJsonCandidate(readFileSync(cachePath, "utf8"));
245
255
  if (!parsed) return null;
256
+ if (isHubTokenMonitorSnapshot(parsed)) return null;
246
257
  const direct = contextPercentFromPayload(parsed);
247
258
  if (direct !== null) return direct;
248
259
  const percents = contextPercentsFromObject(parsed);
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env node
2
+
2
3
  // hooks/session-end-cleanup.mjs — SessionEnd lifecycle state reporter
3
4
  //
4
5
  // Report-only by default. Opt-in cleanup only removes the repo-local
5
6
  // .triflux/subagents/subagents.json file when stale running lifecycle state is
6
7
  // present. It never kills processes or touches external HOME/MCP configs.
7
8
 
9
+ import { execFileSync } from "node:child_process";
8
10
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
9
11
  import { join } from "node:path";
10
12
 
11
13
  const MARKER = "[session-end-cleanup]";
12
14
  const STALE_MS = 30 * 60 * 1000;
13
15
  const MAX_OUTPUT_BYTES = 2 * 1024;
16
+ const DEFAULT_TMUX_PREFIX = "tfx-*";
17
+ const MAX_TMUX_REPORT_SESSIONS = 8;
14
18
 
15
19
  function readStdin() {
16
20
  try {
@@ -65,6 +69,140 @@ function agentEntries(state) {
65
69
  return [];
66
70
  }
67
71
 
72
+ function compileSimpleGlob(pattern) {
73
+ const source = String(pattern || DEFAULT_TMUX_PREFIX)
74
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
75
+ .replace(/\*/g, ".*");
76
+ return new RegExp(`^${source}$`);
77
+ }
78
+
79
+ function formatAge(ageSec) {
80
+ if (!Number.isFinite(ageSec) || ageSec < 0) return "unknown";
81
+ if (ageSec < 60) return `${Math.floor(ageSec)}s`;
82
+ if (ageSec < 3600) return `${Math.floor(ageSec / 60)}m`;
83
+ if (ageSec < 86400) return `${Math.floor(ageSec / 3600)}h`;
84
+ return `${Math.floor(ageSec / 86400)}d`;
85
+ }
86
+
87
+ function parseSessionCreated(rawValue) {
88
+ const parsed = Number.parseInt(String(rawValue || "").trim(), 10);
89
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
90
+ }
91
+
92
+ function parseTmuxListSessions(output, nowSec) {
93
+ const sessions = [];
94
+ for (const line of String(output || "").split(/\r?\n/)) {
95
+ const [name, attachedText, createdText] = line.split("\t");
96
+ if (!name) continue;
97
+ const attached = Number.parseInt(attachedText || "0", 10);
98
+ const created = parseSessionCreated(createdText);
99
+ sessions.push({
100
+ name,
101
+ attached: Number.isFinite(attached) ? attached : 0,
102
+ ageSec: created == null ? null : Math.max(0, nowSec - created),
103
+ });
104
+ }
105
+ return sessions;
106
+ }
107
+
108
+ function parseFirstPane(output) {
109
+ const first = String(output || "")
110
+ .split(/\r?\n/)
111
+ .find((line) => line.trim());
112
+ if (!first) return { cwd: null, command: null, pid: null };
113
+ const [cwd, command, pidText] = first.split("\t");
114
+ const pid = Number.parseInt(pidText || "", 10);
115
+ return {
116
+ cwd: cwd || null,
117
+ command: command || null,
118
+ pid: Number.isFinite(pid) && pid > 0 ? pid : null,
119
+ };
120
+ }
121
+
122
+ function queryMemoryEstimateMb(pid, execFile = execFileSync) {
123
+ if (!pid) return null;
124
+ try {
125
+ const output = execFile("ps", ["-o", "rss=", "-p", String(pid)], {
126
+ encoding: "utf8",
127
+ timeout: 400,
128
+ stdio: ["ignore", "pipe", "ignore"],
129
+ windowsHide: true,
130
+ });
131
+ const rssKb = Number.parseInt(String(output || "").trim(), 10);
132
+ if (!Number.isFinite(rssKb) || rssKb < 0) return null;
133
+ return Math.round(rssKb / 1024);
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ export function inspectDetachedTmuxSessions({
140
+ prefix = DEFAULT_TMUX_PREFIX,
141
+ now = Date.now(),
142
+ execFile = execFileSync,
143
+ limit = MAX_TMUX_REPORT_SESSIONS,
144
+ } = {}) {
145
+ const nowSec = Math.floor(now / 1000);
146
+ const matcher = compileSimpleGlob(prefix);
147
+ let output = "";
148
+ try {
149
+ output = execFile(
150
+ "tmux",
151
+ [
152
+ "list-sessions",
153
+ "-F",
154
+ "#{session_name}\t#{session_attached}\t#{session_created}",
155
+ ],
156
+ {
157
+ encoding: "utf8",
158
+ timeout: 500,
159
+ stdio: ["ignore", "pipe", "ignore"],
160
+ windowsHide: true,
161
+ },
162
+ );
163
+ } catch {
164
+ return { available: false, prefix, sessions: [] };
165
+ }
166
+
167
+ const sessions = [];
168
+ for (const session of parseTmuxListSessions(output, nowSec)) {
169
+ if (session.attached !== 0 || !matcher.test(session.name)) continue;
170
+ let pane = { cwd: null, command: null, pid: null };
171
+ try {
172
+ pane = parseFirstPane(
173
+ execFile(
174
+ "tmux",
175
+ [
176
+ "list-panes",
177
+ "-t",
178
+ session.name,
179
+ "-F",
180
+ "#{pane_current_path}\t#{pane_current_command}\t#{pane_pid}",
181
+ ],
182
+ {
183
+ encoding: "utf8",
184
+ timeout: 500,
185
+ stdio: ["ignore", "pipe", "ignore"],
186
+ windowsHide: true,
187
+ },
188
+ ),
189
+ );
190
+ } catch {
191
+ pane = { cwd: null, command: null, pid: null };
192
+ }
193
+ sessions.push({
194
+ name: session.name,
195
+ age: formatAge(session.ageSec),
196
+ cwd: pane.cwd,
197
+ command: pane.command,
198
+ memory_estimate_mb: queryMemoryEstimateMb(pane.pid, execFile),
199
+ });
200
+ if (sessions.length >= limit) break;
201
+ }
202
+
203
+ return { available: true, prefix, sessions };
204
+ }
205
+
68
206
  export function summarizeLifecycleState(state, now = Date.now()) {
69
207
  const completedEntries = Array.isArray(state?.completed)
70
208
  ? state.completed
@@ -121,28 +259,31 @@ function pruneStaleRunningAgents(state, now = Date.now()) {
121
259
  };
122
260
  }
123
261
 
124
- function buildOutput(summary, cleanup) {
125
- const context =
262
+ function buildContext(summary, cleanup, tmuxReport) {
263
+ let context =
126
264
  `${MARKER} subagent lifecycle: ` +
127
265
  `running=${summary.running} completed=${summary.completed} ` +
128
266
  `stale=${summary.stale} total=${summary.total} cleanup=${cleanup}`;
267
+
268
+ if (tmuxReport?.sessions?.length > 0) {
269
+ context += ` detached_tmux_sessions=${JSON.stringify(tmuxReport.sessions)}`;
270
+ }
271
+ return context;
272
+ }
273
+
274
+ function buildOutput(summary, cleanup, tmuxReport) {
275
+ const context = buildContext(summary, cleanup, tmuxReport);
129
276
  const output = {
130
277
  systemMessage: context,
131
- hookSpecificOutput: {
132
- hookEventName: "SessionEnd",
133
- additionalContext: context,
134
- },
135
278
  };
136
279
 
137
280
  let json = JSON.stringify(output);
138
281
  if (Buffer.byteLength(json, "utf8") >= MAX_OUTPUT_BYTES) {
139
- const bounded = `${MARKER} subagent lifecycle: running=${summary.running} completed=${summary.completed} stale=${summary.stale} cleanup=${cleanup}`;
282
+ const bounded = buildContext(summary, cleanup, {
283
+ sessions: tmuxReport?.sessions?.slice(0, 2) || [],
284
+ });
140
285
  json = JSON.stringify({
141
286
  systemMessage: bounded,
142
- hookSpecificOutput: {
143
- hookEventName: "SessionEnd",
144
- additionalContext: bounded,
145
- },
146
287
  });
147
288
  }
148
289
  return json;
@@ -160,6 +301,9 @@ export function run(input, env = process.env) {
160
301
 
161
302
  const summary = summarizeLifecycleState(state);
162
303
  let cleanup = "report-only";
304
+ const tmuxReport = inspectDetachedTmuxSessions({
305
+ prefix: env.TFX_SESSION_END_TMUX_PREFIX || DEFAULT_TMUX_PREFIX,
306
+ });
163
307
 
164
308
  if (env.TFX_SESSION_END_CLEANUP === "1" && summary.stale > 0) {
165
309
  try {
@@ -184,7 +328,7 @@ export function run(input, env = process.env) {
184
328
  }
185
329
  }
186
330
 
187
- return buildOutput(summary, cleanup);
331
+ return buildOutput(summary, cleanup, tmuxReport);
188
332
  }
189
333
 
190
334
  function main() {
@@ -135,11 +135,11 @@ export function detectMultiplexer() {
135
135
  _cachedMux = "tmux";
136
136
  return _cachedMux;
137
137
  }
138
- if (process.platform === "win32" && hasGitBashTmux()) {
138
+ if (hasGitBashTmux()) {
139
139
  _cachedMux = "git-bash-tmux";
140
140
  return _cachedMux;
141
141
  }
142
- if (process.platform === "win32" && hasWslTmux()) {
142
+ if (hasWslTmux()) {
143
143
  _cachedMux = "wsl-tmux";
144
144
  return _cachedMux;
145
145
  }
@@ -1,4 +1,4 @@
1
- import { exec as defaultExec, execFileSync } from "node:child_process";
1
+ import { exec as defaultExec, spawnSync } from "node:child_process";
2
2
  import { platform as osPlatform } from "node:os";
3
3
  import { psmuxExec as defaultPsmuxExec } from "./psmux.mjs";
4
4
  import { tmuxExec as defaultTmuxExec, detectMultiplexer } from "./session.mjs";
@@ -68,15 +68,27 @@ function shellCommandName(value) {
68
68
  return /^[A-Za-z0-9_./:-]+$/u.test(command) ? command : shellQuote(command);
69
69
  }
70
70
 
71
+ function powershellCommandName(value) {
72
+ const command = String(value);
73
+ return /^[A-Za-z0-9_.:-]+$/u.test(command)
74
+ ? command
75
+ : `& ${powershellSingleQuote(command)}`;
76
+ }
77
+
78
+ function getCommandVersion(command) {
79
+ const r = spawnSync(command, ["-V"], {
80
+ encoding: "utf8",
81
+ stdio: ["ignore", "pipe", "pipe"],
82
+ timeout: 3000,
83
+ windowsHide: true,
84
+ });
85
+ if ((r.status ?? 1) !== 0) return null;
86
+ return `${r.stdout || ""}${r.stderr || ""}`.trim();
87
+ }
88
+
71
89
  function defaultPsmuxBinaryExists(command) {
72
90
  try {
73
- const output = execFileSync(command, ["-V"], {
74
- encoding: "utf8",
75
- stdio: ["ignore", "pipe", "pipe"],
76
- timeout: 3000,
77
- windowsHide: true,
78
- });
79
- return /\bpsmux\b/i.test(String(output || ""));
91
+ return /\bpsmux\b/i.test(getCommandVersion(command) || "");
80
92
  } catch {
81
93
  return false;
82
94
  }
@@ -139,12 +151,18 @@ export function createTerminalOpener(deps = {}) {
139
151
 
140
152
  async function openSession(sessionName, opts = {}) {
141
153
  if (platform === "win32") {
154
+ const command = process.env.PSMUX_BIN || "psmux";
155
+ const psmuxBinaryExists =
156
+ deps.psmuxBinaryExists || defaultPsmuxBinaryExists;
157
+ if (!psmuxBinaryExists(command)) return false;
142
158
  const wt = createWtManager();
143
159
  try {
144
160
  return wtResultSucceeded(
145
161
  await wt.createTab({
146
162
  title: sanitizeTerminalTitle(opts.title ?? sessionName),
147
- command: `psmux attach-session -t ${powershellSingleQuote(sessionName)}`,
163
+ command: `${powershellCommandName(
164
+ command,
165
+ )} attach-session -t ${powershellSingleQuote(sessionName)}`,
148
166
  cwd: opts.cwd,
149
167
  profile: opts.profile ?? "triflux",
150
168
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.22.0",
3
+ "version": "10.23.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {