@triflux/core 10.1.0 → 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.
- package/hooks/agent-route-guard.mjs +14 -10
- package/hooks/cross-review-tracker.mjs +21 -8
- package/hooks/error-context.mjs +20 -8
- package/hooks/hook-adaptive-collector.mjs +18 -16
- package/hooks/hook-manager.mjs +93 -32
- package/hooks/hook-orchestrator.mjs +267 -35
- package/hooks/hook-registry.json +343 -256
- package/hooks/hooks.json +49 -1
- package/hooks/keyword-rules.json +183 -23
- package/hooks/lib/resolve-root.mjs +1 -1
- package/hooks/mcp-config-watcher.mjs +6 -2
- package/hooks/permission-safe-allow.mjs +233 -0
- package/hooks/pipeline-stop.mjs +369 -22
- package/hooks/post-tool-tips.mjs +306 -0
- package/hooks/pre-compact-snapshot.mjs +181 -0
- package/hooks/safety-guard.mjs +254 -33
- package/hooks/session-end-cleanup.mjs +345 -0
- package/hooks/session-start-fast.mjs +214 -0
- package/hooks/subagent-tracker.mjs +267 -0
- package/hooks/subagent-verifier.mjs +9 -4
- package/hub/account-broker.mjs +1036 -66
- package/hub/adaptive-diagnostic.mjs +75 -48
- package/hub/adaptive-inject.mjs +96 -58
- package/hub/adaptive-memory.mjs +156 -42
- package/hub/adaptive.mjs +60 -31
- package/hub/assign-callbacks.mjs +163 -126
- package/hub/bridge.mjs +831 -290
- package/hub/cli-adapter-base.mjs +321 -97
- package/hub/codex-adapter.mjs +179 -93
- package/hub/codex-compat.mjs +4 -4
- package/hub/codex-preflight.mjs +113 -37
- package/hub/delegator/contracts.mjs +19 -23
- package/hub/delegator/index.mjs +14 -14
- package/hub/delegator/service.mjs +88 -64
- package/hub/delegator/tool-definitions.mjs +35 -35
- package/hub/dynamic-routing-engine.mjs +511 -0
- package/hub/fullcycle.mjs +34 -18
- package/hub/gemini-adapter.mjs +77 -95
- package/hub/hitl.mjs +202 -143
- package/hub/intent.mjs +167 -38
- package/hub/lib/bash-path.mjs +73 -0
- package/hub/lib/cache-guard.mjs +43 -17
- package/hub/lib/env-detect.mjs +145 -0
- package/hub/lib/hosts-compat.mjs +319 -0
- package/hub/lib/mcp-response-cache.mjs +239 -0
- package/hub/lib/memory-store.mjs +285 -111
- package/hub/lib/path-utils.mjs +165 -0
- package/hub/lib/phase-manager.mjs +493 -0
- package/hub/lib/process-utils.mjs +797 -104
- package/hub/lib/prompt-tmp.mjs +33 -0
- package/hub/lib/risk-tier.mjs +53 -0
- package/hub/lib/spawn-trace.mjs +561 -0
- package/hub/lib/ssh-command.mjs +44 -24
- package/hub/lib/ssh-retry.mjs +5 -1
- package/hub/lib/staged-file-detect.mjs +43 -0
- package/hub/lib/state-snapshot.mjs +293 -0
- package/hub/lib/tfx-route-args.mjs +309 -0
- package/hub/lib/timeline-adapter.mjs +190 -0
- package/hub/lib/trace-recorder.mjs +153 -0
- package/hub/lib/uuidv7.mjs +4 -3
- package/hub/memory-doctor.mjs +266 -106
- package/hub/middleware/quota-middleware.mjs +70 -0
- package/hub/middleware/request-logger.mjs +61 -34
- package/hub/paths.mjs +9 -9
- package/hub/pipeline/gates/confidence.mjs +34 -15
- package/hub/pipeline/gates/consensus.mjs +27 -15
- package/hub/pipeline/gates/index.mjs +7 -3
- package/hub/pipeline/gates/selfcheck.mjs +57 -19
- package/hub/pipeline/index.mjs +77 -42
- package/hub/pipeline/state.mjs +10 -10
- package/hub/pipeline/transitions.mjs +40 -23
- package/hub/platform.mjs +65 -48
- package/hub/quality/deslop.mjs +70 -49
- package/hub/reflexion.mjs +87 -13
- package/hub/research.mjs +33 -25
- package/hub/router.mjs +941 -791
- package/hub/routing/complexity.mjs +132 -29
- package/hub/routing/index.mjs +17 -12
- package/hub/routing/q-learning.mjs +76 -28
- package/hub/routing-snapshot.mjs +261 -0
- package/hub/session-fingerprint.mjs +127 -61
- package/hub/state.mjs +86 -44
- package/hub/team/retry-state-machine.mjs +267 -0
- package/hub/token-mode.mjs +72 -71
- package/hub/workers/worker-utils.mjs +22 -16
- package/hud/colors.mjs +27 -9
- package/hud/constants.mjs +184 -27
- package/hud/context-monitor.mjs +133 -44
- package/hud/hud-qos-status.mjs +153 -50
- package/hud/mission-board.mjs +56 -0
- package/hud/providers/claude.mjs +398 -114
- package/hud/providers/codex.mjs +96 -21
- package/hud/providers/gemini.mjs +184 -51
- package/hud/renderers.mjs +413 -123
- package/hud/terminal.mjs +101 -31
- package/hud/utils.mjs +81 -39
- package/mesh/index.mjs +69 -0
- package/mesh/mesh-budget.mjs +137 -0
- package/mesh/mesh-heartbeat.mjs +100 -0
- package/mesh/mesh-protocol.mjs +96 -0
- package/mesh/mesh-queue.mjs +163 -0
- package/mesh/mesh-registry.mjs +78 -0
- package/mesh/mesh-router.mjs +77 -0
- package/package.json +5 -1
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +6 -1
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +67 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +3 -3
- package/scripts/lib/cross-review-utils.mjs +13 -5
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +58 -30
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +44 -10
- package/scripts/lib/handoff.mjs +33 -17
- package/scripts/lib/hook-utils.mjs +8 -6
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +48 -19
- package/scripts/lib/logger.mjs +24 -24
- package/scripts/lib/mcp-filter.mjs +391 -247
- package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
- package/scripts/lib/mcp-guard-engine.mjs +1281 -115
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +24 -14
- package/scripts/lib/mcp-server-catalog.mjs +355 -118
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +70 -10
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +44 -14
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +32 -7
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- 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 {
|
|
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 =
|
|
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 ===
|
|
31
|
-
if (e?.code ===
|
|
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) {
|
|
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
|
-
{
|
|
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, {
|
|
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(
|
|
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 {
|
|
118
|
-
|
|
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(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
*
|
|
200
|
-
*
|
|
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
|
-
|
|
268
|
+
export function cleanupOrphanNodeProcesses(opts = {}) {
|
|
269
|
+
return cleanupOrphanRuntimeProcesses({ ...opts, legacy: true });
|
|
270
|
+
}
|
|
211
271
|
|
|
212
|
-
|
|
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
|
-
|
|
279
|
+
function parseCreationDateMs(value) {
|
|
280
|
+
if (!value) return NaN;
|
|
281
|
+
if (value instanceof Date) return value.getTime();
|
|
215
282
|
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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 =
|
|
401
|
+
const hubPid = normalizePid(hubInfo?.pid);
|
|
402
|
+
if (hubPid) protectedPids.add(hubPid);
|
|
223
403
|
}
|
|
224
404
|
} catch {}
|
|
405
|
+
}
|
|
225
406
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
}
|
|
462
|
+
}
|
|
241
463
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
634
|
+
return { ok: killed > 0, killed, errors };
|
|
635
|
+
}
|
|
261
636
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 (!
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
278
|
-
`powershell -NoProfile -WindowStyle Hidden -Command "
|
|
279
|
-
{
|
|
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
|
-
|
|
282
|
-
} catch {
|
|
911
|
+
records = normalizePowerShellJson(output);
|
|
912
|
+
} catch {
|
|
913
|
+
return [];
|
|
914
|
+
}
|
|
283
915
|
|
|
284
|
-
|
|
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(
|
|
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",
|
|
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",
|
|
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 (
|
|
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",
|
|
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 {}
|