@triflux/remote 10.0.0-alpha.1 → 10.0.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/hub/index.mjs +21 -0
- package/hub/pipe.mjs +98 -13
- package/hub/server.mjs +1245 -1124
- package/hub/store-adapter.mjs +14 -747
- package/hub/store.mjs +4 -44
- package/hub/team/backend.mjs +1 -1
- package/hub/team/cli/services/hub-client.mjs +38 -19
- package/hub/team/cli/services/native-control.mjs +1 -1
- package/hub/team/conductor.mjs +671 -0
- package/hub/team/event-log.mjs +76 -0
- package/hub/team/headless.mjs +8 -6
- package/hub/team/health-probe.mjs +272 -0
- package/hub/team/launcher-template.mjs +95 -0
- package/hub/team/lead-control.mjs +104 -0
- package/hub/team/nativeProxy.mjs +9 -2
- package/hub/team/notify.mjs +293 -0
- package/hub/team/pane.mjs +1 -1
- package/hub/team/process-cleanup.mjs +342 -0
- package/hub/team/psmux.mjs +1 -1
- package/hub/team/remote-probe.mjs +276 -0
- package/hub/team/remote-session.mjs +296 -0
- package/hub/team/remote-watcher.mjs +478 -0
- package/hub/team/session-sync.mjs +169 -0
- package/hub/team/staleState.mjs +1 -1
- package/hub/team/tui-remote-adapter.mjs +393 -0
- package/hub/team/tui.mjs +206 -2
- package/hub/tools.mjs +94 -12
- package/hub/tray.mjs +1 -1
- package/hub/workers/codex-mcp.mjs +8 -2
- package/hub/workers/gemini-worker.mjs +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// hub/team/notify.mjs — team notifier (bell / Windows toast / webhook)
|
|
2
|
+
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
export const NOTIFY_EVENT_TYPES = Object.freeze(['completed', 'failed', 'inputWait']);
|
|
7
|
+
export const NOTIFY_CHANNELS = Object.freeze(['bell', 'toast', 'webhook']);
|
|
8
|
+
|
|
9
|
+
function freezeRecord(record) {
|
|
10
|
+
return Object.freeze({ ...record });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function escapePowerShellSingleQuoted(value) {
|
|
14
|
+
return String(value ?? '').replaceAll("'", "''");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeTimestamp(value) {
|
|
18
|
+
if (value instanceof Date) return value.toISOString();
|
|
19
|
+
if (value == null || value === '') return new Date().toISOString();
|
|
20
|
+
return String(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeEvent(event, defaults = {}) {
|
|
24
|
+
if (!event || typeof event !== 'object') {
|
|
25
|
+
throw new TypeError('notify(event) requires an event object');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const type = String(event.type || '').trim();
|
|
29
|
+
if (!NOTIFY_EVENT_TYPES.includes(type)) {
|
|
30
|
+
throw new TypeError(`Unsupported notify event type: ${type || '<empty>'}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return freezeRecord({
|
|
34
|
+
type,
|
|
35
|
+
sessionId: event.sessionId == null ? '' : String(event.sessionId),
|
|
36
|
+
host: event.host == null || event.host === '' ? String(defaults.host || os.hostname()) : String(event.host),
|
|
37
|
+
summary: event.summary == null ? '' : String(event.summary),
|
|
38
|
+
timestamp: normalizeTimestamp(event.timestamp),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function defaultChannelConfig(name, env) {
|
|
43
|
+
switch (name) {
|
|
44
|
+
case 'bell':
|
|
45
|
+
return { enabled: true };
|
|
46
|
+
case 'toast':
|
|
47
|
+
return { enabled: true };
|
|
48
|
+
case 'webhook': {
|
|
49
|
+
const url = String(env?.TRIFLUX_NOTIFY_WEBHOOK || '');
|
|
50
|
+
return { enabled: Boolean(url), url };
|
|
51
|
+
}
|
|
52
|
+
default:
|
|
53
|
+
throw new TypeError(`Unknown notify channel: ${name}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeChannelConfig(name, value, env) {
|
|
58
|
+
const base = defaultChannelConfig(name, env);
|
|
59
|
+
const patch = typeof value === 'boolean'
|
|
60
|
+
? { enabled: value }
|
|
61
|
+
: (value && typeof value === 'object' ? value : {});
|
|
62
|
+
|
|
63
|
+
const next = {
|
|
64
|
+
...base,
|
|
65
|
+
...patch,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if ('enabled' in next) {
|
|
69
|
+
next.enabled = Boolean(next.enabled);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (name === 'webhook') {
|
|
73
|
+
next.url = String(next.url || env?.TRIFLUX_NOTIFY_WEBHOOK || '');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (name === 'toast') {
|
|
77
|
+
if (next.command != null) next.command = String(next.command);
|
|
78
|
+
if (next.timeoutMs != null) next.timeoutMs = Math.max(1, Number.parseInt(String(next.timeoutMs), 10) || 5000);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return freezeRecord(next);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeChannels(channels, env) {
|
|
85
|
+
const source = channels && typeof channels === 'object' ? channels : {};
|
|
86
|
+
const normalized = {};
|
|
87
|
+
for (const name of NOTIFY_CHANNELS) {
|
|
88
|
+
normalized[name] = normalizeChannelConfig(name, source[name], env);
|
|
89
|
+
}
|
|
90
|
+
return Object.freeze(normalized);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function updateChannelConfig(channels, channel, config, env) {
|
|
94
|
+
if (!NOTIFY_CHANNELS.includes(channel)) {
|
|
95
|
+
throw new TypeError(`Unknown notify channel: ${channel}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
...channels,
|
|
100
|
+
[channel]: normalizeChannelConfig(channel, { ...channels[channel], ...(typeof config === 'boolean' ? { enabled: config } : (config || {})) }, env),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatEventTitle(event) {
|
|
105
|
+
switch (event.type) {
|
|
106
|
+
case 'completed':
|
|
107
|
+
return 'Triflux completed';
|
|
108
|
+
case 'failed':
|
|
109
|
+
return 'Triflux failed';
|
|
110
|
+
case 'inputWait':
|
|
111
|
+
return 'Triflux waiting for input';
|
|
112
|
+
default:
|
|
113
|
+
return 'Triflux notification';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatEventBody(event) {
|
|
118
|
+
const parts = [];
|
|
119
|
+
if (event.summary) parts.push(event.summary);
|
|
120
|
+
|
|
121
|
+
const meta = [event.sessionId ? `session ${event.sessionId}` : '', event.host ? `host ${event.host}` : '']
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.join(' · ');
|
|
124
|
+
if (meta) parts.push(meta);
|
|
125
|
+
|
|
126
|
+
return parts.join('\n') || event.timestamp;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function createResult(channel, status, extra = {}) {
|
|
130
|
+
return freezeRecord({ channel, status, ...extra });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function execFileAsync(command, args, options, execFileFn) {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
execFileFn(command, args, options, (error, stdout, stderr) => {
|
|
136
|
+
if (error) {
|
|
137
|
+
error.stdout = stdout;
|
|
138
|
+
error.stderr = stderr;
|
|
139
|
+
reject(error);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
resolve({ stdout, stderr });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function sendBell(config, deps) {
|
|
148
|
+
if (!config.enabled) return createResult('bell', 'skipped', { reason: 'disabled' });
|
|
149
|
+
const stream = deps.stdout;
|
|
150
|
+
if (!stream || typeof stream.write !== 'function') {
|
|
151
|
+
return createResult('bell', 'skipped', { reason: 'stdout-unavailable' });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
stream.write('\u0007');
|
|
156
|
+
return createResult('bell', 'sent');
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return createResult('bell', 'failed', { error: error.message });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildToastScript(title, body) {
|
|
163
|
+
const safeTitle = escapePowerShellSingleQuoted(title);
|
|
164
|
+
const safeBody = escapePowerShellSingleQuoted(body);
|
|
165
|
+
return [
|
|
166
|
+
"$ErrorActionPreference = 'Stop'",
|
|
167
|
+
`$Title = '${safeTitle}'`,
|
|
168
|
+
`$Body = '${safeBody}'`,
|
|
169
|
+
"if (Get-Command New-BurntToastNotification -ErrorAction SilentlyContinue) {",
|
|
170
|
+
' New-BurntToastNotification -Text @($Title, $Body) | Out-Null',
|
|
171
|
+
' return',
|
|
172
|
+
'}',
|
|
173
|
+
'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null',
|
|
174
|
+
'[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null',
|
|
175
|
+
'$escapedTitle = [System.Security.SecurityElement]::Escape($Title)',
|
|
176
|
+
'$escapedBody = [System.Security.SecurityElement]::Escape($Body)',
|
|
177
|
+
'$xml = New-Object Windows.Data.Xml.Dom.XmlDocument',
|
|
178
|
+
'$xml.LoadXml("<toast><visual><binding template=\'ToastGeneric\'><text>$escapedTitle</text><text>$escapedBody</text></binding></visual></toast>")',
|
|
179
|
+
'$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)',
|
|
180
|
+
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Triflux').Show($toast)",
|
|
181
|
+
].join('; ');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function sendToast(event, config, deps) {
|
|
185
|
+
if (!config.enabled) return createResult('toast', 'skipped', { reason: 'disabled' });
|
|
186
|
+
if ((deps.platform || process.platform) !== 'win32') {
|
|
187
|
+
return createResult('toast', 'skipped', { reason: 'unsupported-platform' });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const execFileFn = deps.execFile || execFile;
|
|
191
|
+
const candidates = config.command
|
|
192
|
+
? [config.command]
|
|
193
|
+
: Array.isArray(deps.powerShellCandidates) && deps.powerShellCandidates.length > 0
|
|
194
|
+
? deps.powerShellCandidates
|
|
195
|
+
: ['pwsh', 'powershell.exe'];
|
|
196
|
+
|
|
197
|
+
const title = formatEventTitle(event);
|
|
198
|
+
const body = formatEventBody(event);
|
|
199
|
+
const script = buildToastScript(title, body);
|
|
200
|
+
const failures = [];
|
|
201
|
+
|
|
202
|
+
for (const command of candidates) {
|
|
203
|
+
try {
|
|
204
|
+
await execFileAsync(command, ['-NoLogo', '-NoProfile', '-Command', script], {
|
|
205
|
+
windowsHide: true,
|
|
206
|
+
timeout: config.timeoutMs || 5000,
|
|
207
|
+
}, execFileFn);
|
|
208
|
+
return createResult('toast', 'sent', { command });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
failures.push(`${command}: ${error.message}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return createResult('toast', 'failed', { error: failures.join(' | ') || 'toast-send-failed' });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function sendWebhook(event, config, deps) {
|
|
218
|
+
if (!config.enabled) return createResult('webhook', 'skipped', { reason: 'disabled' });
|
|
219
|
+
if (!config.url) return createResult('webhook', 'skipped', { reason: 'missing-url' });
|
|
220
|
+
if (typeof deps.fetch !== 'function') {
|
|
221
|
+
return createResult('webhook', 'failed', { error: 'fetch-unavailable' });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const response = await deps.fetch(config.url, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'content-type': 'application/json' },
|
|
228
|
+
body: JSON.stringify(event),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (!response?.ok) {
|
|
232
|
+
return createResult('webhook', 'failed', { error: `HTTP ${response?.status ?? 'unknown'}` });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return createResult('webhook', 'sent', { statusCode: response.status });
|
|
236
|
+
} catch (error) {
|
|
237
|
+
return createResult('webhook', 'failed', { error: error.message });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function createNotifierInstance(channels, deps) {
|
|
242
|
+
async function notify(event) {
|
|
243
|
+
const normalizedEvent = normalizeEvent(event, { host: deps.hostname });
|
|
244
|
+
const results = {
|
|
245
|
+
bell: await sendBell(channels.bell, deps),
|
|
246
|
+
toast: await sendToast(normalizedEvent, channels.toast, deps),
|
|
247
|
+
webhook: await sendWebhook(normalizedEvent, channels.webhook, deps),
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return freezeRecord({
|
|
251
|
+
event: normalizedEvent,
|
|
252
|
+
results: Object.freeze(results),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function setChannel(channel, config) {
|
|
257
|
+
return createNotifierInstance(updateChannelConfig(channels, channel, config, deps.env), deps);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return Object.freeze({ notify, setChannel });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 팀 세션 알림기 팩토리.
|
|
265
|
+
* - bell: 터미널 BEL 문자
|
|
266
|
+
* - toast: Windows PowerShell 기반 toast (BurntToast 우선, WinRT fallback)
|
|
267
|
+
* - webhook: TRIFLUX_NOTIFY_WEBHOOK JSON POST
|
|
268
|
+
*
|
|
269
|
+
* Immutable pattern: setChannel()은 기존 notifier를 수정하지 않고 새 notifier를 반환한다.
|
|
270
|
+
*
|
|
271
|
+
* @param {object} [opts]
|
|
272
|
+
* @param {object} [opts.channels] — 채널별 초기 설정
|
|
273
|
+
* @param {object} [opts.env=process.env] — 환경 변수 소스
|
|
274
|
+
* @param {NodeJS.WriteStream|{write:function}} [opts.stdout=process.stdout] — bell 출력 대상
|
|
275
|
+
* @param {string} [opts.platform=process.platform] — 플랫폼 override (test 용)
|
|
276
|
+
* @param {string} [opts.hostname=os.hostname()] — 기본 host override
|
|
277
|
+
* @param {object} [opts.deps] — 테스트용 의존성 주입
|
|
278
|
+
* @returns {{ notify(event: object): Promise<object>, setChannel(channel: string, config: object|boolean): object }}
|
|
279
|
+
*/
|
|
280
|
+
export function createNotifier(opts = {}) {
|
|
281
|
+
const env = opts.env || process.env;
|
|
282
|
+
const deps = Object.freeze({
|
|
283
|
+
env,
|
|
284
|
+
stdout: opts.stdout || process.stdout,
|
|
285
|
+
execFile: opts.deps?.execFile || execFile,
|
|
286
|
+
fetch: opts.deps?.fetch || globalThis.fetch?.bind(globalThis),
|
|
287
|
+
platform: opts.platform || process.platform,
|
|
288
|
+
hostname: opts.hostname || os.hostname(),
|
|
289
|
+
powerShellCandidates: Object.freeze(opts.deps?.powerShellCandidates || ['pwsh', 'powershell.exe']),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return createNotifierInstance(normalizeChannels(opts.channels, env), deps);
|
|
293
|
+
}
|
package/hub/team/pane.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { detectMultiplexer, tmuxExec } from "./session.mjs";
|
|
7
7
|
import { psmuxExec } from "./psmux.mjs";
|
|
8
8
|
|
|
9
|
-
import { buildExecArgs } from "
|
|
9
|
+
import { buildExecArgs } from "@triflux/core/hub/codex-adapter.mjs";
|
|
10
10
|
|
|
11
11
|
function quoteArg(value) {
|
|
12
12
|
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// hub/team/process-cleanup.mjs — 고아 node/python 프로세스 감지 및 정리
|
|
2
|
+
// Windows: Get-CimInstance Win32_Process로 parent PID + cmdLine 접근
|
|
3
|
+
// Unix: ps aux 파싱
|
|
4
|
+
import { execFile as nodeExecFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(nodeExecFile);
|
|
9
|
+
|
|
10
|
+
// ── 상수 ────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const TARGET_PROCESS_NAMES = ["node", "python", "python3"];
|
|
13
|
+
const SIGTERM_GRACE_MS = 5000;
|
|
14
|
+
|
|
15
|
+
// cmdLine 패턴 기반 화이트리스트 (고아 후보에서 제외)
|
|
16
|
+
const WHITELIST_CMDLINE = [
|
|
17
|
+
/oh-my-claudecode/i,
|
|
18
|
+
/triflux[\\/]hub[\\/]s/i,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// 프로세스명 기반 화이트리스트
|
|
22
|
+
const WHITELIST_NAMES = ["claude", "CCXProcess"];
|
|
23
|
+
|
|
24
|
+
// ── PowerShell / ps 파싱 ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Windows: Get-CimInstance Win32_Process로 프로세스 목록 조회
|
|
28
|
+
* @param {Function} execFileFn - DI용 execFile 구현
|
|
29
|
+
* @returns {Promise<Array<{pid,name,parentPid,cmdLine,ramMB}>>}
|
|
30
|
+
*/
|
|
31
|
+
async function queryWindowsProcesses(execFileFn) {
|
|
32
|
+
const script = [
|
|
33
|
+
"Get-CimInstance Win32_Process |",
|
|
34
|
+
" Select-Object ProcessId,Name,ParentProcessId,CommandLine,WorkingSetSize |",
|
|
35
|
+
" ConvertTo-Json -Compress",
|
|
36
|
+
].join(" ");
|
|
37
|
+
|
|
38
|
+
const { stdout } = await execFileFn(
|
|
39
|
+
"pwsh",
|
|
40
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
41
|
+
{ encoding: "utf8", timeout: 15_000, windowsHide: true },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const raw = JSON.parse(stdout.trim());
|
|
45
|
+
const items = Array.isArray(raw) ? raw : [raw];
|
|
46
|
+
|
|
47
|
+
return items
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map((p) => ({
|
|
50
|
+
pid: Number(p.ProcessId),
|
|
51
|
+
name: String(p.Name || "").replace(/\.exe$/i, ""),
|
|
52
|
+
parentPid: Number(p.ParentProcessId) || 0,
|
|
53
|
+
cmdLine: String(p.CommandLine || ""),
|
|
54
|
+
ramMB: Math.round((Number(p.WorkingSetSize) || 0) / 1024 / 1024),
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Unix: ps aux 파싱
|
|
60
|
+
* @param {Function} execFileFn - DI용 execFile 구현
|
|
61
|
+
* @returns {Promise<Array<{pid,name,parentPid,cmdLine,ramMB}>>}
|
|
62
|
+
*/
|
|
63
|
+
async function queryUnixProcesses(execFileFn) {
|
|
64
|
+
const { stdout } = await execFileFn(
|
|
65
|
+
"ps",
|
|
66
|
+
["-eo", "pid,ppid,rss,comm,args", "--no-headers"],
|
|
67
|
+
{ encoding: "utf8", timeout: 10_000 },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return stdout
|
|
71
|
+
.trim()
|
|
72
|
+
.split("\n")
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.map((line) => {
|
|
75
|
+
const parts = line.trim().split(/\s+/);
|
|
76
|
+
const pid = Number(parts[0]);
|
|
77
|
+
const parentPid = Number(parts[1]);
|
|
78
|
+
const ramMB = Math.round((Number(parts[2]) || 0) / 1024);
|
|
79
|
+
const name = String(parts[3] || "");
|
|
80
|
+
const cmdLine = parts.slice(4).join(" ");
|
|
81
|
+
return { pid, name, parentPid, cmdLine, ramMB };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 프로세스 시작 시각을 Unix 기준 ms로 조회 (Windows 전용, best-effort)
|
|
87
|
+
* @param {number} pid
|
|
88
|
+
* @param {Function} execFileFn
|
|
89
|
+
* @returns {Promise<number>} epoch ms, 실패 시 0
|
|
90
|
+
*/
|
|
91
|
+
async function getWindowsProcessStartMs(pid, execFileFn) {
|
|
92
|
+
try {
|
|
93
|
+
const script = `(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CreationDate | Get-Date -Format 'o'`;
|
|
94
|
+
const { stdout } = await execFileFn(
|
|
95
|
+
"pwsh",
|
|
96
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
97
|
+
{ encoding: "utf8", timeout: 5_000, windowsHide: true },
|
|
98
|
+
);
|
|
99
|
+
const ts = Date.parse(stdout.trim());
|
|
100
|
+
return Number.isNaN(ts) ? 0 : ts;
|
|
101
|
+
} catch {
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── 화이트리스트 판정 ──────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 프로세스가 화이트리스트에 해당하는지 판정
|
|
110
|
+
* @param {{name:string, cmdLine:string}} proc
|
|
111
|
+
* @param {Set<number>} parentPids - 화이트리스트 부모 PID 집합
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
function isWhitelisted(proc, parentPids) {
|
|
115
|
+
const nameLower = proc.name.toLowerCase();
|
|
116
|
+
|
|
117
|
+
// 프로세스명 기반
|
|
118
|
+
if (WHITELIST_NAMES.some((n) => nameLower.includes(n.toLowerCase()))) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// cmdLine 패턴 기반
|
|
123
|
+
if (WHITELIST_CMDLINE.some((re) => re.test(proc.cmdLine))) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// CCXProcess 자식 (Adobe Creative Cloud)
|
|
128
|
+
if (parentPids.has(proc.parentPid)) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── psmux 교차검증 ──────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* psmux list-sessions 결과에서 활성 세션 pane PID 집합을 수집
|
|
139
|
+
* @param {Function} execFileFn
|
|
140
|
+
* @returns {Promise<Set<number>>}
|
|
141
|
+
*/
|
|
142
|
+
async function getActivePsmuxPids(execFileFn) {
|
|
143
|
+
try {
|
|
144
|
+
const { stdout: sessOut } = await execFileFn(
|
|
145
|
+
"psmux",
|
|
146
|
+
["list-sessions", "-F", "#{session_name}"],
|
|
147
|
+
{ encoding: "utf8", timeout: 5_000, windowsHide: true },
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const sessions = sessOut.trim().split(/\r?\n/).filter(Boolean);
|
|
151
|
+
if (sessions.length === 0) return new Set();
|
|
152
|
+
|
|
153
|
+
const pids = new Set();
|
|
154
|
+
|
|
155
|
+
await Promise.all(
|
|
156
|
+
sessions.map(async (session) => {
|
|
157
|
+
try {
|
|
158
|
+
const { stdout: paneOut } = await execFileFn(
|
|
159
|
+
"psmux",
|
|
160
|
+
["list-panes", "-t", session, "-a", "-F", "#{pane_pid}"],
|
|
161
|
+
{ encoding: "utf8", timeout: 5_000, windowsHide: true },
|
|
162
|
+
);
|
|
163
|
+
paneOut
|
|
164
|
+
.trim()
|
|
165
|
+
.split(/\r?\n/)
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.forEach((p) => {
|
|
168
|
+
const n = Number(p.trim());
|
|
169
|
+
if (n > 0) pids.add(n);
|
|
170
|
+
});
|
|
171
|
+
} catch {
|
|
172
|
+
// 세션 쿼리 실패 시 해당 세션만 건너뜀
|
|
173
|
+
}
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
return pids;
|
|
178
|
+
} catch {
|
|
179
|
+
// psmux 미설치 또는 실패 시 빈 집합 반환
|
|
180
|
+
return new Set();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── 공개 API ────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 고아 프로세스 목록을 반환한다.
|
|
188
|
+
*
|
|
189
|
+
* @param {object} [opts]
|
|
190
|
+
* @param {Function} [opts.execFileFn] - DI용 execFile (기본: node:child_process.execFile의 promisify 버전)
|
|
191
|
+
* @param {boolean} [opts.skipPsmuxCheck] - psmux 교차검증 생략 (테스트용)
|
|
192
|
+
* @returns {Promise<Array<{pid,name,ramMB,parentPid,cmdLine,age}>>}
|
|
193
|
+
*/
|
|
194
|
+
export async function findOrphanProcesses(opts = {}) {
|
|
195
|
+
const execFileFn = opts.execFileFn ?? execFileAsync;
|
|
196
|
+
|
|
197
|
+
let allProcs;
|
|
198
|
+
try {
|
|
199
|
+
allProcs = IS_WINDOWS
|
|
200
|
+
? await queryWindowsProcesses(execFileFn)
|
|
201
|
+
: await queryUnixProcesses(execFileFn);
|
|
202
|
+
} catch {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (allProcs.length === 0) return [];
|
|
207
|
+
|
|
208
|
+
const pidSet = new Set(allProcs.map((p) => p.pid));
|
|
209
|
+
|
|
210
|
+
// CCXProcess 부모 PID 집합 구성
|
|
211
|
+
const ccxParentPids = new Set(
|
|
212
|
+
allProcs
|
|
213
|
+
.filter((p) => p.name.toLowerCase() === "ccxprocess")
|
|
214
|
+
.map((p) => p.pid),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// 활성 psmux PID 교차검증
|
|
218
|
+
const activePsmuxPids = opts.skipPsmuxCheck
|
|
219
|
+
? new Set()
|
|
220
|
+
: await getActivePsmuxPids(execFileFn);
|
|
221
|
+
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
|
|
224
|
+
const candidates = allProcs.filter((p) => {
|
|
225
|
+
const nameLower = p.name.toLowerCase();
|
|
226
|
+
|
|
227
|
+
// 대상 프로세스만
|
|
228
|
+
if (!TARGET_PROCESS_NAMES.some((t) => nameLower === t || nameLower === `${t}.exe`)) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 화이트리스트
|
|
233
|
+
if (isWhitelisted(p, ccxParentPids)) return false;
|
|
234
|
+
|
|
235
|
+
// 활성 psmux 세션 소속 PID 제외
|
|
236
|
+
if (activePsmuxPids.has(p.pid)) return false;
|
|
237
|
+
|
|
238
|
+
// 부모가 없거나(0, 1 제외 시 존재하지 않는 PID) 고아
|
|
239
|
+
const parentAlive = p.parentPid <= 1 || pidSet.has(p.parentPid);
|
|
240
|
+
if (parentAlive) return false;
|
|
241
|
+
|
|
242
|
+
return true;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// age 계산 (Windows만 best-effort, Unix는 0)
|
|
246
|
+
const results = await Promise.all(
|
|
247
|
+
candidates.map(async (p) => {
|
|
248
|
+
let startMs = 0;
|
|
249
|
+
if (IS_WINDOWS) {
|
|
250
|
+
startMs = await getWindowsProcessStartMs(p.pid, execFileFn);
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
pid: p.pid,
|
|
254
|
+
name: p.name,
|
|
255
|
+
ramMB: p.ramMB,
|
|
256
|
+
parentPid: p.parentPid,
|
|
257
|
+
cmdLine: p.cmdLine,
|
|
258
|
+
age: startMs > 0 ? now - startMs : 0,
|
|
259
|
+
};
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
return results;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* createProcessCleanup — scan/kill/getOrphans 인터페이스를 반환한다.
|
|
268
|
+
*
|
|
269
|
+
* @param {object} [opts]
|
|
270
|
+
* @param {Function} [opts.execFileFn] - DI용 execFile (promisify된 버전)
|
|
271
|
+
* @param {boolean} [opts.dryRun] - true이면 kill 없이 목록만 반환
|
|
272
|
+
* @param {boolean} [opts.skipPsmuxCheck] - psmux 교차검증 생략 (테스트용)
|
|
273
|
+
* @returns {{ scan: Function, kill: Function, getOrphans: Function }}
|
|
274
|
+
*/
|
|
275
|
+
export function createProcessCleanup(opts = {}) {
|
|
276
|
+
const execFileFn = opts.execFileFn ?? execFileAsync;
|
|
277
|
+
const dryRun = opts.dryRun ?? false;
|
|
278
|
+
|
|
279
|
+
let lastOrphans = [];
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 고아 프로세스를 스캔하여 내부 상태에 저장하고 목록을 반환한다.
|
|
283
|
+
* @returns {Promise<Array<{pid,name,ramMB,parentPid,cmdLine,age}>>}
|
|
284
|
+
*/
|
|
285
|
+
async function scan() {
|
|
286
|
+
const found = await findOrphanProcesses({
|
|
287
|
+
execFileFn,
|
|
288
|
+
skipPsmuxCheck: opts.skipPsmuxCheck,
|
|
289
|
+
});
|
|
290
|
+
lastOrphans = found;
|
|
291
|
+
return found;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 마지막 scan 결과의 프로세스를 kill한다.
|
|
296
|
+
* dryRun=true이면 kill 없이 목록만 반환한다.
|
|
297
|
+
* SIGTERM → 5s 대기 → SIGKILL 순서.
|
|
298
|
+
* @returns {Promise<Array<{pid,name,killed,error}>>}
|
|
299
|
+
*/
|
|
300
|
+
async function kill() {
|
|
301
|
+
if (dryRun) {
|
|
302
|
+
return lastOrphans.map((p) => ({ pid: p.pid, name: p.name, killed: false, dryRun: true }));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const results = await Promise.all(
|
|
306
|
+
lastOrphans.map(async (p) => {
|
|
307
|
+
try {
|
|
308
|
+
// SIGTERM
|
|
309
|
+
process.kill(p.pid, "SIGTERM");
|
|
310
|
+
|
|
311
|
+
// 5초 대기 후 살아있으면 SIGKILL
|
|
312
|
+
await new Promise((resolve) => setTimeout(resolve, SIGTERM_GRACE_MS));
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
// 프로세스가 아직 살아있는지 확인 (signal 0)
|
|
316
|
+
process.kill(p.pid, 0);
|
|
317
|
+
// 여전히 살아있음 → SIGKILL
|
|
318
|
+
process.kill(p.pid, "SIGKILL");
|
|
319
|
+
} catch {
|
|
320
|
+
// ESRCH: 이미 종료됨 — 정상
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { pid: p.pid, name: p.name, killed: true };
|
|
324
|
+
} catch (err) {
|
|
325
|
+
return { pid: p.pid, name: p.name, killed: false, error: String(err.message || err) };
|
|
326
|
+
}
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
return results;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 마지막 scan 결과를 반환한다 (재스캔 없음).
|
|
335
|
+
* @returns {Array<{pid,name,ramMB,parentPid,cmdLine,age}>}
|
|
336
|
+
*/
|
|
337
|
+
function getOrphans() {
|
|
338
|
+
return lastOrphans;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { scan, kill, getOrphans };
|
|
342
|
+
}
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
5
5
|
import { tmpdir, homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { formatPsmuxInstallGuidance } from "../../scripts/lib/psmux-info.mjs";
|
|
8
|
-
import { IS_WINDOWS } from "
|
|
8
|
+
import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
|
|
9
9
|
|
|
10
10
|
const PSMUX_BIN = (() => {
|
|
11
11
|
if (process.env.PSMUX_BIN) return process.env.PSMUX_BIN;
|