triflux 5.2.0 → 6.0.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/bin/tfx-doctor.mjs +3 -7
- package/bin/tfx-setup.mjs +3 -7
- package/bin/triflux.mjs +2470 -2463
- package/hub/team/headless.mjs +318 -16
- package/hub/team/psmux.mjs +16 -4
- package/package.json +1 -1
- package/scripts/lib/logger.mjs +4 -4
- package/scripts/preinstall.mjs +80 -43
- package/skills/tfx-multi/SKILL.md +39 -15
package/hub/team/headless.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// hub/team/headless.mjs — 헤드리스 CLI 오케스트레이션
|
|
2
2
|
// psmux pane에서 CLI를 헤드리스 모드로 실행하고 결과를 수집한다.
|
|
3
|
+
// v5.2.0: 기본 headless 엔진 (runHeadless, runHeadlessWithCleanup)
|
|
4
|
+
// v6.0.0: Lead-direct 모드 (runHeadlessInteractive, autoAttachTerminal)
|
|
3
5
|
// 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
|
|
4
6
|
import { join } from "node:path";
|
|
5
7
|
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
6
8
|
import { tmpdir } from "node:os";
|
|
9
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
7
10
|
import {
|
|
8
11
|
createPsmuxSession,
|
|
9
12
|
killPsmuxSession,
|
|
@@ -11,6 +14,8 @@ import {
|
|
|
11
14
|
dispatchCommand,
|
|
12
15
|
waitForCompletion,
|
|
13
16
|
capturePsmuxPane,
|
|
17
|
+
startCapture,
|
|
18
|
+
psmuxExec,
|
|
14
19
|
} from "./psmux.mjs";
|
|
15
20
|
|
|
16
21
|
const RESULT_DIR = join(tmpdir(), "tfx-headless");
|
|
@@ -30,7 +35,7 @@ export function buildHeadlessCommand(cli, prompt, resultFile) {
|
|
|
30
35
|
case "codex":
|
|
31
36
|
return `codex exec '${escaped}' -o '${resultFile}' --color never`;
|
|
32
37
|
case "gemini":
|
|
33
|
-
return `gemini -p '${escaped}' -o text > '${resultFile}' 2
|
|
38
|
+
return `gemini -p '${escaped}' -o text > '${resultFile}' 2>'${resultFile}.err'`;
|
|
34
39
|
case "claude":
|
|
35
40
|
return `claude -p '${escaped}' --output-format text > '${resultFile}' 2>&1`;
|
|
36
41
|
default:
|
|
@@ -41,8 +46,7 @@ export function buildHeadlessCommand(cli, prompt, resultFile) {
|
|
|
41
46
|
/**
|
|
42
47
|
* 결과 파일 읽기 (없으면 capture-pane fallback)
|
|
43
48
|
* @param {string} resultFile
|
|
44
|
-
* @param {string}
|
|
45
|
-
* @param {string} paneName
|
|
49
|
+
* @param {string} paneId
|
|
46
50
|
* @returns {string}
|
|
47
51
|
*/
|
|
48
52
|
function readResult(resultFile, paneId) {
|
|
@@ -62,6 +66,8 @@ function readResult(resultFile, paneId) {
|
|
|
62
66
|
* @param {number} [opts.timeoutSec=300] — 각 워커 타임아웃
|
|
63
67
|
* @param {string} [opts.layout='2x2'] — pane 레이아웃
|
|
64
68
|
* @param {(event: object) => void} [opts.onProgress] — 진행 콜백
|
|
69
|
+
* @param {number} [opts.progressIntervalSec=0] — N초마다 progress 이벤트 발화 (0=비활성)
|
|
70
|
+
* @param {boolean} [opts.progressive=true] — true면 pane을 하나씩 split-window로 추가 (실시간 스플릿)
|
|
65
71
|
* @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}> }}
|
|
66
72
|
*/
|
|
67
73
|
export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
@@ -69,29 +75,96 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
69
75
|
timeoutSec = 300,
|
|
70
76
|
layout = "2x2",
|
|
71
77
|
onProgress,
|
|
78
|
+
progressIntervalSec = 0,
|
|
79
|
+
progressive = true,
|
|
72
80
|
} = opts;
|
|
73
81
|
|
|
74
82
|
mkdirSync(RESULT_DIR, { recursive: true });
|
|
75
|
-
const paneCount = assignments.length + 1; // +1 for lead pane (unused but reserved)
|
|
76
|
-
const session = createPsmuxSession(sessionName, { layout, paneCount });
|
|
77
83
|
|
|
78
|
-
|
|
84
|
+
let dispatches;
|
|
79
85
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const dispatch = dispatchCommand(sessionName, paneName, cmd);
|
|
86
|
+
if (progressive) {
|
|
87
|
+
// ─── 실시간 스플릿 모드: lead pane만 생성 후, 워커를 하나씩 추가 ───
|
|
88
|
+
const session = createPsmuxSession(sessionName, { layout, paneCount: 1 });
|
|
89
|
+
applyTrifluxTheme(sessionName);
|
|
90
|
+
if (onProgress) onProgress({ type: "session_created", sessionName, panes: session.panes });
|
|
86
91
|
|
|
87
|
-
|
|
92
|
+
dispatches = assignments.map((assignment, i) => {
|
|
93
|
+
const paneName = `worker-${i + 1}`;
|
|
94
|
+
const paneTitle = assignment.role
|
|
95
|
+
? `${assignment.cli} (${assignment.role})`
|
|
96
|
+
: `${assignment.cli}-${i + 1}`;
|
|
88
97
|
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
// split-window로 새 pane 추가 — paneId 직접 획득
|
|
99
|
+
const newPaneId = psmuxExec([
|
|
100
|
+
"split-window", "-t", sessionName, "-P", "-F",
|
|
101
|
+
"#{session_name}:#{window_index}.#{pane_index}",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
// 타이틀 설정
|
|
105
|
+
try { psmuxExec(["select-pane", "-t", newPaneId, "-T", paneTitle]); } catch { /* 무시 */ }
|
|
106
|
+
|
|
107
|
+
if (onProgress) onProgress({ type: "worker_added", paneName, cli: assignment.cli, paneTitle });
|
|
108
|
+
|
|
109
|
+
// 캡처 시작 + 명령 dispatch (paneId 직접 사용 — resolvePane race 회피)
|
|
110
|
+
const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
|
|
111
|
+
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile);
|
|
112
|
+
startCapture(sessionName, newPaneId);
|
|
113
|
+
const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
|
|
114
|
+
|
|
115
|
+
if (onProgress) onProgress({ type: "dispatched", paneName, cli: assignment.cli });
|
|
116
|
+
|
|
117
|
+
return { ...dispatch, paneId: newPaneId, paneName, resultFile, cli: assignment.cli, role: assignment.role };
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// 모든 split 완료 후 레이아웃 한 번만 정렬 (깜빡임 방지)
|
|
121
|
+
try { psmuxExec(["select-layout", "-t", sessionName, "tiled"]); } catch { /* 무시 */ }
|
|
122
|
+
|
|
123
|
+
} else {
|
|
124
|
+
// ─── 기존 모드: 모든 pane을 한 번에 생성 ───
|
|
125
|
+
const paneCount = assignments.length + 1;
|
|
126
|
+
const session = createPsmuxSession(sessionName, { layout, paneCount });
|
|
127
|
+
applyTrifluxTheme(sessionName);
|
|
128
|
+
if (onProgress) onProgress({ type: "session_created", sessionName, panes: session.panes });
|
|
129
|
+
|
|
130
|
+
dispatches = assignments.map((assignment, i) => {
|
|
131
|
+
const paneName = `worker-${i + 1}`;
|
|
132
|
+
const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
|
|
133
|
+
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile);
|
|
134
|
+
const dispatch = dispatchCommand(sessionName, paneName, cmd);
|
|
135
|
+
|
|
136
|
+
// P1 fix: 비-progressive에서는 pane 리네임 금지 — 캡처 로그 경로가 타이틀 기반이므로
|
|
137
|
+
// 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
|
|
138
|
+
// progressive 모드에서는 split-window 시 새 pane에 바로 타이틀이 설정되므로 문제없음
|
|
139
|
+
|
|
140
|
+
if (onProgress) onProgress({ type: "dispatched", paneName, cli: assignment.cli });
|
|
141
|
+
|
|
142
|
+
return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
91
145
|
|
|
92
146
|
// 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
|
|
93
147
|
const results = await Promise.all(dispatches.map(async (d) => {
|
|
94
|
-
|
|
148
|
+
// onPoll → onProgress 변환 (throttle by progressIntervalSec)
|
|
149
|
+
const pollOpts = {};
|
|
150
|
+
if (onProgress && progressIntervalSec > 0) {
|
|
151
|
+
let lastProgressAt = 0;
|
|
152
|
+
const intervalMs = progressIntervalSec * 1000;
|
|
153
|
+
pollOpts.onPoll = ({ content }) => {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
if (now - lastProgressAt >= intervalMs) {
|
|
156
|
+
lastProgressAt = now;
|
|
157
|
+
onProgress({
|
|
158
|
+
type: "progress",
|
|
159
|
+
paneName: d.paneName,
|
|
160
|
+
cli: d.cli,
|
|
161
|
+
snapshot: content.split("\n").slice(-15).join("\n"), // 마지막 15줄
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const completion = await waitForCompletion(sessionName, d.paneName, d.token, timeoutSec, pollOpts);
|
|
95
168
|
|
|
96
169
|
const output = completion.matched
|
|
97
170
|
? readResult(d.resultFile, d.paneId)
|
|
@@ -111,6 +184,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
111
184
|
return {
|
|
112
185
|
cli: d.cli,
|
|
113
186
|
paneName: d.paneName,
|
|
187
|
+
paneId: d.paneId,
|
|
114
188
|
role: d.role,
|
|
115
189
|
matched: completion.matched,
|
|
116
190
|
exitCode: completion.exitCode,
|
|
@@ -144,3 +218,231 @@ export async function runHeadlessWithCleanup(assignments, opts = {}) {
|
|
|
144
218
|
}
|
|
145
219
|
}
|
|
146
220
|
}
|
|
221
|
+
|
|
222
|
+
// ─── v6.0.0: Theme + Visual ───
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* psmux 세션에 triflux 테마를 적용한다.
|
|
226
|
+
* status bar + pane border 색상 + 브랜딩.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} sessionName
|
|
229
|
+
*/
|
|
230
|
+
export function applyTrifluxTheme(sessionName) {
|
|
231
|
+
const opts = [
|
|
232
|
+
// Status bar — Catppuccin Mocha 기반
|
|
233
|
+
["status-style", "bg=#1e1e2e,fg=#cdd6f4"],
|
|
234
|
+
["status-left", " #[fg=#89b4fa,bold]▲ triflux#[default] "],
|
|
235
|
+
["status-left-length", "20"],
|
|
236
|
+
["status-right", " #[fg=#a6adc8]#{pane_title}#[default] │ #[fg=#f9e2af]%H:%M#[default] "],
|
|
237
|
+
["status-right-length", "40"],
|
|
238
|
+
// Pane border — active/inactive 구분
|
|
239
|
+
["pane-active-border-style", "fg=#89b4fa"],
|
|
240
|
+
["pane-border-style", "fg=#45475a"],
|
|
241
|
+
// Status bar 위치
|
|
242
|
+
["status-position", "bottom"],
|
|
243
|
+
];
|
|
244
|
+
for (const [key, value] of opts) {
|
|
245
|
+
try { psmuxExec(["set-option", "-t", sessionName, key, value]); } catch { /* 무시 */ }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─── v6.0.0: Lead-Direct Interactive Mode ───
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Windows Terminal에서 psmux 세션을 자동 attach한다.
|
|
253
|
+
* 별도 창이 열리며 사용자가 실시간으로 CLI 출력을 볼 수 있다.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} sessionName — attach할 psmux 세션 이름
|
|
256
|
+
* @param {object} [opts]
|
|
257
|
+
* @param {string} [opts.position] — "right" | "left" | 없으면 기본 위치
|
|
258
|
+
* @returns {boolean} 성공 여부
|
|
259
|
+
*/
|
|
260
|
+
export function autoAttachTerminal(sessionName, opts = {}) {
|
|
261
|
+
try {
|
|
262
|
+
// Windows Terminal이 설치되어 있는지 확인
|
|
263
|
+
execSync("where wt.exe", { stdio: "ignore" });
|
|
264
|
+
} catch {
|
|
265
|
+
return false; // wt.exe 미설치 — 사용자에게 수동 attach 안내 필요
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
// Fix P2: argv 스타일 — shell injection 방지
|
|
270
|
+
execFileSync("wt.exe", ["-w", "0", "nt", "psmux", "attach", "-t", sessionName], { stdio: "ignore" });
|
|
271
|
+
return true;
|
|
272
|
+
} catch {
|
|
273
|
+
try {
|
|
274
|
+
execFileSync("wt.exe", ["psmux", "attach", "-t", sessionName], { stdio: "ignore" });
|
|
275
|
+
return true;
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 모든 워커 pane의 현재 스냅샷을 수집한다.
|
|
284
|
+
*
|
|
285
|
+
* @param {string} sessionName
|
|
286
|
+
* @param {Array<{paneId: string, paneName: string, cli: string}>} dispatches
|
|
287
|
+
* @param {number} [lines=15] — 각 pane에서 캡처할 줄 수
|
|
288
|
+
* @returns {Array<{paneName: string, cli: string, snapshot: string}>}
|
|
289
|
+
*/
|
|
290
|
+
export function getProgressSnapshots(sessionName, dispatches, lines = 15) {
|
|
291
|
+
if (!psmuxSessionExists(sessionName)) return [];
|
|
292
|
+
return dispatches.map((d) => {
|
|
293
|
+
try {
|
|
294
|
+
const snapshot = capturePsmuxPane(d.paneId, lines);
|
|
295
|
+
return { paneName: d.paneName, cli: d.cli, snapshot };
|
|
296
|
+
} catch {
|
|
297
|
+
return { paneName: d.paneName, cli: d.cli, snapshot: "(캡처 실패)" };
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Lead-Direct Interactive 헤드리스 실행.
|
|
304
|
+
* 세션을 유지하면서 결과 수집 후에도 추가 명령을 dispatch할 수 있다.
|
|
305
|
+
* 반환된 handle의 kill()을 반드시 호출하여 세션을 정리해야 한다.
|
|
306
|
+
*
|
|
307
|
+
* @param {string} sessionName — psmux 세션 이름
|
|
308
|
+
* @param {Array<{cli: string, prompt: string, role?: string}>} assignments
|
|
309
|
+
* @param {object} [opts]
|
|
310
|
+
* @param {number} [opts.timeoutSec=300]
|
|
311
|
+
* @param {string} [opts.layout='2x2']
|
|
312
|
+
* @param {(event: object) => void} [opts.onProgress]
|
|
313
|
+
* @param {number} [opts.progressIntervalSec=0]
|
|
314
|
+
* @param {boolean} [opts.autoAttach=false] — Windows Terminal 자동 attach
|
|
315
|
+
* @param {AbortSignal} [opts.signal] — abort 시 자동 세션 정리
|
|
316
|
+
* @param {number} [opts.maxIdleSec=0] — 유휴 시 자동 정리 (0=비활성)
|
|
317
|
+
* @returns {Promise<{
|
|
318
|
+
* sessionName: string,
|
|
319
|
+
* results: Array,
|
|
320
|
+
* dispatches: Array,
|
|
321
|
+
* dispatch: (paneName: string, command: string) => {paneId: string, paneName: string, token: string},
|
|
322
|
+
* capture: (paneName: string, lines?: number) => string,
|
|
323
|
+
* snapshots: (lines?: number) => Array,
|
|
324
|
+
* waitFor: (paneName: string, token: string, timeoutSec?: number, opts?: object) => Promise,
|
|
325
|
+
* alive: () => boolean,
|
|
326
|
+
* kill: () => void,
|
|
327
|
+
* }>}
|
|
328
|
+
*/
|
|
329
|
+
export async function runHeadlessInteractive(sessionName, assignments, opts = {}) {
|
|
330
|
+
const {
|
|
331
|
+
autoAttach = false,
|
|
332
|
+
signal,
|
|
333
|
+
maxIdleSec = 0,
|
|
334
|
+
...runOpts
|
|
335
|
+
} = opts;
|
|
336
|
+
|
|
337
|
+
// autoAttach를 session_created 시점에 트리거 (CLI 실행 전에 터미널 열림)
|
|
338
|
+
const userOnProgress = runOpts.onProgress;
|
|
339
|
+
let terminalAttached = false;
|
|
340
|
+
runOpts.onProgress = (event) => {
|
|
341
|
+
if (autoAttach && event.type === "session_created" && !terminalAttached) {
|
|
342
|
+
terminalAttached = true;
|
|
343
|
+
autoAttachTerminal(sessionName);
|
|
344
|
+
}
|
|
345
|
+
if (userOnProgress) userOnProgress(event);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Phase 1: 세션 생성 → 즉시 터미널 팝업 → dispatch → 대기 → 결과 수집
|
|
349
|
+
const { results } = await runHeadless(sessionName, assignments, runOpts);
|
|
350
|
+
|
|
351
|
+
// Phase 2: 세션을 유지하고 interactive handle 반환
|
|
352
|
+
// Fix P2: paneId를 dispatches에 포함 (snapshots에서 필요)
|
|
353
|
+
const dispatches = results.map((r, i) => ({
|
|
354
|
+
paneName: r.paneName,
|
|
355
|
+
paneId: r.paneId || "",
|
|
356
|
+
cli: r.cli,
|
|
357
|
+
role: r.role,
|
|
358
|
+
}));
|
|
359
|
+
|
|
360
|
+
// Fix P2: maxIdleSec 리셋을 위한 타이머 관리
|
|
361
|
+
let idleTimer = null;
|
|
362
|
+
function resetIdleTimer() {
|
|
363
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
364
|
+
if (maxIdleSec > 0) {
|
|
365
|
+
idleTimer = setTimeout(() => handle.kill(), maxIdleSec * 1000);
|
|
366
|
+
if (idleTimer.unref) idleTimer.unref();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const handle = {
|
|
371
|
+
sessionName,
|
|
372
|
+
results,
|
|
373
|
+
dispatches,
|
|
374
|
+
_killed: false,
|
|
375
|
+
|
|
376
|
+
/** 특정 pane에 후속 명령 dispatch (캡처 자동 재시작) */
|
|
377
|
+
dispatch(paneName, command) {
|
|
378
|
+
if (this._killed) throw new Error("세션이 이미 종료되었습니다.");
|
|
379
|
+
try { startCapture(sessionName, paneName); } catch { /* 이미 활성 — 무시 */ }
|
|
380
|
+
resetIdleTimer();
|
|
381
|
+
return dispatchCommand(sessionName, paneName, command);
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
/** 특정 pane의 현재 출력 캡처 */
|
|
385
|
+
capture(paneName, lines = 30) {
|
|
386
|
+
if (this._killed) return "";
|
|
387
|
+
try {
|
|
388
|
+
// Fix P2: paneName으로 resolvePane을 경유하여 정확한 paneId 획득
|
|
389
|
+
return capturePsmuxPane(paneName, lines);
|
|
390
|
+
} catch {
|
|
391
|
+
return "(캡처 실패)";
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
/** 모든 pane 스냅샷 */
|
|
396
|
+
snapshots(lines = 15) {
|
|
397
|
+
if (this._killed) return [];
|
|
398
|
+
return getProgressSnapshots(sessionName, dispatches, lines);
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
/** 특정 pane에서 완료 대기 */
|
|
402
|
+
async waitFor(paneName, token, timeoutSec = 300, waitOpts = {}) {
|
|
403
|
+
if (this._killed) return { matched: false, sessionDead: true };
|
|
404
|
+
resetIdleTimer();
|
|
405
|
+
return waitForCompletion(sessionName, paneName, token, timeoutSec, waitOpts);
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
/** 세션 생존 확인 */
|
|
409
|
+
alive() {
|
|
410
|
+
if (this._killed) return false;
|
|
411
|
+
return psmuxSessionExists(sessionName);
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
/** 세션 종료 */
|
|
415
|
+
kill() {
|
|
416
|
+
if (this._killed) return;
|
|
417
|
+
this._killed = true;
|
|
418
|
+
try { killPsmuxSession(sessionName); } catch { /* 무시 */ }
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// AbortController signal로 자동 정리
|
|
423
|
+
if (signal) {
|
|
424
|
+
if (signal.aborted) {
|
|
425
|
+
handle.kill();
|
|
426
|
+
} else {
|
|
427
|
+
signal.addEventListener("abort", () => handle.kill(), { once: true });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 유휴 타임아웃 자동 정리
|
|
432
|
+
if (maxIdleSec > 0) {
|
|
433
|
+
const timer = setTimeout(() => handle.kill(), maxIdleSec * 1000);
|
|
434
|
+
if (timer.unref) timer.unref(); // Node.js exit를 방해하지 않음
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 프로세스 종료 시 safety net
|
|
438
|
+
const exitHandler = () => handle.kill();
|
|
439
|
+
process.on("exit", exitHandler);
|
|
440
|
+
// kill() 후 리스너 제거를 위해 참조 보관
|
|
441
|
+
const originalKill = handle.kill.bind(handle);
|
|
442
|
+
handle.kill = function () {
|
|
443
|
+
originalKill();
|
|
444
|
+
process.removeListener("exit", exitHandler);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
return handle;
|
|
448
|
+
}
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -641,9 +641,11 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
|
|
|
641
641
|
* @param {string} paneNameOrTarget
|
|
642
642
|
* @param {string|RegExp} pattern
|
|
643
643
|
* @param {number} timeoutSec
|
|
644
|
+
* @param {object} [opts]
|
|
645
|
+
* @param {(snapshot: {content: string, paneId: string, paneName: string, elapsed: number}) => void} [opts.onPoll] — 각 폴링 주기마다 호출
|
|
644
646
|
* @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null }}
|
|
645
647
|
*/
|
|
646
|
-
export async function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300) {
|
|
648
|
+
export async function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300, opts = {}) {
|
|
647
649
|
ensurePsmuxInstalled();
|
|
648
650
|
|
|
649
651
|
// E4 크래시 복구: 초기 resolvePane도 세션 사망을 감지
|
|
@@ -670,7 +672,8 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
670
672
|
throw new Error(`캡처 로그가 없습니다. 먼저 startCapture(${sessionName}, ${paneName})를 호출하세요.`);
|
|
671
673
|
}
|
|
672
674
|
|
|
673
|
-
const
|
|
675
|
+
const startTime = Date.now();
|
|
676
|
+
const deadline = startTime + Math.max(0, Math.trunc(timeoutSec * 1000));
|
|
674
677
|
const regex = toPatternRegExp(pattern);
|
|
675
678
|
|
|
676
679
|
while (Date.now() <= deadline) {
|
|
@@ -692,6 +695,14 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
692
695
|
}
|
|
693
696
|
|
|
694
697
|
const content = readCaptureLog(logPath);
|
|
698
|
+
|
|
699
|
+
// onPoll 콜백 — 각 폴링 주기마다 중간 상태 전달
|
|
700
|
+
if (opts.onPoll) {
|
|
701
|
+
try {
|
|
702
|
+
opts.onPoll({ content, paneId: pane.paneId, paneName, elapsed: Date.now() - startTime });
|
|
703
|
+
} catch { /* 콜백 예외는 삼킴 — 폴링 루프 보호 */ }
|
|
704
|
+
}
|
|
705
|
+
|
|
695
706
|
const match = regex.exec(content);
|
|
696
707
|
if (match) {
|
|
697
708
|
return {
|
|
@@ -724,14 +735,15 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
724
735
|
* @param {string} paneNameOrTarget
|
|
725
736
|
* @param {string} token
|
|
726
737
|
* @param {number} timeoutSec
|
|
738
|
+
* @param {object} [opts] — waitForPattern에 전달할 옵션 (onPoll 등)
|
|
727
739
|
* @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, token: string, exitCode: number|null }}
|
|
728
740
|
*/
|
|
729
|
-
export async function waitForCompletion(sessionName, paneNameOrTarget, token, timeoutSec = 300) {
|
|
741
|
+
export async function waitForCompletion(sessionName, paneNameOrTarget, token, timeoutSec = 300, opts = {}) {
|
|
730
742
|
const completionRegex = new RegExp(
|
|
731
743
|
`${escapeRegExp(COMPLETION_PREFIX)}${escapeRegExp(token)}:(\\d+)`,
|
|
732
744
|
"m",
|
|
733
745
|
);
|
|
734
|
-
const result = await waitForPattern(sessionName, paneNameOrTarget, completionRegex, timeoutSec);
|
|
746
|
+
const result = await waitForPattern(sessionName, paneNameOrTarget, completionRegex, timeoutSec, opts);
|
|
735
747
|
const exitMatch = result.match ? completionRegex.exec(result.match) : null;
|
|
736
748
|
return {
|
|
737
749
|
...result,
|
package/package.json
CHANGED
package/scripts/lib/logger.mjs
CHANGED
|
@@ -93,13 +93,13 @@ export function createModuleLogger(module) {
|
|
|
93
93
|
|
|
94
94
|
// 정상 종료 시 버퍼 flush 보장
|
|
95
95
|
process.on('uncaughtException', (err) => {
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
logger.fatal({ err }, 'process.uncaught_exception');
|
|
97
|
+
logger.flush();
|
|
98
98
|
process.exit(1);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
process.on('unhandledRejection', (reason) => {
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
logger.fatal({ reason: String(reason) }, 'process.unhandled_rejection');
|
|
103
|
+
logger.flush();
|
|
104
104
|
process.exit(1);
|
|
105
105
|
});
|
package/scripts/preinstall.mjs
CHANGED
|
@@ -1,43 +1,80 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// npm install 전 Hub를 안전하게 중지하여 EBUSY 방지
|
|
3
|
-
// better-sqlite3.node 파일이 Hub 프로세스에 의해 잠기면 npm이 덮어쓸 수 없음
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
// PID 파일 정리
|
|
33
|
-
try { unlinkSync(HUB_PID_FILE); } catch {}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npm install 전 Hub를 안전하게 중지하여 EBUSY 방지
|
|
3
|
+
// better-sqlite3.node 파일이 Hub 프로세스에 의해 잠기면 npm이 덮어쓸 수 없음
|
|
4
|
+
//
|
|
5
|
+
// v6.0.0: taskkill /T /F + Atomics.wait sleep + 파일 잠금 확인
|
|
6
|
+
// (bin/triflux.mjs stopHubForUpdate 패턴과 동일)
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, unlinkSync, openSync, closeSync } from "fs";
|
|
9
|
+
import { join, dirname } from "path";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { execFileSync } from "child_process";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const PKG_ROOT = join(__dirname, "..");
|
|
16
|
+
const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
|
|
17
|
+
|
|
18
|
+
function sleepMs(ms) {
|
|
19
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stopHub() {
|
|
23
|
+
if (!existsSync(HUB_PID_FILE)) return;
|
|
24
|
+
|
|
25
|
+
let info;
|
|
26
|
+
try {
|
|
27
|
+
info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
28
|
+
const pid = Number(info?.pid);
|
|
29
|
+
if (!Number.isFinite(pid) || pid <= 0) return;
|
|
30
|
+
process.kill(pid, 0); // 프로세스 존재 확인
|
|
31
|
+
} catch {
|
|
32
|
+
// 프로세스 없음 또는 PID 파일 손상 — PID 파일만 정리
|
|
33
|
+
try { unlinkSync(HUB_PID_FILE); } catch {}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const pid = Number(info.pid);
|
|
38
|
+
|
|
39
|
+
// 1단계: 프로세스 종료 — Windows는 taskkill, Unix는 SIGTERM
|
|
40
|
+
try {
|
|
41
|
+
if (process.platform === "win32") {
|
|
42
|
+
execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
43
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
44
|
+
timeout: 10000,
|
|
45
|
+
windowsHide: true,
|
|
46
|
+
});
|
|
47
|
+
} else {
|
|
48
|
+
process.kill(pid, "SIGTERM");
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// taskkill 실패 시 SIGKILL fallback
|
|
52
|
+
try { process.kill(pid, "SIGKILL"); } catch {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2단계: 프로세스 종료 대기 (최대 5초, 500ms 간격)
|
|
56
|
+
for (let i = 0; i < 10; i++) {
|
|
57
|
+
sleepMs(500);
|
|
58
|
+
try { process.kill(pid, 0); } catch { break; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3단계: better-sqlite3.node 파일 잠금 해제 확인 (최대 3초)
|
|
62
|
+
const sqliteNode = join(PKG_ROOT, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node");
|
|
63
|
+
if (existsSync(sqliteNode)) {
|
|
64
|
+
for (let i = 0; i < 6; i++) {
|
|
65
|
+
try {
|
|
66
|
+
const fd = openSync(sqliteNode, "r");
|
|
67
|
+
closeSync(fd);
|
|
68
|
+
break; // 열림 = 잠금 해제됨
|
|
69
|
+
} catch {
|
|
70
|
+
sleepMs(500);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 4단계: PID 파일 정리 (종료 확인 후)
|
|
76
|
+
try { unlinkSync(HUB_PID_FILE); } catch {}
|
|
77
|
+
console.log(`[triflux preinstall] Hub 중지 완료 (PID ${pid})`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
stopHub();
|