triflux 9.3.0 → 9.5.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.
@@ -0,0 +1,51 @@
1
+ import { join } from "node:path";
2
+
3
+ export const SESSION_TTL_SEC = 30 * 60;
4
+ export const STATE_REL_PATH = join(".omc", "state", "cross-review.json");
5
+
6
+ export function readStdin() {
7
+ return new Promise((resolve) => {
8
+ let raw = "";
9
+ process.stdin.setEncoding("utf8");
10
+ process.stdin.on("data", (chunk) => {
11
+ raw += chunk;
12
+ });
13
+ process.stdin.on("end", () => resolve(raw));
14
+ process.stdin.on("error", () => resolve(""));
15
+ });
16
+ }
17
+
18
+ export function parseJson(raw) {
19
+ try {
20
+ return JSON.parse(raw);
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ export function nowSec() {
27
+ return Math.floor(Date.now() / 1000);
28
+ }
29
+
30
+ export function resolveBaseDir(payload) {
31
+ if (typeof payload?.cwd === "string" && payload.cwd.trim()) return payload.cwd;
32
+ if (typeof payload?.directory === "string" && payload.directory.trim()) return payload.directory;
33
+ return process.cwd();
34
+ }
35
+
36
+ export function shouldTrackPath(filePath) {
37
+ if (typeof filePath !== "string" || !filePath.trim()) return false;
38
+
39
+ const lower = filePath.toLowerCase();
40
+ if (lower.startsWith(".omc/") || lower.startsWith(".claude/")) return false;
41
+ if (lower === "package-lock.json" || lower.endsWith("/package-lock.json")) return false;
42
+ if (/\.(md|lock|yml|yaml)$/i.test(lower)) return false;
43
+ return true;
44
+ }
45
+
46
+ export function expectedReviewer(author) {
47
+ if (author === "claude") return "codex";
48
+ if (author === "codex") return "claude";
49
+ if (author === "gemini") return "claude";
50
+ return "";
51
+ }
@@ -0,0 +1,14 @@
1
+ export function nudge(message) {
2
+ process.stdout.write(JSON.stringify({
3
+ hookSpecificOutput: {
4
+ hookEventName: "PreToolUse",
5
+ additionalContext: message,
6
+ },
7
+ }));
8
+ process.exit(0);
9
+ }
10
+
11
+ export function deny(reason) {
12
+ process.stderr.write(reason);
13
+ process.exit(2);
14
+ }
@@ -369,6 +369,13 @@ function selectContextualServers(baseServers, profile, options = {}) {
369
369
  const selected = new Set(
370
370
  (profile.alwaysOnServers || []).filter((server) => baseServers.includes(server)),
371
371
  );
372
+ const wantsBrowserObservation = (
373
+ baseServers.includes('playwright')
374
+ && /(?:browser|screenshot|layout|responsive|visual|screen|page|ui|ux|regression|캡처|스크린샷|레이아웃|반응형|화면|브라우저)/iu.test(taskText)
375
+ );
376
+ if (wantsBrowserObservation) {
377
+ selected.add('playwright');
378
+ }
372
379
  const requestedSearchTool = typeof options.searchTool === 'string' ? options.searchTool : '';
373
380
 
374
381
  const rankedServers = rankServers(
@@ -477,7 +484,9 @@ export function resolveAllowedServers(options = {}) {
477
484
  const baseServers = availableServers.length
478
485
  ? profile.allowedServers.filter((server) => availableServers.includes(server))
479
486
  : [...profile.allowedServers];
480
- const manifestFiltered = applyManifestFilter(baseServers);
487
+ const manifestFiltered = availableServers.length
488
+ ? baseServers
489
+ : applyManifestFilter(baseServers);
481
490
  return selectContextualServers(manifestFiltered, profile, { ...options, inventory, inventoryIndex });
482
491
  }
483
492
 
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * psmux-safety-guard.mjs — PreToolUse 훅
4
+ *
5
+ * psmux kill-session 직접 호출을 차단하고,
6
+ * psmux/wt 명령 사용 시 안전 규칙을 강제 주입한다.
7
+ *
8
+ * 동작:
9
+ * - kill-session 직접 호출 → deny (exit 2)
10
+ * - psmux/wt 명령 감지 → additionalContext에 안전 규칙 주입
11
+ * - 그 외 → 통과 (exit 0)
12
+ */
13
+
14
+ import { nudge, deny } from "./lib/hook-utils.mjs";
15
+
16
+ const KILL_PATTERN = /\bpsmux\s+kill-session\b/;
17
+ const GRACEFUL_PATTERN = /send-keys.*exit.*Enter[\s\S]*sleep\s+\d/;
18
+ const PSMUX_OR_WT = /\b(psmux|wt\.exe|wt\s)/;
19
+
20
+ const SAFETY_RULES = `[psmux-safety] WT 프리징 방지 필수 규칙:
21
+ 1) exit → sleep 2 → kill 순서 필수. 바로 kill 절대 금지.
22
+ 2) psmux send-keys는 PowerShell 구문만 (bash 문법 직접 전달 금지).
23
+ 3) 경로는 Windows 형식 (C:\\...). /c/... 금지.
24
+ 4) wt.exe는 sp(split-pane)만 사용. nt(new-tab) 금지.
25
+ 5) -p triflux 프로파일 필수.`;
26
+
27
+ async function main() {
28
+ let raw = "";
29
+ for await (const chunk of process.stdin) raw += chunk;
30
+ if (!raw.trim()) process.exit(0);
31
+
32
+ let input;
33
+ try { input = JSON.parse(raw); } catch { process.exit(0); }
34
+
35
+ if (input.tool_name !== "Bash") process.exit(0);
36
+
37
+ const cmd = input.tool_input?.command || "";
38
+
39
+ // kill-session 직접 호출 감지
40
+ if (KILL_PATTERN.test(cmd)) {
41
+ // 같은 명령 블록 안에 graceful exit 패턴이 있으면 허용
42
+ if (GRACEFUL_PATTERN.test(cmd)) {
43
+ nudge(SAFETY_RULES);
44
+ }
45
+ deny(
46
+ "[psmux-safety] psmux kill-session 직접 호출 차단.\n" +
47
+ "WT 프리징을 방지하려면 반드시 exit → sleep 2 → kill 순서를 따르세요.\n\n" +
48
+ "올바른 패턴:\n" +
49
+ " psmux send-keys -t SESSION \"exit\" Enter\n" +
50
+ " sleep 2\n" +
51
+ " psmux kill-session -t SESSION\n\n" +
52
+ "이 3줄을 하나의 명령 블록으로 실행하세요."
53
+ );
54
+ }
55
+
56
+ // psmux/wt 명령 감지 → 안전 규칙 주입
57
+ if (PSMUX_OR_WT.test(cmd)) {
58
+ nudge(SAFETY_RULES);
59
+ }
60
+
61
+ process.exit(0);
62
+ }
63
+
64
+ main().catch(() => process.exit(0));
@@ -915,48 +915,100 @@ function isPrimaryPaneDead(paneId) {
915
915
  }
916
916
  }
917
917
 
918
- async function runSpawnCleanupWatcher(sessionName, paneId, timingOptions = {}) {
919
- const timings = resolveCleanupWatcherTimingOptions(timingOptions);
920
- const startedAt = Date.now();
918
+ async function watchSpawnSessionExit(sessionName, options = {}) {
919
+ const paneId = options.paneId || `${sessionName}:0.0`;
920
+ const pollMs = parsePositiveInt(options.pollMs, DEFAULT_CLEANUP_WATCH_POLL_MS);
921
+ const graceMs = parsePositiveInt(options.graceMs, DEFAULT_CLEANUP_WATCH_GRACE_MS);
922
+ const maxWaitMs = parsePositiveInt(options.maxWaitMs, DEFAULT_CLEANUP_WATCH_MAX_MS);
923
+ const sessionExists = typeof options.sessionExists === "function"
924
+ ? options.sessionExists
925
+ : psmuxSessionExists;
926
+ const getPaneStatus = typeof options.getPaneStatus === "function"
927
+ ? options.getPaneStatus
928
+ : (targetPaneId) => ({ isDead: isPrimaryPaneDead(targetPaneId), exitCode: null });
929
+ const killSession = typeof options.killSession === "function"
930
+ ? options.killSession
931
+ : killPsmuxSession;
932
+ const now = typeof options.now === "function" ? options.now : Date.now;
933
+ const sleep = typeof options.sleep === "function" ? options.sleep : sleepMsAsync;
934
+ const startedAt = now();
921
935
  let consecutiveErrors = 0;
922
936
 
923
- while (Date.now() - startedAt <= timings.maxMs) {
924
- if (!psmuxSessionExists(sessionName)) {
925
- return;
937
+ while (now() - startedAt <= maxWaitMs) {
938
+ if (!sessionExists(sessionName)) {
939
+ return { cleaned: false, reason: "session-missing" };
926
940
  }
927
941
 
928
- const dead = isPrimaryPaneDead(paneId);
929
- if (dead === null) {
942
+ const paneStatus = getPaneStatus(paneId) || {};
943
+ if (paneStatus.isDead == null) {
930
944
  consecutiveErrors += 1;
931
- if (consecutiveErrors >= 10) return; // psmux 반복 실패 시 조기 종료
945
+ if (consecutiveErrors >= 10) {
946
+ return { cleaned: false, reason: "probe-failed" };
947
+ }
932
948
  } else {
933
949
  consecutiveErrors = 0;
934
950
  }
935
- if (dead === true) {
936
- await sleepMsAsync(timings.graceMs);
937
951
 
938
- if (!psmuxSessionExists(sessionName)) {
939
- return;
952
+ if (paneStatus.isDead === true) {
953
+ await sleep(graceMs);
954
+
955
+ if (!sessionExists(sessionName)) {
956
+ return { cleaned: false, reason: "session-missing" };
940
957
  }
941
958
 
942
- if (isPrimaryPaneDead(paneId) === true) {
943
- try { killPsmuxSession(sessionName); } catch {}
944
- return;
959
+ const afterGrace = getPaneStatus(paneId) || {};
960
+ if (afterGrace.isDead === true) {
961
+ killSession(sessionName);
962
+ return {
963
+ cleaned: true,
964
+ reason: "pane-dead",
965
+ exitCode: afterGrace.exitCode ?? paneStatus.exitCode ?? null,
966
+ };
945
967
  }
946
968
  }
947
969
 
948
- await sleepMsAsync(timings.pollMs);
970
+ await sleep(pollMs);
971
+ }
972
+
973
+ return { cleaned: false, reason: "timeout" };
974
+ }
975
+
976
+ async function runSpawnCleanupWatcher(sessionName, paneId, timingOptions = {}) {
977
+ await watchSpawnSessionExit(sessionName, {
978
+ paneId,
979
+ pollMs: timingOptions.pollMs,
980
+ graceMs: timingOptions.graceMs,
981
+ maxWaitMs: timingOptions.maxMs,
982
+ });
983
+ }
984
+
985
+ function startSpawnExitWatcher(sessionName, options = {}) {
986
+ if (!options.force && !shouldUsePsmux()) {
987
+ return false;
988
+ }
989
+
990
+ const paneId = options.paneId || `${sessionName}:0.0`;
991
+ const args = buildSpawnCleanupWatcherArgs(sessionName, paneId, {
992
+ graceMs: options.graceMs,
993
+ maxMs: options.maxMs,
994
+ pollMs: options.pollMs,
995
+ });
996
+ args[0] = options.scriptPath || args[0];
997
+ const spawnFn = typeof options.spawnFn === "function" ? options.spawnFn : spawn;
998
+ const child = spawnFn(options.execPath || process.execPath, args, {
999
+ detached: true,
1000
+ stdio: "ignore",
1001
+ windowsHide: true,
1002
+ });
1003
+ if (child && typeof child.unref === "function") {
1004
+ child.unref();
949
1005
  }
1006
+ return true;
950
1007
  }
951
1008
 
952
1009
  function startSpawnSessionCleanupWatcher(sessionName, paneId, timingOptions = {}) {
953
- const args = buildSpawnCleanupWatcherArgs(sessionName, paneId, timingOptions);
954
1010
  try {
955
- spawn(process.execPath, args, {
956
- detached: true,
957
- stdio: "ignore",
958
- windowsHide: true,
959
- }).unref();
1011
+ startSpawnExitWatcher(sessionName, { ...timingOptions, force: true, paneId });
960
1012
  } catch {
961
1013
  // watcher 시작 실패는 spawn 자체 실패로 보지 않는다.
962
1014
  }
@@ -1230,6 +1282,8 @@ export const __remoteSpawnTest = {
1230
1282
  buildPromptContext,
1231
1283
  parseArgs,
1232
1284
  rewritePromptPaths,
1285
+ startSpawnExitWatcher,
1233
1286
  stageRemotePromptFiles,
1234
1287
  validateTransferCandidate,
1288
+ watchSpawnSessionExit,
1235
1289
  };
@@ -69,10 +69,11 @@ export function createIsolatedSession(options = {}) {
69
69
  export function attachWithWindowsTerminal(sessionName, options = {}) {
70
70
  const profile = options.profile || DEFAULT_ATTACH_PROFILE;
71
71
  const title = options.title || sessionName;
72
+ const spawnFn = options.spawnFn || spawn;
72
73
 
73
74
  // sp (split-pane), not new-tab
74
75
  const wtArgs = ["sp", "-p", profile, "--title", title, "--", "psmux", "attach-session", "-t", sessionName];
75
- const child = spawn("wt.exe", wtArgs, { detached: true, stdio: "ignore", windowsHide: false });
76
+ const child = spawnFn("wt.exe", wtArgs, { detached: true, stdio: "ignore", windowsHide: false });
76
77
  child.unref();
77
78
  return wtArgs;
78
79
  }
@@ -154,7 +155,7 @@ async function main() {
154
155
  }
155
156
 
156
157
  if (!hasPsmux()) {
157
- process.stderr.write("ERROR: psmux 설치되어 있지 않습니다. npm install -g psmux\n");
158
+ process.stderr.write("ERROR: psmux 미설치. 설치: winget install marlocarlo.psmux (또는 npm i -g psmux)\n");
158
159
  process.exit(1);
159
160
  }
160
161
 
package/scripts/setup.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // - hud-qos-status.mjs를 ~/.claude/hud/에 동기화
5
5
  // - skills/를 ~/.claude/skills/에 동기화
6
6
 
7
- import { copyFileSync, mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, chmodSync, unlinkSync } from "fs";
7
+ import { copyFileSync, mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, chmodSync, unlinkSync, rmSync } from "fs";
8
8
  import { join, dirname } from "path";
9
9
  import { homedir } from "os";
10
10
  import { spawn, execFileSync } from "child_process";
@@ -52,6 +52,7 @@ const REQUIRED_CODEX_PROFILES = [
52
52
  'model = "gpt-5.3-codex-spark"',
53
53
  'model_reasoning_effort = "low"',
54
54
  ],
55
+ proOnly: true, // Pro 플랜 전용 — Plus/기본에서는 미동작
55
56
  },
56
57
  ];
57
58
 
@@ -62,6 +63,54 @@ const SKILL_ALIASES = [
62
63
  },
63
64
  ];
64
65
 
66
+ /** 패키지에서 제거된 스킬 목록 — setup/update 시 ~/.claude/skills/에서 자동 삭제 */
67
+ const DEPRECATED_SKILLS = [
68
+ "tfx-eval",
69
+ "tfx-learn",
70
+ "tfx-wrapup",
71
+ ];
72
+
73
+ /** 마이그레이션 대상 구형 Codex 모델 — 이 모델을 사용하는 프로필을 감지하여 안내 */
74
+ const LEGACY_CODEX_MODELS = ["o4-mini", "o3", "o3-pro", "o1", "o1-mini", "o1-pro", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "codex-mini-latest"];
75
+
76
+ /**
77
+ * ~/.claude/skills/ 에서 패키지에 없는 stale tfx-* 스킬을 제거한다.
78
+ * @param {string} skillsDst - ~/.claude/skills/ 경로
79
+ * @param {string} skillsSrc - 패키지의 skills/ 경로
80
+ * @returns {{ removed: string[], count: number }}
81
+ */
82
+ function cleanupStaleSkills(skillsDst, skillsSrc) {
83
+ const removed = [];
84
+ if (!existsSync(skillsDst)) return { removed, count: 0 };
85
+
86
+ const packageSkills = new Set();
87
+ if (existsSync(skillsSrc)) {
88
+ for (const name of readdirSync(skillsSrc)) {
89
+ packageSkills.add(name);
90
+ }
91
+ }
92
+ // aliases도 유효한 스킬로 등록
93
+ for (const { alias } of SKILL_ALIASES) {
94
+ packageSkills.add(alias);
95
+ }
96
+
97
+ for (const name of readdirSync(skillsDst)) {
98
+ // tfx- 접두사가 아닌 스킬은 사용자 커스텀 — 건드리지 않음
99
+ if (!name.startsWith("tfx-")) continue;
100
+ if (packageSkills.has(name)) continue;
101
+
102
+ // 패키지에 없는 tfx-* 스킬 → 삭제
103
+ const skillDir = join(skillsDst, name);
104
+ try {
105
+ rmSync(skillDir, { recursive: true, force: true });
106
+ removed.push(name);
107
+ } catch {
108
+ // 삭제 실패 시 무시
109
+ }
110
+ }
111
+ return { removed, count: removed.length };
112
+ }
113
+
65
114
  function buildAliasedSkillContent(srcContent, { alias, source }) {
66
115
  return srcContent
67
116
  .replace(/^name:\s*.+$/m, `name: ${alias}`)
@@ -141,6 +190,11 @@ const SYNC_MAP = [
141
190
  dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "claude-worker.mjs"),
142
191
  label: "hub/workers/claude-worker.mjs",
143
192
  },
193
+ {
194
+ src: join(PLUGIN_ROOT, "hub", "workers", "worker-utils.mjs"),
195
+ dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "worker-utils.mjs"),
196
+ label: "hub/workers/worker-utils.mjs",
197
+ },
144
198
  {
145
199
  src: join(PLUGIN_ROOT, "hub", "workers", "factory.mjs"),
146
200
  dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "factory.mjs"),
@@ -216,6 +270,26 @@ const SYNC_MAP = [
216
270
  dst: join(CLAUDE_DIR, "scripts", "lib", "mcp-server-catalog.mjs"),
217
271
  label: "lib/mcp-server-catalog.mjs",
218
272
  },
273
+ {
274
+ src: join(PLUGIN_ROOT, "scripts", "lib", "mcp-manifest.mjs"),
275
+ dst: join(CLAUDE_DIR, "scripts", "lib", "mcp-manifest.mjs"),
276
+ label: "lib/mcp-manifest.mjs",
277
+ },
278
+ {
279
+ src: join(PLUGIN_ROOT, "scripts", "lib", "hook-utils.mjs"),
280
+ dst: join(CLAUDE_DIR, "scripts", "lib", "hook-utils.mjs"),
281
+ label: "lib/hook-utils.mjs",
282
+ },
283
+ {
284
+ src: join(PLUGIN_ROOT, "scripts", "psmux-safety-guard.mjs"),
285
+ dst: join(CLAUDE_DIR, "scripts", "psmux-safety-guard.mjs"),
286
+ label: "psmux-safety-guard.mjs",
287
+ },
288
+ {
289
+ src: join(PLUGIN_ROOT, "scripts", "lib", "cross-review-utils.mjs"),
290
+ dst: join(CLAUDE_DIR, "scripts", "lib", "cross-review-utils.mjs"),
291
+ label: "lib/cross-review-utils.mjs",
292
+ },
219
293
  {
220
294
  src: join(PLUGIN_ROOT, "scripts", "lib", "keyword-rules.mjs"),
221
295
  dst: join(CLAUDE_DIR, "scripts", "lib", "keyword-rules.mjs"),
@@ -331,13 +405,20 @@ function ensureCodexProfiles() {
331
405
  writeFileSync(CODEX_CONFIG_PATH, updated, "utf8");
332
406
  }
333
407
 
334
- return changed;
335
- } catch {
336
- return 0;
408
+ return { ok: true, changed };
409
+ } catch (error) {
410
+ return { ok: false, changed: 0, message: error.message };
337
411
  }
338
412
  }
339
413
 
340
- export { replaceProfileSection, hasProfileSection, detectDevMode, SYNC_MAP, BREADCRUMB_PATH, PLUGIN_ROOT, CLAUDE_DIR, SKILL_ALIASES };
414
+ export {
415
+ replaceProfileSection, hasProfileSection, escapeRegExp, detectDevMode,
416
+ SYNC_MAP, BREADCRUMB_PATH, PLUGIN_ROOT, CLAUDE_DIR,
417
+ SKILL_ALIASES, REQUIRED_CODEX_PROFILES,
418
+ DEPRECATED_SKILLS, LEGACY_CODEX_MODELS,
419
+ buildAliasedSkillContent, syncAliasedSkillDir, getVersion, ensureCodexProfiles,
420
+ cleanupStaleSkills,
421
+ };
341
422
 
342
423
  async function main() {
343
424
  const isSync = process.argv.includes("--sync");
@@ -739,24 +820,49 @@ if (existsSync(HUB_PID_FILE)) {
739
820
  }
740
821
 
741
822
  // ── psmux 자동 설치 (Windows, headless 모드용) ──
823
+ // psmux: Windows용 터미널 멀티플렉서. Codex/Gemini CLI를 병렬 세션으로 실행할 때 필요.
824
+ // 없어도 triflux 기본 기능은 동작하지만, headless 멀티모델 오케스트레이션이 비활성화됨.
742
825
 
826
+ let psmuxInstalled = false;
743
827
  if (process.platform === "win32") {
744
828
  try {
745
829
  execFileSync("where", ["psmux"], { stdio: "ignore" });
830
+ psmuxInstalled = true;
746
831
  } catch {
747
832
  // psmux 미설치 — winget으로 자동 설치 시도
748
- console.log(" psmux 미설치 — winget으로 설치 중...");
833
+ console.log(" psmux 미설치 — 자동 설치 시도 중...");
749
834
  try {
750
835
  execFileSync("winget", ["install", "--id", "marlocarlo.psmux", "--accept-package-agreements", "--accept-source-agreements"], {
751
836
  stdio: ["ignore", "pipe", "pipe"],
752
837
  timeout: 60000,
753
838
  });
754
839
  console.log(" \x1b[32m✓\x1b[0m psmux 설치 완료");
840
+ psmuxInstalled = true;
755
841
  synced++;
756
842
  } catch {
757
- console.log(" \x1b[33m⚠\x1b[0m psmux 자동 설치 실패 — 수동 설치: winget install psmux");
843
+ console.log([
844
+ " \x1b[33m⚠\x1b[0m psmux 자동 설치 실패 — 수동 설치 방법:",
845
+ " \x1b[36m옵션 1:\x1b[0m winget install marlocarlo.psmux",
846
+ " \x1b[36m옵션 2:\x1b[0m scoop install psmux",
847
+ " \x1b[36m옵션 3:\x1b[0m npm install -g psmux",
848
+ " \x1b[2m(없어도 기본 기능은 동작합니다 — 멀티모델 병렬 실행만 비활성화)\x1b[0m",
849
+ ].join("\n"));
758
850
  }
759
851
  }
852
+ } else {
853
+ // non-Windows: tmux 사용 (psmux 불필요)
854
+ psmuxInstalled = true;
855
+ }
856
+
857
+ // ── stale 스킬 정리 (패키지에서 제거된 tfx-* 스킬 삭제) ──
858
+ {
859
+ const skillsDst = join(CLAUDE_DIR, "skills");
860
+ const skillsSrc = join(PLUGIN_ROOT, "skills");
861
+ const cleanup = cleanupStaleSkills(skillsDst, skillsSrc);
862
+ if (cleanup.count > 0) {
863
+ console.log(` \x1b[32m✓\x1b[0m ${cleanup.count}개 구형 스킬 제거: ${cleanup.removed.join(", ")}`);
864
+ synced++;
865
+ }
760
866
  }
761
867
 
762
868
  // ── HUD 에러 캐시 자동 클리어 (업데이트/재설치 시) ──
@@ -814,8 +920,8 @@ if (process.platform === "win32") {
814
920
 
815
921
  // ── Codex 프로필 자동 보정 ──
816
922
 
817
- const codexProfilesAdded = ensureCodexProfiles();
818
- if (codexProfilesAdded > 0) {
923
+ const codexProfileResult = ensureCodexProfiles();
924
+ if (codexProfileResult.changed > 0) {
819
925
  synced++;
820
926
  }
821
927
 
@@ -877,6 +983,7 @@ ${B}╚════════════════════════
877
983
  ${G}✓${R} hud-qos-status → ~/.claude/hud/
878
984
  ${G}✓${R} ${synced > 0 ? synced + " files synced" : "all files up to date"}
879
985
  ${G}✓${R} HUD statusLine → settings.json
986
+ ${psmuxInstalled ? `${G}✓${R} psmux → headless 멀티모델 오케스트레이션` : `${Y}○${R} psmux 미설치 → ${D}winget install marlocarlo.psmux${R} ${D}(선택)${R}`}
880
987
 
881
988
  ${B}Commands:${R}
882
989
  ${C}triflux${R} setup 파일 동기화 + HUD 설정
@@ -900,6 +1007,19 @@ ${B}Skills (Claude Code):${R}
900
1007
  ${Y}!${R} 세션 재시작 후 스킬이 활성화됩니다
901
1008
  ${D}https://github.com/tellang/triflux${R}
902
1009
  `);
1010
+
1011
+ // ── GitHub Star 체크 (비인터랙티브 — postinstall에서는 confirm 불가) ──
1012
+ try {
1013
+ execFileSync("gh", ["auth", "status"], { timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1014
+ try {
1015
+ execFileSync("gh", ["api", "user/starred/tellang/triflux"], { timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1016
+ console.log(` ${G}⭐${R} 이미 함께하고 계시군요.`);
1017
+ } catch {
1018
+ console.log(` ${Y}⭐${R} 하나가 큰 차이를 만듭니다. ${D}https://github.com/tellang/triflux${R}`);
1019
+ }
1020
+ } catch {
1021
+ // gh 미설치/미인증 — 무시
1022
+ }
903
1023
  }
904
1024
 
905
1025
  process.exit(0);