@triflux/core 10.0.3 → 10.25.1

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.
Files changed (143) hide show
  1. package/hooks/agent-route-guard.mjs +14 -10
  2. package/hooks/cross-review-tracker.mjs +21 -8
  3. package/hooks/error-context.mjs +47 -7
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +279 -33
  7. package/hooks/hook-registry.json +104 -7
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +183 -23
  10. package/hooks/lib/resolve-root.mjs +1 -1
  11. package/hooks/mcp-config-watcher.mjs +6 -2
  12. package/hooks/permission-safe-allow.mjs +233 -0
  13. package/hooks/pipeline-stop.mjs +369 -22
  14. package/hooks/post-tool-tips.mjs +306 -0
  15. package/hooks/pre-compact-snapshot.mjs +181 -0
  16. package/hooks/safety-guard.mjs +308 -18
  17. package/hooks/session-end-cleanup.mjs +345 -0
  18. package/hooks/session-start-fast.mjs +214 -0
  19. package/hooks/subagent-tracker.mjs +267 -0
  20. package/hooks/subagent-verifier.mjs +9 -4
  21. package/hub/account-broker.mjs +1036 -66
  22. package/hub/adaptive-diagnostic.mjs +75 -48
  23. package/hub/adaptive-inject.mjs +96 -58
  24. package/hub/adaptive-memory.mjs +156 -42
  25. package/hub/adaptive.mjs +60 -31
  26. package/hub/assign-callbacks.mjs +163 -126
  27. package/hub/bridge.mjs +832 -291
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -3
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +19 -23
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/service.mjs +88 -64
  35. package/hub/delegator/tool-definitions.mjs +35 -35
  36. package/hub/dynamic-routing-engine.mjs +511 -0
  37. package/hub/fullcycle.mjs +34 -17
  38. package/hub/gemini-adapter.mjs +77 -95
  39. package/hub/hitl.mjs +202 -143
  40. package/hub/intent.mjs +171 -39
  41. package/hub/lib/bash-path.mjs +73 -0
  42. package/hub/lib/cache-guard.mjs +43 -17
  43. package/hub/lib/env-detect.mjs +145 -0
  44. package/hub/lib/hosts-compat.mjs +319 -0
  45. package/hub/lib/mcp-response-cache.mjs +239 -0
  46. package/hub/lib/memory-store.mjs +303 -107
  47. package/hub/lib/path-utils.mjs +165 -0
  48. package/hub/lib/phase-manager.mjs +493 -0
  49. package/hub/lib/process-utils.mjs +797 -104
  50. package/hub/lib/prompt-tmp.mjs +33 -0
  51. package/hub/lib/risk-tier.mjs +53 -0
  52. package/hub/lib/spawn-trace.mjs +561 -0
  53. package/hub/lib/ssh-command.mjs +44 -24
  54. package/hub/lib/ssh-retry.mjs +5 -1
  55. package/hub/lib/staged-file-detect.mjs +43 -0
  56. package/hub/lib/state-snapshot.mjs +293 -0
  57. package/hub/lib/tfx-route-args.mjs +309 -0
  58. package/hub/lib/timeline-adapter.mjs +190 -0
  59. package/hub/lib/trace-recorder.mjs +153 -0
  60. package/hub/lib/uuidv7.mjs +4 -3
  61. package/hub/memory-doctor.mjs +266 -106
  62. package/hub/middleware/quota-middleware.mjs +70 -0
  63. package/hub/middleware/request-logger.mjs +61 -34
  64. package/hub/paths.mjs +9 -9
  65. package/hub/pipeline/gates/confidence.mjs +34 -15
  66. package/hub/pipeline/gates/consensus.mjs +27 -15
  67. package/hub/pipeline/gates/index.mjs +7 -3
  68. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  69. package/hub/pipeline/index.mjs +77 -42
  70. package/hub/pipeline/state.mjs +10 -10
  71. package/hub/pipeline/transitions.mjs +40 -23
  72. package/hub/platform.mjs +65 -48
  73. package/hub/quality/deslop.mjs +70 -49
  74. package/hub/reflexion.mjs +116 -58
  75. package/hub/research.mjs +34 -25
  76. package/hub/router.mjs +941 -791
  77. package/hub/routing/complexity.mjs +132 -29
  78. package/hub/routing/index.mjs +17 -12
  79. package/hub/routing/q-learning.mjs +76 -28
  80. package/hub/routing-snapshot.mjs +261 -0
  81. package/hub/session-fingerprint.mjs +129 -63
  82. package/hub/state.mjs +86 -44
  83. package/hub/team/retry-state-machine.mjs +267 -0
  84. package/hub/token-mode.mjs +76 -72
  85. package/hub/workers/worker-utils.mjs +22 -16
  86. package/hud/colors.mjs +27 -9
  87. package/hud/constants.mjs +184 -27
  88. package/hud/context-monitor.mjs +133 -44
  89. package/hud/hud-qos-status.mjs +153 -50
  90. package/hud/mission-board.mjs +56 -0
  91. package/hud/providers/claude.mjs +398 -114
  92. package/hud/providers/codex.mjs +96 -21
  93. package/hud/providers/gemini.mjs +184 -51
  94. package/hud/renderers.mjs +413 -123
  95. package/hud/terminal.mjs +101 -31
  96. package/hud/utils.mjs +81 -39
  97. package/mesh/index.mjs +69 -0
  98. package/mesh/mesh-budget.mjs +137 -0
  99. package/mesh/mesh-heartbeat.mjs +100 -0
  100. package/mesh/mesh-protocol.mjs +96 -0
  101. package/mesh/mesh-queue.mjs +163 -0
  102. package/mesh/mesh-registry.mjs +78 -0
  103. package/mesh/mesh-router.mjs +77 -0
  104. package/package.json +5 -1
  105. package/scripts/lib/agent-json.mjs +27 -0
  106. package/scripts/lib/async.mjs +174 -0
  107. package/scripts/lib/claudemd-scanner.mjs +6 -1
  108. package/scripts/lib/cli-agy.mjs +62 -0
  109. package/scripts/lib/cli-claude.mjs +78 -0
  110. package/scripts/lib/cli-codex.mjs +199 -0
  111. package/scripts/lib/cli-gemini.mjs +67 -0
  112. package/scripts/lib/codex-recovery.sh +50 -0
  113. package/scripts/lib/context.mjs +3 -3
  114. package/scripts/lib/cross-review-utils.mjs +13 -5
  115. package/scripts/lib/doctor-env-checks.mjs +121 -0
  116. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  117. package/scripts/lib/env-probe.mjs +58 -30
  118. package/scripts/lib/env.mjs +14 -0
  119. package/scripts/lib/gemini-profiles.mjs +44 -10
  120. package/scripts/lib/handoff.mjs +33 -17
  121. package/scripts/lib/hook-utils.mjs +8 -6
  122. package/scripts/lib/hub.mjs +48 -0
  123. package/scripts/lib/keyword-rules.mjs +48 -19
  124. package/scripts/lib/logger.mjs +24 -24
  125. package/scripts/lib/mcp-filter.mjs +393 -249
  126. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  127. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  128. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  129. package/scripts/lib/mcp-guard-engine.mjs +1282 -116
  130. package/scripts/lib/mcp-health.mjs +773 -0
  131. package/scripts/lib/mcp-manifest.mjs +24 -14
  132. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  133. package/scripts/lib/pid.mjs +63 -0
  134. package/scripts/lib/process-utils.mjs +26 -0
  135. package/scripts/lib/psmux-info.mjs +72 -12
  136. package/scripts/lib/quota.mjs +47 -0
  137. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  138. package/scripts/lib/skill-state.mjs +220 -0
  139. package/scripts/lib/skill-template.mjs +36 -11
  140. package/scripts/lib/team.mjs +75 -0
  141. package/scripts/lib/timeout.mjs +66 -0
  142. package/scripts/lib/tmp.mjs +26 -0
  143. package/scripts/lib/toml.mjs +113 -0
@@ -1,8 +1,14 @@
1
1
  // hub/lib/process-utils.mjs
2
2
  // 프로세스 관련 공유 유틸리티
3
3
 
4
- import { execSync } from "node:child_process";
5
- import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
4
+ import { execSync, spawnSync } from "node:child_process";
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
6
12
  import { homedir, tmpdir } from "node:os";
7
13
  import { join } from "node:path";
8
14
  import { IS_WINDOWS, killProcess } from "../platform.mjs";
@@ -12,8 +18,23 @@ const SCAN_SCRIPT_PATH = join(CLEANUP_SCRIPT_DIR, "scan-processes.ps1");
12
18
  const TREE_SCRIPT_PATH = join(CLEANUP_SCRIPT_DIR, "get-ancestor-tree.ps1");
13
19
 
14
20
  // 스크립트 버전 — 내용 변경 시 증가하여 캐시된 스크립트를 갱신
15
- const SCRIPT_VERSION = 3;
21
+ const SCRIPT_VERSION = 4;
16
22
  const VERSION_FILE = join(CLEANUP_SCRIPT_DIR, ".version");
23
+ const FSMONITOR_DAEMON_MARKER = "fsmonitor--daemon run --detach";
24
+ const FSMONITOR_DAEMON_PATTERN =
25
+ /(^|\s)fsmonitor--daemon\s+run\s+--detach(\s|$)/;
26
+ const DEFAULT_FSMONITOR_MIN_AGE_MS = 24 * 60 * 60 * 1000;
27
+ const LEGACY_ORPHAN_KILLABLE_NAMES = new Set([
28
+ "node.exe",
29
+ "bash.exe",
30
+ "cmd.exe",
31
+ "uvx.exe",
32
+ ]);
33
+ const LIVE_CLI_SESSION_ROOT_NAMES = new Set([
34
+ "codex.exe",
35
+ "claude.exe",
36
+ "gemini.exe",
37
+ ]);
17
38
 
18
39
  /**
19
40
  * 주어진 PID의 프로세스가 살아있는지 확인한다.
@@ -27,8 +48,8 @@ export function isPidAlive(pid) {
27
48
  process.kill(pid, 0);
28
49
  return true;
29
50
  } catch (e) {
30
- if (e?.code === 'EPERM') return true;
31
- if (e?.code === 'ESRCH') return false;
51
+ if (e?.code === "EPERM") return true;
52
+ if (e?.code === "ESRCH") return false;
32
53
  return false;
33
54
  }
34
55
  }
@@ -41,7 +62,9 @@ function sleepSyncMs(ms) {
41
62
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
42
63
  } catch {
43
64
  const end = Date.now() + ms;
44
- while (Date.now() < end) { /* spin */ }
65
+ while (Date.now() < end) {
66
+ /* spin */
67
+ }
45
68
  }
46
69
  }
47
70
 
@@ -56,7 +79,7 @@ function killWithEscalation(orphanPids, procMap) {
56
79
  if (orphanPids.length === 0) return 0;
57
80
 
58
81
  // SIGTERM 전 alive 스냅샷 — 이미 죽은 PID는 카운트에서 제외
59
- const aliveBeforeKill = new Set(orphanPids.filter(pid => isPidAlive(pid)));
82
+ const aliveBeforeKill = new Set(orphanPids.filter((pid) => isPidAlive(pid)));
60
83
 
61
84
  for (const pid of aliveBeforeKill) {
62
85
  killProcess(pid, { signal: "SIGTERM" });
@@ -77,7 +100,12 @@ function killWithEscalation(orphanPids, procMap) {
77
100
  IS_WINDOWS
78
101
  ? `powershell -NoProfile -WindowStyle Hidden -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}' -ErrorAction SilentlyContinue).ParentProcessId"`
79
102
  : `ps -o ppid= -p ${pid}`,
80
- { encoding: "utf8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
103
+ {
104
+ encoding: "utf8",
105
+ timeout: 3000,
106
+ stdio: ["pipe", "pipe", "pipe"],
107
+ windowsHide: true,
108
+ },
81
109
  );
82
110
  const currentPpid = Number.parseInt(current.trim(), 10);
83
111
  if (Number.isFinite(currentPpid) && currentPpid !== snapshot.ppid) {
@@ -89,7 +117,11 @@ function killWithEscalation(orphanPids, procMap) {
89
117
  }
90
118
  }
91
119
  }
92
- killProcess(pid, { signal: "SIGKILL", force: true });
120
+ killProcess(pid, {
121
+ signal: "SIGKILL",
122
+ force: true,
123
+ tree: IS_WINDOWS,
124
+ });
93
125
  }
94
126
  if (!isPidAlive(pid)) killed++;
95
127
  }
@@ -107,41 +139,56 @@ function ensureHelperScripts() {
107
139
  let needsUpdate = true;
108
140
  try {
109
141
  if (existsSync(VERSION_FILE)) {
110
- const cached = Number.parseInt(readFileSync(VERSION_FILE, "utf8").trim(), 10);
142
+ const cached = Number.parseInt(
143
+ readFileSync(VERSION_FILE, "utf8").trim(),
144
+ 10,
145
+ );
111
146
  if (cached === SCRIPT_VERSION) needsUpdate = false;
112
147
  }
113
148
  } catch {}
114
149
 
115
150
  if (needsUpdate) {
116
151
  // 기존 스크립트 삭제 후 재생성
117
- try { unlinkSync(SCAN_SCRIPT_PATH); } catch {}
118
- try { unlinkSync(TREE_SCRIPT_PATH); } catch {}
152
+ try {
153
+ unlinkSync(SCAN_SCRIPT_PATH);
154
+ } catch {}
155
+ try {
156
+ unlinkSync(TREE_SCRIPT_PATH);
157
+ } catch {}
119
158
  }
120
159
 
121
160
  if (!existsSync(TREE_SCRIPT_PATH)) {
122
- writeFileSync(TREE_SCRIPT_PATH, [
123
- "param([int]$StartPid)",
124
- "$p = $StartPid",
125
- "for ($i = 0; $i -lt 10; $i++) {",
126
- " if ($p -le 0) { break }",
127
- " Write-Output $p",
128
- ' $parent = (Get-CimInstance Win32_Process -Filter "ProcessId=$p" -ErrorAction SilentlyContinue).ParentProcessId',
129
- " if ($null -eq $parent -or $parent -le 0) { break }",
130
- " $p = $parent",
131
- "}",
132
- ].join("\n"), "utf8");
161
+ writeFileSync(
162
+ TREE_SCRIPT_PATH,
163
+ [
164
+ "param([int]$StartPid)",
165
+ "$p = $StartPid",
166
+ "for ($i = 0; $i -lt 10; $i++) {",
167
+ " if ($p -le 0) { break }",
168
+ " Write-Output $p",
169
+ ' $parent = (Get-CimInstance Win32_Process -Filter "ProcessId=$p" -ErrorAction SilentlyContinue).ParentProcessId',
170
+ " if ($null -eq $parent -or $parent -le 0) { break }",
171
+ " $p = $parent",
172
+ "}",
173
+ ].join("\n"),
174
+ "utf8",
175
+ );
133
176
  }
134
177
 
135
178
  if (!existsSync(SCAN_SCRIPT_PATH)) {
136
179
  // CLI + 쉘 + 런타임 전체를 스캔하여 PID,ParentPID,Name 출력
137
180
  // codex/claude/pwsh/uvx 누락 시 중간 프로세스가 alive 판정되어 고아 트리 전체가 보호됨
138
181
  // 예: WT(dead)→pwsh(alive,미스캔)→codex→cmd→node — pwsh에서 isPidAlive=true로 끊김
139
- writeFileSync(SCAN_SCRIPT_PATH, [
140
- "$ErrorActionPreference = 'SilentlyContinue'",
141
- "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 {",
142
- ' Write-Output "$($_.ProcessId),$($_.ParentProcessId),$($_.Name)"',
143
- "}",
144
- ].join("\n"), "utf8");
182
+ writeFileSync(
183
+ SCAN_SCRIPT_PATH,
184
+ [
185
+ "$ErrorActionPreference = 'SilentlyContinue'",
186
+ "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='gemini.exe' OR Name='pwsh.exe' OR Name='uvx.exe'\" | ForEach-Object {",
187
+ ' Write-Output "$($_.ProcessId),$($_.ParentProcessId),$($_.Name)"',
188
+ "}",
189
+ ].join("\n"),
190
+ "utf8",
191
+ );
145
192
  }
146
193
 
147
194
  if (needsUpdate) {
@@ -171,6 +218,9 @@ function hasLiveAncestorChain(pid, procMap, protectedPids) {
171
218
  // 프로세스 맵에 없음 → 살아있는지 직접 확인
172
219
  return isPidAlive(current);
173
220
  }
221
+ if (LIVE_CLI_SESSION_ROOT_NAMES.has(normalizeName(info.name))) {
222
+ return true;
223
+ }
174
224
 
175
225
  const ppid = info.ppid;
176
226
  if (!Number.isFinite(ppid) || ppid <= 0) {
@@ -187,101 +237,727 @@ function hasLiveAncestorChain(pid, procMap, protectedPids) {
187
237
  return false;
188
238
  }
189
239
 
190
- // kill 대상 프로세스 이름 (Windows)
191
- // codex/claude도 포함: protectedPids + hasLiveAncestorChain이 활성 인스턴스를 보호하므로
192
- // 고아(부모 dead + 자식 dead)만 kill됨. pwsh.exe는 사용자 인터랙티브 쉘이므로 제외.
193
- const KILLABLE_NAMES = new Set([
194
- "node.exe", "bash.exe", "cmd.exe", "uvx.exe",
195
- "codex.exe", "claude.exe",
196
- ]);
240
+ function hasLiveCliDescendant(pid, procMap) {
241
+ const children = new Map();
242
+ for (const proc of procMap.values()) {
243
+ if (!Number.isFinite(proc.ppid) || proc.ppid <= 0) continue;
244
+ const list = children.get(proc.ppid) || [];
245
+ list.push(proc);
246
+ children.set(proc.ppid, list);
247
+ }
248
+
249
+ const visited = new Set();
250
+ const stack = [...(children.get(pid) || [])];
251
+ while (stack.length > 0) {
252
+ const proc = stack.pop();
253
+ if (!proc || visited.has(proc.pid)) continue;
254
+ visited.add(proc.pid);
255
+ if (LIVE_CLI_SESSION_ROOT_NAMES.has(normalizeName(proc.name))) {
256
+ return true;
257
+ }
258
+ stack.push(...(children.get(proc.pid) || []));
259
+ }
260
+ return false;
261
+ }
197
262
 
198
263
  /**
199
- * 고아 프로세스 트리를 정리한다 (node.exe + bash.exe + cmd.exe + uvx.exe).
200
- * Windows 전용 — Agent 서브프로세스가 MCP 서버, bash 래퍼, cmd 래퍼를 남기는 문제 대응.
201
- *
202
- * 전략: 부모 체인을 루트까지 추적하여, 체인 중간에 죽은 프로세스가 있으면
203
- * 해당 프로세스 아래의 전체 트리를 고아로 판정하고 정리.
204
- * 스캔 범위에는 codex/claude/pwsh도 포함하여 체인 추적 정확도를 높인다.
205
- *
206
- * 보호 대상: 현재 프로세스 조상 트리, Hub PID
207
- * @returns {{ killed: number, remaining: number }}
264
+ * Legacy wrapper for scoped orphan node runtime cleanup.
265
+ * @param {Parameters<typeof cleanupOrphanRuntimeProcesses>[0]} opts
266
+ * @returns {{ killed: number, remaining: number, killedProcesses: Array<{pid: number, ppid: number, name: string, commandLine: string, killReason: string}> }}
208
267
  */
209
- export function cleanupOrphanNodeProcesses() {
210
- if (!IS_WINDOWS) return cleanupOrphansUnix();
268
+ export function cleanupOrphanNodeProcesses(opts = {}) {
269
+ return cleanupOrphanRuntimeProcesses({ ...opts, legacy: true });
270
+ }
211
271
 
212
- ensureHelperScripts();
272
+ function normalizePowerShellJson(output) {
273
+ const trimmed = String(output || "").trim();
274
+ if (!trimmed || trimmed === "null") return [];
275
+ const parsed = JSON.parse(trimmed);
276
+ return Array.isArray(parsed) ? parsed : [parsed];
277
+ }
213
278
 
214
- const myPid = process.pid;
279
+ function parseCreationDateMs(value) {
280
+ if (!value) return NaN;
281
+ if (value instanceof Date) return value.getTime();
215
282
 
216
- // Hub PID 보호
217
- let hubPid = null;
283
+ const text = String(value);
284
+ const dotNetMatch = /\/Date\((-?\d+)\)\//.exec(text);
285
+ if (dotNetMatch) return Number.parseInt(dotNetMatch[1], 10);
286
+
287
+ const ms = Date.parse(text);
288
+ return Number.isFinite(ms) ? ms : NaN;
289
+ }
290
+
291
+ function normalizePid(value) {
292
+ const pid = Number(value);
293
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
294
+ }
295
+
296
+ function normalizeName(name) {
297
+ return String(name || "").toLowerCase();
298
+ }
299
+
300
+ function normalizeCommandLine(commandLine) {
301
+ return String(commandLine || "").replace(/\//g, "\\");
302
+ }
303
+
304
+ function processRecordFromCim(record) {
305
+ const pid = normalizePid(record?.ProcessId ?? record?.pid);
306
+ if (!pid) return null;
307
+ const ppid = Number(record?.ParentProcessId ?? record?.ppid ?? 0);
308
+ return {
309
+ pid,
310
+ ppid: Number.isFinite(ppid) ? ppid : 0,
311
+ name: String(record?.Name ?? record?.name ?? ""),
312
+ commandLine: String(record?.CommandLine ?? record?.commandLine ?? ""),
313
+ creationDate: record?.CreationDate,
314
+ };
315
+ }
316
+
317
+ function runSpawn(spawnSyncFn, command, args, options = {}) {
318
+ return spawnSyncFn(command, args, {
319
+ encoding: "utf8",
320
+ windowsHide: true,
321
+ stdio: ["ignore", "pipe", "pipe"],
322
+ ...options,
323
+ });
324
+ }
325
+
326
+ function runPowerShellJson(spawnSyncFn, command) {
327
+ const result = runSpawn(spawnSyncFn, "powershell", [
328
+ "-NoProfile",
329
+ "-WindowStyle",
330
+ "Hidden",
331
+ "-ExecutionPolicy",
332
+ "Bypass",
333
+ "-Command",
334
+ command,
335
+ ]);
336
+ if (result.status !== 0) {
337
+ throw new Error(
338
+ String(result.stderr || result.error || "PowerShell failed"),
339
+ );
340
+ }
341
+ return normalizePowerShellJson(result.stdout);
342
+ }
343
+
344
+ function getWindowsProcessSnapshot(spawnSyncFn = spawnSync) {
345
+ const records = runPowerShellJson(
346
+ spawnSyncFn,
347
+ "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine,CreationDate | ConvertTo-Json -Depth 3 -Compress",
348
+ );
349
+ return records.map(processRecordFromCim).filter(Boolean);
350
+ }
351
+
352
+ function parsePosixProcessSnapshot(output) {
353
+ const processes = [];
354
+ for (const line of String(output || "")
355
+ .split(/\r?\n/)
356
+ .slice(1)) {
357
+ const trimmed = line.trim();
358
+ if (!trimmed) continue;
359
+ const match = /^(\d+)\s+(\d+)\s+(\S+)(?:\s+(.*))?$/.exec(trimmed);
360
+ if (!match) continue;
361
+ processes.push({
362
+ pid: Number.parseInt(match[1], 10),
363
+ ppid: Number.parseInt(match[2], 10),
364
+ name: match[3],
365
+ commandLine: match[4] || match[3],
366
+ });
367
+ }
368
+ return processes;
369
+ }
370
+
371
+ function getPosixProcessSnapshot(spawnSyncFn = spawnSync) {
372
+ const result = runSpawn(spawnSyncFn, "ps", ["-eo", "pid,ppid,comm,args"]);
373
+ if (result.status !== 0) return [];
374
+ return parsePosixProcessSnapshot(result.stdout);
375
+ }
376
+
377
+ function getProcessSnapshot({
378
+ isWindows = IS_WINDOWS,
379
+ spawnSyncFn = spawnSync,
380
+ } = {}) {
218
381
  try {
219
- const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
382
+ return isWindows
383
+ ? getWindowsProcessSnapshot(spawnSyncFn)
384
+ : getPosixProcessSnapshot(spawnSyncFn);
385
+ } catch {
386
+ return [];
387
+ }
388
+ }
389
+
390
+ function addHubPid(protectedPids) {
391
+ try {
392
+ const hubPidPath = join(
393
+ homedir(),
394
+ ".claude",
395
+ "cache",
396
+ "tfx-hub",
397
+ "hub.pid",
398
+ );
220
399
  if (existsSync(hubPidPath)) {
221
400
  const hubInfo = JSON.parse(readFileSync(hubPidPath, "utf8"));
222
- hubPid = Number(hubInfo?.pid);
401
+ const hubPid = normalizePid(hubInfo?.pid);
402
+ if (hubPid) protectedPids.add(hubPid);
223
403
  }
224
404
  } catch {}
405
+ }
225
406
 
226
- // 보호 PID 세트: 현재 프로세스 + Hub + 현재 프로세스의 조상 트리
227
- const protectedPids = new Set();
228
- protectedPids.add(myPid);
229
- if (Number.isFinite(hubPid) && hubPid > 0) protectedPids.add(hubPid);
407
+ function getProtectedPids({
408
+ protectedPids,
409
+ isWindows = IS_WINDOWS,
410
+ spawnSyncFn = spawnSync,
411
+ includeAncestorScan = false,
412
+ } = {}) {
413
+ const result = new Set(protectedPids || []);
414
+ result.add(process.pid);
415
+ if (Number.isInteger(process.ppid) && process.ppid > 0) {
416
+ result.add(process.ppid);
417
+ }
418
+ addHubPid(result);
419
+ if (!includeAncestorScan) return result;
230
420
 
231
- try {
232
- const treeOutput = execSync(
233
- `powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "${TREE_SCRIPT_PATH}" -StartPid ${myPid}`,
234
- { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
235
- );
236
- for (const line of treeOutput.split(/\r?\n/)) {
237
- const pid = Number.parseInt(line.trim(), 10);
238
- if (Number.isFinite(pid) && pid > 0) protectedPids.add(pid);
421
+ const snapshot = getProcessSnapshot({ isWindows, spawnSyncFn });
422
+ const byPid = new Map(snapshot.map((proc) => [proc.pid, proc]));
423
+ let current = process.pid;
424
+ const seen = new Set();
425
+ while (current > 0 && !seen.has(current)) {
426
+ seen.add(current);
427
+ result.add(current);
428
+ const parent = byPid.get(current)?.ppid;
429
+ if (!Number.isInteger(parent) || parent <= 0) break;
430
+ current = parent;
431
+ }
432
+
433
+ return result;
434
+ }
435
+
436
+ function collectDescendants(rootPid, processes) {
437
+ const childrenByParent = new Map();
438
+ for (const proc of processes) {
439
+ if (!childrenByParent.has(proc.ppid)) childrenByParent.set(proc.ppid, []);
440
+ childrenByParent.get(proc.ppid).push(proc);
441
+ }
442
+
443
+ const byPid = new Map(processes.map((proc) => [proc.pid, proc]));
444
+ const root = byPid.get(rootPid) || {
445
+ pid: rootPid,
446
+ ppid: 0,
447
+ name: "",
448
+ commandLine: "",
449
+ };
450
+ const result = [];
451
+ const queue = [root];
452
+ const seen = new Set();
453
+
454
+ while (queue.length > 0) {
455
+ const proc = queue.shift();
456
+ if (!proc || seen.has(proc.pid)) continue;
457
+ seen.add(proc.pid);
458
+ result.push(proc);
459
+ for (const child of childrenByParent.get(proc.pid) || []) {
460
+ queue.push(child);
239
461
  }
240
- } catch {}
462
+ }
241
463
 
242
- // 전체 프로세스 맵 구축 (node + bash + cmd + codex + claude + pwsh + uvx)
243
- const procMap = new Map();
244
- try {
245
- const output = execSync(
246
- `powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "${SCAN_SCRIPT_PATH}"`,
247
- { encoding: "utf8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
248
- );
464
+ return result;
465
+ }
466
+
467
+ function snapshotPids(snapshot) {
468
+ const values = Array.isArray(snapshot) ? snapshot : [snapshot];
469
+ return values
470
+ .map((item) => normalizePid(typeof item === "object" ? item?.pid : item))
471
+ .filter(Boolean);
472
+ }
473
+
474
+ function matchesPattern(commandLine, pattern, { exact = false } = {}) {
475
+ if (pattern instanceof RegExp) return pattern.test(commandLine);
476
+ const text = String(pattern || "");
477
+ if (!text) return false;
478
+ return exact ? commandLine === text : commandLine.includes(text);
479
+ }
480
+
481
+ function hasExactGbrainServe(commandLine) {
482
+ const normalized = commandLine.trim();
483
+ return (
484
+ /^("?[^"\s]*bun(?:\.exe)?"?\s+)?gbrain\s+serve$/i.test(normalized) ||
485
+ /^("?[^"\s]*bun(?:\.exe)?"?\s+)"?[^"]*gbrain[\\/]+src[\\/]+cli\.ts"?\s+serve$/i.test(
486
+ normalized,
487
+ )
488
+ );
489
+ }
490
+
491
+ function hasGbrainServeCommand(commandLine) {
492
+ return /(^|\s)gbrain\s+serve(\s|$)/i.test(commandLine.trim());
493
+ }
494
+
495
+ function hasFsmonitorDaemonCommand(commandLine) {
496
+ return FSMONITOR_DAEMON_PATTERN.test(commandLine);
497
+ }
498
+
499
+ function categoryForShardProcess(proc, context) {
500
+ const name = normalizeName(proc.name);
501
+ const commandLine = normalizeCommandLine(proc.commandLine);
502
+ const worktreePath = normalizeCommandLine(context.worktreePath || "");
503
+ const shardMarker = context.shardName
504
+ ? `.codex-swarm\\wt-${context.shardName}`
505
+ : "";
506
+ const hasWorktree = worktreePath && commandLine.includes(worktreePath);
507
+ const hasShardMarker = shardMarker && commandLine.includes(shardMarker);
508
+
509
+ if (name === "node.exe" || name === "node") {
510
+ if (hasWorktree || hasShardMarker) return "node";
511
+ }
512
+
513
+ if (name === "bash.exe" || name === "bash" || name === "sh") {
514
+ if (hasWorktree) return "bash";
515
+ }
516
+
517
+ if (name === "conhost.exe" || name === "conhost") {
518
+ if (
519
+ context.sessionIds?.length > 0 &&
520
+ context.sessionIds.some((id) => id && commandLine.includes(id))
521
+ ) {
522
+ return "conhost";
523
+ }
524
+ }
525
+
526
+ if (name === "bun.exe" || name === "bun") {
527
+ if ((hasWorktree || hasShardMarker) && hasGbrainServeCommand(commandLine)) {
528
+ return "bun";
529
+ }
530
+ }
531
+
532
+ if (name === "git.exe" || name === "git") {
533
+ if (
534
+ (hasWorktree || hasShardMarker) &&
535
+ hasFsmonitorDaemonCommand(commandLine)
536
+ ) {
537
+ return "git";
538
+ }
539
+ }
540
+
541
+ return null;
542
+ }
543
+
544
+ function killPid(
545
+ pid,
546
+ { killFn = process.kill, protectedPids = new Set() } = {},
547
+ ) {
548
+ if (protectedPids.has(pid)) return false;
549
+ killFn(pid, "SIGKILL");
550
+ return true;
551
+ }
249
552
 
250
- for (const line of output.split(/\r?\n/)) {
251
- const trimmed = line.trim();
252
- if (!trimmed) continue;
253
- const [pidStr, ppidStr, name] = trimmed.split(",");
254
- const pid = Number.parseInt(pidStr, 10);
255
- const ppid = Number.parseInt(ppidStr, 10);
256
- if (Number.isFinite(pid) && pid > 0) {
257
- procMap.set(pid, { ppid, name: name || "unknown" });
553
+ /**
554
+ * Return a process tree rooted at `rootPid`.
555
+ *
556
+ * Windows queries `Get-CimInstance Win32_Process` once and builds a
557
+ * ParentProcessId map. POSIX uses `ps` as a best-effort fallback.
558
+ *
559
+ * @param {number} rootPid
560
+ * @param {{isWindows?: boolean, spawnSyncFn?: typeof spawnSync}} opts
561
+ * @returns {Array<{pid: number, ppid: number, name: string, commandLine: string}>}
562
+ */
563
+ export function findProcessTree(
564
+ rootPid,
565
+ { isWindows = IS_WINDOWS, spawnSyncFn = spawnSync } = {},
566
+ ) {
567
+ const pid = normalizePid(rootPid);
568
+ if (!pid) return [];
569
+ const snapshot = getProcessSnapshot({ isWindows, spawnSyncFn });
570
+ return collectDescendants(pid, snapshot).map((proc) => ({
571
+ pid: proc.pid,
572
+ ppid: proc.ppid,
573
+ name: proc.name,
574
+ commandLine: proc.commandLine,
575
+ }));
576
+ }
577
+
578
+ /**
579
+ * Kill a process tree.
580
+ *
581
+ * Windows tries `taskkill /T /F /PID <pid>` first. If that fails, it falls
582
+ * back to a CIM process snapshot and kills descendants recursively. POSIX
583
+ * first targets the process group, then the PID itself.
584
+ *
585
+ * @param {number} pid
586
+ * @param {{isWindows?: boolean, spawnSyncFn?: typeof spawnSync, killFn?: typeof process.kill, isPidAliveFn?: typeof isPidAlive, protectedPids?: Set<number>}} opts
587
+ * @returns {{ok: boolean, killed: number, errors: Array}}
588
+ */
589
+ export function killProcessTree(
590
+ pid,
591
+ {
592
+ isWindows = IS_WINDOWS,
593
+ spawnSyncFn = spawnSync,
594
+ killFn = process.kill,
595
+ isPidAliveFn = isPidAlive,
596
+ protectedPids,
597
+ } = {},
598
+ ) {
599
+ const rootPid = normalizePid(pid);
600
+ const protectedSet = getProtectedPids({
601
+ protectedPids,
602
+ isWindows,
603
+ spawnSyncFn,
604
+ });
605
+ const errors = [];
606
+ if (!rootPid) return { ok: false, killed: 0, errors: ["invalid pid"] };
607
+ if (protectedSet.has(rootPid)) {
608
+ return { ok: false, killed: 0, errors: ["protected pid"] };
609
+ }
610
+
611
+ if (isWindows) {
612
+ const result = runSpawn(spawnSyncFn, "taskkill", [
613
+ "/T",
614
+ "/F",
615
+ "/PID",
616
+ String(rootPid),
617
+ ]);
618
+ if (result.status === 0) return { ok: true, killed: 1, errors };
619
+ errors.push(String(result.stderr || result.error || "taskkill failed"));
620
+
621
+ const tree = findProcessTree(rootPid, { isWindows, spawnSyncFn })
622
+ .filter((proc) => proc.pid !== rootPid)
623
+ .reverse();
624
+ let killed = 0;
625
+ for (const proc of tree) {
626
+ if (protectedSet.has(proc.pid) || !isPidAliveFn(proc.pid)) continue;
627
+ try {
628
+ killFn(proc.pid, "SIGKILL");
629
+ killed++;
630
+ } catch (error) {
631
+ errors.push({ pid: proc.pid, error: String(error?.message || error) });
258
632
  }
259
633
  }
260
- } catch {}
634
+ return { ok: killed > 0, killed, errors };
635
+ }
261
636
 
262
- // 고아 판정 + 정리 (SIGTERM → 3s → SIGKILL 에스컬레이션)
263
- // CLI 도구(codex/claude/pwsh)는 체인 추적용으로만 스캔 — kill 대상에서 제외
264
- const orphanPids = [];
265
- for (const [pid, info] of procMap) {
637
+ let killed = 0;
638
+ try {
639
+ killFn(-rootPid, "SIGKILL");
640
+ killed++;
641
+ } catch (error) {
642
+ errors.push({ pid: -rootPid, error: String(error?.message || error) });
643
+ try {
644
+ killFn(rootPid, "SIGKILL");
645
+ killed++;
646
+ } catch (fallbackError) {
647
+ errors.push({
648
+ pid: rootPid,
649
+ error: String(fallbackError?.message || fallbackError),
650
+ });
651
+ }
652
+ }
653
+
654
+ return { ok: killed > 0, killed, errors };
655
+ }
656
+
657
+ /**
658
+ * Kill a previously captured PID snapshot with SIGKILL.
659
+ *
660
+ * This is used when a parent process has already died and tree lookup can no
661
+ * longer reconstruct the original descendants.
662
+ *
663
+ * @param {Array<number | {pid: number}>} snapshot
664
+ * @param {{killFn?: typeof process.kill, isPidAliveFn?: typeof isPidAlive, protectedPids?: Set<number>}} opts
665
+ * @returns {{killed: number, missing: number}}
666
+ */
667
+ export function killProcessTreeSnapshot(
668
+ snapshot,
669
+ {
670
+ killFn = process.kill,
671
+ isPidAliveFn = isPidAlive,
672
+ protectedPids = new Set(),
673
+ } = {},
674
+ ) {
675
+ let killed = 0;
676
+ let missing = 0;
677
+ for (const pid of snapshotPids(snapshot)) {
266
678
  if (protectedPids.has(pid)) continue;
267
- if (!KILLABLE_NAMES.has(info.name?.toLowerCase())) continue;
268
- if (hasLiveAncestorChain(pid, procMap, protectedPids)) continue;
269
- orphanPids.push(pid);
679
+ if (!isPidAliveFn(pid)) {
680
+ missing++;
681
+ continue;
682
+ }
683
+ try {
684
+ killFn(pid, "SIGKILL");
685
+ killed++;
686
+ } catch {
687
+ missing++;
688
+ }
270
689
  }
690
+ return { killed, missing };
691
+ }
271
692
 
272
- const killed = killWithEscalation(orphanPids, procMap);
693
+ /**
694
+ * Find processes whose command line matches any string or RegExp pattern.
695
+ *
696
+ * String patterns use substring matching by default; pass `exact: true` for
697
+ * exact command-line equality.
698
+ *
699
+ * @param {Array<RegExp | string>} patterns
700
+ * @param {{isWindows?: boolean, spawnSyncFn?: typeof spawnSync, exact?: boolean, nowMs?: number}} opts
701
+ * @returns {Array<{pid: number, name: string, commandLine: string, ageMs?: number}>}
702
+ */
703
+ export function findProcessesByCommandLine(
704
+ patterns,
705
+ {
706
+ isWindows = IS_WINDOWS,
707
+ spawnSyncFn = spawnSync,
708
+ exact = false,
709
+ nowMs = Date.now(),
710
+ } = {},
711
+ ) {
712
+ const list = Array.isArray(patterns) ? patterns : [patterns];
713
+ if (list.length === 0) return [];
273
714
 
274
- // 남은 프로세스 수 확인
275
- let remaining = 0;
715
+ return getProcessSnapshot({ isWindows, spawnSyncFn })
716
+ .filter((proc) =>
717
+ list.some((pattern) =>
718
+ matchesPattern(proc.commandLine, pattern, { exact }),
719
+ ),
720
+ )
721
+ .map((proc) => {
722
+ const creationMs = parseCreationDateMs(proc.creationDate);
723
+ const result = {
724
+ pid: proc.pid,
725
+ name: proc.name,
726
+ commandLine: proc.commandLine,
727
+ };
728
+ if (Number.isFinite(creationMs)) result.ageMs = nowMs - creationMs;
729
+ return result;
730
+ });
731
+ }
732
+
733
+ /**
734
+ * Cleanup processes associated with a swarm shard.
735
+ *
736
+ * Sequence: kill known top PID snapshots, scan scoped command lines, skip
737
+ * protected PIDs, and kill only narrowly gated runtime categories.
738
+ *
739
+ * @param {{worktreePath?: string, sessionIds?: string[], topPids?: Array<number | {pid: number}>, runId?: string, shardName?: string, dryRun?: boolean, isWindows?: boolean, spawnSyncFn?: typeof spawnSync, killFn?: typeof process.kill, protectedPids?: Set<number>}} opts
740
+ * @returns {{killed: number, scanned: number, skipped: number, byCategory: {node: number, bash: number, conhost: number, bun: number, git: number}}}
741
+ */
742
+ export function cleanupShardProcesses({
743
+ worktreePath,
744
+ sessionIds = [],
745
+ topPids = [],
746
+ runId,
747
+ shardName,
748
+ dryRun = false,
749
+ isWindows = IS_WINDOWS,
750
+ spawnSyncFn = spawnSync,
751
+ killFn = process.kill,
752
+ protectedPids,
753
+ } = {}) {
754
+ const protectedSet = getProtectedPids({
755
+ protectedPids,
756
+ isWindows,
757
+ spawnSyncFn,
758
+ includeAncestorScan: true,
759
+ });
760
+ const byCategory = { node: 0, bash: 0, conhost: 0, bun: 0, git: 0 };
761
+ let killed = 0;
762
+ let skipped = 0;
763
+
764
+ if (!dryRun && topPids.length > 0) {
765
+ killed += killProcessTreeSnapshot(topPids, {
766
+ killFn,
767
+ protectedPids: protectedSet,
768
+ }).killed;
769
+ }
770
+
771
+ const scanned = getProcessSnapshot({ isWindows, spawnSyncFn });
772
+
773
+ for (const proc of scanned) {
774
+ const category = categoryForShardProcess(proc, {
775
+ worktreePath,
776
+ sessionIds,
777
+ shardName,
778
+ });
779
+ if (!category) continue;
780
+ byCategory[category]++;
781
+ if (protectedSet.has(proc.pid)) {
782
+ skipped++;
783
+ continue;
784
+ }
785
+ if (dryRun) continue;
786
+ try {
787
+ if (killPid(proc.pid, { killFn, protectedPids: protectedSet })) killed++;
788
+ } catch {}
789
+ }
790
+
791
+ return { killed, scanned: scanned.length, skipped, byCategory };
792
+ }
793
+
794
+ /**
795
+ * Cleanup narrowly gated orphan runtime processes.
796
+ *
797
+ * Windows scans node/bash/bun and optional conhost command lines. POSIX is a
798
+ * best-effort fallback. `legacy: true` preserves `cleanupOrphanNodeProcesses`
799
+ * behavior by targeting scoped orphan node runtimes only.
800
+ *
801
+ * @param {{legacy?: boolean, includeConhost?: boolean, sessionIds?: string[], isWindows?: boolean, spawnSyncFn?: typeof spawnSync, killFn?: typeof process.kill, protectedPids?: Set<number>}} opts
802
+ * @returns {{killed: number, remaining: number, killedProcesses: Array<{pid: number, ppid: number, name: string, commandLine: string, killReason: string}>}}
803
+ */
804
+ export function cleanupOrphanRuntimeProcesses({
805
+ legacy = false,
806
+ includeConhost = false,
807
+ sessionIds = [],
808
+ isWindows = IS_WINDOWS,
809
+ spawnSyncFn = spawnSync,
810
+ killFn = process.kill,
811
+ protectedPids,
812
+ } = {}) {
813
+ if (!isWindows) return cleanupOrphansUnix();
814
+
815
+ const protectedSet = getProtectedPids({
816
+ protectedPids,
817
+ isWindows,
818
+ spawnSyncFn,
819
+ includeAncestorScan: legacy,
820
+ });
821
+ const processes = getProcessSnapshot({ isWindows, spawnSyncFn });
822
+ let killed = 0;
823
+ const killedProcesses = [];
824
+ const procMap = legacy
825
+ ? new Map(processes.map((proc) => [proc.pid, proc]))
826
+ : new Map(processes.map((proc) => [proc.pid, proc]));
827
+
828
+ for (const proc of processes) {
829
+ if (protectedSet.has(proc.pid)) continue;
830
+ const name = normalizeName(proc.name);
831
+ const commandLine = normalizeCommandLine(proc.commandLine);
832
+ let shouldKill = false;
833
+ let killReason = null;
834
+
835
+ if (legacy) {
836
+ if (
837
+ LEGACY_ORPHAN_KILLABLE_NAMES.has(name) &&
838
+ !hasLiveAncestorChain(proc.pid, procMap, protectedSet) &&
839
+ !hasLiveCliDescendant(proc.pid, procMap)
840
+ ) {
841
+ shouldKill = true;
842
+ killReason = "legacy_orphan_ancestor_chain_dead";
843
+ }
844
+ } else if (name === "bun.exe") {
845
+ if (
846
+ hasExactGbrainServe(proc.commandLine) &&
847
+ !hasLiveAncestorChain(proc.pid, procMap, protectedSet) &&
848
+ !hasLiveCliDescendant(proc.pid, procMap)
849
+ ) {
850
+ shouldKill = true;
851
+ killReason = "bun_gbrain_serve_orphan";
852
+ }
853
+ } else if (includeConhost && name === "conhost.exe") {
854
+ if (
855
+ sessionIds.length > 0 &&
856
+ sessionIds.some((id) => id && commandLine.includes(id))
857
+ ) {
858
+ shouldKill = true;
859
+ killReason = "conhost_session_match";
860
+ }
861
+ }
862
+
863
+ if (!shouldKill) continue;
864
+ try {
865
+ killFn(proc.pid, "SIGKILL");
866
+ killed++;
867
+ killedProcesses.push({
868
+ pid: proc.pid,
869
+ ppid: proc.ppid,
870
+ name: proc.name,
871
+ commandLine: String(proc.commandLine || "").slice(0, 200),
872
+ killReason,
873
+ });
874
+ } catch {}
875
+ }
876
+
877
+ const remaining = processes.length - killed;
878
+ return { killed, remaining, killedProcesses };
879
+ }
880
+
881
+ /**
882
+ * Find stale git fsmonitor--daemon processes.
883
+ *
884
+ * Windows only. The CIM query is scoped to git.exe and the command line marker
885
+ * is intentionally narrow so foreground git commands are never targeted.
886
+ *
887
+ * @param {{minAgeMs?: number, execSyncFn?: typeof execSync, nowMs?: number, isWindows?: boolean}} opts
888
+ * @returns {Array<{pid: number, parentPid: number, creationDate: string, ageMs: number, commandLine: string}>}
889
+ */
890
+ export function findFsmonitorDaemons({
891
+ minAgeMs = DEFAULT_FSMONITOR_MIN_AGE_MS,
892
+ execSyncFn = execSync,
893
+ nowMs = Date.now(),
894
+ isWindows = IS_WINDOWS,
895
+ } = {}) {
896
+ if (!isWindows) return [];
897
+
898
+ ensureHelperScripts();
899
+
900
+ let records;
276
901
  try {
277
- const countOutput = execSync(
278
- `powershell -NoProfile -WindowStyle Hidden -Command "(Get-Process node -ErrorAction SilentlyContinue).Count"`,
279
- { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
902
+ const output = execSyncFn(
903
+ `powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process -Filter \\"Name='git.exe'\\" | Where-Object { $_.CommandLine -match '(^|\\s)fsmonitor--daemon run --detach(\\s|$)' } | Select-Object ProcessId,ParentProcessId,CreationDate,CommandLine | ConvertTo-Json -Compress"`,
904
+ {
905
+ encoding: "utf8",
906
+ timeout: 10000,
907
+ stdio: ["ignore", "pipe", "ignore"],
908
+ windowsHide: true,
909
+ },
280
910
  );
281
- remaining = Number.parseInt(countOutput.trim(), 10) || 0;
282
- } catch {}
911
+ records = normalizePowerShellJson(output);
912
+ } catch {
913
+ return [];
914
+ }
283
915
 
284
- return { killed, remaining };
916
+ const stale = [];
917
+ for (const record of records) {
918
+ const pid = Number(record?.ProcessId);
919
+ const parentPid = Number(record?.ParentProcessId);
920
+ const commandLine = String(record?.CommandLine || "");
921
+ if (!Number.isInteger(pid) || pid <= 0) continue;
922
+ if (!commandLine.includes(FSMONITOR_DAEMON_MARKER)) continue;
923
+ if (!FSMONITOR_DAEMON_PATTERN.test(commandLine)) continue;
924
+
925
+ const creationMs = parseCreationDateMs(record?.CreationDate);
926
+ const ageMs = Number.isFinite(creationMs) ? nowMs - creationMs : NaN;
927
+ if (!Number.isFinite(ageMs) || ageMs < minAgeMs) continue;
928
+
929
+ stale.push({
930
+ pid,
931
+ parentPid: Number.isFinite(parentPid) ? parentPid : 0,
932
+ creationDate: String(record?.CreationDate || ""),
933
+ ageMs,
934
+ commandLine,
935
+ });
936
+ }
937
+
938
+ return stale;
939
+ }
940
+
941
+ /**
942
+ * Cleanup stale git fsmonitor--daemon processes.
943
+ * @param {{minAgeMs?: number, execSyncFn?: typeof execSync, nowMs?: number, isWindows?: boolean, killFn?: typeof process.kill}} opts
944
+ * @returns {{killed: number, stale: Array}}
945
+ */
946
+ export function cleanupStaleFsmonitorDaemons({
947
+ killFn = process.kill,
948
+ ...findOpts
949
+ } = {}) {
950
+ const stale = findFsmonitorDaemons(findOpts);
951
+ let killed = 0;
952
+
953
+ for (const proc of stale) {
954
+ try {
955
+ killFn(proc.pid, "SIGKILL");
956
+ killed++;
957
+ } catch {}
958
+ }
959
+
960
+ return { killed, stale };
285
961
  }
286
962
 
287
963
  /**
@@ -296,7 +972,13 @@ function cleanupOrphansUnix() {
296
972
  const protectedPids = new Set();
297
973
  protectedPids.add(myPid);
298
974
  try {
299
- const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
975
+ const hubPidPath = join(
976
+ homedir(),
977
+ ".claude",
978
+ "cache",
979
+ "tfx-hub",
980
+ "hub.pid",
981
+ );
300
982
  if (existsSync(hubPidPath)) {
301
983
  const hubPid = Number(JSON.parse(readFileSync(hubPidPath, "utf8"))?.pid);
302
984
  if (Number.isFinite(hubPid) && hubPid > 0) protectedPids.add(hubPid);
@@ -309,7 +991,9 @@ function cleanupOrphansUnix() {
309
991
  for (let i = 0; i < 10; i++) {
310
992
  protectedPids.add(current);
311
993
  const output = execSync(`ps -o ppid= -p ${current}`, {
312
- encoding: "utf8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
994
+ encoding: "utf8",
995
+ timeout: 3000,
996
+ stdio: ["pipe", "pipe", "pipe"],
313
997
  });
314
998
  const ppid = Number.parseInt(output.trim(), 10);
315
999
  if (!Number.isFinite(ppid) || ppid <= 1) break;
@@ -321,7 +1005,9 @@ function cleanupOrphansUnix() {
321
1005
  const procMap = new Map();
322
1006
  try {
323
1007
  const output = execSync("ps -eo pid,ppid,comm", {
324
- encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
1008
+ encoding: "utf8",
1009
+ timeout: 10000,
1010
+ stdio: ["pipe", "pipe", "pipe"],
325
1011
  });
326
1012
  for (const line of output.split("\n").slice(1)) {
327
1013
  const parts = line.trim().split(/\s+/);
@@ -329,14 +1015,18 @@ function cleanupOrphansUnix() {
329
1015
  const pid = Number.parseInt(parts[0], 10);
330
1016
  const ppid = Number.parseInt(parts[1], 10);
331
1017
  const name = parts.slice(2).join(" ");
332
- if (Number.isFinite(pid) && pid > 0 && /^(node|bash|sh|python|codex|claude|uvx)/.test(name)) {
1018
+ if (
1019
+ Number.isFinite(pid) &&
1020
+ pid > 0 &&
1021
+ /^(node|bash|sh|python|codex|claude|gemini|uvx)/.test(name)
1022
+ ) {
333
1023
  procMap.set(pid, { ppid, name });
334
1024
  }
335
1025
  }
336
1026
  } catch {}
337
1027
 
338
1028
  // kill 대상: node, python, codex, claude, uvx — bash/sh는 사용자 인터랙티브 쉘 가능성
339
- const killableUnix = /^(node|python|codex|claude|uvx)/;
1029
+ const killableUnix = /^(node|python|codex|claude|gemini|uvx)/;
340
1030
 
341
1031
  // 고아 판정 + SIGKILL 에스컬레이션
342
1032
  const orphanPids = [];
@@ -344,6 +1034,7 @@ function cleanupOrphansUnix() {
344
1034
  if (protectedPids.has(pid)) continue;
345
1035
  if (!killableUnix.test(info.name)) continue;
346
1036
  if (hasLiveAncestorChain(pid, procMap, protectedPids)) continue;
1037
+ if (hasLiveCliDescendant(pid, procMap)) continue;
347
1038
  orphanPids.push(pid);
348
1039
  }
349
1040
 
@@ -352,7 +1043,9 @@ function cleanupOrphansUnix() {
352
1043
  let remaining = 0;
353
1044
  try {
354
1045
  const output = execSync("ps -eo comm | grep -c '^node$'", {
355
- encoding: "utf8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
1046
+ encoding: "utf8",
1047
+ timeout: 3000,
1048
+ stdio: ["pipe", "pipe", "pipe"],
356
1049
  });
357
1050
  remaining = Number.parseInt(output.trim(), 10) || 0;
358
1051
  } catch {}