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.
@@ -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>&1`;
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} sessionName
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
- if (onProgress) onProgress({ type: "session_created", sessionName, panes: session.panes });
84
+ let dispatches;
79
85
 
80
- // 워커 pane에 헤드리스 명령 dispatch
81
- const dispatches = assignments.map((assignment, i) => {
82
- const paneName = `worker-${i + 1}`;
83
- const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
84
- const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile);
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
- if (onProgress) onProgress({ type: "dispatched", paneName, cli: assignment.cli });
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
- return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role };
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
- const completion = await waitForCompletion(sessionName, d.paneName, d.token, timeoutSec);
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
+ }
@@ -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 deadline = Date.now() + Math.max(0, Math.trunc(timeoutSec * 1000));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "5.2.0",
3
+ "version": "6.0.1",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -93,13 +93,13 @@ export function createModuleLogger(module) {
93
93
 
94
94
  // 정상 종료 시 버퍼 flush 보장
95
95
  process.on('uncaughtException', (err) => {
96
- const finalLogger = pino.final(logger);
97
- finalLogger.fatal({ err }, 'process.uncaught_exception');
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
- const finalLogger = pino.final(logger);
103
- finalLogger.fatal({ reason: String(reason) }, 'process.unhandled_rejection');
102
+ logger.fatal({ reason: String(reason) }, 'process.unhandled_rejection');
103
+ logger.flush();
104
104
  process.exit(1);
105
105
  });
@@ -1,43 +1,80 @@
1
- #!/usr/bin/env node
2
- // npm install 전 Hub를 안전하게 중지하여 EBUSY 방지
3
- // better-sqlite3.node 파일이 Hub 프로세스에 의해 잠기면 npm이 덮어쓸 수 없음
4
-
5
- import { existsSync, readFileSync, unlinkSync } from "fs";
6
- import { join } from "path";
7
- import { homedir } from "os";
8
-
9
- const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
10
-
11
- function stopHub() {
12
- if (!existsSync(HUB_PID_FILE)) return;
13
-
14
- try {
15
- const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
16
- const pid = Number(info?.pid);
17
- if (!Number.isFinite(pid) || pid <= 0) return;
18
-
19
- // 프로세스 존재 확인
20
- process.kill(pid, 0);
21
-
22
- // SIGTERM 전송
23
- process.kill(pid, "SIGTERM");
24
- console.log(`[triflux preinstall] Hub 중지됨 (PID ${pid}) — EBUSY 방지`);
25
-
26
- // Windows: 프로세스 종료 + 파일 핸들 해제 대기 (최대 3초)
27
- const start = Date.now();
28
- while (Date.now() - start < 3000) {
29
- try { process.kill(pid, 0); } catch { break; }
30
- }
31
-
32
- // PID 파일 정리
33
- try { unlinkSync(HUB_PID_FILE); } catch {}
34
- } catch (err) {
35
- if (err.code === "ESRCH") {
36
- // 프로세스 이미 종료됨 — PID 파일만 정리
37
- try { unlinkSync(HUB_PID_FILE); } catch {}
38
- }
39
- // EPERM 기타 에러는 무시 (설치를 막으면 안 됨)
40
- }
41
- }
42
-
43
- stopHub();
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();