triflux 8.9.1 → 8.10.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 CHANGED
@@ -66,6 +66,8 @@ tfx setup
66
66
  /tfx-debate "Redis vs PostgreSQL LISTEN/NOTIFY for real-time events"
67
67
 
68
68
  # Persistence — 완료될 때까지 멈추지 않음
69
+ /tfx-persist "implement full auth flow with tests"
70
+ # 호환 별칭
69
71
  /tfx-ralph "implement full auth flow with tests"
70
72
 
71
73
  # Team — Multi-CLI 병렬 오케스트레이션
@@ -91,7 +93,7 @@ tfx setup
91
93
  - **Anti-Herding** — 1라운드는 상호 참조 없이 병렬 실행하여 편향 오염을 원천 차단
92
94
  - **Expert Panel** — `tfx-panel`을 통한 가상 전문가 시뮬레이션 (Fowler, Newman, Porter 등)
93
95
  - **94% 토큰 절감** — `tfx-index`가 58K 토큰 분량의 파일 읽기를 3KB 프로젝트 맵으로 대체
94
- - **Persistence Loop** — `tfx-ralph`(3자 검증) `tfx-sisyphus`(자동 라우팅)가 검증 완료까지 반복 실행
96
+ - **Persistence Loop** — `tfx-persist`(정식 이름, 3자 검증), `/tfx-ralph`(호환 별칭), `tfx-sisyphus`(자동 라우팅)가 검증 완료까지 반복 실행
95
97
  - **Hub IPC** — Named Pipe 및 HTTP MCP 브리지를 활용한 초고속 상주형 Hub 서버
96
98
  - **psmux / Windows 네이티브** — `tmux`(WSL)와 `psmux`(Windows Terminal) 하이브리드 지원
97
99
 
@@ -184,7 +186,7 @@ Phase 3: Resolution (합의율 < 70%일 경우)
184
186
 
185
187
  | 스킬 | 유형 | 설명 | 토큰 |
186
188
  |------|------|------|------|
187
- | `tfx-ralph` | Deep | 완료될 때까지 3자 검증 기반 반복 실행 | 가변 |
189
+ | `tfx-persist` | Deep | 완료될 때까지 3자 검증 기반 반복 실행 (`/tfx-ralph` 호환 별칭) | 가변 |
188
190
  | `tfx-sisyphus` | Light | 모델 에스컬레이션을 갖춘 자동 라우팅 실행 | 가변 |
189
191
 
190
192
  ### 메타 및 유틸리티
@@ -306,7 +308,7 @@ tfx setup
306
308
  /tfx-debate "Redis vs PostgreSQL LISTEN/NOTIFY for real-time events"
307
309
 
308
310
  # Persistence — 완료될 때까지 멈추지 않음
309
- /tfx-ralph "implement full auth flow with tests"
311
+ /tfx-persist "implement full auth flow with tests" # /tfx-ralph도 동작
310
312
 
311
313
  # Team — Multi-CLI 병렬 오케스트레이션
312
314
  /tfx-multi "refactor auth + update UI + add tests"
package/README.md CHANGED
@@ -66,6 +66,8 @@ tfx setup
66
66
  /tfx-debate "Redis vs PostgreSQL LISTEN/NOTIFY for real-time events"
67
67
 
68
68
  # Persistence — don't stop until done
69
+ /tfx-persist "implement full auth flow with tests"
70
+ # Compatibility alias
69
71
  /tfx-ralph "implement full auth flow with tests"
70
72
 
71
73
  # Team — Multi-CLI parallel orchestration
@@ -91,7 +93,7 @@ tfx setup
91
93
  - **Anti-Herding** — Round 1 runs in parallel with zero cross-visibility to prevent bias contamination
92
94
  - **Expert Panel** — Virtual expert simulation (Fowler, Newman, Porter, etc.) via `tfx-panel`
93
95
  - **94% Token Reduction** — `tfx-index` creates a 3KB project map replacing 58K tokens of file reads
94
- - **Persistence Loops** — `tfx-ralph` (3-party verified) and `tfx-sisyphus` (auto-routing) run until verified complete
96
+ - **Persistence Loops** — `tfx-persist` (canonical, 3-party verified), `/tfx-ralph` (compat alias), and `tfx-sisyphus` (auto-routing) run until verified complete
95
97
  - **Hub IPC** — Lightning-fast resident Hub server with Named Pipe & HTTP MCP bridge
96
98
  - **psmux / Windows Native** — Hybrid support for `tmux` (WSL) and `psmux` (Windows Terminal)
97
99
 
@@ -184,7 +186,7 @@ Phase 3: Resolution (if consensus < 70%)
184
186
 
185
187
  | Skill | Type | Description | Tokens |
186
188
  |-------|------|-------------|--------|
187
- | `tfx-ralph` | Deep | 3-party verified persistence loop until done | varies |
189
+ | `tfx-persist` | Deep | 3-party verified persistence loop until done (`/tfx-ralph` compat alias) | varies |
188
190
  | `tfx-sisyphus` | Light | Auto-routing execution with model escalation | varies |
189
191
 
190
192
  ### Meta & Utility
@@ -306,7 +308,7 @@ tfx setup
306
308
  /tfx-debate "Redis vs PostgreSQL LISTEN/NOTIFY for real-time events"
307
309
 
308
310
  # Persistence — Don't stop until done
309
- /tfx-ralph "implement full auth flow with tests"
311
+ /tfx-persist "implement full auth flow with tests" # /tfx-ralph also works
310
312
 
311
313
  # Team — Multi-CLI parallel orchestration
312
314
  /tfx-multi "refactor auth + update UI + add tests"
package/bin/triflux.mjs CHANGED
@@ -42,6 +42,49 @@ const REQUIRED_CODEX_PROFILES = [
42
42
  },
43
43
  ];
44
44
 
45
+ const SKILL_ALIASES = [
46
+ {
47
+ alias: "tfx-ralph",
48
+ source: "tfx-persist",
49
+ },
50
+ ];
51
+
52
+ function buildAliasedSkillContent(srcContent, { alias, source }) {
53
+ return srcContent
54
+ .replace(/^name:\s*.+$/m, `name: ${alias}`)
55
+ .replaceAll(source, alias)
56
+ .replace(/^#\s+.+$/m, `# ${alias} — Compatibility Alias for ${source}`);
57
+ }
58
+
59
+ function syncAliasedSkillDir(srcDir, dstDir, { alias, source }) {
60
+ if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
61
+
62
+ let count = 0;
63
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
64
+ const srcPath = join(srcDir, entry.name);
65
+ const dstPath = join(dstDir, entry.name);
66
+
67
+ if (entry.isDirectory()) {
68
+ count += syncAliasedSkillDir(srcPath, dstPath, { alias, source });
69
+ continue;
70
+ }
71
+
72
+ if (!entry.name.endsWith(".md")) continue;
73
+
74
+ const rawContent = readFileSync(srcPath, "utf8");
75
+ const nextContent = entry.name === "SKILL.md"
76
+ ? buildAliasedSkillContent(rawContent, { alias, source })
77
+ : rawContent;
78
+
79
+ if (!existsSync(dstPath) || readFileSync(dstPath, "utf8") !== nextContent) {
80
+ writeFileSync(dstPath, nextContent, "utf8");
81
+ count++;
82
+ }
83
+ }
84
+
85
+ return count;
86
+ }
87
+
45
88
  // ── 색상 체계 (triflux brand: amber/orange accent) ──
46
89
  const CYAN = "\x1b[36m";
47
90
  const GREEN = "\x1b[32m";
@@ -678,6 +721,12 @@ function listSkillSyncActions() {
678
721
  if (!existsSync(src)) continue;
679
722
  actions.push(describeSyncAction(src, dst, `skill:${name}`));
680
723
  }
724
+ for (const { alias, source } of SKILL_ALIASES) {
725
+ const src = join(skillsSrc, source, "SKILL.md");
726
+ const dst = join(CLAUDE_DIR, "skills", alias, "SKILL.md");
727
+ if (!existsSync(src)) continue;
728
+ actions.push(describeSyncAction(src, dst, `skill-alias:${alias}`));
729
+ }
681
730
  return actions;
682
731
  }
683
732
 
@@ -806,6 +855,13 @@ function cmdSetup(options = {}) {
806
855
  }
807
856
  }
808
857
  }
858
+ for (const { alias, source } of SKILL_ALIASES) {
859
+ const srcDir = join(skillsSrc, source);
860
+ const src = join(srcDir, "SKILL.md");
861
+ if (!existsSync(src)) continue;
862
+ skillTotal++;
863
+ skillCount += syncAliasedSkillDir(srcDir, join(skillsDst, alias), { alias, source });
864
+ }
809
865
  if (skillCount > 0) {
810
866
  ok(`스킬: ${skillCount}/${skillTotal}개 업데이트됨`);
811
867
  } else {
@@ -1765,6 +1821,8 @@ function cmdList(options = {}) {
1765
1821
  const installedSkills = join(CLAUDE_DIR, "skills");
1766
1822
  const packageSkills = [];
1767
1823
  const userSkills = [];
1824
+ const aliasNames = new Set(SKILL_ALIASES.map(({ alias }) => alias));
1825
+ const skillAliases = [];
1768
1826
 
1769
1827
  if (existsSync(pluginSkills)) {
1770
1828
  for (const name of readdirSync(pluginSkills).sort()) {
@@ -1775,10 +1833,15 @@ function cmdList(options = {}) {
1775
1833
  }
1776
1834
  }
1777
1835
 
1836
+ for (const { alias, source } of SKILL_ALIASES) {
1837
+ const dst = join(installedSkills, alias, "SKILL.md");
1838
+ skillAliases.push({ alias, source, installed: existsSync(dst) });
1839
+ }
1840
+
1778
1841
  const pkgNames = new Set(existsSync(pluginSkills) ? readdirSync(pluginSkills) : []);
1779
1842
  if (existsSync(installedSkills)) {
1780
1843
  for (const name of readdirSync(installedSkills).sort()) {
1781
- if (pkgNames.has(name)) continue;
1844
+ if (pkgNames.has(name) || aliasNames.has(name)) continue;
1782
1845
  const skill = join(installedSkills, name, "SKILL.md");
1783
1846
  if (!existsSync(skill)) continue;
1784
1847
  userSkills.push(name);
@@ -1788,6 +1851,7 @@ function cmdList(options = {}) {
1788
1851
  if (json) {
1789
1852
  printJson({
1790
1853
  package_skills: packageSkills,
1854
+ skill_aliases: skillAliases,
1791
1855
  user_skills: userSkills,
1792
1856
  install_path: installedSkills,
1793
1857
  });
@@ -1812,6 +1876,15 @@ function cmdList(options = {}) {
1812
1876
  }
1813
1877
  if (userSkills.length === 0) console.log(` ${GRAY}없음${RESET}`);
1814
1878
 
1879
+ if (skillAliases.length > 0) {
1880
+ section("호환 alias");
1881
+ for (const entry of skillAliases) {
1882
+ const icon = entry.installed ? `${GREEN_BRIGHT}↳${RESET}` : `${RED_BRIGHT}↳${RESET}`;
1883
+ const status = entry.installed ? "" : ` ${GRAY}(미설치)${RESET}`;
1884
+ console.log(` ${icon} ${BOLD}${entry.alias}${RESET} ${GRAY}→ ${entry.source}${RESET}${status}`);
1885
+ }
1886
+ }
1887
+
1815
1888
  console.log(`\n ${LINE}`);
1816
1889
  console.log(` ${GRAY}${installedSkills}${RESET}\n`);
1817
1890
  }
@@ -11,7 +11,7 @@ const SCAN_SCRIPT_PATH = join(CLEANUP_SCRIPT_DIR, "scan-processes.ps1");
11
11
  const TREE_SCRIPT_PATH = join(CLEANUP_SCRIPT_DIR, "get-ancestor-tree.ps1");
12
12
 
13
13
  // 스크립트 버전 — 내용 변경 시 증가하여 캐시된 스크립트를 갱신
14
- const SCRIPT_VERSION = 2;
14
+ const SCRIPT_VERSION = 3;
15
15
  const VERSION_FILE = join(CLEANUP_SCRIPT_DIR, ".version");
16
16
 
17
17
  /**
@@ -32,6 +32,69 @@ export function isPidAlive(pid) {
32
32
  }
33
33
  }
34
34
 
35
+ /**
36
+ * 동기적 sleep. Atomics.wait 우선, 불가 시 busy-wait 폴백.
37
+ */
38
+ function sleepSyncMs(ms) {
39
+ try {
40
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
41
+ } catch {
42
+ const end = Date.now() + ms;
43
+ while (Date.now() < end) { /* spin */ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 고아 PID 목록에 SIGTERM → 3초 대기 → SIGKILL 에스컬레이션을 적용한다.
49
+ * PID 재사용 레이스 방어: SIGTERM 전 alive 확인, SIGKILL 전 재검증.
50
+ * @param {number[]} orphanPids
51
+ * @param {Map<number, {ppid: number, name: string}>} [procMap] PID 재사용 감지용 스냅샷
52
+ * @returns {number} killed count
53
+ */
54
+ function killWithEscalation(orphanPids, procMap) {
55
+ if (orphanPids.length === 0) return 0;
56
+
57
+ // SIGTERM 전 alive 스냅샷 — 이미 죽은 PID는 카운트에서 제외
58
+ const aliveBeforeKill = new Set(orphanPids.filter(pid => isPidAlive(pid)));
59
+
60
+ for (const pid of aliveBeforeKill) {
61
+ try { process.kill(pid, "SIGTERM"); } catch {}
62
+ }
63
+
64
+ sleepSyncMs(3000);
65
+
66
+ let killed = 0;
67
+ for (const pid of aliveBeforeKill) {
68
+ if (isPidAlive(pid)) {
69
+ // PID 재사용 방어: procMap이 있으면 스캔 시점의 ppid와 현재 ppid 비교
70
+ // ppid가 변경되었으면 PID가 재사용된 것이므로 kill하지 않���
71
+ if (procMap) {
72
+ const snapshot = procMap.get(pid);
73
+ if (snapshot) {
74
+ try {
75
+ const current = execSync(
76
+ process.platform === "win32"
77
+ ? `powershell -NoProfile -WindowStyle Hidden -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}' -ErrorAction SilentlyContinue).ParentProcessId"`
78
+ : `ps -o ppid= -p ${pid}`,
79
+ { encoding: "utf8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
80
+ );
81
+ const currentPpid = Number.parseInt(current.trim(), 10);
82
+ if (Number.isFinite(currentPpid) && currentPpid !== snapshot.ppid) {
83
+ continue; // PID 재사용 감지 — skip
84
+ }
85
+ } catch {
86
+ // 조회 실패 시 안전하게 skip
87
+ continue;
88
+ }
89
+ }
90
+ }
91
+ try { process.kill(pid, "SIGKILL"); } catch {}
92
+ }
93
+ if (!isPidAlive(pid)) killed++;
94
+ }
95
+ return killed;
96
+ }
97
+
35
98
  /**
36
99
  * PowerShell 헬퍼 스크립트를 임시 디렉토리에 생성한다.
37
100
  * bash의 $_ 이스케이핑 문제를 피하기 위해 -File로 실행.
@@ -69,10 +132,12 @@ function ensureHelperScripts() {
69
132
  }
70
133
 
71
134
  if (!existsSync(SCAN_SCRIPT_PATH)) {
72
- // node.exe + bash.exe + cmd.exe 전체를 스캔하여 PID,ParentPID,Name 출력
135
+ // CLI + + 런타임 전체를 스캔하여 PID,ParentPID,Name 출력
136
+ // codex/claude/pwsh/uvx 누락 시 중간 프로세스가 alive 판정되어 고아 트리 전체가 보호됨
137
+ // 예: WT(dead)→pwsh(alive,미스캔)→codex→cmd→node — pwsh에서 isPidAlive=true로 끊김
73
138
  writeFileSync(SCAN_SCRIPT_PATH, [
74
139
  "$ErrorActionPreference = 'SilentlyContinue'",
75
- "Get-CimInstance Win32_Process -Filter \"Name='node.exe' OR Name='bash.exe' OR Name='cmd.exe'\" | ForEach-Object {",
140
+ "Get-CimInstance Win32_Process -Filter \"Name='node.exe' OR Name='bash.exe' OR Name='cmd.exe' OR Name='codex.exe' OR Name='claude.exe' OR Name='pwsh.exe' OR Name='uvx.exe'\" | ForEach-Object {",
76
141
  ' Write-Output "$($_.ProcessId),$($_.ParentProcessId),$($_.Name)"',
77
142
  "}",
78
143
  ].join("\n"), "utf8");
@@ -121,18 +186,27 @@ function hasLiveAncestorChain(pid, procMap, protectedPids) {
121
186
  return false;
122
187
  }
123
188
 
189
+ // kill 대상 프로세스 이름 (Windows)
190
+ // codex/claude도 포함: protectedPids + hasLiveAncestorChain이 활성 인스턴스를 보호하므로
191
+ // 고아(부모 dead + 자식 dead)만 kill됨. pwsh.exe는 사용자 인터랙티브 쉘이므로 제외.
192
+ const KILLABLE_NAMES = new Set([
193
+ "node.exe", "bash.exe", "cmd.exe", "uvx.exe",
194
+ "codex.exe", "claude.exe",
195
+ ]);
196
+
124
197
  /**
125
- * 고아 프로세스 트리를 정리한다 (node.exe + bash.exe + cmd.exe).
198
+ * 고아 프로세스 트리를 정리한다 (node.exe + bash.exe + cmd.exe + uvx.exe).
126
199
  * Windows 전용 — Agent 서브프로세스가 MCP 서버, bash 래퍼, cmd 래퍼를 남기는 문제 대응.
127
200
  *
128
201
  * 전략: 부모 체인을 루트까지 추적하여, 체인 중간에 죽은 프로세스가 있으면
129
202
  * 해당 프로세스 아래의 전체 트리를 고아로 판정하고 정리.
203
+ * 스캔 범위에는 codex/claude/pwsh도 포함하여 체인 추적 정확도를 높인다.
130
204
  *
131
205
  * 보호 대상: 현재 프로세스 조상 트리, Hub PID
132
206
  * @returns {{ killed: number, remaining: number }}
133
207
  */
134
208
  export function cleanupOrphanNodeProcesses() {
135
- if (process.platform !== "win32") return { killed: 0, remaining: 0 };
209
+ if (process.platform !== "win32") return cleanupOrphansUnix();
136
210
 
137
211
  ensureHelperScripts();
138
212
 
@@ -164,7 +238,7 @@ export function cleanupOrphanNodeProcesses() {
164
238
  }
165
239
  } catch {}
166
240
 
167
- // 전체 프로세스 맵 구축 (node + bash + cmd)
241
+ // 전체 프로세스 맵 구축 (node + bash + cmd + codex + claude + pwsh + uvx)
168
242
  const procMap = new Map();
169
243
  try {
170
244
  const output = execSync(
@@ -184,21 +258,18 @@ export function cleanupOrphanNodeProcesses() {
184
258
  }
185
259
  } catch {}
186
260
 
187
- // 고아 판정 + 정리
188
- let killed = 0;
261
+ // 고아 판정 + 정리 (SIGTERM → 3s → SIGKILL 에스컬레이션)
262
+ // CLI 도구(codex/claude/pwsh)는 체인 추적용으로만 스캔 — kill 대상에서 제외
263
+ const orphanPids = [];
189
264
  for (const [pid, info] of procMap) {
190
265
  if (protectedPids.has(pid)) continue;
191
-
192
- // 조상 체인이 살아있으면 건드리지 않음
266
+ if (!KILLABLE_NAMES.has(info.name?.toLowerCase())) continue;
193
267
  if (hasLiveAncestorChain(pid, procMap, protectedPids)) continue;
194
-
195
- // 고아 → 종료
196
- try {
197
- process.kill(pid, "SIGTERM");
198
- killed++;
199
- } catch {}
268
+ orphanPids.push(pid);
200
269
  }
201
270
 
271
+ const killed = killWithEscalation(orphanPids, procMap);
272
+
202
273
  // 남은 프로세스 수 확인
203
274
  let remaining = 0;
204
275
  try {
@@ -211,3 +282,79 @@ export function cleanupOrphanNodeProcesses() {
211
282
 
212
283
  return { killed, remaining };
213
284
  }
285
+
286
+ /**
287
+ * Unix/macOS 고아 프로세스 정리.
288
+ * `ps -eo pid,ppid,comm` 기반 프로세스 맵 → 동일한 조상 체인 판정 → SIGKILL 에스컬레이션.
289
+ * @returns {{ killed: number, remaining: number }}
290
+ */
291
+ function cleanupOrphansUnix() {
292
+ const myPid = process.pid;
293
+
294
+ // Hub PID 보호
295
+ const protectedPids = new Set();
296
+ protectedPids.add(myPid);
297
+ try {
298
+ const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
299
+ if (existsSync(hubPidPath)) {
300
+ const hubPid = Number(JSON.parse(readFileSync(hubPidPath, "utf8"))?.pid);
301
+ if (Number.isFinite(hubPid) && hubPid > 0) protectedPids.add(hubPid);
302
+ }
303
+ } catch {}
304
+
305
+ // 현재 프로세스의 조상 트리 보호
306
+ try {
307
+ let current = myPid;
308
+ for (let i = 0; i < 10; i++) {
309
+ protectedPids.add(current);
310
+ const output = execSync(`ps -o ppid= -p ${current}`, {
311
+ encoding: "utf8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
312
+ });
313
+ const ppid = Number.parseInt(output.trim(), 10);
314
+ if (!Number.isFinite(ppid) || ppid <= 1) break;
315
+ current = ppid;
316
+ }
317
+ } catch {}
318
+
319
+ // 프로세스 맵 구축 (런타임 + CLI — 체인 추적 정확도를 위해 CLI도 포함)
320
+ const procMap = new Map();
321
+ try {
322
+ const output = execSync("ps -eo pid,ppid,comm", {
323
+ encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
324
+ });
325
+ for (const line of output.split("\n").slice(1)) {
326
+ const parts = line.trim().split(/\s+/);
327
+ if (parts.length < 3) continue;
328
+ const pid = Number.parseInt(parts[0], 10);
329
+ const ppid = Number.parseInt(parts[1], 10);
330
+ const name = parts.slice(2).join(" ");
331
+ if (Number.isFinite(pid) && pid > 0 && /^(node|bash|sh|python|codex|claude|uvx)/.test(name)) {
332
+ procMap.set(pid, { ppid, name });
333
+ }
334
+ }
335
+ } catch {}
336
+
337
+ // kill 대상: node, python, codex, claude, uvx — bash/sh는 사용자 인터랙티브 쉘 가능성
338
+ const killableUnix = /^(node|python|codex|claude|uvx)/;
339
+
340
+ // 고아 판정 + SIGKILL 에스컬레이션
341
+ const orphanPids = [];
342
+ for (const [pid, info] of procMap) {
343
+ if (protectedPids.has(pid)) continue;
344
+ if (!killableUnix.test(info.name)) continue;
345
+ if (hasLiveAncestorChain(pid, procMap, protectedPids)) continue;
346
+ orphanPids.push(pid);
347
+ }
348
+
349
+ const killed = killWithEscalation(orphanPids, procMap);
350
+
351
+ let remaining = 0;
352
+ try {
353
+ const output = execSync("ps -eo comm | grep -c '^node$'", {
354
+ encoding: "utf8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
355
+ });
356
+ remaining = Number.parseInt(output.trim(), 10) || 0;
357
+ } catch {}
358
+
359
+ return { killed, remaining };
360
+ }
package/hub/server.mjs CHANGED
@@ -5,6 +5,7 @@ import { extname, join, resolve, sep } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { execSync as execSyncHub } from 'node:child_process';
8
9
 
9
10
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
10
11
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -779,8 +780,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
779
780
  }, 60000);
780
781
  sessionTimer.unref();
781
782
 
782
- // 고아 node.exe 프로세스 주기적 정리 (5분마다)
783
- // Agent 서브프로세스가 MCP 서버를 남기는 Windows 이슈 대응
783
+ // 고아 node.exe 프로세스 + stale spawn 세션 주기적 정리 (5분마다)
784
784
  const orphanCleanupTimer = setInterval(() => {
785
785
  try {
786
786
  const { killed } = cleanupOrphanNodeProcesses();
@@ -788,6 +788,14 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
788
788
  hubLog.info({ killed }, 'hub.orphan_cleanup');
789
789
  }
790
790
  } catch {}
791
+
792
+ // stale tfx-spawn-* psmux 세션 정리 (30분 이상 idle)
793
+ try {
794
+ const staleKilled = cleanupStaleSpawnSessions(hubLog);
795
+ if (staleKilled > 0) {
796
+ hubLog.info({ killed: staleKilled }, 'hub.stale_spawn_cleanup');
797
+ }
798
+ } catch {}
791
799
  }, 5 * 60 * 1000);
792
800
  orphanCleanupTimer.unref();
793
801
 
@@ -920,6 +928,56 @@ export function getHubInfo() {
920
928
  }
921
929
  }
922
930
 
931
+ /**
932
+ * stale tfx-spawn-* psmux 세션을 감지하고 정리한다.
933
+ * 30분 이상 경과 + pane이 idle 쉘 프롬프트만 표시 → kill.
934
+ * @param {object} [log] logger (optional)
935
+ * @returns {number} killed session count
936
+ */
937
+ function cleanupStaleSpawnSessions(log) {
938
+ const MAX_AGE_MS = 30 * 60 * 1000;
939
+ const IDLE_PROMPT_RE = /^(PS\s|[$%>#]\s*$|\w+@[\w.-]+[:\s]|╰─|╭─|[fb]wd-i-search:|client_loop:\s|Connection\s+(reset|closed))/;
940
+ const execOpts = { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true };
941
+
942
+ let killed = 0;
943
+ let raw;
944
+ try {
945
+ raw = execSyncHub("psmux list-sessions", execOpts);
946
+ } catch {
947
+ return 0; // psmux 없거나 실패
948
+ }
949
+
950
+ const now = Date.now();
951
+ for (const line of raw.split(/\r?\n/)) {
952
+ const match = line.match(/^(tfx-spawn-[^:]+):\s+\d+\s+windows?\s+\(created\s+(.+)\)/);
953
+ if (!match) continue;
954
+
955
+ const [, sessionName, createdStr] = match;
956
+ if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) continue; // shell injection 방지
957
+ const created = new Date(createdStr).getTime();
958
+ if (!Number.isFinite(created) || now - created < MAX_AGE_MS) continue;
959
+
960
+ // pane 내용 확인 — 마지막 3줄 중 idle 쉘 프롬프트가 있는지
961
+ try {
962
+ const pane = execSyncHub(`psmux capture-pane -t "${sessionName}:0.0" -p`, execOpts);
963
+ const tailLines = pane.split(/\r?\n/).filter((l) => l.trim()).slice(-3);
964
+ const hasIdleLine = tailLines.some((l) => IDLE_PROMPT_RE.test(l.trim()));
965
+ if (!hasIdleLine) continue; // 아직 활성 — 건드리지 않음
966
+ } catch {
967
+ continue; // pane 접근 실패 — 건드리지 않음
968
+ }
969
+
970
+ // stale + idle → 정리
971
+ try {
972
+ execSyncHub(`psmux kill-session -t "${sessionName}"`, execOpts);
973
+ killed++;
974
+ if (log) log.info({ session: sessionName, ageMin: Math.round((now - created) / 60000) }, "hub.stale_spawn_killed");
975
+ } catch {}
976
+ }
977
+
978
+ return killed;
979
+ }
980
+
923
981
  const selfRun = process.argv[1]?.replace(/\\/g, '/').endsWith('hub/server.mjs');
924
982
  if (selfRun) {
925
983
  const port = parseInt(process.env.TFX_HUB_PORT || '27888', 10);
@@ -928,6 +986,8 @@ if (selfRun) {
928
986
  startHub({ port, dbPath }).then((info) => {
929
987
  const shutdown = async (signal) => {
930
988
  hubLog.info({ signal }, 'hub.stopping');
989
+ try { cleanupOrphanNodeProcesses(); } catch {}
990
+ try { cleanupStaleSpawnSessions(hubLog); } catch {}
931
991
  await info.stop();
932
992
  process.exit(0);
933
993
  };
@@ -1,6 +1,6 @@
1
1
  // hub/team/native-supervisor.mjs — tmux 없이 멀티 CLI를 직접 띄우는 네이티브 팀 런타임
2
2
  import { createServer } from "node:http";
3
- import { spawn } from "node:child_process";
3
+ import { spawn, execSync as execSyncSupervisor } from "node:child_process";
4
4
  import { mkdirSync, readFileSync, writeFileSync, createWriteStream } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { verifySlimWrapperRouteExecution } from "./native.mjs";
@@ -275,7 +275,13 @@ async function shutdown() {
275
275
  setTimeout(() => {
276
276
  for (const state of processMap.values()) {
277
277
  if (state.status === "running") {
278
- try { state.child.kill("SIGKILL"); } catch {}
278
+ const pid = state.child?.pid;
279
+ if (process.platform === "win32" && Number.isInteger(pid) && pid > 0) {
280
+ // Windows: 프로세스 트리 전체 강제 종료 (손자 MCP 서버 포함)
281
+ try { execSyncSupervisor(`taskkill /T /F /PID ${pid}`, { stdio: "pipe", windowsHide: true, timeout: 5000 }); } catch {}
282
+ } else {
283
+ try { state.child.kill("SIGKILL"); } catch {}
284
+ }
279
285
  }
280
286
  }
281
287
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "8.9.1",
3
+ "version": "8.10.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": {
@@ -72,7 +72,6 @@ function startHubDetached(port) {
72
72
  // hook timeout 시 프로세스 트리 킬에서 살아남음
73
73
  const child = spawn("cmd.exe", ["/c", "start", "/b", "", process.execPath, serverPath], {
74
74
  env,
75
- detached: true,
76
75
  stdio: "ignore",
77
76
  windowsHide: true,
78
77
  });
@@ -0,0 +1,17 @@
1
+ # mcp-cleanup.ps1 — Claude Code Stop hook: MCP 고아 프로세스 정리
2
+ # Windows에서 Claude Code 세션 종료 시 남는 MCP 서버 고아 프로세스를 정리한다.
3
+ # 원인: Claude Code가 stdio MCP 자식 프로세스 트리를 Windows에서 제대로 kill하지 못함
4
+ # (GitHub Issues #1935, #15211, #28126)
5
+ $ErrorActionPreference = 'SilentlyContinue'
6
+
7
+ # npx MCP servers (brave, notion, context7, exa, tavily, jira, playwright, etc.)
8
+ # + oh-my-codex MCP servers (team/code-intel/memory/trace/state)
9
+ # + omc bridge
10
+ Get-CimInstance Win32_Process -Filter "Name='node.exe' OR Name='cmd.exe'" |
11
+ Where-Object { $_.CommandLine -match 'npx-cli|oh-my-codex[\\/]dist[\\/]mcp|omc.*bridge.*mcp-server' } |
12
+ ForEach-Object { taskkill /F /PID $_.ProcessId 2>$null }
13
+
14
+ # serena (uvx) + python MCP orphans
15
+ Get-CimInstance Win32_Process -Filter "Name='python.exe' OR Name='uvx.exe'" |
16
+ Where-Object { $_.CommandLine -match 'serena|uv[\\/](cache|python)' } |
17
+ ForEach-Object { taskkill /F /PID $_.ProcessId 2>$null }
@@ -19,6 +19,7 @@ import {
19
19
  capturePsmuxPane,
20
20
  createPsmuxSession,
21
21
  hasPsmux,
22
+ killPsmuxSession,
22
23
  listPsmuxSessions,
23
24
  psmuxExec,
24
25
  psmuxSessionExists,
@@ -661,35 +662,40 @@ function spawnLocal(args, claudePath, prompt) {
661
662
  }
662
663
 
663
664
  createPsmuxSession(sessionName, { layout: "1xN", paneCount: 1 });
664
- sendKeysToPane(paneId, `cd '${escapePwshSingleQuoted(dir)}'`);
665
- sleepMs(300);
666
-
667
- if (prompt && tmpFile) {
668
- // pwsh -File 패턴: 인라인 쿼팅 문제 회피 (피드백: -Command 금지)
669
- // 1단계: 프롬프트를 Get-Content -Raw claude -p (one-shot), 세션 ID 추출
670
- // 2단계: --resume으로 인터랙티브 세션 이어붙이기
671
- const tmpFileNorm = normalizeCommandPath(tmpFile);
672
- const flags = getPermissionFlag().map((f) => `'${escapePwshSingleQuoted(f)}'`).join(", ");
673
- const scriptContent = [
674
- `$ErrorActionPreference = 'SilentlyContinue'`,
675
- `$t = '${escapePwshSingleQuoted(tmpFileNorm)}'`,
676
- `$c = '${escapePwshSingleQuoted(claudePathNorm)}'`,
677
- `$f = @(${flags})`,
678
- `$raw = Get-Content -Raw $t`,
679
- `Remove-Item -ErrorAction SilentlyContinue $t`,
680
- `Remove-Item -ErrorAction SilentlyContinue $MyInvocation.MyCommand.Definition`,
681
- `& $c @f $raw`,
682
- ].join("\n");
683
- const scriptFile = join(tmpdir(), `tfx-spawn-${randomUUID().slice(0, 8)}.ps1`);
684
- writeFileSync(scriptFile, scriptContent, { encoding: "utf8" });
685
- sendKeysToPane(paneId, `pwsh -NoProfile -NoExit -File '${escapePwshSingleQuoted(normalizeCommandPath(scriptFile))}'`);
686
- } else {
687
- const command = `& '${escapePwshSingleQuoted(claudePathNorm)}'${permissionFlags ? ` ${permissionFlags}` : ""}`;
688
- sendKeysToPane(paneId, command);
689
- }
665
+ try {
666
+ sendKeysToPane(paneId, `cd '${escapePwshSingleQuoted(dir)}'`);
667
+ sleepMs(300);
668
+
669
+ if (prompt && tmpFile) {
670
+ // pwsh -File 패턴: 인라인 쿼팅 문제 회피 (피드백: -Command 금지)
671
+ // 1단계: 프롬프트를 Get-Content -Raw → claude -p (one-shot), 세션 ID 추출
672
+ // 2단계: --resume으로 인터랙티브 세션 이어붙이기
673
+ const tmpFileNorm = normalizeCommandPath(tmpFile);
674
+ const flags = getPermissionFlag().map((f) => `'${escapePwshSingleQuoted(f)}'`).join(", ");
675
+ const scriptContent = [
676
+ `$ErrorActionPreference = 'SilentlyContinue'`,
677
+ `$t = '${escapePwshSingleQuoted(tmpFileNorm)}'`,
678
+ `$c = '${escapePwshSingleQuoted(claudePathNorm)}'`,
679
+ `$f = @(${flags})`,
680
+ `$raw = Get-Content -Raw $t`,
681
+ `Remove-Item -ErrorAction SilentlyContinue $t`,
682
+ `Remove-Item -ErrorAction SilentlyContinue $MyInvocation.MyCommand.Definition`,
683
+ `& $c @f $raw`,
684
+ ].join("\n");
685
+ const scriptFile = join(tmpdir(), `tfx-spawn-${randomUUID().slice(0, 8)}.ps1`);
686
+ writeFileSync(scriptFile, scriptContent, { encoding: "utf8" });
687
+ sendKeysToPane(paneId, `pwsh -NoProfile -NoExit -File '${escapePwshSingleQuoted(normalizeCommandPath(scriptFile))}'`);
688
+ } else {
689
+ const command = `& '${escapePwshSingleQuoted(claudePathNorm)}'${permissionFlags ? ` ${permissionFlags}` : ""}`;
690
+ sendKeysToPane(paneId, command);
691
+ }
690
692
 
691
- openAttachTab(sessionName, "Claude@local");
692
- console.log(sessionName);
693
+ openAttachTab(sessionName, "Claude@local");
694
+ console.log(sessionName);
695
+ } catch (err) {
696
+ try { killPsmuxSession(sessionName); } catch {}
697
+ throw err;
698
+ }
693
699
  }
694
700
 
695
701
  async function spawnRemote(args, prompt) {
@@ -715,26 +721,31 @@ async function spawnRemote(args, prompt) {
715
721
  const permissionFlags = getPermissionFlag().join(" ");
716
722
 
717
723
  createPsmuxSession(sessionName, { layout: "1xN", paneCount: 1 });
718
- sendKeysToPane(paneId, `ssh -t ${host}`);
719
- await waitForRemotePrompt(sessionName, paneId);
724
+ try {
725
+ sendKeysToPane(paneId, `ssh -t ${host}`);
726
+ await waitForRemotePrompt(sessionName, paneId);
727
+
728
+ if (env.shell === "pwsh") {
729
+ const claudeCommand = `& "${escapePwshDoubleQuoted(env.claudePath)}"${permissionFlags ? ` ${permissionFlags}` : ""}`;
730
+ sendKeysToPane(paneId, `cd '${escapePwshSingleQuoted(resolvedDir)}'`);
731
+ sendKeysToPane(paneId, claudeCommand);
732
+ } else {
733
+ const claudeCommand = `${shellQuote(env.claudePath)}${permissionFlags ? ` ${permissionFlags}` : ""}`;
734
+ sendKeysToPane(paneId, `cd ${shellQuote(resolvedDir)}`);
735
+ sendKeysToPane(paneId, claudeCommand);
736
+ }
720
737
 
721
- if (env.shell === "pwsh") {
722
- const claudeCommand = `& "${escapePwshDoubleQuoted(env.claudePath)}"${permissionFlags ? ` ${permissionFlags}` : ""}`;
723
- sendKeysToPane(paneId, `cd '${escapePwshSingleQuoted(resolvedDir)}'`);
724
- sendKeysToPane(paneId, claudeCommand);
725
- } else {
726
- const claudeCommand = `${shellQuote(env.claudePath)}${permissionFlags ? ` ${permissionFlags}` : ""}`;
727
- sendKeysToPane(paneId, `cd ${shellQuote(resolvedDir)}`);
728
- sendKeysToPane(paneId, claudeCommand);
729
- }
738
+ if (prompt) {
739
+ sleepMs(2000);
740
+ sendKeysToPane(paneId, prompt);
741
+ }
730
742
 
731
- if (prompt) {
732
- sleepMs(2000);
733
- sendKeysToPane(paneId, prompt);
743
+ openAttachTab(sessionName, `Claude@${host}`);
744
+ console.log(sessionName);
745
+ } catch (err) {
746
+ try { killPsmuxSession(sessionName); } catch {}
747
+ throw err;
734
748
  }
735
-
736
- openAttachTab(sessionName, `Claude@${host}`);
737
- console.log(sessionName);
738
749
  }
739
750
 
740
751
  function sendPromptToSession(sessionName, prompt) {
package/scripts/setup.mjs CHANGED
@@ -53,6 +53,49 @@ const REQUIRED_CODEX_PROFILES = [
53
53
  },
54
54
  ];
55
55
 
56
+ const SKILL_ALIASES = [
57
+ {
58
+ alias: "tfx-ralph",
59
+ source: "tfx-persist",
60
+ },
61
+ ];
62
+
63
+ function buildAliasedSkillContent(srcContent, { alias, source }) {
64
+ return srcContent
65
+ .replace(/^name:\s*.+$/m, `name: ${alias}`)
66
+ .replaceAll(source, alias)
67
+ .replace(/^#\s+.+$/m, `# ${alias} — Compatibility Alias for ${source}`);
68
+ }
69
+
70
+ function syncAliasedSkillDir(srcDir, dstDir, { alias, source }) {
71
+ if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
72
+
73
+ let count = 0;
74
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
75
+ const srcPath = join(srcDir, entry.name);
76
+ const dstPath = join(dstDir, entry.name);
77
+
78
+ if (entry.isDirectory()) {
79
+ count += syncAliasedSkillDir(srcPath, dstPath, { alias, source });
80
+ continue;
81
+ }
82
+
83
+ if (!entry.name.endsWith(".md")) continue;
84
+
85
+ const rawContent = readFileSync(srcPath, "utf8");
86
+ const nextContent = entry.name === "SKILL.md"
87
+ ? buildAliasedSkillContent(rawContent, { alias, source })
88
+ : rawContent;
89
+
90
+ if (!existsSync(dstPath) || readFileSync(dstPath, "utf8") !== nextContent) {
91
+ writeFileSync(dstPath, nextContent, "utf8");
92
+ count++;
93
+ }
94
+ }
95
+
96
+ return count;
97
+ }
98
+
56
99
  // ── 파일 동기화 ──
57
100
 
58
101
  const SYNC_MAP = [
@@ -101,6 +144,11 @@ const SYNC_MAP = [
101
144
  dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "factory.mjs"),
102
145
  label: "hub/workers/factory.mjs",
103
146
  },
147
+ {
148
+ src: join(PLUGIN_ROOT, "scripts", "mcp-cleanup.ps1"),
149
+ dst: join(CLAUDE_DIR, "scripts", "mcp-cleanup.ps1"),
150
+ label: "mcp-cleanup.ps1",
151
+ },
104
152
  {
105
153
  src: join(PLUGIN_ROOT, "hud", "hud-qos-status.mjs"),
106
154
  dst: join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
@@ -282,7 +330,7 @@ function ensureCodexProfiles() {
282
330
  }
283
331
  }
284
332
 
285
- export { replaceProfileSection, hasProfileSection, detectDevMode, SYNC_MAP, BREADCRUMB_PATH, PLUGIN_ROOT, CLAUDE_DIR };
333
+ export { replaceProfileSection, hasProfileSection, detectDevMode, SYNC_MAP, BREADCRUMB_PATH, PLUGIN_ROOT, CLAUDE_DIR, SKILL_ALIASES };
286
334
 
287
335
  async function main() {
288
336
  const isSync = process.argv.includes("--sync");
@@ -424,6 +472,13 @@ if (existsSync(skillsSrc)) {
424
472
 
425
473
  synced += syncSkillDir(skillDir, join(skillsDst, name));
426
474
  }
475
+
476
+ for (const { alias, source } of SKILL_ALIASES) {
477
+ const sourceDir = join(skillsSrc, source);
478
+ const sourceSkillMd = join(sourceDir, "SKILL.md");
479
+ if (!existsSync(sourceSkillMd)) continue;
480
+ synced += syncAliasedSkillDir(sourceDir, join(skillsDst, alias), { alias, source });
481
+ }
427
482
  }
428
483
 
429
484
  // ── settings.json 통합 R/W ──
@@ -530,6 +585,44 @@ function applyHooks(s) {
530
585
  changed = true;
531
586
  }
532
587
 
588
+ // ── Stop 훅: MCP 고아 프로세스 정리 (Windows 전용) ──
589
+ if (process.platform === "win32") {
590
+ if (!Array.isArray(s.hooks.Stop)) s.hooks.Stop = [];
591
+
592
+ const cleanupScriptPath = join(CLAUDE_DIR, "scripts", "mcp-cleanup.ps1").replace(/\\/g, "/");
593
+ const hasCleanupHook = s.hooks.Stop.some((entry) =>
594
+ Array.isArray(entry.hooks) &&
595
+ entry.hooks.some((h) => typeof h.command === "string" && h.command.includes("mcp-cleanup")),
596
+ );
597
+
598
+ if (!hasCleanupHook && existsSync(cleanupScriptPath.replace(/\//g, "\\"))) {
599
+ // 기존 Stop 엔트리가 있으면 거기에 추가, 없으면 새 엔트리 생성
600
+ const existingEntry = s.hooks.Stop.find((entry) => entry.matcher === "*" && Array.isArray(entry.hooks));
601
+ const cleanupHook = {
602
+ type: "command",
603
+ command: `powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "${cleanupScriptPath}"`,
604
+ timeout: 8,
605
+ };
606
+
607
+ if (existingEntry) {
608
+ existingEntry.hooks.push(cleanupHook);
609
+ } else {
610
+ s.hooks.Stop.push({ matcher: "*", hooks: [cleanupHook] });
611
+ }
612
+ changed = true;
613
+ } else if (hasCleanupHook) {
614
+ for (const entry of s.hooks.Stop) {
615
+ if (!Array.isArray(entry.hooks)) continue;
616
+ for (const h of entry.hooks) {
617
+ if (typeof h.command === "string" && h.command.includes("mcp-cleanup") && !h.command.includes(cleanupScriptPath)) {
618
+ h.command = `powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "${cleanupScriptPath}"`;
619
+ changed = true;
620
+ }
621
+ }
622
+ }
623
+ }
624
+ }
625
+
533
626
  // ── PreToolUse 훅: headless-guard (auto-route) ──
534
627
  if (!Array.isArray(s.hooks.PreToolUse)) s.hooks.PreToolUse = [];
535
628
 
@@ -337,7 +337,49 @@ options:
337
337
 
338
338
  저장 후 결과 보고.
339
339
 
340
- **2-8. 후속 작업**
340
+ **2-8. MCP 고아 프로세스 정리 훅 배포 (Windows 원격 호스트)**
341
+
342
+ 원격 호스트가 Windows인 경우, MCP 고아 프로세스 정리 훅을 자동 배포한다.
343
+ Claude Code 세션 종료 시 MCP 서버 프로세스가 정리되지 않는 Windows 고유 버그 대응.
344
+ (GitHub Issues #1935, #15211, #28126)
345
+
346
+ ```bash
347
+ # mcp-cleanup.ps1 배포
348
+ scp "$(npm root -g)/triflux/scripts/mcp-cleanup.ps1" {host}:~/.claude/scripts/mcp-cleanup.ps1
349
+ ```
350
+
351
+ 배포 후 원격 호스트의 `~/.claude/settings.json`에 Stop 훅을 등록한다:
352
+
353
+ ```bash
354
+ ssh {host} "node -e \"
355
+ const fs = require('fs');
356
+ const p = require('path').join(require('os').homedir(), '.claude', 'settings.json');
357
+ const s = JSON.parse(fs.readFileSync(p, 'utf8'));
358
+ if (!s.hooks) s.hooks = {};
359
+ if (!Array.isArray(s.hooks.Stop)) s.hooks.Stop = [];
360
+ const has = s.hooks.Stop.some(e => e.hooks?.some(h => h.command?.includes('mcp-cleanup')));
361
+ if (!has) {
362
+ const script = require('path').join(require('os').homedir(), '.claude/scripts/mcp-cleanup.ps1').replace(/\\\\\\\\/g, '/');
363
+ const entry = s.hooks.Stop.find(e => e.matcher === '*' && Array.isArray(e.hooks));
364
+ const hook = { type: 'command', command: 'powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \\\"' + script + '\\\"', timeout: 8 };
365
+ if (entry) entry.hooks.push(hook); else s.hooks.Stop.push({ matcher: '*', hooks: [hook] });
366
+ fs.writeFileSync(p, JSON.stringify(s, null, 2) + '\\n');
367
+ console.log('mcp-cleanup hook registered');
368
+ } else { console.log('mcp-cleanup hook already exists'); }
369
+ \""
370
+ ```
371
+
372
+ macOS/Linux 원격 호스트에서는 이 단계를 건너뛴다 (PGID 기반 kill이 정상 동작).
373
+
374
+ 프로브 결과에서 OS를 확인하여 자동 판단:
375
+ - Windows → 배포 실행
376
+ - macOS/Linux → 건너뛰기 (표시만)
377
+
378
+ ```
379
+ "{host}는 Windows입니다. MCP 고아 프로세스 정리 훅을 배포합니다."
380
+ ```
381
+
382
+ **2-9. 후속 작업**
341
383
 
342
384
  ```
343
385
  question: "호스트가 등록되었습니다. 추가 작업이 있습니까?"